支付功能对齐网关文档 + 完善管理操作

- 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:
yuzhiran-dev
2026-06-05 13:19:44 +08:00
parent 538de50bb1
commit fb8152401b
5 changed files with 139 additions and 10 deletions
@@ -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) {
@@ -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<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) {
if (this.mock) {
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 { 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: '订单已关闭' };
}
}