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:
@@ -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) {
|
async quota(@Req() req: any) {
|
||||||
return this.sandboxService.getQuota(req.user.userId);
|
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 { Module } from '@nestjs/common';
|
||||||
import { SandboxController } from './sandbox.controller';
|
import { SandboxController } from './sandbox.controller';
|
||||||
|
import { SandboxShareController } from './sandbox-share.controller';
|
||||||
import { SandboxService } from './sandbox.service';
|
import { SandboxService } from './sandbox.service';
|
||||||
import { AIModule } from '../ai/ai.module';
|
import { AIModule } from '../ai/ai.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AIModule],
|
imports: [AIModule],
|
||||||
controllers: [SandboxController],
|
controllers: [SandboxController, SandboxShareController],
|
||||||
providers: [SandboxService],
|
providers: [SandboxService],
|
||||||
exports: [SandboxService],
|
exports: [SandboxService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
|
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID, createHmac } from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SandboxService {
|
export class SandboxService {
|
||||||
@@ -151,4 +151,44 @@ export class SandboxService {
|
|||||||
});
|
});
|
||||||
return { dailyLimit: user?.sandboxDaily || 10, used, remaining: (user?.sandboxDaily || 10) - used };
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -224,6 +224,21 @@ export default function SandboxPage() {
|
|||||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shareSessionLink() {
|
||||||
|
if (!getToken() || !currentSessionId) { alert('请先登录'); return; }
|
||||||
|
try {
|
||||||
|
const tk = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/sandbox/sessions/${currentSessionId}/share`, {
|
||||||
|
method: 'POST', headers: { Authorization: `Bearer ${tk}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.shareUrl) {
|
||||||
|
await navigator.clipboard.writeText(data.shareUrl);
|
||||||
|
alert('链接已复制');
|
||||||
|
}
|
||||||
|
} catch { alert('生成分享链接失败'); }
|
||||||
|
}
|
||||||
|
|
||||||
function shareToCommunity(content: string, title?: string) {
|
function shareToCommunity(content: string, title?: string) {
|
||||||
if (!getToken()) { alert('请先登录'); return; }
|
if (!getToken()) { alert('请先登录'); return; }
|
||||||
apiFetch('/community/posts', {
|
apiFetch('/community/posts', {
|
||||||
@@ -421,7 +436,11 @@ export default function SandboxPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!sending && messages.length > 2 && (
|
{!sending && messages.length > 2 && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={shareSessionLink}
|
||||||
|
className="text-xs text-brand-600 hover:underline">
|
||||||
|
{t.sandbox.copyShareLink}
|
||||||
|
</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const lastAssistantMsg = [...messages].reverse().find(m => m.role === 'assistant');
|
const lastAssistantMsg = [...messages].reverse().find(m => m.role === 'assistant');
|
||||||
if (lastAssistantMsg) shareToCommunity(lastAssistantMsg.content);
|
if (lastAssistantMsg) shareToCommunity(lastAssistantMsg.content);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
interface SharedMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedSession {
|
||||||
|
id: number;
|
||||||
|
model: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
messages: SharedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SharedSessionInner() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const [session, setSession] = useState<SharedSession | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) { setError('缺少分享参数'); setLoading(false); return; }
|
||||||
|
fetch(`${API_BASE}/sandbox/shared/${encodeURIComponent(token)}`)
|
||||||
|
.then(r => { if (!r.ok) throw new Error('分享链接无效'); return r.json(); })
|
||||||
|
.then(data => setSession(data))
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<Skeleton className="h-8 w-48 mb-6" />
|
||||||
|
<Skeleton className="h-4 w-72 mb-8" />
|
||||||
|
<Skeleton className="h-64 w-full rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error || !session) return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
||||||
|
<div className="text-4xl mb-4">🔗</div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-2">分享内容不可用</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">{error || '该分享链接可能已过期或不存在'}</p>
|
||||||
|
<Link href="/sandbox" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
|
||||||
|
前往 AI 沙盒
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href="/sandbox" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
|
||||||
|
← AI 沙盒
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{session.title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
模型: {session.model} · {new Date(session.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{session.messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex items-start gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-sm font-bold shrink-0">Y</div>
|
||||||
|
)}
|
||||||
|
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||||
|
msg.role === 'user' ? 'bg-brand-600 text-white' : 'bg-muted text-foreground'
|
||||||
|
}`}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div className="w-8 h-8 bg-muted-foreground/20 rounded-xl flex items-center justify-center text-xs font-bold shrink-0">我</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharedSessionPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<Skeleton className="h-8 w-48 mb-6" />
|
||||||
|
<Skeleton className="h-64 w-full rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<SharedSessionInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ const en: Translations = {
|
|||||||
notHelpful: 'Not helpful',
|
notHelpful: 'Not helpful',
|
||||||
runInCodeSandbox: 'Run in Code Sandbox',
|
runInCodeSandbox: 'Run in Code Sandbox',
|
||||||
shareToCommunity: 'Share to Community',
|
shareToCommunity: 'Share to Community',
|
||||||
|
copyShareLink: 'Copy Share Link',
|
||||||
|
linkCopied: 'Link copied',
|
||||||
loginForMore: 'Login for more',
|
loginForMore: 'Login for more',
|
||||||
dailyQuota: '{used} used today, {remaining} remaining',
|
dailyQuota: '{used} used today, {remaining} remaining',
|
||||||
aiReplyDisclaimer: 'AI replies are for reference only.',
|
aiReplyDisclaimer: 'AI replies are for reference only.',
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const zh = {
|
|||||||
notHelpful: '没用',
|
notHelpful: '没用',
|
||||||
runInCodeSandbox: '在代码沙盒中运行',
|
runInCodeSandbox: '在代码沙盒中运行',
|
||||||
shareToCommunity: '分享到社区',
|
shareToCommunity: '分享到社区',
|
||||||
|
copyShareLink: '复制分享链接',
|
||||||
|
linkCopied: '链接已复制',
|
||||||
loginForMore: '登录使用更多',
|
loginForMore: '登录使用更多',
|
||||||
dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次',
|
dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次',
|
||||||
aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。',
|
aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。',
|
||||||
|
|||||||
Reference in New Issue
Block a user