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

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