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:
yuzhiran-dev
2026-06-18 18:14:07 +08:00
parent fb8152401b
commit 0f215d2aad
103 changed files with 5240 additions and 778 deletions
+250 -1
View File
@@ -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();
+41 -7
View File
@@ -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: '已退出' };
}
}
+8 -2
View File
@@ -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;
+22 -2
View File
@@ -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 评分系统遇到临时问题,请稍后重新查看评分详情。',
};
}
}
}
+33 -7
View File
@@ -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;
}
+42 -2
View File
@@ -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);
}
}
+1 -1
View File
@@ -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({