const { requireAuth, pool, withTransaction, computeNextRevision, normalizeRevisionValue, shouldRejectStaleRevision, formatGenerationTaskRow, normalizeGenerationTaskPayload, normalizeProjectOssKey, buildOssPublicUrl, requireOwnedProject, upsertGenerationTask, } = require("./context"); const crypto = require("node:crypto"); const { getObject, putObject, isOssConfigured } = require("../ossClient"); function countArray(value) { return Array.isArray(value) ? value.length : 0; } function stableProjectFingerprint(json) { return crypto.createHash("sha256").update(json).digest("hex"); } function isPlainObject(value) { return !!value && typeof value === "object" && !Array.isArray(value); } function mediaUrlToProjectOssKey(mediaUrl, userId) { if (typeof mediaUrl !== "string" || !mediaUrl.startsWith("media://")) return null; const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); try { const parsed = new URL(mediaUrl); const host = decodeURIComponent(parsed.hostname || ""); const parts = parsed.pathname .split("/") .map((part) => decodeURIComponent(part).trim()) .filter(Boolean); if (!safeUserId || parts.length === 0 || parts.some((part) => part === "." || part === "..")) { return null; } if (host.startsWith("u--")) { const mediaUserId = host.slice(3).replace(/[^a-zA-Z0-9_-]/g, ""); if (mediaUserId !== safeUserId) return null; return `users/${safeUserId}/projects/${parts.join("/")}`; } if (host === "images" || host === "videos") { return `users/${safeUserId}/projects/${host}/${parts.join("/")}`; } return `users/${safeUserId}/projects/${host}/${parts.join("/")}`; } catch { return null; } } function resolveProjectMediaUrls(value, userId) { if (typeof value === "string") { const ossKey = mediaUrlToProjectOssKey(value, userId); if (!ossKey) return value; try { return buildOssPublicUrl(ossKey); } catch { return value; } } if (Array.isArray(value)) { return value.map((item) => resolveProjectMediaUrls(item, userId)); } if (isPlainObject(value)) { const next = Object.fromEntries( Object.entries(value).map(([key, entry]) => [key, resolveProjectMediaUrls(entry, userId)]), ); const ossKey = String(next.ossKey || next.oss_key || "").trim().replace(/^\/+/, ""); if ( ossKey && /^(users|tmp)\/[^/]+\//.test(ossKey) && !next.url && !next.imageUrl && !next.image_url && !next.previewUrl && !next.preview_url && !next.coverUrl && !next.cover_url ) { try { next.publicUrl = buildOssPublicUrl(ossKey); } catch { // Keep the original metadata shape if OSS public URL config is incomplete. } } return next; } return value; } const PROJECT_MEDIA_URL_KEYS = new Set([ "previewUrl", "preview_url", "imageUrl", "image_url", "videoUrl", "video_url", "coverUrl", "cover_url", "thumbnailUrl", "thumbnail_url", ]); const PROJECT_MEDIA_MATERIALIZE_MAX_BYTES = 180 * 1024 * 1024; function isProjectMediaUrlKey(key) { return PROJECT_MEDIA_URL_KEYS.has(String(key || "")); } function isSignedOrTemporaryMediaUrl(value, userId, projectId) { if (typeof value !== "string" || !/^https?:\/\//i.test(value)) return false; const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); const safeProjectId = String(projectId).replace(/[^a-zA-Z0-9_-]/g, ""); try { const parsed = new URL(value); const path = decodeURIComponent(parsed.pathname || ""); if ( (safeUserId && path.includes(`/users/${safeUserId}/generation-results/`)) || (safeUserId && safeProjectId && path.includes(`/users/${safeUserId}/projects/${safeProjectId}/`)) || (safeUserId && path.includes(`/users/${safeUserId}/assets/`)) ) { return false; } const queryKeys = Array.from(parsed.searchParams.keys()).join("&").toLowerCase(); const hasSignedQuery = /(?:expires|signature|ossaccesskeyid|x-oss-signature|x-amz-signature|x-amz-expires|sig|se)=?/i.test(queryKeys) || /(?:expires|signature|ossaccesskeyid|x-oss-signature|x-amz-signature|x-amz-expires|sig|se)=/i.test(parsed.search); const hostLooksTemporaryProvider = /(?:dashscope|oss-accelerate|aliyuncs|volces|kling|grsai|dakka|rightcode)/i.test(parsed.hostname); return hasSignedQuery || hostLooksTemporaryProvider; } catch { return false; } } function mediaExtensionFromContentType(contentType, url, kindHint = "image") { const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); const mimeExtension = { "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif", "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov", "video/x-msvideo": "avi", }[mime]; if (mimeExtension) return mimeExtension; try { const ext = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i)?.[1]; if (ext) return ext.toLowerCase(); } catch { // Use the kind fallback below. } return kindHint === "video" ? "mp4" : "png"; } function isErrorDocumentContentType(contentType) { return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); } function getProjectMediaKindFromKey(key) { return String(key || "").toLowerCase().includes("video") ? "video" : "image"; } async function materializeProjectMediaUrl(url, userId, projectId, key) { if (!isOssConfigured() || !isSignedOrTemporaryMediaUrl(url, userId, projectId)) return url; try { const response = await fetch(url, { method: "GET" }); if (!response.ok) { throw new Error(`media fetch returned ${response.status}`); } const contentType = response.headers.get("content-type") || ""; if (isErrorDocumentContentType(contentType)) { const text = await response.text().catch(() => ""); throw new Error(`media fetch returned error document: ${text.slice(0, 120)}`); } const declaredLength = Number(response.headers.get("content-length") || 0); if (declaredLength > PROJECT_MEDIA_MATERIALIZE_MAX_BYTES) { throw new Error(`media is too large to persist (${declaredLength} bytes)`); } const buffer = Buffer.from(await response.arrayBuffer()); if (!buffer.length) throw new Error("media fetch returned empty content"); if (buffer.length > PROJECT_MEDIA_MATERIALIZE_MAX_BYTES) { throw new Error(`media is too large to persist (${buffer.length} bytes)`); } const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); const safeProjectId = String(projectId).replace(/[^a-zA-Z0-9_-]/g, ""); const kind = getProjectMediaKindFromKey(key); const extension = mediaExtensionFromContentType(contentType, url, kind); const finalContentType = contentType || (kind === "video" ? "video/mp4" : `image/${extension === "jpg" ? "jpeg" : extension}`); const objectKey = `users/${safeUserId}/projects/${safeProjectId}/media/${kind}s/${Date.now()}-${crypto.randomUUID()}.${extension}`; const uploaded = await putObject(objectKey, buffer, finalContentType, { "x-oss-object-acl": "public-read" }); return uploaded.url; } catch (error) { console.warn("[projects] media materialization skipped:", error.message); return url; } } async function materializeProjectMediaUrls(value, userId, projectId, key = "") { if (typeof value === "string") { if (!isProjectMediaUrlKey(key)) return value; return materializeProjectMediaUrl(value, userId, projectId, key); } if (Array.isArray(value)) { const items = []; for (const item of value) { items.push(await materializeProjectMediaUrls(item, userId, projectId, key)); } return items; } if (isPlainObject(value)) { const next = {}; for (const [entryKey, entryValue] of Object.entries(value)) { next[entryKey] = await materializeProjectMediaUrls(entryValue, userId, projectId, entryKey); } return next; } return value; } function formatProjectContentMeta(projectId, userId, content, meta = {}) { const workflowNodes = Array.isArray(content?.workflowData?.nodes) ? content.workflowData.nodes : []; const imageCount = Number(meta.imageCount ?? meta.image_count) || workflowNodes.filter((node) => node?.type === "image" || node?.kind === "image").length; const videoCount = Number(meta.videoCount ?? meta.video_count) || countArray(content?.videos) || workflowNodes.filter((node) => node?.type === "video" || node?.kind === "video").length; return { id: projectId, name: String(meta.name || content?.name || content?.projectName || "Untitled project").trim().slice(0, 200), description: meta.description === null || meta.description === undefined ? content?.description || content?.projectDescription || null : String(meta.description).slice(0, 2000), ossKey: `users/${String(userId).replace(/[^a-zA-Z0-9_-]/g, "")}/projects/${projectId}/current/project.json`, thumbnailUrl: meta.thumbnailUrl || meta.thumbnail_url || content?.thumbnailUrl || content?.thumbnail_url || null, storyboardCount: Number(meta.storyboardCount ?? meta.storyboard_count) || countArray(content?.storyboards), imageCount, videoCount, }; } function registerProjectRoutes(router) { // ── Projects (Cloud Sync) ────────────────────────────────────────── router.get("/projects", requireAuth, async (req, res) => { try { const userId = req.user.id; const { rows } = await pool.query( `SELECT id, name, description, oss_key, thumbnail_url, storyboard_count, image_count, video_count, file_size, current_revision AS revision, current_fingerprint AS fingerprint, updated_by_device_id, source_case_id, origin_type, created_at, updated_at FROM projects WHERE user_id = $1 ORDER BY updated_at DESC`, [userId], ); res.json({ projects: rows }); } catch (err) { console.error("[projects] list failed:", err.message); res.status(500).json({ error: "获取项目列表失败" }); } }); router.post("/projects/upsert", requireAuth, async (req, res) => { try { const userId = req.user.id; const { id, name, description, ossKey, thumbnailUrl, storyboardCount, imageCount, videoCount, fileSize, fingerprint, deviceId, baseRevision, forceOverwrite, saveReason, sourceCaseId, originType, } = req.body; if (!id || !name || !ossKey) { return res.status(400).json({ error: "缺少必要字段 (id, name, ossKey)" }); } const normalizedOssKey = normalizeProjectOssKey(ossKey, userId, id); if (normalizedOssKey.error) { return res.status(400).json({ error: normalizedOssKey.error }); } const saveReasonValue = String(saveReason || "save").slice(0, 32) || "save"; const originTypeValue = String(originType || (sourceCaseId ? "community_copy" : "manual")) .trim() .slice(0, 32); const result = await withTransaction(async (client) => { const { rows: existingRows } = await client.query( "SELECT id, user_id, current_revision, current_fingerprint, oss_key, updated_at FROM projects WHERE id = $1 FOR UPDATE", [id], ); const existing = existingRows[0] || null; if (existing && Number(existing.user_id) !== Number(userId)) { const error = new Error("无权保存该项目"); error.status = 403; throw error; } const revisionInfo = computeNextRevision(existing?.current_revision, baseRevision); if ( existing && shouldRejectStaleRevision( existing.current_revision, baseRevision, Boolean(forceOverwrite), ) ) { const error = new Error("stale_revision"); error.status = 409; error.code = "stale_revision"; error.currentRevision = revisionInfo.currentRevision; error.normalizedBaseRevision = revisionInfo.normalizedBaseRevision; error.currentFingerprint = existing.current_fingerprint || null; error.currentOssKey = existing.oss_key || null; error.currentUpdatedAt = existing.updated_at || null; throw error; } const { rows: [projectRow], } = await client.query( ` INSERT INTO projects ( id, user_id, name, description, oss_key, thumbnail_url, storyboard_count, image_count, video_count, file_size, current_revision, current_fingerprint, updated_by_device_id, source_case_id, origin_type, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW() ) ON CONFLICT (id) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, description = EXCLUDED.description, oss_key = EXCLUDED.oss_key, thumbnail_url = EXCLUDED.thumbnail_url, storyboard_count = EXCLUDED.storyboard_count, image_count = EXCLUDED.image_count, video_count = EXCLUDED.video_count, file_size = EXCLUDED.file_size, current_revision = EXCLUDED.current_revision, current_fingerprint = EXCLUDED.current_fingerprint, updated_by_device_id = EXCLUDED.updated_by_device_id, source_case_id = COALESCE(projects.source_case_id, EXCLUDED.source_case_id), origin_type = CASE WHEN projects.origin_type IS NULL OR projects.origin_type = 'manual' THEN EXCLUDED.origin_type ELSE projects.origin_type END, updated_at = NOW() RETURNING id, name, description, oss_key, thumbnail_url, storyboard_count, image_count, video_count, file_size, current_revision AS revision, current_fingerprint AS fingerprint, updated_by_device_id, source_case_id, origin_type, created_at, updated_at `, [ id, userId, name, description || null, normalizedOssKey.value, thumbnailUrl || null, storyboardCount || 0, imageCount || 0, videoCount || 0, fileSize || 0, revisionInfo.nextRevision, fingerprint || null, deviceId || null, sourceCaseId || null, originTypeValue || "manual", ], ); await client.query( ` INSERT INTO project_revisions ( project_id, revision_number, oss_key, content_fingerprint, source_device_id, save_reason ) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (project_id, revision_number) DO UPDATE SET oss_key = EXCLUDED.oss_key, content_fingerprint = EXCLUDED.content_fingerprint, source_device_id = EXCLUDED.source_device_id, save_reason = EXCLUDED.save_reason `, [ id, revisionInfo.nextRevision, normalizedOssKey.value, fingerprint || null, deviceId || null, saveReasonValue, ], ); return { project: projectRow, appliedRevision: normalizeRevisionValue(projectRow?.revision), baseWasStale: revisionInfo.baseWasStale, }; }); res.json({ project: result.project, appliedRevision: result.appliedRevision, baseWasStale: result.baseWasStale, }); } catch (err) { const status = err && typeof err === "object" && err.status ? err.status : 500; console.error("[projects] upsert failed:", err.message); if (err.code === "stale_revision") { return res.status(status).json({ error: err.message || "stale_revision", code: err.code, currentRevision: err.currentRevision, normalizedBaseRevision: err.normalizedBaseRevision, currentFingerprint: err.currentFingerprint, currentOssKey: err.currentOssKey, currentUpdatedAt: err.currentUpdatedAt, }); } res.status(status).json({ error: err.message || "保存项目元数据失败" }); } }); router.put("/projects/:id/content", requireAuth, async (req, res) => { const userId = req.user.id; const projectId = req.params.id; const { content, meta, baseRevision, fingerprint, deviceId, saveReason, forceOverwrite } = req.body || {}; if (!content || typeof content !== "object" || Array.isArray(content)) { return res.status(400).json({ error: "content must be an object" }); } if (!isOssConfigured()) { return res.status(501).json({ error: "OSS is not configured" }); } try { const durableContent = await materializeProjectMediaUrls(content, userId, projectId); const durableMeta = await materializeProjectMediaUrls(meta || {}, userId, projectId); const contentJson = JSON.stringify(durableContent); const resolvedMeta = formatProjectContentMeta(projectId, userId, durableContent, durableMeta); const normalizedOssKey = normalizeProjectOssKey(resolvedMeta.ossKey, userId, projectId); if (normalizedOssKey.error) { return res.status(400).json({ error: normalizedOssKey.error }); } const contentFingerprint = String(fingerprint || stableProjectFingerprint(contentJson)).slice(0, 128); const saveReasonValue = String(saveReason || "web-autosave").slice(0, 32) || "web-autosave"; const result = await withTransaction(async (client) => { const { rows: existingRows } = await client.query( "SELECT id, user_id, current_revision, current_fingerprint, oss_key, updated_at FROM projects WHERE id = $1 FOR UPDATE", [projectId], ); const existing = existingRows[0] || null; if (!existing || Number(existing.user_id) !== Number(userId)) { const error = new Error("Project not found"); error.status = 404; throw error; } const revisionInfo = computeNextRevision(existing.current_revision, baseRevision); if ( shouldRejectStaleRevision( existing.current_revision, baseRevision, Boolean(forceOverwrite), ) ) { const error = new Error("stale_revision"); error.status = 409; error.code = "stale_revision"; error.currentRevision = revisionInfo.currentRevision; error.normalizedBaseRevision = revisionInfo.normalizedBaseRevision; error.currentFingerprint = existing.current_fingerprint || null; error.currentOssKey = existing.oss_key || null; error.currentUpdatedAt = existing.updated_at || null; throw error; } await putObject(normalizedOssKey.value, contentJson, "application/json"); const { rows: [projectRow], } = await client.query( ` UPDATE projects SET name = $3, description = $4, oss_key = $5, thumbnail_url = $6, storyboard_count = $7, image_count = $8, video_count = $9, file_size = $10, current_revision = $11, current_fingerprint = $12, updated_by_device_id = $13, updated_at = NOW() WHERE id = $1 AND user_id = $2 RETURNING id, name, description, oss_key, thumbnail_url, storyboard_count, image_count, video_count, file_size, current_revision AS revision, current_fingerprint AS fingerprint, updated_by_device_id, source_case_id, origin_type, created_at, updated_at `, [ projectId, userId, resolvedMeta.name, resolvedMeta.description, normalizedOssKey.value, resolvedMeta.thumbnailUrl, resolvedMeta.storyboardCount, resolvedMeta.imageCount, resolvedMeta.videoCount, Buffer.byteLength(contentJson), revisionInfo.nextRevision, contentFingerprint, deviceId || "web", ], ); await client.query( ` INSERT INTO project_revisions ( project_id, revision_number, oss_key, content_fingerprint, source_device_id, save_reason ) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (project_id, revision_number) DO UPDATE SET oss_key = EXCLUDED.oss_key, content_fingerprint = EXCLUDED.content_fingerprint, source_device_id = EXCLUDED.source_device_id, save_reason = EXCLUDED.save_reason `, [ projectId, revisionInfo.nextRevision, normalizedOssKey.value, contentFingerprint, deviceId || "web", saveReasonValue, ], ); return { project: projectRow, appliedRevision: normalizeRevisionValue(projectRow?.revision), baseWasStale: revisionInfo.baseWasStale, }; }); res.json(result); } catch (err) { const status = err && typeof err === "object" && err.status ? err.status : 500; console.error("[projects] content save failed:", err.message); if (err.code === "stale_revision") { return res.status(status).json({ error: err.message || "stale_revision", code: err.code, currentRevision: err.currentRevision, normalizedBaseRevision: err.normalizedBaseRevision, currentFingerprint: err.currentFingerprint, currentOssKey: err.currentOssKey, currentUpdatedAt: err.currentUpdatedAt, }); } res.status(status).json({ error: err.message || "Failed to save project content" }); } }); router.delete("/projects/:id", requireAuth, async (req, res) => { try { const userId = req.user.id; const projectId = req.params.id; const { rowCount } = await pool.query("DELETE FROM projects WHERE id = $1 AND user_id = $2", [ projectId, userId, ]); if (rowCount === 0) { return res.status(404).json({ error: "项目不存在" }); } res.json({ success: true }); } catch (err) { console.error("[projects] delete failed:", err.message); res.status(500).json({ error: "删除项目失败" }); } }); router.get("/projects/:id", requireAuth, async (req, res) => { try { const userId = req.user.id; const projectId = req.params.id; const { rows } = await pool.query( `SELECT id, name, description, oss_key, thumbnail_url, storyboard_count, image_count, video_count, file_size, current_revision AS revision, current_fingerprint AS fingerprint, updated_by_device_id, source_case_id, origin_type, created_at, updated_at FROM projects WHERE id = $1 AND user_id = $2`, [projectId, userId], ); if (rows.length === 0) { return res.status(404).json({ error: "项目不存在" }); } res.json({ project: rows[0] }); } catch (err) { console.error("[projects] get failed:", err.message); res.status(500).json({ error: "获取项目失败" }); } }); router.get("/projects/:id/tasks", requireAuth, async (req, res) => { try { const userId = req.user.id; const projectId = req.params.id; const { rows } = await pool.query( `SELECT gt.* FROM generation_tasks gt JOIN projects p ON p.id = gt.project_id WHERE gt.project_id = $1 AND gt.user_id = $2 AND p.user_id = $2 ORDER BY gt.updated_at DESC LIMIT 500`, [projectId, userId], ); res.json({ tasks: rows.map(formatGenerationTaskRow) }); } catch (err) { console.error("[projects/tasks] list failed:", err.message); res.status(500).json({ error: "获取项目任务失败" }); } }); router.post("/projects/:id/tasks/upsert", requireAuth, async (req, res) => { try { const userId = req.user.id; const projectId = req.params.id; const normalized = normalizeGenerationTaskPayload(req.body || {}); if (normalized.error) { return res.status(400).json({ error: normalized.error }); } const task = await withTransaction(async (client) => { const ownsProject = await requireOwnedProject(client, userId, projectId); if (!ownsProject) { const error = new Error("Project not found"); error.status = 404; throw error; } return upsertGenerationTask(client, userId, projectId, normalized.value); }); res.json({ task: formatGenerationTaskRow(task) }); } catch (err) { const status = err && typeof err === "object" && err.status ? err.status : 500; console.error("[projects/tasks] upsert failed:", err.message); res.status(status).json({ error: err.message || "保存任务失败" }); } }); router.post("/projects/:id/tasks/batch-upsert", requireAuth, async (req, res) => { try { const userId = req.user.id; const projectId = req.params.id; const taskBodies = Array.isArray(req.body?.tasks) ? req.body.tasks : []; if (taskBodies.length === 0) { return res.json({ tasks: [] }); } const normalizedTasks = []; for (const taskBody of taskBodies.slice(0, 500)) { const normalized = normalizeGenerationTaskPayload(taskBody || {}); if (normalized.error) { return res.status(400).json({ error: normalized.error }); } normalizedTasks.push(normalized.value); } const tasks = await withTransaction(async (client) => { const ownsProject = await requireOwnedProject(client, userId, projectId); if (!ownsProject) { const error = new Error("Project not found"); error.status = 404; throw error; } const rows = []; for (const task of normalizedTasks) { rows.push(await upsertGenerationTask(client, userId, projectId, task)); } return rows; }); res.json({ tasks: tasks.map(formatGenerationTaskRow) }); } catch (err) { const status = err && typeof err === "object" && err.status ? err.status : 500; console.error("[projects/tasks] batch upsert failed:", err.message); res.status(status).json({ error: err.message || "批量保存任务失败" }); } }); router.delete("/projects/:id/tasks/:taskId", requireAuth, async (req, res) => { try { const userId = req.user.id; const projectId = req.params.id; const taskId = req.params.taskId; const { rowCount } = await pool.query( `DELETE FROM generation_tasks WHERE project_id = $1 AND user_id = $2 AND client_queue_id = $3`, [projectId, userId, taskId], ); if (rowCount === 0) { return res.status(404).json({ error: "任务不存在" }); } res.json({ success: true }); } catch (err) { console.error("[projects/tasks] delete failed:", err.message); res.status(500).json({ error: "删除任务失败" }); } }); router.get("/projects/:id/content", requireAuth, async (req, res) => { try { const userId = req.user.id; const projectId = req.params.id; const { rows } = await pool.query( "SELECT oss_key FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId], ); if (rows.length === 0) { return res.status(404).json({ error: "项目不存在" }); } const ossKey = rows[0].oss_key; if (!ossKey) { return res.status(404).json({ error: "项目内容未上传" }); } if (!isOssConfigured()) { return res.status(501).json({ error: "OSS 未配置" }); } const content = await getObject(ossKey); const parsed = JSON.parse(content); const shouldResolveMedia = req.query.resolveMedia === "1" || req.query.resolveMedia === "true" || req.query.resolve_media === "1"; res.json({ content: shouldResolveMedia ? resolveProjectMediaUrls(parsed, userId) : parsed }); } catch (err) { const status = err && typeof err === "object" && err.status ? err.status : 500; console.error("[projects] content download failed:", err.message); if (status === 404 && err.code === "oss_no_such_key") { return res.status(404).json({ error: "项目内容文件不存在,请重新保存或删除该旧项目。", code: "project_content_missing", }); } res.status(status).json({ error: err.message || "获取项目内容失败" }); } }); } module.exports = { registerProjectRoutes, };