注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + AI 助手 Tool Calling 重构

- Prisma User 模型新增 username 字段(唯一索引)
- 注册先查重复再创建,返回友好中文提示(非 500)
- 登录支持用户名/手机号/邮箱三种方式
- 前端注册表单增加用户名输入框,预校验 2-20 位格式
- 新增 scripts/deploy.sh:一键构建并部署主站+镜像站+重启后端+重载 Nginx
- 镜像站 www.yuzhiran.com.cn Nginx 配置与主站同步
- AI 助手架构升级:用户端/管理后台均采用完整 Tool Calling 架构
- 新增 UserAiAssistantService(18 工具)+ AiAssistantController
- admin 助手新增 search + mark-all-notifications-read 工具
- 修复注册 500 错误:catch Prisma P2002 → BadRequestException
- Baidu Analytics Script 注入 root layout
This commit is contained in:
yuzhiran-dev
2026-06-01 23:14:42 +08:00
parent 6f3fe50ee0
commit 538de50bb1
329 changed files with 8777 additions and 868 deletions
@@ -0,0 +1,321 @@
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;
returnUrl?: 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 returnUrl: 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.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,使用模拟支付模式');
}
}
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,
return_url: params.returnUrl || this.returnUrl,
};
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 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 || (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}`);
}
}