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
@@ -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)