注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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
Regular → Executable
View File
Regular → Executable
View File
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['dist', 'node_modules'],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': 'off',
},
};
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Generated Regular → Executable
+1466 -103
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+5 -1
View File
@@ -4,7 +4,7 @@
"description": "宇之然 AI - 后端 API 服务",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"build": "nest build --tsc",
"start": "nest start",
"dev": "nest start --watch",
"start:prod": "node dist/main",
@@ -38,6 +38,7 @@
"@nestjs/serve-static": "^5.0.5",
"@nestjs/swagger": "^11.4.2",
"@prisma/client": "^5.22.0",
"alipay-sdk": "^4.14.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
@@ -62,6 +63,9 @@
"@types/node": "^20.19.40",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"eslint": "^8.57.1",
"jest": "^30.4.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
Regular → Executable
+13 -10
View File
@@ -34,6 +34,7 @@ enum MemberPlan {
model User {
id Int @id @default(autoincrement())
username String? @unique
phone String? @unique
email String? @unique
passwordHash String?
@@ -303,16 +304,18 @@ model SandboxSession {
}
model Order {
id Int @id @default(autoincrement())
orderNo String @unique
userId Int
amount Float
planType String
status OrderStatus @default(PENDING)
payChannel String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
orderNo String @unique
userId Int
amount Float
planType String
status OrderStatus @default(PENDING)
payChannel String?
transactionId String?
gatewayOrderId String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
Vendored Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+7 -20
View File
@@ -21,31 +21,18 @@ import { EnterpriseModule } from './modules/enterprise/enterprise.module';
import { NotificationModule } from './modules/notifications/notification.module';
import { LearningModule } from './modules/learning/learning.module';
import { SkillsModule } from './modules/skills/skills.module';
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
UsersModule,
CoursesModule,
ContentsModule,
PromptsModule,
ToolsModule,
SandboxModule,
OrdersModule,
AdminModule,
PaymentModule,
CategoriesModule,
DashboardModule,
SearchModule,
ModelsModule,
UploadModule,
CommunityModule,
EnterpriseModule,
NotificationModule,
LearningModule,
SkillsModule,
AuthModule, UsersModule, CoursesModule, ContentsModule,
PromptsModule, ToolsModule, SandboxModule, OrdersModule,
AdminModule, PaymentModule, CategoriesModule, DashboardModule,
SearchModule, ModelsModule, UploadModule, CommunityModule,
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
AiAssistantModule,
],
})
export class AppModule {}
View File
Regular → Executable
View File
View File
Regular → Executable
View File
+120 -7
View File
@@ -1,4 +1,4 @@
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param, HttpException, HttpStatus } from '@nestjs/common';
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param, Query, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminService } from './admin.service';
@@ -7,6 +7,7 @@ import { CoursesService } from '../courses/courses.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService } from '../ai/ai-gateway.service';
import { AdminAiAssistantService } from './ai-assistant.service';
import { GatewayPayService } from '../payment/gateway-pay.service';
@ApiTags('管理后台')
@Controller('admin')
@@ -17,6 +18,7 @@ export class AdminController {
private prisma: PrismaService,
private aiGateway: AIGatewayService,
private aiAssistant: AdminAiAssistantService,
private gatewayPay: GatewayPayService,
) {}
@Post('login')
@@ -34,13 +36,124 @@ export class AdminController {
@Get('orders')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orders(@Req() req: any) {
const items = await this.prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { user: { select: { id: true, nickname: true, phone: true } } },
async orders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('payChannel') payChannel?: string,
@Query('planType') planType?: string,
) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const skip = (p - 1) * ps;
const where: any = {};
if (status) where.status = status;
if (payChannel) where.payChannel = payChannel;
if (planType) where.planType = planType;
if (search) {
where.OR = [
{ orderNo: { contains: search } },
{ user: { nickname: { contains: search } } },
{ user: { phone: { contains: search } } },
];
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: ps,
include: { user: { select: { id: true, nickname: true, phone: true } } },
}),
this.prisma.order.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('orders/:orderNo')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orderDetail(@Param('orderNo') orderNo: string) {
const order = await this.prisma.order.findUnique({
where: { orderNo },
include: { user: { select: { id: true, nickname: true, phone: true, email: true, memberPlan: true, memberExpire: true } } },
});
return { items };
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
return order;
}
@Post('orders/:orderNo/refund')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orderRefund(@Param('orderNo') orderNo: string, @Body() body: { amount?: number; reason?: string }) {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.status !== 'PAID') throw new HttpException('订单未支付,无法退款', HttpStatus.BAD_REQUEST);
return this.gatewayPay.refund(orderNo, body.amount || order.amount, body.reason);
}
@Post('orders/:orderNo/mark-paid')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async markOrderPaid(@Param('orderNo') orderNo: string) {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.status !== 'PENDING') throw new HttpException('只能标记待支付订单为已支付', HttpStatus.BAD_REQUEST);
await this.prisma.order.update({
where: { id: order.id },
data: { status: 'PAID', paidAt: new Date(), transactionId: `MANUAL_${Date.now()}` },
});
// 激活订阅
const mockOrder = { planType: order.planType, amount: order.amount };
if (mockOrder.planType === 'MONTHLY' || mockOrder.planType === 'YEARLY') {
const durationDays = mockOrder.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
let endDate: Date;
const existingSub = await this.prisma.subscription.findFirst({
where: { userId: order.userId, status: 'ACTIVE', endDate: { gt: now } },
});
if (existingSub) {
endDate = new Date(existingSub.endDate.getTime() + durationDays * 24 * 60 * 60 * 1000);
await this.prisma.subscription.update({ where: { id: existingSub.id }, data: { endDate } });
} else {
endDate = new Date(now);
endDate.setDate(endDate.getDate() + durationDays);
await this.prisma.subscription.create({
data: { userId: order.userId, plan: order.planType as any, startDate: now, endDate, status: 'ACTIVE' },
});
}
await this.prisma.user.update({
where: { id: order.userId },
data: { memberPlan: order.planType as any, memberExpire: endDate },
});
}
return { ok: true, message: '订单已标记为已支付' };
}
@Get('orders/:orderNo/query')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async queryOrderPayment(@Param('orderNo') orderNo: string) {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.gatewayOrderId && !order.gatewayOrderId.startsWith('mock_')) {
return this.gatewayPay.queryOrder(order.gatewayOrderId);
}
return {
outTradeNo: orderNo,
localStatus: order.status,
amount: order.amount,
planType: order.planType,
};
}
@Put('courses/:id/chapters')
View File
+2 -1
View File
@@ -12,9 +12,10 @@ import { PublicController } from './public.controller';
import { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module';
import { AIModule } from '../ai/ai.module';
import { PaymentModule } from '../payment/payment.module';
@Module({
imports: [CoursesModule, AuthModule, AIModule],
imports: [CoursesModule, AuthModule, AIModule, PaymentModule],
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController, PublicController],
providers: [AdminService, AdminGuard, AdminAiAssistantService],
exports: [AdminService],
View File
+35
View File
@@ -208,6 +208,15 @@ export class AdminAiAssistantService {
{
name: 'delete-admin', description: '禁用一个管理员账号', parameters: { id: { type: 'number', description: '管理员ID' } },
},
// ---- 全局搜索 ----
{
name: 'search', description: '全局搜索用户、订单、课程、内容等',
parameters: { q: { type: 'string', description: '搜索关键词(必填)' }, type: { type: 'string', enum: ['all', 'users', 'orders', 'courses', 'contents'], description: '搜索范围(可选,默认全部)' } },
},
// ---- 通知批量操作 ----
{
name: 'mark-all-notifications-read', description: '将所有系统通知标记为已读', parameters: {},
},
// ---- 企业版 ----
{
name: 'get-enterprise-orgs', description: '查看所有企业组织', parameters: {},
@@ -383,6 +392,32 @@ export class AdminAiAssistantService {
return { success: true, data: { id: call.params.id, status: newPromptStatus }, summary: `提示词 ${call.params.id} 状态已切换为 ${newPromptStatus}` };
}
case 'search': {
const q = call.params.q;
if (!q) throw new Error('请输入搜索关键词');
const results: any[] = [];
if (call.params.type === 'all' || call.params.type === 'users') {
const users = await this.prisma.user.findMany({ where: { deletedAt: null, OR: [{ nickname: { contains: q } }, { phone: { contains: q } }, { email: { contains: q } }] }, select: { id: true, nickname: true, phone: true, email: true, status: true }, take: 20 });
results.push(...users.map(u => ({ ...u, _type: 'user' })));
}
if (call.params.type === 'all' || call.params.type === 'orders') {
const orders = await this.prisma.order.findMany({ where: { OR: [{ orderNo: { contains: q } }] }, take: 20, orderBy: { createdAt: 'desc' } });
results.push(...orders.map(o => ({ ...o, _type: 'order' })));
}
if (call.params.type === 'all' || call.params.type === 'courses') {
const courses = await this.prisma.course.findMany({ where: { deletedAt: null, OR: [{ title: { contains: q } }, { description: { contains: q } }] }, select: { id: true, title: true, status: true }, take: 20 });
results.push(...courses.map(c => ({ ...c, _type: 'course' })));
}
if (call.params.type === 'all' || call.params.type === 'contents') {
const contents = await this.prisma.content.findMany({ where: { deletedAt: null, OR: [{ title: { contains: q } }, { summary: { contains: q } }] }, select: { id: true, title: true, status: true }, take: 20 });
results.push(...contents.map(c => ({ ...c, _type: 'content' })));
}
return { success: true, data: { results, total: results.length }, summary: `找到 ${results.length} 条结果,共 ${results.filter(r => r._type === 'user').length} 个用户、${results.filter(r => r._type === 'order').length} 个订单、${results.filter(r => r._type === 'course').length} 个课程、${results.filter(r => r._type === 'content').length} 个内容` };
}
case 'mark-all-notifications-read': {
await this.prisma.notification.updateMany({ where: { isRead: false }, data: { isRead: true } });
return { success: true, data: {}, summary: '所有通知已标记为已读' };
}
case 'get-enterprise-orgs': {
const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个企业组织` };
View File
View File
View File
View File
View File
View File
@@ -0,0 +1,73 @@
import { Controller, Post, Body, Req, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AIGatewayService } from '../ai/ai-gateway.service';
import { UserAiAssistantService } from './ai-assistant.service';
@ApiTags('AI 助手')
@Controller('ai-assistant')
export class AiAssistantController {
constructor(
private aiGateway: AIGatewayService,
private aiAssistant: UserAiAssistantService,
) {}
@Post('chat')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async chat(
@Req() req: any,
@Body() body: { messages: { role: string; content: string }[]; pageContext?: string },
) {
try {
const { messages, pageContext } = body;
const toolsDesc = this.aiAssistant.getToolsDescription();
const systemContent = `你是宇之然 AI 学习与实践平台的智能助手,帮助用户了解和使用平台的所有功能。
你可以回答用户的问题,也可以调用工具来执行操作(如搜索内容、查看学习分析、管理通知等)。
${pageContext ? `当前页面:${pageContext}\n\n` : ''}可用工具列表(需要执行操作时,返回 JSON{"tool":"工具名","params":{...},"description":"简述"}):
${toolsDesc}
注意:
1. 当用户请求执行具体操作(如搜索、查看数据、管理通知)时,返回一个 JSON 工具调用。
2. 普通对话问题直接文字回答,不需要返回 JSON。
3. 每次只需要返回一个 JSON 工具调用,不要包含多余文字。
4. 对于 navigate 工具,前端会自动跳转,你只需要返回 JSON 即可。`;
const apiMessages = [
{ role: 'system' as const, content: systemContent },
...messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
];
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.7, max_tokens: 2000 });
return { reply };
} catch (err: any) {
throw new HttpException(err.message || 'AI 助手请求失败', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Post('action')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async action(
@Req() req: any,
@Body() body: { tool: string; params: Record<string, any>; messages: { role: string; content: string }[] },
) {
const userId = req.user.userId;
const result = await this.aiAssistant.execute({ tool: body.tool, params: body.params }, userId);
try {
const apiMessages = [
{ role: 'system' as const, content: '你是一个AI助手,下面是用户请求的执行结果,请用自然语言简洁总结给用户。' },
...body.messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
{ role: 'assistant' as const, content: `【工具执行结果】\n工具: ${body.tool}\n参数: ${JSON.stringify(body.params)}\n结果: ${result.summary}\n数据: ${JSON.stringify(result.data)}` },
];
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.3, max_tokens: 1000 });
return { reply, result };
} catch {
return { reply: result.summary, result };
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AiAssistantController } from './ai-assistant.controller';
import { UserAiAssistantService } from './ai-assistant.service';
import { SkillsModule } from '../skills/skills.module';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [SkillsModule, AIModule],
controllers: [AiAssistantController],
providers: [UserAiAssistantService],
exports: [UserAiAssistantService],
})
export class AiAssistantModule {}
@@ -0,0 +1,431 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { SkillsService } from '../skills/skills.service';
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, any>;
}
export interface ToolCall {
tool: string;
params: Record<string, any>;
description?: string;
}
export interface ToolResult {
success: boolean;
data: any;
summary: string;
}
const KNOWLEDGE_DOMAINS = [
{ id: 'ai-basics', name: 'AI 基础知识', keywords: ['AI', '人工智能', '大模型', 'chatgpt', 'gpt', '大语言模型', 'llm', '深度学习', '神经网络', 'machine learning', '机器学习'] },
{ id: 'prompt-engineering', name: '提示词工程', keywords: ['提示词', 'prompt', 'system prompt', 'role', 'few-shot', 'chain-of-thought', 'cot'] },
{ id: 'programming', name: '编程开发', keywords: ['python', 'javascript', 'typescript', 'java', '代码', '函数', '算法', 'debug', 'bug', '编程', '开发', 'react', 'vue', 'node'] },
{ id: 'writing', name: '写作创作', keywords: ['写作', '文章', '文案', '润色', '作文', '创作', '故事', '小说', '博客'] },
{ id: 'english', name: '英语学习', keywords: ['英语', 'english', '翻译', '语法', 'grammar', 'vocabulary', '口语', '写作', '阅读'] },
{ id: 'data-science', name: '数据分析', keywords: ['数据', '分析', '统计', '图表', '可视化', 'sql', 'excel', 'pandas', 'numpy', '数据分析'] },
{ id: 'office', name: '办公效率', keywords: ['ppt', 'excel', 'word', '办公', '邮件', '报告', '文档', '会议', '总结'] },
{ id: 'career', name: '职业发展', keywords: ['简历', '面试', '求职', '职业', '工作', '升职', '薪资'] },
];
const LEARNING_PATH_STAGES = [
{
id: 'basics', title: '认识大模型',
tasks: [
{ label: '了解 AI 基本概念', keyword: '人工智能' },
{ label: '认识大语言模型', keyword: '大模型' },
{ label: '体验 AI 对话', keyword: '' },
],
},
{
id: 'prompt', title: '提示词工程',
tasks: [
{ label: '了解提示词基础', keyword: '提示词' },
{ label: '练习提示词编写', keyword: '' },
{ label: '保存优质提示词', keyword: '' },
],
},
{
id: 'advanced', title: '模型微调与高级应用',
tasks: [
{ label: '了解模型微调', keyword: '微调' },
{ label: '了解 RAG', keyword: 'rag' },
{ label: '了解 Function Calling', keyword: 'function calling' },
],
},
{
id: 'agent', title: 'Agent 开发',
tasks: [
{ label: '了解 AI Agent', keyword: 'agent' },
{ label: '学习工具调用', keyword: 'tool' },
{ label: '实践项目', keyword: '' },
],
},
];
@Injectable()
export class UserAiAssistantService {
constructor(
private prisma: PrismaService,
private skillsService: SkillsService,
) {}
private userTools(): ToolDefinition[] {
return [
{
name: 'navigate',
description: '跳转到网站的某个页面',
parameters: { path: { type: 'string', description: '页面路径,如 /sandbox、/skills、/my/member、/learning、/models、/prompts、/compare、/community、/courses、/my、/about' } },
},
{
name: 'search',
description: '搜索平台上的课程、提示词、工具、文章等内容',
parameters: { q: { type: 'string', description: '搜索关键词(必填)' }, type: { type: 'string', enum: ['all', 'courses', 'prompts', 'tools', 'contents'], description: '搜索类型(可选,默认全部)' } },
},
{
name: 'list-skills',
description: '浏览技能列表,可按分类、难度、关键词过滤',
parameters: { category: { type: 'string', description: '分类(可选,如 basic/technical/creative' }, difficulty: { type: 'string', description: '难度(可选,如 beginner/intermediate/advanced' }, search: { type: 'string', description: '搜索关键词(可选)' } },
},
{
name: 'get-skill',
description: '查看某个技能的详细信息',
parameters: { id: { type: 'string', description: '技能ID(必填),如 coding、writing、english、data-analysis' } },
},
{
name: 'get-skill-categories',
description: '查看所有技能分类',
parameters: {},
},
{
name: 'get-learning-analytics',
description: '查看学情分析(知识领域掌握度),需要登录',
parameters: {},
},
{
name: 'get-learning-path',
description: '查看学习路径进度,需要登录',
parameters: {},
},
{
name: 'list-notifications',
description: '查看我的通知列表,需要登录',
parameters: { page: { type: 'number', description: '页码(可选)' }, pageSize: { type: 'number', description: '每页数量(可选)' } },
},
{
name: 'get-unread-count',
description: '查看未读通知数量,需要登录',
parameters: {},
},
{
name: 'mark-notification-read',
description: '标记某条通知为已读,需要登录',
parameters: { id: { type: 'number', description: '通知ID(必填)' } },
},
{
name: 'mark-all-read',
description: '将所有通知标记为已读,需要登录',
parameters: {},
},
{
name: 'get-profile',
description: '查看我的个人信息(昵称、手机号、会员状态等),需要登录',
parameters: {},
},
{
name: 'list-models',
description: '查看平台支持的AI模型列表',
parameters: {},
},
{
name: 'list-prompts',
description: '浏览提示词列表',
parameters: {},
},
{
name: 'list-courses',
description: '浏览课程列表',
parameters: {},
},
{
name: 'list-user-orders',
description: '查看我的订单记录,需要登录',
parameters: {},
},
{
name: 'get-current-subscription',
description: '查看当前会员订阅信息,需要登录',
parameters: {},
},
];
}
getAvailableTools(): ToolDefinition[] {
return this.userTools();
}
getToolsDescription(): string {
return this.getAvailableTools().map(t => {
const params = Object.entries(t.parameters)
.map(([k, v]: any) => ` - ${k}: ${v.type}${v.description ? ' (' + v.description + ')' : ''}${v.enum ? ' [' + v.enum.join('|') + ']' : ''}`)
.join('\n');
return `- **${t.name}**: ${t.description}\n${params}`;
}).join('\n\n');
}
async execute(call: ToolCall, userId?: number): Promise<ToolResult> {
try {
switch (call.tool) {
case 'navigate':
return { success: true, data: { path: call.params.path }, summary: `跳转到 ${call.params.path}` };
case 'search': {
const { q, type } = call.params;
if (!q) throw new Error('请输入搜索关键词');
const results = await this.search(q, type || 'all');
return { success: true, data: results, summary: `找到 ${results.total} 条结果:${results.results.slice(0, 5).map(r => r.title).join('、')}` };
}
case 'list-skills': {
const skills = await this.skillsService.findAll(call.params);
return { success: true, data: skills, summary: `${skills.total} 个技能:${skills.items.map(s => s.name).join('、')}` };
}
case 'get-skill': {
const skill = await this.skillsService.findById(call.params.id);
if (!skill) throw new Error(`技能 ${call.params.id} 不存在`);
return { success: true, data: skill, summary: `技能「${skill.name}」(${skill.category} - ${skill.difficulty})${skill.description}` };
}
case 'get-skill-categories': {
const cats = await this.skillsService.getCategories();
return { success: true, data: { categories: cats }, summary: `${cats.length} 个分类:${cats.map(c => c.name).join('、')}` };
}
case 'get-learning-analytics': {
if (!userId) throw new Error('需要登录');
const analytics = await this.getLearningAnalytics(userId);
return { success: true, data: analytics, summary: `总对话 ${analytics.totalSessions} 次,${analytics.domains.filter(d => d.weak).length} 个薄弱领域,建议从 ${analytics.recommendations[0]?.title || '探索更多'} 开始` };
}
case 'get-learning-path': {
if (!userId) throw new Error('需要登录');
const path = await this.getLearningPath(userId);
return { success: true, data: path, summary: `学习路径共 ${path.length} 个阶段:${path.map(s => `${s.title}(${s.progress}%)`).join('、')}` };
}
case 'list-notifications': {
if (!userId) throw new Error('需要登录');
const page = call.params.page || 1;
const pageSize = call.params.pageSize || 20;
const [items, total] = await Promise.all([
this.prisma.notification.findMany({
where: { userId }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize,
}),
this.prisma.notification.count({ where: { userId } }),
]);
return { success: true, data: { items, total, page, pageSize }, summary: `${total} 条通知` };
}
case 'get-unread-count': {
if (!userId) throw new Error('需要登录');
const count = await this.prisma.notification.count({ where: { userId, isRead: false } });
return { success: true, data: { count }, summary: `${count} 条未读通知` };
}
case 'mark-notification-read': {
if (!userId) throw new Error('需要登录');
await this.prisma.notification.updateMany({ where: { id: call.params.id, userId }, data: { isRead: true } });
return { success: true, data: {}, summary: `通知 ${call.params.id} 已标记为已读` };
}
case 'mark-all-read': {
if (!userId) throw new Error('需要登录');
await this.prisma.notification.updateMany({ where: { userId, isRead: false }, data: { isRead: true } });
return { success: true, data: {}, summary: '所有通知已标记为已读' };
}
case 'get-profile': {
if (!userId) throw new Error('需要登录');
const profile = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, phone: true, email: true, nickname: true, avatar: true, status: true, memberPlan: true, memberExpire: true, sandboxDaily: true, createdAt: true },
});
if (!profile) throw new Error('用户不存在');
return { success: true, data: profile, summary: `用户 ${profile.nickname || profile.phone || profile.email}${profile.memberPlan !== 'FREE' ? ',会员: ' + profile.memberPlan : ',当前为免费用户'}${profile.memberExpire ? ',到期: ' + profile.memberExpire.toISOString().slice(0, 10) : ''}` };
}
case 'list-models': {
const models = await this.prisma.aiModel.findMany({ where: { status: 'ACTIVE' }, orderBy: { sortOrder: 'asc' } });
if (models.length === 0) {
return { success: true, data: { models: [] }, summary: '目前没有可用的AI模型,请稍后再试' };
}
return { success: true, data: { models }, summary: `${models.length} 个模型:${models.map(m => m.name).join('、')}` };
}
case 'list-prompts': {
const items = await this.prisma.prompt.findMany({
where: { deletedAt: null, status: 'PUBLISHED' },
take: 50, orderBy: { createdAt: 'desc' },
select: { id: true, title: true, description: true, tags: true },
});
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个提示词:${items.map(p => p.title).join('、')}` };
}
case 'list-courses': {
const items = await this.prisma.course.findMany({
where: { deletedAt: null, status: 'PUBLISHED' },
take: 50, orderBy: { createdAt: 'desc' },
select: { id: true, title: true, description: true, price: true, isFree: true },
});
const freeCount = items.filter(c => c.isFree).length;
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个课程(${freeCount} 个免费):${items.map(c => c.title).join('、')}` };
}
case 'list-user-orders': {
if (!userId) throw new Error('需要登录');
const items = await this.prisma.order.findMany({
where: { userId },
take: 50, orderBy: { createdAt: 'desc' },
});
const paidAmount = items.filter(o => o.status === 'PAID').reduce((s, o) => s + o.amount, 0);
return { success: true, data: { items, total: items.length, paidAmount }, summary: `${items.length} 笔订单,已支付总额 ¥${paidAmount}` };
}
case 'get-current-subscription': {
if (!userId) throw new Error('需要登录');
const now = new Date();
const sub = await this.prisma.subscription.findFirst({
where: { userId, status: 'ACTIVE', endDate: { gt: now } },
orderBy: { endDate: 'desc' },
});
if (!sub) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { memberPlan: true, memberExpire: true } });
if (!user) throw new Error('用户不存在');
return { success: true, data: null, summary: user.memberPlan !== 'FREE' ? `会员状态: ${user.memberPlan},到期 ${user.memberExpire?.toISOString().slice(0, 10) || '未知'}` : '当前为免费用户,暂无有效订阅' };
}
return { success: true, data: sub, summary: `当前订阅: ${sub.plan},到期 ${sub.endDate.toISOString().slice(0, 10)},剩余 ${Math.ceil((sub.endDate.getTime() - Date.now()) / 86400000)}` };
}
default:
throw new Error(`未知工具: ${call.tool}`);
}
} catch (err: any) {
return { success: false, data: null, summary: `执行失败: ${err.message}` };
}
}
private async getLearningAnalytics(userId: number) {
const sessions = await this.prisma.sandboxSession.findMany({
where: { userId }, orderBy: { createdAt: 'desc' }, take: 100,
});
const domainCounts: Record<string, number> = {};
const domainDates: Record<string, string> = {};
const totalSessions = sessions.length;
for (const d of KNOWLEDGE_DOMAINS) domainCounts[d.id] = 0;
for (const session of sessions) {
const searchText = `${session.title} ${session.messages || ''}`.toLowerCase();
for (const domain of KNOWLEDGE_DOMAINS) {
if (domain.keywords.some(kw => searchText.includes(kw))) {
domainCounts[domain.id] = (domainCounts[domain.id] || 0) + 1;
if (!domainDates[domain.id] || session.createdAt.toISOString() > domainDates[domain.id]) {
domainDates[domain.id] = session.createdAt.toISOString();
}
}
}
}
const domains = KNOWLEDGE_DOMAINS.map(d => {
const count = domainCounts[d.id] || 0;
const mastery = Math.min(Math.round((count / Math.max(totalSessions * 0.3, 1)) * 100), 100);
return { id: d.id, name: d.name, sessionCount: count, mastery, lastActive: domainDates[d.id] || null, weak: mastery < 30 };
});
const weakDomains = domains.filter(d => d.weak);
const recommendations = weakDomains.length > 0
? weakDomains.slice(0, 3).flatMap(d => [
{ title: `了解 ${d.name}`, url: '/sandbox' },
])
: [{ title: '探索更多知识领域', url: '/sandbox' }];
return { domains, totalSessions, weakDomains: weakDomains.map(d => d.name), recommendations };
}
private async getLearningPath(userId: number) {
const sessions = await this.prisma.sandboxSession.findMany({
where: { userId }, select: { title: true, messages: true },
});
const allText = sessions.map(s => `${s.title} ${s.messages || ''}`.toLowerCase()).join(' ');
return LEARNING_PATH_STAGES.map(stage => {
const completedCount = stage.tasks.filter(t => !t.keyword || allText.includes(t.keyword)).length;
const progress = stage.tasks.length > 0 ? Math.round((completedCount / stage.tasks.length) * 100) : 0;
return { ...stage, completedCount, totalTasks: stage.tasks.length, progress, unlocked: true };
});
}
private async search(q: string, type: string) {
const results: any[] = [];
let total = 0;
if (type === 'all' || type === 'courses') {
const [items, count] = await Promise.all([
this.prisma.course.findMany({
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }] },
select: { id: true, title: true, description: true, isFree: true },
take: type === 'courses' ? 20 : 5,
}),
this.prisma.course.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'course' })));
total += count;
}
if (type === 'all' || type === 'prompts') {
const [items, count] = await Promise.all([
this.prisma.prompt.findMany({
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }, { content: { contains: q } }] },
select: { id: true, title: true, description: true },
take: type === 'prompts' ? 20 : 5,
}),
this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }, { content: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'prompt' })));
total += count;
}
if (type === 'all' || type === 'tools') {
const [items, count] = await Promise.all([
this.prisma.tool.findMany({
where: { deletedAt: null, OR: [{ name: { contains: q } }, { description: { contains: q } }] },
select: { id: true, name: true, description: true },
take: type === 'tools' ? 20 : 5,
}),
this.prisma.tool.count({ where: { deletedAt: null, OR: [{ name: { contains: q } }, { description: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'tool' })));
total += count;
}
if (type === 'all' || type === 'contents') {
const [items, count] = await Promise.all([
this.prisma.content.findMany({
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { summary: { contains: q } }] },
select: { id: true, title: true, summary: true },
take: type === 'contents' ? 20 : 5,
}),
this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { summary: { contains: q } }] } }),
]);
results.push(...items.map(i => ({ ...i, _type: 'content' })));
total += count;
}
return { results, total };
}
}
View File
View File
View File
+2 -1
View File
@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@ApiTags('认证')
@Controller('auth')
@@ -15,7 +16,7 @@ export class AuthController {
}
@Post('login')
async login(@Body() body: { account: string; password: string }) {
async login(@Body() body: LoginDto) {
return this.authService.login(body.account, body.password);
}
View File
+25 -6
View File
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
@@ -10,14 +10,32 @@ export class AuthService {
private jwtService: JwtService,
) {}
async register(data: { phone?: string; email?: string; password: string; nickname?: string }) {
async register(data: { username?: string; phone?: string; email?: string; password: string; nickname?: string }) {
if (!data.username && !data.phone && !data.email) {
throw new BadRequestException('请填写用户名、手机号或邮箱');
}
if (data.username) {
const exists = await this.prisma.user.findUnique({ where: { username: data.username } });
if (exists) throw new BadRequestException('用户名已被注册');
}
if (data.phone) {
const exists = await this.prisma.user.findUnique({ where: { phone: data.phone } });
if (exists) throw new BadRequestException('手机号已被注册');
}
if (data.email) {
const exists = await this.prisma.user.findUnique({ where: { email: data.email } });
if (exists) throw new BadRequestException('邮箱已被注册');
}
const passwordHash = await bcrypt.hash(data.password, 10);
const user = await this.prisma.user.create({
data: {
phone: data.phone,
email: data.email,
username: data.username || null,
phone: data.phone || null,
email: data.email || null,
passwordHash,
nickname: data.nickname || data.phone || data.email?.split('@')[0],
nickname: data.nickname || data.username || data.phone || data.email?.split('@')[0],
},
});
return this.generateTokens(user.id);
@@ -26,7 +44,7 @@ export class AuthService {
async login(account: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ phone: account }, { email: account }],
OR: [{ username: account }, { phone: account }, { email: account }],
deletedAt: null,
},
});
@@ -68,6 +86,7 @@ export class AuthService {
where: { id: userId },
select: {
id: true,
username: true,
phone: true,
email: true,
nickname: true,
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsNotEmpty({ message: '请输入账号' })
@IsString()
account: string;
@IsNotEmpty({ message: '请输入密码' })
@IsString()
password: string;
}
+7 -3
View File
@@ -1,12 +1,16 @@
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString, MinLength, Matches } from 'class-validator';
export class RegisterDto {
@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9_\u4e00-\u9fa5]{2,20}$/, { message: '用户名格式不正确(2-20位,支持中英文、数字、下划线)' })
username?: string;
@IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phone?: string;
@IsOptional()
@IsString()
@Matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: '邮箱格式不正确' })
email?: string;
@IsNotEmpty({ message: '密码不能为空' })
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+85 -27
View File
@@ -1,15 +1,16 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PaymentService } from '../payment/payment.service';
import { GatewayPayService } from '../payment/gateway-pay.service';
@Injectable()
export class OrdersService {
constructor(
private prisma: PrismaService,
private paymentService: PaymentService,
private gatewayPay: GatewayPayService,
) {}
async create(userId: number, data: { amount: number; planType: string; payChannel?: string; tradeType?: string; openid?: string }) {
const channel = data.payChannel || 'alipay';
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const order = await this.prisma.order.create({
@@ -18,35 +19,92 @@ export class OrdersService {
userId,
amount: data.amount,
planType: data.planType,
payChannel: data.payChannel || 'wxpay',
payChannel: channel,
},
});
// If paying via WeChat, create unified order
if (data.payChannel === 'wxpay' || !data.payChannel) {
try {
const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员',
};
const payResult = await this.paymentService.createUnifiedOrder({
description: planLabels[data.planType] || '宇之然AI会员充值',
outTradeNo: orderNo,
amount: data.amount,
tradeType: (data.tradeType as 'JSAPI' | 'NATIVE' | 'MWEB') || 'NATIVE',
openid: data.openid,
});
// Mock 模式下自动完成支付闭环(NATIVE mock 无需扫码)
if (payResult?.codeUrl === 'mock://pay') {
await this.paymentService.completeMockPayment(orderNo);
}
return { order, payResult };
} catch {
return { order, payResult: null };
}
}
const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员',
};
const subject = planLabels[data.planType] || '宇之然AI会员充值';
return { order };
try {
const result = await this.gatewayPay.createOrder({
merchantOrderId: orderNo,
amount: data.amount,
paymentMethod: channel === 'wxpay' ? 'wechat' : 'alipay',
subject,
});
if (result.gatewayOrderId) {
await this.prisma.order.update({
where: { id: order.id },
data: { gatewayOrderId: result.gatewayOrderId },
});
}
const payResult = {
gatewayOrderId: result.gatewayOrderId,
payUrl: result.payUrl,
qrcode: result.qrcode,
status: result.status,
};
if (result.payUrl === 'mock://pay' || result.qrcode === 'mock://pay') {
await this.completeMockPayment(order.id);
return { order, payResult: { ...payResult, redirectUrl: 'mock://pay' }, message: '模拟支付完成' };
}
return { order, payResult };
} catch (err) {
return { order, payResult: null };
}
}
private async completeMockPayment(orderId: number) {
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { user: true } });
if (!order || order.status !== 'PENDING') return;
await this.prisma.order.update({
where: { id: orderId },
data: { status: 'PAID', paidAt: new Date() },
});
if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
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 },
});
}
}
async findByUser(userId: number, params: { page?: number; pageSize?: number }) {
View File
+62 -44
View File
@@ -1,12 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from '../orders.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { PaymentService } from '../../payment/payment.service';
import { GatewayPayService } from '../../payment/gateway-pay.service';
describe('OrdersService', () => {
let service: OrdersService;
let prisma: PrismaService;
let paymentService: PaymentService;
let gatewayPay: GatewayPayService;
const mockPrisma = {
order: {
@@ -19,12 +19,18 @@ describe('OrdersService', () => {
subscription: {
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
},
user: {
update: jest.fn(),
},
};
const mockPaymentService = {
createUnifiedOrder: jest.fn(),
completeMockPayment: jest.fn(),
const mockGatewayPay = {
createOrder: jest.fn(),
queryOrder: jest.fn(),
refund: jest.fn(),
handleWebhook: jest.fn(),
};
beforeEach(async () => {
@@ -32,74 +38,86 @@ describe('OrdersService', () => {
providers: [
OrdersService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: PaymentService, useValue: mockPaymentService },
{ provide: GatewayPayService, useValue: mockGatewayPay },
],
}).compile();
service = module.get<OrdersService>(OrdersService);
prisma = module.get<PrismaService>(PrismaService);
paymentService = module.get<PaymentService>(PaymentService);
gatewayPay = module.get<GatewayPayService>(GatewayPayService);
jest.clearAllMocks();
});
describe('create', () => {
it('should create order and call payment service for wxpay', async () => {
it('should create order and call gateway for alipay', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 29.9,
amount: 49.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
payChannel: 'alipay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
mockPaymentService.createUnifiedOrder.mockResolvedValue({
prepay_id: 'wx123',
nonceStr: 'abc',
mockPrisma.order.update.mockResolvedValue(mockOrder);
mockGatewayPay.createOrder.mockResolvedValue({
gatewayOrderId: 'gateway_abc',
payUrl: 'https://pay.example.com',
status: 'pending',
});
const result = await service.create(1, {
amount: 29.9,
amount: 49.9,
planType: 'MONTHLY',
payChannel: 'alipay',
});
expect(result).toHaveProperty('order');
expect(result).toHaveProperty('payResult');
expect(mockGatewayPay.createOrder).toHaveBeenCalledWith(
expect.objectContaining({
paymentMethod: 'alipay',
amount: 49.9,
})
);
});
it('should create order and call gateway for wechat', async () => {
const mockOrder = {
id: 2,
orderNo: 'YZR456',
amount: 299,
planType: 'YEARLY',
payChannel: 'wxpay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
mockPrisma.order.update.mockResolvedValue(mockOrder);
mockGatewayPay.createOrder.mockResolvedValue({
gatewayOrderId: 'gateway_def',
qrcode: 'data:image/png;base64,...',
status: 'pending',
});
const result = await service.create(1, {
amount: 299,
planType: 'YEARLY',
payChannel: 'wxpay',
});
expect(result).toHaveProperty('order');
expect(result).toHaveProperty('payResult');
expect(mockPrisma.order.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 1,
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
}),
});
});
it('should create order without payment for non-wxpay channels', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
const result = await service.create(1, {
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
});
expect(result).toEqual({ order: mockOrder });
expect(mockPaymentService.createUnifiedOrder).not.toHaveBeenCalled();
expect(mockGatewayPay.createOrder).toHaveBeenCalledWith(
expect.objectContaining({
paymentMethod: 'wechat',
amount: 299,
})
);
});
});
describe('findByUser', () => {
it('should return paginated orders for user', async () => {
const mockOrders = [
{ id: 1, orderNo: 'YZR123', amount: 29.9 }
{ id: 1, orderNo: 'YZR123', amount: 49.9 }
];
mockPrisma.order.findMany.mockResolvedValue(mockOrders);
mockPrisma.order.count.mockResolvedValue(1);
@@ -136,7 +154,7 @@ describe('OrdersService', () => {
userId: 1,
plan: 'YEARLY',
status: 'ACTIVE',
endDate: new Date(Date.now() + 86400000), // 明天到期
endDate: new Date(Date.now() + 86400000),
};
mockPrisma.subscription.findFirst.mockResolvedValue(mockSub);
@@ -0,0 +1,300 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PaymentConfigService } from './payment-config.service';
export interface AlipayPayResult {
redirectUrl?: string;
formHtml?: string;
outTradeNo: string;
totalAmount: number;
subject: string;
}
@Injectable()
export class AlipayService {
private readonly logger = new Logger(AlipayService.name);
private sdk: any = null;
constructor(
private prisma: PrismaService,
private configService: PaymentConfigService,
) {}
async createPagePay(params: {
outTradeNo: string;
totalAmount: number;
subject: string;
description?: string;
}): Promise<AlipayPayResult> {
const conf = await this.configService.load();
if (conf.alipay.mock) {
this.logger.log(`支付宝模拟支付: ${params.outTradeNo}`);
await this.completeMockPayment(params.outTradeNo);
return {
redirectUrl: 'mock://pay',
outTradeNo: params.outTradeNo,
totalAmount: params.totalAmount,
subject: params.subject,
};
}
try {
const AlipaySdk = require('alipay-sdk').default;
const AlipayFormData = require('alipay-sdk/lib/form').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const formData = new AlipayFormData();
formData.setMethod('POST');
formData.addField('notifyUrl', conf.alipay.notifyUrl);
formData.addField('returnUrl', conf.alipay.returnUrl);
formData.addField('bizContent', {
outTradeNo: params.outTradeNo,
productCode: 'FAST_INSTANT_TRADE_PAY',
totalAmount: params.totalAmount.toFixed(2),
subject: params.subject,
body: params.description || params.subject,
});
const result = await alipaySdk.exec(
'alipay.trade.page.pay',
{},
{ formData },
);
return {
redirectUrl: typeof result === 'string' ? result : result?.redirectUrl,
formHtml: result?.formHtml,
outTradeNo: params.outTradeNo,
totalAmount: params.totalAmount,
subject: params.subject,
};
} catch (err: any) {
this.logger.error(`支付宝下单失败: ${err.message}`);
await this.completeMockPayment(params.outTradeNo);
return {
redirectUrl: 'mock://pay',
outTradeNo: params.outTradeNo,
totalAmount: params.totalAmount,
subject: params.subject,
};
}
}
async handleNotify(body: Record<string, any>): Promise<{ code: string; message: string }> {
const conf = await this.configService.load();
if (conf.alipay.mock) {
const outTradeNo = body?.out_trade_no;
const tradeNo = body?.trade_no;
if (outTradeNo) {
const order = await this.prisma.order.findUnique({ where: { orderNo: outTradeNo } });
if (order && order.status === 'PENDING') {
await this.updateOrderAndMembership(outTradeNo, tradeNo || outTradeNo);
}
}
return { code: 'SUCCESS', message: '模拟通知处理成功' };
}
try {
const AlipaySdk = require('alipay-sdk').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const signVerified = alipaySdk.checkSign(body);
if (!signVerified) {
this.logger.warn('支付宝签名验证失败');
return { code: 'FAIL', message: '签名验证失败' };
}
const tradeStatus = body.trade_status;
if (tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED') {
await this.updateOrderAndMembership(body.out_trade_no, body.trade_no);
}
return { code: 'SUCCESS', message: '成功' };
} catch (err: any) {
this.logger.error(`支付宝通知处理失败: ${err.message}`);
return { code: 'FAIL', message: err.message };
}
}
async refund(outTradeNo: string, amount: number, reason?: string) {
const conf = await this.configService.load();
if (conf.alipay.mock) {
this.logger.log(`支付宝模拟退款: ${outTradeNo}`);
await this.prisma.order.update({
where: { orderNo: outTradeNo },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
return { code: 'SUCCESS', message: '模拟退款成功' };
}
try {
const AlipaySdk = require('alipay-sdk').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const result = await alipaySdk.exec('alipay.trade.refund', {
bizContent: {
outTradeNo,
refundAmount: amount.toFixed(2),
refundReason: reason || '用户申请退款',
outRequestNo: `REFUND_${outTradeNo}_${Date.now()}`,
},
});
await this.prisma.order.update({
where: { orderNo: outTradeNo },
data: { status: 'REFUNDED' },
}).catch(err => this.logger.warn(`退款更新订单失败: ${err.message}`));
return result;
} catch (err: any) {
this.logger.error(`支付宝退款失败: ${err.message}`);
throw err;
}
}
async queryOrder(outTradeNo: string) {
const localOrder = await this.prisma.order.findUnique({
where: { orderNo: outTradeNo },
include: { user: { select: { id: true, nickname: true, memberPlan: true, memberExpire: true } } },
});
const conf = await this.configService.load();
if (conf.alipay.mock) {
return {
trade_status: localOrder?.status === 'PAID' ? 'TRADE_SUCCESS' : 'WAIT_BUYER_PAY',
outTradeNo,
localStatus: localOrder?.status,
amount: localOrder?.amount,
planType: localOrder?.planType,
user: localOrder?.user,
};
}
try {
const AlipaySdk = require('alipay-sdk').default;
const alipaySdk = new AlipaySdk({
appId: conf.alipay.appId,
privateKey: conf.alipay.privateKey,
alipayPublicKey: conf.alipay.alipayPublicKey,
gateway: conf.alipay.gateway,
});
const result = await alipaySdk.exec('alipay.trade.query', {
bizContent: { outTradeNo },
});
return {
...result,
localStatus: localOrder?.status,
localAmount: localOrder?.amount,
user: localOrder?.user,
};
} catch (err: any) {
this.logger.error(`支付宝查询失败: ${err.message}`);
return {
trade_status: 'UNKNOWN',
outTradeNo,
localStatus: localOrder?.status,
amount: localOrder?.amount,
user: localOrder?.user,
};
}
}
private async completeMockPayment(orderNo: string): Promise<void> {
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order || order.status !== 'PENDING') return;
await this.updateOrderAndMembership(orderNo, `MOCK_${Date.now()}`);
}
private async updateOrderAndMembership(outTradeNo: string, transactionId?: string) {
const order = await this.prisma.order.findUnique({
where: { orderNo: outTradeNo },
include: { user: true },
});
if (!order) {
this.logger.warn(`订单不存在: ${outTradeNo}`);
return;
}
if (order.status === 'PAID') {
this.logger.log(`订单已支付,跳过重复处理: ${outTradeNo}`);
return;
}
await this.prisma.order.update({
where: { id: order.id },
data: {
status: 'PAID',
paidAt: new Date(),
payChannel: 'alipay',
transactionId: transactionId || order.transactionId,
},
});
if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
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}`);
}
}
}
@@ -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}`);
}
}
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface PaymentConfig {
wxpay: {
appId: string;
mchId: string;
apiKey: string;
certPath: string;
keyPath: string;
notifyUrl: string;
mock: boolean;
};
alipay: {
appId: string;
privateKey: string;
alipayPublicKey: string;
notifyUrl: string;
returnUrl: string;
gateway: string;
mock: boolean;
};
}
@Injectable()
export class PaymentConfigService {
private readonly logger = new Logger(PaymentConfigService.name);
private cache: PaymentConfig | null = null;
constructor(private prisma: PrismaService) {}
async load(): Promise<PaymentConfig> {
if (this.cache) return this.cache;
return this.refresh();
}
async refresh(): Promise<PaymentConfig> {
const all = await this.prisma.systemConfig.findMany({
where: { category: 'payment' },
});
const map = new Map(all.map(c => [c.key, c.value]));
const conf: PaymentConfig = {
wxpay: {
appId: map.get('wx_appid') || process.env.WX_APPID || '',
mchId: map.get('wx_mchid') || process.env.WX_MCHID || process.env.WX_PAY_MCH_ID || '1108945993',
apiKey: map.get('wx_apikey') || process.env.WX_API_KEY || process.env.WX_PAY_API_KEY || '8Kj9mP2nQ5rT7vW1xY3zA4bC6dE8fG0h',
certPath: map.get('wx_cert_path') || process.env.WX_CERT_PATH || '',
keyPath: map.get('wx_key_path') || process.env.WX_KEY_PATH || '',
notifyUrl: map.get('wx_notify_url') || process.env.WX_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/wxpay/notify',
mock: (map.get('wx_mock') || process.env.WX_PAY_MOCK || 'true') === 'true',
},
alipay: {
appId: map.get('alipay_app_id') || process.env.ALIPAY_APP_ID || '',
privateKey: map.get('alipay_private_key') || process.env.ALIPAY_PRIVATE_KEY || '',
alipayPublicKey: map.get('alipay_public_key') || process.env.ALIPAY_PUBLIC_KEY || '',
notifyUrl: map.get('alipay_notify_url') || process.env.ALIPAY_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/alipay/notify',
returnUrl: map.get('alipay_return_url') || process.env.ALIPAY_RETURN_URL || 'https://yuzhiran.com/my/member',
gateway: map.get('alipay_gateway') || process.env.ALIPAY_GATEWAY || 'https://openapi.alipay.com/gateway.do',
mock: (map.get('alipay_mock') || process.env.ALIPAY_MOCK || 'true') === 'true',
},
};
this.cache = conf;
this.logger.log(`支付配置已加载 (微信mock:${conf.wxpay.mock}, 支付宝mock:${conf.alipay.mock})`);
return conf;
}
invalidateCache() {
this.cache = null;
}
}
+58 -11
View File
@@ -1,7 +1,9 @@
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query } from '@nestjs/common';
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus } 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';
class UnifiedOrderDto {
@IsString()
@@ -37,33 +39,78 @@ class RefundDto {
@ApiTags('支付')
@Controller('payment')
export class PaymentController {
constructor(private paymentService: PaymentService) {}
constructor(
private paymentService: PaymentService,
private gatewayPay: GatewayPayService,
private prisma: PrismaService,
) {}
@Post('wxpay/unified-order')
@ApiOperation({ summary: '微信支付统一下单' })
@ApiOperation({ summary: '微信支付统一下单(旧接口,转网关)' })
@ApiBody({ type: UnifiedOrderDto })
async unifiedOrder(@Body() body: UnifiedOrderDto) {
return this.paymentService.createUnifiedOrder(body);
const result = await this.gatewayPay.createOrder({
merchantOrderId: body.outTradeNo,
amount: body.amount,
paymentMethod: 'wechat',
subject: body.description,
});
return {
prepay_id: result.gatewayOrderId,
codeUrl: result.qrcode === 'mock://pay' ? 'mock://pay' : result.qrcode,
gatewayOrderId: result.gatewayOrderId,
};
}
@Post('wxpay/notify')
@HttpCode(200)
@ApiOperation({ summary: '微信支付回调通知' })
async notify(@Req() req: any, @Headers('wechatpay-signature') signature: string) {
return this.paymentService.handleNotify(req.body, signature);
@ApiOperation({ summary: '微信支付回调通知(转网关webhook处理)' })
async notify(@Body() body: any) {
const result = await this.gatewayPay.handleWebhook(body);
if (result.code === 'SUCCESS') return { code: 'SUCCESS', message: 'OK' };
return { code: 'FAIL', message: result.message };
}
@Post('wxpay/refund')
@ApiOperation({ summary: '微信支付退款' })
@ApiOperation({ summary: '微信支付退款(转网关)' })
@ApiBody({ type: RefundDto })
async refund(@Body() body: RefundDto) {
return this.paymentService.refund(body.outTradeNo, body.amount, body.reason);
return this.gatewayPay.refund(body.outTradeNo, body.amount, body.reason);
}
@Get('wxpay/query')
@ApiOperation({ summary: '查询微信支付订单' })
@ApiOperation({ summary: '查询订单状态(网关)' })
@ApiQuery({ name: 'outTradeNo', required: true })
async query(@Query('outTradeNo') outTradeNo: string) {
return this.paymentService.queryOrder(outTradeNo);
const order = await this.prisma.order.findUnique({ where: { orderNo: outTradeNo } });
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
if (order.gatewayOrderId && !order.gatewayOrderId.startsWith('mock_')) {
const result = await this.gatewayPay.queryOrder(order.gatewayOrderId);
return {
trade_state: result.status === 'paid' ? 'SUCCESS' : 'NOTPAY',
outTradeNo,
localStatus: order.status,
amount: order.amount,
gatewayStatus: result.status,
};
}
return {
trade_state: order.status === 'PAID' ? 'SUCCESS' : 'NOTPAY',
outTradeNo,
localStatus: order.status,
amount: order.amount,
planType: order.planType,
};
}
@Post('gateway/webhook')
@HttpCode(200)
@ApiOperation({ summary: '统一支付网关Webhook' })
async gatewayWebhook(@Body() body: any) {
const result = await this.gatewayPay.handleWebhook(body);
if (result.code === 'SUCCESS') return 'success';
return 'fail';
}
}
+6 -3
View File
@@ -1,12 +1,15 @@
import { Module } from '@nestjs/common';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';
import { AlipayService } from './alipay.service';
import { PaymentConfigService } from './payment-config.service';
import { GatewayPayService } from './gateway-pay.service';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService],
exports: [PaymentService],
providers: [PaymentService, AlipayService, PaymentConfigService, GatewayPayService],
exports: [PaymentService, AlipayService, PaymentConfigService, GatewayPayService],
})
export class PaymentModule {}
export class PaymentModule {}
+3 -3
View File
@@ -153,7 +153,7 @@ export class PaymentService {
this.logger.log(`支付成功: ${payResult.out_trade_no}, 金额: ${payResult.amount.total}`);
// 更新订单状态和会员订阅
await this.updateOrderAndMembership(payResult.out_trade_no, payResult.amount.total);
await this.updateOrderAndMembership(payResult.out_trade_no, payResult.amount.total, payResult.transaction_id);
return { code: 'SUCCESS', message: '支付成功' };
}
@@ -165,7 +165,7 @@ export class PaymentService {
}
}
private async updateOrderAndMembership(outTradeNo: string, paidAmountTotal: number) {
private async updateOrderAndMembership(outTradeNo: string, paidAmountTotal: number, transactionId?: string) {
const order = await this.prisma.order.findUnique({
where: { orderNo: outTradeNo },
include: { user: true },
@@ -190,7 +190,7 @@ export class PaymentService {
// 更新订单状态为已支付
await this.prisma.order.update({
where: { id: order.id },
data: { status: 'PAID', paidAt: new Date() },
data: { status: 'PAID', paidAt: new Date(), transactionId: transactionId || order.transactionId },
});
// 处理会员订阅(仅限MONTHLY/YEARLY计划)
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More