From ff4622ff6cbb68b108c0cb63b311513aacbf369e Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Wed, 1 Jul 2026 22:41:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E5=BE=AE=E4=BF=A1=E6=94=AF?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-user/src/pages/PayPage.tsx | 19 ++- packages/shared-types/src/config.ts | 2 + server/dukang-api/.env.example | 2 + server/dukang-api/src/app.module.ts | 2 + .../src/callbacks/callbacks.module.ts | 10 ++ .../src/callbacks/wechat-pay.controller.ts | 38 ++++++ .../src/integrations/integrations.module.ts | 5 +- .../integrations/pay/pay.wechat.provider.ts | 10 +- .../integrations/wechat/wechat-pay.util.ts | 69 ++++++++++ .../wechat/wechat.api.provider.ts | 125 ++++++++++++++++-- .../wechat/wechat.disabled.provider.ts | 12 ++ .../integrations/wechat/wechat.interface.ts | 21 ++- server/dukang-api/src/main.ts | 12 +- .../src/modules/trade/trade.service.ts | 81 ++++++++++++ 14 files changed, 389 insertions(+), 19 deletions(-) create mode 100644 server/dukang-api/src/callbacks/callbacks.module.ts create mode 100644 server/dukang-api/src/callbacks/wechat-pay.controller.ts create mode 100644 server/dukang-api/src/integrations/wechat/wechat-pay.util.ts diff --git a/apps/h5-user/src/pages/PayPage.tsx b/apps/h5-user/src/pages/PayPage.tsx index 2f4efa4..9207e56 100644 --- a/apps/h5-user/src/pages/PayPage.tsx +++ b/apps/h5-user/src/pages/PayPage.tsx @@ -6,6 +6,19 @@ import { request } from '../lib/api'; import { buildOrderConfirmUrl } from '../lib/navigation'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitOrderPaid(orderId: string, maxAttempts = 15) { + for (let i = 0; i < maxAttempts; i += 1) { + const order = await request<{ payStatus?: string }>('USER_H5', `/trade/orders/${orderId}`); + if (order.payStatus === 'PAID') return true; + await sleep(2000); + } + return false; +} + export default function PayPage() { const [params] = useSearchParams(); const orderId = params.get('orderId') || ''; @@ -35,6 +48,10 @@ export default function PayPage() { setMockMode(false); await weixinSdk.init(); await weixinSdk.pay(result.prepay); + const paid = await waitOrderPaid(orderId); + if (!paid) { + alert('支付结果确认中,请稍后在订单列表查看'); + } navigate('/orders?tab=pending_ship'); return; } @@ -60,7 +77,7 @@ export default function PayPage() {

{mockMode ? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台' - : '请在微信内完成支付'} + : '请在微信内完成支付,支付成功后自动跳转'}

订单号 {orderId}

diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index 7be4ed9..d96c5f8 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -7,6 +7,7 @@ export interface AppConfig { wechatAuthEnabled: boolean; wechatPayEnabled: boolean; wxAppId: string; + wxMchId: string; /** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */ ossEnabled: boolean; } @@ -25,6 +26,7 @@ export function loadAppConfig(env?: Record): AppConf wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true', wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false', wxAppId: e.WX_APP_ID ?? '', + wxMchId: e.WX_MCH_ID ?? '', ossEnabled: e.OSS_ENABLED === 'true', }; } diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index d89f86c..cb14b88 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -21,6 +21,8 @@ WX_MCH_ID= WX_MCH_SERIAL_NO= WX_MCH_PRIVATE_KEY= WX_API_V3_KEY= +# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空) +WX_PLATFORM_CERT= WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay # 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL) diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts index ff79ab6..837e413 100644 --- a/server/dukang-api/src/app.module.ts +++ b/server/dukang-api/src/app.module.ts @@ -16,6 +16,7 @@ import { AnalyticsModule } from './modules/analytics/analytics.module'; import { JobsModule } from './jobs/jobs.module'; import { OpsModule } from './modules/ops/ops.module'; import { CommonModule } from './modules/common/common.module'; +import { CallbacksModule } from './callbacks/callbacks.module'; @Module({ imports: [ @@ -40,6 +41,7 @@ import { CommonModule } from './modules/common/common.module'; JobsModule, OpsModule, CommonModule, + CallbacksModule, ], }) export class AppModule {} diff --git a/server/dukang-api/src/callbacks/callbacks.module.ts b/server/dukang-api/src/callbacks/callbacks.module.ts new file mode 100644 index 0000000..c3b3883 --- /dev/null +++ b/server/dukang-api/src/callbacks/callbacks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { IntegrationsModule } from '../integrations/integrations.module'; +import { TradeModule } from '../modules/trade/trade.module'; +import { WechatPayCallbackController } from './wechat-pay.controller'; + +@Module({ + imports: [IntegrationsModule, TradeModule], + controllers: [WechatPayCallbackController], +}) +export class CallbacksModule {} diff --git a/server/dukang-api/src/callbacks/wechat-pay.controller.ts b/server/dukang-api/src/callbacks/wechat-pay.controller.ts new file mode 100644 index 0000000..1b03a0c --- /dev/null +++ b/server/dukang-api/src/callbacks/wechat-pay.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { TradeService } from '../modules/trade/trade.service'; +import { WECHAT_PROVIDER } from '../integrations/integrations.constants'; +import type { IWechatProvider } from '../integrations/wechat/wechat.interface'; + +type RawBodyRequest = Request & { body: Buffer }; + +@Controller('callbacks/wechat') +export class WechatPayCallbackController { + constructor( + private readonly tradeService: TradeService, + @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, + ) {} + + @Post('pay') + async payNotify( + @Req() req: RawBodyRequest, + @Headers() headers: Record, + @Res() res: Response, + ) { + try { + const rawBody = + (req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ?? + (typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {})); + const notify = await this.wechat.parsePayNotification(headers, rawBody); + await this.tradeService.handlePaySuccess({ + orderNo: notify.outTradeNo, + transactionId: notify.transactionId, + amountFen: notify.amountFen, + }); + return res.status(200).json({ code: 'SUCCESS', message: '成功' }); + } catch (err) { + const message = err instanceof Error ? err.message : '处理失败'; + return res.status(500).json({ code: 'FAIL', message }); + } + } +} diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 4b4c154..89b5a6e 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -31,7 +31,10 @@ import type { IOssProvider } from './oss/oss.interface'; provide: WECHAT_PROVIDER, useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => { const cfg = loadAppConfig(); - return cfg.wechatAuthEnabled && cfg.wxAppId ? api : disabled; + const enabled = + (cfg.wechatAuthEnabled || cfg.wechatPayEnabled) && + (!!cfg.wxAppId || !!process.env.WX_MCH_ID); + return enabled ? api : disabled; }, inject: [WechatApiProvider, WechatDisabledProvider], }, diff --git a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts index acb405d..3aae1cf 100644 --- a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { loadAppConfig } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { WECHAT_PROVIDER } from '../integrations.constants'; @@ -7,6 +7,7 @@ import type { IPayProvider, PayOrderResult } from './pay.interface'; @Injectable() export class PayWechatProvider implements IPayProvider { + private readonly logger = new Logger(PayWechatProvider.name); private readonly config = loadAppConfig(); constructor( @@ -18,17 +19,18 @@ export class PayWechatProvider implements IPayProvider { if (this.config.mockPay) { return { mode: 'mock', externalNo: `MOCK-${Date.now()}` }; } + if (!this.wechat.isPayEnabled()) { + throw new Error('微信支付未配置:请设置 WECHAT_PAY_ENABLED=true 与 WX_MCH_ID 等商户参数'); + } if (!openId) { throw new Error('微信支付需要用户 openId,请先完成微信授权登录'); } - if (!this.wechat.isEnabled()) { - throw new Error('微信能力未启用,请配置 WECHAT_AUTH_ENABLED 与 WX_APP_ID/SECRET'); - } const order = await this.prisma.order.findUnique({ where: { id: orderId } }); if (!order) throw new Error('订单不存在'); const amountFen = Math.round(Number(order.payAmount) * 100); + this.logger.log(`create JSAPI prepay order=${order.orderNo} mchid=${this.wechat.getMchId()}`); const prepay = await this.wechat.createJsapiPrepay({ orderNo: order.orderNo, description: `杜康好客订单 ${order.orderNo}`, diff --git a/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts new file mode 100644 index 0000000..48d19ce --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts @@ -0,0 +1,69 @@ +import { createDecipheriv, createVerify, timingSafeEqual } from 'crypto'; + +export type WechatPayNotifyResource = { + transaction_id: string; + out_trade_no: string; + trade_state: string; + trade_state_desc?: string; + amount?: { total?: number; payer_total?: number }; +}; + +export type WechatPayNotifyEnvelope = { + id: string; + create_time: string; + event_type: string; + resource_type: string; + summary: string; + resource: { + algorithm: string; + ciphertext: string; + associated_data?: string; + nonce: string; + original_type?: string; + }; +}; + +export function decryptPayResource( + apiV3Key: string, + associatedData: string, + nonce: string, + ciphertext: string, +): WechatPayNotifyResource { + const key = Buffer.from(apiV3Key, 'utf8'); + const buf = Buffer.from(ciphertext, 'base64'); + const authTag = buf.subarray(buf.length - 16); + const data = buf.subarray(0, buf.length - 16); + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8')); + if (associatedData) { + decipher.setAAD(Buffer.from(associatedData, 'utf8')); + } + decipher.setAuthTag(authTag); + const decoded = Buffer.concat([decipher.update(data), decipher.final()]); + return JSON.parse(decoded.toString('utf8')) as WechatPayNotifyResource; +} + +export function verifyPaySignature(params: { + platformPublicKeyPem: string; + timestamp: string; + nonce: string; + body: string; + signature: string; +}): boolean { + const message = `${params.timestamp}\n${params.nonce}\n${params.body}\n`; + const verifier = createVerify('RSA-SHA256'); + verifier.update(message); + verifier.end(); + const ok = verifier.verify(params.platformPublicKeyPem, params.signature, 'base64'); + if (!ok) return false; + const ts = Number(params.timestamp); + if (!Number.isFinite(ts)) return false; + const skewMs = Math.abs(Date.now() - ts * 1000); + return skewMs <= 5 * 60 * 1000; +} + +export function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a); + const bb = Buffer.from(b); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index bd755a7..a7619ba 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -1,8 +1,13 @@ import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto'; -import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import { loadAppConfig } from '@dukang/shared-types'; import { RedisService } from '../../common/redis/redis.service'; import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface'; +import { + decryptPayResource, + verifyPaySignature, + type WechatPayNotifyEnvelope, +} from './wechat-pay.util'; type TokenCache = { accessToken: string; expiresAt: number }; type TicketCache = { ticket: string; expiresAt: number }; @@ -21,6 +26,7 @@ export class WechatApiProvider implements IWechatProvider { private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n'); private readonly apiV3Key = process.env.WX_API_V3_KEY ?? ''; private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? ''; + private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n'); constructor(private readonly redis: RedisService) {} @@ -28,6 +34,21 @@ export class WechatApiProvider implements IWechatProvider { return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret; } + isPayEnabled() { + return ( + this.config.wechatPayEnabled && + !!this.appId && + !!this.mchId && + !!this.mchSerialNo && + !!this.mchPrivateKey && + !!this.apiV3Key + ); + } + + getMchId() { + return this.mchId; + } + buildOAuthUrl(redirectUri: string, state: string, scope = 'snsapi_userinfo') { const qs = new URLSearchParams({ appid: this.appId, @@ -131,10 +152,15 @@ export class WechatApiProvider implements IWechatProvider { openId: string; notifyUrl: string; }) { - if (!this.mchId || !this.mchPrivateKey || !this.apiV3Key) { - throw new InternalServerErrorException('微信支付商户配置不完整'); + if (!this.isPayEnabled()) { + throw new InternalServerErrorException( + '微信支付未配置:请设置 WECHAT_PAY_ENABLED=true、WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY', + ); } const notifyUrl = params.notifyUrl || this.notifyUrl; + if (!notifyUrl) { + throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL'); + } const body = { appid: this.appId, mchid: this.mchId, @@ -147,18 +173,22 @@ export class WechatApiProvider implements IWechatProvider { const path = '/v3/pay/transactions/jsapi'; const payload = JSON.stringify(body); const auth = this.signPayRequest('POST', path, payload); - const res = await this.fetchJson<{ prepay_id?: string }>(`https://api.mch.weixin.qq.com${path}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: auth, + const res = await this.fetchPayJson<{ prepay_id?: string }>( + `https://api.mch.weixin.qq.com${path}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: auth, + }, + body: payload, }, - body: payload, - }); + ); if (!res.prepay_id) { throw new InternalServerErrorException('微信预支付下单失败'); } + this.logger.log(`JSAPI prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`); const timeStamp = String(Math.floor(Date.now() / 1000)); const nonceStr = randomUUID().replace(/-/g, ''); const packageStr = `prepay_id=${res.prepay_id}`; @@ -177,6 +207,61 @@ export class WechatApiProvider implements IWechatProvider { }; } + async parsePayNotification( + headers: Record, + rawBody: string, + ) { + if (!this.isPayEnabled()) { + throw new BadRequestException('微信支付未启用'); + } + const signature = this.headerValue(headers, 'wechatpay-signature'); + const timestamp = this.headerValue(headers, 'wechatpay-timestamp'); + const nonce = this.headerValue(headers, 'wechatpay-nonce'); + if (!signature || !timestamp || !nonce) { + throw new BadRequestException('微信回调签名头缺失'); + } + if (this.platformCert) { + const valid = verifyPaySignature({ + platformPublicKeyPem: this.platformCert, + timestamp, + nonce, + body: rawBody, + signature, + }); + if (!valid) { + throw new BadRequestException('微信回调验签失败'); + } + } else { + this.logger.warn('WX_PLATFORM_CERT 未配置,跳过回调 RSA 验签(仅建议开发环境)'); + } + + const envelope = JSON.parse(rawBody) as WechatPayNotifyEnvelope; + if (envelope.event_type !== 'TRANSACTION.SUCCESS') { + throw new BadRequestException(`忽略的事件类型: ${envelope.event_type}`); + } + const resource = decryptPayResource( + this.apiV3Key, + envelope.resource.associated_data ?? '', + envelope.resource.nonce, + envelope.resource.ciphertext, + ); + if (resource.trade_state !== 'SUCCESS') { + throw new BadRequestException(`交易未成功: ${resource.trade_state}`); + } + return { + transactionId: resource.transaction_id, + outTradeNo: resource.out_trade_no, + tradeState: resource.trade_state, + amountFen: resource.amount?.total ?? resource.amount?.payer_total ?? 0, + }; + } + + private headerValue(headers: Record, key: string) { + const raw = headers[key] ?? headers[key.toLowerCase()]; + if (Array.isArray(raw)) return raw[0]; + return raw; + } + private async getAccessToken(): Promise { const cached = await this.redis.getJson(ACCESS_TOKEN_KEY); if (cached && cached.expiresAt > Date.now()) return cached.accessToken; @@ -234,6 +319,24 @@ export class WechatApiProvider implements IWechatProvider { return `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`; } + private async fetchPayJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + const text = await res.text(); + let data: T & { code?: string; message?: string }; + try { + data = JSON.parse(text) as T & { code?: string; message?: string }; + } catch { + this.logger.error(`WeChat Pay invalid JSON (${res.status}): ${text.slice(0, 300)}`); + throw new InternalServerErrorException('微信支付接口响应异常'); + } + if (!res.ok) { + const detail = data.message || data.code || text.slice(0, 200); + this.logger.error(`WeChat Pay API ${res.status}: ${detail}`); + throw new InternalServerErrorException(`微信支付下单失败: ${detail}`); + } + return data; + } + private async fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); const text = await res.text(); diff --git a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts index 198a94d..ae28645 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts @@ -7,6 +7,14 @@ export class WechatDisabledProvider implements IWechatProvider { return false; } + isPayEnabled() { + return false; + } + + getMchId() { + return ''; + } + private disabled(): never { throw new NotImplementedException('FEATURE_DISABLED'); } @@ -34,4 +42,8 @@ export class WechatDisabledProvider implements IWechatProvider { createJsapiPrepay() { return this.disabled(); } + + parsePayNotification() { + return this.disabled(); + } } diff --git a/server/dukang-api/src/integrations/wechat/wechat.interface.ts b/server/dukang-api/src/integrations/wechat/wechat.interface.ts index 9b05d11..cd3cdbb 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.interface.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts @@ -14,9 +14,22 @@ export type WechatOAuthSession = { refreshToken?: string; }; +export type WechatPayNotifyResult = { + transactionId: string; + outTradeNo: string; + tradeState: string; + amountFen: number; +}; + export interface IWechatProvider { isEnabled(): boolean; + /** 微信支付是否已配置(商户号 + 证书) */ + isPayEnabled(): boolean; + + /** 当前商户号(用于日志/排查) */ + getMchId(): string; + /** 小程序 code2session */ code2Session(code: string): Promise; @@ -32,7 +45,7 @@ export interface IWechatProvider { /** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */ getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise; - /** 创建 JSAPI 预支付参数 */ + /** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */ createJsapiPrepay(params: { orderNo: string; description: string; @@ -40,4 +53,10 @@ export interface IWechatProvider { openId: string; notifyUrl: string; }): Promise; + + /** 解析并验签支付回调通知 */ + parsePayNotification( + headers: Record, + rawBody: string, + ): Promise; } diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index 2b6bcfb..087bcdc 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -1,15 +1,25 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; +import { json } from 'express'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bodyParser: false }); app.setGlobalPrefix('api/v1'); app.set('trust proxy', true); app.enableCors({ origin: true, credentials: true }); + app.use( + json({ + verify: (req, _res, buf) => { + if (req.url?.includes('/callbacks/wechat/pay')) { + (req as { rawBody?: Buffer }).rawBody = buf; + } + }, + }), + ); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseInterceptor()); diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index ad12c35..4440e2a 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -222,6 +222,87 @@ export class TradeService { return this.getOrder(userId, orderId); } + /** 微信支付回调:幂等更新订单为已支付并发券 */ + async handlePaySuccess(params: { + orderNo: string; + transactionId: string; + amountFen: number; + }) { + const order = await this.prisma.order.findUnique({ where: { orderNo: params.orderNo } }); + if (!order) { + throw new NotFoundException('订单不存在'); + } + + if (order.payStatus === 'PAID') { + return { orderId: order.id.toString(), alreadyPaid: true }; + } + + const expectedFen = Math.round(Number(order.payAmount) * 100); + if (params.amountFen > 0 && params.amountFen !== expectedFen) { + throw new BadRequestException('支付金额与订单不符'); + } + + const existingLog = await this.prisma.logThirdParty.findFirst({ + where: { + provider: 'WECHAT_PAY', + externalNo: params.transactionId, + status: 'SUCCESS', + }, + }); + if (existingLog) { + return { orderId: order.id.toString(), alreadyPaid: true }; + } + + const now = new Date(); + await this.prisma.$transaction(async (tx) => { + const current = await tx.order.findUnique({ where: { id: order.id } }); + if (!current || current.payStatus === 'PAID') return; + + await tx.order.update({ + where: { id: order.id }, + data: { + status: 'PENDING_SHIP', + payStatus: 'PAID', + paidAt: now, + payExternalNo: params.transactionId, + }, + }); + await tx.logThirdParty.create({ + data: { + provider: 'WECHAT_PAY', + scene: 'ORDER_PAY', + refType: 'ORDER', + refId: order.id, + externalNo: params.transactionId, + amount: order.payAmount, + status: 'SUCCESS', + }, + }); + await tx.commonEvent.create({ + data: buildOrderStatusEvent({ + orderId: order.id, + fromStatus: 'PENDING_PAY', + toStatus: 'PENDING_SHIP', + operator: 'WECHAT_PAY', + }), + }); + const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); + if (!delivery) { + await tx.orderDelivery.create({ + data: { orderId: order.id, provider: 'MANUAL' }, + }); + } + }); + + const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } }); + if (refreshed?.payStatus === 'PAID') { + await this.benefitService.grantOnOrderPaid(order.id); + await this.deliveryProvider.scheduleAutoAdvance(order.id); + } + + return { orderId: order.id.toString(), alreadyPaid: false }; + } + async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) { const statuses = orderTabToStatuses(tab); const where = {