From 9ab7756369a87e1dff2b04cf63bf3307f5be251b Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 19 May 2026 14:31:16 +0800 Subject: [PATCH] feat: add audit database and safer image review --- .memory/worklog.json | 66 ++++ Dockerfile | 1 + RULES.md | 5 + .../api/assets/[assetId]/regenerate/route.ts | 50 ++- src/app/api/audit/[sessionId]/route.ts | 135 ++++++++ src/app/api/character/cleanup/route.ts | 12 + .../api/character/lock-from-upload/route.ts | 13 + src/app/api/character/lock/route.ts | 13 + src/app/api/gallery/[sessionId]/route.ts | 107 ++++-- src/app/api/generate/route.ts | 29 ++ src/app/api/packs/generate-all/route.ts | 30 +- src/app/api/packs/generate/route.ts | 47 ++- src/app/api/projects/from-upload/route.ts | 25 ++ src/app/api/select/route.ts | 9 + src/app/api/uploads/route.ts | 8 + src/app/api/video/generate/route.ts | 7 +- src/app/api/video/status/[taskId]/route.ts | 6 +- src/app/page.tsx | 22 +- src/components/PackPanel.tsx | 65 ++-- src/components/ResultGrid.tsx | 5 +- src/lib/auditDb.ts | 308 ++++++++++++++++++ src/lib/storage.ts | 259 ++++++++++++++- src/lib/types.ts | 1 + 23 files changed, 1159 insertions(+), 64 deletions(-) create mode 100644 src/app/api/audit/[sessionId]/route.ts create mode 100644 src/lib/auditDb.ts diff --git a/.memory/worklog.json b/.memory/worklog.json index 76173dc..6e4be9e 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -710,6 +710,72 @@ "message": "auto-save 2026-05-19 13:56 (+1, ~1)", "hash": "cdda350", "files_changed": 2 + }, + { + "ts": "2026-05-19T13:58:09+08:00", + "type": "commit", + "message": "feat: add generated image gallery", + "hash": "8ddda6a", + "files_changed": 2 + }, + { + "ts": "2026-05-19T06:00:02Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:feat: add generated image gallery", + "files_changed": 1 + }, + { + "ts": "2026-05-19T14:07:36+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 14:07 (+1, ~1)", + "hash": "eaed492", + "files_changed": 2 + }, + { + "ts": "2026-05-19T06:10:02Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:07 (+1, ~1)", + "files_changed": 1 + }, + { + "ts": "2026-05-19T14:13:02+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 14:13 (~14)", + "hash": "d327949", + "files_changed": 14 + }, + { + "ts": "2026-05-19T14:18:28+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 14:18 (+1, ~10)", + "hash": "49db765", + "files_changed": 11 + }, + { + "ts": "2026-05-19T06:20:02Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:18 (+1, ~10)", + "files_changed": 1 + }, + { + "ts": "2026-05-19T14:29:21+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 14:29 (~2)", + "hash": "6dfcd08", + "files_changed": 2 + }, + { + "ts": "2026-05-19T06:30:02Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:29 (~2)", + "files_changed": 1 + }, + { + "ts": "2026-05-19T14:31:16+08:00", + "type": "commit", + "message": "feat: add audit database and safer image review", + "hash": "a4fffd4", + "files_changed": 23 } ] } diff --git a/Dockerfile b/Dockerfile index bd9a7b6..55cf265 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=4560 +RUN apk add --no-cache sqlite RUN addgroup -S nextjs -g 1001 && adduser -S nextjs -u 1001 COPY --from=builder --chown=nextjs:nextjs /app/package.json /app/package-lock.json ./ COPY --from=builder --chown=nextjs:nextjs /app/node_modules ./node_modules diff --git a/RULES.md b/RULES.md index 31556b8..9753033 100644 --- a/RULES.md +++ b/RULES.md @@ -23,6 +23,8 @@ ## 元数据回写清单 - 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节 - 数据持久化在 `data/`(gitignored),不入库;上传原图在 `data/uploads/` +- 后端审计库:`data/app.db`(SQLite);Docker 镜像内置 `sqlite3`,记录上传、生成、选择、角色锁定、素材包进度、重做、视频提交、图库/记录查看等事件 +- 审计兜底:非 Docker 本地如果缺少 `sqlite3`,写入 `data/audit-fallback.jsonl`,不阻断生成流程 - 本地 Docker 使用 `docker-compose.yml`,挂载 `./data:/app/data`,读取 `.env.local`,并强制 `PUBLIC_APP_URL=http://localhost:4560` - VPS 生产 Docker 使用 `docker-compose.prod.yml`,挂载 `./data:/app/data`,读取 `deploy/.env.production`,并强制 `PUBLIC_APP_URL=https://ai-toy.kang-kang.com` - VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data` @@ -55,6 +57,9 @@ - L3:包内其它图,基于对应 L2 根图生成 - pack 图像生成必须走真实图生图:读取 anchor 图片字节后调用 GPT image edit,不再把参考图 URL 当纯文本拼进 prompt - 单张重做接口:`POST /api/assets/[assetId]/regenerate`,必须沿用该图的 anchor +- 单张重做需要 `confirmCost=true`;前端会二次确认,服务端会拒绝未确认请求,并对同一 session/asset 加并发锁防连点烧钱 +- 生成图库:`/api/gallery/[sessionId]`,按真实图片宽高比例展示缩略图,鼠标悬停显示大图;同页可跳转操作记录 +- 操作记录:`/api/audit/[sessionId]`,读取 `data/app.db` 展示事件流水和图片索引 - 视频参考优先级:宣发白底图 `mkt_white_front` → 专利主图 `patent_front` → L1 白底锚图 → L0 意向图 - 上传 API:`POST /api/uploads`,multipart 图片存入 `data/uploads/`,返回 `UploadedImage` - 复刻建项目 API:`POST /api/projects/from-upload`,`mode=replicate` 时跳过批量生图,创建 selected L0,Vision 推断 `CharacterSpec`,并用 strict L1 净化 prompt diff --git a/src/app/api/assets/[assetId]/regenerate/route.ts b/src/app/api/assets/[assetId]/regenerate/route.ts index 774db73..3379d31 100644 --- a/src/app/api/assets/[assetId]/regenerate/route.ts +++ b/src/app/api/assets/[assetId]/regenerate/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; +import { startGenerationLock } from '@/lib/generationLocks'; import { regeneratePackAsset } from '@/lib/packGenerator'; +import { detectProvider } from '@/lib/providers'; import { loadSession, saveSession } from '@/lib/storage'; import type { RegenerateAssetRequest, RegenerateAssetResponse } from '@/lib/types'; @@ -8,24 +11,69 @@ export const dynamic = 'force-dynamic'; export async function POST(req: Request, ctx: { params: Promise<{ assetId: string }> }) { const { assetId } = await ctx.params; - const { sessionId, userRefinement } = (await req.json()) as RegenerateAssetRequest; + const { sessionId, userRefinement, confirmCost } = (await req.json()) as RegenerateAssetRequest; if (!assetId || !sessionId) { return NextResponse.json({ error: 'assetId and sessionId required' }, { status: 400 }); } + if (confirmCost !== true) { + recordEvent({ + action: 'asset.regenerate_blocked_unconfirmed', + sessionId, + targetType: 'asset', + targetId: assetId, + status: 'blocked', + provider: detectProvider(), + }); + return NextResponse.json({ error: 'confirmCost required for paid regenerate' }, { status: 400 }); + } const session = await loadSession(sessionId); if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + const releaseLock = startGenerationLock(`regenerate:${sessionId}:${assetId}`); + if (!releaseLock) { + recordEvent({ + action: 'asset.regenerate_blocked_running', + sessionId, + targetType: 'asset', + targetId: assetId, + status: 'blocked', + provider: detectProvider(), + }); + return NextResponse.json({ error: 'asset regenerate already running' }, { status: 429 }); + } + try { + recordEvent({ + action: 'asset.regenerate_started', + sessionId, + targetType: 'asset', + targetId: assetId, + status: 'started', + provider: detectProvider(), + metadata: { refinement: Boolean(userRefinement?.trim()) }, + }); const regenerated = await regeneratePackAsset({ session, assetId, userRefinement }); await saveSession(session); + recordEvent({ + action: 'asset.regenerate_completed', + sessionId, + targetType: 'asset', + targetId: assetId, + status: 'ok', + provider: regenerated.provider, + metadata: { packId: regenerated.pack.id, templateId: regenerated.asset.templateId, url: regenerated.asset.url }, + }); return NextResponse.json({ asset: regenerated.asset, pack: regenerated.pack, provider: regenerated.provider, } satisfies RegenerateAssetResponse); } catch (error) { + recordEvent({ action: 'asset.regenerate_failed', sessionId, targetType: 'asset', targetId: assetId, status: 'error', provider: detectProvider(), message: String(error) }); return NextResponse.json({ error: String(error) }, { status: 500 }); + } finally { + releaseLock(); } } diff --git a/src/app/api/audit/[sessionId]/route.ts b/src/app/api/audit/[sessionId]/route.ts new file mode 100644 index 0000000..1a800a3 --- /dev/null +++ b/src/app/api/audit/[sessionId]/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from 'next/server'; +import { listAuditEvents, listImageAssets, recordEvent } from '@/lib/auditDb'; +import { loadSession } from '@/lib/storage'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function escapeHtml(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' }); +} + +function compactMetadata(metadata: string | null): string { + if (!metadata) return ''; + try { + const parsed = JSON.parse(metadata) as unknown; + const raw = JSON.stringify(parsed); + return raw.length > 260 ? `${raw.slice(0, 260)}...` : raw; + } catch { + return metadata.length > 260 ? `${metadata.slice(0, 260)}...` : metadata; + } +} + +export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: string }> }) { + const { sessionId } = await ctx.params; + if (!/^s_[a-z0-9_-]+$/i.test(sessionId)) { + return NextResponse.json({ error: 'bad sessionId' }, { status: 400 }); + } + + const session = await loadSession(sessionId); + if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + + recordEvent({ action: 'audit.view', sessionId, targetType: 'session', targetId: sessionId, status: 'ok' }); + const events = listAuditEvents(sessionId, 500); + const assets = listImageAssets(sessionId, 1000); + const imageRows = assets.map(asset => ` + + ${escapeHtml(asset.filename)} + ${escapeHtml(asset.bucket)} + ${escapeHtml(asset.kind ?? '')} + ${escapeHtml(asset.template_id ?? asset.target_id ?? '')} + ${asset.width && asset.height ? `${asset.width}×${asset.height}` : ''} + ${asset.current ? '当前' : '历史'} + ${escapeHtml(asset.status ?? '')} + ${formatTime(asset.updated_at)} + + `).join(''); + + const eventRows = events.map(event => ` + + ${formatTime(event.ts)} + ${escapeHtml(event.status)} + ${escapeHtml(event.action)} + ${escapeHtml(event.target_type ?? '')} + ${escapeHtml(event.target_id ?? '')} + ${escapeHtml(event.provider ?? '')} + ${escapeHtml(event.message ?? compactMetadata(event.metadata))} + + `).join(''); + + const html = ` + + + + + ${escapeHtml(sessionId)} Audit + + + +
+
+

操作记录 / 数据库

+
Session: ${escapeHtml(sessionId)}
+
每次上传、生成、选择、锁定、重做和图库查看都会记录在 data/app.db
+
+
+
+ 事件 ${events.length} + 图片索引 ${assets.length} + 当前图 ${assets.filter(asset => asset.current).length} +
+
返回图库
+
+
+ +

图片索引

+ ${assets.length ? ` + + ${imageRows} +
文件槽位/目标真实尺寸状态资产状态更新时间
` : '

暂无图片索引。

'} + +

操作事件

+ ${events.length ? ` + + ${eventRows} +
时间状态动作目标类型目标 IDProvider信息
` : '

暂无操作记录。

'} + +`; + + return new NextResponse(html, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/app/api/character/cleanup/route.ts b/src/app/api/character/cleanup/route.ts index ff25ca2..a77f166 100644 --- a/src/app/api/character/cleanup/route.ts +++ b/src/app/api/character/cleanup/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator'; import { loadSession, saveSession } from '@/lib/storage'; import type { CleanupCharacterRequest, CleanupCharacterResponse } from '@/lib/types'; @@ -20,12 +21,22 @@ export async function POST(req: Request) { if (!sourceImage) return NextResponse.json({ error: 'image not found' }, { status: 404 }); try { + recordEvent({ action: 'character.cleanup_started', sessionId, targetType: 'image', targetId: imageId, status: 'started', metadata: { force, preserveLevel } }); const characterSpec = session.characterSpec?.sourceImageId === imageId ? session.characterSpec : await buildCharacterSpec(session, sourceImage); const cleaned = await cleanupCharacterAnchor({ session, sourceImage, characterSpec, force, preserveLevel }); session.characterSpec = cleaned.characterSpec; await saveSession(session); + recordEvent({ + action: 'character.cleanup_completed', + sessionId, + targetType: 'image', + targetId: imageId, + status: 'ok', + provider: cleaned.provider, + metadata: { preserveLevel, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl }, + }); return NextResponse.json({ characterSpec: cleaned.characterSpec, @@ -33,6 +44,7 @@ export async function POST(req: Request) { provider: cleaned.provider, } satisfies CleanupCharacterResponse); } catch (error) { + recordEvent({ action: 'character.cleanup_failed', sessionId, targetType: 'image', targetId: imageId, status: 'error', message: String(error) }); return NextResponse.json({ error: String(error) }, { status: 500 }); } } diff --git a/src/app/api/character/lock-from-upload/route.ts b/src/app/api/character/lock-from-upload/route.ts index e0161f7..0485003 100644 --- a/src/app/api/character/lock-from-upload/route.ts +++ b/src/app/api/character/lock-from-upload/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator'; import { detectProvider } from '@/lib/providers'; import { loadSession, saveSession } from '@/lib/storage'; @@ -23,6 +24,7 @@ export async function POST(req: Request) { if (!sourceImage) return NextResponse.json({ error: 'subject image not found' }, { status: 404 }); if (!force && session.characterSpec?.sourceImageId === sourceImage.id && session.characterSpec.cleanReferenceImageUrl) { + recordEvent({ action: 'character.lock_from_upload_cached', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'ok', provider: detectProvider() }); return NextResponse.json({ characterSpec: session.characterSpec, provider: detectProvider(), @@ -30,6 +32,7 @@ export async function POST(req: Request) { } try { + recordEvent({ action: 'character.lock_from_upload_started', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'started', provider: detectProvider(), metadata: { force, userHint: Boolean(userHint?.trim()) } }); if (userHint?.trim()) session.prompt = userHint.trim(); const characterSpec = await buildCharacterSpec(session, sourceImage); const cleaned = await cleanupCharacterAnchor({ @@ -41,12 +44,22 @@ export async function POST(req: Request) { }); session.characterSpec = cleaned.characterSpec; await saveSession(session); + recordEvent({ + action: 'character.lock_from_upload_completed', + sessionId, + targetType: 'upload', + targetId: subjectImageId, + status: 'ok', + provider: cleaned.provider, + metadata: { name: cleaned.characterSpec.name, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl }, + }); return NextResponse.json({ characterSpec: cleaned.characterSpec, provider: cleaned.provider, } satisfies LockCharacterResponse); } catch (error) { + recordEvent({ action: 'character.lock_from_upload_failed', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'error', provider: detectProvider(), message: String(error) }); return NextResponse.json({ error: String(error) }, { status: 500 }); } } diff --git a/src/app/api/character/lock/route.ts b/src/app/api/character/lock/route.ts index 726440a..05bb78a 100644 --- a/src/app/api/character/lock/route.ts +++ b/src/app/api/character/lock/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator'; import { detectProvider } from '@/lib/providers'; import { loadSession, saveSession } from '@/lib/storage'; @@ -25,10 +26,12 @@ export async function POST(req: Request) { characterSpec: session.characterSpec, provider: detectProvider(), }; + recordEvent({ action: 'character.lock_cached', sessionId, targetType: 'image', targetId: imageId, status: 'ok', provider: response.provider }); return NextResponse.json(response); } try { + recordEvent({ action: 'character.lock_started', sessionId, targetType: 'image', targetId: imageId, status: 'started', provider: detectProvider(), metadata: { force } }); const characterSpec = await buildCharacterSpec(session, sourceImage); const cleaned = await cleanupCharacterAnchor({ session, @@ -43,8 +46,18 @@ export async function POST(req: Request) { characterSpec: cleaned.characterSpec, provider: detectProvider(), }; + recordEvent({ + action: 'character.lock_completed', + sessionId, + targetType: 'image', + targetId: imageId, + status: 'ok', + provider: response.provider, + metadata: { name: cleaned.characterSpec.name, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl }, + }); return NextResponse.json(response); } catch (error) { + recordEvent({ action: 'character.lock_failed', sessionId, targetType: 'image', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error) }); return NextResponse.json({ error: String(error) }, { status: 500 }); } } diff --git a/src/app/api/gallery/[sessionId]/route.ts b/src/app/api/gallery/[sessionId]/route.ts index 05cfb4a..8b2c101 100644 --- a/src/app/api/gallery/[sessionId]/route.ts +++ b/src/app/api/gallery/[sessionId]/route.ts @@ -1,7 +1,9 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { NextResponse } from 'next/server'; +import { imageFileInfo, recordEvent, upsertImageAsset } from '@/lib/auditDb'; import { loadSession } from '@/lib/storage'; +import type { AssetPack, ToyAsset } from '@/lib/types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -14,6 +16,11 @@ type GalleryItem = { sizeKb: number; mtime: number; current: boolean; + width: number | null; + height: number | null; + aspectRatio?: string; + packId?: string; + templateId?: string; }; function escapeHtml(input: string): string { @@ -25,6 +32,11 @@ function escapeHtml(input: string): string { .replace(/'/g, '''); } +type CurrentAssetMeta = { + pack: AssetPack; + asset: ToyAsset; +}; + function packGroup(filename: string): string { const match = filename.match(/^pack_([^_]+)_/); return match?.[1] ?? 'unknown'; @@ -37,7 +49,14 @@ function fileLabel(filename: string): string { .replace(/_/g, ' '); } -async function listPackItems(currentFilenames: Set): Promise { +function cssRatio(item: GalleryItem): string { + if (item.width && item.height) return `${item.width} / ${item.height}`; + if (item.aspectRatio && item.aspectRatio !== 'long') return item.aspectRatio.replace(':', ' / '); + if (item.aspectRatio === 'long') return '1 / 3'; + return '1 / 1'; +} + +async function listPackItems(currentAssets: Map, sessionId: string): Promise { const dir = path.join(process.cwd(), 'data', 'packs'); let files: string[] = []; try { @@ -50,16 +69,43 @@ async function listPackItems(currentFilenames: Set): Promise /\.(png|jpe?g|webp)$/i.test(filename)) .map(async filename => { - const stat = await fs.stat(path.join(dir, filename)); - return { + const filePath = path.join(dir, filename); + const stat = await fs.stat(filePath); + const info = imageFileInfo(filePath); + const meta = currentAssets.get(filename); + const item = { filename, url: `/api/img/packs/${encodeURIComponent(filename)}`, - group: packGroup(filename), - label: fileLabel(filename), + group: meta?.pack.kind ?? packGroup(filename), + label: meta?.asset.title ?? fileLabel(filename), sizeKb: Math.round(stat.size / 1024), mtime: stat.mtimeMs, - current: currentFilenames.has(filename), + current: Boolean(meta), + width: info.width, + height: info.height, + aspectRatio: meta?.asset.aspectRatio, + packId: meta?.pack.id, + templateId: meta?.asset.templateId, } satisfies GalleryItem; + upsertImageAsset({ + filename, + url: item.url, + bucket: 'packs', + sessionId, + packId: meta?.pack.id, + targetId: meta?.asset.id, + kind: meta?.pack.kind ?? packGroup(filename), + templateId: meta?.asset.templateId, + title: item.label, + aspectRatio: item.aspectRatio, + width: item.width, + height: item.height, + sizeBytes: stat.size, + current: Boolean(meta), + origin: meta ? 'current' : 'archived', + status: meta?.asset.status ?? 'archived', + }); + return item; }), ); @@ -72,14 +118,15 @@ async function listPackItems(currentFilenames: Set): Promise ` -
+
- ${escapeHtml(item.filename)} + ${escapeHtml(item.filename)} +
${escapeHtml(item.group)} · ${escapeHtml(item.label)} ${escapeHtml(item.filename)} - ${item.sizeKb} KB + ${item.width && item.height ? `${item.width}×${item.height} · ` : ''}${item.sizeKb} KB
`).join(''); @@ -98,6 +145,7 @@ function renderPage(opts: { archived: GalleryItem[]; packsSummary: string; sourceHtml: string; + auditUrl: string; }): string { const total = opts.current.length + opts.archived.length; return ` @@ -117,9 +165,15 @@ function renderPage(opts: { .pill { border: 1px solid #2d2f36; border-radius: 999px; padding: 6px 10px; background: #15161b; color: #d1d5db; font-size: 12px; } .source { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; } .source a { color: #f5f5f5; text-decoration: none; border: 1px solid #2d2f36; border-radius: 10px; padding: 8px 10px; background: #15161b; } + .toplinks { display: flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 10px; } + .toplinks a { color: #f5f5f5; text-decoration: none; border: 1px solid #3b3e47; border-radius: 10px; padding: 8px 10px; background: #1e2027; font-size: 12px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; } - .card { background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: hidden; } - .card img { width: 100%; aspect-ratio: 1 / 1; object-fit: contain; display: block; background: #fff; } + .card { position: relative; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: visible; } + .card a { position: relative; display: block; border-radius: 12px 12px 0 0; background: #fff; overflow: hidden; } + .thumb { width: 100%; aspect-ratio: var(--ratio, 1 / 1); object-fit: contain; display: block; background: #fff; } + .zoom { display: none; position: absolute; left: 50%; top: 10px; width: min(620px, 82vw); max-height: 82vh; object-fit: contain; transform: translate(-50%, -6%); z-index: 100; background: #fff; border: 1px solid #3b3e47; border-radius: 14px; box-shadow: 0 24px 90px rgba(0,0,0,.72); pointer-events: none; } + .card:hover { z-index: 20; border-color: #565b68; } + .card:hover .zoom { display: block; } .meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; } .meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; } .empty { color: #9ca3af; } @@ -132,11 +186,14 @@ function renderPage(opts: {
Session: ${escapeHtml(opts.sessionId)}
${opts.sourceHtml} -
- 总图 ${total} - 当前有效 ${opts.current.length} - 历史未挂载 ${opts.archived.length} - ${opts.packsSummary} +
+
+ 总图 ${total} + 当前有效 ${opts.current.length} + 历史未挂载 ${opts.archived.length} + ${opts.packsSummary} +
+
${renderItems('当前 session 有效图', opts.current)} @@ -154,14 +211,15 @@ export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: str const session = await loadSession(sessionId); if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); - const currentFilenames = new Set( - (session.packs ?? []) - .flatMap(pack => pack.assets ?? []) - .map(asset => asset.url?.match(/\/api\/img\/packs\/([^/?#]+)$/)?.[1]) - .filter((filename): filename is string => Boolean(filename)) - .map(filename => decodeURIComponent(filename)), - ); - const allItems = await listPackItems(currentFilenames); + recordEvent({ action: 'gallery.view', sessionId, targetType: 'session', targetId: sessionId, status: 'ok' }); + const currentAssets = new Map(); + for (const pack of session.packs ?? []) { + for (const asset of pack.assets ?? []) { + const filename = asset.url?.match(/\/api\/img\/packs\/([^/?#]+)$/)?.[1]; + if (filename) currentAssets.set(decodeURIComponent(filename), { pack, asset }); + } + } + const allItems = await listPackItems(currentAssets, sessionId); const sourceLinks = [ session.characterSpec?.cleanReferenceImageUrl ? `L1 白底锚图` : '', ...(session.uploadedImages ?? []).map(upload => `上传图 ${escapeHtml(upload.originalFilename ?? upload.filename)}`), @@ -176,6 +234,7 @@ export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: str archived: allItems.filter(item => !item.current), packsSummary, sourceHtml: sourceLinks ? `
${sourceLinks}
` : '', + auditUrl: `/api/audit/${encodeURIComponent(sessionId)}`, }), { headers: { 'Content-Type': 'text/html; charset=utf-8', diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 23dc908..b2f8e43 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { randomBytes } from 'node:crypto'; +import { recordEvent } from '@/lib/auditDb'; import { detectProvider, generateGptImages, generateMock } from '@/lib/providers'; import { saveSession, saveGeneratedImage, saveRefImage } from '@/lib/storage'; import type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types'; @@ -18,6 +19,15 @@ export async function POST(req: Request) { const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`; const finalPrompt = style ? `${prompt}, style: ${style}` : prompt; const provider = detectProvider(); + recordEvent({ + action: 'idea.generate_started', + sessionId, + targetType: 'session', + targetId: sessionId, + status: 'started', + provider, + metadata: { count, refImages: refImages.length, style: style ?? null, promptLength: finalPrompt.length }, + }); const savedRefUrls: string[] = []; for (let i = 0; i < refImages.length; i++) { @@ -35,6 +45,16 @@ export async function POST(req: Request) { ? await generateGptImages({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls }) : await generateMock({ sessionId, prompt: finalPrompt, count }); } catch (e) { + recordEvent({ + action: 'idea.generate_failed', + sessionId, + targetType: 'session', + targetId: sessionId, + status: 'error', + provider, + message: String(e), + metadata: { count }, + }); return NextResponse.json({ error: String(e) }, { status: 500 }); } @@ -56,6 +76,15 @@ export async function POST(req: Request) { images, }; await saveSession(session); + recordEvent({ + action: 'idea.generate_completed', + sessionId, + targetType: 'session', + targetId: sessionId, + status: 'ok', + provider, + metadata: { images: images.length }, + }); const resp: GenerateResponse = { sessionId, images, provider }; return NextResponse.json(resp); diff --git a/src/app/api/packs/generate-all/route.ts b/src/app/api/packs/generate-all/route.ts index a180a0e..8cfdcb5 100644 --- a/src/app/api/packs/generate-all/route.ts +++ b/src/app/api/packs/generate-all/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { startGenerationLock } from '@/lib/generationLocks'; import { generateAssetPack } from '@/lib/packGenerator'; import { detectProvider } from '@/lib/providers'; @@ -44,6 +45,14 @@ export async function POST(req: Request) { const baseSourceImage = sourceImage; const releaseAllLock = startGenerationLock(`packs:all:${sessionId}:${imageId}`); if (!releaseAllLock) { + recordEvent({ + action: 'packs.generate_all_blocked_running', + sessionId, + targetType: 'pack_all', + targetId: imageId, + status: 'blocked', + provider: detectProvider(), + }); return NextResponse.json({ ok: true, background: true, @@ -59,9 +68,11 @@ export async function POST(req: Request) { let workingSession: GenSession = baseSession; try { + recordEvent({ action: 'packs.generate_all_started', sessionId, targetType: 'pack_all', targetId: imageId, status: 'started', provider: detectProvider(), metadata: { background } }); for (const kind of PACK_ORDER) { const existingPack = workingSession.packs?.find(pack => pack.kind === kind && isCompletePack(pack, imageId)); if (existingPack) { + recordEvent({ action: 'pack.generate_skipped_existing', sessionId, targetType: 'pack', targetId: existingPack.id, status: 'ok', provider: detectProvider(), metadata: { kind, assets: existingPack.assets.length } }); const existingManifest = workingSession.exports?.find(manifest => ( manifest.packKind === kind && manifest.source.sourceImageId === imageId && @@ -73,15 +84,23 @@ export async function POST(req: Request) { } const releasePackLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`); - if (!releasePackLock) continue; + if (!releasePackLock) { + recordEvent({ action: 'pack.generate_blocked_running', sessionId, targetType: 'pack', targetId: kind, status: 'blocked', provider: detectProvider(), metadata: { imageId, fromAll: true } }); + continue; + } try { + recordEvent({ action: 'pack.generate_started', sessionId, targetType: 'pack', targetId: kind, status: 'started', provider: detectProvider(), metadata: { imageId, fromAll: true } }); const generated = await generateAssetPack({ session: workingSession, sourceImage: baseSourceImage, kind, - onProgress: progressPack => persistPackProgress(workingSession, imageId, progressPack), + onProgress: async progressPack => { + await persistPackProgress(workingSession, imageId, progressPack); + recordEvent({ action: 'pack.generate_progress', sessionId, targetType: 'pack', targetId: progressPack.id, status: 'running', provider: detectProvider(), metadata: { kind, assets: progressPack.assets.length, packStatus: progressPack.status, fromAll: true } }); + }, }); + recordEvent({ action: 'pack.generate_completed', sessionId, targetType: 'pack', targetId: generated.pack.id, status: 'ok', provider: generated.provider, metadata: { kind, assets: generated.pack.assets.length, fromAll: true } }); packs.push(generated.pack); manifests.push(generated.manifest); workingSession = { @@ -102,6 +121,7 @@ export async function POST(req: Request) { } await saveSession(workingSession); + recordEvent({ action: 'packs.generate_all_completed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'ok', provider: detectProvider(), metadata: { packs: packs.length, manifests: manifests.length } }); return { packs, @@ -114,7 +134,10 @@ export async function POST(req: Request) { } if (background) { - void run().catch(error => console.error('[packs:all] background generation failed', error)); + void run().catch(error => { + recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: true } }); + console.error('[packs:all] background generation failed', error); + }); return NextResponse.json({ ok: true, background: true, @@ -125,6 +148,7 @@ export async function POST(req: Request) { try { return NextResponse.json(await run()); } catch (error) { + recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: false } }); return NextResponse.json({ error: String(error) }, { status: 500 }); } } diff --git a/src/app/api/packs/generate/route.ts b/src/app/api/packs/generate/route.ts index d5dd1ac..e30b0d6 100644 --- a/src/app/api/packs/generate/route.ts +++ b/src/app/api/packs/generate/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { startGenerationLock } from '@/lib/generationLocks'; import { generateAssetPack } from '@/lib/packGenerator'; import { detectProvider } from '@/lib/providers'; @@ -38,6 +39,15 @@ export async function POST(req: Request) { const baseSourceImage = sourceImage; const releaseLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`); if (!releaseLock) { + recordEvent({ + action: 'pack.generate_blocked_running', + sessionId, + targetType: 'pack', + targetId: kind, + status: 'blocked', + provider: detectProvider(), + metadata: { imageId, kind }, + }); return NextResponse.json({ ok: true, background: true, @@ -50,11 +60,31 @@ export async function POST(req: Request) { async function run() { try { + recordEvent({ + action: 'pack.generate_started', + sessionId, + targetType: 'pack', + targetId: kind, + status: 'started', + provider: detectProvider(), + metadata: { imageId, background }, + }); const { pack, manifest, provider } = await generateAssetPack({ session: baseSession, sourceImage: baseSourceImage, kind, - onProgress: progressPack => persistPackProgress(baseSession, imageId, progressPack), + onProgress: async progressPack => { + await persistPackProgress(baseSession, imageId, progressPack); + recordEvent({ + action: 'pack.generate_progress', + sessionId, + targetType: 'pack', + targetId: progressPack.id, + status: 'running', + provider: detectProvider(), + metadata: { kind, assets: progressPack.assets.length, packStatus: progressPack.status }, + }); + }, }); baseSession.characterSpec = pack.characterSpec; baseSession.packs = [ @@ -66,6 +96,15 @@ export async function POST(req: Request) { manifest, ]; await saveSession(baseSession); + recordEvent({ + action: 'pack.generate_completed', + sessionId, + targetType: 'pack', + targetId: pack.id, + status: 'ok', + provider, + metadata: { kind, assets: pack.assets.length, manifestId: manifest.id }, + }); return { pack, manifest, provider } satisfies GeneratePackResponse; } finally { release(); @@ -73,7 +112,10 @@ export async function POST(req: Request) { } if (background) { - void run().catch(error => console.error(`[pack:${kind}] background generation failed`, error)); + void run().catch(error => { + recordEvent({ action: 'pack.generate_failed', sessionId, targetType: 'pack', targetId: kind, status: 'error', provider: detectProvider(), message: String(error), metadata: { imageId, background: true } }); + console.error(`[pack:${kind}] background generation failed`, error); + }); return NextResponse.json({ ok: true, background: true, @@ -87,6 +129,7 @@ export async function POST(req: Request) { return NextResponse.json(response); } catch (error) { + recordEvent({ action: 'pack.generate_failed', sessionId, targetType: 'pack', targetId: kind, status: 'error', provider: detectProvider(), message: String(error), metadata: { imageId, background: false } }); return NextResponse.json({ error: String(error) }, { status: 500 }); } } diff --git a/src/app/api/projects/from-upload/route.ts b/src/app/api/projects/from-upload/route.ts index a750aeb..07215c2 100644 --- a/src/app/api/projects/from-upload/route.ts +++ b/src/app/api/projects/from-upload/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { randomBytes } from 'node:crypto'; +import { recordEvent } from '@/lib/auditDb'; import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator'; import { detectProvider, generateGptImageEdit, generateMock } from '@/lib/providers'; import { saveGeneratedImage, saveSession } from '@/lib/storage'; @@ -155,12 +156,36 @@ export async function POST(req: Request) { } const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`; + recordEvent({ + action: 'upload_project.create_started', + sessionId, + targetType: 'session', + targetId: sessionId, + status: 'started', + provider: detectProvider(), + metadata: { mode: body.mode, uploads: body.uploadedImages.length, count: body.count ?? null }, + }); const response = body.mode === 'remix' ? await createRemixSession(body, sessionId) : await createReplicateOrExtendSession(body, sessionId); + recordEvent({ + action: 'upload_project.create_completed', + sessionId, + targetType: 'session', + targetId: sessionId, + status: 'ok', + provider: response.provider, + metadata: { mode: body.mode, images: response.images?.length ?? 1, preFilledSlots: response.preFilledSlots?.length ?? 0 }, + }); return NextResponse.json(response satisfies ProjectFromUploadResponse); } catch (error) { + recordEvent({ + action: 'upload_project.create_failed', + status: 'error', + provider: detectProvider(), + message: String(error), + }); return NextResponse.json({ error: String(error) }, { status: 500 }); } } diff --git a/src/app/api/select/route.ts b/src/app/api/select/route.ts index 1f80fda..0f3d964 100644 --- a/src/app/api/select/route.ts +++ b/src/app/api/select/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { copyToSelected, loadSession, saveSession } from '@/lib/storage'; export const runtime = 'nodejs'; @@ -26,5 +27,13 @@ export async function POST(req: Request) { img.status = 'pending'; } await saveSession(session); + recordEvent({ + action: `image.${action}`, + sessionId, + targetType: 'image', + targetId: imageId, + status: 'ok', + metadata: { selectedUrl: img.meta?.selectedUrl ?? null }, + }); return NextResponse.json({ ok: true, image: img }); } diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index 79c3473..c562480 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { saveUploadedImage } from '@/lib/storage'; import type { UploadImageResponse, UploadedImageRole } from '@/lib/types'; @@ -48,6 +49,13 @@ export async function POST(req: Request) { accessoryName, needsCleanup, }); + recordEvent({ + action: 'upload.completed', + targetType: 'upload', + targetId: uploadedImage.id, + status: 'ok', + metadata: { filename: uploadedImage.filename, originalFilename: uploadedImage.originalFilename, role, size: file.size }, + }); return NextResponse.json({ uploadedImage } satisfies UploadImageResponse); } diff --git a/src/app/api/video/generate/route.ts b/src/app/api/video/generate/route.ts index b9cf320..e47702e 100644 --- a/src/app/api/video/generate/route.ts +++ b/src/app/api/video/generate/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { generateSeedanceVideo } from '@/lib/videoProviders'; import type { VideoGenerationRequest } from '@/lib/types'; @@ -8,9 +9,13 @@ export const dynamic = 'force-dynamic'; export async function POST(req: Request) { const body = (await req.json()) as VideoGenerationRequest; try { - return NextResponse.json(await generateSeedanceVideo(body)); + recordEvent({ action: 'video.generate_started', targetType: 'video', status: 'started', provider: 'seedance', metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } }); + const response = await generateSeedanceVideo(body); + recordEvent({ action: 'video.generate_submitted', targetType: 'video', targetId: response.taskId ?? response.status, status: 'queued', provider: 'seedance', metadata: { status: response.status } }); + return NextResponse.json(response); } catch (error) { const message = String(error); + recordEvent({ action: 'video.generate_failed', targetType: 'video', status: 'error', provider: 'seedance', message }); return NextResponse.json({ error: message }, { status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500, }); diff --git a/src/app/api/video/status/[taskId]/route.ts b/src/app/api/video/status/[taskId]/route.ts index cf7ccf7..e5510e0 100644 --- a/src/app/api/video/status/[taskId]/route.ts +++ b/src/app/api/video/status/[taskId]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; import { getSeedanceVideoTask } from '@/lib/videoProviders'; export const runtime = 'nodejs'; @@ -7,9 +8,12 @@ export const dynamic = 'force-dynamic'; export async function GET(_req: Request, ctx: { params: Promise<{ taskId: string }> }) { const { taskId } = await ctx.params; try { - return NextResponse.json(await getSeedanceVideoTask(taskId)); + const response = await getSeedanceVideoTask(taskId); + recordEvent({ action: 'video.status_checked', targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } }); + return NextResponse.json(response); } catch (error) { const message = String(error); + recordEvent({ action: 'video.status_failed', targetType: 'video', targetId: taskId, status: 'error', provider: 'seedance', message }); return NextResponse.json({ error: message }, { status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 5a6b450..8116de1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -217,7 +217,7 @@ export default function Home() { const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: current.id, userRefinement }), + body: JSON.stringify({ sessionId: current.id, userRefinement, confirmCost: true }), }); if (!r.ok) { alert('单张重做失败:' + (await r.text())); @@ -299,6 +299,26 @@ export default function Home() {
+ {current && ( + <> + + 图库 + + + 记录 + + + )} {provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider} diff --git a/src/components/PackPanel.tsx b/src/components/PackPanel.tsx index 9d5043e..15ea6ad 100644 --- a/src/components/PackPanel.tsx +++ b/src/components/PackPanel.tsx @@ -27,6 +27,12 @@ const ASPECT_PX: Record = { 'long': '1024×3200', }; +function aspectCss(aspectRatio: AssetTemplate['aspectRatio'] | string | undefined): string { + if (!aspectRatio) return '1 / 1'; + if (aspectRatio === 'long') return '1 / 3'; + return aspectRatio.replace(':', ' / '); +} + function manifestUrl(sessionId: string, kind: PackKind, version: string) { return `/api/export/${sessionId}_${kind}_${version}_manifest.json`; } @@ -45,6 +51,8 @@ function AssetRow({ template, asset, accent, onRegenerate }: { const ready = !!asset; async function handleRedo() { if (!asset || !onRegenerate || regenerating) return; + const ok = window.confirm('重做这 1 张会重新调用图片模型并产生费用。确认重做?'); + if (!ok) return; setRegenerating(true); try { await onRegenerate(asset.id, refinement); @@ -55,13 +63,18 @@ function AssetRow({ template, asset, accent, onRegenerate }: { } } return ( -
+
{/* thumbnail */} -
+
{ready ? ( - {template.title} + <> + {template.title} +
+ +
+ ) : ( -
+
{template.aspectRatio}
)} @@ -99,6 +112,17 @@ function AssetRow({ template, asset, accent, onRegenerate }: { anchor: {asset.anchorAssetId ?? asset.anchorImageUrl}
)} + {ready && onRegenerate && ( +
+ 成本操作 + +
+ )} {showRedo && ready && (
- {regenerating ? '...' : '确认'} + {regenerating ? '...' : '确认付费重做'}
)} @@ -125,14 +149,6 @@ function AssetRow({ template, asset, accent, onRegenerate }: { {ready ? `L${asset!.derivationLevel}` : '待生成'} - {ready && onRegenerate && ( - - )}
@@ -156,8 +172,16 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, const total = templates.length; const progressPct = Math.round((generatedCount / total) * 100); + function handleGenerateClick() { + if (pack) { + const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重跑会重新调用图片模型并产生费用。确认继续?`); + if (!ok) return; + } + onGenerate(); + } + return ( -
+
{/* header — always visible */}
@@ -191,7 +215,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, )}
- selected + selected
主方案
@@ -523,7 +547,10 @@ export default function PackPanel({ {characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}