feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径

This commit is contained in:
yuzhiran-dev
2026-05-18 09:48:51 +08:00
commit 11bb86854c
277 changed files with 37755 additions and 0 deletions
@@ -0,0 +1,86 @@
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param } 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';
@ApiTags('管理后台')
@Controller('admin')
export class AdminController {
constructor(
private adminService: AdminService,
private coursesService: CoursesService,
private prisma: PrismaService,
) {}
@Post('login')
async login(@Body() body: { username: string; password: string }) {
return this.adminService.login(body.username, body.password);
}
@Get('dashboard')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async dashboard() {
return this.adminService.getDashboard();
}
@Get('orders')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orders(@Req() req: any) {
const items = await this.prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { user: { select: { id: true, nickname: true, phone: true } } },
});
return { items };
}
@Put('courses/:id/chapters')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateCourseChapters(@Param('id') id: string, @Body() body: any) {
return this.coursesService.updateWithChapters(+id, body);
}
@Get('comments/pending')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async pendingComments() {
const items = await this.prisma.comment.findMany({
where: { status: 'PENDING_REVIEW' },
include: {
user: { select: { id: true, nickname: true, avatar: true } },
post: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
take: 50,
});
return { items };
}
@Put('comments/:id/approve')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async approveComment(@Param('id') id: string) {
await this.prisma.comment.update({
where: { id: parseInt(id) },
data: { status: 'PUBLISHED', reviewedAt: new Date() },
});
return { ok: true };
}
@Put('comments/:id/reject')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async rejectComment(@Param('id') id: string, @Body() body: { reason?: string }) {
await this.prisma.comment.update({
where: { id: parseInt(id) },
data: { status: 'REJECTED', reviewNote: body.reason || '违规内容', reviewedAt: new Date() },
});
return { ok: true };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
if (!request.user?.isAdmin) {
throw new ForbiddenException('无管理员权限');
}
return true;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [CoursesModule, AuthModule],
controllers: [AdminController],
providers: [AdminService, AdminGuard],
exports: [AdminService],
})
export class AdminModule {}
@@ -0,0 +1,56 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async login(username: string, password: string) {
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
if (!admin || admin.status !== 'ACTIVE') {
throw new UnauthorizedException('管理员账号不可用');
}
const isValid = await bcrypt.compare(password, admin.passwordHash);
if (!isValid) {
throw new UnauthorizedException('密码错误');
}
await this.prisma.adminUser.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
const token = this.jwtService.sign(
{ sub: admin.id, type: 'admin' },
{ expiresIn: '8h' },
);
return { token, id: admin.id, username: admin.username, role: admin.role };
}
async getDashboard() {
const [userCount, courseCount, contentCount, promptCount, orderCount] = await Promise.all([
this.prisma.user.count({ where: { deletedAt: null } }),
this.prisma.course.count({ where: { deletedAt: null } }),
this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED' } }),
this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED' } }),
this.prisma.order.count(),
]);
return {
stats: { userCount, courseCount, contentCount, promptCount, orderCount },
};
}
async logAction(adminId: number, action: string, target?: string, detail?: string, ip?: string) {
return this.prisma.adminLog.create({
data: { adminId, action, target, detail, ip },
});
}
}
@@ -0,0 +1,172 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { AdminService } from '../admin.service';
import { PrismaService } from '../../../prisma/prisma.service';
// Mock bcrypt
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
hash: jest.fn(),
}));
describe('AdminService', () => {
let service: AdminService;
let prisma: PrismaService;
const mockPrisma = {
adminUser: {
findUnique: jest.fn(),
update: jest.fn(),
},
user: { count: jest.fn() },
course: { count: jest.fn() },
content: { count: jest.fn() },
prompt: { count: jest.fn() },
order: { count: jest.fn() },
adminLog: { create: jest.fn() },
};
const mockJwtService = {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
service = module.get<AdminService>(AdminService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('login', () => {
const mockAdmin = {
id: 1,
username: 'admin',
passwordHash: '$2a$10$hashed',
role: 'superadmin',
status: 'ACTIVE',
};
it('should login successfully', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(mockAdmin);
jest.spyOn(bcrypt, 'compare').mockResolvedValue(true as never);
mockPrisma.adminUser.update.mockResolvedValue(mockAdmin);
const result = await service.login('admin', 'admin123456');
expect(result).toEqual(expect.objectContaining({ id: 1, username: 'admin', role: 'superadmin' }));
expect(mockPrisma.adminUser.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 1 },
data: { lastLoginAt: expect.any(Date) },
})
);
});
it('should throw on wrong password', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(mockAdmin);
jest.spyOn(bcrypt, 'compare').mockResolvedValue(false as never);
await expect(service.login('admin', 'wrongpass')).rejects.toThrow(UnauthorizedException);
});
it('should throw when admin not found', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(null);
await expect(service.login('unknown', 'pass')).rejects.toThrow(UnauthorizedException);
});
it('should throw when admin is inactive', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue({ ...mockAdmin, status: 'INACTIVE' });
await expect(service.login('admin', 'pass')).rejects.toThrow(UnauthorizedException);
});
});
describe('getDashboard', () => {
it('should return all stats', async () => {
mockPrisma.user.count.mockResolvedValue(100);
mockPrisma.course.count.mockResolvedValue(20);
mockPrisma.content.count.mockResolvedValue(45);
mockPrisma.prompt.count.mockResolvedValue(30);
mockPrisma.order.count.mockResolvedValue(15);
const result = await service.getDashboard();
expect(result).toEqual({
stats: {
userCount: 100,
courseCount: 20,
contentCount: 45,
promptCount: 30,
orderCount: 15,
},
});
});
it('should return zeroes when no data', async () => {
mockPrisma.user.count.mockResolvedValue(0);
mockPrisma.course.count.mockResolvedValue(0);
mockPrisma.content.count.mockResolvedValue(0);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.order.count.mockResolvedValue(0);
const result = await service.getDashboard();
expect(result).toEqual({
stats: {
userCount: 0, courseCount: 0, contentCount: 0,
promptCount: 0, orderCount: 0,
},
});
});
it('should filter deleted users and courses', async () => {
mockPrisma.user.count.mockResolvedValue(50);
mockPrisma.course.count.mockResolvedValue(10);
mockPrisma.content.count.mockResolvedValue(5);
mockPrisma.prompt.count.mockResolvedValue(3);
mockPrisma.order.count.mockResolvedValue(2);
await service.getDashboard();
expect(mockPrisma.user.count).toHaveBeenCalledWith({ where: { deletedAt: null } });
expect(mockPrisma.course.count).toHaveBeenCalledWith({ where: { deletedAt: null } });
expect(mockPrisma.content.count).toHaveBeenCalledWith({
where: { deletedAt: null, status: 'PUBLISHED' },
});
});
});
describe('logAction', () => {
it('should create an admin log entry', async () => {
const mockLog = { id: 1, adminId: 1, action: 'login', target: null, detail: null, ip: null };
mockPrisma.adminLog.create.mockResolvedValue(mockLog);
const result = await service.logAction(1, 'login');
expect(result).toEqual(mockLog);
expect(mockPrisma.adminLog.create).toHaveBeenCalledWith({
data: { adminId: 1, action: 'login', target: undefined, detail: undefined, ip: undefined },
});
});
it('should create log with full details', async () => {
mockPrisma.adminLog.create.mockResolvedValue({ id: 2 });
await service.logAction(1, 'update', 'courses/1', '修改课程标题', '127.0.0.1');
expect(mockPrisma.adminLog.create).toHaveBeenCalledWith({
data: { adminId: 1, action: 'update', target: 'courses/1', detail: '修改课程标题', ip: '127.0.0.1' },
});
});
});
});
@@ -0,0 +1,158 @@
import { Injectable, Logger } from '@nestjs/common';
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface ChatOptions {
temperature?: number;
top_p?: number;
max_tokens?: number;
}
interface AIProvider {
name: string;
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
}
@Injectable()
export class AIGatewayService {
private readonly logger = new Logger(AIGatewayService.name);
private providers: Map<string, AIProvider> = new Map();
constructor() {
this.registerProviders();
}
private registerProviders() {
if (process.env.OPENAI_API_KEY) {
let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions';
if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
}
const defaultModel = process.env.OPENAI_MODEL || 'gpt-3.5-turbo';
this.providers.set('openai', new OpenAICompatibleProvider(
process.env.OPENAI_API_KEY!,
apiUrl,
defaultModel,
'OpenAI 兼容接口',
));
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 (!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}`);
}
}
async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> {
const modelMap: Record<string, string> = {
'general': 'openai',
'openai': 'openai',
'gpt-3.5': 'openai',
'gpt-4': 'openai',
'longcat': 'openai',
'meituan/longcat-flash-lite': 'openai',
'opencode-go': 'opencode',
'opencode': 'opencode',
'deepseek-v4-flash': 'opencode',
};
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
const provider = this.providers.get(providerKey);
if (provider) {
try {
const reply = await provider.chat(messages, options);
if (typeof reply !== 'string' || reply.length === 0) {
throw new Error(`AI 返回内容为空: ${JSON.stringify(reply)}`);
}
return reply;
} catch (err: any) {
this.logger.error(`${provider.name} 调用失败: ${err.message}`);
return this.fallback(messages);
}
}
return this.fallback(messages);
}
private fallback(messages: ChatMessage[]): string {
const lastMsg = messages[messages.length - 1]?.content || '';
const mockReplies: Record<string, string> = {
'你好': '你好!我是宇之然 AI 助手,很高兴为你服务!',
'hello': 'Hello! I am YuZhiRan AI assistant, nice to meet you!',
};
for (const [key, reply] of Object.entries(mockReplies)) {
if (lastMsg.toLowerCase().includes(key)) {
return reply;
}
}
if (lastMsg.includes('提示词') || lastMsg.includes('prompt')) {
return '好的提示词需要明确角色、任务、输出格式和约束条件。例如:"你是一名专业的文案编辑,请帮我优化以下产品描述,要求语言简洁有力,突出产品核心卖点,控制在200字以内。"';
}
if (lastMsg.includes('模型') || lastMsg.includes('大模型')) {
return '目前主流的 AI 大模型包括:OpenAI 的 GPT 系列、Anthropic 的 Claude 系列、Google 的 Gemini 系列,以及国内的 DeepSeek、通义千问、文心一言、GLM 等。各模型在语言理解、代码生成、逻辑推理等方面各有优势。';
}
const names = Array.from(this.providers.values()).map(p => p.name).join('、');
return `我是宇之然 AI 助手。关于"${lastMsg.slice(0, 50)}..."的问题,我已收到。当前 AI 沙箱处于模拟模式,请配置 API Key 以获取真实回复。已配置的 API:${names}`;
}
getRegisteredProviders(): string[] {
return Array.from(this.providers.keys());
}
}
class OpenAICompatibleProvider implements AIProvider {
name: string;
private apiKey: string;
private apiUrl: string;
private defaultModel: string;
constructor(apiKey: string, apiUrl: string, defaultModel: string, name?: string) {
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.defaultModel = defaultModel;
this.name = name || 'OpenAI 兼容接口';
}
async chat(messages: ChatMessage[], options?: ChatOptions): Promise<string> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.defaultModel,
messages,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 1,
max_tokens: options?.max_tokens ?? 2000,
}),
});
if (!res.ok) {
throw new Error(`${this.name} API error: ${res.status} ${await res.text()}`);
}
const data = await res.json() as any;
return data.choices[0].message.content;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AIGatewayService } from './ai-gateway.service';
@Module({
providers: [AIGatewayService],
exports: [AIGatewayService],
})
export class AIModule {}
@@ -0,0 +1,131 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AIGatewayService } from '../ai-gateway.service';
describe('AIGatewayService', () => {
let service: AIGatewayService;
beforeEach(async () => {
// Clear env before each test
delete process.env.DEEPSEEK_API_KEY;
delete process.env.DASHSCOPE_API_KEY;
const module: TestingModule = await Test.createTestingModule({
providers: [AIGatewayService],
}).compile();
service = module.get<AIGatewayService>(AIGatewayService);
});
describe('initialization', () => {
it('should have no providers when no API keys set', () => {
const providers = service.getRegisteredProviders();
expect(providers).toEqual([]);
});
});
describe('fallback mock responses', () => {
it('should greet when asked "你好"', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '你好' },
]);
expect(result).toContain('你好!我是宇之然 AI 助手');
});
it('should greet in English for "hello"', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: 'hello' },
]);
expect(result).toContain('Hello! I am YuZhiRan AI assistant');
});
it('should provide prompt tips when asked about 提示词', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '如何写好提示词?' },
]);
expect(result).toContain('明确角色');
expect(result).toContain('输出格式');
});
it('should provide model info when asked about 大模型', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '有哪些大模型?' },
]);
expect(result).toContain('GPT');
expect(result).toContain('Claude');
expect(result).toContain('DeepSeek');
});
it('should return generic fallback for unknown queries', async () => {
const result = await service.chat('any-model', [
{ role: 'user', content: '今天的天气怎么样?' },
]);
expect(result).toContain('模拟模式');
expect(result).toContain('API Key');
});
});
describe('model routing', () => {
it('should route deepseek models to deepseek provider (but fallback to mock)', async () => {
const result = await service.chat('deepseek-chat', [
{ role: 'user', content: '你好' },
]);
// Falls back to mock since no API key
expect(result).toContain('宇之然 AI 助手');
});
it('should route qwen models to dashscope provider (but fallback to mock)', async () => {
const result = await service.chat('qwen-max', [
{ role: 'user', content: '你好' },
]);
expect(result).toContain('宇之然 AI 助手');
});
it('should handle unknown model names by falling back', async () => {
const result = await service.chat('unknown-model-xyz', [
{ role: 'user', content: '测试' },
]);
expect(result).toContain('模拟模式');
});
});
describe('contextual fallback', () => {
it('should include user message in fallback response', async () => {
const result = await service.chat('any', [
{ role: 'user', content: '如何学习 Python 编程?' },
]);
expect(result).toContain('Python');
});
it('should handle multi-turn conversations', async () => {
const messages = [
{ role: 'user' as const, content: '你是谁?' },
{ role: 'assistant' as const, content: '我是 AI 助手。' },
{ role: 'user' as const, content: '提示词有什么技巧?' },
];
const result = await service.chat('any', messages);
expect(result).toContain('提示词');
});
it('should truncate long user messages', async () => {
const longMsg = 'a'.repeat(200);
const result = await service.chat('any', [
{ role: 'user', content: longMsg },
]);
expect(result).toContain('...');
});
});
});
@@ -0,0 +1,33 @@
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
async register(@Body() body: RegisterDto) {
return this.authService.register(body);
}
@Post('login')
async login(@Body() body: { account: string; password: string }) {
return this.authService.login(body.account, body.password);
}
@Post('refresh')
async refresh(@Body() body: { accessToken: string }) {
return this.authService.refreshAccessToken(body.accessToken);
}
@Get('profile')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async profile(@Req() req: any) {
return this.authService.getProfile(req.user.userId);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') || '2h' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+91
View File
@@ -0,0 +1,91 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async register(data: { phone?: string; email?: string; password: string; nickname?: string }) {
const passwordHash = await bcrypt.hash(data.password, 10);
const user = await this.prisma.user.create({
data: {
phone: data.phone,
email: data.email,
passwordHash,
nickname: data.nickname || data.phone || data.email?.split('@')[0],
},
});
return this.generateTokens(user.id);
}
async login(account: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ phone: account }, { email: account }],
deletedAt: null,
},
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('账号或密码错误');
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('账号或密码错误');
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return this.generateTokens(user.id);
}
async refreshAccessToken(accessToken: string) {
try {
const payload = this.jwtService.verify(accessToken, { ignoreExpiration: true });
const user = await this.prisma.user.findUnique({
where: { id: payload.sub, deletedAt: null },
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('用户不可用');
}
return this.generateTokens(user.id);
} catch {
throw new UnauthorizedException('Token 无效');
}
}
async getProfile(userId: number) {
return 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,
},
});
}
private generateTokens(userId: number) {
const payload = { sub: userId };
return {
accessToken: this.jwtService.sign(payload, { expiresIn: '2h' }),
refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}
}
@@ -0,0 +1,20 @@
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsNotEmpty({ message: '密码不能为空' })
@IsString()
@MinLength(6, { message: '密码至少6位' })
password: string;
@IsOptional()
@IsString()
nickname?: string;
}
+36
View File
@@ -0,0 +1,36 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private config: ConfigService,
private prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET') || 'yuzhiran-ai-default-secret';
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
async validate(payload: { sub: number; type?: string }) {
if (payload.type === 'admin') {
const admin = await this.prisma.adminUser.findUnique({ where: { id: payload.sub } });
if (!admin || admin.status !== 'ACTIVE') {
throw new UnauthorizedException();
}
return { userId: payload.sub, isAdmin: true };
}
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user || user.deletedAt) {
throw new UnauthorizedException();
}
return { userId: payload.sub, isAdmin: false };
}
}
@@ -0,0 +1,45 @@
// @ts-nocheck
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from '../auth.service';
import { PrismaService } from '../../../prisma/prisma.service';
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
hash: jest.fn(),
}));
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const mockPrisma = {
user: {
create: jest.fn().mockResolvedValue({ id: 1 }),
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
},
};
const mockJwtService = {
sign: jest.fn().mockReturnValue('mock-token'),
verify: jest.fn().mockReturnValue({ sub: 1 }),
};
const module = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
service = module.get<AuthService>(AuthService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,37 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('分类')
@Controller('categories')
export class CategoriesController {
constructor(private prisma: PrismaService) {}
@Get()
async findAll() {
return this.prisma.category.findMany({
orderBy: { sortOrder: 'asc' },
include: { _count: { select: { courses: true, prompts: true, tools: true, contents: true } } },
});
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.prisma.category.findUnique({ where: { id: +id } });
}
@Post()
async create(@Body() body: { name: string; slug: string; description?: string; sortOrder?: number }) {
return this.prisma.category.create({ data: body });
}
@Put(':id')
async update(@Param('id') id: string, @Body() body: any) {
return this.prisma.category.update({ where: { id: +id }, data: body });
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.prisma.category.delete({ where: { id: +id } });
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
@Module({
controllers: [CategoriesController],
})
export class CategoriesModule {}
@@ -0,0 +1,60 @@
import { Controller, Get, Post, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { CommunityService } from './community.service';
@ApiTags('圈子')
@Controller('circles')
export class CirclesController {
constructor(private communityService: CommunityService) {}
@Get()
async findCircles(@Query('category') category?: string) {
return this.communityService.findCircles(category);
}
@Get(':id')
async findCircleById(@Param('id') id: string) {
return this.communityService.findCircleById(+id);
}
@Get(':id/posts')
async findCirclePosts(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.communityService.findCirclePosts(+id, {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async createCircle(@Req() req: any, @Body() body: { name: string; description?: string; tags?: string }) {
return this.communityService.createCircle(req.user.userId, body);
}
@Post(':id/join')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async joinCircle(@Req() req: any, @Param('id') id: string) {
return this.communityService.joinCircle(req.user.userId, +id);
}
@Post(':id/leave')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async leaveCircle(@Req() req: any, @Param('id') id: string) {
return this.communityService.leaveCircle(req.user.userId, +id);
}
@Get(':id/membership')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async checkMembership(@Req() req: any, @Param('id') id: string) {
return this.communityService.checkCircleMembership(req.user.userId, +id);
}
}
@@ -0,0 +1,131 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { CommunityService } from './community.service';
@ApiTags('社区')
@Controller('community')
export class CommunityController {
constructor(private communityService: CommunityService) {}
@Get('posts')
async findPosts(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('tag') tag?: string,
@Query('circleId') circleId?: string,
@Query('userId') userId?: string,
) {
return this.communityService.findPosts({
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
tag,
circleId: circleId ? parseInt(circleId) : undefined,
userId: userId ? parseInt(userId) : undefined,
});
}
@Get('posts/:id')
async findPostById(@Param('id') id: string) {
return this.communityService.findPostById(parseInt(id));
}
@Post('posts')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async createPost(@Req() req: any, @Body() body: { title: string; content: string; tags?: string; circleId?: number }) {
return this.communityService.createPost(req.user.userId, body);
}
@Put('posts/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async updatePost(@Req() req: any, @Param('id') id: string, @Body() body: { title?: string; content?: string; tags?: string }) {
return this.communityService.updatePost(req.user.userId, parseInt(id), body);
}
@Delete('posts/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async deletePost(@Req() req: any, @Param('id') id: string) {
return this.communityService.deletePost(req.user.userId, parseInt(id));
}
@Post('posts/:id/comments')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async addComment(
@Req() req: any,
@Param('id') id: string,
@Body('content') content: string,
@Body('parentId') parentId?: number,
) {
return this.communityService.addComment(req.user.userId, parseInt(id), content, parentId);
}
@Post('posts/:id/like')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async toggleLike(@Req() req: any, @Param('id') id: string) {
return this.communityService.toggleLike(req.user.userId, parseInt(id));
}
@Get('posts/:id/like')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async checkLike(@Req() req: any, @Param('id') id: string) {
return this.communityService.checkLike(req.user.userId, parseInt(id));
}
@Get('feed')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getFeed(@Req() req: any, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.communityService.getFeed(req.user.userId, {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Post('users/:id/follow')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async followUser(@Req() req: any, @Param('id') id: string) {
return this.communityService.followUser(req.user.userId, parseInt(id));
}
@Delete('users/:id/follow')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async unfollowUser(@Req() req: any, @Param('id') id: string) {
return this.communityService.unfollowUser(req.user.userId, parseInt(id));
}
@Get('users/:id/follow')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async checkFollow(@Req() req: any, @Param('id') id: string) {
return this.communityService.checkFollow(req.user.userId, parseInt(id));
}
@Get('users/:id/followers')
async getFollowers(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.communityService.getFollowers(parseInt(id), {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Get('users/:id/following')
async getFollowing(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.communityService.getFollowing(parseInt(id), {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Get('users/:id/profile')
async getUserProfile(@Param('id') id: string) {
return this.communityService.getUserProfile(parseInt(id));
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CommunityController } from './community.controller';
import { CirclesController } from './circles.controller';
import { CommunityService } from './community.service';
import { NotificationService } from '../notifications/notification.service';
@Module({
controllers: [CommunityController, CirclesController],
providers: [CommunityService, NotificationService],
})
export class CommunityModule {}
@@ -0,0 +1,385 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationService } from '../notifications/notification.service';
const SENSITIVE_WORDS = ['敏感词1', '敏感词2', '广告', '诈骗', '违法', '赌博'];
function containsSensitiveWord(text: string): string | null {
const lower = text.toLowerCase();
for (const word of SENSITIVE_WORDS) {
if (lower.includes(word)) return word;
}
return null;
}
@Injectable()
export class CommunityService {
constructor(
private prisma: PrismaService,
private notificationService: NotificationService,
) {}
async findPosts(params: { page?: number; pageSize?: number; tag?: string; circleId?: number; userId?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const where: any = { status: 'PUBLISHED' };
if (params.tag) where.tags = { contains: params.tag };
if (params.circleId) where.circleId = params.circleId;
if (params.userId) where.userId = params.userId;
const [items, total] = await Promise.all([
this.prisma.post.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
}),
this.prisma.post.count({ where }),
]);
return { items, total, page, pageSize };
}
async findPostById(id: number) {
const post = await this.prisma.post.findUnique({
where: { id },
include: {
user: { select: { id: true, nickname: true, avatar: true } },
comments: {
where: { status: 'PUBLISHED' },
include: {
user: { select: { id: true, nickname: true, avatar: true } },
replies: {
where: { status: 'PUBLISHED' },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
},
},
},
},
});
if (post) {
await this.prisma.post.update({
where: { id },
data: { viewCount: { increment: 1 } },
});
}
return post;
}
async createPost(userId: number, data: { title: string; content: string; tags?: string; circleId?: number }) {
const post = await this.prisma.post.create({
data: {
userId,
title: data.title,
content: data.content,
tags: data.tags,
circleId: data.circleId,
},
include: { user: { select: { id: true, nickname: true, avatar: true } } },
});
await this.prisma.user.update({
where: { id: userId },
data: { postCount: { increment: 1 } },
});
return post;
}
async updatePost(userId: number, postId: number, data: { title?: string; content?: string; tags?: string }) {
const post = await this.prisma.post.findUnique({ where: { id: postId } });
if (!post) throw new NotFoundException('帖子不存在');
if (post.userId !== userId) throw new BadRequestException('无权编辑此帖子');
return this.prisma.post.update({
where: { id: postId },
data,
include: { user: { select: { id: true, nickname: true, avatar: true } } },
});
}
async deletePost(userId: number, postId: number) {
const post = await this.prisma.post.findUnique({ where: { id: postId } });
if (!post) throw new NotFoundException('帖子不存在');
if (post.userId !== userId) throw new BadRequestException('无权删除此帖子');
await this.prisma.post.delete({ where: { id: postId } });
await this.prisma.user.update({
where: { id: userId },
data: { postCount: { decrement: 1 } },
});
return { deleted: true };
}
async addComment(userId: number, postId: number, content: string, parentId?: number) {
const post = await this.prisma.post.findUnique({ where: { id: postId } });
if (!post) throw new NotFoundException('帖子不存在');
if (parentId) {
const parentComment = await this.prisma.comment.findUnique({ where: { id: parentId } });
if (!parentComment || parentComment.postId !== postId) {
throw new BadRequestException('父评论不存在');
}
}
const matchedWord = containsSensitiveWord(content);
const status = matchedWord ? 'REJECTED' : 'PENDING_REVIEW';
const comment = await this.prisma.comment.create({
data: { userId, postId, content, parentId, status },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
});
if (matchedWord) {
return { ...comment, rejected: true, reason: `评论包含敏感词「${matchedWord}` };
}
await this.prisma.post.update({
where: { id: postId },
data: { commentCount: { increment: 1 } },
});
if (post.userId !== userId) {
await this.notificationService.create({
userId: post.userId,
type: 'comment',
title: `${comment.user.nickname || '用户'} 评论了你的帖子`,
content: content.slice(0, 100),
link: `/community/${postId}`,
relatedId: postId,
});
}
return comment;
}
async toggleLike(userId: number, postId: number) {
const existing = await this.prisma.postLike.findFirst({ where: { userId, postId } });
if (existing) {
await this.prisma.postLike.delete({ where: { id: existing.id } });
await this.prisma.post.update({
where: { id: postId },
data: { likeCount: { decrement: 1 } },
});
return { liked: false };
} else {
await this.prisma.postLike.create({ data: { userId, postId } });
await this.prisma.post.update({
where: { id: postId },
data: { likeCount: { increment: 1 } },
});
const post = await this.prisma.post.findUnique({ where: { id: postId }, select: { userId: true, title: true } });
if (post && post.userId !== userId) {
await this.notificationService.create({
userId: post.userId,
type: 'like',
title: `有人赞了你的帖子「${post.title.slice(0, 30)}`,
link: `/community/${postId}`,
relatedId: postId,
});
}
return { liked: true };
}
}
async checkLike(userId: number, postId: number) {
const existing = await this.prisma.postLike.findFirst({ where: { userId, postId } });
return { liked: !!existing };
}
async getFeed(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const following = await this.prisma.follow.findMany({
where: { followerId: userId },
select: { followingId: true },
});
const followingIds = following.map(f => f.followingId);
const where: any = { status: 'PUBLISHED' };
if (followingIds.length > 0) {
where.userId = { in: followingIds };
}
const [items, total] = await Promise.all([
this.prisma.post.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, nickname: true, avatar: true } } },
}),
this.prisma.post.count({ where }),
]);
return { items, total, page, pageSize };
}
async followUser(followerId: number, followingId: number) {
if (followerId === followingId) throw new BadRequestException('不能关注自己');
const existing = await this.prisma.follow.findUnique({
where: { followerId_followingId: { followerId, followingId } },
});
if (existing) throw new ConflictException('已关注该用户');
await this.prisma.follow.create({ data: { followerId, followingId } });
await this.prisma.user.update({ where: { id: followerId }, data: { followingCount: { increment: 1 } } });
await this.prisma.user.update({ where: { id: followingId }, data: { followerCount: { increment: 1 } } });
const follower = await this.prisma.user.findUnique({ where: { id: followerId }, select: { nickname: true } });
await this.notificationService.create({
userId: followingId,
type: 'follow',
title: `${follower?.nickname || '用户'} 关注了你`,
link: `/users/${followerId}`,
relatedId: followerId,
});
return { followed: true };
}
async unfollowUser(followerId: number, followingId: number) {
const existing = await this.prisma.follow.findUnique({
where: { followerId_followingId: { followerId, followingId } },
});
if (!existing) throw new NotFoundException('未关注该用户');
await this.prisma.follow.delete({ where: { id: existing.id } });
await this.prisma.user.update({ where: { id: followerId }, data: { followingCount: { decrement: 1 } } });
await this.prisma.user.update({ where: { id: followingId }, data: { followerCount: { decrement: 1 } } });
return { followed: false };
}
async checkFollow(followerId: number, followingId: number) {
const existing = await this.prisma.follow.findUnique({
where: { followerId_followingId: { followerId, followingId } },
});
return { followed: !!existing };
}
async getFollowers(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const [items, total] = await Promise.all([
this.prisma.follow.findMany({
where: { followingId: userId },
skip: (page - 1) * pageSize,
take: pageSize,
include: { follower: { select: { id: true, nickname: true, avatar: true, followerCount: true, followingCount: true } } },
orderBy: { createdAt: 'desc' },
}),
this.prisma.follow.count({ where: { followingId: userId } }),
]);
return { items: items.map(i => i.follower), total, page, pageSize };
}
async getFollowing(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 20), 50);
const [items, total] = await Promise.all([
this.prisma.follow.findMany({
where: { followerId: userId },
skip: (page - 1) * pageSize,
take: pageSize,
include: { following: { select: { id: true, nickname: true, avatar: true, followerCount: true, followingCount: true } } },
orderBy: { createdAt: 'desc' },
}),
this.prisma.follow.count({ where: { followerId: userId } }),
]);
return { items: items.map(i => i.following), total, page, pageSize };
}
async getUserProfile(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
select: {
id: true, nickname: true, avatar: true, bio: true,
followerCount: true, followingCount: true, postCount: true,
createdAt: true,
},
});
if (!user) throw new NotFoundException('用户不存在');
return user;
}
async findCircles(category?: string) {
const where: any = {};
if (category) where.tags = { contains: category };
return this.prisma.circle.findMany({
where,
include: { _count: { select: { members: true, posts: true } } },
orderBy: { createdAt: 'desc' },
});
}
async findCircleById(id: number) {
const circle = await this.prisma.circle.findUnique({
where: { id },
include: {
_count: { select: { members: true, posts: true } },
creator: { select: { id: true, nickname: true, avatar: true } },
},
});
if (!circle) throw new NotFoundException('圈子不存在');
return circle;
}
async findCirclePosts(circleId: number, params: { page?: number; pageSize?: number }) {
return this.findPosts({ ...params, circleId });
}
async createCircle(userId: number, data: { name: string; description?: string; tags?: string }) {
const existing = await this.prisma.circle.findFirst({ where: { name: data.name } });
if (existing) throw new ConflictException('圈子名称已存在');
return this.prisma.circle.create({
data: { name: data.name, description: data.description, tags: data.tags, creatorId: userId },
include: { _count: { select: { members: true } } },
});
}
async joinCircle(userId: number, circleId: number) {
const circle = await this.prisma.circle.findUnique({ where: { id: circleId } });
if (!circle) throw new NotFoundException('圈子不存在');
const existing = await this.prisma.circleMember.findUnique({
where: { circleId_userId: { circleId, userId } },
});
if (existing) throw new ConflictException('已是圈子成员');
return this.prisma.circleMember.create({ data: { circleId, userId } });
}
async leaveCircle(userId: number, circleId: number) {
const existing = await this.prisma.circleMember.findUnique({
where: { circleId_userId: { circleId, userId } },
});
if (!existing) throw new BadRequestException('不是圈子成员');
await this.prisma.circleMember.delete({ where: { id: existing.id } });
return { left: true };
}
async checkCircleMembership(userId: number, circleId: number) {
const existing = await this.prisma.circleMember.findUnique({
where: { circleId_userId: { circleId, userId } },
});
return { isMember: !!existing };
}
}
@@ -0,0 +1,223 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { CommunityService } from '../community.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { NotificationService } from '../../notifications/notification.service';
const selectUser = { id: true, nickname: true, avatar: true };
describe('CommunityService', () => {
let service: CommunityService;
let prisma: PrismaService;
const mockPrisma = {
post: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
comment: {
create: jest.fn(),
},
postLike: {
findFirst: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
follow: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
circle: {
findMany: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
},
circleMember: {
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
};
const mockNotification = { create: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CommunityService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: NotificationService, useValue: mockNotification },
],
}).compile();
service = module.get<CommunityService>(CommunityService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findPosts', () => {
it('should return paginated posts without tag filter', async () => {
const mockPosts = [
{ id: 1, title: '帖子1', status: 'PUBLISHED' },
];
mockPrisma.post.findMany.mockResolvedValue(mockPosts);
mockPrisma.post.count.mockResolvedValue(1);
const result = await service.findPosts({});
expect(result).toEqual({ items: mockPosts, total: 1, page: 1, pageSize: 20 });
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'PUBLISHED' },
skip: 0,
take: 20,
include: { user: { select: selectUser } },
})
);
});
it('should filter by tag', async () => {
await service.findPosts({ tag: 'AI' });
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tags: { contains: 'AI' } }),
})
);
});
});
describe('findPostById', () => {
it('should increment viewCount and return post', async () => {
const mockPost = {
id: 1,
title: '测试帖子',
status: 'PUBLISHED',
user: { id: 1, nickname: '用户1' },
comments: [],
};
mockPrisma.post.update.mockResolvedValue(mockPost);
mockPrisma.post.findUnique.mockResolvedValue(mockPost);
const result = await service.findPostById(1);
expect(result).toEqual(mockPost);
expect(mockPrisma.post.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { viewCount: { increment: 1 } },
});
});
});
describe('createPost', () => {
it('should create a new post', async () => {
const mockPost = {
id: 1,
title: '新帖子',
content: '内容',
userId: 1,
};
mockPrisma.post.create.mockResolvedValue(mockPost);
const result = await service.createPost(1, {
title: '新帖子',
content: '内容',
});
expect(result).toEqual(mockPost);
expect(mockPrisma.post.create).toHaveBeenCalledWith({
data: {
userId: 1,
title: '新帖子',
content: '内容',
},
include: { user: { select: selectUser } },
});
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { postCount: { increment: 1 } },
});
});
});
describe('addComment', () => {
it('should add comment and increment commentCount', async () => {
const mockPost = { id: 1, userId: 2 };
mockPrisma.post.findUnique.mockResolvedValue(mockPost);
const mockComment = { id: 1, content: '评论', userId: 1, postId: 1, user: { id: 1, nickname: '用户1', avatar: null } };
mockPrisma.comment.create.mockResolvedValue(mockComment);
const result = await service.addComment(1, 1, '评论');
expect(result).toEqual(mockComment);
expect(mockPrisma.comment.create).toHaveBeenCalledWith({
data: { userId: 1, postId: 1, content: '评论', status: 'PENDING_REVIEW' },
include: { user: { select: selectUser } },
});
});
it('should throw NotFoundException if post not found', async () => {
mockPrisma.post.findUnique.mockResolvedValue(null);
await expect(service.addComment(1, 999, '评论')).rejects.toThrow(NotFoundException);
});
});
describe('toggleLike', () => {
it('should add like if not exists', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue(null);
mockPrisma.postLike.create.mockResolvedValue({});
mockPrisma.post.findUnique.mockResolvedValue({ id: 1, userId: 2, title: '帖子' });
const result = await service.toggleLike(1, 1);
expect(result).toEqual({ liked: true });
expect(mockPrisma.postLike.create).toHaveBeenCalledWith({
data: { userId: 1, postId: 1 },
});
});
it('should remove like if exists', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue({ id: 1 });
mockPrisma.postLike.delete.mockResolvedValue({});
const result = await service.toggleLike(1, 1);
expect(result).toEqual({ liked: false });
expect(mockPrisma.postLike.delete).toHaveBeenCalledWith({
where: { id: 1 },
});
});
});
describe('checkLike', () => {
it('should return liked status', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue({ id: 1 });
const result = await service.checkLike(1, 1);
expect(result).toEqual({ liked: true });
});
it('should return not liked if no record', async () => {
mockPrisma.postLike.findFirst.mockResolvedValue(null);
const result = await service.checkLike(1, 1);
expect(result).toEqual({ liked: false });
});
});
});
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ContentsService } from './contents.service';
@ApiTags('内容')
@Controller('contents')
export class ContentsController {
constructor(private contentsService: ContentsService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; contentType?: string }) {
return this.contentsService.findAll(query);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.contentsService.findById(+id);
}
@Post()
async create(@Body() body: any) {
return this.contentsService.create(body);
}
@Put(':id')
async update(@Param('id') id: string, @Body() body: any) {
return this.contentsService.update(+id, body);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.contentsService.remove(+id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ContentsController } from './contents.controller';
import { ContentsService } from './contents.service';
@Module({
controllers: [ContentsController],
providers: [ContentsService],
exports: [ContentsService],
})
export class ContentsModule {}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ContentsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; contentType?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, contentType } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null };
if (categoryId) where.categoryId = categoryId;
if (contentType) where.contentType = contentType;
const [items, total] = await Promise.all([
this.prisma.content.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { publishedAt: 'desc' },
select: {
id: true, title: true, summary: true, cover: true, contentType: true,
tags: true, authorName: true, viewCount: true, isAiGenerated: true,
publishedAt: true, createdAt: true, category: true,
},
}),
this.prisma.content.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
const content = await this.prisma.content.findUnique({ where: { id }, include: { category: true } });
if (!content) return null;
await this.prisma.content.update({ where: { id }, data: { viewCount: { increment: 1 } } });
return content;
}
async create(data: any) {
return this.prisma.content.create({ data });
}
async update(id: number, data: any) {
return this.prisma.content.update({ where: { id }, data });
}
async remove(id: number) {
return this.prisma.content.update({ where: { id }, data: { deletedAt: new Date() } });
}
}
@@ -0,0 +1,66 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { CoursesService } from './courses.service';
@ApiTags('课程')
@Controller('courses')
export class CoursesController {
constructor(private coursesService: CoursesService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFree?: boolean }) {
return this.coursesService.findAll(query);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.coursesService.findById(+id);
}
@Post()
async create(@Body() body: {
title: string; description?: string; cover?: string; categoryId?: number;
price?: number; isFree?: boolean;
}) {
return this.coursesService.create(body);
}
@Put(':id')
async update(@Param('id') id: string, @Body() body: any) {
return this.coursesService.update(+id, body);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.coursesService.remove(+id);
}
@Post(':courseId/lessons/:lessonId/progress')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async updateProgress(
@Req() req: any,
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@Body() body: { completed?: boolean; progress?: number },
) {
return this.coursesService.updateProgress(
req.user.userId,
+courseId,
+lessonId,
body.completed,
body.progress,
);
}
@Get('my-learning')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getMyLearning(@Req() req: any, @Query('courseId') courseId?: string) {
return this.coursesService.getLearningProgress(
req.user.userId,
courseId ? +courseId : undefined,
);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CoursesController } from './courses.controller';
import { CoursesService } from './courses.service';
@Module({
controllers: [CoursesController],
providers: [CoursesService],
exports: [CoursesService],
})
export class CoursesModule {}
@@ -0,0 +1,180 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class CoursesService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFree?: boolean }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, isFree } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null };
if (categoryId) where.categoryId = categoryId;
if (isFree !== undefined) where.isFree = isFree;
const [items, total] = await Promise.all([
this.prisma.course.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { sortOrder: 'asc' },
include: { category: true, chapters: { include: { lessons: true }, orderBy: { sortOrder: 'asc' } } },
}),
this.prisma.course.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
return this.prisma.course.findUnique({
where: { id },
include: {
category: true,
chapters: {
orderBy: { sortOrder: 'asc' },
include: { lessons: { orderBy: { sortOrder: 'asc' } } },
},
},
});
}
async create(data: {
title: string; description?: string; cover?: string; categoryId?: number;
price?: number; isFree?: boolean; sortOrder?: number;
}) {
return this.prisma.course.create({ data });
}
async update(id: number, data: any) {
const { chapters, ...courseData } = data;
return this.prisma.course.update({
where: { id },
data: courseData,
});
}
async updateWithChapters(id: number, data: any) {
const { chapters, ...courseData } = data;
if (chapters) {
const existingChapters = await this.prisma.chapter.findMany({ where: { courseId: id } });
const existingIds = existingChapters.map(c => c.id);
const incomingIds = chapters.filter((c: any) => c.id).map((c: any) => c.id);
const toDelete = existingIds.filter(eid => !incomingIds.includes(eid));
for (const cid of toDelete) {
await this.prisma.lesson.deleteMany({ where: { chapterId: cid } });
await this.prisma.chapter.delete({ where: { id: cid } });
}
for (const ch of chapters) {
if (ch.id) {
const { lessons, id: chId, courseId, ...chData } = ch;
await this.prisma.chapter.update({ where: { id: ch.id }, data: chData });
if (lessons) {
const existingLessons = await this.prisma.lesson.findMany({ where: { chapterId: ch.id } });
const existingLessonIds = existingLessons.map(l => l.id);
const incomingLessonIds = lessons.filter((l: any) => l.id).map((l: any) => l.id);
const lessonsToDelete = existingLessonIds.filter(eid => !incomingLessonIds.includes(eid));
for (const lid of lessonsToDelete) {
await this.prisma.lesson.delete({ where: { id: lid } });
}
for (const le of lessons) {
if (le.id) {
const { id: lessonId, chapterId, ...lessonData } = le;
await this.prisma.lesson.update({ where: { id: le.id }, data: lessonData });
} else {
const { id: _li, ...lessonData } = le;
await this.prisma.lesson.create({ data: { ...lessonData, chapterId: ch.id } });
}
}
}
} else {
const { lessons, id: _newChId, ...chData } = ch;
const newCh = await this.prisma.chapter.create({ data: { ...chData, courseId: id } });
if (lessons) {
for (const le of lessons) {
const { id: _li, ...lessonData } = le;
await this.prisma.lesson.create({ data: { ...lessonData, chapterId: newCh.id } });
}
}
}
}
}
return this.prisma.course.update({
where: { id },
data: courseData,
include: { chapters: { include: { lessons: { orderBy: { sortOrder: 'asc' } } }, orderBy: { sortOrder: 'asc' } } },
});
}
async remove(id: number) {
return this.prisma.course.update({ where: { id }, data: { deletedAt: new Date() } });
}
async updateProgress(userId: number, courseId: number, lessonId: number, completed?: boolean, progress?: number) {
const existing = await this.prisma.learnRecord.findUnique({
where: { userId_lessonId: { userId, lessonId } },
});
if (existing) {
return this.prisma.learnRecord.update({
where: { id: existing.id },
data: {
completed: completed ?? existing.completed,
progress: progress ?? existing.progress,
},
});
} else {
return this.prisma.learnRecord.create({
data: {
userId,
courseId,
lessonId,
completed: completed ?? false,
progress: progress ?? 0,
},
});
}
}
async getLearningProgress(userId: number, courseId?: number) {
const where: any = { userId };
if (courseId) {
where.courseId = courseId;
}
const records = await this.prisma.learnRecord.findMany({
where,
include: {
course: { select: { id: true, title: true } },
lesson: { select: { id: true, title: true, chapterId: true } },
},
});
const courseMap: Record<number, any> = {};
records.forEach(r => {
if (!courseMap[r.courseId]) {
courseMap[r.courseId] = {
courseId: r.courseId,
courseTitle: r.course.title,
totalLessons: 0,
completedLessons: 0,
progress: 0,
records: [],
};
}
courseMap[r.courseId].records.push(r);
courseMap[r.courseId].totalLessons++;
if (r.completed) courseMap[r.courseId].completedLessons++;
});
Object.values(courseMap).forEach(c => {
c.progress = c.totalLessons > 0 ? Math.round((c.completedLessons / c.totalLessons) * 100) : 0;
});
return {
items: Object.values(courseMap),
total: records.length,
};
}
}
@@ -0,0 +1,279 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CoursesService } from '../courses.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('CoursesService', () => {
let service: CoursesService;
let prisma: PrismaService;
const mockPrisma = {
course: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
chapter: {
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
lesson: {
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CoursesService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<CoursesService>(CoursesService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated courses with chapters/lessons', async () => {
const mockCourses = [
{ id: 1, title: 'AI 入门', status: 'PUBLISHED', chapters: [{ id: 1, lessons: [{ id: 1 }] }] },
];
mockPrisma.course.findMany.mockResolvedValue(mockCourses);
mockPrisma.course.count.mockResolvedValue(1);
const result = await service.findAll({ page: 1, pageSize: 20 });
expect(result).toEqual({ items: mockCourses, total: 1, page: 1, pageSize: 20 });
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'PUBLISHED', deletedAt: null },
skip: 0,
take: 20,
})
);
});
it('should filter by categoryId', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
await service.findAll({ categoryId: 3 });
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ categoryId: 3 }),
})
);
});
it('should filter free courses', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
await service.findAll({ isFree: true });
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isFree: true }),
})
);
});
it('should apply pagination correctly', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(50);
const result = await service.findAll({ page: 3, pageSize: 10 });
expect(result.page).toBe(3);
expect(result.pageSize).toBe(10);
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 20, take: 10 })
);
});
});
describe('findById', () => {
it('should return course with chapters and lessons', async () => {
const mockCourse = {
id: 1,
title: 'AI 入门',
chapters: [{ id: 1, title: '第一章', lessons: [{ id: 1, title: '第一课' }] }],
};
mockPrisma.course.findUnique.mockResolvedValue(mockCourse);
const result = await service.findById(1);
expect(result).toEqual(mockCourse);
expect(mockPrisma.course.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
include: expect.objectContaining({
chapters: expect.objectContaining({
include: { lessons: expect.any(Object) },
}),
}),
});
});
it('should return null for non-existent course', async () => {
mockPrisma.course.findUnique.mockResolvedValue(null);
const result = await service.findById(999);
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create a new course', async () => {
const newCourse = { id: 2, title: '新课程', isFree: true };
mockPrisma.course.create.mockResolvedValue(newCourse);
const result = await service.create({ title: '新课程', isFree: true });
expect(result).toEqual(newCourse);
expect(mockPrisma.course.create).toHaveBeenCalledWith({
data: { title: '新课程', isFree: true },
});
});
it('should create course with all fields', async () => {
mockPrisma.course.create.mockResolvedValue({ id: 3 });
await service.create({
title: '完整课程',
description: '描述',
categoryId: 1,
price: 99,
isFree: false,
sortOrder: 5,
});
expect(mockPrisma.course.create).toHaveBeenCalledWith({
data: {
title: '完整课程',
description: '描述',
categoryId: 1,
price: 99,
isFree: false,
sortOrder: 5,
},
});
});
});
describe('update', () => {
it('should update course fields', async () => {
mockPrisma.course.update.mockResolvedValue({ id: 1, title: '更新标题' });
const result = await service.update(1, { title: '更新标题' });
expect(result).toEqual({ id: 1, title: '更新标题' });
expect(mockPrisma.course.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { title: '更新标题' },
});
});
it('should strip chapters from course data update', async () => {
mockPrisma.course.update.mockResolvedValue({ id: 1 });
await service.update(1, { title: 'test', chapters: [{ title: 'ch1' }] });
expect(mockPrisma.course.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { title: 'test' }, // chapters stripped
});
});
});
describe('remove', () => {
it('should soft-delete a course', async () => {
mockPrisma.course.update.mockResolvedValue({ id: 1, deletedAt: new Date() });
const result = await service.remove(1);
expect(result).toBeDefined();
expect(mockPrisma.course.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { deletedAt: expect.any(Date) },
});
});
});
describe('updateWithChapters', () => {
beforeEach(() => {
mockPrisma.chapter.findMany.mockResolvedValue([]);
mockPrisma.course.update.mockResolvedValue({ id: 1 });
});
it('should create new chapters and lessons', async () => {
mockPrisma.chapter.findMany.mockResolvedValue([]);
mockPrisma.chapter.create.mockResolvedValue({ id: 10 });
mockPrisma.lesson.create.mockResolvedValue({ id: 20 });
const result = await service.updateWithChapters(1, {
title: '更新课程',
chapters: [
{
title: '新章节',
sortOrder: 1,
lessons: [{ title: '新课时', sortOrder: 1, status: 'PUBLISHED' }],
},
],
});
expect(result).toBeDefined();
expect(mockPrisma.chapter.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
title: '新章节',
courseId: 1,
}),
})
);
});
it('should delete removed chapters', async () => {
mockPrisma.chapter.findMany.mockResolvedValue([
{ id: 5, courseId: 1, title: '旧章节', sortOrder: 1 },
]);
mockPrisma.lesson.deleteMany.mockResolvedValue({ count: 0 });
mockPrisma.chapter.delete.mockResolvedValue({ id: 5 });
await service.updateWithChapters(1, {
title: '更新',
chapters: [], // no chapters = delete everything
});
expect(mockPrisma.chapter.delete).toHaveBeenCalledWith({ where: { id: 5 } });
});
it('should update existing chapters', async () => {
mockPrisma.chapter.findMany.mockResolvedValue([
{ id: 5, courseId: 1, title: '旧章节', sortOrder: 1 },
]);
mockPrisma.lesson.findMany.mockResolvedValue([]);
mockPrisma.chapter.update.mockResolvedValue({ id: 5 });
await service.updateWithChapters(1, {
chapters: [{ id: 5, title: '更新章节', sortOrder: 2 }],
});
expect(mockPrisma.chapter.update).toHaveBeenCalledWith({
where: { id: 5 },
data: { title: '更新章节', sortOrder: 2 },
});
});
});
});
@@ -0,0 +1,37 @@
import { Controller, Get, Put, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { DashboardService } from './dashboard.service';
@ApiTags('仪表盘')
@Controller('dashboard')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class DashboardController {
constructor(private dashboardService: DashboardService) {}
@Get('stats')
async getStats(@Req() req: any) {
return this.dashboardService.getStats(req.user.userId);
}
@Get('progress')
async getProgress(@Req() req: any) {
return this.dashboardService.getProgress(req.user.userId);
}
@Get('favorites')
async getFavorites(@Req() req: any) {
return this.dashboardService.getFavorites(req.user.userId);
}
@Get('profile')
async getProfile(@Req() req: any) {
return this.dashboardService.getProfile(req.user.userId);
}
@Put('profile')
async updateProfile(@Req() req: any, @Body() body: { nickname?: string; avatar?: string }) {
return this.dashboardService.updateProfile(req.user.userId, body);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}
@@ -0,0 +1,201 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) {}
async getStats(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
select: {
nickname: true,
avatar: true,
memberPlan: true,
memberExpire: true,
sandboxDaily: true,
createdAt: true,
},
});
if (!user) throw new NotFoundException('用户不存在');
const learnRecords = await this.prisma.learnRecord.findMany({
where: { userId },
select: { courseId: true, completed: true },
});
const courseIds = [...new Set(learnRecords.map(r => r.courseId))];
const completedCount = learnRecords.filter(r => r.completed).length;
const inProgressCourses = courseIds.length;
const favoriteCount = await this.prisma.promptFavorite.count({ where: { userId } });
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayCount = await this.prisma.learnRecord.count({
where: { userId, updatedAt: { gte: today } },
});
const firstRecord = await this.prisma.learnRecord.findFirst({
where: { userId },
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
});
const studyDays = firstRecord
? Math.max(1, Math.ceil((Date.now() - firstRecord.createdAt.getTime()) / 86400000))
: 0;
return {
user: {
nickname: user.nickname,
avatar: user.avatar,
memberPlan: user.memberPlan,
memberExpire: user.memberExpire,
sandboxDaily: user.sandboxDaily,
joinedAt: user.createdAt,
},
stats: {
inProgressCourses,
completedLessons: completedCount,
favoritePrompts: favoriteCount,
studyDays,
todayLearned: todayCount,
},
};
}
async getProgress(userId: number) {
const learnRecords = await this.prisma.learnRecord.findMany({
where: { userId },
include: {
course: { select: { id: true, title: true, cover: true } },
lesson: { select: { id: true, title: true } },
},
orderBy: { updatedAt: 'desc' },
});
const courseMap = new Map<number, { course: any; lessons: any[]; completedCount: number; totalCount: number }>();
for (const record of learnRecords) {
if (!courseMap.has(record.courseId)) {
const totalLessons = await this.prisma.lesson.count({
where: { chapter: { courseId: record.courseId } },
});
courseMap.set(record.courseId, {
course: record.course,
lessons: [],
completedCount: 0,
totalCount: totalLessons,
});
}
const entry = courseMap.get(record.courseId)!;
entry.lessons.push({
id: record.lesson.id,
title: record.lesson.title,
completed: record.completed,
progress: record.progress,
updatedAt: record.updatedAt,
});
if (record.completed) entry.completedCount++;
}
const courses = Array.from(courseMap.values()).map(entry => ({
course: entry.course,
progress: entry.totalCount > 0 ? Math.round((entry.completedCount / entry.totalCount) * 100) : 0,
completedCount: entry.completedCount,
totalCount: entry.totalCount,
recentLessons: entry.lessons.slice(0, 5),
}));
const recentRecords = learnRecords.slice(0, 10).map(r => ({
lessonId: r.lesson.id,
lessonTitle: r.lesson.title,
courseId: r.course.id,
courseTitle: r.course.title,
completed: r.completed,
progress: r.progress,
updatedAt: r.updatedAt,
}));
return { courses, recentRecords };
}
async getFavorites(userId: number) {
const favorites = await this.prisma.promptFavorite.findMany({
where: { userId },
include: {
prompt: {
select: {
id: true,
title: true,
description: true,
model: true,
viewCount: true,
likeCount: true,
createdAt: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return favorites.map(f => ({
id: f.id,
promptId: f.prompt.id,
title: f.prompt.title,
description: f.prompt.description,
model: f.prompt.model,
viewCount: f.prompt.viewCount,
likeCount: f.prompt.likeCount,
favoritedAt: f.createdAt,
}));
}
async getProfile(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
select: {
id: true,
phone: true,
email: true,
nickname: true,
avatar: true,
status: true,
memberPlan: true,
memberExpire: true,
sandboxDaily: true,
createdAt: true,
},
});
if (!user) throw new NotFoundException('用户不存在');
return user;
}
async updateProfile(userId: number, data: { nickname?: string; avatar?: string }) {
const user = await this.prisma.user.findUnique({
where: { id: userId, deletedAt: null },
});
if (!user) throw new NotFoundException('用户不存在');
return this.prisma.user.update({
where: { id: userId },
data: {
...(data.nickname !== undefined && { nickname: data.nickname }),
...(data.avatar !== undefined && { avatar: data.avatar }),
},
select: {
id: true,
phone: true,
email: true,
nickname: true,
avatar: true,
status: true,
memberPlan: true,
memberExpire: true,
sandboxDaily: true,
createdAt: true,
},
});
}
}
@@ -0,0 +1,90 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { EnterpriseService } from './enterprise.service';
@ApiTags('企业版')
@Controller('enterprise')
export class EnterpriseController {
constructor(private enterpriseService: EnterpriseService) {}
@Post('organizations')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Req() req: any, @Body() body: { name: string; description?: string; contactName?: string; contactPhone?: string }) {
return this.enterpriseService.createOrganization(req.user.userId, body);
}
@Get('organizations')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async list(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.enterpriseService.listOrganizations(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 20,
);
}
@Get('organizations/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async get(@Param('id') id: string) {
return this.enterpriseService.getOrganization(parseInt(id));
}
@Put('organizations/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: { name?: string; description?: string; contactName?: string; contactPhone?: string }) {
return this.enterpriseService.updateOrganization(parseInt(id), body);
}
@Post('organizations/:id/members')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async addMember(@Req() req: any, @Param('id') id: string, @Body() body: { userId: number; role?: string }) {
return this.enterpriseService.addMember(parseInt(id), req.user.userId, body.userId, body.role);
}
@Delete('organizations/:orgId/members/:userId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async removeMember(@Req() req: any, @Param('orgId') orgId: string, @Param('userId') userId: string) {
return this.enterpriseService.removeMember(parseInt(orgId), parseInt(userId), req.user.userId);
}
@Post('organizations/:id/assignments')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async assignCourse(@Req() req: any, @Param('id') id: string, @Body() body: { courseId: number; deadline?: string }) {
return this.enterpriseService.assignCourse(parseInt(id), body.courseId, req.user.userId, body.deadline);
}
@Delete('organizations/:orgId/assignments/:courseId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async removeAssignment(@Param('orgId') orgId: string, @Param('courseId') courseId: string) {
return this.enterpriseService.removeAssignment(parseInt(orgId), parseInt(courseId));
}
@Get('organizations/:id/progress')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getProgress(@Param('id') id: string) {
return this.enterpriseService.getOrgProgress(parseInt(id));
}
@Get('organizations/:id/report')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getReport(@Param('id') id: string) {
return this.enterpriseService.getOrganizationReport(parseInt(id));
}
@Get('my')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async myOrganizations(@Req() req: any) {
return this.enterpriseService.getMyOrganizations(req.user.userId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EnterpriseController } from './enterprise.controller';
import { EnterpriseService } from './enterprise.service';
@Module({
controllers: [EnterpriseController],
providers: [EnterpriseService],
exports: [EnterpriseService],
})
export class EnterpriseModule {}
@@ -0,0 +1,221 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class EnterpriseService {
constructor(private prisma: PrismaService) {}
async createOrganization(userId: number, data: { name: string; description?: string; contactName?: string; contactPhone?: string }) {
const existing = await this.prisma.organization.findFirst({ where: { name: data.name } });
if (existing) throw new ConflictException('组织名称已存在');
const org = await this.prisma.organization.create({
data: { name: data.name, description: data.description, contactName: data.contactName, contactPhone: data.contactPhone },
});
await this.prisma.organizationMember.create({
data: { organizationId: org.id, userId, role: 'ADMIN' },
});
await this.prisma.organization.update({
where: { id: org.id },
data: { memberCount: { increment: 1 } },
});
return org;
}
async listOrganizations(page = 1, pageSize = 20) {
page = Number(page);
pageSize = Number(pageSize);
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.organization.findMany({
skip, take: pageSize,
orderBy: { createdAt: 'desc' },
include: { _count: { select: { members: true, assignments: true } } },
}),
this.prisma.organization.count(),
]);
return { items, total, page, pageSize };
}
async getOrganization(id: number) {
const org = await this.prisma.organization.findUnique({
where: { id },
include: {
_count: { select: { members: true, assignments: true } },
members: {
include: { user: { select: { id: true, nickname: true, avatar: true, email: true, phone: true } } },
},
assignments: {
include: { course: { select: { id: true, title: true, cover: true } } },
},
},
});
if (!org) throw new NotFoundException('组织不存在');
return org;
}
async updateOrganization(id: number, data: { name?: string; description?: string; contactName?: string; contactPhone?: string }) {
const org = await this.prisma.organization.findUnique({ where: { id } });
if (!org) throw new NotFoundException('组织不存在');
return this.prisma.organization.update({ where: { id }, data });
}
async addMember(orgId: number, adminId: number, userId: number, role = 'MEMBER') {
const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('组织不存在');
const caller = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId: adminId } },
});
if (!caller || caller.role !== 'ADMIN') throw new ForbiddenException('只有管理员可管理成员');
const membership = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId } },
});
if (membership) throw new ConflictException('该用户已是组织成员');
await this.prisma.organizationMember.create({
data: { organizationId: orgId, userId, role },
});
await this.prisma.organization.update({
where: { id: orgId },
data: { memberCount: { increment: 1 } },
});
return { added: true };
}
async removeMember(orgId: number, userId: number, callerId: number) {
const membership = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId } },
});
if (!membership) throw new NotFoundException('该用户不是组织成员');
if (membership.role === 'ADMIN') throw new BadRequestException('不能移除管理员');
const caller = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId: callerId } },
});
if (!caller || caller.role !== 'ADMIN') throw new ForbiddenException('只有管理员可管理成员');
await this.prisma.organizationMember.delete({ where: { id: membership.id } });
await this.prisma.organization.update({
where: { id: orgId },
data: { memberCount: { decrement: 1 } },
});
return { removed: true };
}
async assignCourse(orgId: number, courseId: number, assignedBy: number, deadline?: string) {
const [org, course] = await Promise.all([
this.prisma.organization.findUnique({ where: { id: orgId } }),
this.prisma.course.findUnique({ where: { id: courseId } }),
]);
if (!org) throw new NotFoundException('组织不存在');
if (!course) throw new NotFoundException('课程不存在');
const existing = await this.prisma.courseAssignment.findFirst({
where: { organizationId: orgId, courseId },
});
if (existing) throw new ConflictException('该课程已分配给此组织');
return this.prisma.courseAssignment.create({
data: {
organizationId: orgId,
courseId,
assignedBy,
deadline: deadline ? new Date(deadline) : undefined,
},
include: { course: { select: { id: true, title: true, cover: true } } },
});
}
async removeAssignment(orgId: number, courseId: number) {
const assignment = await this.prisma.courseAssignment.findFirst({
where: { organizationId: orgId, courseId },
});
if (!assignment) throw new NotFoundException('未找到该课程分配');
await this.prisma.courseAssignment.delete({ where: { id: assignment.id } });
return { removed: true };
}
async getOrgProgress(orgId: number) {
const members = await this.prisma.organizationMember.findMany({
where: { organizationId: orgId, status: 'ACTIVE' },
select: { userId: true },
});
const userIds = members.map(m => m.userId);
const assignments = await this.prisma.courseAssignment.findMany({
where: { organizationId: orgId },
include: { course: { select: { id: true, title: true } } },
});
const courseIds = assignments.map(a => a.courseId);
const records = await this.prisma.learnRecord.findMany({
where: { userId: { in: userIds }, courseId: { in: courseIds } },
});
const totalLessons = await this.prisma.lesson.count({
where: { chapter: { courseId: { in: courseIds } } },
});
const completedCount = records.filter(r => r.completed).length;
const activeMembers = userIds.length;
const progressByCourse = courseIds.map(courseId => {
const courseLessons = records.filter(r => r.courseId === courseId);
const uniqueLessons = new Set(courseLessons.map(r => r.lessonId));
return {
courseId,
completedLessons: courseLessons.filter(r => r.completed).length,
totalUniqueLessons: uniqueLessons.size,
};
});
return {
totalMembers: activeMembers,
totalCourses: courseIds.length,
completedLessons: completedCount,
totalLessons,
completionRate: totalLessons > 0 ? Math.round((completedCount / (totalLessons * Math.max(activeMembers, 1))) * 100) : 0,
progressByCourse,
};
}
async getMyOrganizations(userId: number) {
const memberships = await this.prisma.organizationMember.findMany({
where: { userId },
include: {
organization: {
include: { _count: { select: { members: true, assignments: true } } },
},
},
});
return memberships.map(m => ({ ...m.organization, role: m.role }));
}
async getOrganizationReport(orgId: number) {
const org = await this.getOrganization(orgId);
const progress = await this.getOrgProgress(orgId);
const memberProgress = await Promise.all(
org.members.map(async (member) => {
const records = await this.prisma.learnRecord.count({
where: { userId: member.user.id, completed: true },
});
return { user: member.user, completedLessons: records };
}),
);
return {
organization: { id: org.id, name: org.name, memberCount: org._count.members },
summary: progress,
memberProgress,
assignments: org.assignments,
};
}
}
@@ -0,0 +1,158 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { EnterpriseService } from '../enterprise.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('EnterpriseService', () => {
let service: EnterpriseService;
let prisma: PrismaService;
const mockPrisma = {
organization: {
findFirst: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
organizationMember: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
courseAssignment: {
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
course: {
findUnique: jest.fn(),
},
learnRecord: {
findMany: jest.fn(),
count: jest.fn(),
},
lesson: {
count: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EnterpriseService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<EnterpriseService>(EnterpriseService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('createOrganization', () => {
it('should create organization and add creator as admin', async () => {
mockPrisma.organization.findFirst.mockResolvedValue(null);
mockPrisma.organization.create.mockResolvedValue({ id: 1, name: '测试企业', memberCount: 0 });
mockPrisma.organizationMember.create.mockResolvedValue({});
mockPrisma.organization.update.mockResolvedValue({});
const result = await service.createOrganization(1, { name: '测试企业' });
expect(result).toEqual({ id: 1, name: '测试企业', memberCount: 0 });
expect(mockPrisma.organization.create).toHaveBeenCalledWith({
data: { name: '测试企业', description: undefined, contactName: undefined, contactPhone: undefined },
});
expect(mockPrisma.organizationMember.create).toHaveBeenCalledWith({
data: { organizationId: 1, userId: 1, role: 'ADMIN' },
});
});
it('should throw ConflictException if name exists', async () => {
mockPrisma.organization.findFirst.mockResolvedValue({ id: 1 });
await expect(service.createOrganization(1, { name: '测试企业' }))
.rejects.toThrow(ConflictException);
});
});
describe('getOrganization', () => {
it('should return organization with members and assignments', async () => {
const mockOrg = {
id: 1, name: '测试企业',
_count: { members: 2, assignments: 1 },
members: [{ id: 1, userId: 1, user: { id: 1, nickname: '用户1' } }],
assignments: [{ id: 1, courseId: 1, course: { id: 1, title: '课程1' } }],
};
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
const result = await service.getOrganization(1);
expect(result).toEqual(mockOrg);
});
it('should throw NotFoundException if not found', async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);
await expect(service.getOrganization(999)).rejects.toThrow(NotFoundException);
});
});
describe('addMember', () => {
it('should add member to organization', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.organizationMember.findUnique
.mockResolvedValueOnce({ role: 'ADMIN' }) // caller is admin
.mockResolvedValueOnce(null); // user not yet member
mockPrisma.organizationMember.create.mockResolvedValue({});
mockPrisma.organization.update.mockResolvedValue({});
const result = await service.addMember(1, 1, 2);
expect(result).toEqual({ added: true });
});
it('should throw ConflictException if already member', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.organizationMember.findUnique
.mockResolvedValueOnce({ role: 'ADMIN' }) // caller is admin
.mockResolvedValueOnce({ id: 1 }); // user already member
await expect(service.addMember(1, 1, 2)).rejects.toThrow(ConflictException);
});
});
describe('assignCourse', () => {
it('should assign course to organization', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.course.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.courseAssignment.findFirst.mockResolvedValue(null);
mockPrisma.courseAssignment.create.mockResolvedValue({
id: 1, courseId: 1, course: { id: 1, title: '课程1' },
});
const result = await service.assignCourse(1, 1, 1);
expect(result).toHaveProperty('id');
});
it('should throw NotFoundException if org not found', async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);
await expect(service.assignCourse(1, 1, 1)).rejects.toThrow(NotFoundException);
});
});
describe('listOrganizations', () => {
it('should return paginated organizations', async () => {
mockPrisma.organization.findMany.mockResolvedValue([{ id: 1, name: '测试企业' }]);
mockPrisma.organization.count.mockResolvedValue(1);
const result = await service.listOrganizations(1, 20);
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
});
});
});
@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { LearningService } from './learning.service';
@ApiTags('学习分析')
@Controller('learning')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class LearningController {
constructor(private learningService: LearningService) {}
@Get('analytics')
async getAnalytics(@Req() req: any) {
return this.learningService.getAnalytics(req.user.userId);
}
@Get('path')
async getLearningPath(@Req() req: any) {
return this.learningService.getLearningPath(req.user.userId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LearningController } from './learning.controller';
import { LearningService } from './learning.service';
@Module({
controllers: [LearningController],
providers: [LearningService],
exports: [LearningService],
})
export class LearningModule {}
@@ -0,0 +1,192 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
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 COURSE_RECOMMENDATIONS: Record<string, { title: string; url: string }[]> = {
'ai-basics': [
{ title: 'AI 通识:零基础入门', url: '/courses' },
{ title: '大模型原理与应用', url: '/courses' },
],
'prompt-engineering': [
{ title: '提示词工程从入门到精通', url: '/courses' },
{ title: '高级 Prompt 技巧', url: '/prompts' },
],
'programming': [
{ title: '用 Python 入门 AI 编程', url: '/courses' },
{ title: 'AI 辅助编程实战', url: '/sandbox' },
],
'writing': [
{ title: 'AI 写作实战指南', url: '/prompts/workshop' },
{ title: '内容创作与润色技巧', url: '/courses' },
],
'english': [
{ title: 'AI 辅助英语学习', url: '/sandbox' },
{ title: '英语写作提升课程', url: '/courses' },
],
'data-science': [
{ title: '数据分析入门', url: '/courses' },
{ title: 'Python 数据分析', url: '/courses' },
],
'office': [
{ title: '用 AI 提升 10 倍办公效率', url: '/courses' },
{ title: 'AI 办公自动化', url: '/courses' },
],
'career': [
{ title: 'AI 时代职业规划', url: '/courses' },
{ title: '面试技巧与简历优化', url: '/sandbox' },
],
};
@Injectable()
export class LearningService {
constructor(private prisma: PrismaService) {}
async getAnalytics(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 domain of KNOWLEDGE_DOMAINS) {
domainCounts[domain.id] = 0;
}
for (const session of sessions) {
const searchText = `${session.title} ${session.messages || ''}`.toLowerCase();
for (const domain of KNOWLEDGE_DOMAINS) {
const matched = domain.keywords.some(kw => searchText.includes(kw));
if (matched) {
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 totalSessionsForUser = Math.max(totalSessions, 1);
const mastery = Math.min(Math.round((count / Math.max(totalSessionsForUser * 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 => (COURSE_RECOMMENDATIONS[d.id] || []).slice(0, 2))
: [{ title: '探索更多知识领域', url: '/sandbox' }];
return {
domains,
totalSessions,
weakDomains: weakDomains.map(d => d.name),
recommendations: [...new Map(recommendations.map(r => [r.title, r])).values()],
};
}
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(' ');
const stages = [
{
id: 'basics',
title: '认识大模型',
icon: '🤖',
description: '了解 AI 和大型语言模型的基本概念,学会如何使用 AI 工具。',
tasks: [
{ label: '了解 AI 基本概念', action: '向 AI 提问什么是人工智能', keyword: '人工智能' },
{ label: '认识大语言模型', action: '向 AI 提问什么是大模型', keyword: '大模型' },
{ label: '体验 AI 对话', action: '在沙盒中发起一次对话', keyword: '' },
],
links: [
{ title: 'AI 通识:零基础入门', url: '/courses' },
{ title: '打开 AI 沙盒', url: '/sandbox' },
],
},
{
id: 'prompt',
title: '提示词工程',
icon: '✍️',
description: '学习如何编写高质量的提示词,掌握与 AI 高效沟通的技巧。',
tasks: [
{ label: '了解提示词基础', action: '询问提示词编写技巧', keyword: '提示词' },
{ label: '练习提示词编写', action: '在提示词工坊中测试', keyword: '' },
{ label: '保存优质提示词', action: '将好的提示词保存到库中', keyword: '' },
],
links: [
{ title: '提示词工坊', url: '/prompts/workshop' },
{ title: '提示词库', url: '/prompts' },
],
},
{
id: 'advanced',
title: '模型微调与高级应用',
icon: '⚙️',
description: '了解模型微调、RAG、Function Calling 等高级技术。',
tasks: [
{ label: '了解模型微调', action: '询问什么是模型微调', keyword: '微调' },
{ label: '了解 RAG', action: '询问什么是 RAG 检索增强', keyword: 'rag' },
{ label: '了解 Function Calling', action: '询问 function calling 是什么', keyword: 'function calling' },
],
links: [
{ title: 'AI 沙盒 - 对比模式', url: '/sandbox/compare' },
{ title: '代码沙盒', url: '/sandbox/code' },
],
},
{
id: 'agent',
title: 'Agent 开发',
icon: '🚀',
description: '学习构建 AI Agent,实现自动化任务和复杂工作流。',
tasks: [
{ label: '了解 AI Agent', action: '询问什么是 AI Agent', keyword: 'agent' },
{ label: '学习工具调用', action: '询问 AI 工具调用机制', keyword: 'tool' },
{ label: '实践项目', action: '尝试用 AI 构建一个小项目', keyword: '' },
],
links: [
{ title: '代码沙盒 - 运行项目', url: '/sandbox/code' },
{ title: '对比实验室', url: '/sandbox/compare' },
],
},
];
return 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,
};
});
}
}
@@ -0,0 +1,19 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ModelsService } from './models.service';
@ApiTags('AI 模型')
@Controller('models')
export class ModelsController {
constructor(private modelsService: ModelsService) {}
@Get()
async findAll(@Query('featured') featured?: string) {
return this.modelsService.findAll({ featured: featured === 'true' });
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.modelsService.findById(parseInt(id, 10));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ModelsController } from './models.controller';
import { ModelsService } from './models.service';
@Module({
controllers: [ModelsController],
providers: [ModelsService],
})
export class ModelsModule {}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ModelsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { featured?: boolean }) {
const where: any = { status: 'ACTIVE' };
if (params.featured) where.isFeatured = true;
return this.prisma.aiModel.findMany({
where,
orderBy: { sortOrder: 'asc' },
});
}
async findById(id: number) {
return this.prisma.aiModel.findUnique({ where: { id } });
}
}
@@ -0,0 +1,35 @@
import { Controller, Get, Patch, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { NotificationService } from './notification.service';
@ApiTags('通知')
@Controller('notifications')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class NotificationController {
constructor(private notificationService: NotificationService) {}
@Get()
async findAll(@Req() req: any, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.notificationService.findAll(req.user.userId, page ? parseInt(page) : 1, pageSize ? parseInt(pageSize) : 20);
}
@Get('unread')
async countUnread(@Req() req: any) {
const count = await this.notificationService.countUnread(req.user.userId);
return { count };
}
@Patch(':id/read')
async markAsRead(@Req() req: any, @Param('id') id: string) {
await this.notificationService.markAsRead(parseInt(id), req.user.userId);
return { ok: true };
}
@Patch('read-all')
async markAllAsRead(@Req() req: any) {
await this.notificationService.markAllAsRead(req.user.userId);
return { ok: true };
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationController } from './notification.controller';
import { NotificationService } from './notification.service';
@Module({
controllers: [NotificationController],
providers: [NotificationService],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class NotificationService {
constructor(private prisma: PrismaService) {}
async create(data: { userId: number; type: string; title: string; content?: string; link?: string; relatedId?: number }) {
return this.prisma.notification.create({ data });
}
async findAll(userId: number, page = 1, 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 { items, total, page, pageSize, unread: items.filter(n => !n.isRead).length };
}
async countUnread(userId: number) {
return this.prisma.notification.count({ where: { userId, isRead: false } });
}
async markAsRead(id: number, userId: number) {
return this.prisma.notification.updateMany({
where: { id, userId },
data: { isRead: true },
});
}
async markAllAsRead(userId: number) {
return this.prisma.notification.updateMany({
where: { userId, isRead: false },
data: { isRead: true },
});
}
}
@@ -0,0 +1,42 @@
import { Controller, Post, Get, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { IsNumber, IsString, IsOptional, IsIn } from 'class-validator';
import { AuthGuard } from '@nestjs/passport';
import { OrdersService } from './orders.service';
class CreateOrderDto {
@IsNumber()
amount: number;
@IsString()
@IsIn(['MONTHLY', 'YEARLY', 'COURSE'])
planType: string;
@IsOptional()
@IsString()
payChannel?: string;
}
@ApiTags('订单')
@Controller('orders')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class OrdersController {
constructor(private ordersService: OrdersService) {}
@Post('create')
@ApiBody({ type: CreateOrderDto })
async create(@Req() req: any, @Body() body: CreateOrderDto) {
return this.ordersService.create(req.user.userId, body);
}
@Get()
async findByUser(@Req() req: any, @Query() query: { page?: number; pageSize?: number }) {
return this.ordersService.findByUser(req.user.userId, query);
}
@Get(':orderNo')
async findByOrderNo(@Param('orderNo') orderNo: string) {
return this.ordersService.findByOrderNo(orderNo);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { PaymentModule } from '../payment/payment.module';
@Module({
imports: [PaymentModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PaymentService } from '../payment/payment.service';
@Injectable()
export class OrdersService {
constructor(
private prisma: PrismaService,
private paymentService: PaymentService,
) {}
async create(userId: number, data: { amount: number; planType: string; payChannel?: string }) {
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const order = await this.prisma.order.create({
data: {
orderNo,
userId,
amount: data.amount,
planType: data.planType,
payChannel: data.payChannel || 'wxpay',
},
});
// If paying via WeChat, create unified order
if (data.payChannel === 'wxpay' || !data.payChannel) {
try {
const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员',
};
const payResult = await this.paymentService.createUnifiedOrder({
description: planLabels[data.planType] || '宇之然AI会员充值',
outTradeNo: orderNo,
amount: data.amount,
});
return { order, payResult };
} catch {
return { order, payResult: null };
}
}
return { order };
}
async findByUser(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where: { userId },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.order.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
async findByOrderNo(orderNo: string) {
return this.prisma.order.findUnique({ where: { orderNo } });
}
async getCurrentSubscription(userId: number) {
const now = new Date();
return this.prisma.subscription.findFirst({
where: {
userId,
status: 'ACTIVE',
endDate: { gt: now },
},
orderBy: { endDate: 'desc' },
});
}
async getSubscriptions(userId: number) {
return this.prisma.subscription.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
}
@@ -0,0 +1,22 @@
import { Controller, Get, Post, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { OrdersService } from './orders.service';
@ApiTags('订阅')
@Controller('subscriptions')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class SubscriptionsController {
constructor(private ordersService: OrdersService) {}
@Get('current')
async getCurrentSubscription(@Req() req: any) {
return this.ordersService.getCurrentSubscription(req.user.userId);
}
@Get()
async getSubscriptions(@Req() req: any) {
return this.ordersService.getSubscriptions(req.user.userId);
}
}
@@ -0,0 +1,169 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from '../orders.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { PaymentService } from '../../payment/payment.service';
describe('OrdersService', () => {
let service: OrdersService;
let prisma: PrismaService;
let paymentService: PaymentService;
const mockPrisma = {
order: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
subscription: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
};
const mockPaymentService = {
createUnifiedOrder: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: PaymentService, useValue: mockPaymentService },
],
}).compile();
service = module.get<OrdersService>(OrdersService);
prisma = module.get<PrismaService>(PrismaService);
paymentService = module.get<PaymentService>(PaymentService);
jest.clearAllMocks();
});
describe('create', () => {
it('should create order and call payment service for wxpay', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
mockPaymentService.createUnifiedOrder.mockResolvedValue({
prepay_id: 'wx123',
nonceStr: 'abc',
});
const result = await service.create(1, {
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
});
expect(result).toHaveProperty('order');
expect(result).toHaveProperty('payResult');
expect(mockPrisma.order.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 1,
amount: 29.9,
planType: 'MONTHLY',
payChannel: 'wxpay',
}),
});
});
it('should create order without payment for non-wxpay channels', async () => {
const mockOrder = {
id: 1,
orderNo: 'YZR123',
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
};
mockPrisma.order.create.mockResolvedValue(mockOrder);
const result = await service.create(1, {
amount: 199,
planType: 'YEARLY',
payChannel: 'alipay',
});
expect(result).toEqual({ order: mockOrder });
expect(mockPaymentService.createUnifiedOrder).not.toHaveBeenCalled();
});
});
describe('findByUser', () => {
it('should return paginated orders for user', async () => {
const mockOrders = [
{ id: 1, orderNo: 'YZR123', amount: 29.9 }
];
mockPrisma.order.findMany.mockResolvedValue(mockOrders);
mockPrisma.order.count.mockResolvedValue(1);
const result = await service.findByUser(1, { page: 1, pageSize: 20 });
expect(result).toEqual({
items: mockOrders,
total: 1,
page: 1,
pageSize: 20,
});
});
});
describe('findByOrderNo', () => {
it('should return order by orderNo', async () => {
const mockOrder = { id: 1, orderNo: 'YZR123' };
mockPrisma.order.findUnique.mockResolvedValue(mockOrder);
const result = await service.findByOrderNo('YZR123');
expect(result).toEqual(mockOrder);
expect(mockPrisma.order.findUnique).toHaveBeenCalledWith({
where: { orderNo: 'YZR123' },
});
});
});
describe('getCurrentSubscription', () => {
it('should return active subscription', async () => {
const mockSub = {
id: 1,
userId: 1,
plan: 'YEARLY',
status: 'ACTIVE',
endDate: new Date(Date.now() + 86400000), // 明天到期
};
mockPrisma.subscription.findFirst.mockResolvedValue(mockSub);
const result = await service.getCurrentSubscription(1);
expect(result).toEqual(mockSub);
});
it('should return null if no active subscription', async () => {
mockPrisma.subscription.findFirst.mockResolvedValue(null);
const result = await service.getCurrentSubscription(1);
expect(result).toBeNull();
});
});
describe('getSubscriptions', () => {
it('should return all subscriptions for user', async () => {
const mockSubs = [
{ id: 1, plan: 'MONTHLY' },
{ id: 2, plan: 'YEARLY' },
];
mockPrisma.subscription.findMany.mockResolvedValue(mockSubs);
const result = await service.getSubscriptions(1);
expect(result).toEqual(mockSubs);
});
});
});
@@ -0,0 +1,69 @@
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
import { PaymentService } from './payment.service';
class UnifiedOrderDto {
@IsString()
description: string;
@IsString()
outTradeNo: string;
@IsNumber()
amount: number;
@IsOptional()
@IsString()
openid?: string;
@IsOptional()
@IsIn(['JSAPI', 'NATIVE', 'MWEB'])
tradeType?: 'JSAPI' | 'NATIVE' | 'MWEB';
}
class RefundDto {
@IsString()
outTradeNo: string;
@IsNumber()
amount: number;
@IsOptional()
@IsString()
reason?: string;
}
@ApiTags('支付')
@Controller('payment')
export class PaymentController {
constructor(private paymentService: PaymentService) {}
@Post('wxpay/unified-order')
@ApiOperation({ summary: '微信支付统一下单' })
@ApiBody({ type: UnifiedOrderDto })
async unifiedOrder(@Body() body: UnifiedOrderDto) {
return this.paymentService.createUnifiedOrder(body);
}
@Post('wxpay/notify')
@HttpCode(200)
@ApiOperation({ summary: '微信支付回调通知' })
async notify(@Req() req: any, @Headers('wechatpay-signature') signature: string) {
return this.paymentService.handleNotify(req.body, signature);
}
@Post('wxpay/refund')
@ApiOperation({ summary: '微信支付退款' })
@ApiBody({ type: RefundDto })
async refund(@Body() body: RefundDto) {
return this.paymentService.refund(body.outTradeNo, body.amount, body.reason);
}
@Get('wxpay/query')
@ApiOperation({ summary: '查询微信支付订单' })
@ApiQuery({ name: 'outTradeNo', required: true })
async query(@Query('outTradeNo') outTradeNo: string) {
return this.paymentService.queryOrder(outTradeNo);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService],
exports: [PaymentService],
})
export class PaymentModule {}
@@ -0,0 +1,326 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import * as path from 'path';
// WeChat Pay V3 SDK types
interface WxPayConfig {
appId: string;
mchId: string;
apiKey: string;
certPath: string;
keyPath: string;
notifyUrl: string;
}
export interface UnifiedOrderResult {
prepay_id: string;
nonceStr: string;
timeStamp: string;
package: string;
paySign: string;
signType: string;
}
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
private config: WxPayConfig;
private wxPay: any;
constructor(private prisma: PrismaService) {
this.config = {
appId: process.env.WX_APPID || '',
mchId: process.env.WX_MCHID || process.env.WX_PAY_MCH_ID || '1108945993',
apiKey: process.env.WX_API_KEY || process.env.WX_PAY_API_KEY || '8Kj9mP2nQ5rT7vW1xY3zA4bC6dE8fG0h',
certPath: process.env.WX_CERT_PATH || path.resolve(__dirname, '../../../cert/key/apiclient_cert.pem'),
keyPath: process.env.WX_KEY_PATH || path.resolve(__dirname, '../../../cert/key/apiclient_key.pem'),
notifyUrl: process.env.WX_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/wxpay/notify',
};
try {
const fs = require('fs');
const { WechatPay } = require('wechat-pay-nodejs');
this.wxPay = new WechatPay({
appid: this.config.appId,
mchid: this.config.mchId,
key: this.config.apiKey,
cert_private_content: fs.readFileSync(this.config.keyPath),
cert_public_content: fs.readFileSync(this.config.certPath),
});
this.logger.log(`微信支付初始化成功 (商户号: ${this.config.mchId})`);
} catch (err: any) {
this.logger.warn(`微信支付 SDK 初始化失败: ${err.message},将使用模拟模式`);
this.wxPay = null;
}
}
async createUnifiedOrder(params: {
description: string;
outTradeNo: string;
amount: number;
openid?: string;
tradeType?: 'JSAPI' | 'NATIVE' | 'MWEB';
}): Promise<UnifiedOrderResult & { codeUrl?: string }> {
const { description, outTradeNo, amount, openid, tradeType = 'JSAPI' } = params;
if (!this.wxPay) {
return { ...this.mockPayResult(outTradeNo, amount), codeUrl: 'mock://pay' };
}
try {
const baseParams = {
description,
out_trade_no: outTradeNo,
amount: { total: Math.round(amount * 100) },
notify_url: this.config.notifyUrl,
};
let result: any;
if (tradeType === 'NATIVE') {
result = await this.wxPay.prepayNative({
...baseParams,
product_id: outTradeNo,
});
} else if (tradeType === 'MWEB') {
result = await this.wxPay.prepayMweb({
...baseParams,
scene_info: { payer_client_ip: '127.0.0.1' },
});
} else {
result = await this.wxPay.prepayJsapi({
...baseParams,
payer: { openid: openid || 'oVtFy6Wv8L0lKJ9xG2rH3nM5pQ7sT1uZ' },
});
}
if (result.success && result.data) {
const response: any = {
prepay_id: result.data.package?.replace('prepay_id=', '') || result.data.prepay_id || '',
nonceStr: result.data.nonceStr || result.data.nonce_str,
timeStamp: result.data.timeStamp || String(Math.floor(Date.now() / 1000)),
paySign: result.data.paySign || result.data.sign,
signType: 'RSA',
};
if (result.data.code_url) response.codeUrl = result.data.code_url;
if (result.data.mweb_url) response.mwebUrl = result.data.mweb_url;
return response;
}
throw new Error(result.errMsg || '下单失败');
} catch (err: any) {
this.logger.error(`微信支付统一下单失败: ${err.message}`);
return { ...this.mockPayResult(outTradeNo, amount), codeUrl: 'mock://pay' };
}
}
async handleNotify(body: any, signature: string): Promise<{ code: string; message: string }> {
if (!this.wxPay) {
// 模拟模式:尝试更新订单状态
try {
const data = typeof body === 'string' ? JSON.parse(body) : body;
const outTradeNo = data.out_trade_no || (data.resource && data.resource.out_trade_no);
if (outTradeNo) {
await this.updateOrderAndMembership(outTradeNo, data.amount?.total || 0);
}
} catch (err) {
this.logger.warn(`模拟模式处理通知失败: ${err.message}`);
}
return { code: 'SUCCESS', message: '模拟模式-通知处理成功' };
}
try {
const verified = this.wxPay.verifySignature(body, signature);
if (!verified) {
return { code: 'FAIL', message: '签名验证失败' };
}
const data = typeof body === 'string' ? JSON.parse(body) : body;
const { event_type, resource } = data;
if (event_type === 'TRANSACTION.SUCCESS') {
const ciphertext = resource.ciphertext;
const associatedData = resource.associated_data;
const nonce = resource.nonce;
const decrypted = this.wxPay.decryptGCM(ciphertext, associatedData, nonce);
const payResult = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted;
this.logger.log(`支付成功: ${payResult.out_trade_no}, 金额: ${payResult.amount.total}`);
// 更新订单状态和会员订阅
await this.updateOrderAndMembership(payResult.out_trade_no, payResult.amount.total);
return { code: 'SUCCESS', message: '支付成功' };
}
return { code: 'SUCCESS', message: '已接收' };
} catch (err: any) {
this.logger.error(`支付通知处理失败: ${err.message}`);
return { code: 'FAIL', message: err.message };
}
}
private async updateOrderAndMembership(outTradeNo: string, paidAmountTotal: number) {
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;
}
// 验证金额(微信支付单位:分,订单单位:元)
const paidAmount = paidAmountTotal / 100;
if (Math.abs(paidAmount - order.amount) > 0.01) {
this.logger.warn(`支付金额不一致: 订单${order.amount}元,支付${paidAmount}`);
}
// 更新订单状态为已支付
await this.prisma.order.update({
where: { id: order.id },
data: { status: 'PAID', paidAt: new Date() },
});
// 处理会员订阅(仅限MONTHLY/YEARLY计划)
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}`);
}
}
async refund(outTradeNo: string, amount: number, reason?: string) {
if (!this.wxPay) {
this.logger.log(`模拟退款: ${outTradeNo}`);
// 模拟退款成功后更新订单状态
try {
await this.prisma.order.update({
where: { orderNo: outTradeNo },
data: { status: 'REFUNDED' },
});
} catch(err) {
this.logger.warn(`模拟退款更新订单失败: ${err.message}`);
}
return { code: 'SUCCESS', message: '模拟退款成功' };
}
try {
const result = await this.wxPay.refunds({
out_trade_no: outTradeNo,
out_refund_no: `REFUND_${outTradeNo}_${Date.now()}`,
amount: {
refund: Math.round(amount * 100),
total: Math.round(amount * 100),
currency: 'CNY',
},
reason: reason || '用户申请退款',
});
// 退款成功后更新订单状态
if (result.success) {
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 } } },
});
if (!this.wxPay) {
return {
trade_state: localOrder?.status === 'PAID' ? 'SUCCESS' : 'NOTPAY',
out_trade_no: outTradeNo,
localStatus: localOrder?.status,
amount: localOrder?.amount,
planType: localOrder?.planType,
};
}
const wxResult = await this.wxPay.queryByOutTradeNo(outTradeNo);
return {
...wxResult,
localStatus: localOrder?.status,
localAmount: localOrder?.amount,
user: localOrder?.user,
};
}
private mockPayResult(outTradeNo: string, amount: number): UnifiedOrderResult {
const nonceStr = this.generateNonceStr();
const timeStamp = String(Math.floor(Date.now() / 1000));
const prepayId = `wx${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
return {
prepay_id: prepayId,
nonceStr,
timeStamp,
package: `prepay_id=${prepayId}`,
paySign: 'MOCK_SIGN_FOR_DEVELOPMENT',
signType: 'RSA',
};
}
private generateNonceStr(): string {
return Math.random().toString(36).substring(2, 18) + Math.random().toString(36).substring(2, 18);
}
}
@@ -0,0 +1,19 @@
// @ts-nocheck
import { Test, TestingModule } from '@nestjs/testing';
import { PaymentService } from '../payment.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('PaymentService', () => {
let service: PaymentService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [PaymentService, { provide: PrismaService, useValue: {} }],
}).compile();
service = module.get<PaymentService>(PaymentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,44 @@
import { Controller, Get, Post, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { PromptsService } from './prompts.service';
@ApiTags('提示词')
@Controller('prompts')
export class PromptsController {
constructor(private promptsService: PromptsService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; search?: string }) {
return this.promptsService.findAll(query);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.promptsService.findById(+id);
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Req() req: any, @Body() body: { title: string; content: string; description?: string; categoryId?: number; tags?: string; model?: string }) {
return this.promptsService.create({ ...body, authorId: req.user.userId });
}
@Post(':id/favorite')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async toggleFavorite(@Req() req: any, @Param('id') id: string) {
return this.promptsService.toggleFavorite(req.user.userId, +id);
}
@Get('favorites')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async findFavorites(@Req() req: any, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.promptsService.findFavorites(req.user.userId, {
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PromptsController } from './prompts.controller';
import { PromptsService } from './prompts.service';
@Module({
controllers: [PromptsController],
providers: [PromptsService],
exports: [PromptsService],
})
export class PromptsModule {}
@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class PromptsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; search?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, search } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null, isPublic: true };
if (categoryId) where.categoryId = categoryId;
if (search) {
where.OR = [
{ title: { contains: search } },
{ content: { contains: search } },
];
}
const [items, total] = await Promise.all([
this.prisma.prompt.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { likeCount: 'desc' },
select: {
id: true, title: true, description: true, content: true,
tags: true, model: true, viewCount: true, likeCount: true,
createdAt: true, category: true, author: { select: { nickname: true } },
},
}),
this.prisma.prompt.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
await this.prisma.prompt.update({ where: { id }, data: { viewCount: { increment: 1 } } });
return this.prisma.prompt.findUnique({
where: { id },
include: { category: true, author: { select: { nickname: true } } },
});
}
async create(data: { title: string; content: string; description?: string; categoryId?: number; tags?: string; model?: string; authorId?: number }) {
return this.prisma.prompt.create({ data });
}
async toggleFavorite(userId: number, promptId: number) {
const existing = await this.prisma.promptFavorite.findUnique({
where: { userId_promptId: { userId, promptId } },
});
if (existing) {
await this.prisma.promptFavorite.delete({ where: { id: existing.id } });
await this.prisma.prompt.update({ where: { id: promptId }, data: { likeCount: { decrement: 1 } } });
return { favorited: false };
}
await this.prisma.promptFavorite.create({ data: { userId, promptId } });
await this.prisma.prompt.update({ where: { id: promptId }, data: { likeCount: { increment: 1 } } });
return { favorited: true };
}
async findFavorites(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const [items, total] = await Promise.all([
this.prisma.promptFavorite.findMany({
where: { userId },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { prompt: true },
}),
this.prisma.promptFavorite.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
}
@@ -0,0 +1,161 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../../prisma/prisma.service';
import { PromptsService } from '../prompts.service';
describe('PromptsService', () => {
let service: PromptsService;
let prisma: PrismaService;
const mockPrisma = {
prompt: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
promptFavorite: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PromptsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<PromptsService>(PromptsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated prompts with default params', async () => {
const mockPrompts = [
{ id: 1, title: '提示词1', status: 'PUBLISHED', deletedAt: null, isPublic: true },
];
mockPrisma.prompt.findMany.mockResolvedValue(mockPrompts);
mockPrisma.prompt.count.mockResolvedValue(1);
const result = await service.findAll({});
expect(result).toEqual({ items: mockPrompts, total: 1, page: 1, pageSize: 20 });
expect(mockPrisma.prompt.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'PUBLISHED', deletedAt: null, isPublic: true },
skip: 0,
take: 20,
})
);
});
it('should filter by categoryId', async () => {
await service.findAll({ categoryId: 5 });
expect(mockPrisma.prompt.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ categoryId: 5 }),
})
);
});
it('should search by keyword', async () => {
await service.findAll({ search: 'AI' });
expect(mockPrisma.prompt.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [{ title: { contains: 'AI' } }, { content: { contains: 'AI' } }],
}),
})
);
});
});
describe('findById', () => {
it('should increment viewCount and return prompt', async () => {
const mockPrompt = { id: 1, title: '测试提示词', viewCount: 10 };
mockPrisma.prompt.findUnique.mockResolvedValue(mockPrompt);
const result = await service.findById(1);
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { viewCount: { increment: 1 } },
});
});
});
describe('create', () => {
it('should create a new prompt', async () => {
const promptData = {
title: '新提示词',
content: '内容',
description: '描述',
categoryId: 1,
tags: 'AI,提示词',
model: 'gpt-3.5',
authorId: 1,
};
const created = { id: 1, ...promptData };
mockPrisma.prompt.create.mockResolvedValue(created);
const result = await service.create(promptData);
expect(result).toEqual(created);
expect(mockPrisma.prompt.create).toHaveBeenCalledWith({ data: promptData });
});
});
describe('toggleFavorite', () => {
it('should add favorite if not exists', async () => {
mockPrisma.promptFavorite.findUnique.mockResolvedValue(null);
mockPrisma.promptFavorite.create.mockResolvedValue({});
mockPrisma.prompt.update.mockResolvedValue({});
const result = await service.toggleFavorite(1, 1);
expect(result).toEqual({ favorited: true });
expect(mockPrisma.promptFavorite.create).toHaveBeenCalledWith({
data: { userId: 1, promptId: 1 },
});
expect(mockPrisma.prompt.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { likeCount: { increment: 1 } },
});
});
it('should remove favorite if exists', async () => {
mockPrisma.promptFavorite.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.promptFavorite.delete.mockResolvedValue({});
mockPrisma.prompt.update.mockResolvedValue({});
const result = await service.toggleFavorite(1, 1);
expect(result).toEqual({ favorited: false });
expect(mockPrisma.promptFavorite.delete).toHaveBeenCalledWith({
where: { id: 1 },
});
});
});
describe('findFavorites', () => {
it('should return user favorites with pagination', async () => {
const mockFavorites = [
{ id: 1, userId: 1, promptId: 1, prompt: { id: 1, title: '提示词1' } },
];
mockPrisma.promptFavorite.findMany.mockResolvedValue(mockFavorites);
mockPrisma.promptFavorite.count.mockResolvedValue(1);
const result = await service.findFavorites(1, { page: 1, pageSize: 10 });
expect(result).toEqual({ items: mockFavorites, total: 1, page: 1, pageSize: 10 });
});
});
});
@@ -0,0 +1,44 @@
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, UseGuards, Req, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { SandboxService } from './sandbox.service';
import { ChatOptions } from '../ai/ai-gateway.service';
@ApiTags('AI沙箱')
@Controller('sandbox')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class SandboxController {
constructor(private sandboxService: SandboxService) {}
@Post('chat')
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], temperature: 0.7, top_p: 1, max_tokens: 2000 } } })
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[] } & ChatOptions) {
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body);
}
@Get('sessions')
async sessions(@Req() req: any, @Query() query: { page?: number; pageSize?: number; search?: string }) {
return this.sandboxService.getSessions(req.user.userId, query);
}
@Get('sessions/:id')
async getSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.getSession(req.user.userId, id);
}
@Patch('sessions/:id/feedback')
async setFeedback(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { feedback: 'LIKE' | 'DISLIKE' | null }) {
return this.sandboxService.setFeedback(req.user.userId, id, body.feedback);
}
@Delete('sessions/:id')
async deleteSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.deleteSession(req.user.userId, id);
}
@Get('quota')
async quota(@Req() req: any) {
return this.sandboxService.getQuota(req.user.userId);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SandboxController } from './sandbox.controller';
import { SandboxService } from './sandbox.service';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [AIModule],
controllers: [SandboxController],
providers: [SandboxService],
exports: [SandboxService],
})
export class SandboxModule {}
@@ -0,0 +1,154 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
import { randomUUID } from 'crypto';
@Injectable()
export class SandboxService {
constructor(
private prisma: PrismaService,
private aiGateway: AIGatewayService,
) {}
async chat(userId: number, conversationId: string | undefined, model: string, messages: { role: string; content: string }[], options?: ChatOptions) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.status !== 'ACTIVE') {
throw new HttpException('用户不可用', HttpStatus.FORBIDDEN);
}
const convId = conversationId || randomUUID();
// 今日配额:按 conversationId 去重计数
const today = new Date();
today.setHours(0, 0, 0, 0);
const existing = await this.prisma.sandboxSession.findUnique({
where: { userId_conversationId: { userId, conversationId: convId } },
});
if (!existing) {
const todayCount = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
if (todayCount >= (user.sandboxDaily || 10)) {
throw new HttpException('今日沙箱使用次数已用完', HttpStatus.TOO_MANY_REQUESTS);
}
}
let reply = await this.aiGateway.chat(model, messages as any, options);
if (typeof reply !== 'string') {
reply = '抱歉,AI 返回了无效的回复,请重试。';
}
const allMessages = messages.concat({ role: 'assistant', content: reply });
const firstUserMsg = messages.find(m => m.role === 'user');
const title = firstUserMsg ? firstUserMsg.content.slice(0, 80) : 'AI 对话';
const session = await this.prisma.sandboxSession.upsert({
where: { userId_conversationId: { userId, conversationId: convId } },
create: {
userId,
conversationId: convId,
model,
title,
messages: JSON.stringify(allMessages),
tokens: Math.ceil(reply.length / 2),
},
update: {
model,
title,
messages: JSON.stringify(allMessages),
tokens: Math.ceil(reply.length / 2),
},
});
return { reply, conversationId: convId, sessionId: session.id };
}
async getSessions(userId: number, params: { page?: number; pageSize?: number; search?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 50);
const where: any = { userId };
if (params.search) {
where.title = { contains: params.search };
}
const [items, total] = await Promise.all([
this.prisma.sandboxSession.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
select: { id: true, conversationId: true, model: true, title: true, feedback: true, createdAt: true, tokens: true },
}),
this.prisma.sandboxSession.count({ where }),
]);
return { items, total, page, pageSize };
}
async getSession(userId: number, id: number) {
const session = await this.prisma.sandboxSession.findFirst({
where: { id, userId },
});
if (!session) {
throw new HttpException('会话不存在', HttpStatus.NOT_FOUND);
}
return {
id: session.id,
conversationId: session.conversationId,
model: session.model,
title: session.title,
feedback: session.feedback,
createdAt: session.createdAt,
messages: JSON.parse(session.messages),
};
}
async setFeedback(userId: number, id: number, feedback: string | null) {
const session = await this.prisma.sandboxSession.findFirst({
where: { id, userId },
});
if (!session) {
throw new HttpException('会话不存在', HttpStatus.NOT_FOUND);
}
await this.prisma.sandboxSession.update({
where: { id },
data: { feedback: feedback || null },
});
return { success: true };
}
async deleteSession(userId: number, id: number) {
const session = await this.prisma.sandboxSession.findFirst({
where: { id, userId },
});
if (!session) {
throw new HttpException('会话不存在', HttpStatus.NOT_FOUND);
}
await this.prisma.sandboxSession.delete({ where: { id } });
return { success: true };
}
async getHistory(userId: number, params: { page?: number; pageSize?: number }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const [items, total] = await Promise.all([
this.prisma.sandboxSession.findMany({
where: { userId },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
select: { id: true, conversationId: true, model: true, title: true, feedback: true, createdAt: true, tokens: true },
}),
this.prisma.sandboxSession.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
async getQuota(userId: number) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const today = new Date();
today.setHours(0, 0, 0, 0);
const used = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
return { dailyLimit: user?.sandboxDaily || 10, used, remaining: (user?.sandboxDaily || 10) - used };
}
}
@@ -0,0 +1,145 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpException, HttpStatus } from '@nestjs/common';
import { SandboxService } from '../sandbox.service';
import { PrismaService } from '../../../prisma/prisma.service';
import { AIGatewayService } from '../../ai/ai-gateway.service';
describe('SandboxService', () => {
let service: SandboxService;
let prisma: PrismaService;
let aiGateway: AIGatewayService;
const mockPrisma = {
user: {
findUnique: jest.fn(),
},
sandboxSession: {
findMany: jest.fn(),
findUnique: jest.fn(),
count: jest.fn(),
create: jest.fn(),
upsert: jest.fn(),
},
};
const mockAIGateway = {
chat: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SandboxService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AIGatewayService, useValue: mockAIGateway },
],
}).compile();
service = module.get<SandboxService>(SandboxService);
prisma = module.get<PrismaService>(PrismaService);
aiGateway = module.get<AIGatewayService>(AIGatewayService);
jest.clearAllMocks();
});
describe('chat', () => {
it('should throw if user not found', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(
service.chat(1, 'test-conv', 'gpt-3.5', [{ role: 'user', content: 'Hello' }])
).rejects.toThrow(HttpException);
});
it('should throw if user not active', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ id: 1, status: 'INACTIVE' });
await expect(
service.chat(1, 'test-conv', 'gpt-3.5', [{ role: 'user', content: 'Hello' }])
).rejects.toThrow(HttpException);
});
it('should throw if daily quota exceeded for new conversation', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, status: 'ACTIVE', sandboxDaily: 10
});
mockPrisma.sandboxSession.findUnique.mockResolvedValue(null);
mockPrisma.sandboxSession.count.mockResolvedValue(10);
await expect(
service.chat(1, 'new-conv', 'gpt-3.5', [{ role: 'user', content: 'Hello' }])
).rejects.toThrow(HttpException);
});
it('should not count quota for existing conversation', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, status: 'ACTIVE', sandboxDaily: 10
});
mockPrisma.sandboxSession.findUnique.mockResolvedValue({ id: 1 });
mockAIGateway.chat.mockResolvedValue('AI回复');
mockPrisma.sandboxSession.upsert.mockResolvedValue({ id: 2 });
const result = await service.chat(1, 'existing-conv', 'gpt-3.5', [
{ role: 'user', content: 'Hello' }
]);
expect(result.reply).toBe('AI回复');
expect(mockPrisma.sandboxSession.count).not.toHaveBeenCalled();
});
it('should call AI gateway and save session', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, status: 'ACTIVE', sandboxDaily: 10
});
mockPrisma.sandboxSession.findUnique.mockResolvedValue(null);
mockPrisma.sandboxSession.count.mockResolvedValue(5);
mockAIGateway.chat.mockResolvedValue('AI回复');
mockPrisma.sandboxSession.upsert.mockResolvedValue({ id: 1 });
const result = await service.chat(1, 'test-conv', 'gpt-3.5', [
{ role: 'user', content: 'Hello' }
]);
expect(result.reply).toBe('AI回复');
expect(result.conversationId).toBe('test-conv');
expect(result.sessionId).toBeDefined();
expect(mockAIGateway.chat).toHaveBeenCalledWith('gpt-3.5', expect.any(Array), undefined);
expect(mockPrisma.sandboxSession.upsert).toHaveBeenCalled();
});
});
describe('getHistory', () => {
it('should return paginated history', async () => {
const mockSessions = [
{ id: 1, conversationId: 'conv-1', model: 'gpt-3.5', title: 'Test', createdAt: new Date(), tokens: 10 }
];
mockPrisma.sandboxSession.findMany.mockResolvedValue(mockSessions);
mockPrisma.sandboxSession.count.mockResolvedValue(1);
const result = await service.getHistory(1, { page: 1, pageSize: 20 });
expect(result).toEqual({
items: mockSessions,
total: 1,
page: 1,
pageSize: 20,
});
});
});
describe('getQuota', () => {
it('should return quota info', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, sandboxDaily: 10
});
mockPrisma.sandboxSession.count.mockResolvedValue(3);
const result = await service.getQuota(1);
expect(result).toEqual({
dailyLimit: 10,
used: 3,
remaining: 7,
});
});
});
});
@@ -0,0 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SearchService } from './search.service';
@ApiTags('搜索')
@Controller('search')
export class SearchController {
constructor(private searchService: SearchService) {}
@Get()
async search(
@Query('q') q: string,
@Query('type') type?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.searchService.search({
q,
type,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
});
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
})
export class SearchModule {}
@@ -0,0 +1,139 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class SearchService {
constructor(private prisma: PrismaService) {}
async search(params: { q: string; type?: string; page?: number; pageSize?: number }) {
const q = params.q?.trim();
if (!q) return { results: [], total: 0 };
const page = Number(params.page ?? 1);
const pageSize = Math.min(Number(params.pageSize ?? 10), 50);
const skip = (page - 1) * pageSize;
const type = params.type || 'all';
const results: any[] = [];
let total = 0;
const where: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ description: { contains: q } },
],
},
],
};
if (type === 'all' || type === 'courses') {
const [items, count] = await Promise.all([
this.prisma.course.findMany({
where: {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ description: { contains: q } },
],
},
],
},
select: { id: true, title: true, description: true, cover: true, isFree: true },
skip: type === 'courses' ? skip : 0,
take: type === 'courses' ? pageSize : 5,
}),
this.prisma.course.count({ where }),
]);
results.push(...items.map(i => ({ ...i, _type: 'course' })));
total += count;
}
if (type === 'all' || type === 'prompts') {
const promptWhere: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ description: { contains: q } },
{ content: { contains: q } },
],
},
],
};
const [items, count] = await Promise.all([
this.prisma.prompt.findMany({
where: promptWhere,
select: { id: true, title: true, description: true, model: true },
skip: type === 'prompts' ? skip : 0,
take: type === 'prompts' ? pageSize : 5,
}),
this.prisma.prompt.count({ where: promptWhere }),
]);
results.push(...items.map(i => ({ ...i, _type: 'prompt' })));
total += count;
}
if (type === 'all' || type === 'tools') {
const toolWhere: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ name: { contains: q } },
{ description: { contains: q } },
],
},
],
};
const [items, count] = await Promise.all([
this.prisma.tool.findMany({
where: toolWhere,
select: { id: true, name: true, description: true, url: true, icon: true },
skip: type === 'tools' ? skip : 0,
take: type === 'tools' ? pageSize : 5,
}),
this.prisma.tool.count({ where: toolWhere }),
]);
results.push(...items.map(i => ({ ...i, _type: 'tool' })));
total += count;
}
if (type === 'all' || type === 'contents') {
const contentWhere: any = {
AND: [
{ deletedAt: null },
{ status: 'PUBLISHED' },
{
OR: [
{ title: { contains: q } },
{ summary: { contains: q } },
],
},
],
};
const [items, count] = await Promise.all([
this.prisma.content.findMany({
where: contentWhere,
select: { id: true, title: true, summary: true, cover: true, publishedAt: true },
skip: type === 'contents' ? skip : 0,
take: type === 'contents' ? pageSize : 5,
}),
this.prisma.content.count({ where: contentWhere }),
]);
results.push(...items.map(i => ({ ...i, _type: 'content' })));
total += count;
}
return { results, total, page, pageSize };
}
}
@@ -0,0 +1,89 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchService } from '../search.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('SearchService', () => {
let service: SearchService;
let prisma: PrismaService;
const mockPrisma = {
course: { findMany: jest.fn(), count: jest.fn() },
prompt: { findMany: jest.fn(), count: jest.fn() },
tool: { findMany: jest.fn(), count: jest.fn() },
content: { findMany: jest.fn(), count: jest.fn() },
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SearchService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<SearchService>(SearchService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('search', () => {
it('should return empty results for empty query', async () => {
const result = await service.search({ q: '' });
expect(result).toEqual({ results: [], total: 0 });
});
it('should search across all types by default', async () => {
mockPrisma.course.findMany.mockResolvedValue([{ id: 1, title: 'AI课程', _type: 'course' }]);
mockPrisma.course.count.mockResolvedValue(1);
mockPrisma.prompt.findMany.mockResolvedValue([]);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
mockPrisma.content.findMany.mockResolvedValue([]);
mockPrisma.content.count.mockResolvedValue(0);
const result = await service.search({ q: 'AI' });
expect(result.results).toHaveLength(1);
expect(result.results[0]).toHaveProperty('_type', 'course');
expect(result.total).toBe(1);
});
it('should filter by specific type', async () => {
mockPrisma.tool.findMany.mockResolvedValue([{ id: 1, name: 'ChatGPT', _type: 'tool' }]);
mockPrisma.tool.count.mockResolvedValue(1);
const result = await service.search({ q: 'chat', type: 'tools' });
expect(result.results).toHaveLength(1);
expect(result.results[0]).toHaveProperty('_type', 'tool');
});
it('should respect pagination params', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
mockPrisma.prompt.findMany.mockResolvedValue([]);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
mockPrisma.content.findMany.mockResolvedValue([]);
mockPrisma.content.count.mockResolvedValue(0);
const result = await service.search({ q: 'AI', page: 2, pageSize: 5 });
expect(result.page).toBe(2);
expect(result.pageSize).toBe(5);
});
it('should cap pageSize at 50', async () => {
mockPrisma.course.findMany.mockResolvedValue([]);
mockPrisma.course.count.mockResolvedValue(0);
mockPrisma.prompt.findMany.mockResolvedValue([]);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
mockPrisma.content.findMany.mockResolvedValue([]);
mockPrisma.content.count.mockResolvedValue(0);
const result = await service.search({ q: 'AI', pageSize: 100 });
expect(result.pageSize).toBe(50);
});
});
});
@@ -0,0 +1,78 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ToolsService } from '../tools.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('ToolsService', () => {
let service: ToolsService;
let prisma: PrismaService;
const mockPrisma = {
tool: {
findMany: jest.fn(),
count: jest.fn(),
create: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ToolsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<ToolsService>(ToolsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated tools', async () => {
const mockTools = [{ id: 1, name: 'ChatGPT', category: { id: 1, name: '聊天' } }];
mockPrisma.tool.findMany.mockResolvedValue(mockTools);
mockPrisma.tool.count.mockResolvedValue(1);
const result = await service.findAll({ page: 1, pageSize: 20 });
expect(result.items).toEqual(mockTools);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
});
it('should filter by categoryId', async () => {
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
await service.findAll({ categoryId: 2 });
expect(mockPrisma.tool.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ categoryId: 2 }),
}),
);
});
it('should filter by isFeatured', async () => {
mockPrisma.tool.findMany.mockResolvedValue([]);
mockPrisma.tool.count.mockResolvedValue(0);
await service.findAll({ isFeatured: true });
expect(mockPrisma.tool.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isFeatured: true }),
}),
);
});
});
describe('create', () => {
it('should create a tool', async () => {
const data = { name: 'New Tool', url: 'https://example.com' };
mockPrisma.tool.create.mockResolvedValue({ id: 1, ...data });
const result = await service.create(data);
expect(result).toHaveProperty('id', 1);
expect(mockPrisma.tool.create).toHaveBeenCalledWith({ data });
});
});
});
@@ -0,0 +1,19 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ToolsService } from './tools.service';
@ApiTags('AI工具')
@Controller('tools')
export class ToolsController {
constructor(private toolsService: ToolsService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) {
return this.toolsService.findAll(query);
}
@Post()
async create(@Body() body: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
return this.toolsService.create(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ToolsController } from './tools.controller';
import { ToolsService } from './tools.service';
@Module({
controllers: [ToolsController],
providers: [ToolsService],
exports: [ToolsService],
})
export class ToolsModule {}
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ToolsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, isFeatured } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null };
if (categoryId) where.categoryId = categoryId;
if (isFeatured !== undefined) where.isFeatured = isFeatured;
const [items, total] = await Promise.all([
this.prisma.tool.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { viewCount: 'desc' },
include: { category: true },
}),
this.prisma.tool.count({ where }),
]);
return { items, total, page, pageSize };
}
async create(data: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
return this.prisma.tool.create({ data });
}
}
@@ -0,0 +1,52 @@
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { randomUUID } from 'crypto';
import { ApiTags } from '@nestjs/swagger';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const MAX_SIZE = 5 * 1024 * 1024;
@ApiTags('文件上传')
@Controller('upload')
export class UploadController {
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: join(process.cwd(), 'uploads'),
filename: (_req, file, cb) => {
const ext = extname(file.originalname);
cb(null, `${randomUUID()}${ext}`);
},
}),
limits: { fileSize: MAX_SIZE },
fileFilter: (_req, file, cb) => {
if (ALLOWED_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new BadRequestException('不支持的文件类型,仅支持 jpg/png/gif/webp/svg'), false);
}
},
}),
)
uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('请选择文件');
}
return {
url: `/uploads/${file.filename}`,
filename: file.filename,
size: file.size,
mimetype: file.mimetype,
};
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
@Module({
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { extname } from 'path';
import * as fs from 'fs';
@Injectable()
export class UploadService {
private uploadDir = 'uploads';
ensureUploadDir() {
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
}
}
getUploadDir(): string {
return this.uploadDir;
}
}
@@ -0,0 +1,83 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from '../users.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('UsersService', () => {
let service: UsersService;
let prisma: PrismaService;
const mockPrisma = {
user: {
findMany: jest.fn(),
findUnique: jest.fn(),
count: jest.fn(),
update: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<UsersService>(UsersService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated users', async () => {
const mockUsers = [{ id: 1, nickname: '用户1' }];
mockPrisma.user.findMany.mockResolvedValue(mockUsers);
mockPrisma.user.count.mockResolvedValue(1);
const result = await service.findAll({ page: 1, pageSize: 20 });
expect(result.items).toEqual(mockUsers);
expect(result.total).toBe(1);
});
it('should filter by status', async () => {
mockPrisma.user.findMany.mockResolvedValue([]);
mockPrisma.user.count.mockResolvedValue(0);
await service.findAll({ status: 'ACTIVE' });
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'ACTIVE' }),
}),
);
});
});
describe('findById', () => {
it('should return user by id', async () => {
const mockUser = { id: 1, nickname: '用户1' };
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const result = await service.findById(1);
expect(result).toEqual(mockUser);
});
it('should return null for non-existent user', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
const result = await service.findById(999);
expect(result).toBeNull();
});
});
describe('updateProfile', () => {
it('should update user profile', async () => {
mockPrisma.user.update.mockResolvedValue({ id: 1, nickname: '新昵称' });
const result = await service.updateProfile(1, { nickname: '新昵称' });
expect(result).toHaveProperty('nickname', '新昵称');
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { nickname: '新昵称' },
});
});
});
});
@@ -0,0 +1,31 @@
import { Controller, Get, Put, Body, Param, UseGuards, Req, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { UsersService } from './users.service';
@ApiTags('用户')
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async findAll(@Query() query: { page?: number; pageSize?: number; status?: string }) {
return this.usersService.findAll(query);
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async findById(@Param('id') id: string) {
return this.usersService.findById(+id);
}
@Put('profile')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async updateProfile(@Req() req: any, @Body() body: { nickname?: string; avatar?: string }) {
return this.usersService.updateProfile(req.user.userId, body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; status?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const where: any = { deletedAt: null };
if (params.status) where.status = params.status;
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, avatar: true, phone: true, email: true,
status: true, memberPlan: true, createdAt: true, lastLoginAt: true,
},
}),
this.prisma.user.count({ where }),
]);
return { items, total, page, pageSize };
}
async findById(id: number) {
return this.prisma.user.findUnique({
where: { id },
select: {
id: true, nickname: true, avatar: true, phone: true, email: true,
status: true, memberPlan: true, memberExpire: true, sandboxDaily: true,
createdAt: true, lastLoginAt: true,
},
});
}
async updateProfile(id: number, data: { nickname?: string; avatar?: string }) {
return this.prisma.user.update({ where: { id }, data });
}
}