feat: P0f 会话分享链接 + 全栈验证

- 后端: POST /sessions/:id/share 生成 HMAC 签名分享令牌
- 后端: GET /sandbox/shared/:token 公开查看分享会话
- 前端: sandbox/page.tsx 新增「复制分享链接」按钮
- 前端: /sandbox/share?token=xxx 分享查看页面
- 全栈: 后端 build + 前端 61 页 + 92 tests 全部通过
This commit is contained in:
yuzhiran-dev
2026-05-18 10:43:25 +08:00
parent f2a6bb93f9
commit cba785e6bd
9 changed files with 187 additions and 4 deletions
@@ -0,0 +1,12 @@
import { Controller, Get, Param } from '@nestjs/common';
import { SandboxService } from './sandbox.service';
@Controller('sandbox')
export class SandboxShareController {
constructor(private sandboxService: SandboxService) {}
@Get('shared/:token')
async viewShared(@Param('token') token: string) {
return this.sandboxService.getSessionByShareToken(token);
}
}
@@ -41,4 +41,9 @@ export class SandboxController {
async quota(@Req() req: any) {
return this.sandboxService.getQuota(req.user.userId);
}
@Post('sessions/:id/share')
async share(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.generateShareToken(req.user.userId, id);
}
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { SandboxController } from './sandbox.controller';
import { SandboxShareController } from './sandbox-share.controller';
import { SandboxService } from './sandbox.service';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [AIModule],
controllers: [SandboxController],
controllers: [SandboxController, SandboxShareController],
providers: [SandboxService],
exports: [SandboxService],
})
+41 -1
View File
@@ -1,7 +1,7 @@
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';
import { randomUUID, createHmac } from 'crypto';
@Injectable()
export class SandboxService {
@@ -151,4 +151,44 @@ export class SandboxService {
});
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);
}
}
}