feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { CoursesModule } from './modules/courses/courses.module';
|
||||
import { ContentsModule } from './modules/contents/contents.module';
|
||||
import { PromptsModule } from './modules/prompts/prompts.module';
|
||||
import { ToolsModule } from './modules/tools/tools.module';
|
||||
import { SandboxModule } from './modules/sandbox/sandbox.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { PaymentModule } from './modules/payment/payment.module';
|
||||
import { CategoriesModule } from './modules/categories/categories.module';
|
||||
import { DashboardModule } from './modules/dashboard/dashboard.module';
|
||||
import { SearchModule } from './modules/search/search.module';
|
||||
import { ModelsModule } from './modules/models/models.module';
|
||||
import { UploadModule } from './modules/upload/upload.module';
|
||||
import { CommunityModule } from './modules/community/community.module';
|
||||
import { EnterpriseModule } from './modules/enterprise/enterprise.module';
|
||||
import { NotificationModule } from './modules/notifications/notification.module';
|
||||
import { LearningModule } from './modules/learning/learning.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
CoursesModule,
|
||||
ContentsModule,
|
||||
PromptsModule,
|
||||
ToolsModule,
|
||||
SandboxModule,
|
||||
OrdersModule,
|
||||
AdminModule,
|
||||
PaymentModule,
|
||||
CategoriesModule,
|
||||
DashboardModule,
|
||||
SearchModule,
|
||||
ModelsModule,
|
||||
UploadModule,
|
||||
CommunityModule,
|
||||
EnterpriseModule,
|
||||
NotificationModule,
|
||||
LearningModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = '服务器内部错误';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
message = typeof res === 'string' ? res : (res as any).message || message;
|
||||
if (Array.isArray(message)) message = message[0];
|
||||
}
|
||||
|
||||
this.logger.error(`[${status}] ${message}`, exception instanceof Error ? exception.stack : '');
|
||||
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message,
|
||||
data: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GlobalExceptionFilter } from './filters/global-exception.filter';
|
||||
export { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { method, url } = request;
|
||||
const now = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const ms = Date.now() - now;
|
||||
this.logger.log(`${method} ${url} ${response.statusCode} ${ms}ms`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import helmet from 'helmet';
|
||||
import * as compression from 'compression';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter, LoggingInterceptor } from './common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: ['log', 'error', 'warn', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
app.use(compression());
|
||||
|
||||
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('宇之然 AI API')
|
||||
.setDescription('宇之然 AI 学习与实践平台 API 文档')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 4000;
|
||||
await app.listen(port);
|
||||
console.log(`宇之然 AI API 服务已启动: http://localhost:${port}`);
|
||||
console.log(`API 文档: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user