feat: 支付闭环 + 运营助手 Tool Calling + 管理后台完善

- 支付系统:微信支付 mock 自动完成、NATIVE 扫码支付、JSAPI 集成
- 运营助手:Tool Calling 架构,19 个可执行工具,AI 驱动操作
- 角色管理:表格布局 + Dialog 表单 + 权限勾选
- 配置统一:config.ts 单一数据源
- API 审计:补齐 status toggle / comments 端点
- 暗黑模式硬件编码颜色全部替换为 CSS 变量
This commit is contained in:
yuzhiran-dev
2026-05-20 18:29:00 +08:00
parent 728edc59ef
commit 23edb74bce
76 changed files with 1589 additions and 473 deletions
+37 -1
View File
@@ -1,10 +1,12 @@
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param } from '@nestjs/common';
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { CoursesService } from '../courses/courses.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService } from '../ai/ai-gateway.service';
import { AdminAiAssistantService } from './ai-assistant.service';
@ApiTags('管理后台')
@Controller('admin')
@@ -13,6 +15,8 @@ export class AdminController {
private adminService: AdminService,
private coursesService: CoursesService,
private prisma: PrismaService,
private aiGateway: AIGatewayService,
private aiAssistant: AdminAiAssistantService,
) {}
@Post('login')
@@ -83,4 +87,36 @@ export class AdminController {
});
return { ok: true };
}
@Post('ai-assistant/chat')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async aiAssistantChat(@Body() body: { messages: { role: string; content: string }[] }) {
try {
const { messages } = body;
const apiMessages = messages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', 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('ai-assistant/action')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async aiAssistantAction(@Body() body: { tool: string; params: Record<string, any>; messages: { role: string; content: string }[] }) {
const result = await this.aiAssistant.execute({ tool: body.tool, params: body.params });
try {
const apiMessages = [
{ role: 'system' as const, content: '你是一个管理后台助手。下面是对用户请求的执行结果,请用自然语言总结给用户,语言简洁。' },
...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 };
}
}
}
+4 -2
View File
@@ -3,17 +3,19 @@ import { JwtModule } from '@nestjs/jwt';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { AdminAiAssistantService } from './ai-assistant.service';
import { AnalyticsController } from './analytics.controller';
import { SettingsController } from './settings.controller';
import { OperationsController } from './operations.controller';
import { UsersController } from './users.controller';
import { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [CoursesModule, AuthModule],
imports: [CoursesModule, AuthModule, AIModule],
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController],
providers: [AdminService, AdminGuard],
providers: [AdminService, AdminGuard, AdminAiAssistantService],
exports: [AdminService],
})
export class AdminModule {}
@@ -0,0 +1,309 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.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;
}
@Injectable()
export class AdminAiAssistantService {
constructor(
private prisma: PrismaService,
) {}
getAvailableTools(): ToolDefinition[] {
return [
{
name: 'navigate',
description: '跳转到管理后台的某个页面',
parameters: {
path: { type: 'string', description: '页面路径,如 /admin/users、/admin/orders、/admin/analytics、/admin/enterprise、/admin/operations/banners、/admin/operations/notifications、/admin/settings/roles、/admin/settings/config、/admin/comments、/admin/courses、/admin/prompts、/admin/contents、/admin/tools' },
},
},
{
name: 'get-dashboard',
description: '获取仪表盘概览数据(用户数、课程数、内容数、提示词数、订单数)',
parameters: {},
},
{
name: 'list-users',
description: '列出用户,可按关键词搜索',
parameters: {
search: { type: 'string', description: '搜索关键词(可选)' },
page: { type: 'number', description: '页码(可选,默认1' },
pageSize: { type: 'number', description: '每页数量(可选,默认20' },
},
},
{
name: 'get-user',
description: '查看某个用户的详细信息',
parameters: {
id: { type: 'number', description: '用户ID' },
},
},
{
name: 'update-user-status',
description: '修改用户状态(启用/禁用/封禁)',
parameters: {
id: { type: 'number', description: '用户ID' },
status: { type: 'string', enum: ['ACTIVE', 'INACTIVE', 'BANNED'], description: '新状态' },
},
},
{
name: 'list-orders',
description: '查看最近订单列表',
parameters: {},
},
{
name: 'get-analytics-overview',
description: '获取数据分析概览(用户增长、收入、趋势)',
parameters: {},
},
{
name: 'list-comments',
description: '查看评论列表,可按状态筛选',
parameters: {
status: { type: 'string', enum: ['PENDING_REVIEW', 'PUBLISHED', 'REJECTED'], description: '评论状态(可选)' },
},
},
{
name: 'approve-comment',
description: '通过一条待审核评论',
parameters: {
id: { type: 'number', description: '评论ID' },
},
},
{
name: 'reject-comment',
description: '拒绝一条评论并给出原因',
parameters: {
id: { type: 'number', description: '评论ID' },
reason: { type: 'string', description: '拒绝原因(可选)' },
},
},
{
name: 'list-banners',
description: '查看所有Banner列表',
parameters: {},
},
{
name: 'list-notifications',
description: '查看所有系统通知',
parameters: {},
},
{
name: 'list-config',
description: '查看系统配置项',
parameters: {},
},
{
name: 'list-roles',
description: '查看所有管理角色',
parameters: {},
},
{
name: 'list-admins',
description: '查看所有管理员账号',
parameters: {},
},
{
name: 'toggle-course-status',
description: '上架或下架一个课程',
parameters: {
id: { type: 'number', description: '课程ID' },
},
},
{
name: 'toggle-content-status',
description: '上架或下架一个内容',
parameters: {
id: { type: 'number', description: '内容ID' },
},
},
{
name: 'toggle-prompt-status',
description: '上架或下架一个提示词',
parameters: {
id: { type: 'number', description: '提示词ID' },
},
},
{
name: 'get-enterprise-orgs',
description: '查看所有企业版组织',
parameters: {},
},
];
}
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): Promise<ToolResult> {
try {
switch (call.tool) {
case 'navigate':
return { success: true, data: { path: call.params.path }, summary: `跳转到 ${call.params.path}` };
case 'get-dashboard': {
const data = await this.prisma.user.count({ where: { deletedAt: null } });
const courses = await this.prisma.course.count({ where: { deletedAt: null } });
const contents = await this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED' } });
const prompts = await this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED' } });
const orders = await this.prisma.order.count();
return { success: true, data: { userCount: data, courseCount: courses, contentCount: contents, promptCount: prompts, orderCount: orders }, summary: `总用户${data},课程${courses},内容${contents},提示词${prompts},订单${orders}` };
}
case 'list-users': {
const { search, page = 1, pageSize = 20 } = call.params;
const where: any = { deletedAt: null };
if (search) where.OR = [
{ nickname: { contains: search } },
{ phone: { contains: search } },
{ email: { contains: search } },
];
const [items, total] = await Promise.all([
this.prisma.user.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: 'desc' }, select: { id: true, nickname: true, phone: true, email: true, status: true, memberPlan: true, createdAt: true } }),
this.prisma.user.count({ where }),
]);
return { success: true, data: { items, total, page, pageSize }, summary: `找到 ${total} 个用户` };
}
case 'get-user': {
const user = await this.prisma.user.findUnique({ where: { id: call.params.id }, include: { orders: { take: 5, orderBy: { createdAt: 'desc' } } } });
if (!user) throw new Error('用户不存在');
return { success: true, data: user, summary: `用户 ${user.nickname || user.phone || '未知'} (ID:${user.id})` };
}
case 'update-user-status': {
const { id, status } = call.params;
await this.prisma.user.update({ where: { id }, data: { status } });
return { success: true, data: { id, status }, summary: `用户 ${id} 状态已改为 ${status}` };
}
case 'list-orders': {
const items = await this.prisma.order.findMany({
take: 50, orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, nickname: true } } },
});
const total = items.length;
const revenue = items.filter(o => o.status === 'PAID').reduce((s, o) => s + o.amount, 0);
return { success: true, data: { items, total, revenue }, summary: `最近 ${total} 笔订单,已收金额 ¥${revenue}` };
}
case 'get-analytics-overview': {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [userTotal, ordersTotal, todayOrders, todayUsers, revenue] = await Promise.all([
this.prisma.user.count({ where: { deletedAt: null } }),
this.prisma.order.count(),
this.prisma.order.count({ where: { createdAt: { gte: today } } }),
this.prisma.user.count({ where: { deletedAt: null, createdAt: { gte: today } } }),
this.prisma.order.aggregate({ _sum: { amount: true }, where: { status: 'PAID' } }),
]);
return {
success: true,
data: { users: userTotal, orders: ordersTotal, todayOrders, todayUsers, revenue: revenue._sum.amount || 0 },
summary: `总用户 ${userTotal},今日新增 ${todayUsers};总订单 ${ordersTotal},今日 ${todayOrders};总收入 ¥${revenue._sum.amount || 0}`,
};
}
case 'list-comments': {
const where: any = {};
if (call.params.status) where.status = call.params.status;
const items = await this.prisma.comment.findMany({
where, take: 50, orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, nickname: true } } },
});
return { success: true, data: { items, total: items.length }, summary: `${items.length} 条评论` };
}
case 'approve-comment': {
await this.prisma.comment.update({ where: { id: call.params.id }, data: { status: 'PUBLISHED', reviewedAt: new Date() } });
return { success: true, data: { id: call.params.id }, summary: `评论 ${call.params.id} 已通过` };
}
case 'reject-comment': {
await this.prisma.comment.update({ where: { id: call.params.id }, data: { status: 'REJECTED', reviewNote: call.params.reason || '违规内容', reviewedAt: new Date() } });
return { success: true, data: { id: call.params.id }, summary: `评论 ${call.params.id} 已拒绝` };
}
case 'list-banners': {
const items = await this.prisma.banner.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
return { success: true, data: { items }, summary: `${items.length} 个Banner` };
}
case 'list-notifications': {
const items = await this.prisma.notification.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
return { success: true, data: { items, total: items.length }, summary: `${items.length} 条通知` };
}
case 'list-config': {
const items = await this.prisma.systemConfig.findMany();
return { success: true, data: { items }, summary: `${items.length} 个配置项` };
}
case 'list-roles': {
const items = await this.prisma.adminRole.findMany();
return { success: true, data: { items }, summary: `${items.length} 个角色` };
}
case 'list-admins': {
const items = await this.prisma.adminUser.findMany({ include: { role: { select: { name: true } } } });
return { success: true, data: { items }, summary: `${items.length} 个管理员` };
}
case 'toggle-course-status': {
const course = await this.prisma.course.findUnique({ where: { id: call.params.id } });
if (!course) throw new Error('课程不存在');
const newStatus = course.status === 'PUBLISHED' ? 'INACTIVE' : 'PUBLISHED';
await this.prisma.course.update({ where: { id: call.params.id }, data: { status: newStatus as any } });
return { success: true, data: { id: call.params.id, status: newStatus }, summary: `课程 ${call.params.id} 状态已切换为 ${newStatus}` };
}
case 'toggle-content-status': {
const content = await this.prisma.content.findUnique({ where: { id: call.params.id } });
if (!content) throw new Error('内容不存在');
const newContentStatus = content.status === 'PUBLISHED' ? 'INACTIVE' : 'PUBLISHED';
await this.prisma.content.update({ where: { id: call.params.id }, data: { status: newContentStatus as any } });
return { success: true, data: { id: call.params.id, status: newContentStatus }, summary: `内容 ${call.params.id} 状态已切换为 ${newContentStatus}` };
}
case 'toggle-prompt-status': {
const prompt = await this.prisma.prompt.findUnique({ where: { id: call.params.id } });
if (!prompt) throw new Error('提示词不存在');
const newPromptStatus = prompt.status === 'PUBLISHED' ? 'INACTIVE' : 'PUBLISHED';
await this.prisma.prompt.update({ where: { id: call.params.id }, data: { status: newPromptStatus as any } });
return { success: true, data: { id: call.params.id, status: newPromptStatus }, summary: `提示词 ${call.params.id} 状态已切换为 ${newPromptStatus}` };
}
case 'get-enterprise-orgs': {
const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
return { success: true, data: { items, total: items.length }, summary: `${items.length} 个企业组织` };
}
default:
throw new Error(`未知工具: ${call.tool}`);
}
} catch (err: any) {
return { success: false, data: null, summary: `执行失败: ${err.message}` };
}
}
}
@@ -77,6 +77,35 @@ export class OperationsController {
return { items: await this.prisma.systemConfig.findMany({ where: { category } }) };
}
// Content status toggles (for admin list pages)
@Put('courses/:id/status')
async toggleCourseStatus(@Param('id') id: string, @Body() body: { status: string }) {
return this.prisma.course.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
}
@Put('contents/:id/status')
async toggleContentStatus(@Param('id') id: string, @Body() body: { status: string }) {
return this.prisma.content.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
}
@Put('prompts/:id/status')
async togglePromptStatus(@Param('id') id: string, @Body() body: { status: string }) {
return this.prisma.prompt.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
}
@Put('tools/:id/status')
async toggleToolStatus(@Param('id') id: string, @Body() body: { status: string }) {
return this.prisma.tool.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
}
// Comments
@Get('comments')
async comments(@Query('status') status?: string) {
const where: any = {};
if (status) where.status = status;
return { items: await this.prisma.comment.findMany({ where, orderBy: { createdAt: 'desc' }, include: { user: { select: { id: true, nickname: true } }, post: { select: { id: true, title: true } } } }) };
}
@Put('config/:key')
async updateConfig(@Param('key') key: string, @Body() body: { value: string }) {
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
+18 -15
View File
@@ -28,8 +28,9 @@ const MODEL_CATALOG: Record<string, ModelInfo> = {
'openai': { id: 'openai', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 },
'gpt-3.5': { id: 'gpt-3.5', provider: 'OpenAI', capabilities: ['chat', 'code'], contextWindow: 16384 },
'gpt-4': { id: 'gpt-4', provider: 'OpenAI', capabilities: ['chat', 'code', 'vision'], contextWindow: 32768 },
'opencode-go': { id: 'opencode-go', provider: 'OpenCode Go', capabilities: ['chat', 'code'], contextWindow: 32768 },
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: 'OpenCode Go', capabilities: ['chat', 'code'], contextWindow: 32768 },
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
'sensenova-6.7-flash-lite': { id: 'sensenova-6.7-flash-lite', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
'sensenova-u1-fast': { id: 'sensenova-u1-fast', provider: '商汤科技', capabilities: ['chat', 'code', 'vision'], contextWindow: 65536 },
}
@Injectable()
@@ -58,19 +59,21 @@ export class AIGatewayService {
this.logger.log(`OpenAI 兼容接口已注册: ${defaultModel}`);
}
if (process.env.OPENCODE_API_KEY) {
let apiUrl = process.env.OPENCODE_API_URL || 'https://opencode.ai/zen/go/v1';
if (process.env.SENSENOVA_API_KEY) {
let apiUrl = process.env.SENSENOVA_API_URL || 'https://token.sensenova.cn/v1';
if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
}
const defaultModel = process.env.OPENCODE_MODEL || 'deepseek-v4-flash';
this.providers.set('opencode', new OpenAICompatibleProvider(
process.env.OPENCODE_API_KEY!,
apiUrl,
defaultModel,
'OpenCode Go',
));
this.logger.log(`OpenCode Go 已注册: ${defaultModel}`);
const models = ['deepseek-v4-flash', 'sensenova-6.7-flash-lite', 'sensenova-u1-fast'];
for (const modelName of models) {
this.providers.set(modelName, new OpenAICompatibleProvider(
process.env.SENSENOVA_API_KEY!,
apiUrl,
modelName,
'商汤科技',
));
}
this.logger.log(`商汤科技已注册: ${models.join(', ')}`);
}
}
@@ -82,9 +85,9 @@ export class AIGatewayService {
'gpt-4': 'openai',
'longcat': 'openai',
'meituan/longcat-flash-lite': 'openai',
'opencode-go': 'opencode',
'opencode': 'opencode',
'deepseek-v4-flash': 'opencode',
'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
'sensenova-u1-fast': 'sensenova-u1-fast',
};
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
@@ -15,6 +15,15 @@ class CreateOrderDto {
@IsOptional()
@IsString()
payChannel?: string;
@IsOptional()
@IsString()
@IsIn(['JSAPI', 'NATIVE', 'MWEB'])
tradeType?: string;
@IsOptional()
@IsString()
openid?: string;
}
@ApiTags('订单')
+7 -1
View File
@@ -9,7 +9,7 @@ export class OrdersService {
private paymentService: PaymentService,
) {}
async create(userId: number, data: { amount: number; planType: string; payChannel?: string }) {
async create(userId: number, data: { amount: number; planType: string; payChannel?: string; tradeType?: string; openid?: string }) {
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const order = await this.prisma.order.create({
@@ -33,7 +33,13 @@ export class OrdersService {
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 };
@@ -24,6 +24,7 @@ describe('OrdersService', () => {
const mockPaymentService = {
createUnifiedOrder: jest.fn(),
completeMockPayment: jest.fn(),
};
beforeEach(async () => {
@@ -37,6 +37,12 @@ export class PaymentService {
notifyUrl: process.env.WX_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/wxpay/notify',
};
if (process.env.WX_PAY_MOCK === 'true') {
this.logger.warn('WX_PAY_MOCK=true,强制使用模拟支付模式');
this.wxPay = null;
return;
}
try {
const fs = require('fs');
const { WechatPay } = require('wechat-pay-nodejs');
@@ -237,6 +243,27 @@ export class PaymentService {
}
}
/**
* 完成模拟支付(mock 模式):自动更新订单状态 + 升级会员订阅
*/
async completeMockPayment(orderNo: string): Promise<void> {
if (this.wxPay) {
this.logger.warn(`非模拟模式,跳过模拟支付完成: ${orderNo}`);
return;
}
const order = await this.prisma.order.findUnique({ where: { orderNo } });
if (!order) {
this.logger.warn(`订单不存在: ${orderNo}`);
return;
}
if (order.status === 'PAID') {
this.logger.log(`订单已支付,跳过重复处理: ${orderNo}`);
return;
}
await this.updateOrderAndMembership(orderNo, Math.round(order.amount * 100));
this.logger.log(`模拟支付完成: ${orderNo}`);
}
async refund(outTradeNo: string, amount: number, reason?: string) {
if (!this.wxPay) {
this.logger.log(`模拟退款: ${outTradeNo}`);