P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善
- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日 - 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块 - 导航: 主菜单隐藏企业版/社区(URL 可访问) - 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写 - 工具页: affiliateLink 绿色推荐 Badge - SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127 - 沙盒: 新增编程导师场景(苏格拉底教学法) - 练习系统: Practices 多场景练习(含结构化评分) - 技能广场: 6 个付费 Skill(标题大师/回款助手等) - 管理后台: Models/Posts/Practices CRUD 页面 - 文档: README + progress.md 全面更新;AGENTS.md 同步定位 - 清理: .env.example 移除;tsbuildinfo gitignore
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -22,10 +24,19 @@ import { NotificationModule } from './modules/notifications/notification.module'
|
||||
import { LearningModule } from './modules/learning/learning.module';
|
||||
import { SkillsModule } from './modules/skills/skills.module';
|
||||
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
|
||||
import { PracticesModule } from './modules/practices/practices.module';
|
||||
import { RedisThrottlerStorage } from './common/redis-throttler-storage';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot({
|
||||
throttlers: [
|
||||
{ name: 'short', ttl: 60000, limit: 60 },
|
||||
{ name: 'medium', ttl: 300000, limit: 200 },
|
||||
],
|
||||
storage: new RedisThrottlerStorage(),
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule, UsersModule, CoursesModule, ContentsModule,
|
||||
PromptsModule, ToolsModule, SandboxModule, OrdersModule,
|
||||
@@ -33,6 +44,10 @@ import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
|
||||
SearchModule, ModelsModule, UploadModule, CommunityModule,
|
||||
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
|
||||
AiAssistantModule,
|
||||
PracticesModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ThrottlerStorage } from '@nestjs/throttler';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export class RedisThrottlerStorage implements ThrottlerStorage {
|
||||
private redis: Redis;
|
||||
|
||||
constructor() {
|
||||
this.redis = new Redis({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
keyPrefix: 'throttle:',
|
||||
});
|
||||
}
|
||||
|
||||
async increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise<{
|
||||
totalHits: number;
|
||||
timeToExpire: number;
|
||||
isBlocked: boolean;
|
||||
timeToBlockExpire: number;
|
||||
}> {
|
||||
const redisKey = `throttle:${throttlerName}:${key}`;
|
||||
const total = await this.redis.incr(redisKey);
|
||||
if (total === 1) {
|
||||
await this.redis.pexpire(redisKey, ttl);
|
||||
}
|
||||
const ttlRemaining = await this.redis.pttl(redisKey);
|
||||
return {
|
||||
totalHits: total,
|
||||
timeToExpire: Math.max(0, Math.ceil(ttlRemaining / 1000)),
|
||||
isBlocked: total > limit,
|
||||
timeToBlockExpire: total > limit ? Math.max(0, Math.ceil(ttlRemaining / 1000)) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -5,6 +5,7 @@ import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import helmet from 'helmet';
|
||||
import * as compression from 'compression';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter, LoggingInterceptor } from './common';
|
||||
|
||||
@@ -15,14 +16,36 @@ async function bootstrap() {
|
||||
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
contentSecurityPolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'https:'],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'https:'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
||||
connectSrc: ["'self'", 'https:'],
|
||||
fontSrc: ["'self'", 'https:'],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'self'"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.use(cookieParser());
|
||||
app.use(compression());
|
||||
|
||||
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const allowedOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',')
|
||||
: ['https://yuzhiran.com', 'https://www.yuzhiran.com', 'https://www.yuzhiran.com.cn'];
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(null, false);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
@@ -49,4 +72,11 @@ async function bootstrap() {
|
||||
console.log(`API 文档: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('Unhandled Rejection:', reason);
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param, Query, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Put, Delete, Body, UseGuards, Req, Param, Query, HttpException, HttpStatus, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AdminService } from './admin.service';
|
||||
@@ -232,4 +232,253 @@ export class AdminController {
|
||||
return { reply: result.summary, result };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Practice Questions Admin ---
|
||||
@Get('practices')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminPractices(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('search') search?: string) {
|
||||
const p = Math.max(1, Number(page) || 1);
|
||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
|
||||
const where: any = {};
|
||||
if (search) where.title = { contains: search };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.practiceQuestion.findMany({ where, orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
|
||||
this.prisma.practiceQuestion.count({ where }),
|
||||
]);
|
||||
return { items, total, page: p, pageSize: ps };
|
||||
}
|
||||
|
||||
@Get('practices/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminPractice(@Param('id', ParseIntPipe) id: number) {
|
||||
const item = await this.prisma.practiceQuestion.findUnique({ where: { id } });
|
||||
if (!item) throw new HttpException('练习题目不存在', HttpStatus.NOT_FOUND);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Post('practices')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async createPractice(@Body() body: any) {
|
||||
const item = await this.prisma.practiceQuestion.create({ data: body });
|
||||
return item;
|
||||
}
|
||||
|
||||
@Put('practices/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async updatePractice(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
|
||||
const { id: _id, ...data } = body;
|
||||
const item = await this.prisma.practiceQuestion.update({ where: { id }, data });
|
||||
return item;
|
||||
}
|
||||
|
||||
@Delete('practices/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async deletePractice(@Param('id', ParseIntPipe) id: number) {
|
||||
await this.prisma.practiceQuestion.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Skills Admin ---
|
||||
@Get('skills')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminSkills(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
const p = Math.max(1, Number(page) || 1);
|
||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.skill.findMany({ orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
|
||||
this.prisma.skill.count(),
|
||||
]);
|
||||
return { items, total, page: p, pageSize: ps };
|
||||
}
|
||||
|
||||
@Get('skills/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminSkill(@Param('id') id: string) {
|
||||
const item = await this.prisma.skill.findUnique({ where: { id } });
|
||||
if (!item) throw new HttpException('技能不存在', HttpStatus.NOT_FOUND);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Put('skills/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async updateSkill(@Param('id') id: string, @Body() body: any) {
|
||||
const { id: _id, purchasedBy, ...data } = body;
|
||||
const item = await this.prisma.skill.update({ where: { id }, data });
|
||||
return item;
|
||||
}
|
||||
|
||||
// --- Circles Admin ---
|
||||
@Get('circles')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminCircles(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
const p = Math.max(1, Number(page) || 1);
|
||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.circle.findMany({ orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps, include: { creator: { select: { id: true, nickname: true } }, _count: { select: { members: true, posts: true } } } }),
|
||||
this.prisma.circle.count(),
|
||||
]);
|
||||
return { items, total, page: p, pageSize: ps };
|
||||
}
|
||||
|
||||
@Delete('circles/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async deleteCircle(@Param('id', ParseIntPipe) id: number) {
|
||||
await this.prisma.circle.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Community Posts Admin ---
|
||||
@Get('posts')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminPosts(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('status') status?: string) {
|
||||
const p = Math.max(1, Number(page) || 1);
|
||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.post.findMany({
|
||||
where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps,
|
||||
include: { user: { select: { id: true, nickname: true } }, _count: { select: { comments: true, likes: true } } },
|
||||
}),
|
||||
this.prisma.post.count({ where }),
|
||||
]);
|
||||
return { items, total, page: p, pageSize: ps };
|
||||
}
|
||||
|
||||
@Get('posts/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminPost(@Param('id', ParseIntPipe) id: number) {
|
||||
const item = await this.prisma.post.findUnique({
|
||||
where: { id },
|
||||
include: { user: { select: { id: true, nickname: true } }, _count: { select: { comments: true, likes: true } } },
|
||||
});
|
||||
if (!item) throw new HttpException('帖子不存在', HttpStatus.NOT_FOUND);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Put('posts/:id/status')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async updatePostStatus(@Param('id', ParseIntPipe) id: number, @Body() body: { status: string }) {
|
||||
const item = await this.prisma.post.update({ where: { id }, data: { status: body.status } });
|
||||
return item;
|
||||
}
|
||||
|
||||
@Delete('posts/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async deletePost(@Param('id', ParseIntPipe) id: number) {
|
||||
await this.prisma.post.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- AI Tools Admin ---
|
||||
@Get('tools')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminTools(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('search') search?: string) {
|
||||
const p = Math.max(1, Number(page) || 1);
|
||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
|
||||
const where: any = {};
|
||||
if (search) where.name = { contains: search };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.tool.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps }),
|
||||
this.prisma.tool.count({ where }),
|
||||
]);
|
||||
return { items, total, page: p, pageSize: ps };
|
||||
}
|
||||
|
||||
@Get('tools/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminTool(@Param('id', ParseIntPipe) id: number) {
|
||||
const item = await this.prisma.tool.findUnique({ where: { id } });
|
||||
if (!item) throw new HttpException('工具不存在', HttpStatus.NOT_FOUND);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Post('tools')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async createTool(@Body() body: any) {
|
||||
const item = await this.prisma.tool.create({ data: body });
|
||||
return item;
|
||||
}
|
||||
|
||||
@Put('tools/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async updateTool(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
|
||||
const { id: _id, ...data } = body;
|
||||
const item = await this.prisma.tool.update({ where: { id }, data });
|
||||
return item;
|
||||
}
|
||||
|
||||
@Delete('tools/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async deleteTool(@Param('id', ParseIntPipe) id: number) {
|
||||
await this.prisma.tool.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- AI Models Admin ---
|
||||
@Get('models')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminModels(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
const p = Math.max(1, Number(page) || 1);
|
||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.aiModel.findMany({ orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
|
||||
this.prisma.aiModel.count(),
|
||||
]);
|
||||
return { items, total, page: p, pageSize: ps };
|
||||
}
|
||||
|
||||
@Get('models/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async adminModel(@Param('id', ParseIntPipe) id: number) {
|
||||
const item = await this.prisma.aiModel.findUnique({ where: { id } });
|
||||
if (!item) throw new HttpException('模型不存在', HttpStatus.NOT_FOUND);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Post('models')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async createModel(@Body() body: any) {
|
||||
const item = await this.prisma.aiModel.create({ data: body });
|
||||
return item;
|
||||
}
|
||||
|
||||
@Put('models/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async updateModel(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
|
||||
const { id: _id, ...data } = body;
|
||||
const item = await this.prisma.aiModel.update({ where: { id }, data });
|
||||
return item;
|
||||
}
|
||||
|
||||
@Delete('models/:id')
|
||||
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async deleteModel(@Param('id', ParseIntPipe) id: number) {
|
||||
await this.prisma.aiModel.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
export class PublicController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get('stats')
|
||||
async getStats() {
|
||||
const [courses, prompts, tools, users] = await Promise.all([
|
||||
this.prisma.course.count({ where: { status: 'PUBLISHED', deletedAt: null } }),
|
||||
this.prisma.prompt.count({ where: { status: 'PUBLISHED' } }),
|
||||
this.prisma.tool.count({ where: { status: 'PUBLISHED' } }),
|
||||
this.prisma.user.count({ where: { status: 'ACTIVE' } }),
|
||||
]);
|
||||
return { courses, prompts, tools, users };
|
||||
}
|
||||
|
||||
@Get('banners')
|
||||
async banners(@Query('position') position?: string) {
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, UseGuards, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
@@ -10,19 +11,39 @@ import { LoginDto } from './dto/login.dto';
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
private setTokenCookie(res: any, accessToken: string) {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
res.cookie('token', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() body: RegisterDto) {
|
||||
return this.authService.register(body);
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
async register(@Body() body: RegisterDto, @Res({ passthrough: true }) res: any) {
|
||||
const tokens = await this.authService.register(body);
|
||||
this.setTokenCookie(res, tokens.accessToken);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() body: LoginDto) {
|
||||
return this.authService.login(body.account, body.password);
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async login(@Body() body: LoginDto, @Res({ passthrough: true }) res: any) {
|
||||
const tokens = await this.authService.login(body.account, body.password);
|
||||
this.setTokenCookie(res, tokens.accessToken);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
async refresh(@Body() body: { accessToken: string }) {
|
||||
return this.authService.refreshAccessToken(body.accessToken);
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
async refresh(@Body() body: { accessToken: string }, @Res({ passthrough: true }) res: any) {
|
||||
const tokens = await this.authService.refreshAccessToken(body.accessToken);
|
||||
this.setTokenCookie(res, tokens.accessToken);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@@ -31,4 +52,17 @@ export class AuthController {
|
||||
async profile(@Req() req: any) {
|
||||
return this.authService.getProfile(req.user.userId);
|
||||
}
|
||||
|
||||
@Get('verify')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
async verify(@Req() req: any) {
|
||||
const profile = await this.authService.getProfile(req.user.userId);
|
||||
return { ...profile, accessToken: req.headers.authorization?.replace('Bearer ', '') || '' };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
async logout(@Res({ passthrough: true }) res: any) {
|
||||
res.cookie('token', '', { httpOnly: true, maxAge: 0, path: '/' });
|
||||
return { message: '已退出' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
const secret = config.get<string>('JWT_SECRET') || 'yuzhiran-ai-default-secret';
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
}
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
(req) => req?.cookies?.token || null,
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@ApiTags('分类')
|
||||
@@ -21,16 +22,22 @@ export class CategoriesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async create(@Body() body: { name: string; slug: string; description?: string; sortOrder?: number }) {
|
||||
return this.prisma.category.create({ data: body });
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
return this.prisma.category.update({ where: { id: +id }, data: body });
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.prisma.category.delete({ where: { id: +id } });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ContentsService } from './contents.service';
|
||||
|
||||
@ApiTags('内容')
|
||||
@@ -18,16 +19,22 @@ export class ContentsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async create(@Body() body: any) {
|
||||
return this.contentsService.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
return this.contentsService.update(+id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.contentsService.remove(+id);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export class CoursesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async create(@Body() body: {
|
||||
title: string; description?: string; cover?: string; categoryId?: number;
|
||||
price?: number; isFree?: boolean;
|
||||
@@ -27,11 +29,15 @@ export class CoursesController {
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
return this.coursesService.update(+id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.coursesService.remove(+id);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ class CreateOrderDto {
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['MONTHLY', 'YEARLY', 'COURSE'])
|
||||
@IsIn(['MONTHLY', 'YEARLY', 'COURSE', 'PACKAGE', 'SKILL'])
|
||||
planType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skillId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
payChannel?: string;
|
||||
|
||||
@@ -9,10 +9,12 @@ export class OrdersService {
|
||||
private gatewayPay: GatewayPayService,
|
||||
) {}
|
||||
|
||||
async create(userId: number, data: { amount: number; planType: string; payChannel?: string; tradeType?: string; openid?: string }) {
|
||||
async create(userId: number, data: { amount: number; planType: string; skillId?: string; payChannel?: string; tradeType?: string; openid?: string }) {
|
||||
const channel = data.payChannel || 'alipay';
|
||||
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
|
||||
const metadata = data.skillId ? JSON.stringify({ skillId: data.skillId }) : null;
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
@@ -20,12 +22,15 @@ export class OrdersService {
|
||||
amount: data.amount,
|
||||
planType: data.planType,
|
||||
payChannel: channel,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
const planLabels: Record<string, string> = {
|
||||
MONTHLY: '宇之然AI月卡会员',
|
||||
YEARLY: '宇之然AI年卡会员',
|
||||
PACKAGE: '宇之然AI用量包',
|
||||
SKILL: 'AI技能购买',
|
||||
};
|
||||
const subject = planLabels[data.planType] || '宇之然AI会员充值';
|
||||
|
||||
@@ -73,7 +78,13 @@ export class OrdersService {
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
});
|
||||
|
||||
if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
|
||||
if (order.planType === 'PACKAGE') {
|
||||
const extraQuota = order.amount >= 49 ? 300 : 50;
|
||||
await this.prisma.user.update({
|
||||
where: { id: order.userId },
|
||||
data: { sandboxExtra: { increment: extraQuota } },
|
||||
});
|
||||
} else if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
|
||||
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
|
||||
const now = new Date();
|
||||
let subscriptionEndDate: Date;
|
||||
@@ -106,6 +117,15 @@ export class OrdersService {
|
||||
where: { id: order.userId },
|
||||
data: { memberPlan: order.planType as any, memberExpire: subscriptionEndDate },
|
||||
});
|
||||
} else if (order.planType === 'SKILL' && order.metadata) {
|
||||
const meta = JSON.parse(order.metadata);
|
||||
if (meta.skillId) {
|
||||
await this.prisma.userSkill.upsert({
|
||||
where: { userId_skillId: { userId: order.userId, skillId: meta.skillId } },
|
||||
update: { orderId: order.id },
|
||||
create: { userId: order.userId, skillId: meta.skillId, orderId: order.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -323,7 +323,22 @@ export class GatewayPayService {
|
||||
where: { id: orderId },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!order || (order.planType !== 'MONTHLY' && order.planType !== 'YEARLY')) return;
|
||||
if (!order) return;
|
||||
|
||||
if (order.planType === 'SKILL' && order.metadata) {
|
||||
const meta = JSON.parse(order.metadata);
|
||||
if (meta.skillId) {
|
||||
await this.prisma.userSkill.upsert({
|
||||
where: { userId_skillId: { userId: order.userId, skillId: meta.skillId } },
|
||||
update: { orderId: order.id },
|
||||
create: { userId: order.userId, skillId: meta.skillId, orderId: order.id },
|
||||
});
|
||||
this.logger.log(`网关支付激活技能: 用户${order.userId}, 技能${meta.skillId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (order.planType !== 'MONTHLY' && order.planType !== 'YEARLY') return;
|
||||
|
||||
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
|
||||
import { GatewayPayService } from './gateway-pay.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
@@ -70,6 +71,8 @@ export class PaymentController {
|
||||
}
|
||||
|
||||
@Post('wxpay/refund')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '微信支付退款(转网关)' })
|
||||
@ApiBody({ type: RefundDto })
|
||||
async refund(@Body() body: RefundDto) {
|
||||
@@ -113,6 +116,8 @@ export class PaymentController {
|
||||
}
|
||||
|
||||
@Post('gateway/sync/:orderNo')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '手动同步订单状态(从网关查询并更新本地)' })
|
||||
async syncOrderStatus(@Param('orderNo') orderNo: string) {
|
||||
const result = await this.gatewayPay.syncOrderStatus(orderNo);
|
||||
@@ -123,6 +128,8 @@ export class PaymentController {
|
||||
}
|
||||
|
||||
@Post('gateway/close/:gatewayOrderId')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '关闭未支付订单' })
|
||||
async closeOrder(@Param('gatewayOrderId') gatewayOrderId: string) {
|
||||
const ok = await this.gatewayPay.closeOrder(gatewayOrderId);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
||||
|
||||
export class SubmitPracticeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
answer: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class PracticeQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
difficulty?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skillId?: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Controller, Get, Post, Param, Query, Body, Req, UseGuards, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { PracticesService } from './practices.service';
|
||||
import { SubmitPracticeDto, PracticeQueryDto } from './dto/submit-practice.dto';
|
||||
|
||||
@ApiTags('练习')
|
||||
@Controller('practices')
|
||||
export class PracticesController {
|
||||
constructor(private practicesService: PracticesService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: PracticeQueryDto) {
|
||||
return this.practicesService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
getCategories() {
|
||||
return this.practicesService.getCategories();
|
||||
}
|
||||
|
||||
@Get('difficulties')
|
||||
getDifficulties() {
|
||||
return this.practicesService.getDifficulties();
|
||||
}
|
||||
|
||||
@Get('submissions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
getUserSubmissions(
|
||||
@Req() req: any,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.practicesService.getUserSubmissions(req.user.userId, page, limit);
|
||||
}
|
||||
|
||||
@Get('submissions/:id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
getSubmissionDetail(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.practicesService.getSubmissionDetail(req.user.userId, id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findById(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.practicesService.findById(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
submitAnswer(
|
||||
@Req() req: any,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: SubmitPracticeDto,
|
||||
) {
|
||||
return this.practicesService.submitAnswer(req.user.userId, id, body.answer, body.duration || 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PracticesController } from './practices.controller';
|
||||
import { PracticesService } from './practices.service';
|
||||
import { AIModule } from '../ai/ai.module';
|
||||
|
||||
@Module({
|
||||
imports: [AIModule],
|
||||
controllers: [PracticesController],
|
||||
providers: [PracticesService],
|
||||
exports: [PracticesService],
|
||||
})
|
||||
export class PracticesModule {}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AIGatewayService } from '../ai/ai-gateway.service';
|
||||
|
||||
@Injectable()
|
||||
export class PracticesService {
|
||||
private readonly logger = new Logger(PracticesService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private ai: AIGatewayService,
|
||||
) {}
|
||||
|
||||
async findAll(query: { category?: string; difficulty?: string; search?: string; skillId?: string }) {
|
||||
const where: any = { status: 'PUBLISHED' };
|
||||
|
||||
if (query.category) where.category = query.category;
|
||||
if (query.difficulty) where.difficulty = query.difficulty;
|
||||
if (query.skillId) where.skillId = query.skillId;
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: query.search } },
|
||||
{ description: { contains: query.search } },
|
||||
];
|
||||
}
|
||||
|
||||
const items = await this.prisma.practiceQuestion.findMany({
|
||||
where,
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
scenario: true,
|
||||
title: true,
|
||||
description: true,
|
||||
difficulty: true,
|
||||
category: true,
|
||||
sortOrder: true,
|
||||
attemptCount: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { items, total: items.length };
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
const question = await this.prisma.practiceQuestion.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!question) throw new NotFoundException('练习题目不存在');
|
||||
return question;
|
||||
}
|
||||
|
||||
async getCategories() {
|
||||
const result = await this.prisma.practiceQuestion.findMany({
|
||||
where: { status: 'PUBLISHED' },
|
||||
select: { category: true },
|
||||
distinct: ['category'],
|
||||
});
|
||||
return result.map(r => r.category);
|
||||
}
|
||||
|
||||
async getDifficulties() {
|
||||
const result = await this.prisma.practiceQuestion.findMany({
|
||||
where: { status: 'PUBLISHED' },
|
||||
select: { difficulty: true },
|
||||
distinct: ['difficulty'],
|
||||
});
|
||||
return result.map(r => r.difficulty);
|
||||
}
|
||||
|
||||
async submitAnswer(userId: number, questionId: number, answer: string, duration: number = 0) {
|
||||
const question = await this.prisma.practiceQuestion.findUnique({
|
||||
where: { id: questionId },
|
||||
});
|
||||
|
||||
if (!question) throw new NotFoundException('练习题目不存在');
|
||||
|
||||
const existing = await this.prisma.practiceSubmission.findFirst({
|
||||
where: { userId, questionId, status: 'SUBMITTED' },
|
||||
});
|
||||
|
||||
if (existing) throw new ForbiddenException('你已经提交过此练习的答案');
|
||||
|
||||
const submission = await this.prisma.practiceSubmission.create({
|
||||
data: {
|
||||
userId,
|
||||
questionId,
|
||||
answer,
|
||||
duration,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.practiceQuestion.update({
|
||||
where: { id: questionId },
|
||||
data: { attemptCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
try {
|
||||
const criteria = question.expectedCriteria;
|
||||
const scoringPrompt = this.buildScoringPrompt(question, answer, criteria);
|
||||
const aiResult = await this.ai.chat('deepseek-v4-flash', [
|
||||
{ role: 'system', content: '你是一个专业的 AI 练习评分助手。请严格按照评分标准评估用户的回答,返回 JSON 格式的评分结果。' },
|
||||
{ role: 'user', content: scoringPrompt },
|
||||
], { temperature: 0.3 });
|
||||
|
||||
const scoreData = this.parseScoreResult(aiResult);
|
||||
|
||||
const updated = await this.prisma.practiceSubmission.update({
|
||||
where: { id: submission.id },
|
||||
data: {
|
||||
score: scoreData.totalScore,
|
||||
maxScore: 100,
|
||||
feedback: scoreData.feedback,
|
||||
criteriaScores: JSON.stringify(scoreData.criteria),
|
||||
status: 'SCORED',
|
||||
scoredAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`评分失败: ${err.message}`);
|
||||
return submission;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserSubmissions(userId: number, page: number = 1, limit: number = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.practiceSubmission.findMany({
|
||||
where: { userId },
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
include: {
|
||||
question: {
|
||||
select: { id: true, title: true, scenario: true, difficulty: true, category: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.practiceSubmission.count({ where: { userId } }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
async getSubmissionDetail(userId: number, submissionId: number) {
|
||||
const submission = await this.prisma.practiceSubmission.findUnique({
|
||||
where: { id: submissionId },
|
||||
include: { question: true },
|
||||
});
|
||||
|
||||
if (!submission) throw new NotFoundException('提交记录不存在');
|
||||
if (submission.userId !== userId) throw new ForbiddenException('无权查看此提交');
|
||||
|
||||
return submission;
|
||||
}
|
||||
|
||||
private buildScoringPrompt(question: any, answer: string, criteria: string): string {
|
||||
return `请根据以下场景和评分标准,评估用户的回答。
|
||||
|
||||
## 练习场景
|
||||
${question.scenario}
|
||||
|
||||
## 练习要求
|
||||
${question.instructions}
|
||||
|
||||
## 用户的回答
|
||||
${answer}
|
||||
|
||||
## 评分标准
|
||||
${criteria}
|
||||
|
||||
## 输出格式(必须返回纯 JSON,不要包含 markdown 代码块)
|
||||
{
|
||||
"criteria": [
|
||||
{ "name": "评分项名称", "score": 分数, "maxScore": 满分, "reason": "评分理由" }
|
||||
],
|
||||
"totalScore": 总分,
|
||||
"feedback": "总体反馈和改进建议(50-200字)"
|
||||
}`;
|
||||
}
|
||||
|
||||
private parseScoreResult(aiResult: string): { criteria: any[]; totalScore: number; feedback: string } {
|
||||
try {
|
||||
const cleaned = aiResult.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
const parsed = JSON.parse(cleaned);
|
||||
return {
|
||||
criteria: parsed.criteria || [],
|
||||
totalScore: Math.min(100, Math.max(0, parsed.totalScore || 0)),
|
||||
feedback: parsed.feedback || '评分完成',
|
||||
};
|
||||
} catch {
|
||||
this.logger.warn(`AI 评分结果解析失败,使用默认评分: ${aiResult.slice(0, 100)}`);
|
||||
return {
|
||||
criteria: [{ name: '综合评分', score: 75, maxScore: 100, reason: 'AI 评分解析失败,已使用默认分数' }],
|
||||
totalScore: 75,
|
||||
feedback: 'AI 评分系统遇到临时问题,请稍后重新查看评分详情。',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class SandboxService {
|
||||
|
||||
const convId = conversationId || randomUUID();
|
||||
|
||||
// 今日配额:按 conversationId 去重计数
|
||||
// 配额检查:先用每日免费配额,再用购买的额外配额
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const existing = await this.prisma.sandboxSession.findUnique({
|
||||
@@ -36,8 +36,16 @@ export class SandboxService {
|
||||
const todayCount = await this.prisma.sandboxSession.count({
|
||||
where: { userId, createdAt: { gte: today } },
|
||||
});
|
||||
if (todayCount >= (user.sandboxDaily || 10)) {
|
||||
throw new HttpException('今日沙箱使用次数已用完', HttpStatus.TOO_MANY_REQUESTS);
|
||||
const dailyLimit = user.sandboxDaily || 10;
|
||||
if (todayCount >= dailyLimit) {
|
||||
if ((user.sandboxExtra || 0) > 0) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { sandboxExtra: { decrement: 1 } },
|
||||
});
|
||||
} else {
|
||||
throw new HttpException('今日沙箱使用次数已用完', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +112,17 @@ export class SandboxService {
|
||||
const todayCount = await this.prisma.sandboxSession.count({
|
||||
where: { userId, createdAt: { gte: today } },
|
||||
});
|
||||
if (todayCount >= (user.sandboxDaily || 10)) {
|
||||
yield JSON.stringify({ type: 'error', message: '今日沙箱使用次数已用完' } as StreamResult);
|
||||
return;
|
||||
const dailyLimit = user.sandboxDaily || 10;
|
||||
if (todayCount >= dailyLimit) {
|
||||
if ((user.sandboxExtra || 0) > 0) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { sandboxExtra: { decrement: 1 } },
|
||||
});
|
||||
} else {
|
||||
yield JSON.stringify({ type: 'error', message: '今日沙箱使用次数已用完' } as StreamResult);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +275,17 @@ export class SandboxService {
|
||||
const used = await this.prisma.sandboxSession.count({
|
||||
where: { userId, createdAt: { gte: today } },
|
||||
});
|
||||
return { dailyLimit: user?.sandboxDaily || 10, used, remaining: (user?.sandboxDaily || 10) - used };
|
||||
const dailyLimit = user?.sandboxDaily || 10;
|
||||
const extra = user?.sandboxExtra || 0;
|
||||
const dailyRemaining = Math.max(0, dailyLimit - used);
|
||||
return {
|
||||
dailyLimit,
|
||||
used,
|
||||
dailyRemaining,
|
||||
extra,
|
||||
canUse: dailyRemaining > 0 || extra > 0,
|
||||
totalRemaining: dailyRemaining + extra,
|
||||
};
|
||||
}
|
||||
|
||||
async generateShareToken(userId: number, sessionId: number) {
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('SandboxService', () => {
|
||||
describe('getQuota', () => {
|
||||
it('should return quota info', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue({
|
||||
id: 1, sandboxDaily: 10
|
||||
id: 1, sandboxDaily: 10, sandboxExtra: 0
|
||||
});
|
||||
mockPrisma.sandboxSession.count.mockResolvedValue(3);
|
||||
|
||||
@@ -138,7 +138,10 @@ describe('SandboxService', () => {
|
||||
expect(result).toEqual({
|
||||
dailyLimit: 10,
|
||||
used: 3,
|
||||
remaining: 7,
|
||||
dailyRemaining: 7,
|
||||
extra: 0,
|
||||
canUse: true,
|
||||
totalRemaining: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { SkillsService } from './skills.service';
|
||||
|
||||
@ApiTags('技能')
|
||||
@@ -12,6 +13,12 @@ export class SkillsController {
|
||||
return this.skillsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('marketplace')
|
||||
getMarketplace(@Req() req: any) {
|
||||
const userId = req.user?.userId;
|
||||
return this.skillsService.getMarketplace(userId);
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
getCategories() {
|
||||
return this.skillsService.getCategories();
|
||||
@@ -23,8 +30,9 @@ export class SkillsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findById(@Param('id') id: string) {
|
||||
const skill = this.skillsService.findById(id);
|
||||
async findById(@Req() req: any, @Param('id') id: string) {
|
||||
const userId = req.user?.userId;
|
||||
const skill = await this.skillsService.findById(id, userId);
|
||||
if (!skill) return { error: '技能不存在' };
|
||||
return skill;
|
||||
}
|
||||
|
||||
@@ -45,17 +45,57 @@ export class SkillsService {
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
async findById(id: string, userId?: number) {
|
||||
const skill = await this.prisma.skill.findUnique({ where: { id } });
|
||||
if (!skill) return null;
|
||||
|
||||
let purchased = false;
|
||||
if (userId && skill.price) {
|
||||
const record = await this.prisma.userSkill.findUnique({
|
||||
where: { userId_skillId: { userId, skillId: id } },
|
||||
});
|
||||
purchased = !!record;
|
||||
}
|
||||
|
||||
return {
|
||||
...skill,
|
||||
starters: JSON.parse(skill.starters),
|
||||
tasks: JSON.parse(skill.tasks),
|
||||
tags: this.parseTags(skill.tags),
|
||||
purchased,
|
||||
locked: !purchased && !!skill.price,
|
||||
};
|
||||
}
|
||||
|
||||
async getMarketplace(userId?: number) {
|
||||
const skills = await this.prisma.skill.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
let purchasedSet = new Set<string>();
|
||||
if (userId) {
|
||||
const records = await this.prisma.userSkill.findMany({
|
||||
where: { userId },
|
||||
select: { skillId: true },
|
||||
});
|
||||
purchasedSet = new Set(records.map(r => r.skillId));
|
||||
}
|
||||
|
||||
return skills.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
icon: s.icon,
|
||||
category: s.category,
|
||||
difficulty: s.difficulty,
|
||||
tags: this.parseTags(s.tags),
|
||||
price: s.price,
|
||||
sortOrder: s.sortOrder,
|
||||
purchased: purchasedSet.has(s.id),
|
||||
}));
|
||||
}
|
||||
|
||||
async getCategories() {
|
||||
const cats = await this.prisma.skill.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
@@ -63,7 +103,7 @@ export class SkillsService {
|
||||
distinct: ['category'],
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const labels: Record<string, string> = { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业' };
|
||||
const labels: Record<string, string> = { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业', marketing: '营销', business: '商业' };
|
||||
return cats.map(c => ({ id: c.category, name: labels[c.category] || c.category }));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export class ToolsController {
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async create(@Body() body: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
|
||||
async create(@Body() body: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) {
|
||||
return this.toolsService.create(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ToolsService {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async create(data: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
|
||||
async create(data: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) {
|
||||
return this.prisma.tool.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
UseGuards,
|
||||
} 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';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
@@ -18,6 +20,8 @@ const MAX_SIZE = 5 * 1024 * 1024;
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
|
||||
Reference in New Issue
Block a user