905 lines
30 KiB
JavaScript
905 lines
30 KiB
JavaScript
|
|
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,
|
||
|
|
};
|