From 047cf879f8bfe2a1e7a092691f6d253061b0354d Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 21:36:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=B0=8F=E9=A3=9E=E4=BE=A0=E7=9A=84?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/constants.ts | 13 ++ apps/admin-web/src/pages/OrdersPage.tsx | 16 ++- .../src/pages/ThirdPartyLogsPage.tsx | 3 +- .../callbacks/delivery-track.controller.ts | 39 +++++- .../integrations/courier/courier-log.util.ts | 85 +++++++++++++ .../courier/xiaofeixia/xiaofeixia.client.ts | 113 ++++++++++++++++-- 6 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 server/dukang-api/src/integrations/courier/courier-log.util.ts diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 88d6ac5..faef146 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -19,6 +19,19 @@ export const ORDER_STATUS_LABELS: Record = { REFUNDED: '已退款', }; +/** 订单状态 Tag 颜色(Ant Design preset) */ +export const ORDER_STATUS_COLORS: Record = { + PENDING_PAY: 'red', + PENDING_SHIP: 'green', + OUT_WAREHOUSE: 'green', + SHIPPING: 'processing', + PENDING_RECEIVE: 'cyan', + COMPLETED: 'green', + CANCELLED: 'default', + REFUNDING: 'orange', + REFUNDED: 'default', +}; + export const STORE_STATUS_LABELS: Record = { OPEN: '营业中', PAUSED: '暂停', diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index ebc0c2f..b518c7f 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -18,7 +18,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type AdminOrderRow, type Paginated } from '../lib/api'; -import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; type ShipDefaults = { provider: string; @@ -172,7 +172,9 @@ export default function OrdersPage() { title: '状态', dataIndex: 'status', width: 100, - render: (s) => {ORDER_STATUS_LABELS[s] || s}, + render: (s) => ( + {ORDER_STATUS_LABELS[s] || s} + ), }, { title: '配送', @@ -293,7 +295,11 @@ export default function OrdersPage() { <> {detail.orderNo} - {ORDER_STATUS_LABELS[detail.status] || detail.status} + + + {ORDER_STATUS_LABELS[detail.status] || detail.status} + + {detail.user?.userNo} / {detail.user?.nickname} ¥{detail.productAmount} ¥{detail.payAmount} @@ -435,7 +441,9 @@ export default function OrdersPage() { title: '状态', dataIndex: 'status', width: 100, - render: (s) => {ORDER_STATUS_LABELS[s] || s}, + render: (s) => ( + {ORDER_STATUS_LABELS[s] || s} + ), }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '收货人', dataIndex: 'receiverName', width: 90 }, diff --git a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx index 7f2c77b..f158c3e 100644 --- a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx +++ b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx @@ -27,7 +27,8 @@ const PROVIDER_OPTIONS = [ { value: 'WECHAT_MAP', label: 'WECHAT_MAP' }, { value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' }, { value: 'MOCK_SMS', label: 'MOCK_SMS' }, - { value: 'XIAOFEIXIA', label: 'XIAOFEIXIA' }, + { value: 'XFX', label: '小飞侠 (XFX)' }, + { value: 'LOGISTICS', label: 'LOGISTICS' }, ]; const STATUS_COLOR: Record = { diff --git a/server/dukang-api/src/callbacks/delivery-track.controller.ts b/server/dukang-api/src/callbacks/delivery-track.controller.ts index 1baf89d..db54c94 100644 --- a/server/dukang-api/src/callbacks/delivery-track.controller.ts +++ b/server/dukang-api/src/callbacks/delivery-track.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Post } from '@nestjs/common'; import { TradeService } from '../modules/trade/trade.service'; import { CourierService } from '../integrations/courier/courier.service'; import { PrismaService } from '../common/prisma/prisma.module'; +import { logCourierCall } from '../integrations/courier/courier-log.util'; @Controller('callbacks/delivery') export class DeliveryCallbackController { @@ -13,13 +14,34 @@ export class DeliveryCallbackController { @Post('track') async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) { + const baseLog = { + scene: 'TRACK_CALLBACK', + requestUrl: '/api/v1/callbacks/delivery/track', + requestBody: body as Record, + }; + if (!body.orderId && !body.orderNo) { + await logCourierCall(this.prisma, { + ...baseLog, + status: 'FAILED', + errorMessage: '缺少 orderId / orderNo', + }); return this.courier.buildTrackCallbackResponse(false); } + const order = body.orderId ? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } }) : await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } }); - if (!order) return this.courier.buildTrackCallbackResponse(false); + + if (!order) { + await logCourierCall(this.prisma, { + ...baseLog, + status: 'FAILED', + errorMessage: '订单不存在', + externalNo: body.orderNo, + }); + return this.courier.buildTrackCallbackResponse(false); + } const statusMap: Record = { SHIPPED: 'SHIPPING', @@ -28,9 +50,22 @@ export class DeliveryCallbackController { COMPLETED: 'COMPLETED', }; const target = statusMap[body.status ?? ''] ?? body.status; + let applied = false; if (target && target !== order.status) { await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK'); + applied = true; } - return this.courier.buildTrackCallbackResponse(true); + + const response = this.courier.buildTrackCallbackResponse(true); + await logCourierCall(this.prisma, { + ...baseLog, + responseBody: response, + status: 'SUCCESS', + externalNo: order.orderNo, + ref: { refType: 'ORDER', refId: order.id }, + errorMessage: applied ? undefined : '状态未变更', + }); + + return response; } } diff --git a/server/dukang-api/src/integrations/courier/courier-log.util.ts b/server/dukang-api/src/integrations/courier/courier-log.util.ts new file mode 100644 index 0000000..7c5a044 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier-log.util.ts @@ -0,0 +1,85 @@ +import type { PrismaService } from '../../common/prisma/prisma.module'; +import { XIAOFEIXIA_CMD } from './xiaofeixia/xiaofeixia.constants'; + +const CMD_SCENE: Record = { + [XIAOFEIXIA_CMD.CREATE_ORDER]: 'CREATE_SHIPMENT', + [XIAOFEIXIA_CMD.TRACK_ROUTE]: 'GET_TRACK', + [XIAOFEIXIA_CMD.CANCEL_ORDER]: 'CANCEL_SHIPMENT', + [XIAOFEIXIA_CMD.GET_ORDER]: 'GET_SHIPMENT', + [XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT', + [XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT', + [XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE', +}; + +export type CourierLogRef = { + refType?: string; + refId?: bigint; +}; + +export type LogCourierCallInput = { + scene: string; + requestUrl: string; + requestBody?: Record; + responseBody?: unknown; + externalNo?: string; + status: 'SUCCESS' | 'FAILED' | 'PENDING'; + errorMessage?: string; + ref?: CourierLogRef; +}; + +function maskMchId(mchId?: string) { + if (!mchId) return mchId; + if (mchId.length <= 4) return '****'; + return `${mchId.slice(0, 4)}****`; +} + +/** 请求体入库前脱敏(去掉 sign,商户号打码) */ +export function sanitizeXfxRequestBody(body: Record) { + const { sign: _sign, mchId, ...rest } = body; + return { + ...rest, + ...(mchId != null ? { mchId: maskMchId(String(mchId)) } : {}), + }; +} + +export function sceneForXfxCmd(cmd: string) { + return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`; +} + +export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) { + const responseBody = + input.responseBody === undefined + ? undefined + : typeof input.responseBody === 'object' && input.responseBody !== null + ? (input.responseBody as Record) + : { value: input.responseBody }; + + const row = await prisma.logThirdParty.create({ + data: { + provider: 'XFX', + scene: input.scene, + refType: input.ref?.refType, + refId: input.ref?.refId, + requestUrl: input.requestUrl.slice(0, 512), + requestBody: input.requestBody as never, + responseBody: responseBody as never, + externalNo: input.externalNo?.slice(0, 128), + status: input.status, + errorMessage: input.errorMessage?.slice(0, 512), + }, + }); + return row.id; +} + +export async function resolveOrderRefByOutNumber( + prisma: PrismaService, + outNumber?: string, +): Promise { + if (!outNumber?.trim()) return undefined; + const order = await prisma.order.findUnique({ + where: { orderNo: outNumber.trim() }, + select: { id: true }, + }); + if (!order) return undefined; + return { refType: 'ORDER', refId: order.id }; +} diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts index c633050..659f3bc 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts @@ -1,6 +1,13 @@ import { Injectable } from '@nestjs/common'; import { CourierApiError } from '../courier.error'; import { CourierConfigService } from '../courier.config'; +import { PrismaService } from '../../../common/prisma/prisma.module'; +import { + logCourierCall, + resolveOrderRefByOutNumber, + sanitizeXfxRequestBody, + sceneForXfxCmd, +} from '../courier-log.util'; import { buildXiaofeixiaSign } from './xiaofeixia.sign'; import { XIAOFEIXIA_SUCCESS_CODE } from './xiaofeixia.constants'; import type { XiaofeixiaApiResponse } from './xiaofeixia.types'; @@ -9,12 +16,25 @@ type RequestParams = Record; @Injectable() export class XiaofeixiaClient { - constructor(private readonly courierConfig: CourierConfigService) {} + constructor( + private readonly courierConfig: CourierConfigService, + private readonly prisma: PrismaService, + ) {} async request(cmd: string, bizParams: RequestParams): Promise { const cfg = this.courierConfig.load().xiaofeixia; + const scene = sceneForXfxCmd(cmd); + const externalNo = this.pickExternalNo(bizParams); if (!cfg.mchId || !cfg.apiKey) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl || '(未配置)', + requestBody: sanitizeXfxRequestBody({ cmd, ...bizParams, mchId: cfg.mchId }), + status: 'FAILED', + errorMessage: '小飞侠商户配置不完整', + externalNo, + }); throw new CourierApiError( '小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY', 'CONFIG_ERROR', @@ -42,6 +62,12 @@ export class XiaofeixiaClient { } } + const logRequestBody = sanitizeXfxRequestBody({ ...baseParams, sign: '[REDACTED]' }); + const orderRef = await resolveOrderRefByOutNumber( + this.prisma, + typeof bizParams.outNumber === 'string' ? bizParams.outNumber : undefined, + ); + let response: Response; try { response = await fetch(cfg.apiUrl, { @@ -50,15 +76,32 @@ export class XiaofeixiaClient { body: body.toString(), }); } catch (error) { - throw new CourierApiError( - '小飞侠接口网络异常', - '200000', - 'XIAOFEIXIA', - error, - ); + const message = error instanceof Error ? error.message : String(error); + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + status: 'FAILED', + errorMessage: `网络异常: ${message}`, + externalNo, + ref: orderRef, + }); + throw new CourierApiError('小飞侠接口网络异常', '200000', 'XIAOFEIXIA', error); } + const rawText = await response.text(); + if (!response.ok) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: { httpStatus: response.status, body: rawText.slice(0, 500) }, + status: 'FAILED', + errorMessage: `HTTP ${response.status}`, + externalNo, + ref: orderRef, + }); throw new CourierApiError( `小飞侠 HTTP 请求失败: ${response.status}`, '200000', @@ -66,11 +109,22 @@ export class XiaofeixiaClient { ); } - const rawText = await response.text(); let payload: XiaofeixiaApiResponse; try { - payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse) : (null as unknown as XiaofeixiaApiResponse); + payload = rawText + ? (JSON.parse(rawText) as XiaofeixiaApiResponse) + : (null as unknown as XiaofeixiaApiResponse); } catch { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: { raw: rawText.slice(0, 500) }, + status: 'FAILED', + errorMessage: '响应非 JSON', + externalNo, + ref: orderRef, + }); throw new CourierApiError( `小飞侠响应非 JSON(HTTP ${response.status}): ${rawText.slice(0, 200) || '(空)'}`, '200000', @@ -80,10 +134,29 @@ export class XiaofeixiaClient { } if (!payload) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + status: 'FAILED', + errorMessage: '小飞侠返回空响应', + externalNo, + ref: orderRef, + }); throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA'); } if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: payload as unknown as Record, + status: 'FAILED', + errorMessage: payload.message || '业务失败', + externalNo: externalNo || payload.data?.toString(), + ref: orderRef, + }); throw new CourierApiError( payload.message || '小飞侠接口业务失败', payload.code, @@ -92,6 +165,28 @@ export class XiaofeixiaClient { ); } + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: payload as unknown as Record, + status: 'SUCCESS', + externalNo: externalNo || this.pickExternalNoFromData(payload.data), + ref: orderRef, + }); + return payload.data as T; } + + private pickExternalNo(params: RequestParams) { + const outNumber = params.outNumber != null ? String(params.outNumber) : undefined; + const number = params.number != null ? String(params.number) : undefined; + return outNumber || number; + } + + private pickExternalNoFromData(data: unknown) { + if (!data || typeof data !== 'object') return undefined; + const row = data as { number?: string; outNumber?: string; id?: string }; + return row.number || row.outNumber || row.id; + } } From 386ee9e754f6ca05f7d01ccab4f275c5050bddb5 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 22:10:45 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=A0=B8=E9=94=80=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dukang-api/scripts/test-redeem-confirm.ts | 100 ++++++++++++++++++ .../common/filters/http-exception.filter.ts | 38 ++++++- .../src/modules/benefit/benefit.service.ts | 5 +- .../modules/ops/admin-redeem-debug.service.ts | 14 ++- .../src/modules/redeem/redeem.service.ts | 46 ++++++-- .../modules/settlement/settlement.service.ts | 4 +- 6 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 server/dukang-api/scripts/test-redeem-confirm.ts diff --git a/server/dukang-api/scripts/test-redeem-confirm.ts b/server/dukang-api/scripts/test-redeem-confirm.ts new file mode 100644 index 0000000..82f96a0 --- /dev/null +++ b/server/dukang-api/scripts/test-redeem-confirm.ts @@ -0,0 +1,100 @@ +import '../src/load-env'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../src/app.module'; +import { PrismaService } from '../src/common/prisma/prisma.module'; +import { RedeemService } from '../src/modules/redeem/redeem.service'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] }); + const prisma = app.get(PrismaService); + const redeem = app.get(RedeemService); + + let coupon = await prisma.benefitCoupon.findFirst({ + where: { status: 'ACTIVE', balance: { gt: 10 } }, + orderBy: { createdAt: 'desc' }, + }); + if (!coupon) { + const user = await prisma.user.findFirst({ orderBy: { id: 'asc' } }); + const city = await prisma.commonCity.findFirst(); + const product = await prisma.commonProductItem.findFirst(); + if (!user || !city || !product) throw new Error('seed base data missing'); + const order = await prisma.order.create({ + data: { + orderNo: `T${Date.now()}`, + userId: user.id, + cityId: city.id, + status: 'COMPLETED', + payStatus: 'PAID', + deliveryType: 'LOCAL', + productId: product.id, + barcode69: product.barcode69, + productName: product.name, + productSpec: product.spec ?? '500ml', + quantity: 2, + listUnitPrice: 100, + listAmount: 200, + productAmount: 200, + payAmount: 200, + benefitAmount: 200, + receiverName: 'test', + receiverPhone: user.phone ?? '13800000001', + receiverProvince: '河南', + receiverCity: '郑州', + receiverDistrict: '金水', + receiverAddress: 'test addr', + }, + }); + coupon = await prisma.benefitCoupon.create({ + data: { + couponNo: `TEST${Date.now()}`, + userId: user.id, + orderId: order.id, + totalAmount: 200, + usedAmount: 0, + balance: 200, + status: 'ACTIVE', + sourceProduct: product.name, + }, + }); + console.log('created test coupon', coupon.id.toString()); + } + + const storeAccount = await prisma.storeAccount.findFirst({ + where: { status: 'ACTIVE', store: { status: 'OPEN' } }, + include: { store: true }, + }); + if (!storeAccount) throw new Error('no active store account on OPEN store'); + + console.log('userId', coupon.userId.toString()); + console.log('storeId', storeAccount.storeId.toString()); + console.log('coupon balance', coupon.balance.toString()); + + const tokenRes = await redeem.createToken(coupon.userId, { + amount: 10, + storeId: storeAccount.storeId.toString(), + }); + console.log('token created', tokenRes.token); + + try { + const preview = await redeem.previewRedeem(storeAccount.id, tokenRes.token); + console.log('preview ok', JSON.stringify(preview)); + } catch (e) { + console.error('preview failed:', e); + throw e; + } + + try { + const record = await redeem.confirmRedeem(storeAccount.id, { token: tokenRes.token }); + console.log('confirm ok', JSON.stringify(record)); + } catch (e) { + console.error('confirm failed:', e); + throw e; + } + + await app.close(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/dukang-api/src/common/filters/http-exception.filter.ts b/server/dukang-api/src/common/filters/http-exception.filter.ts index 3c9938d..56589f8 100644 --- a/server/dukang-api/src/common/filters/http-exception.filter.ts +++ b/server/dukang-api/src/common/filters/http-exception.filter.ts @@ -5,6 +5,23 @@ import { HttpException, HttpStatus, } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string { + switch (exception.code) { + case 'P2002': + return '数据冲突,请刷新后重试'; + case 'P2003': + return '关联数据不存在,请检查门店/权益配置'; + case 'P2021': + case 'P2022': + return '数据库表结构未同步,请在服务器执行 prisma db push'; + case 'P2025': + return '记录不存在或已被删除'; + default: + return exception.message; + } +} @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -27,10 +44,29 @@ export class HttpExceptionFilter implements ExceptionFilter { return; } + if (exception instanceof Prisma.PrismaClientKnownRequestError) { + console.error(exception); + response.status(HttpStatus.BAD_REQUEST).json({ + code: 400, + message: prismaErrorMessage(exception), + data: null, + }); + return; + } + + if (exception instanceof SyntaxError && /BigInt/i.test(exception.message)) { + response.status(HttpStatus.BAD_REQUEST).json({ + code: 400, + message: 'ID 格式无效', + data: null, + }); + return; + } + console.error(exception); response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ code: 500, - message: 'Internal server error', + message: exception instanceof Error ? exception.message : 'Internal server error', data: null, }); } diff --git a/server/dukang-api/src/modules/benefit/benefit.service.ts b/server/dukang-api/src/modules/benefit/benefit.service.ts index 0bb5c78..ea6c92a 100644 --- a/server/dukang-api/src/modules/benefit/benefit.service.ts +++ b/server/dukang-api/src/modules/benefit/benefit.service.ts @@ -100,7 +100,10 @@ export class BenefitService { const coupon = await tx.benefitCoupon.findUniqueOrThrow({ where: { id: BigInt(alloc.couponId) }, }); - const allocAmount = alloc.amount; + const allocAmount = Number(alloc.amount); + if (!Number.isFinite(allocAmount) || allocAmount <= 0) { + throw new Error('BENEFIT_ALLOC_INVALID'); + } const updated = await tx.benefitCoupon.updateMany({ where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } }, data: { diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts index a99478c..1a50396 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedeemService } from '../redeem/redeem.service'; import type { @@ -13,9 +13,17 @@ export class AdminRedeemDebugService { private readonly redeemService: RedeemService, ) {} + private parseId(value: string, label: string): bigint { + const normalized = value?.trim(); + if (!normalized || !/^\d+$/.test(normalized)) { + throw new BadRequestException(`${label}格式无效`); + } + return BigInt(normalized); + } + private async resolveStoreAccountId(storeId: string): Promise { const account = await this.prisma.storeAccount.findFirst({ - where: { storeId: BigInt(storeId), status: 'ACTIVE' }, + where: { storeId: this.parseId(storeId, '门店 ID'), status: 'ACTIVE' }, orderBy: { id: 'asc' }, select: { id: true, store: { select: { name: true } } }, }); @@ -26,7 +34,7 @@ export class AdminRedeemDebugService { } async createToken(dto: AdminRedeemDebugCreateTokenDto) { - return this.redeemService.createToken(BigInt(dto.userId), { + return this.redeemService.createToken(this.parseId(dto.userId, '用户 ID'), { amount: dto.amount, couponId: dto.couponId, storeId: dto.storeId, diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index ae5f8a8..e10f402 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -150,23 +150,38 @@ export class RedeemService { throw new BadRequestException('核销码数据异常'); } - const allocSum = allocations.reduce((sum, item) => sum + item.amount, 0); - if (Math.abs(allocSum - cached.amount) > 0.001) { + const normalizedAllocations = allocations.map((item) => ({ + couponId: String(item.couponId), + amount: Number(item.amount), + })); + const tokenAmount = Number(cached.amount); + if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) { throw new BadRequestException('核销码数据异常'); } - for (const alloc of allocations) { + const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0); + if (Math.abs(allocSum - tokenAmount) > 0.001) { + throw new BadRequestException('核销码数据异常'); + } + + for (const alloc of normalizedAllocations) { + let couponId: bigint; + try { + couponId = BigInt(alloc.couponId); + } catch { + throw new BadRequestException('核销码数据异常'); + } const coupon = await this.prisma.benefitCoupon.findUnique({ - where: { id: BigInt(alloc.couponId) }, + where: { id: couponId }, }); if (!coupon) throw new BadRequestException('券不存在'); const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance)); if (!check.ok) throw new BadRequestException(check.message); } - const amount = Number(cached.amount); - const cityRule = await this.prisma.commonCityCommissionRule.findFirst({ - where: { city: { stores: { some: { id: account.storeId } } } }, + const amount = tokenAmount; + const cityRule = await this.prisma.commonCityCommissionRule.findUnique({ + where: { cityId: account.store.cityId }, }); const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6; const settleAmount = calcRedeemSettleAmount(amount, settlementRate); @@ -174,29 +189,40 @@ export class RedeemService { let record; try { record = await this.prisma.$transaction(async (tx) => { - await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId); + await this.benefitService.deductCoupons(tx, normalizedAllocations, 'STORE', account.storeId); const redeemRecord = await tx.redeemRecord.create({ data: { redeemNo: generateRedeemNo(), userId: BigInt(cached.userId), - couponId: BigInt(allocations[0].couponId), + couponId: BigInt(normalizedAllocations[0].couponId), storeId: account.storeId, amount, settleAmount, }, }); + await this.settlementService.createStorePayout( + redeemRecord.id, + account.storeId, + amount, + settleAmount, + settlementRate, + tx, + ); + return redeemRecord; }); } catch (e) { if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') { throw new BadRequestException('核销失败,请重试'); } + if (e instanceof Error && e.message === 'BENEFIT_ALLOC_INVALID') { + throw new BadRequestException('核销码数据异常'); + } throw e; } - await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate); await this.redis.del(`redeem:token:${body.token}`); this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', { diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 5051c2f..e76a949 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -17,10 +17,12 @@ export class SettlementService { redeemAmount: number, payoutAmount: number, settlementRate: number, + tx?: Prisma.TransactionClient, ) { const expectedPayAt = new Date(); expectedPayAt.setDate(expectedPayAt.getDate() + 1); - const payout = await this.prisma.storePayout.create({ + const client = tx ?? this.prisma; + const payout = await client.storePayout.create({ data: { redeemRecordId, storeId, From d708a10095c6f052b9483de430ce0b17ec45fd7f Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 22:22:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=A0=B8=E9=94=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/pages/RedeemDebugPage.tsx | 22 +++++----- apps/h5-user/src/pages/MinePage.tsx | 8 ---- .../modules/ops/admin-redeem-debug.service.ts | 44 ++++++++++++++++--- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/apps/admin-web/src/pages/RedeemDebugPage.tsx b/apps/admin-web/src/pages/RedeemDebugPage.tsx index 5ca1924..bdad447 100644 --- a/apps/admin-web/src/pages/RedeemDebugPage.tsx +++ b/apps/admin-web/src/pages/RedeemDebugPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { - Alert, Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message, + Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message, } from 'antd'; import { request } from '../lib/api'; @@ -56,13 +56,6 @@ export default function RedeemDebugPage() { return (
核销调试 -
- - + + @@ -90,8 +87,9 @@ export default function RedeemDebugPage() { type="primary" loading={loading} onClick={() => { - const values = createForm.getFieldsValue(); - void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成'); + void createForm.validateFields().then((values) => { + void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成'); + }); }} > 生成核销码 diff --git a/apps/h5-user/src/pages/MinePage.tsx b/apps/h5-user/src/pages/MinePage.tsx index bd7a777..3dbe594 100644 --- a/apps/h5-user/src/pages/MinePage.tsx +++ b/apps/h5-user/src/pages/MinePage.tsx @@ -102,14 +102,6 @@ export default function MinePage() { {hasWechat ? '微信会员' : '好客会员'}
-
diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts index 1a50396..b91a2ea 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts @@ -13,17 +13,46 @@ export class AdminRedeemDebugService { private readonly redeemService: RedeemService, ) {} - private parseId(value: string, label: string): bigint { - const normalized = value?.trim(); + private parseStoreId(value: string): bigint { + const normalized = String(value ?? '').trim(); if (!normalized || !/^\d+$/.test(normalized)) { - throw new BadRequestException(`${label}格式无效`); + throw new BadRequestException('门店 ID 格式无效'); } return BigInt(normalized); } + private async resolveUserId(identifier: string): Promise { + const normalized = String(identifier ?? '').trim(); + if (!normalized) { + throw new BadRequestException('请填写用户 ID、用户编号或手机号'); + } + + if (/^\d+$/.test(normalized)) { + const byId = await this.prisma.user.findUnique({ + where: { id: BigInt(normalized) }, + select: { id: true }, + }); + if (byId) return byId.id; + + const byPhone = await this.prisma.user.findFirst({ + where: { phone: normalized }, + select: { id: true }, + }); + if (byPhone) return byPhone.id; + } else { + const byNo = await this.prisma.user.findFirst({ + where: { userNo: normalized }, + select: { id: true }, + }); + if (byNo) return byNo.id; + } + + throw new NotFoundException('用户不存在,请检查 ID、用户编号或手机号'); + } + private async resolveStoreAccountId(storeId: string): Promise { const account = await this.prisma.storeAccount.findFirst({ - where: { storeId: this.parseId(storeId, '门店 ID'), status: 'ACTIVE' }, + where: { storeId: this.parseStoreId(storeId), status: 'ACTIVE' }, orderBy: { id: 'asc' }, select: { id: true, store: { select: { name: true } } }, }); @@ -34,10 +63,11 @@ export class AdminRedeemDebugService { } async createToken(dto: AdminRedeemDebugCreateTokenDto) { - return this.redeemService.createToken(this.parseId(dto.userId, '用户 ID'), { + const userId = await this.resolveUserId(dto.userId); + return this.redeemService.createToken(userId, { amount: dto.amount, - couponId: dto.couponId, - storeId: dto.storeId, + couponId: dto.couponId?.trim() || undefined, + storeId: dto.storeId?.trim() || undefined, }); } From a66207ffa99e6d2da43a6b61b5aff171f2e05490 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 22:33:30 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E8=B6=85=E7=AE=A1=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=99=BB=E5=BD=95=EF=BC=9B=20=E4=BB=A3=E7=A0=81=E6=A0=B8?= =?UTF-8?q?=E9=94=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/pages/LoginPage.tsx | 122 +++++++++++++----- server/dukang-api/package.json | 1 + server/dukang-api/prisma/schema.prisma | 10 +- .../dukang-api/scripts/upsert-super-admin.ts | 46 +++++++ .../src/common/crypto/password.util.ts | 20 +++ .../src/modules/iam/admin-auth.controller.ts | 7 +- .../src/modules/iam/auth.service.ts | 25 ++++ .../src/modules/iam/dto/auth.dto.ts | 10 ++ 8 files changed, 203 insertions(+), 38 deletions(-) create mode 100644 server/dukang-api/scripts/upsert-super-admin.ts create mode 100644 server/dukang-api/src/common/crypto/password.util.ts diff --git a/apps/admin-web/src/pages/LoginPage.tsx b/apps/admin-web/src/pages/LoginPage.tsx index 0255659..59461da 100644 --- a/apps/admin-web/src/pages/LoginPage.tsx +++ b/apps/admin-web/src/pages/LoginPage.tsx @@ -1,14 +1,17 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Button, Card, Form, Input, message, Typography } from 'antd'; +import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd'; import { saveAuth, request } from '../lib/api'; +type LoginResult = { accessToken: string; refreshToken: string }; + export default function LoginPage() { const navigate = useNavigate(); - const [form] = Form.useForm(); + const [smsForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); const [loading, setLoading] = useState(false); const [codeCooldown, setCodeCooldown] = useState(0); - const phone = Form.useWatch('phone', form); + const phone = Form.useWatch('phone', smsForm); async function sendCode() { if (!phone) { @@ -19,7 +22,7 @@ export default function LoginPage() { method: 'POST', body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }), }); - message.success('验证码已发送(Mock: 123456)'); + message.success('验证码已发送'); setCodeCooldown(60); const timer = setInterval(() => { setCodeCooldown((c) => { @@ -32,19 +35,35 @@ export default function LoginPage() { }, 1000); } - async function onFinish(values: { phone: string; code: string }) { + async function finishLogin(data: LoginResult) { + saveAuth(data); + message.success('登录成功'); + navigate('/'); + } + + async function onSmsFinish(values: { phone: string; code: string }) { setLoading(true); try { - const data = await request<{ accessToken: string; refreshToken: string }>( - '/admin/auth/login/sms', - { - method: 'POST', - body: JSON.stringify(values), - }, - ); - saveAuth(data); - message.success('登录成功'); - navigate('/'); + const data = await request('/admin/auth/login/sms', { + method: 'POST', + body: JSON.stringify(values), + }); + await finishLogin(data); + } catch (e) { + message.error(e instanceof Error ? e.message : '登录失败'); + } finally { + setLoading(false); + } + } + + async function onPasswordFinish(values: { loginName: string; password: string }) { + setLoading(true); + try { + const data = await request('/admin/auth/login/password', { + method: 'POST', + body: JSON.stringify(values), + }); + await finishLogin(data); } catch (e) { message.error(e instanceof Error ? e.message : '登录失败'); } finally { @@ -66,24 +85,61 @@ export default function LoginPage() { HQ 管理后台 - - - - - - 0} onClick={() => void sendCode()}> - {codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'} - - } - /> - - - + + + + + + + + + + ), + }, + { + key: 'sms', + label: '短信验证码', + children: ( +
+ + + + + 0} onClick={() => void sendCode()}> + {codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'} + + } + /> + + +
+ ), + }, + ]} + />
); diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 69fbd26..1eaa770 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -12,6 +12,7 @@ "prisma:validate": "prisma validate", "prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts", "prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts", + "prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts", "prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts" }, "dependencies": { diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 287771e..74e4feb 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -493,10 +493,12 @@ model PartnerBill { // ─── HQ ─────────────────────────────────────────────── model HqAccount { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - phone String @unique @db.VarChar(20) - name String @db.VarChar(64) - adminRole HqAdminRole @default(OPS) @map("admin_role") + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + phone String @unique @db.VarChar(20) + loginName String? @unique @map("login_name") @db.VarChar(64) + passwordHash String? @map("password_hash") @db.VarChar(255) + name String @db.VarChar(64) + adminRole HqAdminRole @default(OPS) @map("admin_role") wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxUnionId String? @map("wx_union_id") @db.VarChar(64) status AccountStatus @default(ACTIVE) diff --git a/server/dukang-api/scripts/upsert-super-admin.ts b/server/dukang-api/scripts/upsert-super-admin.ts new file mode 100644 index 0000000..ec38ced --- /dev/null +++ b/server/dukang-api/scripts/upsert-super-admin.ts @@ -0,0 +1,46 @@ +import '../src/load-env'; +import { PrismaClient } from '@prisma/client'; +import { hashPassword } from '../src/common/crypto/password.util'; + +const LOGIN_NAME = process.env.SUPER_ADMIN_LOGIN ?? 'admin'; +const PASSWORD = process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!'; +const PLACEHOLDER_PHONE = process.env.SUPER_ADMIN_PHONE ?? '19900000001'; + +async function main() { + const prisma = new PrismaClient(); + const passwordHash = hashPassword(PASSWORD); + try { + const deleted = await prisma.$executeRaw` + DELETE FROM hq_account WHERE admin_role = 'SUPER_ADMIN' + `; + console.log(`Deleted ${deleted} SUPER_ADMIN account row(s).`); + + await prisma.$executeRaw` + INSERT INTO hq_account ( + phone, login_name, password_hash, name, admin_role, status, created_at, updated_at + ) VALUES ( + ${PLACEHOLDER_PHONE}, + ${LOGIN_NAME}, + ${passwordHash}, + ${'超级管理员'}, + ${'SUPER_ADMIN'}, + ${'ACTIVE'}, + NOW(3), + NOW(3) + ) + `; + + console.log('Created SUPER_ADMIN:', { + loginName: LOGIN_NAME, + phone: PLACEHOLDER_PHONE, + password: '(hidden)', + }); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/dukang-api/src/common/crypto/password.util.ts b/server/dukang-api/src/common/crypto/password.util.ts new file mode 100644 index 0000000..21f4e7b --- /dev/null +++ b/server/dukang-api/src/common/crypto/password.util.ts @@ -0,0 +1,20 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +const SALT_LEN = 16; +const KEY_LEN = 64; + +export function hashPassword(password: string): string { + const salt = randomBytes(SALT_LEN); + const hash = scryptSync(password, salt, KEY_LEN); + return `${salt.toString('hex')}:${hash.toString('hex')}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const [saltHex, hashHex] = stored.split(':'); + if (!saltHex || !hashHex) return false; + const salt = Buffer.from(saltHex, 'hex'); + const expected = Buffer.from(hashHex, 'hex'); + const actual = scryptSync(password, salt, expected.length); + if (actual.length !== expected.length) return false; + return timingSafeEqual(actual, expected); +} diff --git a/server/dukang-api/src/modules/iam/admin-auth.controller.ts b/server/dukang-api/src/modules/iam/admin-auth.controller.ts index 025144c..b57dac6 100644 --- a/server/dukang-api/src/modules/iam/admin-auth.controller.ts +++ b/server/dukang-api/src/modules/iam/admin-auth.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { LoginSmsDto, SendSmsDto } from './dto/auth.dto'; +import { LoginPasswordDto, LoginSmsDto, SendSmsDto } from './dto/auth.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { AuthUser } from '../../common/guards/jwt-auth.guard'; @@ -20,6 +20,11 @@ export class AdminAuthController { return this.authService.loginHq(dto.phone, dto.code, ClientApp.HQ_WEB); } + @Post('login/password') + loginPassword(@Body() dto: LoginPasswordDto) { + return this.authService.loginHqPassword(dto.loginName, dto.password, ClientApp.HQ_WEB); + } + @Get('me') @UseGuards(JwtAuthGuard) me(@CurrentUser() user: AuthUser) { diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 4e10256..cb344d1 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -19,6 +19,7 @@ import type { SmsActorRef } from '../../integrations/sms/sms.interface'; import { SmsCodeStore } from '../../integrations/sms/sms-code.store'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { verifyPassword } from '../../common/crypto/password.util'; import { AnalyticsService } from '../analytics/analytics.service'; import { UserAddressService } from './user-address.service'; @@ -450,6 +451,30 @@ export class AuthService { }); } + async loginHqPassword(loginName: string, password: string, clientApp: ClientApp) { + const normalizedLogin = loginName.trim(); + if (!normalizedLogin) throw new BadRequestException('请输入账号'); + const account = await this.prisma.hqAccount.findUnique({ + where: { loginName: normalizedLogin }, + }); + if (!account?.passwordHash) throw new BadRequestException('账号或密码错误'); + if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用'); + if (!verifyPassword(password, account.passwordHash)) { + throw new BadRequestException('账号或密码错误'); + } + await this.prisma.hqAccount.update({ + where: { id: account.id }, + data: { lastLoginAt: new Date() }, + }); + return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, { + id: account.id.toString(), + phone: account.phone, + name: account.name, + adminRole: account.adminRole, + status: account.status, + }); + } + async getMe(actorType: string, actorId: bigint) { if (actorType === 'USER') { const user = await this.assertActiveUser(actorId); diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index fccec9c..79d3268 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -69,6 +69,16 @@ export class BindWechatPhoneDto { code: string; } +export class LoginPasswordDto { + @IsString() + @IsNotEmpty() + loginName: string; + + @IsString() + @IsNotEmpty() + password: string; +} + export class BindWechatDto { @IsString() @IsOptional()