From 386ee9e754f6ca05f7d01ccab4f275c5050bddb5 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 22:10:45 +0800 Subject: [PATCH] =?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,