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 = {