注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + 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:
@@ -1,97 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend-lint-and-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ./backend
|
|
||||||
|
|
||||||
services:
|
|
||||||
mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
env:
|
|
||||||
MYSQL_ROOT_PASSWORD: root123
|
|
||||||
MYSQL_DATABASE: yuzhiran
|
|
||||||
ports:
|
|
||||||
- 3306:3306
|
|
||||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: backend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Generate Prisma client
|
|
||||||
run: npx prisma generate
|
|
||||||
env:
|
|
||||||
DATABASE_URL: mysql://root:root123@localhost:3306/yuzhiran
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
frontend-lint-and-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ./frontend
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
docker-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [backend-lint-and-test, frontend-lint-and-test]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build backend
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ./backend
|
|
||||||
push: false
|
|
||||||
tags: yuzhiran/backend:latest
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ./frontend
|
|
||||||
push: false
|
|
||||||
tags: yuzhiran/frontend:latest
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
Regular → Executable
@@ -90,7 +90,7 @@
|
|||||||
|
|
||||||
1. **任务开始前** — 读 AGENTS.md + docs/progress.md,了解当前进度和上下文
|
1. **任务开始前** — 读 AGENTS.md + docs/progress.md,了解当前进度和上下文
|
||||||
2. **规划阶段** — 分析需求,拆解为 TODO 列表,按优先级排序
|
2. **规划阶段** — 分析需求,拆解为 TODO 列表,按优先级排序
|
||||||
3. **执行阶段** — 按 TODO 依次实施,每次完成后验证(lint/test/build)
|
3. **执行阶段** — 按 TODO 依次实施,完成后先做**代码评审修复**(检查代码风格、类型安全、边界情况、安全隐患、是否符合项目约定),再做**测试验证**(运行 lint/build + 相关测试,发现失败立即修复)
|
||||||
4. **任务完成后** — 更新 docs/progress.md(更新阶段状态、补充完成项)
|
4. **任务完成后** — 更新 docs/progress.md(更新阶段状态、补充完成项)
|
||||||
5. **归档** — 旧版 progress.md 移到 `docs/archive/progress-YYYY-MM-DD.md`
|
5. **归档** — 旧版 progress.md 移到 `docs/archive/progress-YYYY-MM-DD.md`
|
||||||
6. **提交** — 只有在用户明确要求时才创建 git commit
|
6. **提交** — 只有在用户明确要求时才创建 git commit
|
||||||
|
|||||||
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 服务",
|
"description": "宇之然 AI - 后端 API 服务",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build --tsc",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"@nestjs/serve-static": "^5.0.5",
|
"@nestjs/serve-static": "^5.0.5",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"alipay-sdk": "^4.14.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
@@ -62,6 +63,9 @@
|
|||||||
"@types/node": "^20.19.40",
|
"@types/node": "^20.19.40",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.2.0",
|
"@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",
|
"jest": "^30.4.0",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"ts-jest": "^29.4.9",
|
"ts-jest": "^29.4.9",
|
||||||
|
|||||||
Regular → Executable
+13
-10
@@ -34,6 +34,7 @@ enum MemberPlan {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
username String? @unique
|
||||||
phone String? @unique
|
phone String? @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
@@ -303,16 +304,18 @@ model SandboxSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
orderNo String @unique
|
orderNo String @unique
|
||||||
userId Int
|
userId Int
|
||||||
amount Float
|
amount Float
|
||||||
planType String
|
planType String
|
||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
payChannel String?
|
payChannel String?
|
||||||
paidAt DateTime?
|
transactionId String?
|
||||||
createdAt DateTime @default(now())
|
gatewayOrderId String?
|
||||||
updatedAt DateTime @updatedAt
|
paidAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
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 { NotificationModule } from './modules/notifications/notification.module';
|
||||||
import { LearningModule } from './modules/learning/learning.module';
|
import { LearningModule } from './modules/learning/learning.module';
|
||||||
import { SkillsModule } from './modules/skills/skills.module';
|
import { SkillsModule } from './modules/skills/skills.module';
|
||||||
|
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule, UsersModule, CoursesModule, ContentsModule,
|
||||||
UsersModule,
|
PromptsModule, ToolsModule, SandboxModule, OrdersModule,
|
||||||
CoursesModule,
|
AdminModule, PaymentModule, CategoriesModule, DashboardModule,
|
||||||
ContentsModule,
|
SearchModule, ModelsModule, UploadModule, CommunityModule,
|
||||||
PromptsModule,
|
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
|
||||||
ToolsModule,
|
AiAssistantModule,
|
||||||
SandboxModule,
|
|
||||||
OrdersModule,
|
|
||||||
AdminModule,
|
|
||||||
PaymentModule,
|
|
||||||
CategoriesModule,
|
|
||||||
DashboardModule,
|
|
||||||
SearchModule,
|
|
||||||
ModelsModule,
|
|
||||||
UploadModule,
|
|
||||||
CommunityModule,
|
|
||||||
EnterpriseModule,
|
|
||||||
NotificationModule,
|
|
||||||
LearningModule,
|
|
||||||
SkillsModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
@@ -7,6 +7,7 @@ import { CoursesService } from '../courses/courses.service';
|
|||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { AIGatewayService } from '../ai/ai-gateway.service';
|
import { AIGatewayService } from '../ai/ai-gateway.service';
|
||||||
import { AdminAiAssistantService } from './ai-assistant.service';
|
import { AdminAiAssistantService } from './ai-assistant.service';
|
||||||
|
import { GatewayPayService } from '../payment/gateway-pay.service';
|
||||||
|
|
||||||
@ApiTags('管理后台')
|
@ApiTags('管理后台')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@@ -17,6 +18,7 @@ export class AdminController {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private aiGateway: AIGatewayService,
|
private aiGateway: AIGatewayService,
|
||||||
private aiAssistant: AdminAiAssistantService,
|
private aiAssistant: AdminAiAssistantService,
|
||||||
|
private gatewayPay: GatewayPayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@@ -34,13 +36,124 @@ export class AdminController {
|
|||||||
@Get('orders')
|
@Get('orders')
|
||||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
async orders(@Req() req: any) {
|
async orders(
|
||||||
const items = await this.prisma.order.findMany({
|
@Query('page') page?: string,
|
||||||
orderBy: { createdAt: 'desc' },
|
@Query('pageSize') pageSize?: string,
|
||||||
take: 100,
|
@Query('search') search?: string,
|
||||||
include: { user: { select: { id: true, nickname: true, phone: true } } },
|
@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')
|
@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 { CoursesModule } from '../courses/courses.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { AIModule } from '../ai/ai.module';
|
import { AIModule } from '../ai/ai.module';
|
||||||
|
import { PaymentModule } from '../payment/payment.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoursesModule, AuthModule, AIModule],
|
imports: [CoursesModule, AuthModule, AIModule, PaymentModule],
|
||||||
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController, PublicController],
|
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController, PublicController],
|
||||||
providers: [AdminService, AdminGuard, AdminAiAssistantService],
|
providers: [AdminService, AdminGuard, AdminAiAssistantService],
|
||||||
exports: [AdminService],
|
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: '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: {},
|
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}` };
|
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': {
|
case 'get-enterprise-orgs': {
|
||||||
const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
|
const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
|
||||||
return { success: true, data: { items, total: items.length }, summary: `共 ${items.length} 个企业组织` };
|
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 { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
|
||||||
@ApiTags('认证')
|
@ApiTags('认证')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -15,7 +16,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() body: { account: string; password: string }) {
|
async login(@Body() body: LoginDto) {
|
||||||
return this.authService.login(body.account, body.password);
|
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 { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
@@ -10,14 +10,32 @@ export class AuthService {
|
|||||||
private jwtService: JwtService,
|
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 passwordHash = await bcrypt.hash(data.password, 10);
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
phone: data.phone,
|
username: data.username || null,
|
||||||
email: data.email,
|
phone: data.phone || null,
|
||||||
|
email: data.email || null,
|
||||||
passwordHash,
|
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);
|
return this.generateTokens(user.id);
|
||||||
@@ -26,7 +44,7 @@ export class AuthService {
|
|||||||
async login(account: string, password: string) {
|
async login(account: string, password: string) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ phone: account }, { email: account }],
|
OR: [{ username: account }, { phone: account }, { email: account }],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -68,6 +86,7 @@ export class AuthService {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
username: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
email: true,
|
email: true,
|
||||||
nickname: 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 {
|
export class RegisterDto {
|
||||||
@IsOptional()
|
@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;
|
phone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: '邮箱格式不正确' })
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
@IsNotEmpty({ message: '密码不能为空' })
|
@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 { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { PaymentService } from '../payment/payment.service';
|
import { GatewayPayService } from '../payment/gateway-pay.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdersService {
|
export class OrdersService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private paymentService: PaymentService,
|
private gatewayPay: GatewayPayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: number, data: { amount: number; planType: string; payChannel?: string; tradeType?: string; openid?: string }) {
|
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 orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||||
|
|
||||||
const order = await this.prisma.order.create({
|
const order = await this.prisma.order.create({
|
||||||
@@ -18,35 +19,92 @@ export class OrdersService {
|
|||||||
userId,
|
userId,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
planType: data.planType,
|
planType: data.planType,
|
||||||
payChannel: data.payChannel || 'wxpay',
|
payChannel: channel,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If paying via WeChat, create unified order
|
const planLabels: Record<string, string> = {
|
||||||
if (data.payChannel === 'wxpay' || !data.payChannel) {
|
MONTHLY: '宇之然AI月卡会员',
|
||||||
try {
|
YEARLY: '宇之然AI年卡会员',
|
||||||
const planLabels: Record<string, string> = {
|
};
|
||||||
MONTHLY: '宇之然AI月卡会员',
|
const subject = planLabels[data.planType] || '宇之然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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) {
|
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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { OrdersService } from '../orders.service';
|
import { OrdersService } from '../orders.service';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { PaymentService } from '../../payment/payment.service';
|
import { GatewayPayService } from '../../payment/gateway-pay.service';
|
||||||
|
|
||||||
describe('OrdersService', () => {
|
describe('OrdersService', () => {
|
||||||
let service: OrdersService;
|
let service: OrdersService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let paymentService: PaymentService;
|
let gatewayPay: GatewayPayService;
|
||||||
|
|
||||||
const mockPrisma = {
|
const mockPrisma = {
|
||||||
order: {
|
order: {
|
||||||
@@ -19,12 +19,18 @@ describe('OrdersService', () => {
|
|||||||
subscription: {
|
subscription: {
|
||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPaymentService = {
|
const mockGatewayPay = {
|
||||||
createUnifiedOrder: jest.fn(),
|
createOrder: jest.fn(),
|
||||||
completeMockPayment: jest.fn(),
|
queryOrder: jest.fn(),
|
||||||
|
refund: jest.fn(),
|
||||||
|
handleWebhook: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -32,74 +38,86 @@ describe('OrdersService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
OrdersService,
|
OrdersService,
|
||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
{ provide: PaymentService, useValue: mockPaymentService },
|
{ provide: GatewayPayService, useValue: mockGatewayPay },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<OrdersService>(OrdersService);
|
service = module.get<OrdersService>(OrdersService);
|
||||||
prisma = module.get<PrismaService>(PrismaService);
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
paymentService = module.get<PaymentService>(PaymentService);
|
gatewayPay = module.get<GatewayPayService>(GatewayPayService);
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
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 = {
|
const mockOrder = {
|
||||||
id: 1,
|
id: 1,
|
||||||
orderNo: 'YZR123',
|
orderNo: 'YZR123',
|
||||||
amount: 29.9,
|
amount: 49.9,
|
||||||
planType: 'MONTHLY',
|
planType: 'MONTHLY',
|
||||||
payChannel: 'wxpay',
|
payChannel: 'alipay',
|
||||||
};
|
};
|
||||||
mockPrisma.order.create.mockResolvedValue(mockOrder);
|
mockPrisma.order.create.mockResolvedValue(mockOrder);
|
||||||
mockPaymentService.createUnifiedOrder.mockResolvedValue({
|
mockPrisma.order.update.mockResolvedValue(mockOrder);
|
||||||
prepay_id: 'wx123',
|
mockGatewayPay.createOrder.mockResolvedValue({
|
||||||
nonceStr: 'abc',
|
gatewayOrderId: 'gateway_abc',
|
||||||
|
payUrl: 'https://pay.example.com',
|
||||||
|
status: 'pending',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.create(1, {
|
const result = await service.create(1, {
|
||||||
amount: 29.9,
|
amount: 49.9,
|
||||||
planType: 'MONTHLY',
|
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',
|
payChannel: 'wxpay',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toHaveProperty('order');
|
expect(result).toHaveProperty('order');
|
||||||
expect(result).toHaveProperty('payResult');
|
expect(result).toHaveProperty('payResult');
|
||||||
expect(mockPrisma.order.create).toHaveBeenCalledWith({
|
expect(mockGatewayPay.createOrder).toHaveBeenCalledWith(
|
||||||
data: expect.objectContaining({
|
expect.objectContaining({
|
||||||
userId: 1,
|
paymentMethod: 'wechat',
|
||||||
amount: 29.9,
|
amount: 299,
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findByUser', () => {
|
describe('findByUser', () => {
|
||||||
it('should return paginated orders for user', async () => {
|
it('should return paginated orders for user', async () => {
|
||||||
const mockOrders = [
|
const mockOrders = [
|
||||||
{ id: 1, orderNo: 'YZR123', amount: 29.9 }
|
{ id: 1, orderNo: 'YZR123', amount: 49.9 }
|
||||||
];
|
];
|
||||||
mockPrisma.order.findMany.mockResolvedValue(mockOrders);
|
mockPrisma.order.findMany.mockResolvedValue(mockOrders);
|
||||||
mockPrisma.order.count.mockResolvedValue(1);
|
mockPrisma.order.count.mockResolvedValue(1);
|
||||||
@@ -136,7 +154,7 @@ describe('OrdersService', () => {
|
|||||||
userId: 1,
|
userId: 1,
|
||||||
plan: 'YEARLY',
|
plan: 'YEARLY',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
endDate: new Date(Date.now() + 86400000), // 明天到期
|
endDate: new Date(Date.now() + 86400000),
|
||||||
};
|
};
|
||||||
mockPrisma.subscription.findFirst.mockResolvedValue(mockSub);
|
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 { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||||
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
|
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
|
||||||
import { PaymentService } from './payment.service';
|
import { PaymentService } from './payment.service';
|
||||||
|
import { GatewayPayService } from './gateway-pay.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
class UnifiedOrderDto {
|
class UnifiedOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -37,33 +39,78 @@ class RefundDto {
|
|||||||
@ApiTags('支付')
|
@ApiTags('支付')
|
||||||
@Controller('payment')
|
@Controller('payment')
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private paymentService: PaymentService) {}
|
constructor(
|
||||||
|
private paymentService: PaymentService,
|
||||||
|
private gatewayPay: GatewayPayService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('wxpay/unified-order')
|
@Post('wxpay/unified-order')
|
||||||
@ApiOperation({ summary: '微信支付统一下单' })
|
@ApiOperation({ summary: '微信支付统一下单(旧接口,转网关)' })
|
||||||
@ApiBody({ type: UnifiedOrderDto })
|
@ApiBody({ type: UnifiedOrderDto })
|
||||||
async unifiedOrder(@Body() body: 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')
|
@Post('wxpay/notify')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: '微信支付回调通知' })
|
@ApiOperation({ summary: '微信支付回调通知(转网关webhook处理)' })
|
||||||
async notify(@Req() req: any, @Headers('wechatpay-signature') signature: string) {
|
async notify(@Body() body: any) {
|
||||||
return this.paymentService.handleNotify(req.body, signature);
|
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')
|
@Post('wxpay/refund')
|
||||||
@ApiOperation({ summary: '微信支付退款' })
|
@ApiOperation({ summary: '微信支付退款(转网关)' })
|
||||||
@ApiBody({ type: RefundDto })
|
@ApiBody({ type: RefundDto })
|
||||||
async refund(@Body() body: 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')
|
@Get('wxpay/query')
|
||||||
@ApiOperation({ summary: '查询微信支付订单' })
|
@ApiOperation({ summary: '查询订单状态(网关)' })
|
||||||
@ApiQuery({ name: 'outTradeNo', required: true })
|
@ApiQuery({ name: 'outTradeNo', required: true })
|
||||||
async query(@Query('outTradeNo') outTradeNo: string) {
|
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 { Module } from '@nestjs/common';
|
||||||
import { PaymentController } from './payment.controller';
|
import { PaymentController } from './payment.controller';
|
||||||
import { PaymentService } from './payment.service';
|
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';
|
import { PrismaModule } from '../../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [PaymentController],
|
controllers: [PaymentController],
|
||||||
providers: [PaymentService],
|
providers: [PaymentService, AlipayService, PaymentConfigService, GatewayPayService],
|
||||||
exports: [PaymentService],
|
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}`);
|
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: '支付成功' };
|
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({
|
const order = await this.prisma.order.findUnique({
|
||||||
where: { orderNo: outTradeNo },
|
where: { orderNo: outTradeNo },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
@@ -190,7 +190,7 @@ export class PaymentService {
|
|||||||
// 更新订单状态为已支付
|
// 更新订单状态为已支付
|
||||||
await this.prisma.order.update({
|
await this.prisma.order.update({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
data: { status: 'PAID', paidAt: new Date() },
|
data: { status: 'PAID', paidAt: new Date(), transactionId: transactionId || order.transactionId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理会员订阅(仅限MONTHLY/YEARLY计划)
|
// 处理会员订阅(仅限MONTHLY/YEARLY计划)
|
||||||
|
|||||||
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