支付功能对齐网关文档 + 完善管理操作
- GatewayPayService: 新增 remark 字段(传uid/oid), 移除废弃 return_url, 新增 closeOrder/syncOrderStatus - PaymentController: 新增 POST /payment/gateway/sync/:orderNo 和 /close/:id 端点 - Admin 订单页: 待支付订单增加「同步状态」按钮 - OrdersService: 下单时传入 remark 使 webhook 可匹配 - 清理 PaymentService 未使用依赖 - docs/progress.md 更新支付架构
This commit is contained in:
@@ -30,11 +30,13 @@ export class OrdersService {
|
|||||||
const subject = planLabels[data.planType] || '宇之然AI会员充值';
|
const subject = planLabels[data.planType] || '宇之然AI会员充值';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const remark = JSON.stringify({ uid: userId, oid: order.id });
|
||||||
const result = await this.gatewayPay.createOrder({
|
const result = await this.gatewayPay.createOrder({
|
||||||
merchantOrderId: orderNo,
|
merchantOrderId: orderNo,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
paymentMethod: channel === 'wxpay' ? 'wechat' : 'alipay',
|
paymentMethod: channel === 'wxpay' ? 'wechat' : 'alipay',
|
||||||
subject,
|
subject,
|
||||||
|
remark,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.gatewayOrderId) {
|
if (result.gatewayOrderId) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface GatewayCreateOrderParams {
|
|||||||
amount: number;
|
amount: number;
|
||||||
paymentMethod: 'alipay' | 'wechat';
|
paymentMethod: 'alipay' | 'wechat';
|
||||||
subject?: string;
|
subject?: string;
|
||||||
returnUrl?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GatewayCreateOrderResult {
|
interface GatewayCreateOrderResult {
|
||||||
@@ -37,7 +37,6 @@ export class GatewayPayService {
|
|||||||
private readonly apiSecret: string;
|
private readonly apiSecret: string;
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly notifyUrl: string;
|
private readonly notifyUrl: string;
|
||||||
private readonly returnUrl: string;
|
|
||||||
private readonly mock: boolean;
|
private readonly mock: boolean;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {
|
constructor(private prisma: PrismaService) {
|
||||||
@@ -45,7 +44,6 @@ export class GatewayPayService {
|
|||||||
this.apiSecret = process.env.GATEWAY_PAY_API_SECRET || '';
|
this.apiSecret = process.env.GATEWAY_PAY_API_SECRET || '';
|
||||||
this.baseUrl = (process.env.GATEWAY_PAY_BASE_URL || 'http://localhost:8100').replace(/\/+$/, '');
|
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.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;
|
this.mock = process.env.GATEWAY_PAY_MOCK === 'true' || !this.apiKey;
|
||||||
if (this.mock) {
|
if (this.mock) {
|
||||||
this.logger.warn('GATEWAY_PAY_MOCK=true,使用模拟支付模式');
|
this.logger.warn('GATEWAY_PAY_MOCK=true,使用模拟支付模式');
|
||||||
@@ -88,8 +86,10 @@ export class GatewayPayService {
|
|||||||
payment_method: params.paymentMethod,
|
payment_method: params.paymentMethod,
|
||||||
subject: params.subject || '宇之然AI会员充值',
|
subject: params.subject || '宇之然AI会员充值',
|
||||||
notify_url: this.notifyUrl,
|
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);
|
const auth = this.signRequest('POST', '/v1/pay/orders', body);
|
||||||
|
|
||||||
@@ -168,6 +168,55 @@ export class GatewayPayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async closeOrder(gatewayOrderId: string): Promise<boolean> {
|
||||||
|
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) {
|
async refund(merchantOrderId: string, amount?: number, reason?: string) {
|
||||||
if (this.mock) {
|
if (this.mock) {
|
||||||
this.logger.log(`模拟网关退款: ${merchantOrderId}`);
|
this.logger.log(`模拟网关退款: ${merchantOrderId}`);
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||||
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
|
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
|
||||||
import { PaymentService } from './payment.service';
|
|
||||||
import { GatewayPayService } from './gateway-pay.service';
|
import { GatewayPayService } from './gateway-pay.service';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ class RefundDto {
|
|||||||
@Controller('payment')
|
@Controller('payment')
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(
|
constructor(
|
||||||
private paymentService: PaymentService,
|
|
||||||
private gatewayPay: GatewayPayService,
|
private gatewayPay: GatewayPayService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
@@ -113,4 +111,22 @@ export class PaymentController {
|
|||||||
if (result.code === 'SUCCESS') return 'success';
|
if (result.code === 'SUCCESS') return 'success';
|
||||||
return 'fail';
|
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: '订单已关闭' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,3 +236,39 @@ pm2 restart yuzhiran-api
|
|||||||
- ✅ 前端 75 静态页面全部生成成功
|
- ✅ 前端 75 静态页面全部生成成功
|
||||||
- ✅ Nginx 配置语法验证通过
|
- ✅ 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 静态页面全部生成成功
|
||||||
|
|||||||
@@ -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) {
|
async function handleMarkPaid(orderNo: string) {
|
||||||
if (!confirm('确认要将此订单标记为已支付吗?')) return;
|
if (!confirm('确认要将此订单标记为已支付吗?')) return;
|
||||||
try {
|
try {
|
||||||
@@ -234,9 +255,14 @@ export default function AdminOrders() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{order.status === 'PENDING' && (
|
{order.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleSyncOrder(order.orderNo)} className="text-xs text-blue-600 hover:text-blue-800">
|
||||||
|
同步状态
|
||||||
|
</button>
|
||||||
<button onClick={() => handleMarkPaid(order.orderNo)} className="text-xs text-green-600 hover:text-green-800">
|
<button onClick={() => handleMarkPaid(order.orderNo)} className="text-xs text-green-600 hover:text-green-800">
|
||||||
标记已付
|
标记已付
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user