注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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:
Regular → Executable
Regular → Executable
@@ -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
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1466
-103
File diff suppressed because it is too large
Load Diff
Regular → Executable
+5
-1
@@ -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
@@ -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])
|
||||
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+7
-20
@@ -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 {}
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+120
-7
@@ -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')
|
||||
|
||||
Regular → Executable
Regular → Executable
+2
-1
@@ -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],
|
||||
|
||||
Regular → Executable
Regular → Executable
+35
@@ -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} 个企业组织` };
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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 };
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
Regular → Executable
+25
-6
@@ -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,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsNotEmpty({ message: '请输入账号' })
|
||||
@IsString()
|
||||
account: string;
|
||||
|
||||
@IsNotEmpty({ message: '请输入密码' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
Regular → Executable
+7
-3
@@ -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: '密码不能为空' })
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+85
-27
@@ -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 }) {
|
||||
|
||||
Regular → Executable
Regular → Executable
+62
-44
@@ -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;
|
||||
}
|
||||
}
|
||||
Regular → Executable
+58
-11
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+6
-3
@@ -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 {}
|
||||
Regular → Executable
+3
-3
@@ -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计划)
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user