import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service'; import { randomUUID, createHmac } 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 }; } async generateShareToken(userId: number, sessionId: number) { const session = await this.prisma.sandboxSession.findFirst({ where: { id: sessionId, userId }, }); if (!session) throw new HttpException('会话不存在', HttpStatus.NOT_FOUND); const secret = process.env.JWT_SECRET || 'yuzhiran-share-secret'; const ts = Date.now().toString(36); const hmac = createHmac('sha256', secret).update(`${sessionId}:${ts}`).digest('hex').slice(0, 12); const token = Buffer.from(`${sessionId}|${ts}|${hmac}`).toString('base64url'); return { shareUrl: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/sandbox/share?token=${token}` }; } async getSessionByShareToken(token: string) { try { const decoded = Buffer.from(token, 'base64url').toString(); const parts = decoded.split('|'); if (parts.length !== 3) throw new Error('invalid token'); const [sessionId, ts, hmac] = parts; const secret = process.env.JWT_SECRET || 'yuzhiran-share-secret'; const expected = createHmac('sha256', secret).update(`${sessionId}:${ts}`).digest('hex').slice(0, 12); if (hmac !== expected) throw new Error('invalid signature'); const session = await this.prisma.sandboxSession.findUnique({ where: { id: parseInt(sessionId) }, }); if (!session) throw new Error('session not found'); return { id: session.id, model: session.model, title: session.title, createdAt: session.createdAt, messages: JSON.parse(session.messages).filter((m: any) => m.role !== 'system'), }; } catch { throw new HttpException('分享链接无效或已过期', HttpStatus.BAD_REQUEST); } } }