feat: 积分商城-供应商-对账-发票3
This commit is contained in:
+107
-42
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user