From fb8152401bd25e7a4ac7f9ace7628ce82432704c Mon Sep 17 00:00:00 2001 From: yuzhiran-dev Date: Fri, 5 Jun 2026 13:19:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=8A=9F=E8=83=BD=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E7=BD=91=E5=85=B3=E6=96=87=E6=A1=A3=20+=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E7=AE=A1=E7=90=86=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GatewayPayService: 新增 remark 字段(传uid/oid), 移除废弃 return_url, 新增 closeOrder/syncOrderStatus - PaymentController: 新增 POST /payment/gateway/sync/:orderNo 和 /close/:id 端点 - Admin 订单页: 待支付订单增加「同步状态」按钮 - OrdersService: 下单时传入 remark 使 webhook 可匹配 - 清理 PaymentService 未使用依赖 - docs/progress.md 更新支付架构 --- backend/src/modules/orders/orders.service.ts | 2 + .../modules/payment/gateway-pay.service.ts | 57 +++++++++++++++++-- .../src/modules/payment/payment.controller.ts | 22 ++++++- docs/progress.md | 36 ++++++++++++ frontend/src/app/admin/orders/page.tsx | 32 ++++++++++- 5 files changed, 139 insertions(+), 10 deletions(-) diff --git a/backend/src/modules/orders/orders.service.ts b/backend/src/modules/orders/orders.service.ts index f4e113b..1a95553 100755 --- a/backend/src/modules/orders/orders.service.ts +++ b/backend/src/modules/orders/orders.service.ts @@ -30,11 +30,13 @@ export class OrdersService { const subject = planLabels[data.planType] || '宇之然AI会员充值'; try { + const remark = JSON.stringify({ uid: userId, oid: order.id }); const result = await this.gatewayPay.createOrder({ merchantOrderId: orderNo, amount: data.amount, paymentMethod: channel === 'wxpay' ? 'wechat' : 'alipay', subject, + remark, }); if (result.gatewayOrderId) { diff --git a/backend/src/modules/payment/gateway-pay.service.ts b/backend/src/modules/payment/gateway-pay.service.ts index 3b0a18d..40e0124 100644 --- a/backend/src/modules/payment/gateway-pay.service.ts +++ b/backend/src/modules/payment/gateway-pay.service.ts @@ -7,7 +7,7 @@ interface GatewayCreateOrderParams { amount: number; paymentMethod: 'alipay' | 'wechat'; subject?: string; - returnUrl?: string; + remark?: string; } interface GatewayCreateOrderResult { @@ -37,7 +37,6 @@ export class GatewayPayService { private readonly apiSecret: string; private readonly baseUrl: string; private readonly notifyUrl: string; - private readonly returnUrl: string; private readonly mock: boolean; constructor(private prisma: PrismaService) { @@ -45,7 +44,6 @@ export class GatewayPayService { this.apiSecret = process.env.GATEWAY_PAY_API_SECRET || ''; this.baseUrl = (process.env.GATEWAY_PAY_BASE_URL || 'http://localhost:8100').replace(/\/+$/, ''); this.notifyUrl = process.env.GATEWAY_PAY_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/gateway/webhook'; - this.returnUrl = process.env.GATEWAY_PAY_RETURN_URL || 'https://yuzhiran.com/my/member'; this.mock = process.env.GATEWAY_PAY_MOCK === 'true' || !this.apiKey; if (this.mock) { this.logger.warn('GATEWAY_PAY_MOCK=true,使用模拟支付模式'); @@ -88,8 +86,10 @@ export class GatewayPayService { payment_method: params.paymentMethod, subject: params.subject || '宇之然AI会员充值', notify_url: this.notifyUrl, - return_url: params.returnUrl || this.returnUrl, }; + if (params.remark) { + body.remark = params.remark; + } const auth = this.signRequest('POST', '/v1/pay/orders', body); @@ -168,6 +168,55 @@ export class GatewayPayService { } } + async closeOrder(gatewayOrderId: string): Promise { + if (this.mock || gatewayOrderId.startsWith('mock_')) { + this.logger.log(`模拟关闭订单: ${gatewayOrderId}`); + return true; + } + + const path = `/v1/pay/orders/${gatewayOrderId}/close`; + const auth = this.signRequest('POST', path, {}); + + try { + const res = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { Authorization: auth, 'Content-Type': 'application/json' }, + body: '{}', + }); + const json = await res.json(); + if (json.code !== 0) throw new Error(json.message || '关闭订单失败'); + this.logger.log(`网关订单已关闭: ${gatewayOrderId}`); + return true; + } catch (err: any) { + this.logger.error(`关闭网关订单失败: ${err.message}`); + return false; + } + } + + async syncOrderStatus(orderNo: string): Promise<{ updated: boolean; status: string }> { + const order = await this.prisma.order.findUnique({ where: { orderNo } }); + if (!order) return { updated: false, status: 'not_found' }; + if (order.status !== 'PENDING' || !order.gatewayOrderId) { + return { updated: false, status: order.status }; + } + + const gatewayResult = await this.queryOrder(order.gatewayOrderId); + if (gatewayResult.status === 'paid' && order.status === 'PENDING') { + await this.prisma.order.update({ + where: { id: order.id }, + data: { + status: 'PAID', + paidAt: gatewayResult.paidAt ? new Date(gatewayResult.paidAt) : new Date(), + transactionId: gatewayResult.transactionId || order.transactionId, + }, + }); + await this.activateSubscription(order.id); + this.logger.log(`订单状态同步成功: ${orderNo} (PENDING→PAID)`); + return { updated: true, status: 'PAID' }; + } + return { updated: false, status: gatewayResult.status }; + } + async refund(merchantOrderId: string, amount?: number, reason?: string) { if (this.mock) { this.logger.log(`模拟网关退款: ${merchantOrderId}`); diff --git a/backend/src/modules/payment/payment.controller.ts b/backend/src/modules/payment/payment.controller.ts index ea0b907..84664e6 100755 --- a/backend/src/modules/payment/payment.controller.ts +++ b/backend/src/modules/payment/payment.controller.ts @@ -1,7 +1,6 @@ -import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus } from '@nestjs/common'; +import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus, Param } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger'; import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator'; -import { PaymentService } from './payment.service'; import { GatewayPayService } from './gateway-pay.service'; import { PrismaService } from '../../prisma/prisma.service'; @@ -40,7 +39,6 @@ class RefundDto { @Controller('payment') export class PaymentController { constructor( - private paymentService: PaymentService, private gatewayPay: GatewayPayService, private prisma: PrismaService, ) {} @@ -113,4 +111,22 @@ export class PaymentController { if (result.code === 'SUCCESS') return 'success'; return 'fail'; } + + @Post('gateway/sync/:orderNo') + @ApiOperation({ summary: '手动同步订单状态(从网关查询并更新本地)' }) + async syncOrderStatus(@Param('orderNo') orderNo: string) { + const result = await this.gatewayPay.syncOrderStatus(orderNo); + if (result.status === 'not_found') { + throw new HttpException('订单不存在', HttpStatus.NOT_FOUND); + } + return result; + } + + @Post('gateway/close/:gatewayOrderId') + @ApiOperation({ summary: '关闭未支付订单' }) + async closeOrder(@Param('gatewayOrderId') gatewayOrderId: string) { + const ok = await this.gatewayPay.closeOrder(gatewayOrderId); + if (!ok) throw new HttpException('关闭订单失败', HttpStatus.BAD_REQUEST); + return { code: 0, message: '订单已关闭' }; + } } diff --git a/docs/progress.md b/docs/progress.md index b351e18..1dcced8 100755 --- a/docs/progress.md +++ b/docs/progress.md @@ -236,3 +236,39 @@ pm2 restart yuzhiran-api - ✅ 前端 75 静态页面全部生成成功 - ✅ Nginx 配置语法验证通过 - ✅ 主站 + 镜像站均已部署 + +--- + +## 2026-06-01 Session — 支付功能对齐网关文档 + 完善管理操作 + +### Changes Made +- **GatewayPayService 对齐网关文档**: + - `createOrder()` 新增 `remark` 参数(传入 `{"uid":userId,"oid":orderId}`),移除废弃的 `return_url` + - 新增 `closeOrder(gatewayOrderId)` — 调用 `POST /v1/pay/orders/{id}/close` + - 新增 `syncOrderStatus(orderNo)` — 从网关查询订单状态,若 `paid` 则自动更新本地 + 激活订阅 +- **OrdersService**: 下单时传入 `remark`(含 uid/oid),使 webhook 回调可正确匹配用户和订单 +- **PaymentController**: + - 新增 `POST /payment/gateway/sync/:orderNo` — 手动同步单笔订单状态 + - 新增 `POST /payment/gateway/close/:gatewayOrderId` — 关闭未支付订单 + - 清理未使用的 `PaymentService` 依赖 +- **管理后台订单页** (`admin/orders/page.tsx`): + - 待支付订单增加「同步状态」按钮 — 调用网关查询后更新 +- **文档**: `docs/progress.md` 支付部署架构更新为统一网关模式 + +### Payment 架构现状 +``` +下单: 会员页 → POST /orders/create → OrdersService → GatewayPayService → yzrcloud.cn 网关 +查询: POST /payment/gateway/sync/:orderNo → GatewayPayService.syncOrderStatus() +关闭: POST /payment/gateway/close/:id → GatewayPayService.closeOrder() +退款: POST /admin/orders/:orderNo/refund → GatewayPayService.refund() +回调: POST /payment/gateway/webhook → GatewayPayService.handleWebhook() +``` + +### Gaps Identified (远期) +- `payment.service.ts` (微信直连) 和 `alipay.service.ts` (支付宝直连) 为遗留代码,已被 GatewayPayService 全面替代,待确认无人依赖后可移除 +- 课程购买页 (`courses/[id]/client.tsx`) 仍直接调用 `wxpay/unified-order` 绕过 OrdersService,未传入 `remark` + +### Verification +- ✅ 后端 92 项测试全部通过 +- ✅ 后端 TypeScript 编译无错误 +- ✅ 前端 75 静态页面全部生成成功 diff --git a/frontend/src/app/admin/orders/page.tsx b/frontend/src/app/admin/orders/page.tsx index 595912c..444a083 100755 --- a/frontend/src/app/admin/orders/page.tsx +++ b/frontend/src/app/admin/orders/page.tsx @@ -92,6 +92,27 @@ export default function AdminOrders() { } } + async function handleSyncOrder(orderNo: string) { + try { + const res = await fetch(`${API_BASE}/payment/gateway/sync/${orderNo}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + if (data.updated) { + alert('订单状态已同步更新为已支付'); + } else { + alert(`状态同步完成:当前状态 ${data.status}`); + } + loadOrders(); + } else { + const err = await res.json(); + alert(err.message || '同步失败'); + } + } catch { alert('同步失败,请重试'); } + } + async function handleMarkPaid(orderNo: string) { if (!confirm('确认要将此订单标记为已支付吗?')) return; try { @@ -234,9 +255,14 @@ export default function AdminOrders() { )} {order.status === 'PENDING' && ( - + <> + + + )}