feat: 积分商城-供应商-对账-发票3

This commit is contained in:
2026-09-08 16:41:48 +08:00
parent 3abe4ce9c9
commit f42ea5b270
47 changed files with 1211 additions and 285 deletions
+107 -42
View File
@@ -1,61 +1,126 @@
# supplied-service
Netlify Functions backend for the supplied mall H5 project.
供应商业务后端,为 supplied-h5、supplied-admin 和 zhgh 平台对接提供接口。当前 HTTP 入口为 `src/server.ts`,默认端口为 `8888`。
## Stack
## 技术栈
- Netlify Functions
- TypeScript
- Prisma ORM
- Neon Postgres
- `@prisma/adapter-neon` + `@neondatabase/serverless`
- Koa、TypeScript:HTTP 服务与业务逻辑。
- Prisma、MySQL:数据库访问与模型定义。
- Redis:平台桥接 Token 缓存。
- MinIO:文件对象存储。
- Node.js crypto:管理端密码哈希与随机登录 Token。
## Directory
## 目录结构
```text
netlify/
functions/ # API entrypoints
utils/ # shared function utilities
types/ # shared API types
src/
server.ts # Koa 入口、路由分发、管理端登录校验
controllers/ # 请求解析与响应封装
services/ # 业务逻辑
dao/ # 数据访问
functions/ # 导出各接口处理器
utils/ # 数据库、日志、平台桥接等工具
types/ # 公共类型
prisma/
schema.prisma # Prisma models
schema.prisma # 数据模型与表映射
sql/ # 专项表结构脚本
script/supplier_db.sql # 数据库结构及开发数据快照
init-supplied-user.mjs # 管理账号初始化与旧表处理
tools/ # 发票等辅助工具
```
## Environment
`src/functions` 保留接口处理器导出,当前本地服务由 Koa 的 `/api` 路由分发。
Copy `.env.example` to `.env` and set:
## 环境配置
- `DATABASE_URL`: Neon Postgres connection string for Netlify Functions runtime and Prisma CLI
- `JWT_SECRET`: auth signing secret
- `UPLOAD_TOKEN`: upload guard token
- `POINTS_MALL_OPEN_API_BASE`: main platform bridge base URL, for example `http://localhost/open`
配置文件为本目录 `.env`,可参考 `.env.example`。数据库及存储服务的账号信息不在本文重复列出。
Supplier H5 uses `VUE_APP_SUPPLIER_IDENTIFICATION` to request supplier config from this service. The supplier `clientSecret` stays in the database and is only used by the Netlify Function when requesting the platform bridge token.
| 配置项 | 用途 |
| --- | --- |
| `DATABASE_URL` | MySQL 连接地址 |
| `PORT` | Koa 服务端口,默认 `8888` |
| `PRISMA_CLIENT_ENGINE_TYPE` | Prisma 引擎设置,示例为 `binary` |
| `POINTS_MALL_OPEN_API_BASE` | zhgh 平台接口根地址,例如 `http://localhost/open` |
| `SUPPLIER_OPEN_SM4_KEY_HEX`、`SUPPLIER_OPEN_SM4_IV_HEX` | 供应商开放接口 SM4 配置 |
| `REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_DB` | 平台 Token 缓存连接配置 |
| `MINIO_ENDPOINT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY` | MinIO 地址与访问凭据 |
| `MINIO_BUCKET`、`MINIO_REGION` | 存储桶与区域;本项目配置示例使用 `njupt` |
## Scripts
`.env.example` 中的 `JWT_SECRET`、`UPLOAD_TOKEN` 不参与当前管理端登录校验;管理端采用数据库保存 Token 哈希的会话机制。
```bash
npm run dev
npm run build
npm run typecheck
npm run prisma:generate
npm run prisma:migrate
npm run prisma:studio
```
H5 通过供应商标识获取配置。供应商 `clientSecret` 保存在后端数据库中,用于请求平台桥接 Token,与管理端登录密码、登录 Token 分开处理。
## Function Routes
## 已有脚本说明
Netlify exposes functions under `/.netlify/functions/<name>`.
以下列出 `package.json` 中已有脚本的用途。
Current placeholders:
| 脚本名 | 用途 |
| --- | --- |
| `dev`、`start` | 通过 tsx 运行 Koa 服务 |
| `build`、`prisma:generate` | 生成 Prisma Client;`build` 不生成后端应用打包产物 |
| `typecheck` | TypeScript 静态类型检查 |
| `prisma:migrate` | Prisma 开发迁移 |
| `prisma:studio` | Prisma 数据查看界面 |
| `seed:products` | 商品初始化数据脚本 |
- `login`
- `product-list`
- `product-detail`
- `cart-add`
- `order-create`
- `order-pay`
- `user-info`
- `upload`
- `supplier-info`
- `supplier-token`
## 管理账号与表结构
| 表名 | 用途 |
| --- | --- |
| `supplied_user` | 管理端账号、密码哈希、会话及失败登录状态 |
| `supplied_cart_item` | 购物车条目,Prisma 模型为 `CartItem` |
管理端密码使用随机盐和 scrypt 哈希,格式为 `scrypt$盐$哈希`。登录时校验密码后签发随机 Token,数据库仅保存 Token 的 SHA-256 哈希及有效期。
- 会话有效期:8 小时,同一账号仅保留最新一次登录。
- 连续输错密码:启用账号累计 5 次后锁定 15 分钟。
- 退出登录:清除当前 Token 哈希与有效期。
- 登录请求密码不会写入供应商调试日志。
- 购物车 `userId` 仍为业务用户 ID,不关联 `supplied_user` 中的管理员。
### 账号初始化脚本
`prisma/init-supplied-user.mjs` 读取本目录 `.env`,创建账号表并插入管理账号:
- `SUPPLIED_ADMIN_USERNAME`:初始化账号,默认 `admin`。
- `SUPPLIED_ADMIN_PASSWORD`:初始化密码;未指定时随机生成,长度要求为 12–128 位。
- 账号创建成功后输出账号和密码,已存在账号不会覆盖密码。
- 可将旧 `cartitem` 重命名为 `supplied_cart_item`;新旧表同时存在时停止,避免覆盖数据。
- 旧 `user` 表有数据时停止;为空时移除。表结构处理发生在账号是否已存在的判断之前。
- 此脚本不会创建缺失的购物车表,也不处理曾使用过的 `supplied_cartitem` 名称。
当前开发账号见 [项目 README](../README.md#管理端登录)。
## 接口入口
| 路径 | 用途 |
| --- | --- |
| `/api/:name` | H5 及通用业务接口 |
| `/api/admin/:name` | 管理端入口,除 `login` 外校验 Bearer Token |
| `/supplierOpenApi/pointOrder/*` | zhgh 调用的供应商开放接口 |
| `/api/supplier-logs` | 查询或清除当前进程保存的调试日志 |
### 管理端登录接口
| 方法 | 路径 | 请求内容 |
| --- | --- | --- |
| POST | `/api/admin/login` | JSON:`username`、`password` |
| POST | `/api/admin/admin-session` | Bearer Token,返回用户信息及过期时间 |
| POST | `/api/admin/admin-logout` | Bearer Token,使当前登录失效 |
登录成功返回 `data.token`、`data.expiresAt` 和 `data.user`。管理端后续请求通过 `Authorization: Bearer <token>` 携带凭据。
### 业务接口分组
- 商品:`product-list`、`product-detail`、`merchant-product-*`。
- 订单:`order-create`、`order-pay`、`order-list`、`order-detail`、取消及事件推送接口。
- 商家处理:`merchant-order-*`、`merchant-after-sale-*`。
- 对账:`merchant-reconciliation-*`、`supplier-reconciliation-*`。
- 发票:`supplier-invoice-apply`、`supplier-invoice-info`、`supplier-file-base64-info`。
- 平台授权:`supplier-info`、`supplier-session`、`supplier-token`。
完整路由以 `src/server.ts` 为准。`cart-add`、`user-info` 仍为占位实现。
## 调试日志
`GET /api/supplier-logs` 返回当前后端接收请求、向 zhgh 发送请求、接收 zhgh 请求的记录;`DELETE /api/supplier-logs` 清除记录。日志保存在进程内存中,最多保留 200 条,服务重启后清空。
@@ -0,0 +1,41 @@
import { config } from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { randomBytes, scryptSync } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
config({ path: fileURLToPath(new URL('../.env', import.meta.url)), quiet: true })
const prisma = new PrismaClient()
try {
const username = (process.env.SUPPLIED_ADMIN_USERNAME || 'admin').trim()
const password = process.env.SUPPLIED_ADMIN_PASSWORD || `Sp!${randomBytes(12).toString('base64url')}9a`
if (!username || username.length > 64 || password.length < 12 || password.length > 128) {
throw new Error('账号长度需为 1-64,密码长度需为 12-128')
}
const tables = await prisma.$queryRaw`SELECT TABLE_NAME AS name FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) IN ('user', 'cartitem', 'supplied_cart_item')`
const legacyUser = tables.find(row => row.name.toLowerCase() === 'user')
const legacyCart = tables.find(row => row.name.toLowerCase() === 'cartitem')
if (legacyUser) {
const rows = await prisma.$queryRawUnsafe('SELECT COUNT(*) AS count FROM `' + legacyUser.name.replace(/`/g, '``') + '`')
if (Number(rows[0].count) > 0) throw new Error('旧 user 表存在数据,未删除,请先处理原有数据')
}
if (legacyCart) {
if (tables.some(row => row.name.toLowerCase() === 'supplied_cart_item')) throw new Error('新旧购物车表同时存在,未覆盖数据')
await prisma.$executeRawUnsafe('RENAME TABLE `' + legacyCart.name.replace(/`/g, '``') + '` TO `supplied_cart_item`')
}
if (legacyUser) await prisma.$executeRawUnsafe('DROP TABLE `' + legacyUser.name.replace(/`/g, '``') + '`')
await prisma.$executeRawUnsafe(readFileSync(new URL('./sql/supplied-user.sql', import.meta.url), 'utf8').trim().replace(/;$/, ''))
const existing = await prisma.$queryRaw`SELECT id FROM supplied_user WHERE username = ${username} LIMIT 1`
if (existing.length) throw new Error('账号已存在,未覆盖原有密码')
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex')
const passwordHash = `scrypt$${salt}$${hash}`
await prisma.$executeRaw`INSERT INTO supplied_user (username, passwordHash, nickname) VALUES (${username}, ${passwordHash}, ${'管理员'})`
console.log(JSON.stringify({ username, password, table: 'supplied_user' }))
} catch (error) {
console.error(error instanceof Error ? error.message : '初始化账号失败')
process.exitCode = 1
} finally {
await prisma.$disconnect()
}
+16 -11
View File
@@ -7,16 +7,21 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
mobile String @unique
nickname String?
avatarUrl String?
points Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model SuppliedUser {
id Int @id @default(autoincrement())
username String? @unique @db.VarChar(64)
virtualId String? @unique @db.VarChar(191)
passwordHash String? @db.VarChar(255)
nickname String @default("管理员") @db.VarChar(64)
enabled Boolean @default(true)
tokenHash String? @unique @db.Char(64)
tokenExpiresAt DateTime?
failedLoginCount Int @default(0)
lockedUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carts CartItem[]
@@map("supplied_user")
}
model SupplierProduct {
@@ -75,10 +80,10 @@ model CartItem {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product SupplierProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId, skuText])
@@unique([userId, productId, skuText], map: "CartItem_userId_productId_skuText_key")
@@map("supplied_cart_item")
}
model SupplierOrderMain {
+22 -23
View File
@@ -18,10 +18,10 @@ SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for cartitem
-- Table structure for supplied_cart_item
-- ----------------------------
DROP TABLE IF EXISTS `cartitem`;
CREATE TABLE `cartitem` (
DROP TABLE IF EXISTS `supplied_cart_item`;
CREATE TABLE `supplied_cart_item` (
`id` int NOT NULL AUTO_INCREMENT,
`userId` int NOT NULL,
`productId` int NOT NULL,
@@ -35,7 +35,7 @@ CREATE TABLE `cartitem` (
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of cartitem
-- Records of supplied_cart_item
-- ----------------------------
-- ----------------------------
@@ -528,24 +528,23 @@ CREATE TABLE `supplier_user_address` (
INSERT INTO `supplier_user_address` VALUES (1, 'addr-1785395646503', '99999999', 'b2397ac9a67f476c9833471d27709db1', '谌盼兮', '13545478393', '吉林省', '白山市', '临江市', '未来科技城', '220681', '430213', 1, '2026-07-30 07:14:06.541', '2026-07-30 07:29:34.829', 0);
INSERT INTO `supplier_user_address` VALUES (2, 'addr-1788252731181', '99999999', 'c281e124321c8db3a14f0a7c86046c3c', '谌盼兮', '19090230905', '湖北省', '武汉市', '洪山区', '中国地质大学', '420111', '430074', 1, '2026-09-01 08:52:11.226', '2026-09-01 08:52:11.226', 0);
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`mobile` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`nickname` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`avatarUrl` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`points` int NOT NULL DEFAULT 0,
`createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` datetime(3) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `User_mobile_key`(`mobile` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user
-- ----------------------------
CREATE TABLE IF NOT EXISTS `supplied_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NULL,
`virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL,
`passwordHash` VARCHAR(255) NULL,
`nickname` VARCHAR(64) NOT NULL DEFAULT '管理员',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`tokenHash` CHAR(64) NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`failedLoginCount` INT NOT NULL DEFAULT 0,
`lockedUntil` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `supplied_user_username_key` (`username`),
UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
UNIQUE KEY `supplied_user_tokenHash_key` (`tokenHash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
@@ -0,0 +1,5 @@
ALTER TABLE `supplied_user`
ADD COLUMN `virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER `username`,
ADD UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
MODIFY COLUMN `username` VARCHAR(64) NULL,
MODIFY COLUMN `passwordHash` VARCHAR(255) NULL;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS `supplied_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NULL,
`virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL,
`passwordHash` VARCHAR(255) NULL,
`nickname` VARCHAR(64) NOT NULL DEFAULT '管理员',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`tokenHash` CHAR(64) NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`failedLoginCount` INT NOT NULL DEFAULT 0,
`lockedUntil` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `supplied_user_username_key` (`username`),
UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
UNIQUE KEY `supplied_user_tokenHash_key` (`tokenHash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -3,14 +3,27 @@ import { guardMethod } from '../utils/method.js'
import { withErrorBoundary } from '../utils/handler.js'
import { basicService } from '../services/basic.service.js'
import { minioUploadService, type PreviewInput, type UploadInput } from '../services/minio-upload.service.js'
import { readJson } from '../utils/request.js'
import { getBearerToken, readJson } from '../utils/request.js'
import { adminAuthService, type AdminLoginInput } from '../services/admin-auth.service.js'
import { toHttpResponse } from './controller-response.js'
export const loginController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(basicService.login())
return toHttpResponse(await adminAuthService.login(await readJson<AdminLoginInput>(request)))
})
export const adminSessionController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await adminAuthService.session(getBearerToken(request)))
})
export const adminLogoutController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await adminAuthService.logout(getBearerToken(request)))
})
export const cartAddController = withErrorBoundary(async (request: Request, _context: Context) => {
@@ -0,0 +1,71 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../utils/prisma.js'
export type SuppliedUser = {
id: number
username: string
passwordHash: string
nickname: string
enabled: boolean
tokenExpiresAt: Date | null
lockedUntil: Date | null
}
export type SuppliedCustomer = { id: number; virtualId: string; nickname: string; enabled: boolean }
export const suppliedUserDao = {
async findByVirtualId(virtualId: string) {
const rows = await prisma.$queryRaw<SuppliedCustomer[]>`
SELECT id, virtualId, nickname, enabled FROM supplied_user WHERE virtualId = ${virtualId} LIMIT 1`
return rows[0]
},
async findOrCreateByVirtualId(virtualId: string, nickname: string) {
const existing = await this.findByVirtualId(virtualId)
if (existing) return existing
// 并发进入时由虚拟 ID 唯一索引去重,不覆盖已有昵称。
await prisma.$executeRaw`
INSERT INTO supplied_user (virtualId, nickname, username, passwordHash)
VALUES (${virtualId}, ${nickname}, NULL, NULL)
ON DUPLICATE KEY UPDATE virtualId = VALUES(virtualId)`
const user = await this.findByVirtualId(virtualId)
if (!user) throw new Error('获取供应商用户信息失败')
return user
},
async findByVirtualIds(virtualIds: string[]) {
if (!virtualIds.length) return []
return prisma.$queryRaw<SuppliedCustomer[]>`
SELECT id, virtualId, nickname, enabled FROM supplied_user
WHERE virtualId IN (${Prisma.join(virtualIds)})`
},
async findByUsername(username: string) {
const rows = await prisma.$queryRaw<SuppliedUser[]>`
SELECT id, username, passwordHash, nickname, enabled, tokenExpiresAt, lockedUntil
FROM supplied_user WHERE username = ${username} AND virtualId IS NULL LIMIT 1`
return rows[0]
},
async findSession(tokenHash: string) {
const now = new Date()
const rows = await prisma.$queryRaw<SuppliedUser[]>`
SELECT id, username, nickname, tokenExpiresAt FROM supplied_user
WHERE tokenHash = ${tokenHash} AND virtualId IS NULL AND enabled = TRUE AND tokenExpiresAt > ${now} LIMIT 1`
return rows[0]
},
async recordFailure(id: number) {
await prisma.$executeRaw`
UPDATE supplied_user SET
failedLoginCount = IF(lockedUntil IS NOT NULL AND lockedUntil <= NOW(3), 1, failedLoginCount + 1),
lockedUntil = IF(failedLoginCount >= 5, DATE_ADD(NOW(3), INTERVAL 15 MINUTE), NULL),
updatedAt = NOW(3) WHERE id = ${id}`
},
async saveSession(id: number, tokenHash: string, expiresAt: Date) {
return prisma.$executeRaw`
UPDATE supplied_user SET tokenHash = ${tokenHash}, tokenExpiresAt = ${expiresAt},
failedLoginCount = 0, lockedUntil = NULL, updatedAt = NOW(3)
WHERE id = ${id} AND virtualId IS NULL AND enabled = TRUE AND (lockedUntil IS NULL OR lockedUntil <= NOW(3))`
},
async clearSession(tokenHash: string) {
await prisma.$executeRaw`
UPDATE supplied_user SET tokenHash = NULL, tokenExpiresAt = NULL, updatedAt = NOW(3)
WHERE tokenHash = ${tokenHash}`
}
}
+24 -2
View File
@@ -4,6 +4,7 @@ import Router from '@koa/router'
import bodyParser from 'koa-bodyparser'
import type { FunctionHandler } from './utils/handler.js'
import { logger } from './utils/logger.js'
import { adminAuthService } from './services/admin-auth.service.js'
import {
addressDefaultController,
addressDeleteController,
@@ -11,6 +12,8 @@ import {
addressSaveController
} from './controllers/address.controller.js'
import {
adminSessionController,
adminLogoutController,
cartAddController,
loginController,
uploadController,
@@ -87,6 +90,8 @@ const handlers: Record<string, FunctionHandler> = {
'after-sale-logistics': afterSaleLogisticsController,
'cart-add': cartAddController,
login: loginController,
'admin-session': adminSessionController,
'admin-logout': adminLogoutController,
'merchant-after-sale-audit': merchantAfterSaleAuditController,
'merchant-after-sale-detail': merchantAfterSaleDetailController,
'merchant-after-sale-list': merchantAfterSaleListController,
@@ -185,8 +190,9 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
path: ctx.path,
url: ctx.originalUrl,
query: ctx.query,
body: ctx.request.body || {}
body: name === 'login' ? { password: '[REDACTED]' } : ctx.request.body || {}
})
if (name === 'login' || name === 'admin-session' || name === 'admin-logout') ctx.set('Cache-Control', 'no-store')
const handler = handlers[name]
if (!handler) {
ctx.status = 404
@@ -225,7 +231,23 @@ router.delete('/api/supplier-logs', ctx => {
}
})
router.all('/api/:name', async ctx => dispatch(ctx.params.name)(ctx))
router.all('/api/admin/:name', async ctx => dispatch(ctx.params.name)(ctx))
router.all('/api/admin/:name', async ctx => {
if (ctx.params.name !== 'login') {
try {
const token = ctx.get('Authorization').match(/^Bearer\s+(.+)$/i)?.[1] || ''
if (!await adminAuthService.findSession(token)) {
ctx.status = 401
ctx.body = { success: false, error: { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录' } }
return
}
} catch {
ctx.status = 500
ctx.body = { success: false, error: { code: 'AUTH_SERVICE_ERROR', message: '登录状态校验失败,请稍后重试' } }
return
}
}
await dispatch(ctx.params.name)(ctx)
})
router.post('/supplierOpenApi/pointOrder/queryOrderInfo', dispatch('supplier-order-info'))
router.post('/supplierOpenApi/pointOrder/queryReconciliationInfo', dispatch('supplier-reconciliation-info'))
router.post('/supplierOpenApi/pointOrder/applyForInvoices', dispatch('supplier-invoice-apply'))
@@ -0,0 +1,55 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { suppliedUserDao, type SuppliedUser } from '../dao/supplied-user.dao.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type AdminLoginInput = { username?: unknown; password?: unknown }
const digest = (token: string) => createHash('sha256').update(token).digest('hex')
const publicUser = (user: SuppliedUser) => ({ id: user.id, username: user.username, nickname: user.nickname })
const derivePassword = (password: string, salt: string) => new Promise<Buffer>((resolve, reject) => {
scrypt(password, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }, (error, key) => {
if (error) reject(error)
else resolve(key)
})
})
export const adminAuthService = {
async login(input: AdminLoginInput) {
const username = typeof input?.username === 'string' ? input.username.trim() : ''
const password = typeof input?.password === 'string' ? input.password : ''
if (!username || username.length > 64 || !password || password.length > 128) {
return serviceFail('请输入有效的账号和密码', 400, 'INVALID_LOGIN_INPUT')
}
const user = await suppliedUserDao.findByUsername(username)
const [algorithm, salt, hash] = (user?.passwordHash || '').split('$')
const validHash = algorithm === 'scrypt' && /^[a-f0-9]{32}$/.test(salt || '') && /^[a-f0-9]{128}$/.test(hash || '')
const actual = await derivePassword(password, validHash ? salt : '00000000000000000000000000000000')
const expected = validHash ? Buffer.from(hash, 'hex') : Buffer.alloc(64)
const passwordMatches = timingSafeEqual(actual, expected) && validHash
if (user?.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
return serviceFail('登录尝试过多,请稍后重试', 429, 'LOGIN_LOCKED')
}
if (!user || !user.enabled || !passwordMatches) {
if (user?.enabled) await suppliedUserDao.recordFailure(user.id)
return serviceFail('账号或密码错误', 401, 'INVALID_CREDENTIALS')
}
const token = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000)
if (!await suppliedUserDao.saveSession(user.id, digest(token), expiresAt)) {
return serviceFail('账号暂时无法登录,请稍后重试', 401, 'LOGIN_UNAVAILABLE')
}
return serviceOk({ token, expiresAt: expiresAt.toISOString(), user: publicUser(user) })
},
async findSession(token: string) {
if (!/^[a-f0-9]{64}$/.test(token)) return undefined
return suppliedUserDao.findSession(digest(token))
},
async session(token: string) {
const user = await this.findSession(token)
return user ? serviceOk({ user: publicUser(user), expiresAt: user.tokenExpiresAt?.toISOString() })
: serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED')
},
async logout(token: string) {
if (/^[a-f0-9]{64}$/.test(token)) await suppliedUserDao.clearSession(digest(token))
return serviceOk({})
}
}
@@ -138,7 +138,7 @@ const createServiceOrderId = (subOrderId: string, serviceType: number) => {
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送退货单')
}
@@ -149,6 +149,7 @@ const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof ord
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -159,7 +160,7 @@ const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof ord
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送换货单')
}
@@ -170,6 +171,7 @@ const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof o
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -180,7 +182,7 @@ const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof o
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法取消子单')
}
@@ -191,6 +193,7 @@ const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof
orderId: record.subOrderId,
mainOrderId: record.mainOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -498,14 +501,14 @@ export const afterSaleService = {
? (isSigned ? AFTER_SALE_LOGISTICS_STATE.WAIT_USER_SHIP : AFTER_SALE_LOGISTICS_STATE.NONE)
: AFTER_SALE_LOGISTICS_STATE.NONE
const now = new Date()
if (finishUnsignedReturn) {
const cancelSubOrder = await pushCancelSubOrder(record)
const cancelSubOrder = await pushCancelSubOrder(record, now)
if (!cancelSubOrder.success) {
return cancelSubOrder
}
}
const now = new Date()
await orderDao.transaction(async tx => {
await tx.supplierAfterSale.update({
where: { serviceOrderId },
@@ -550,20 +553,20 @@ export const afterSaleService = {
return serviceFail('退款/售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
}
const now = new Date()
if (record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_SHIPPED) {
const exchangeOrder = await pushExchangeOrder(record)
const exchangeOrder = await pushExchangeOrder(record, now)
if (!exchangeOrder.success) {
return exchangeOrder
}
}
if (record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED) {
const returnOrder = await pushReturnOrder(record)
const returnOrder = await pushReturnOrder(record, now)
if (!returnOrder.success) {
return returnOrder
}
}
const now = new Date()
const finish = logisticsState === AFTER_SALE_LOGISTICS_STATE.USER_RECEIVED ||
(record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED)
const finishReturn = record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER &&
@@ -2,11 +2,6 @@ import { logger } from '../utils/logger.js'
import { serviceOk } from '../types/service-result.js'
export const basicService = {
login() {
logger.info('login placeholder called')
return serviceOk({ message: '登录接口待实现' })
},
addCart() {
logger.info('cart add placeholder called')
return serviceOk({ message: '加入购物车接口待实现' })
@@ -7,6 +7,7 @@ import {
} from '../constants/order.js'
import { orderDao } from '../dao/order.dao.js'
import { supplierDao } from '../dao/supplier.dao.js'
import { suppliedUserDao } from '../dao/supplied-user.dao.js'
import { afterSaleService, type AfterSaleAuditInput, type AfterSaleLogisticsInput } from './after-sale.service.js'
import { orderActionService } from './order-action.service.js'
import { logger } from '../utils/logger.js'
@@ -123,10 +124,14 @@ export const merchantService = {
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id))
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination)
const virtualIds = [...new Set(orders.map(order => order.userId).filter((id): id is string => Boolean(id)))]
const users = await suppliedUserDao.findByVirtualIds(virtualIds)
const nicknames = new Map(users.map(user => [user.virtualId, user.nickname]))
const orderItems = orders.map(order => ({
orderId: order.orderId,
mainOrderId: order.mainOrderId,
userId: order.userId || '',
userNickname: nicknames.get(order.userId || '') || '',
supplierId: order.supplierId,
orderState: order.orderState,
orderNumberId: order.orderNumberId || '',
@@ -64,14 +64,15 @@ export const orderActionService = {
return serviceFail('已支付主单不能通过未支付取消接口取消', 400, 'MAIN_ORDER_ALREADY_PAID')
}
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
await tx.supplierOrderSub.updateMany({
where: { mainOrderId, supplierId, userId, deleted: 0 },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -80,6 +81,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -112,14 +114,15 @@ export const orderActionService = {
return serviceFail('该主单已拆单,不能按已支付未拆单整单取消', 400, 'MAIN_ORDER_ALREADY_SPLIT')
}
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
await tx.supplierOrderSub.updateMany({
where: { mainOrderId, supplierId, userId, deleted: 0 },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -128,6 +131,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -153,10 +157,11 @@ export const orderActionService = {
const mainOrderId = input.mainOrderId || subOrder.mainOrderId
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderSub.update({
where: { orderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -165,6 +170,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -259,6 +265,7 @@ export const orderActionService = {
orderId: subOrderIds,
mainOrderId: orderId,
userId: order.userId,
operationTime: toDateTimeText(payTime),
time: Date.now()
})
if (!split.response.ok || split.payload.resultCode !== 0) {
@@ -376,6 +383,7 @@ export const orderActionService = {
deliveryTime: toDateTimeText(now),
logisticsOrderId,
crrgBsnNm,
operationTime: toDateTimeText(now),
time: Date.now()
})
if (!ship.response.ok || ship.payload.resultCode !== 0) {
@@ -17,6 +17,7 @@ export type OrderPushEventInput = {
mainOrderId?: string
subOrderId?: string
time?: number
operationTime?: string
amount?: number
reason?: string
settlementId?: string
@@ -61,12 +62,18 @@ const operateType = (orderType: number) => {
return orderType
}
const toDateTimeText = (date: Date) => {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const buildBridgeBody = (body: OrderPushEventInput) => ({
supplierId: String(body.supplierId || '').trim(),
orderType: Number(body.orderType),
orderId: String(body.orderId || '').trim(),
mainOrderId: String(body.mainOrderId || '').trim(),
userId: String(body.userId || '').trim(),
operationTime: body.operationTime,
time: Number(body.time || Date.now())
})
@@ -87,8 +94,17 @@ export const orderPushEventService = {
return serviceFail('暂不支持该订单事件类型', 400, 'ORDER_EVENT_NOT_SUPPORTED')
}
// 操作时间在业务处理时确定,写入事件及推送记录;不得由接口 time 推导。
const operationTime = body.operationTime ?? toDateTimeText(new Date())
if (typeof operationTime !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(operationTime)) {
return serviceFail('业务操作时间格式必须为 yyyy-MM-dd HH:mm:ss')
}
const now = new Date(operationTime.replace(' ', 'T'))
if (Number.isNaN(now.getTime()) || toDateTimeText(now) !== operationTime) {
return serviceFail('业务操作时间无效')
}
body = { ...body, operationTime }
const bridgeBody = buildBridgeBody(body)
const now = new Date()
let pushLogId = 0
let affectedSubOrderIds: string[] = []
@@ -0,0 +1,18 @@
import { randomBytes, randomInt } from 'node:crypto'
import { suppliedUserDao } from '../dao/supplied-user.dao.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export const suppliedUserService = {
async getSessionUser(userId: string) {
const virtualId = userId.trim()
if (!virtualId || virtualId.length > 191) return serviceFail('用户虚拟 ID 无效')
const letters = 'abcdefghijklmnopqrstuvwxyz'
const nickname = `用户${randomInt(10)}${letters[randomInt(letters.length)]}${randomBytes(4).toString('hex')}`
const user = await suppliedUserDao.findOrCreateByVirtualId(virtualId, nickname)
if (!user.enabled) return serviceFail('当前用户已停用', 403, 'USER_DISABLED')
return serviceOk({
userId: user.virtualId,
userInfo: { id: user.id, virtualId: user.virtualId, nickname: user.nickname }
})
}
}
@@ -1,4 +1,5 @@
import { supplierDao } from '../dao/supplier.dao.js'
import { suppliedUserService } from './supplied-user.service.js'
import { bridgeFetchWithAuth, findSupplierByIdentification, getBridgeApiBase, getBridgeToken, warmBridgeToken } from '../utils/bridge-auth.js'
import { sha256 } from '../utils/crypto.js'
import { logger } from '../utils/logger.js'
@@ -46,7 +47,7 @@ export const supplierService = {
return serviceFail('用户参数无效')
}
return serviceOk({ userId })
return await suppliedUserService.getSessionUser(userId)
} catch (error) {
logger.warn('supplier session exchange failed', {
message: error instanceof Error ? error.message : String(error)
@@ -79,7 +80,7 @@ export const supplierService = {
return serviceFail('用户参数无效')
}
return serviceOk({ userId })
return await suppliedUserService.getSessionUser(userId)
} catch (error) {
logger.warn('supplier session decrypt failed', {
message: error instanceof Error ? error.message : String(error)