Files
ai-learning-platform/backend/src/modules/payment/gateway-pay.service.ts
T
yuzhiran-dev 0f215d2aad P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善
- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日
- 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块
- 导航: 主菜单隐藏企业版/社区(URL 可访问)
- 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写
- 工具页: affiliateLink 绿色推荐 Badge
- SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127
- 沙盒: 新增编程导师场景(苏格拉底教学法)
- 练习系统: Practices 多场景练习(含结构化评分)
- 技能广场: 6 个付费 Skill(标题大师/回款助手等)
- 管理后台: Models/Posts/Practices CRUD 页面
- 文档: README + progress.md 全面更新;AGENTS.md 同步定位
- 清理: .env.example 移除;tsbuildinfo gitignore
2026-06-18 18:14:07 +08:00

386 lines
13 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import * as crypto from 'crypto';
interface GatewayCreateOrderParams {
merchantOrderId: string;
amount: number;
paymentMethod: 'alipay' | 'wechat';
subject?: string;
remark?: string;
}
interface GatewayCreateOrderResult {
gatewayOrderId: string;
merchantOrderId: string;
amount: number;
paymentMethod: string;
payUrl?: string;
qrcode?: string;
status: string;
}
export interface GatewayQueryResult {
gatewayOrderId: string;
merchantOrderId: string;
amount: number;
paymentMethod: string;
status: string;
transactionId?: string;
paidAt?: string;
}
@Injectable()
export class GatewayPayService {
private readonly logger = new Logger(GatewayPayService.name);
private readonly apiKey: string;
private readonly apiSecret: string;
private readonly baseUrl: string;
private readonly notifyUrl: string;
private readonly mock: boolean;
constructor(private prisma: PrismaService) {
this.apiKey = process.env.GATEWAY_PAY_API_KEY || '';
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.mock = process.env.GATEWAY_PAY_MOCK === 'true' || !this.apiKey;
if (this.mock) {
this.logger.warn('GATEWAY_PAY_MOCK=true,使用模拟支付模式');
}
}
private signRequest(method: string, path: string, body: Record<string, any>): string {
const timestamp = String(Math.floor(Date.now() / 1000));
const bodySha256 = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
const signStr = `${method}\n${path}\n${timestamp}\n${bodySha256}`;
const signature = crypto.createHmac('sha256', this.apiSecret).update(signStr).digest('hex');
return `PAY ${this.apiKey}:${timestamp}:${signature}`;
}
private signGetRequest(method: string, path: string): string {
const timestamp = String(Math.floor(Date.now() / 1000));
const emptyHash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
const signStr = `${method}\n${path}\n${timestamp}\n${emptyHash}`;
const signature = crypto.createHmac('sha256', this.apiSecret).update(signStr).digest('hex');
return `PAY ${this.apiKey}:${timestamp}:${signature}`;
}
async createOrder(params: GatewayCreateOrderParams): Promise<GatewayCreateOrderResult> {
if (this.mock) {
this.logger.log(`模拟下单: ${params.merchantOrderId}, ${params.paymentMethod}`);
return {
gatewayOrderId: `mock_${Date.now()}`,
merchantOrderId: params.merchantOrderId,
amount: params.amount,
paymentMethod: params.paymentMethod,
payUrl: params.paymentMethod === 'alipay' ? 'mock://pay' : undefined,
qrcode: params.paymentMethod === 'wechat' ? 'mock://pay' : undefined,
status: 'pending',
};
}
const body: Record<string, any> = {
merchant_order_id: params.merchantOrderId,
amount: params.amount,
payment_method: params.paymentMethod,
subject: params.subject || '宇之然AI会员充值',
notify_url: this.notifyUrl,
};
if (params.remark) {
body.remark = params.remark;
}
const auth = this.signRequest('POST', '/v1/pay/orders', body);
try {
const res = await fetch(`${this.baseUrl}/v1/pay/orders`, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '网关下单失败');
this.logger.log(`网关下单成功: ${params.merchantOrderId}, gatewayId: ${json.data.gateway_order_id}`);
return {
gatewayOrderId: json.data.gateway_order_id,
merchantOrderId: json.data.merchant_order_id,
amount: json.data.amount,
paymentMethod: json.data.payment_method,
payUrl: json.data.pay_url,
qrcode: json.data.qrcode,
status: json.data.status,
};
} catch (err: any) {
this.logger.error(`网关下单失败: ${err.message},使用模拟模式`);
return {
gatewayOrderId: `mock_${Date.now()}`,
merchantOrderId: params.merchantOrderId,
amount: params.amount,
paymentMethod: params.paymentMethod,
payUrl: params.paymentMethod === 'alipay' ? 'mock://pay' : undefined,
qrcode: params.paymentMethod === 'wechat' ? 'mock://pay' : undefined,
status: 'pending',
};
}
}
async queryOrder(gatewayOrderId: string): Promise<GatewayQueryResult> {
if (this.mock || gatewayOrderId.startsWith('mock_')) {
const order = await this.prisma.order.findFirst({ where: { gatewayOrderId } });
return {
gatewayOrderId,
merchantOrderId: order?.orderNo || '',
amount: order?.amount || 0,
paymentMethod: order?.payChannel || 'alipay',
status: order?.status === 'PAID' ? 'paid' : 'pending',
transactionId: order?.transactionId || undefined,
};
}
const path = `/v1/pay/orders/${gatewayOrderId}`;
const auth = this.signGetRequest('GET', path);
try {
const res = await fetch(`${this.baseUrl}${path}`, {
headers: { Authorization: auth },
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '查询失败');
return {
gatewayOrderId: json.data.gateway_order_id,
merchantOrderId: json.data.merchant_order_id,
amount: json.data.amount,
paymentMethod: json.data.payment_method,
status: json.data.status,
transactionId: json.data.transaction_id,
paidAt: json.data.paid_at,
};
} catch (err: any) {
this.logger.error(`网关查询失败: ${err.message}`);
return {
gatewayOrderId,
merchantOrderId: '',
amount: 0,
paymentMethod: 'alipay',
status: 'unknown',
};
}
}
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}`);
await this.prisma.order.update({
where: { orderNo: merchantOrderId },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
return { code: 0, message: '模拟退款成功' };
}
const body: Record<string, any> = {
merchant_order_id: merchantOrderId,
reason: reason || '用户申请退款',
};
if (amount) body.amount = amount;
const auth = this.signRequest('POST', '/v1/pay/refunds', body);
try {
const res = await fetch(`${this.baseUrl}/v1/pay/refunds`, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.code === 0) {
await this.prisma.order.update({
where: { orderNo: merchantOrderId },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
}
return json;
} catch (err: any) {
this.logger.error(`网关退款失败: ${err.message}`);
throw err;
}
}
async handleWebhook(body: any): Promise<{ code: string; message: string }> {
const event = body?.event;
const data = body?.data;
if (!event || !data) {
return { code: 'FAIL', message: '无效的webhook数据' };
}
this.logger.log(`网关webhook事件: ${event}`);
if (event === 'recharge.completed') {
const merchantOrderId = data.merchant_order_id;
const gatewayOrderId = data.order_id;
const transactionId = data.transaction_id;
if (!merchantOrderId) {
return { code: 'FAIL', message: '缺少商户订单号' };
}
const order = await this.prisma.order.findUnique({
where: { orderNo: merchantOrderId },
include: { user: true },
});
if (!order) {
this.logger.warn(`webhook订单不存在: ${merchantOrderId}`);
return { code: 'FAIL', message: '订单不存在' };
}
if (order.status === 'PAID') {
this.logger.log(`webhook订单已支付,跳过: ${merchantOrderId}`);
return { code: 'SUCCESS', message: '订单已处理' };
}
await this.prisma.order.update({
where: { id: order.id },
data: {
status: 'PAID',
paidAt: new Date(),
transactionId: transactionId || order.transactionId,
gatewayOrderId: gatewayOrderId || order.gatewayOrderId,
payChannel: data.payment_method === 'alipay' ? 'alipay' : 'wxpay',
},
});
await this.activateSubscription(order.id);
return { code: 'SUCCESS', message: '支付成功' };
}
if (event === 'order.refunded') {
const merchantOrderId = data.merchant_order_id;
if (merchantOrderId) {
await this.prisma.order.update({
where: { orderNo: merchantOrderId },
data: { status: 'REFUNDED' },
}).catch(() => {});
}
return { code: 'SUCCESS', message: '退款处理成功' };
}
return { code: 'SUCCESS', message: '事件已接收' };
}
private async activateSubscription(orderId: number) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { user: true },
});
if (!order) return;
if (order.planType === 'SKILL' && order.metadata) {
const meta = JSON.parse(order.metadata);
if (meta.skillId) {
await this.prisma.userSkill.upsert({
where: { userId_skillId: { userId: order.userId, skillId: meta.skillId } },
update: { orderId: order.id },
create: { userId: order.userId, skillId: meta.skillId, orderId: order.id },
});
this.logger.log(`网关支付激活技能: 用户${order.userId}, 技能${meta.skillId}`);
}
return;
}
if (order.planType !== 'MONTHLY' && order.planType !== 'YEARLY') return;
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
let subscriptionEndDate: Date;
const existingSub = await this.prisma.subscription.findFirst({
where: {
userId: order.userId,
status: 'ACTIVE',
endDate: { gt: now },
},
});
if (existingSub) {
subscriptionEndDate = new Date(existingSub.endDate.getTime() + durationDays * 24 * 60 * 60 * 1000);
await this.prisma.subscription.update({
where: { id: existingSub.id },
data: { endDate: subscriptionEndDate },
});
} else {
subscriptionEndDate = new Date(now);
subscriptionEndDate.setDate(subscriptionEndDate.getDate() + durationDays);
await this.prisma.subscription.create({
data: {
userId: order.userId,
plan: order.planType as any,
startDate: now,
endDate: subscriptionEndDate,
status: 'ACTIVE',
},
});
}
await this.prisma.user.update({
where: { id: order.userId },
data: {
memberPlan: order.planType as any,
memberExpire: subscriptionEndDate,
},
});
this.logger.log(`网关支付激活订阅: 用户${order.userId}, 类型${order.planType}, 到期${subscriptionEndDate}`);
}
}