From 0f8f3825e10097fe47736edc3ccba7a5bfb58bbb Mon Sep 17 00:00:00 2001 From: stringadmin Date: Tue, 2 Jun 2026 13:14:38 +0800 Subject: [PATCH] Remove backup/env files from tracking, update .gitignore --- .env.backup.2026-04-21-02-53-17 | 12 - .env.enterprise-beta.20260526012458 | 17 - .env.rolefix.2026-04-21-03-26-28 | 17 - .gitignore | 5 + src/routes/ai.js.bak | 1956 --------------- src/routes/ai.js.bak-classify | 2098 ----------------- src/routes/ai.js.bak-superresolve | 1811 -------------- ...ise.js.enterprise-usage-fix.20260526013026 | 456 ---- 8 files changed, 5 insertions(+), 6367 deletions(-) delete mode 100644 .env.backup.2026-04-21-02-53-17 delete mode 100644 .env.enterprise-beta.20260526012458 delete mode 100644 .env.rolefix.2026-04-21-03-26-28 delete mode 100644 src/routes/ai.js.bak delete mode 100644 src/routes/ai.js.bak-classify delete mode 100644 src/routes/ai.js.bak-superresolve delete mode 100644 src/routes/enterprise.js.enterprise-usage-fix.20260526013026 diff --git a/.env.backup.2026-04-21-02-53-17 b/.env.backup.2026-04-21-02-53-17 deleted file mode 100644 index 8a2444c..0000000 --- a/.env.backup.2026-04-21-02-53-17 +++ /dev/null @@ -1,12 +0,0 @@ -PG_HOST=localhost -PG_PORT=5432 -PG_DATABASE=omniai -PG_USER=omniai -PG_PASSWORD=bybyby@123 -PG_POOL_MAX=10 -PORT=3600 -HOST=0.0.0.0 -JWT_SECRET=499808ef76791e59ab1019f8fbb86d2b -DEFAULT_ADMIN_PASSWORD=bybyby@123BY -JWT_EXPIRES_IN=7d -CORS_ORIGINS=* diff --git a/.env.enterprise-beta.20260526012458 b/.env.enterprise-beta.20260526012458 deleted file mode 100644 index 4754d24..0000000 --- a/.env.enterprise-beta.20260526012458 +++ /dev/null @@ -1,17 +0,0 @@ -PG_HOST=localhost -PG_PORT=5432 -PG_DATABASE=omniai -PG_USER=omniai -PG_PASSWORD=bybyby@123 -PG_POOL_MAX=10 -PORT=3600 -HOST=0.0.0.0 -JWT_SECRET=499808ef76791e59ab1019f8fbb86d2b -DEFAULT_ADMIN_PASSWORD=bybyby@123BY -JWT_EXPIRES_IN=7d -CORS_ORIGINS=* -STS_ACCESS_KEY_ID=LTAI5t7qL3iR9dchydHQ3cmT -STS_ACCESS_KEY_SECRET=ssywO1bUwu2pPZaq3KugXbaE0Za9gi -OSS_ROLE_ARN=acs:ram::1582660594690998:role/omniai-oss-role -OSS_BUCKET=stringtest -OSS_REGION=oss-cn-hangzhou diff --git a/.env.rolefix.2026-04-21-03-26-28 b/.env.rolefix.2026-04-21-03-26-28 deleted file mode 100644 index b23fb0b..0000000 --- a/.env.rolefix.2026-04-21-03-26-28 +++ /dev/null @@ -1,17 +0,0 @@ -PG_HOST=localhost -PG_PORT=5432 -PG_DATABASE=omniai -PG_USER=omniai -PG_PASSWORD=bybyby@123 -PG_POOL_MAX=10 -PORT=3600 -HOST=0.0.0.0 -JWT_SECRET=499808ef76791e59ab1019f8fbb86d2b -DEFAULT_ADMIN_PASSWORD=bybyby@123BY -JWT_EXPIRES_IN=7d -CORS_ORIGINS=* -STS_ACCESS_KEY_ID=LTAI5t7qL3iR9dchydHQ3cmT -STS_ACCESS_KEY_SECRET=ssywO1bUwu2pPZaq3KugXbaE0Za9gi -OSS_ROLE_ARN=acs:ram::1582660594690998:role/OmniAI-OSS-Upload -OSS_BUCKET=stringtest -OSS_REGION=oss-cn-hangzhou diff --git a/.gitignore b/.gitignore index 624cb0d..df4fac7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ config/internal-beta-codes.md data/ *.tmp .DS_Store +*.env.backup* +*.env.rolefix* +*.env.enterprise* +src/routes/ai.js.bak* +src/routes/enterprise.js.enterprise-usage-fix* diff --git a/src/routes/ai.js.bak b/src/routes/ai.js.bak deleted file mode 100644 index c462c03..0000000 --- a/src/routes/ai.js.bak +++ /dev/null @@ -1,1956 +0,0 @@ -"use strict"; - -const crypto = require("node:crypto"); -const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); -const { putObject, isOssConfigured } = require("../ossClient"); -const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); -const { - isEnterpriseVideoBillingUser, - markEnterpriseVideoCreditsAccepted, - prepareEnterpriseVideoBilling, - refundEnterpriseVideoCredits, - reserveEnterpriseVideoCredits, - calculateEnterpriseVideoCredits, - getEnterpriseVideoCreditRate, -} = require("../enterpriseVideoBilling"); -const { - startPolling, - updateTaskInDb, - extractProviderTaskId, - extractImageUrl, - extractGeminiImageUrl, - extractVideoUrl, - parseKlingCredential, - createKlingJwt, -} = require("../aiTaskWorker"); -const { - buildDashscopeImageSuperResolveBody, - buildDashscopeVideoStyleTransformBody, - normalizeImageUpscaleFactor, - normalizeVideoStyleTransformOptions, -} = require("../aiUpscaleHelpers"); - -const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ - ["gpt-image-2", "1K"], -]); - -const GRSAI_IMAGE_MAX_QUALITY = new Map([ - ["gpt-image-2", "2K"], -]); - -const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ - ["wan2.7-image", "2K"], -]); - -const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; -const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; -const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; -const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 120; -const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 150_000; -const RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS = 70_000; -const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; -const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; -const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; -const MAX_USER_ACTIVE_GENERATION_TASKS = 3; -const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; - -const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { - "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, - "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, - "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, - "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, - "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, -}; - -function mapAspectRatioToPixels(ratio, quality) { - const q = String(quality || "1K").toUpperCase(); - const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; - return map ? (map[q] || map["1K"]) : "1024x1024"; -} - -function mapAspectRatioToDashscopeSize(ratio, quality) { - return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); -} - -function normalizeQuality(value, fallback = "1K") { - const q = String(value || fallback).trim().toUpperCase(); - if (q === "4K" || q === "2K" || q === "1K") return q; - return fallback; -} - -function clampImageQualityForModel(model, quality) { - const normalized = normalizeQuality(quality, "2K"); - const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); - if (maxQuality === "2K" && normalized === "4K") return "2K"; - if (maxQuality === "1K" && normalized !== "1K") return "1K"; - return normalized; -} - -function clampGrsaiImageQualityForModel(model, quality) { - const normalized = normalizeQuality(quality, "1K"); - const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); - if (maxQuality === "2K" && normalized === "4K") return "2K"; - if (maxQuality === "1K" && normalized !== "1K") return "1K"; - return normalized; -} - -function normalizeDuration(value, min = 4, max = 15, fallback = 5) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return fallback; - return Math.max(min, Math.min(max, Math.round(numeric))); -} - -function normalizeRatio(value, fallback = "16:9") { - const ratio = String(value || fallback).trim(); - return ratio === "auto" ? "adaptive" : ratio; -} - -function normalizeVideoResolution(value, allowed, fallback = "720p") { - const resolution = String(value || "").trim().toLowerCase(); - return allowed.includes(resolution) ? resolution : fallback; -} - -function normalizeS2vResolution(value) { - const resolution = String(value || "").trim().toLowerCase(); - return resolution === "480p" ? "480P" : "720P"; -} - -function normalizeS2vStyle(value) { - const style = String(value || "").trim().toLowerCase(); - return ["speech", "sing", "performance"].includes(style) ? style : "speech"; -} - -function normalizePublicHttpUrl(value) { - const url = String(value || "").trim(); - return /^https?:\/\//i.test(url) ? url : ""; -} - -function percentEncodeRpc(value) { - return encodeURIComponent(String(value)) - .replace(/!/g, "%21") - .replace(/'/g, "%27") - .replace(/\(/g, "%28") - .replace(/\)/g, "%29") - .replace(/\*/g, "%2A"); -} - -function signAliyunRpcParams(method, params, accessKeySecret) { - const canonicalQuery = Object.keys(params) - .sort() - .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) - .join("&"); - const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; - return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); -} - -function getAliyunVideoEnhanCredentials() { - const accessKeyId = - process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || - process.env.ALIYUN_ACCESS_KEY_ID || - process.env.STS_ACCESS_KEY_ID || - ""; - const accessKeySecret = - process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || - process.env.ALIYUN_ACCESS_KEY_SECRET || - process.env.STS_ACCESS_KEY_SECRET || - ""; - return { accessKeyId, accessKeySecret }; -} - -function buildAliyunRpcUrl(action, actionParams = {}) { - const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); - if (!accessKeyId || !accessKeySecret) { - const error = new Error("Aliyun video super-resolution is not configured"); - error.status = 501; - throw error; - } - - const params = { - Action: action, - Version: ALIYUN_VIDEOENHAN_VERSION, - Format: "JSON", - AccessKeyId: accessKeyId, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - SignatureNonce: crypto.randomUUID(), - Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), - ...actionParams, - }; - params.Signature = signAliyunRpcParams("POST", params, accessKeySecret); - - const body = Object.entries(params) - .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) - .join("&"); - return { url: ALIYUN_VIDEOENHAN_ENDPOINT, body }; -} - -function parseAliyunJsonResult(value) { - if (!value) return null; - if (typeof value === "object") return value; - if (typeof value !== "string") return null; - try { - return JSON.parse(value); - } catch { - return null; - } -} - -async function callAliyunRpc(action, params) { - const req = buildAliyunRpcUrl(action, params); - const response = await fetch(req.url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: req.body, - }); - const text = await response.text().catch(() => ""); - let json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch { - throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); - } - - if (!response.ok || json.Code || json.code) { - throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); - } - - return json; -} - -function normalizeSuperResolveBitRate(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 10; - return Math.max(1, Math.min(20, Math.round(numeric))); -} - -function normalizeAliyunJobStatus(value) { - return String(value || "").trim().toUpperCase(); -} - -async function ensureDefaultProject(userId) { - const projectId = `web-default-${userId}`; - const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); - if (rows.length === 0) { - const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); - await pool.query( - `INSERT INTO projects ( - id, - user_id, - name, - description, - oss_key, - storyboard_count, - image_count, - video_count, - file_size, - current_revision, - updated_by_device_id, - created_at, - updated_at - ) - VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) - ON CONFLICT (id) DO NOTHING`, - [ - projectId, - userId, - "Default workbench", - "Web fallback project for legacy generation requests", - `users/${safeUserId}/projects/${projectId}/current/project.json`, - ], - ); - } - return projectId; -} - -async function resolveTaskProject(userId, requestedProjectId) { - const projectId = String(requestedProjectId || "").trim().slice(0, 64); - if (!projectId) { - return ensureDefaultProject(userId); - } - - const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ - projectId, - userId, - ]); - if (rows.length === 0) { - const error = new Error("Project not found"); - error.status = 404; - throw error; - } - return projectId; -} - -async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { - if (!client) { - return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); - } - - await assertUserGenerationConcurrencyLimit(userId, client); - const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const { rows: [row] } = await client.query( - `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, - [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], - ); - return row; -} - -async function assertUserGenerationConcurrencyLimit(userId, client = pool) { - await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); - const { rows } = await client.query( - "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", - [userId], - ); - const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); - if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; - - const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); - error.status = 429; - error.code = "GENERATION_CONCURRENCY_LIMIT"; - error.activeCount = activeCount; - error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; - throw error; -} - -async function providerPoolExists(provider) { - if (!provider) return false; - const { rows } = await pool.query( - "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", - [provider], - ); - return rows.length > 0; -} - -function releaseLease(slotResult) { - if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); -} - -function sendAiRouteError(res, err) { - res.status(err.status || 500).json({ - error: err.message, - code: err.code, - activeCount: err.activeCount, - maxActiveTasks: err.maxActiveTasks, - }); -} - -async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } catch (err) { - if (err?.name === "AbortError") { - throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); - } - throw err; - } finally { - clearTimeout(timer); - } -} - -function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { - const raw = String(value || "").trim(); - if (!raw) return fallback; - - let message = raw; - try { - const parsed = JSON.parse(raw); - message = - parsed?.error?.message || - parsed?.error_description || - parsed?.message || - parsed?.error || - raw; - } catch {} - - const compact = String(message).replace(/\s+/g, " ").trim(); - const looksLikeMarkup = - /^]/i.test(compact) || - /^<\?xml/i.test(compact) || - /<\/?[a-z][^>]*>/i.test(compact); - - if (looksLikeMarkup) return fallback; - return compact.slice(0, 320); -} - -function translateDashscopeContentError(message) { - const msg = String(message || ""); - if (msg.includes("Green net check failed for image input") || msg.includes("Input data may contain inappropriate content")) { - return "\u53c2\u8003\u56fe\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff08\u7eff\u7f51\u68c0\u67e5\uff09\uff0c\u8bf7\u66f4\u6362\u56fe\u7247\u540e\u91cd\u8bd5\u3002\u5e38\u89c1\u539f\u56e0\uff1a\u4eba\u7269\u66b4\u9732\u3001\u654f\u611f\u9762\u90e8\u3001\u56fe\u7247\u542b\u654f\u611f\u6587\u5b57\u7b49\u3002"; - } - if (msg.includes("Output data may contain inappropriate content")) { - return "\u751f\u6210\u7ed3\u679c\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; - } - if (msg.includes("content policies") || msg.includes("prompt may violate")) { - return "\u63d0\u793a\u8bcd\u672a\u901a\u8fc7\u5185\u5bb9\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63cf\u8ff0\u540e\u91cd\u8bd5\u3002\u907f\u514d\u4f7f\u7528\u6d89\u53ca\u66b4\u9732\u3001\u66b4\u529b\u3001\u653f\u6cbb\u654f\u611f\u7b49\u8bcd\u6c47\u3002"; - } - if (msg.includes("inappropriate content")) { - return "\u5185\u5bb9\u672a\u901a\u8fc7\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; - } - return ""; -} - -function parseTaskParams(value) { - if (!value || typeof value !== "string") return {}; - try { - return JSON.parse(value); - } catch { - return {}; - } -} - -function formatAiTaskRow(row) { - return { - taskId: String(row.id), - projectId: row.project_id, - conversationId: row.conversation_id, - clientQueueId: row.client_queue_id || null, - type: row.type, - status: row.status, - progress: Number(row.progress || 0), - resultUrl: row.result_url || null, - error: row.error || null, - params: parseTaskParams(row.params_json), - createdAt: row.created_at, - updatedAt: row.updated_at, - completedAt: row.completed_at || null, - }; -} - -function extensionFromContentType(contentType, fallbackType) { - const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); - if (mime === "image/jpeg") return "jpg"; - if (mime === "image/png") return "png"; - if (mime === "image/webp") return "webp"; - if (mime === "image/gif") return "gif"; - if (mime === "video/webm") return "webm"; - if (mime === "video/quicktime") return "mov"; - if (mime === "video/mp4") return "mp4"; - return fallbackType === "video" ? "mp4" : "png"; -} - -function contentDispositionFilename(value) { - return String(value || "generated") - .replace(/[\\/:*?"<>|]+/g, "-") - .replace(/[^\x20-\x7e]/g, "") - .trim() - .slice(0, 120) || "generated"; -} - -function isErrorContentType(contentType) { - return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); -} - -function buildDashscopeImageBody(params) { - const content = []; - for (const url of params.referenceUrls || []) { - if (url) content.push({ image: url }); - } - content.push({ text: params.prompt }); - const quality = clampImageQualityForModel(params.model, params.quality); - return { - model: params.model, - input: { - messages: [{ role: "user", content }], - }, - parameters: { - size: mapAspectRatioToDashscopeSize(params.ratio, quality), - n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, - watermark: false, - }, - }; -} - -function buildGrsaiImageBody(params) { - const isGptImage = String(params.model || "").startsWith("gpt-image"); - const modelKey = String(params.model || "").toLowerCase(); - const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); - return isGptImage - ? { - model: params.model, - prompt: params.prompt, - images: params.referenceUrls || [], - aspectRatio: mapAspectRatioToPixels(params.ratio, quality), - replyType: "json", - } - : { - model: params.model, - prompt: params.prompt, - images: params.referenceUrls || [], - aspectRatio: params.ratio || "auto", - imageSize: quality, - replyType: "json", - }; -} - -function buildRightcodeImageBody(providerConfig, params) { - const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; - const quality = normalizeQuality(params.quality, "1K"); - - return { - model: providerConfig.model || params.model, - prompt: params.prompt, - image: referenceUrls, - size: mapAspectRatioToPixels(params.ratio, quality), - response_format: "url", - }; -} - -function getGridCount(gridMode) { - if (gridMode === "grid-4") return 4; - if (gridMode === "grid-9") return 9; - if (gridMode === "grid-25") return 25; - return 1; -} - -function buildGeminiImageBody(params) { - const parts = [{ text: String(params.prompt || "").trim() }]; - const refs = (params.referenceUrls || []).filter(Boolean); - for (const url of refs) { - parts.push({ - fileData: { fileUri: url, mimeType: "image/png" }, - }); - } - const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; - const count = getGridCount(params.gridMode); - if (count > 1) generationConfig.candidateCount = count; - return { - contents: [{ parts }], - generationConfig, - }; -} - -function buildOpenAIImageBody(providerConfig, params) { - const userContent = []; - const prompt = String(params.prompt || "").trim(); - if (prompt) userContent.push({ type: "text", text: prompt }); - const refs = (params.referenceUrls || []).filter(Boolean); - for (const url of refs) { - userContent.push({ type: "image_url", image_url: { url } }); - } - const body = { - model: providerConfig.model || params.model, - messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], - }; - const count = getGridCount(params.gridMode); - if (count > 1) body.n = count; - return body; -} - -function buildImageRequest(providerConfig, params, apiKey) { - const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; - if (providerConfig.transport === "dashscope-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; - } - if (providerConfig.transport === "rightcode-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; - } - if (providerConfig.transport === "gemini-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; - } - if (providerConfig.transport === "openai-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; - } - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; -} - -function buildSeedVideoBody(params) { - const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); - const metadata = { - generate_audio: true, - watermark: false, - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 4, 15, 5), - resolution, - }; - const body = { - model: params.model, - prompt: params.prompt, - metadata, - }; - const refs = params.referenceUrls || []; - if (params.frameMode === "start-end" && refs.length >= 2) { - metadata.first_frame_image = refs[0]; - metadata.last_frame_image = refs[refs.length - 1]; - } else if (refs.length === 1) { - body.image = refs[0]; - } else if (refs.length > 1) { - metadata.reference_images = refs; - } - return body; -} - -function buildArkSeedVideoBody(params) { - const content = []; - if (params.prompt) content.push({ type: "text", text: params.prompt }); - - const refs = params.referenceUrls || []; - if (params.frameMode === "start-end" && refs.length >= 2) { - content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); - content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); - } else { - refs.forEach((url, index) => { - content.push({ - type: "image_url", - image_url: { url }, - role: index === 0 ? "first_frame" : "reference_image", - }); - }); - } - - const body = { - model: params.model, - content, - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 4, 15, 5), - generate_audio: true, - watermark: false, - }; - body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); - return body; -} - -function buildWanI2vBody(params) { - const refs = params.referenceUrls || []; - const media = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - media.push({ type: "first_frame", url: refs[0] }); - media.push({ type: "last_frame", url: refs[refs.length - 1] }); - } else if (refs[0]) { - media.push({ type: "first_frame", url: refs[0] }); - } - - const input = { prompt: params.prompt }; - if (media.length) input.media = media; - const requestedResolution = String(params.quality || "").toUpperCase(); - const parameters = { - resolution: requestedResolution === "720P" ? "720P" : "1080P", - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 3, 15, 5), - watermark: false, - }; - parameters.prompt_extend = true; - - return { - model: params.model, - input, - parameters, - }; -} - -function normalizeHappyHorseResolution(value) { - return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; -} - -function getReferenceImageUrls(params, limit = 9) { - return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) - .map((url) => normalizePublicHttpUrl(url)) - .filter(Boolean) - .slice(0, limit); -} - -function buildHappyHorseBaseParameters(params, { includeRatio }) { - const parameters = { - resolution: normalizeHappyHorseResolution(params.quality), - duration: normalizeDuration(params.duration, 3, 15, 5), - watermark: false, - }; - if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); - return parameters; -} - -function createMissingReferenceError(message) { - const error = new Error(message); - error.status = 400; - return error; -} - -function buildHappyHorseT2vBody(params) { - return { - model: params.model, - input: { - prompt: params.prompt, - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), - }; -} - -function buildHappyHorseI2vBody(params) { - const [firstFrame] = getReferenceImageUrls(params, 1); - if (!firstFrame) { - throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); - } - - return { - model: params.model, - input: { - prompt: params.prompt, - media: [{ type: "first_frame", url: firstFrame }], - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), - }; -} - -function buildHappyHorseR2vBody(params) { - const refs = getReferenceImageUrls(params, 9); - if (!refs.length) { - throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); - } - - return { - model: params.model, - input: { - prompt: params.prompt, - media: refs.map((url) => ({ type: "reference_image", url })), - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), - }; -} - -function getHappyHorseReferenceError(protocol, referenceUrls) { - if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { - return "HappyHorse I2V requires one first-frame image."; - } - if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { - return "HappyHorse R2V requires 1 to 9 reference images."; - } - return ""; -} - -async function assertWanS2vImageDetected(providerConfig, params, apiKey) { - const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); - if (!imageUrl) { - const error = new Error("Missing imageUrl"); - error.status = 400; - throw error; - } - - const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: providerConfig.detectModel || "wan2.2-s2v-detect", - input: { image_url: imageUrl }, - }), - }); - - const text = await response.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch {} - - if (!response.ok) { - throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); - } - - const output = json && typeof json === "object" ? json.output || json.data || json : {}; - const pass = - output.check_pass === true || - output.checkPass === true || - output.passed === true || - output.pass === true || - String(output.code || "").toLowerCase() === "success"; - - if (!pass) { - const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; - const error = new Error(message); - error.status = 400; - throw error; - } -} - -function extractProviderDetectMessage(output) { - if (!output || typeof output !== "object") return ""; - return String( - output.message || - output.reason || - output.failure_reason || - output.description || - output.error || - "", - ).trim(); -} - -function buildWanS2vBody(params) { - const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); - const audioUrl = normalizePublicHttpUrl(params.audioUrl); - if (!imageUrl) { - const error = new Error("Missing imageUrl"); - error.status = 400; - throw error; - } - if (!audioUrl) { - const error = new Error("Missing audioUrl"); - error.status = 400; - throw error; - } - - const parameters = { - resolution: normalizeS2vResolution(params.quality), - style: normalizeS2vStyle(params.style), - }; - - return { - model: params.model, - input: { - image_url: imageUrl, - audio_url: audioUrl, - }, - parameters, - }; -} - -function buildDashscopeKlingBody(params) { - const refs = params.referenceUrls || []; - const media = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - media.push({ type: "first_frame", url: refs[0] }); - media.push({ type: "last_frame", url: refs[refs.length - 1] }); - } else if (refs[0]) { - media.push({ type: "first_frame", url: refs[0] }); - } - - const input = { prompt: params.prompt }; - if (media.length) input.media = media; - const parameters = { - mode: params.quality === "std" ? "std" : "pro", - duration: normalizeDuration(params.duration, 5, 10, 5), - audio: false, - watermark: false, - }; - if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); - - return { model: params.model, input, parameters }; -} - -function buildKlingOmniBody(params) { - const refs = params.referenceUrls || []; - const imageList = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - imageList.push({ image_url: refs[0], type: "first_frame" }); - imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); - } else if (refs[0]) { - imageList.push({ image_url: refs[0], type: "first_frame" }); - } - - const body = { - model_name: "kling-v3-omni", - mode: params.quality === "std" ? "std" : "pro", - sound: "off", - duration: String(normalizeDuration(params.duration, 3, 15, 5)), - watermark_info: { enabled: false }, - prompt: params.prompt, - }; - if (imageList.length) body.image_list = imageList; - else body.aspect_ratio = normalizeRatio(params.ratio); - return body; -} - -function buildViduT2vBody(params) { - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = requestedRes === "720P" ? "720P" : "1080P"; - const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; - return { model: params.model, input: { prompt: params.prompt }, parameters: { resolution, size: sizeMap[resolution], duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; -} - -function buildViduI2vBody(params) { - const [img] = getReferenceImageUrls(params, 1); - if (!img) throw createMissingReferenceError("Vidu I2V requires one reference image."); - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = requestedRes === "720P" ? "720P" : "1080P"; - return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; -} - -function buildPixverseT2vBody(params) { - const requestedRes = String(params.quality || "").toUpperCase(); - const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; - const size = sizeMap[requestedRes] || "1280*720"; - return { model: params.model, input: { prompt: params.prompt }, parameters: { size, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; -} - -function buildPixverseI2vBody(params) { - const [img] = getReferenceImageUrls(params, 1); - if (!img) throw createMissingReferenceError("PixVerse I2V requires one reference image."); - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; - return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image_url", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; -} -function buildPixverseKf2vBody(params) { - const refs = getReferenceImageUrls(params, 2); - if (refs.length < 1) throw createMissingReferenceError("PixVerse KF2V requires at least one reference image."); - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; - const media = refs.map(url => ({ type: "image_url", url })); - return { model: params.model, input: { prompt: params.prompt || "", media }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; -} - -function buildVideoRequest(providerConfig, params, apiKey) { - const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; - let body; - - if (providerConfig.protocol === "seed-video-ark") { - body = buildArkSeedVideoBody(params); - } else if (providerConfig.protocol === "happyhorse-t2v") { - body = buildHappyHorseT2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "happyhorse-i2v") { - body = buildHappyHorseI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "happyhorse-r2v") { - body = buildHappyHorseR2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "wan-i2v") { - body = buildWanI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "wan-s2v") { - body = buildWanS2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "kling-dashscope") { - body = buildDashscopeKlingBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "vidu-t2v") { - body = buildViduT2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "vidu-i2v") { - body = buildViduI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "pixverse-t2v") { - body = buildPixverseT2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "pixverse-i2v") { - body = buildPixverseI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "pixverse-kf2v") { - body = buildPixverseKf2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "kling-omni") { - body = buildKlingOmniBody(params); - const credential = parseKlingCredential(apiKey); - if (credential) { - headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; - } - } else { - body = buildSeedVideoBody(params); - } - - return { headers, body }; -} - -function registerAiRoutes(router) { - router.post("/ai/image", requireAuth, async (req, res) => { - const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; - if (!prompt) return res.status(400).json({ error: "Missing prompt" }); - - try { - const providerCandidates = resolveImageProviderCandidates(model); - const primaryProviderConfig = providerCandidates[0]; - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: primaryProviderConfig.model, - requestedModel: primaryProviderConfig.requestedModel, - prompt, - ratio, - quality, - gridMode, - referenceUrls, - }; - const { taskRow, imageBilling } = await withTransaction(async (client) => { - const nextTaskRow = await insertTask( - req.user.id, - projectId, - "image", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - client, - ); - const billingResult = await deductImageGenerationCredits(req.user.id, client, { - taskId: nextTaskRow.id, - model: params.requestedModel || params.model || model, - resolution: [ratio, quality].filter(Boolean).join(" / "), - }); - if (!billingResult.success) { - const error = new Error(billingResult.message || "账户积分不足"); - error.status = 402; - error.code = "INSUFFICIENT_BALANCE"; - error.costCents = billingResult.costCents; - throw error; - } - return { taskRow: nextTaskRow, imageBilling: billingResult }; - }); - const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; - - res.status(202).json({ - taskId: String(taskRow.id), - status: "pending", - imageBilling: { - costCents: imageBilling.costCents, - deductionType: imageBilling.deductionType, - balanceAfterCents: imageBilling.balanceAfterCents, - }, - providerDebug: buildImageProviderDebug(model), - }); - submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { - console.error("[ai/image] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - }); - } catch (err) { - console.error("[ai/image] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/video", requireAuth, async (req, res) => { - const { - model, - prompt, - ratio, - duration, - quality, - frameMode, - referenceUrls, - imageUrl, - audioUrl, - resolution, - muted, - hasReferenceVideo, - style, - projectId: requestedProjectId, - conversationId, - } = req.body; - const providerConfig = resolveVideoProvider(model); - const provider = providerConfig.provider; - const isWanS2v = providerConfig.protocol === "wan-s2v"; - const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); - - if (!isWanS2v && !prompt) return res.status(400).json({ error: "Missing prompt" }); - if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); - if (isWanS2v) { - if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { - return res.status(400).json({ error: "Missing imageUrl" }); - } - if (!normalizePublicHttpUrl(audioUrl)) { - return res.status(400).json({ error: "Missing audioUrl" }); - } - } - - let slotResult = null; - try { - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: providerConfig.model, - requestedModel: providerConfig.requestedModel, - prompt: prompt || "数字人口播视频", - ratio, - duration, - quality: quality || resolution, - resolution: resolution || quality, - frameMode, - referenceUrls, - imageUrl, - audioUrl, - muted: Boolean(muted), - hasReferenceVideo: Boolean(hasReferenceVideo), - style, - }; - - let enterpriseBilling = null; - let preauth = null; - if (isEnterpriseVideoBillingUser(req.user)) { - enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); - preauth = { - authorized: true, - estimatedCostCents: enterpriseBilling.amountCents, - billingMode: "enterprise", - }; - } else { - preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { - const nextTaskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - client, - ); - if (enterpriseBilling) { - const nextBilling = await reserveEnterpriseVideoCredits(client, { - ...enterpriseBilling, - taskId: nextTaskRow.id, - }); - return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; - } - // Regular user: deduct from personal balance - const credits = calculateEnterpriseVideoCredits({ - model: params.model, - resolution: params.resolution || params.quality, - durationSeconds: params.duration, - muted: params.muted, - hasReferenceVideo: params.hasReferenceVideo, - }); - const costCents = Math.ceil(credits * 100); - const { rows: [deducted] } = await client.query( - "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", - [costCents, req.user.id], - ); - if (!deducted) { - throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); - } - await client.query( - "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", - [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], - ); - return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; - }); - - if (reservedBilling) { - params.enterpriseBilling = { - creditLedgerId: reservedBilling.creditLedgerId, - amountCents: reservedBilling.amountCents, - resolution: reservedBilling.resolution, - durationSeconds: reservedBilling.durationSeconds, - rateCentsPerSecond: reservedBilling.rateCentsPerSecond, - }; - await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ - JSON.stringify(params), - taskRow.id, - ]); - } - - res.status(202).json({ - taskId: String(taskRow.id), - status: "pending", - enterpriseBilling: reservedBilling - ? { - creditLedgerId: reservedBilling.creditLedgerId, - amountCents: reservedBilling.amountCents, - enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, - } - : undefined, - }); - const activeSlotResult = slotResult; - slotResult = null; - submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) - .then(async () => { - try { - await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); - } catch (settlementError) { - console.error("[ai/video] enterprise ledger settle error:", settlementError.message); - } - }) - .catch(async (err) => { - console.error("[ai/video] submit error:", err.message); - await updateTaskInDb(taskRow.id, { status: "failed", error: translateDashscopeContentError(err.message) || err.message }); - await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); - releaseLease(activeSlotResult); - }); - } catch (err) { - releaseLease(slotResult); - console.error("[ai/video] error:", err.message); - if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { - return res.status(err.status || 402).json({ - error: err.message, - code: "INSUFFICIENT_ENTERPRISE_BALANCE", - }); - } - sendAiRouteError(res, err); - } - }); - - router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { - const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); - const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); - const { projectId: requestedProjectId, conversationId } = req.body || {}; - - if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); - - const provider = "dashscope"; - let slotResult; - try { - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - for (let srAttempt = 0; srAttempt < 3; srAttempt++) { - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 20000 }); - if (slotResult) break; - if (srAttempt < 2) { - console.info(`[ai/image/super-resolve] concurrency full, retry ${srAttempt + 1}/2 after 5s`); - await new Promise((r) => setTimeout(r, 5000)); - } - } - if (!slotResult) { - return res.status(429).json({ error: "\u8d85\u5206\u670d\u52a1\u7e41\u5fd9\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" }); - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: "wanx2.1-imageedit", - operation: "image-super-resolution", - imageUrl, - scale, - }; - const taskRow = await insertTask( - req.user.id, - projectId, - "image", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { - console.error("[ai/image/super-resolve] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - releaseLease(slotResult); - }); - } catch (err) { - if (slotResult) releaseLease(slotResult); - console.error("[ai/image/super-resolve] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { - const videoUrl = String(req.body?.videoUrl || "").trim(); - const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); - const providerMode = String(req.body?.provider || req.body?.model || "").trim(); - const shouldUseDashscopeStyle = - providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; - const { projectId: requestedProjectId, conversationId } = req.body || {}; - - if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); - if (!/^https?:\/\//i.test(videoUrl)) { - return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); - } - - let dashscopeSlotResult; - try { - if (shouldUseDashscopeStyle) { - const provider = "dashscope"; - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!dashscopeSlotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const styleOptions = normalizeVideoStyleTransformOptions(req.body); - const params = { - model: "video-style-transform", - operation: "video-style-super-resolution", - videoUrl, - ...styleOptions, - }; - const taskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { - console.error("[ai/video/super-resolve] dashscope submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - releaseLease(dashscopeSlotResult); - }); - return; - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; - const taskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { - console.error("[ai/video/super-resolve] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - }); - } catch (err) { - if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); - console.error("[ai/video/super-resolve] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/chat", requireAuth, async (req, res) => { - const { model, messages, stream = true, temperature } = req.body; - if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); - - const providerConfig = resolveTextProvider(model); - const provider = providerConfig.provider; - let slotResult; - try { - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; - const reqHeaders = { - "Content-Type": "application/json", - Authorization: `Bearer ${slotResult.apiKey}`, - }; - const reqBody = JSON.stringify({ - model: providerConfig.model, - messages, - stream, - temperature: temperature || 0.7, - max_tokens: 4096, - }); - - if (stream) { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.flushHeaders(); - - const abortController = new AbortController(); - req.on("close", () => abortController.abort()); - - try { - const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); - if (!upstream.ok) { - const errText = await upstream.text().catch(() => "upstream error"); - res.write( - `data: ${JSON.stringify({ - error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), - done: true, - })}\n\n`, - ); - res.end(); - releaseLease(slotResult); - return; - } - - const reader = upstream.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (payload === "[DONE]") { - res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); - continue; - } - try { - const chunk = JSON.parse(payload); - const delta = chunk.choices?.[0]?.delta?.content || ""; - if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); - } catch {} - } - } - - res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); - res.end(); - releaseLease(slotResult); - } catch (streamErr) { - if (streamErr.name !== "AbortError") { - res.write( - `data: ${JSON.stringify({ - error: sanitizeUpstreamError(streamErr.message), - done: true, - })}\n\n`, - ); - } - res.end(); - releaseLease(slotResult); - } - } else { - const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody }); - const text = await upstream.text().catch(() => ""); - releaseLease(slotResult); - - let json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch { - return res.status(502).json({ - error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), - }); - } - - if (!upstream.ok || json.error) { - return res.status(502).json({ - error: sanitizeUpstreamError( - json.error?.message || json.message || json.error || text, - `文本服务返回 HTTP ${upstream.status}`, - ), - }); - } - - const content = json.choices?.[0]?.message?.content || ""; - const usage = json.usage || {}; - res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); - } - } catch (err) { - releaseLease(slotResult); - console.error("[ai/chat] error:", err.message); - res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks", requireAuth, async (req, res) => { - try { - const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); - const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); - const status = String(req.query.status || "").trim(); - const type = String(req.query.type || "").trim(); - const projectId = String(req.query.projectId || req.query.project_id || "").trim(); - const params = [req.user.id]; - const where = ["user_id = $1"]; - - if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { - params.push(status); - where.push(`status = $${params.length}`); - } - if (["image", "video"].includes(type)) { - params.push(type); - where.push(`type = $${params.length}`); - } - if (projectId) { - params.push(projectId); - where.push(`project_id = $${params.length}`); - } - - params.push(limit, offset); - const { rows } = await pool.query( - ` - SELECT * - FROM generation_tasks - WHERE ${where.join(" AND ")} - ORDER BY updated_at DESC - LIMIT $${params.length - 1} - OFFSET $${params.length} - `, - params, - ); - res.json({ tasks: rows.map(formatAiTaskRow) }); - } catch (err) { - console.error("[ai/tasks] list failed:", err.message); - res.status(500).json({ error: "Failed to load task history" }); - } - }); - - router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { - const taskId = Number(req.params.taskId); - const conversationId = Number(req.body?.conversationId); - - if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { - return res.status(400).json({ error: "Invalid task or conversation id" }); - } - - try { - const { rows: conversationRows } = await pool.query( - "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", - [conversationId, req.user.id], - ); - if (conversationRows.length === 0) { - return res.status(404).json({ error: "Conversation not found" }); - } - - const { rows } = await pool.query( - `UPDATE generation_tasks - SET conversation_id = $1, updated_at = NOW() - WHERE id = $2 AND user_id = $3 - RETURNING id, conversation_id`, - [conversationId, taskId, req.user.id], - ); - if (rows.length === 0) { - return res.status(404).json({ error: "Task not found" }); - } - - res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { - const { taskId } = req.params; - try { - const { rows } = await pool.query( - "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); - - res.json(formatAiTaskRow(rows[0])); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { - const taskId = Number(req.params.taskId); - if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); - - try { - const { rows } = await pool.query( - "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); - res.json({ id: rows[0].id, status: rows[0].status }); - } catch (err) { - console.error("[ai/task-cancel] error:", err.message); - res.status(500).json({ error: "取消任务失败" }); - } - }); - - router.get("/ai/proxy-download", requireAuth, async (req, res) => { - const url = String(req.query.url || "").trim(); - if (!url || !/^https?:\/\//i.test(url)) { - return res.status(400).json({ error: "Missing or invalid url parameter" }); - } - // Only allow proxying from our own OSS bucket - if (!url.includes("stringtest.oss") && !url.includes("aliyuncs.com")) { - return res.status(403).json({ error: "URL not allowed for proxy download" }); - } - try { - const upstream = await fetch(url, { method: "GET" }); - if (!upstream.ok) { - return res.status(upstream.status || 502).json({ error: "Upstream returned " + upstream.status }); - } - const contentType = upstream.headers.get("content-type") || "image/png"; - const buffer = Buffer.from(await upstream.arrayBuffer()); - if (!buffer.length) { - return res.status(502).json({ error: "Upstream returned empty content" }); - } - res.setHeader("Content-Type", contentType); - res.setHeader("Content-Length", String(buffer.length)); - res.setHeader("Cache-Control", "public, max-age=86400"); - res.end(buffer); - } catch (err) { - console.error("[ai/proxy-download] failed:", err.message); - if (!res.headersSent) res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { - const { taskId } = req.params; - try { - const { rows } = await pool.query( - "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); - - const task = rows[0]; - const resultUrl = String(task.result_url || "").trim(); - if (!/^https?:\/\//i.test(resultUrl)) { - return res.status(400).json({ error: "Task result is not downloadable" }); - } - - const upstream = await fetch(resultUrl, { method: "GET" }); - if (!upstream.ok || !upstream.body) { - return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); - } - - const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); - if (isErrorContentType(contentType)) { - const text = await upstream.text().catch(() => ""); - return res.status(502).json({ - error: text.includes("Expired") || text.includes("AccessDenied") - ? "结果链接已过期,请重新生成后再下载" - : "结果链接返回了错误内容,请重新生成后再下载", - }); - } - const buffer = Buffer.from(await upstream.arrayBuffer()); - if (!buffer.length) { - return res.status(502).json({ error: "Result download returned empty content" }); - } - - const extension = extensionFromContentType(contentType, task.type); - const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); - - res.setHeader("Content-Type", contentType); - res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); - res.setHeader("Content-Length", String(buffer.length)); - res.setHeader("Cache-Control", "no-store"); - res.end(buffer); - } catch (err) { - console.error("[ai/tasks/download] failed:", err.message); - if (!res.headersSent) res.status(500).json({ error: err.message }); - } - }); -} - -async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { - const errors = [...previousErrors]; - const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; - - for (let index = 0; index < candidates.length; index += 1) { - const providerConfig = candidates[index]; - const provider = providerConfig?.provider; - let slotResult = null; - - if (!provider) continue; - - try { - if (index > 0 && !(await providerPoolExists(provider))) { - throw new Error(`${provider} provider pool is not configured`); - } - - slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - throw new Error(`${provider} concurrency pool is full`); - } - - await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { - onTaskFailed: async (failureMessage) => { - const providerError = `${provider}: ${failureMessage}`; - const remainingCandidates = candidates.slice(index + 1); - if (remainingCandidates.length === 0) { - await updateTaskInDb(taskDbId, { - status: "failed", - error: translateDashscopeContentError([...errors, providerError].join(" | ")) || `All image providers failed: ${[...errors, providerError].join(" | ")}`, - }); - return true; - } - - console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); - await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); - try { - await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [ - ...errors, - providerError, - ]); - return true; - } catch (fallbackErr) { - await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); - return true; - } - }, - }); - if (index > 0) { - console.info(`[ai/image] task ${taskDbId} switched provider to ${provider}`); - } - return; - } catch (err) { - const message = err?.message || String(err); - errors.push(`${provider}: ${message}`); - console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); - releaseLease(slotResult); - - if (index < candidates.length - 1) { - await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); - } - } - } - - throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); -} - -async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { - const url = getPostUrl(providerConfig); - const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); - - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const submitTimeout = providerConfig.transport === "rightcode-image" ? RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS : providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; - const maxAttempts = providerConfig.transport === "rightcode-image" ? 2 : 1; - let response; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { - console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} failed (HTTP ${response.status}), retrying...`); - await new Promise((r) => setTimeout(r, 2000)); - continue; - } - throw new Error(sanitizeUpstreamError(errText, `\u56fe\u7247\u670d\u52a1\u8fd4\u56deHTTP ${response.status}`)); - } - break; - } catch (err) { - if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { - console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} error: ${err.message}, retrying...`); - await new Promise((r) => setTimeout(r, 2000)); - continue; - } - throw err; - } - } - - const json = await response.json(); - - // Synchronous transports — extract image URL directly, no polling - if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { - let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); - const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; - console.info( - `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, - ); - if (!directUrl) { - // Retry once for kuaikuai empty result - if (tag === "kuaikuai") { - console.info(`[ai/image/kuaikuai] task ${taskDbId} retrying after empty result...`); - await new Promise((r) => setTimeout(r, 3000)); - const retryResponse = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); - if (retryResponse.ok) { - const retryJson = await retryResponse.json(); - directUrl = extractImageUrl(retryJson) || extractGeminiImageUrl(retryJson); - console.info(`[ai/image/kuaikuai] task ${taskDbId} retry result ${directUrl ? "parsed" : "still missing"}`); - } - } - if (!directUrl) throw new Error(`${tag} did not return an image url`); - } - - - // Gemini may return base64 data URL — too large for DB, upload to OSS first - if (directUrl.startsWith("data:") && isOssConfigured()) { - const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); - if (match) { - const mimeType = match[1]; - const buffer = Buffer.from(match[2], "base64"); - const ext = mimeType.split("/")[1] || "png"; - const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; - await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); - const bucket = process.env.OSS_BUCKET || ""; - const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); - directUrl = process.env.OSS_PUBLIC_BASE_URL - ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` - : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; - console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); - } - } - - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); - releaseLease(slotResult); - return; - } - - const directUrl = extractImageUrl(json); - - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("Provider did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "image", - providerConfig, - leaseToken: slotResult.leaseToken, - keyManager, - onTaskFailed: options.onTaskFailed, - }); -} - -async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { - const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; - const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); - - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - if (providerConfig.protocol === "wan-s2v") { - await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); - await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); - } - const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractVideoUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("Video provider did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "video", - providerConfig, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const body = buildDashscopeImageSuperResolveBody(params); - const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - Authorization: `Bearer ${slotResult.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractImageUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "image", - providerConfig: { transport: "dashscope-image" }, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const body = buildDashscopeVideoStyleTransformBody(params); - const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - Authorization: `Bearer ${slotResult.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractVideoUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "video", - providerConfig: { - protocol: "wan-i2v", - baseUrl: "https://dashscope.aliyuncs.com", - }, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitVideoSuperResolveTask(taskDbId, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); - const submitResult = await callAliyunRpc("SuperResolveVideo", { - VideoUrl: params.videoUrl, - BitRate: String(params.bitRate || 10), - }); - const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; - if (!jobId) { - throw new Error("Aliyun SuperResolveVideo did not return a job id"); - } - - await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); - - for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); - } - - const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); - const data = result.Data || result.data || {}; - const status = normalizeAliyunJobStatus(data.Status || data.status); - const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); - - if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { - const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; - const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; - if (!videoUrl) { - throw new Error("Aliyun super-resolution completed without a video url"); - } - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); - return; - } - - if ( - status === "PROCESS_FAILED" || - status === "FAIL" || - status === "FAILED" || - status === "TIMEOUT_FAILED" || - status === "LIMIT_RETRY_FAILED" - ) { - throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); - } - - await updateTaskInDb(taskDbId, { status: "running", progress }); - } - - throw new Error("Aliyun video super-resolution timed out"); -} - -module.exports = { registerAiRoutes }; diff --git a/src/routes/ai.js.bak-classify b/src/routes/ai.js.bak-classify deleted file mode 100644 index e61ee6b..0000000 --- a/src/routes/ai.js.bak-classify +++ /dev/null @@ -1,2098 +0,0 @@ -"use strict"; - -const crypto = require("node:crypto"); -const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); -const { putObject, isOssConfigured } = require("../ossClient"); -const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); -const { shouldSkipProvider, recordProviderSuccess, recordProviderFailure, getAdaptiveTimeout, getAllBreakerStats } = require("../providerCircuitBreaker"); -const { - isEnterpriseVideoBillingUser, - markEnterpriseVideoCreditsAccepted, - prepareEnterpriseVideoBilling, - refundEnterpriseVideoCredits, - reserveEnterpriseVideoCredits, - calculateEnterpriseVideoCredits, - getEnterpriseVideoCreditRate, -} = require("../enterpriseVideoBilling"); -const { - startPolling, - updateTaskInDb, - extractProviderTaskId, - extractImageUrl, - extractGeminiImageUrl, - extractVideoUrl, - parseKlingCredential, - createKlingJwt, -} = require("../aiTaskWorker"); -const { - buildDashscopeImageSuperResolveBody, - buildDashscopeVideoStyleTransformBody, - normalizeImageUpscaleFactor, - normalizeVideoStyleTransformOptions, -} = require("../aiUpscaleHelpers"); - -const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ - ["gpt-image-2", "1K"], - ["gpt-image-2-vip", "2K"], -]); - -const GRSAI_IMAGE_MAX_QUALITY = new Map([ - ["gpt-image-2", "2K"], -]); - -const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ - ["wan2.7-image", "2K"], -]); - -const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; -const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; -const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; -const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 120; -const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 150_000; -const RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS = 70_000; -const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; -const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; -const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; -const MAX_USER_ACTIVE_GENERATION_TASKS = 3; -const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; - -const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { - "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, - "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, - "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, - "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, - "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, -}; - -function mapAspectRatioToPixels(ratio, quality) { - const q = String(quality || "1K").toUpperCase(); - const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; - return map ? (map[q] || map["1K"]) : "1024x1024"; -} - -function mapAspectRatioToDashscopeSize(ratio, quality) { - return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); -} - -function normalizeQuality(value, fallback = "1K") { - const q = String(value || fallback).trim().toUpperCase(); - if (q === "4K" || q === "2K" || q === "1K") return q; - return fallback; -} - -function clampImageQualityForModel(model, quality) { - const normalized = normalizeQuality(quality, "2K"); - const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); - if (maxQuality === "2K" && normalized === "4K") return "2K"; - if (maxQuality === "1K" && normalized !== "1K") return "1K"; - return normalized; -} - -function clampGrsaiImageQualityForModel(model, quality) { - const normalized = normalizeQuality(quality, "1K"); - const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); - if (maxQuality === "2K" && normalized === "4K") return "2K"; - if (maxQuality === "1K" && normalized !== "1K") return "1K"; - return normalized; -} - -function normalizeDuration(value, min = 4, max = 15, fallback = 5) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return fallback; - return Math.max(min, Math.min(max, Math.round(numeric))); -} - -function normalizeRatio(value, fallback = "16:9") { - const ratio = String(value || fallback).trim(); - return ratio === "auto" ? "adaptive" : ratio; -} - -function normalizeVideoResolution(value, allowed, fallback = "720p") { - const resolution = String(value || "").trim().toLowerCase(); - return allowed.includes(resolution) ? resolution : fallback; -} - -function normalizeS2vResolution(value) { - const resolution = String(value || "").trim().toLowerCase(); - return resolution === "480p" ? "480P" : "720P"; -} - -function normalizeS2vStyle(value) { - const style = String(value || "").trim().toLowerCase(); - return ["speech", "sing", "performance"].includes(style) ? style : "speech"; -} - -function normalizePublicHttpUrl(value) { - const url = String(value || "").trim(); - return /^https?:\/\//i.test(url) ? url : ""; -} - -function percentEncodeRpc(value) { - return encodeURIComponent(String(value)) - .replace(/!/g, "%21") - .replace(/'/g, "%27") - .replace(/\(/g, "%28") - .replace(/\)/g, "%29") - .replace(/\*/g, "%2A"); -} - -function signAliyunRpcParams(method, params, accessKeySecret) { - const canonicalQuery = Object.keys(params) - .sort() - .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) - .join("&"); - const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; - return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); -} - -function getAliyunVideoEnhanCredentials() { - const accessKeyId = - process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || - process.env.ALIYUN_ACCESS_KEY_ID || - process.env.STS_ACCESS_KEY_ID || - ""; - const accessKeySecret = - process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || - process.env.ALIYUN_ACCESS_KEY_SECRET || - process.env.STS_ACCESS_KEY_SECRET || - ""; - return { accessKeyId, accessKeySecret }; -} - -function buildAliyunRpcUrl(action, actionParams = {}) { - const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); - if (!accessKeyId || !accessKeySecret) { - const error = new Error("Aliyun video super-resolution is not configured"); - error.status = 501; - throw error; - } - - const params = { - Action: action, - Version: ALIYUN_VIDEOENHAN_VERSION, - Format: "JSON", - AccessKeyId: accessKeyId, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - SignatureNonce: crypto.randomUUID(), - Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), - ...actionParams, - }; - params.Signature = signAliyunRpcParams("POST", params, accessKeySecret); - - const body = Object.entries(params) - .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) - .join("&"); - return { url: ALIYUN_VIDEOENHAN_ENDPOINT, body }; -} - -function parseAliyunJsonResult(value) { - if (!value) return null; - if (typeof value === "object") return value; - if (typeof value !== "string") return null; - try { - return JSON.parse(value); - } catch { - return null; - } -} - -async function callAliyunRpc(action, params) { - const req = buildAliyunRpcUrl(action, params); - const response = await fetch(req.url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: req.body, - }); - const text = await response.text().catch(() => ""); - let json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch { - throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); - } - - if (!response.ok || json.Code || json.code) { - throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); - } - - return json; -} - -function normalizeSuperResolveBitRate(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 10; - return Math.max(1, Math.min(20, Math.round(numeric))); -} - -function normalizeAliyunJobStatus(value) { - return String(value || "").trim().toUpperCase(); -} - -async function ensureDefaultProject(userId) { - const projectId = `web-default-${userId}`; - const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); - if (rows.length === 0) { - const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); - await pool.query( - `INSERT INTO projects ( - id, - user_id, - name, - description, - oss_key, - storyboard_count, - image_count, - video_count, - file_size, - current_revision, - updated_by_device_id, - created_at, - updated_at - ) - VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) - ON CONFLICT (id) DO NOTHING`, - [ - projectId, - userId, - "Default workbench", - "Web fallback project for legacy generation requests", - `users/${safeUserId}/projects/${projectId}/current/project.json`, - ], - ); - } - return projectId; -} - -async function resolveTaskProject(userId, requestedProjectId) { - const projectId = String(requestedProjectId || "").trim().slice(0, 64); - if (!projectId) { - return ensureDefaultProject(userId); - } - - const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ - projectId, - userId, - ]); - if (rows.length === 0) { - const error = new Error("Project not found"); - error.status = 404; - throw error; - } - return projectId; -} - -async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { - if (!client) { - return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); - } - - await assertUserGenerationConcurrencyLimit(userId, client); - const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const { rows: [row] } = await client.query( - `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, - [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], - ); - return row; -} - -async function assertUserGenerationConcurrencyLimit(userId, client = pool) { - await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); - const { rows } = await client.query( - "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", - [userId], - ); - const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); - if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; - - const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); - error.status = 429; - error.code = "GENERATION_CONCURRENCY_LIMIT"; - error.activeCount = activeCount; - error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; - throw error; -} - -async function providerPoolExists(provider) { - if (!provider) return false; - const { rows } = await pool.query( - "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", - [provider], - ); - return rows.length > 0; -} - -function releaseLease(slotResult) { - if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); -} - -function sendAiRouteError(res, err) { - res.status(err.status || 500).json({ - error: err.message, - code: err.code, - activeCount: err.activeCount, - maxActiveTasks: err.maxActiveTasks, - }); -} - -async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } catch (err) { - if (err?.name === "AbortError") { - throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); - } - throw err; - } finally { - clearTimeout(timer); - } -} - -function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { - const raw = String(value || "").trim(); - if (!raw) return fallback; - - let message = raw; - try { - const parsed = JSON.parse(raw); - message = - parsed?.error?.message || - parsed?.error_description || - parsed?.message || - parsed?.error || - raw; - } catch {} - - const compact = String(message).replace(/\s+/g, " ").trim(); - const looksLikeMarkup = - /^]/i.test(compact) || - /^<\?xml/i.test(compact) || - /<\/?[a-z][^>]*>/i.test(compact); - - if (looksLikeMarkup) return fallback; - return compact.slice(0, 320); -} - -function translateDashscopeContentError(message) { - const msg = String(message || ""); - if (msg.includes("Green net check failed for image input") || msg.includes("Input data may contain inappropriate content")) { - return "\u53c2\u8003\u56fe\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff08\u7eff\u7f51\u68c0\u67e5\uff09\uff0c\u8bf7\u66f4\u6362\u56fe\u7247\u540e\u91cd\u8bd5\u3002\u5e38\u89c1\u539f\u56e0\uff1a\u4eba\u7269\u66b4\u9732\u3001\u654f\u611f\u9762\u90e8\u3001\u56fe\u7247\u542b\u654f\u611f\u6587\u5b57\u7b49\u3002"; - } - if (msg.includes("Output data may contain inappropriate content")) { - return "\u751f\u6210\u7ed3\u679c\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; - } - if (msg.includes("content policies") || msg.includes("prompt may violate")) { - return "\u63d0\u793a\u8bcd\u672a\u901a\u8fc7\u5185\u5bb9\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63cf\u8ff0\u540e\u91cd\u8bd5\u3002\u907f\u514d\u4f7f\u7528\u6d89\u53ca\u66b4\u9732\u3001\u66b4\u529b\u3001\u653f\u6cbb\u654f\u611f\u7b49\u8bcd\u6c47\u3002"; - } - if (msg.includes("inappropriate content")) { - return "\u5185\u5bb9\u672a\u901a\u8fc7\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; - } - return ""; -} - -function parseTaskParams(value) { - if (!value || typeof value !== "string") return {}; - try { - return JSON.parse(value); - } catch { - return {}; - } -} - -function formatAiTaskRow(row) { - return { - taskId: String(row.id), - projectId: row.project_id, - conversationId: row.conversation_id, - clientQueueId: row.client_queue_id || null, - type: row.type, - status: row.status, - progress: Number(row.progress || 0), - resultUrl: row.result_url || null, - error: row.error || null, - params: parseTaskParams(row.params_json), - createdAt: row.created_at, - updatedAt: row.updated_at, - completedAt: row.completed_at || null, - }; -} - -function extensionFromContentType(contentType, fallbackType) { - const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); - if (mime === "image/jpeg") return "jpg"; - if (mime === "image/png") return "png"; - if (mime === "image/webp") return "webp"; - if (mime === "image/gif") return "gif"; - if (mime === "video/webm") return "webm"; - if (mime === "video/quicktime") return "mov"; - if (mime === "video/mp4") return "mp4"; - return fallbackType === "video" ? "mp4" : "png"; -} - -function contentDispositionFilename(value) { - return String(value || "generated") - .replace(/[\\/:*?"<>|]+/g, "-") - .replace(/[^\x20-\x7e]/g, "") - .trim() - .slice(0, 120) || "generated"; -} - -function isErrorContentType(contentType) { - return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); -} - -function buildDashscopeImageBody(params) { - const content = []; - for (const url of params.referenceUrls || []) { - if (url) content.push({ image: url }); - } - content.push({ text: params.prompt }); - const quality = clampImageQualityForModel(params.model, params.quality); - return { - model: params.model, - input: { - messages: [{ role: "user", content }], - }, - parameters: { - size: mapAspectRatioToDashscopeSize(params.ratio, quality), - n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, - watermark: false, - }, - }; -} - -function buildGrsaiImageBody(params) { - const isGptImage = String(params.model || "").startsWith("gpt-image"); - const modelKey = String(params.model || "").toLowerCase(); - const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); - return isGptImage - ? { - model: params.model, - prompt: params.prompt, - images: params.referenceUrls || [], - aspectRatio: mapAspectRatioToPixels(params.ratio, quality), - replyType: "json", - } - : { - model: params.model, - prompt: params.prompt, - images: params.referenceUrls || [], - aspectRatio: params.ratio || "auto", - imageSize: quality, - replyType: "json", - }; -} - -function buildRightcodeImageBody(providerConfig, params) { - const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; - const quality = normalizeQuality(params.quality, "1K"); - - return { - model: providerConfig.model || params.model, - prompt: params.prompt, - image: referenceUrls, - size: mapAspectRatioToPixels(params.ratio, quality), - response_format: "url", - }; -} - -function getGridCount(gridMode) { - if (gridMode === "grid-4") return 4; - if (gridMode === "grid-9") return 9; - if (gridMode === "grid-25") return 25; - return 1; -} - -function buildGeminiImageBody(params) { - const parts = [{ text: String(params.prompt || "").trim() }]; - const refs = (params.referenceUrls || []).filter(Boolean); - for (const url of refs) { - parts.push({ - fileData: { fileUri: url, mimeType: "image/png" }, - }); - } - const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; - const count = getGridCount(params.gridMode); - if (count > 1) generationConfig.candidateCount = count; - return { - contents: [{ parts }], - generationConfig, - }; -} - -function buildOpenAIImageBody(providerConfig, params) { - const userContent = []; - const prompt = String(params.prompt || "").trim(); - if (prompt) userContent.push({ type: "text", text: prompt }); - const refs = (params.referenceUrls || []).filter(Boolean); - for (const url of refs) { - userContent.push({ type: "image_url", image_url: { url } }); - } - const body = { - model: providerConfig.model || params.model, - messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], - }; - const count = getGridCount(params.gridMode); - if (count > 1) body.n = count; - return body; -} - -function buildImageRequest(providerConfig, params, apiKey) { - const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; - if (providerConfig.transport === "dashscope-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; - } - if (providerConfig.transport === "rightcode-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; - } - if (providerConfig.transport === "gemini-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; - } - if (providerConfig.transport === "openai-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; - } - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; -} - -function buildSeedVideoBody(params) { - const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); - const metadata = { - generate_audio: true, - watermark: false, - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 4, 15, 5), - resolution, - }; - const body = { - model: params.model, - prompt: params.prompt, - metadata, - }; - const refs = params.referenceUrls || []; - if (params.frameMode === "start-end" && refs.length >= 2) { - metadata.first_frame_image = refs[0]; - metadata.last_frame_image = refs[refs.length - 1]; - } else if (refs.length === 1) { - body.image = refs[0]; - } else if (refs.length > 1) { - metadata.reference_images = refs; - } - return body; -} - -function buildArkSeedVideoBody(params) { - const content = []; - if (params.prompt) content.push({ type: "text", text: params.prompt }); - - const refs = params.referenceUrls || []; - if (params.frameMode === "start-end" && refs.length >= 2) { - content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); - content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); - } else { - refs.forEach((url, index) => { - content.push({ - type: "image_url", - image_url: { url }, - role: index === 0 ? "first_frame" : "reference_image", - }); - }); - } - - const body = { - model: params.model, - content, - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 4, 15, 5), - generate_audio: true, - watermark: false, - }; - body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); - return body; -} - -function buildWanI2vBody(params) { - const refs = params.referenceUrls || []; - const media = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - media.push({ type: "first_frame", url: refs[0] }); - media.push({ type: "last_frame", url: refs[refs.length - 1] }); - } else if (refs[0]) { - media.push({ type: "first_frame", url: refs[0] }); - } - - const input = { prompt: params.prompt }; - if (media.length) input.media = media; - const requestedResolution = String(params.quality || "").toUpperCase(); - const parameters = { - resolution: requestedResolution === "720P" ? "720P" : "1080P", - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 3, 15, 5), - watermark: false, - }; - parameters.prompt_extend = true; - - return { - model: params.model, - input, - parameters, - }; -} - -function normalizeHappyHorseResolution(value) { - return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; -} - -function getReferenceImageUrls(params, limit = 9) { - return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) - .map((url) => normalizePublicHttpUrl(url)) - .filter(Boolean) - .slice(0, limit); -} - -function buildHappyHorseBaseParameters(params, { includeRatio }) { - const parameters = { - resolution: normalizeHappyHorseResolution(params.quality), - duration: normalizeDuration(params.duration, 3, 15, 5), - watermark: false, - }; - if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); - return parameters; -} - -function createMissingReferenceError(message) { - const error = new Error(message); - error.status = 400; - return error; -} - -function buildHappyHorseT2vBody(params) { - return { - model: params.model, - input: { - prompt: params.prompt, - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), - }; -} - -function buildHappyHorseI2vBody(params) { - const [firstFrame] = getReferenceImageUrls(params, 1); - if (!firstFrame) { - throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); - } - - return { - model: params.model, - input: { - prompt: params.prompt, - media: [{ type: "first_frame", url: firstFrame }], - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), - }; -} - -function buildHappyHorseR2vBody(params) { - const refs = getReferenceImageUrls(params, 9); - if (!refs.length) { - throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); - } - - return { - model: params.model, - input: { - prompt: params.prompt, - media: refs.map((url) => ({ type: "reference_image", url })), - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), - }; -} - -function getHappyHorseReferenceError(protocol, referenceUrls) { - if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { - return "HappyHorse I2V requires one first-frame image."; - } - if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { - return "HappyHorse R2V requires 1 to 9 reference images."; - } - return ""; -} - -async function assertWanS2vImageDetected(providerConfig, params, apiKey) { - const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); - if (!imageUrl) { - const error = new Error("Missing imageUrl"); - error.status = 400; - throw error; - } - - const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: providerConfig.detectModel || "wan2.2-s2v-detect", - input: { image_url: imageUrl }, - }), - }); - - const text = await response.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch {} - - if (!response.ok) { - throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); - } - - const output = json && typeof json === "object" ? json.output || json.data || json : {}; - const pass = - output.check_pass === true || - output.checkPass === true || - output.passed === true || - output.pass === true || - String(output.code || "").toLowerCase() === "success"; - - if (!pass) { - const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; - const error = new Error(message); - error.status = 400; - throw error; - } -} - -function extractProviderDetectMessage(output) { - if (!output || typeof output !== "object") return ""; - return String( - output.message || - output.reason || - output.failure_reason || - output.description || - output.error || - "", - ).trim(); -} - -function buildWanS2vBody(params) { - const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); - const audioUrl = normalizePublicHttpUrl(params.audioUrl); - if (!imageUrl) { - const error = new Error("Missing imageUrl"); - error.status = 400; - throw error; - } - if (!audioUrl) { - const error = new Error("Missing audioUrl"); - error.status = 400; - throw error; - } - - const parameters = { - resolution: normalizeS2vResolution(params.quality), - style: normalizeS2vStyle(params.style), - }; - - return { - model: params.model, - input: { - image_url: imageUrl, - audio_url: audioUrl, - }, - parameters, - }; -} - -function buildDashscopeKlingBody(params) { - const refs = params.referenceUrls || []; - const media = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - media.push({ type: "first_frame", url: refs[0] }); - media.push({ type: "last_frame", url: refs[refs.length - 1] }); - } else if (refs[0]) { - media.push({ type: "first_frame", url: refs[0] }); - } - - const input = { prompt: params.prompt }; - if (media.length) input.media = media; - const parameters = { - mode: params.quality === "std" ? "std" : "pro", - duration: normalizeDuration(params.duration, 5, 10, 5), - audio: false, - watermark: false, - }; - if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); - - return { model: params.model, input, parameters }; -} - -function buildKlingOmniBody(params) { - const refs = params.referenceUrls || []; - const imageList = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - imageList.push({ image_url: refs[0], type: "first_frame" }); - imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); - } else if (refs[0]) { - imageList.push({ image_url: refs[0], type: "first_frame" }); - } - - const body = { - model_name: "kling-v3-omni", - mode: params.quality === "std" ? "std" : "pro", - sound: "off", - duration: String(normalizeDuration(params.duration, 3, 15, 5)), - watermark_info: { enabled: false }, - prompt: params.prompt, - }; - if (imageList.length) body.image_list = imageList; - else body.aspect_ratio = normalizeRatio(params.ratio); - return body; -} - -function buildViduT2vBody(params) { - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = requestedRes === "720P" ? "720P" : "1080P"; - const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; - return { model: params.model, input: { prompt: params.prompt }, parameters: { resolution, size: sizeMap[resolution], duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; -} - -function buildViduI2vBody(params) { - const [img] = getReferenceImageUrls(params, 1); - if (!img) throw createMissingReferenceError("Vidu I2V requires one reference image."); - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = requestedRes === "720P" ? "720P" : "1080P"; - return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; -} - -function buildPixverseT2vBody(params) { - const requestedRes = String(params.quality || "").toUpperCase(); - const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; - const size = sizeMap[requestedRes] || "1280*720"; - return { model: params.model, input: { prompt: params.prompt }, parameters: { size, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; -} - -function buildPixverseI2vBody(params) { - const [img] = getReferenceImageUrls(params, 1); - if (!img) throw createMissingReferenceError("PixVerse I2V requires one reference image."); - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; - return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image_url", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; -} -function buildPixverseKf2vBody(params) { - const refs = getReferenceImageUrls(params, 2); - if (refs.length < 1) throw createMissingReferenceError("PixVerse KF2V requires at least one reference image."); - const requestedRes = String(params.quality || "").toUpperCase(); - const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; - const media = refs.map(url => ({ type: "image_url", url })); - return { model: params.model, input: { prompt: params.prompt || "", media }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; -} - -function buildWanAnimateMixBody(params) { - const imageUrl = normalizePublicHttpUrl(params.imageUrl); - const videoUrl = normalizePublicHttpUrl((params.referenceUrls || [])[0]); - if (!imageUrl) { const e = new Error("Missing imageUrl"); e.status = 400; throw e; } - if (!videoUrl) { const e = new Error("Missing videoUrl"); e.status = 400; throw e; } - return { model: params.model, input: { image_url: imageUrl, video_url: videoUrl }, parameters: { mode: "wan-pro" } }; -} -function buildVideoRequest(providerConfig, params, apiKey) { - const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; - let body; - - if (providerConfig.protocol === "seed-video-ark") { - body = buildArkSeedVideoBody(params); - } else if (providerConfig.protocol === "happyhorse-t2v") { - body = buildHappyHorseT2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "happyhorse-i2v") { - body = buildHappyHorseI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "happyhorse-r2v") { - body = buildHappyHorseR2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "wan-i2v") { - body = buildWanI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "wan-s2v") { - body = buildWanS2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "kling-dashscope") { - body = buildDashscopeKlingBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "vidu-t2v") { - body = buildViduT2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "vidu-i2v") { - body = buildViduI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "pixverse-t2v") { - body = buildPixverseT2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "pixverse-i2v") { - body = buildPixverseI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "pixverse-kf2v") { - body = buildPixverseKf2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "wan-animate-mix") { - body = buildWanAnimateMixBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "kling-omni") { - body = buildKlingOmniBody(params); - const credential = parseKlingCredential(apiKey); - if (credential) { - headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; - } - } else { - body = buildSeedVideoBody(params); - } - - return { headers, body }; -} - -function registerAiRoutes(router) { - router.get("/ai/provider-health", requireAuth, (req, res) => { - res.json({ providers: getAllBreakerStats() }); - }); - - router.post("/ai/image", requireAuth, async (req, res) => { - const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; - if (!prompt) return res.status(400).json({ error: "Missing prompt" }); - - try { - const providerCandidates = resolveImageProviderCandidates(model); - const primaryProviderConfig = providerCandidates[0]; - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: primaryProviderConfig.model, - requestedModel: primaryProviderConfig.requestedModel, - prompt, - ratio, - quality, - gridMode, - referenceUrls, - }; - const { taskRow, imageBilling } = await withTransaction(async (client) => { - const nextTaskRow = await insertTask( - req.user.id, - projectId, - "image", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - client, - ); - const billingResult = await deductImageGenerationCredits(req.user.id, client, { - taskId: nextTaskRow.id, - model: params.requestedModel || params.model || model, - resolution: [ratio, quality].filter(Boolean).join(" / "), - }); - if (!billingResult.success) { - const error = new Error(billingResult.message || "账户积分不足"); - error.status = 402; - error.code = "INSUFFICIENT_BALANCE"; - error.costCents = billingResult.costCents; - throw error; - } - return { taskRow: nextTaskRow, imageBilling: billingResult }; - }); - const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; - - res.status(202).json({ - taskId: String(taskRow.id), - status: "pending", - imageBilling: { - costCents: imageBilling.costCents, - deductionType: imageBilling.deductionType, - balanceAfterCents: imageBilling.balanceAfterCents, - }, - providerDebug: buildImageProviderDebug(model), - }); - submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { - console.error("[ai/image] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - }); - } catch (err) { - console.error("[ai/image] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/video", requireAuth, async (req, res) => { - const { - model, - prompt, - ratio, - duration, - quality, - frameMode, - referenceUrls, - imageUrl, - audioUrl, - resolution, - muted, - hasReferenceVideo, - style, - projectId: requestedProjectId, - conversationId, - } = req.body; - const providerConfig = resolveVideoProvider(model); - const provider = providerConfig.provider; - const isWanS2v = providerConfig.protocol === "wan-s2v"; - const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); - - if (!isWanS2v && !prompt) return res.status(400).json({ error: "Missing prompt" }); - if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); - if (isWanS2v) { - if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { - return res.status(400).json({ error: "Missing imageUrl" }); - } - if (!normalizePublicHttpUrl(audioUrl)) { - return res.status(400).json({ error: "Missing audioUrl" }); - } - } - - let slotResult = null; - try { - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: providerConfig.model, - requestedModel: providerConfig.requestedModel, - prompt: prompt || "数字人口播视频", - ratio, - duration, - quality: quality || resolution, - resolution: resolution || quality, - frameMode, - referenceUrls, - imageUrl, - audioUrl, - muted: Boolean(muted), - hasReferenceVideo: Boolean(hasReferenceVideo), - style, - }; - - let enterpriseBilling = null; - let preauth = null; - if (isEnterpriseVideoBillingUser(req.user)) { - enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); - preauth = { - authorized: true, - estimatedCostCents: enterpriseBilling.amountCents, - billingMode: "enterprise", - }; - } else { - preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { - const nextTaskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - client, - ); - if (enterpriseBilling) { - const nextBilling = await reserveEnterpriseVideoCredits(client, { - ...enterpriseBilling, - taskId: nextTaskRow.id, - }); - return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; - } - // Regular user: deduct from personal balance - const credits = calculateEnterpriseVideoCredits({ - model: params.model, - resolution: params.resolution || params.quality, - durationSeconds: params.duration, - muted: params.muted, - hasReferenceVideo: params.hasReferenceVideo, - }); - const costCents = Math.ceil(credits * 100); - const { rows: [deducted] } = await client.query( - "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", - [costCents, req.user.id], - ); - if (!deducted) { - throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); - } - await client.query( - "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", - [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], - ); - return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; - }); - - if (reservedBilling) { - params.enterpriseBilling = { - creditLedgerId: reservedBilling.creditLedgerId, - amountCents: reservedBilling.amountCents, - resolution: reservedBilling.resolution, - durationSeconds: reservedBilling.durationSeconds, - rateCentsPerSecond: reservedBilling.rateCentsPerSecond, - }; - await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ - JSON.stringify(params), - taskRow.id, - ]); - } - - res.status(202).json({ - taskId: String(taskRow.id), - status: "pending", - enterpriseBilling: reservedBilling - ? { - creditLedgerId: reservedBilling.creditLedgerId, - amountCents: reservedBilling.amountCents, - enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, - } - : undefined, - }); - const activeSlotResult = slotResult; - slotResult = null; - submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) - .then(async () => { - try { - await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); - } catch (settlementError) { - console.error("[ai/video] enterprise ledger settle error:", settlementError.message); - } - }) - .catch(async (err) => { - console.error("[ai/video] submit error:", err.message); - await updateTaskInDb(taskRow.id, { status: "failed", error: translateDashscopeContentError(err.message) || err.message }); - await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); - releaseLease(activeSlotResult); - }); - } catch (err) { - releaseLease(slotResult); - console.error("[ai/video] error:", err.message); - if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { - return res.status(err.status || 402).json({ - error: err.message, - code: "INSUFFICIENT_ENTERPRISE_BALANCE", - }); - } - sendAiRouteError(res, err); - } - }); - - router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { - const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); - const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); - const { projectId: requestedProjectId, conversationId } = req.body || {}; - - if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); - - const provider = "dashscope"; - let slotResult; - try { - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - for (let srAttempt = 0; srAttempt < 3; srAttempt++) { - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 20000 }); - if (slotResult) break; - if (srAttempt < 2) { - console.info(`[ai/image/super-resolve] concurrency full, retry ${srAttempt + 1}/2 after 5s`); - await new Promise((r) => setTimeout(r, 5000)); - } - } - if (!slotResult) { - return res.status(429).json({ error: "\u8d85\u5206\u670d\u52a1\u7e41\u5fd9\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" }); - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: "wanx2.1-imageedit", - operation: "image-super-resolution", - imageUrl, - scale, - }; - const taskRow = await insertTask( - req.user.id, - projectId, - "image", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { - console.error("[ai/image/super-resolve] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - releaseLease(slotResult); - }); - } catch (err) { - if (slotResult) releaseLease(slotResult); - console.error("[ai/image/super-resolve] error:", err.message); - sendAiRouteError(res, err); - } - }); - - - // --- Image Edit (watermark removal, etc.) --- - router.post("/ai/image/edit", requireAuth, async (req, res) => { - const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); - const editFunction = req.body?.function; - const prompt = req.body?.prompt || ""; - const n = req.body?.n || 1; - - if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); - if (!editFunction) return res.status(400).json({ error: "Missing function" }); - - const provider = "dashscope"; - let slotResult; - try { - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - for (let attempt = 0; attempt < 3; attempt++) { - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 20000 }); - if (slotResult) break; - if (attempt < 2) { - console.info(`[ai/image/edit] concurrency full, retry ${attempt + 1}/2 after 5s`); - await new Promise((r) => setTimeout(r, 5000)); - } - } - if (!slotResult) { - return res.status(429).json({ error: "图片编辑服务繁忙,请稍后重试" }); - } - - const { taskRow, imageBilling } = await withTransaction(async (client) => { - const params = { - model: "wanx2.1-imageedit", - operation: editFunction, - imageUrl, - prompt, - n, - }; - const nextTaskRow = await insertTask(req.user.id, null, "image", params, null, client); - const billingResult = await deductImageGenerationCredits(req.user.id, client, { - taskId: nextTaskRow.id, - model: "wanx2.1-imageedit", - resolution: editFunction, - }); - if (!billingResult.success) { - const error = new Error(billingResult.message || "账户积分不足"); - error.status = 402; - error.code = "INSUFFICIENT_BALANCE"; - throw error; - } - return { taskRow: nextTaskRow, imageBilling: billingResult }; - }); - const params = { model: "wanx2.1-imageedit", operation: editFunction, imageUrl, prompt, n }; - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitDashscopeImageEditTask(taskRow.id, slotResult, params).catch((err) => { - console.error("[ai/image/edit] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - releaseLease(slotResult); - }); - } catch (err) { - if (slotResult) releaseLease(slotResult); - console.error("[ai/image/edit] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { - const videoUrl = String(req.body?.videoUrl || "").trim(); - const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); - const providerMode = String(req.body?.provider || req.body?.model || "").trim(); - const shouldUseDashscopeStyle = - providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; - const { projectId: requestedProjectId, conversationId } = req.body || {}; - - if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); - if (!/^https?:\/\//i.test(videoUrl)) { - return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); - } - - let dashscopeSlotResult; - try { - if (shouldUseDashscopeStyle) { - const provider = "dashscope"; - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!dashscopeSlotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const styleOptions = normalizeVideoStyleTransformOptions(req.body); - const params = { - model: "video-style-transform", - operation: "video-style-super-resolution", - videoUrl, - ...styleOptions, - }; - const taskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { - console.error("[ai/video/super-resolve] dashscope submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - releaseLease(dashscopeSlotResult); - }); - return; - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; - const taskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { - console.error("[ai/video/super-resolve] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - }); - } catch (err) { - if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); - console.error("[ai/video/super-resolve] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/chat", requireAuth, async (req, res) => { - const { model, messages, stream = true, temperature } = req.body; - if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); - - const providerConfig = resolveTextProvider(model); - const provider = providerConfig.provider; - let slotResult; - try { - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; - const reqHeaders = { - "Content-Type": "application/json", - Authorization: `Bearer ${slotResult.apiKey}`, - }; - const reqBody = JSON.stringify({ - model: providerConfig.model, - messages, - stream, - temperature: temperature || 0.7, - max_tokens: 4096, - }); - - if (stream) { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.flushHeaders(); - - const abortController = new AbortController(); - req.on("close", () => abortController.abort()); - - try { - const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); - if (!upstream.ok) { - const errText = await upstream.text().catch(() => "upstream error"); - res.write( - `data: ${JSON.stringify({ - error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), - done: true, - })}\n\n`, - ); - res.end(); - releaseLease(slotResult); - return; - } - - const reader = upstream.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (payload === "[DONE]") { - res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); - continue; - } - try { - const chunk = JSON.parse(payload); - const delta = chunk.choices?.[0]?.delta?.content || ""; - if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); - } catch {} - } - } - - res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); - res.end(); - releaseLease(slotResult); - } catch (streamErr) { - if (streamErr.name !== "AbortError") { - res.write( - `data: ${JSON.stringify({ - error: sanitizeUpstreamError(streamErr.message), - done: true, - })}\n\n`, - ); - } - res.end(); - releaseLease(slotResult); - } - } else { - const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody }); - const text = await upstream.text().catch(() => ""); - releaseLease(slotResult); - - let json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch { - return res.status(502).json({ - error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), - }); - } - - if (!upstream.ok || json.error) { - return res.status(502).json({ - error: sanitizeUpstreamError( - json.error?.message || json.message || json.error || text, - `文本服务返回 HTTP ${upstream.status}`, - ), - }); - } - - const content = json.choices?.[0]?.message?.content || ""; - const usage = json.usage || {}; - res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); - } - } catch (err) { - releaseLease(slotResult); - console.error("[ai/chat] error:", err.message); - res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks", requireAuth, async (req, res) => { - try { - const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); - const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); - const status = String(req.query.status || "").trim(); - const type = String(req.query.type || "").trim(); - const projectId = String(req.query.projectId || req.query.project_id || "").trim(); - const params = [req.user.id]; - const where = ["user_id = $1"]; - - if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { - params.push(status); - where.push(`status = $${params.length}`); - } - if (["image", "video"].includes(type)) { - params.push(type); - where.push(`type = $${params.length}`); - } - if (projectId) { - params.push(projectId); - where.push(`project_id = $${params.length}`); - } - - params.push(limit, offset); - const { rows } = await pool.query( - ` - SELECT * - FROM generation_tasks - WHERE ${where.join(" AND ")} - ORDER BY updated_at DESC - LIMIT $${params.length - 1} - OFFSET $${params.length} - `, - params, - ); - res.json({ tasks: rows.map(formatAiTaskRow) }); - } catch (err) { - console.error("[ai/tasks] list failed:", err.message); - res.status(500).json({ error: "Failed to load task history" }); - } - }); - - router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { - const taskId = Number(req.params.taskId); - const conversationId = Number(req.body?.conversationId); - - if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { - return res.status(400).json({ error: "Invalid task or conversation id" }); - } - - try { - const { rows: conversationRows } = await pool.query( - "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", - [conversationId, req.user.id], - ); - if (conversationRows.length === 0) { - return res.status(404).json({ error: "Conversation not found" }); - } - - const { rows } = await pool.query( - `UPDATE generation_tasks - SET conversation_id = $1, updated_at = NOW() - WHERE id = $2 AND user_id = $3 - RETURNING id, conversation_id`, - [conversationId, taskId, req.user.id], - ); - if (rows.length === 0) { - return res.status(404).json({ error: "Task not found" }); - } - - res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { - const { taskId } = req.params; - try { - const { rows } = await pool.query( - "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); - - res.json(formatAiTaskRow(rows[0])); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { - const taskId = Number(req.params.taskId); - if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); - - try { - const { rows } = await pool.query( - "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); - res.json({ id: rows[0].id, status: rows[0].status }); - } catch (err) { - console.error("[ai/task-cancel] error:", err.message); - res.status(500).json({ error: "取消任务失败" }); - } - }); - - router.get("/ai/proxy-download", requireAuth, async (req, res) => { - const url = String(req.query.url || "").trim(); - if (!url || !/^https?:\/\//i.test(url)) { - return res.status(400).json({ error: "Missing or invalid url parameter" }); - } - // Only allow proxying from our own OSS bucket - if (!url.includes("stringtest.oss") && !url.includes("aliyuncs.com")) { - return res.status(403).json({ error: "URL not allowed for proxy download" }); - } - try { - const upstream = await fetch(url, { method: "GET" }); - if (!upstream.ok) { - return res.status(upstream.status || 502).json({ error: "Upstream returned " + upstream.status }); - } - const contentType = upstream.headers.get("content-type") || "image/png"; - const buffer = Buffer.from(await upstream.arrayBuffer()); - if (!buffer.length) { - return res.status(502).json({ error: "Upstream returned empty content" }); - } - res.setHeader("Content-Type", contentType); - res.setHeader("Content-Length", String(buffer.length)); - res.setHeader("Cache-Control", "public, max-age=86400"); - res.end(buffer); - } catch (err) { - console.error("[ai/proxy-download] failed:", err.message); - if (!res.headersSent) res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { - const { taskId } = req.params; - try { - const { rows } = await pool.query( - "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); - - const task = rows[0]; - const resultUrl = String(task.result_url || "").trim(); - if (!/^https?:\/\//i.test(resultUrl)) { - return res.status(400).json({ error: "Task result is not downloadable" }); - } - - const upstream = await fetch(resultUrl, { method: "GET" }); - if (!upstream.ok || !upstream.body) { - return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); - } - - const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); - if (isErrorContentType(contentType)) { - const text = await upstream.text().catch(() => ""); - return res.status(502).json({ - error: text.includes("Expired") || text.includes("AccessDenied") - ? "结果链接已过期,请重新生成后再下载" - : "结果链接返回了错误内容,请重新生成后再下载", - }); - } - const buffer = Buffer.from(await upstream.arrayBuffer()); - if (!buffer.length) { - return res.status(502).json({ error: "Result download returned empty content" }); - } - - const extension = extensionFromContentType(contentType, task.type); - const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); - - res.setHeader("Content-Type", contentType); - res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); - res.setHeader("Content-Length", String(buffer.length)); - res.setHeader("Cache-Control", "no-store"); - res.end(buffer); - } catch (err) { - console.error("[ai/tasks/download] failed:", err.message); - if (!res.headersSent) res.status(500).json({ error: err.message }); - } - }); -} - -async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { - const errors = [...previousErrors]; - const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; - - for (let index = 0; index < candidates.length; index += 1) { - const providerConfig = candidates[index]; - const provider = providerConfig?.provider; - let slotResult = null; - - if (!provider) continue; - - if (shouldSkipProvider(provider)) { - errors.push(`${provider}: circuit breaker OPEN (skipped)`); - console.info(`[ai/image] skipping ${provider} for task ${taskDbId} — circuit breaker OPEN`); - continue; - } - - const startTime = Date.now(); - try { - if (index > 0 && !(await providerPoolExists(provider))) { - throw new Error(`${provider} provider pool is not configured`); - } - - slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - throw new Error(`${provider} concurrency pool is full`); - } - - await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { - adaptiveTimeoutMs: getAdaptiveTimeout(provider, IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS), - onTaskFailed: async (failureMessage) => { - recordProviderFailure(provider); - const providerError = `${provider}: ${failureMessage}`; - const remainingCandidates = candidates.slice(index + 1); - if (remainingCandidates.length === 0) { - await updateTaskInDb(taskDbId, { - status: "failed", - error: translateDashscopeContentError([...errors, providerError].join(" | ")) || `All image providers failed: ${[...errors, providerError].join(" | ")}`, - }); - return true; - } - console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); - await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); - try { - await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [...errors, providerError]); - return true; - } catch (fallbackErr) { - await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); - return true; - } - }, - onTaskCompleted: () => { - recordProviderSuccess(provider, Date.now() - startTime); - }, - }); - if (index > 0) { - console.info(`[ai/image] task ${taskDbId} switched to ${provider} (fallback)`); - } - return; - } catch (err) { - const message = err?.message || String(err); - errors.push(`${provider}: ${message}`); - recordProviderFailure(provider); - console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); - releaseLease(slotResult); - if (index < candidates.length - 1) { - await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); - } - } - } - - throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); -} - -async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { - const url = getPostUrl(providerConfig); - const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); - - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const baseTimeout = providerConfig.transport === "rightcode-image" ? RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS : providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; - const submitTimeout = options.adaptiveTimeoutMs || baseTimeout; - const maxAttempts = providerConfig.transport === "rightcode-image" ? 2 : 1; - let response; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { - console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} failed (HTTP ${response.status}), retrying...`); - await new Promise((r) => setTimeout(r, 2000)); - continue; - } - throw new Error(sanitizeUpstreamError(errText, `\u56fe\u7247\u670d\u52a1\u8fd4\u56deHTTP ${response.status}`)); - } - break; - } catch (err) { - if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { - console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} error: ${err.message}, retrying...`); - await new Promise((r) => setTimeout(r, 2000)); - continue; - } - throw err; - } - } - - const json = await response.json(); - - // Synchronous transports — extract image URL directly, no polling - if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { - let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); - const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; - console.info( - `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, - ); - if (!directUrl) { - // Retry once for kuaikuai empty result - if (tag === "kuaikuai") { - console.info(`[ai/image/kuaikuai] task ${taskDbId} retrying after empty result...`); - await new Promise((r) => setTimeout(r, 3000)); - const retryResponse = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); - if (retryResponse.ok) { - const retryJson = await retryResponse.json(); - directUrl = extractImageUrl(retryJson) || extractGeminiImageUrl(retryJson); - console.info(`[ai/image/kuaikuai] task ${taskDbId} retry result ${directUrl ? "parsed" : "still missing"}`); - } - } - if (!directUrl) throw new Error(`${tag} did not return an image url`); - } - - - // Gemini may return base64 data URL — too large for DB, upload to OSS first - if (directUrl.startsWith("data:") && isOssConfigured()) { - const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); - if (match) { - const mimeType = match[1]; - const buffer = Buffer.from(match[2], "base64"); - const ext = mimeType.split("/")[1] || "png"; - const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; - await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); - const bucket = process.env.OSS_BUCKET || ""; - const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); - directUrl = process.env.OSS_PUBLIC_BASE_URL - ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` - : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; - console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); - } - } - - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); - if (typeof options.onTaskCompleted === "function") { try { options.onTaskCompleted(); } catch (_) {} } - releaseLease(slotResult); - return; - } - - const directUrl = extractImageUrl(json); - - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("Provider did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "image", - providerConfig, - leaseToken: slotResult.leaseToken, - keyManager, - onTaskFailed: options.onTaskFailed, - onTaskCompleted: options.onTaskCompleted, - }); -} - -async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { - const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; - const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); - - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - if (providerConfig.protocol === "wan-s2v") { - await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); - await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); - } - const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractVideoUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("Video provider did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "video", - providerConfig, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitDashscopeImageEditTask(taskDbId, slotResult, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const body = { - model: "wanx2.1-imageedit", - input: { - function: params.operation, - prompt: params.prompt || "去除图像中的文字", - base_image_url: params.imageUrl, - }, - parameters: { n: params.n || 1 }, - }; - const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - Authorization: `Bearer ${slotResult.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `图片编辑服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractImageUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("DashScope image edit did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "image", - providerConfig: { transport: "dashscope-image" }, - slotResult, - }); -} - -async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const body = buildDashscopeImageSuperResolveBody(params); - const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - Authorization: `Bearer ${slotResult.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractImageUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "image", - providerConfig: { transport: "dashscope-image" }, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const body = buildDashscopeVideoStyleTransformBody(params); - const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - Authorization: `Bearer ${slotResult.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractVideoUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "video", - providerConfig: { - protocol: "wan-i2v", - baseUrl: "https://dashscope.aliyuncs.com", - }, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitVideoSuperResolveTask(taskDbId, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); - const submitResult = await callAliyunRpc("SuperResolveVideo", { - VideoUrl: params.videoUrl, - BitRate: String(params.bitRate || 10), - }); - const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; - if (!jobId) { - throw new Error("Aliyun SuperResolveVideo did not return a job id"); - } - - await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); - - for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); - } - - const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); - const data = result.Data || result.data || {}; - const status = normalizeAliyunJobStatus(data.Status || data.status); - const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); - - if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { - const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; - const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; - if (!videoUrl) { - throw new Error("Aliyun super-resolution completed without a video url"); - } - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); - return; - } - - if ( - status === "PROCESS_FAILED" || - status === "FAIL" || - status === "FAILED" || - status === "TIMEOUT_FAILED" || - status === "LIMIT_RETRY_FAILED" - ) { - throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); - } - - await updateTaskInDb(taskDbId, { status: "running", progress }); - } - - throw new Error("Aliyun video super-resolution timed out"); -} - -module.exports = { registerAiRoutes }; diff --git a/src/routes/ai.js.bak-superresolve b/src/routes/ai.js.bak-superresolve deleted file mode 100644 index b2e7558..0000000 --- a/src/routes/ai.js.bak-superresolve +++ /dev/null @@ -1,1811 +0,0 @@ -"use strict"; - -const crypto = require("node:crypto"); -const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); -const { putObject, isOssConfigured } = require("../ossClient"); -const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); -const { - isEnterpriseVideoBillingUser, - markEnterpriseVideoCreditsAccepted, - prepareEnterpriseVideoBilling, - refundEnterpriseVideoCredits, - reserveEnterpriseVideoCredits, - calculateEnterpriseVideoCredits, - getEnterpriseVideoCreditRate, -} = require("../enterpriseVideoBilling"); -const { - startPolling, - updateTaskInDb, - extractProviderTaskId, - extractImageUrl, - extractGeminiImageUrl, - extractVideoUrl, - parseKlingCredential, - createKlingJwt, -} = require("../aiTaskWorker"); -const { - buildDashscopeImageSuperResolveBody, - buildDashscopeVideoStyleTransformBody, - normalizeImageUpscaleFactor, - normalizeVideoStyleTransformOptions, -} = require("../aiUpscaleHelpers"); - -const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ - ["gpt-image-2", "1K"], -]); - -const GRSAI_IMAGE_MAX_QUALITY = new Map([ - ["gpt-image-2", "2K"], -]); - -const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ - ["wan2.7-image", "2K"], -]); - -const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; -const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; -const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; -const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 120; -const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 90_000; -const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; -const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; -const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; -const MAX_USER_ACTIVE_GENERATION_TASKS = 3; -const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; - -const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { - "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, - "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, - "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, - "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, - "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, -}; - -function mapAspectRatioToPixels(ratio, quality) { - const q = String(quality || "1K").toUpperCase(); - const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; - return map ? (map[q] || map["1K"]) : "1024x1024"; -} - -function mapAspectRatioToDashscopeSize(ratio, quality) { - return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); -} - -function normalizeQuality(value, fallback = "1K") { - const q = String(value || fallback).trim().toUpperCase(); - if (q === "4K" || q === "2K" || q === "1K") return q; - return fallback; -} - -function clampImageQualityForModel(model, quality) { - const normalized = normalizeQuality(quality, "2K"); - const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); - if (maxQuality === "2K" && normalized === "4K") return "2K"; - if (maxQuality === "1K" && normalized !== "1K") return "1K"; - return normalized; -} - -function clampGrsaiImageQualityForModel(model, quality) { - const normalized = normalizeQuality(quality, "1K"); - const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); - if (maxQuality === "2K" && normalized === "4K") return "2K"; - if (maxQuality === "1K" && normalized !== "1K") return "1K"; - return normalized; -} - -function normalizeDuration(value, min = 4, max = 15, fallback = 5) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return fallback; - return Math.max(min, Math.min(max, Math.round(numeric))); -} - -function normalizeRatio(value, fallback = "16:9") { - const ratio = String(value || fallback).trim(); - return ratio === "auto" ? "adaptive" : ratio; -} - -function normalizeVideoResolution(value, allowed, fallback = "720p") { - const resolution = String(value || "").trim().toLowerCase(); - return allowed.includes(resolution) ? resolution : fallback; -} - -function normalizeS2vResolution(value) { - const resolution = String(value || "").trim().toLowerCase(); - return resolution === "480p" ? "480P" : "720P"; -} - -function normalizeS2vStyle(value) { - const style = String(value || "").trim().toLowerCase(); - return ["speech", "sing", "performance"].includes(style) ? style : "speech"; -} - -function normalizePublicHttpUrl(value) { - const url = String(value || "").trim(); - return /^https?:\/\//i.test(url) ? url : ""; -} - -function percentEncodeRpc(value) { - return encodeURIComponent(String(value)) - .replace(/!/g, "%21") - .replace(/'/g, "%27") - .replace(/\(/g, "%28") - .replace(/\)/g, "%29") - .replace(/\*/g, "%2A"); -} - -function signAliyunRpcParams(method, params, accessKeySecret) { - const canonicalQuery = Object.keys(params) - .sort() - .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) - .join("&"); - const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; - return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); -} - -function getAliyunVideoEnhanCredentials() { - const accessKeyId = - process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || - process.env.ALIYUN_ACCESS_KEY_ID || - process.env.STS_ACCESS_KEY_ID || - ""; - const accessKeySecret = - process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || - process.env.ALIYUN_ACCESS_KEY_SECRET || - process.env.STS_ACCESS_KEY_SECRET || - ""; - return { accessKeyId, accessKeySecret }; -} - -function buildAliyunRpcUrl(action, actionParams = {}) { - const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); - if (!accessKeyId || !accessKeySecret) { - const error = new Error("Aliyun video super-resolution is not configured"); - error.status = 501; - throw error; - } - - const params = { - Action: action, - Version: ALIYUN_VIDEOENHAN_VERSION, - Format: "JSON", - AccessKeyId: accessKeyId, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - SignatureNonce: crypto.randomUUID(), - Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), - ...actionParams, - }; - params.Signature = signAliyunRpcParams("GET", params, accessKeySecret); - - const queryString = Object.entries(params) - .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) - .join("&"); - return `${ALIYUN_VIDEOENHAN_ENDPOINT}?${queryString}`; -} - -function parseAliyunJsonResult(value) { - if (!value) return null; - if (typeof value === "object") return value; - if (typeof value !== "string") return null; - try { - return JSON.parse(value); - } catch { - return null; - } -} - -async function callAliyunRpc(action, params) { - const response = await fetch(buildAliyunRpcUrl(action, params), { method: "GET" }); - const text = await response.text().catch(() => ""); - let json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch { - throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); - } - - if (!response.ok || json.Code || json.code) { - throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); - } - - return json; -} - -function normalizeSuperResolveBitRate(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 10; - return Math.max(1, Math.min(20, Math.round(numeric))); -} - -function normalizeAliyunJobStatus(value) { - return String(value || "").trim().toUpperCase(); -} - -async function ensureDefaultProject(userId) { - const projectId = `web-default-${userId}`; - const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); - if (rows.length === 0) { - const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); - await pool.query( - `INSERT INTO projects ( - id, - user_id, - name, - description, - oss_key, - storyboard_count, - image_count, - video_count, - file_size, - current_revision, - updated_by_device_id, - created_at, - updated_at - ) - VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) - ON CONFLICT (id) DO NOTHING`, - [ - projectId, - userId, - "Default workbench", - "Web fallback project for legacy generation requests", - `users/${safeUserId}/projects/${projectId}/current/project.json`, - ], - ); - } - return projectId; -} - -async function resolveTaskProject(userId, requestedProjectId) { - const projectId = String(requestedProjectId || "").trim().slice(0, 64); - if (!projectId) { - return ensureDefaultProject(userId); - } - - const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ - projectId, - userId, - ]); - if (rows.length === 0) { - const error = new Error("Project not found"); - error.status = 404; - throw error; - } - return projectId; -} - -async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { - if (!client) { - return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); - } - - await assertUserGenerationConcurrencyLimit(userId, client); - const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const { rows: [row] } = await client.query( - `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, - [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], - ); - return row; -} - -async function assertUserGenerationConcurrencyLimit(userId, client = pool) { - await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); - const { rows } = await client.query( - "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", - [userId], - ); - const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); - if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; - - const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); - error.status = 429; - error.code = "GENERATION_CONCURRENCY_LIMIT"; - error.activeCount = activeCount; - error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; - throw error; -} - -async function providerPoolExists(provider) { - if (!provider) return false; - const { rows } = await pool.query( - "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", - [provider], - ); - return rows.length > 0; -} - -function releaseLease(slotResult) { - if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); -} - -function sendAiRouteError(res, err) { - res.status(err.status || 500).json({ - error: err.message, - code: err.code, - activeCount: err.activeCount, - maxActiveTasks: err.maxActiveTasks, - }); -} - -async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } catch (err) { - if (err?.name === "AbortError") { - throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); - } - throw err; - } finally { - clearTimeout(timer); - } -} - -function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { - const raw = String(value || "").trim(); - if (!raw) return fallback; - - let message = raw; - try { - const parsed = JSON.parse(raw); - message = - parsed?.error?.message || - parsed?.error_description || - parsed?.message || - parsed?.error || - raw; - } catch {} - - const compact = String(message).replace(/\s+/g, " ").trim(); - const looksLikeMarkup = - /^]/i.test(compact) || - /^<\?xml/i.test(compact) || - /<\/?[a-z][^>]*>/i.test(compact); - - if (looksLikeMarkup) return fallback; - return compact.slice(0, 320); -} - -function parseTaskParams(value) { - if (!value || typeof value !== "string") return {}; - try { - return JSON.parse(value); - } catch { - return {}; - } -} - -function formatAiTaskRow(row) { - return { - taskId: String(row.id), - projectId: row.project_id, - conversationId: row.conversation_id, - clientQueueId: row.client_queue_id || null, - type: row.type, - status: row.status, - progress: Number(row.progress || 0), - resultUrl: row.result_url || null, - error: row.error || null, - params: parseTaskParams(row.params_json), - createdAt: row.created_at, - updatedAt: row.updated_at, - completedAt: row.completed_at || null, - }; -} - -function extensionFromContentType(contentType, fallbackType) { - const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); - if (mime === "image/jpeg") return "jpg"; - if (mime === "image/png") return "png"; - if (mime === "image/webp") return "webp"; - if (mime === "image/gif") return "gif"; - if (mime === "video/webm") return "webm"; - if (mime === "video/quicktime") return "mov"; - if (mime === "video/mp4") return "mp4"; - return fallbackType === "video" ? "mp4" : "png"; -} - -function contentDispositionFilename(value) { - return String(value || "generated") - .replace(/[\\/:*?"<>|]+/g, "-") - .replace(/[^\x20-\x7e]/g, "") - .trim() - .slice(0, 120) || "generated"; -} - -function isErrorContentType(contentType) { - return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); -} - -function buildDashscopeImageBody(params) { - const content = []; - for (const url of params.referenceUrls || []) { - if (url) content.push({ image: url }); - } - content.push({ text: params.prompt }); - const quality = clampImageQualityForModel(params.model, params.quality); - return { - model: params.model, - input: { - messages: [{ role: "user", content }], - }, - parameters: { - size: mapAspectRatioToDashscopeSize(params.ratio, quality), - n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, - watermark: false, - }, - }; -} - -function buildGrsaiImageBody(params) { - const isGptImage = String(params.model || "").startsWith("gpt-image"); - const modelKey = String(params.model || "").toLowerCase(); - const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); - return isGptImage - ? { - model: params.model, - prompt: params.prompt, - images: params.referenceUrls || [], - aspectRatio: mapAspectRatioToPixels(params.ratio, quality), - replyType: "json", - } - : { - model: params.model, - prompt: params.prompt, - images: params.referenceUrls || [], - aspectRatio: params.ratio || "auto", - imageSize: quality, - replyType: "json", - }; -} - -function buildRightcodeImageBody(providerConfig, params) { - const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; - const quality = normalizeQuality(params.quality, "1K"); - - return { - model: providerConfig.model || params.model, - prompt: params.prompt, - image: referenceUrls, - size: mapAspectRatioToPixels(params.ratio, quality), - response_format: "url", - }; -} - -function getGridCount(gridMode) { - if (gridMode === "grid-4") return 4; - if (gridMode === "grid-9") return 9; - if (gridMode === "grid-25") return 25; - return 1; -} - -function buildGeminiImageBody(params) { - const parts = [{ text: String(params.prompt || "").trim() }]; - const refs = (params.referenceUrls || []).filter(Boolean); - for (const url of refs) { - parts.push({ - fileData: { fileUri: url, mimeType: "image/png" }, - }); - } - const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; - const count = getGridCount(params.gridMode); - if (count > 1) generationConfig.candidateCount = count; - return { - contents: [{ parts }], - generationConfig, - }; -} - -function buildOpenAIImageBody(providerConfig, params) { - const userContent = []; - const prompt = String(params.prompt || "").trim(); - if (prompt) userContent.push({ type: "text", text: prompt }); - const refs = (params.referenceUrls || []).filter(Boolean); - for (const url of refs) { - userContent.push({ type: "image_url", image_url: { url } }); - } - const body = { - model: providerConfig.model || params.model, - messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], - }; - const count = getGridCount(params.gridMode); - if (count > 1) body.n = count; - return body; -} - -function buildImageRequest(providerConfig, params, apiKey) { - const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; - if (providerConfig.transport === "dashscope-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; - } - if (providerConfig.transport === "rightcode-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; - } - if (providerConfig.transport === "gemini-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; - } - if (providerConfig.transport === "openai-image") { - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; - } - return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; -} - -function buildSeedVideoBody(params) { - const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); - const metadata = { - generate_audio: true, - watermark: false, - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 4, 15, 5), - resolution, - }; - const body = { - model: params.model, - prompt: params.prompt, - metadata, - }; - const refs = params.referenceUrls || []; - if (params.frameMode === "start-end" && refs.length >= 2) { - metadata.first_frame_image = refs[0]; - metadata.last_frame_image = refs[refs.length - 1]; - } else if (refs.length === 1) { - body.image = refs[0]; - } else if (refs.length > 1) { - metadata.reference_images = refs; - } - return body; -} - -function buildArkSeedVideoBody(params) { - const content = []; - if (params.prompt) content.push({ type: "text", text: params.prompt }); - - const refs = params.referenceUrls || []; - if (params.frameMode === "start-end" && refs.length >= 2) { - content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); - content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); - } else { - refs.forEach((url, index) => { - content.push({ - type: "image_url", - image_url: { url }, - role: index === 0 ? "first_frame" : "reference_image", - }); - }); - } - - const body = { - model: params.model, - content, - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 4, 15, 5), - generate_audio: true, - watermark: false, - }; - body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); - return body; -} - -function buildWanI2vBody(params) { - const refs = params.referenceUrls || []; - const media = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - media.push({ type: "first_frame", url: refs[0] }); - media.push({ type: "last_frame", url: refs[refs.length - 1] }); - } else if (refs[0]) { - media.push({ type: "first_frame", url: refs[0] }); - } - - const input = { prompt: params.prompt }; - if (media.length) input.media = media; - const requestedResolution = String(params.quality || "").toUpperCase(); - const parameters = { - resolution: requestedResolution === "720P" ? "720P" : "1080P", - ratio: normalizeRatio(params.ratio), - duration: normalizeDuration(params.duration, 3, 15, 5), - watermark: false, - }; - parameters.prompt_extend = true; - - return { - model: params.model, - input, - parameters, - }; -} - -function normalizeHappyHorseResolution(value) { - return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; -} - -function getReferenceImageUrls(params, limit = 9) { - return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) - .map((url) => normalizePublicHttpUrl(url)) - .filter(Boolean) - .slice(0, limit); -} - -function buildHappyHorseBaseParameters(params, { includeRatio }) { - const parameters = { - resolution: normalizeHappyHorseResolution(params.quality), - duration: normalizeDuration(params.duration, 3, 15, 5), - watermark: false, - }; - if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); - return parameters; -} - -function createMissingReferenceError(message) { - const error = new Error(message); - error.status = 400; - return error; -} - -function buildHappyHorseT2vBody(params) { - return { - model: params.model, - input: { - prompt: params.prompt, - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), - }; -} - -function buildHappyHorseI2vBody(params) { - const [firstFrame] = getReferenceImageUrls(params, 1); - if (!firstFrame) { - throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); - } - - return { - model: params.model, - input: { - prompt: params.prompt, - media: [{ type: "first_frame", url: firstFrame }], - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), - }; -} - -function buildHappyHorseR2vBody(params) { - const refs = getReferenceImageUrls(params, 9); - if (!refs.length) { - throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); - } - - return { - model: params.model, - input: { - prompt: params.prompt, - media: refs.map((url) => ({ type: "reference_image", url })), - }, - parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), - }; -} - -function getHappyHorseReferenceError(protocol, referenceUrls) { - if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { - return "HappyHorse I2V requires one first-frame image."; - } - if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { - return "HappyHorse R2V requires 1 to 9 reference images."; - } - return ""; -} - -async function assertWanS2vImageDetected(providerConfig, params, apiKey) { - const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); - if (!imageUrl) { - const error = new Error("Missing imageUrl"); - error.status = 400; - throw error; - } - - const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: providerConfig.detectModel || "wan2.2-s2v-detect", - input: { image_url: imageUrl }, - }), - }); - - const text = await response.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch {} - - if (!response.ok) { - throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); - } - - const output = json && typeof json === "object" ? json.output || json.data || json : {}; - const pass = - output.check_pass === true || - output.checkPass === true || - output.passed === true || - output.pass === true || - String(output.code || "").toLowerCase() === "success"; - - if (!pass) { - const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; - const error = new Error(message); - error.status = 400; - throw error; - } -} - -function extractProviderDetectMessage(output) { - if (!output || typeof output !== "object") return ""; - return String( - output.message || - output.reason || - output.failure_reason || - output.description || - output.error || - "", - ).trim(); -} - -function buildWanS2vBody(params) { - const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); - const audioUrl = normalizePublicHttpUrl(params.audioUrl); - if (!imageUrl) { - const error = new Error("Missing imageUrl"); - error.status = 400; - throw error; - } - if (!audioUrl) { - const error = new Error("Missing audioUrl"); - error.status = 400; - throw error; - } - - const parameters = { - resolution: normalizeS2vResolution(params.quality), - style: normalizeS2vStyle(params.style), - }; - - return { - model: params.model, - input: { - image_url: imageUrl, - audio_url: audioUrl, - }, - parameters, - }; -} - -function buildDashscopeKlingBody(params) { - const refs = params.referenceUrls || []; - const media = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - media.push({ type: "first_frame", url: refs[0] }); - media.push({ type: "last_frame", url: refs[refs.length - 1] }); - } else if (refs[0]) { - media.push({ type: "first_frame", url: refs[0] }); - } - - const input = { prompt: params.prompt }; - if (media.length) input.media = media; - const parameters = { - mode: params.quality === "std" ? "std" : "pro", - duration: normalizeDuration(params.duration, 5, 10, 5), - audio: false, - watermark: false, - }; - if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); - - return { model: params.model, input, parameters }; -} - -function buildKlingOmniBody(params) { - const refs = params.referenceUrls || []; - const imageList = []; - if (params.frameMode === "start-end" && refs.length >= 2) { - imageList.push({ image_url: refs[0], type: "first_frame" }); - imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); - } else if (refs[0]) { - imageList.push({ image_url: refs[0], type: "first_frame" }); - } - - const body = { - model_name: "kling-v3-omni", - mode: params.quality === "std" ? "std" : "pro", - sound: "off", - duration: String(normalizeDuration(params.duration, 3, 15, 5)), - watermark_info: { enabled: false }, - prompt: params.prompt, - }; - if (imageList.length) body.image_list = imageList; - else body.aspect_ratio = normalizeRatio(params.ratio); - return body; -} - -function buildVideoRequest(providerConfig, params, apiKey) { - const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; - let body; - - if (providerConfig.protocol === "seed-video-ark") { - body = buildArkSeedVideoBody(params); - } else if (providerConfig.protocol === "happyhorse-t2v") { - body = buildHappyHorseT2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "happyhorse-i2v") { - body = buildHappyHorseI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "happyhorse-r2v") { - body = buildHappyHorseR2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "wan-i2v") { - body = buildWanI2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "wan-s2v") { - body = buildWanS2vBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "kling-dashscope") { - body = buildDashscopeKlingBody(params); - headers["X-DashScope-Async"] = "enable"; - } else if (providerConfig.protocol === "kling-omni") { - body = buildKlingOmniBody(params); - const credential = parseKlingCredential(apiKey); - if (credential) { - headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; - } - } else { - body = buildSeedVideoBody(params); - } - - return { headers, body }; -} - -function registerAiRoutes(router) { - router.post("/ai/image", requireAuth, async (req, res) => { - const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; - if (!prompt) return res.status(400).json({ error: "Missing prompt" }); - - try { - const providerCandidates = resolveImageProviderCandidates(model); - const primaryProviderConfig = providerCandidates[0]; - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: primaryProviderConfig.model, - requestedModel: primaryProviderConfig.requestedModel, - prompt, - ratio, - quality, - gridMode, - referenceUrls, - }; - const { taskRow, imageBilling } = await withTransaction(async (client) => { - const nextTaskRow = await insertTask( - req.user.id, - projectId, - "image", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - client, - ); - const billingResult = await deductImageGenerationCredits(req.user.id, client, { - taskId: nextTaskRow.id, - model: params.requestedModel || params.model || model, - resolution: [ratio, quality].filter(Boolean).join(" / "), - }); - if (!billingResult.success) { - const error = new Error(billingResult.message || "账户积分不足"); - error.status = 402; - error.code = "INSUFFICIENT_BALANCE"; - error.costCents = billingResult.costCents; - throw error; - } - return { taskRow: nextTaskRow, imageBilling: billingResult }; - }); - const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; - - res.status(202).json({ - taskId: String(taskRow.id), - status: "pending", - imageBilling: { - costCents: imageBilling.costCents, - deductionType: imageBilling.deductionType, - balanceAfterCents: imageBilling.balanceAfterCents, - }, - providerDebug: buildImageProviderDebug(model), - }); - submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { - console.error("[ai/image] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - }); - } catch (err) { - console.error("[ai/image] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/video", requireAuth, async (req, res) => { - const { - model, - prompt, - ratio, - duration, - quality, - frameMode, - referenceUrls, - imageUrl, - audioUrl, - resolution, - muted, - hasReferenceVideo, - style, - projectId: requestedProjectId, - conversationId, - } = req.body; - const providerConfig = resolveVideoProvider(model); - const provider = providerConfig.provider; - const isWanS2v = providerConfig.protocol === "wan-s2v"; - const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); - - if (!isWanS2v && !prompt) return res.status(400).json({ error: "Missing prompt" }); - if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); - if (isWanS2v) { - if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { - return res.status(400).json({ error: "Missing imageUrl" }); - } - if (!normalizePublicHttpUrl(audioUrl)) { - return res.status(400).json({ error: "Missing audioUrl" }); - } - } - - let slotResult = null; - try { - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: providerConfig.model, - requestedModel: providerConfig.requestedModel, - prompt: prompt || "数字人口播视频", - ratio, - duration, - quality: quality || resolution, - resolution: resolution || quality, - frameMode, - referenceUrls, - imageUrl, - audioUrl, - muted: Boolean(muted), - hasReferenceVideo: Boolean(hasReferenceVideo), - style, - }; - - let enterpriseBilling = null; - let preauth = null; - if (isEnterpriseVideoBillingUser(req.user)) { - enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); - preauth = { - authorized: true, - estimatedCostCents: enterpriseBilling.amountCents, - billingMode: "enterprise", - }; - } else { - preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { - const nextTaskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - client, - ); - if (enterpriseBilling) { - const nextBilling = await reserveEnterpriseVideoCredits(client, { - ...enterpriseBilling, - taskId: nextTaskRow.id, - }); - return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; - } - // Regular user: deduct from personal balance - const credits = calculateEnterpriseVideoCredits({ - model: params.model, - resolution: params.resolution || params.quality, - durationSeconds: params.duration, - muted: params.muted, - hasReferenceVideo: params.hasReferenceVideo, - }); - const costCents = Math.ceil(credits * 100); - const { rows: [deducted] } = await client.query( - "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", - [costCents, req.user.id], - ); - if (!deducted) { - throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); - } - await client.query( - "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", - [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], - ); - return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; - }); - - if (reservedBilling) { - params.enterpriseBilling = { - creditLedgerId: reservedBilling.creditLedgerId, - amountCents: reservedBilling.amountCents, - resolution: reservedBilling.resolution, - durationSeconds: reservedBilling.durationSeconds, - rateCentsPerSecond: reservedBilling.rateCentsPerSecond, - }; - await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ - JSON.stringify(params), - taskRow.id, - ]); - } - - res.status(202).json({ - taskId: String(taskRow.id), - status: "pending", - enterpriseBilling: reservedBilling - ? { - creditLedgerId: reservedBilling.creditLedgerId, - amountCents: reservedBilling.amountCents, - enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, - } - : undefined, - }); - const activeSlotResult = slotResult; - slotResult = null; - submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) - .then(async () => { - try { - await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); - } catch (settlementError) { - console.error("[ai/video] enterprise ledger settle error:", settlementError.message); - } - }) - .catch(async (err) => { - console.error("[ai/video] submit error:", err.message); - await updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); - releaseLease(activeSlotResult); - }); - } catch (err) { - releaseLease(slotResult); - console.error("[ai/video] error:", err.message); - if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { - return res.status(err.status || 402).json({ - error: err.message, - code: "INSUFFICIENT_ENTERPRISE_BALANCE", - }); - } - sendAiRouteError(res, err); - } - }); - - router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { - const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); - const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); - const { projectId: requestedProjectId, conversationId } = req.body || {}; - - if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); - - const provider = "dashscope"; - let slotResult; - try { - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { - model: "wanx2.1-imageedit", - operation: "image-super-resolution", - imageUrl, - scale, - }; - const taskRow = await insertTask( - req.user.id, - projectId, - "image", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { - console.error("[ai/image/super-resolve] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - releaseLease(slotResult); - }); - } catch (err) { - if (slotResult) releaseLease(slotResult); - console.error("[ai/image/super-resolve] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { - const videoUrl = String(req.body?.videoUrl || "").trim(); - const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); - const providerMode = String(req.body?.provider || req.body?.model || "").trim(); - const shouldUseDashscopeStyle = - providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; - const { projectId: requestedProjectId, conversationId } = req.body || {}; - - if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); - if (!/^https?:\/\//i.test(videoUrl)) { - return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); - } - - let dashscopeSlotResult; - try { - if (shouldUseDashscopeStyle) { - const provider = "dashscope"; - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - await assertUserGenerationConcurrencyLimit(req.user.id); - dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!dashscopeSlotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const styleOptions = normalizeVideoStyleTransformOptions(req.body); - const params = { - model: "video-style-transform", - operation: "video-style-super-resolution", - videoUrl, - ...styleOptions, - }; - const taskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { - console.error("[ai/video/super-resolve] dashscope submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - releaseLease(dashscopeSlotResult); - }); - return; - } - - const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; - const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; - const taskRow = await insertTask( - req.user.id, - projectId, - "video", - params, - Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, - ); - - res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); - submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { - console.error("[ai/video/super-resolve] submit error:", err.message); - updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); - }); - } catch (err) { - if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); - console.error("[ai/video/super-resolve] error:", err.message); - sendAiRouteError(res, err); - } - }); - - router.post("/ai/chat", requireAuth, async (req, res) => { - const { model, messages, stream = true, temperature } = req.body; - if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); - - const providerConfig = resolveTextProvider(model); - const provider = providerConfig.provider; - let slotResult; - try { - const preauth = await preauthorizeCall(req.user.id, provider); - if (!preauth.authorized) { - return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); - } - - slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); - } - - const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; - const reqHeaders = { - "Content-Type": "application/json", - Authorization: `Bearer ${slotResult.apiKey}`, - }; - const reqBody = JSON.stringify({ - model: providerConfig.model, - messages, - stream, - temperature: temperature || 0.7, - max_tokens: 4096, - }); - - if (stream) { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.flushHeaders(); - - const abortController = new AbortController(); - req.on("close", () => abortController.abort()); - - try { - const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); - if (!upstream.ok) { - const errText = await upstream.text().catch(() => "upstream error"); - res.write( - `data: ${JSON.stringify({ - error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), - done: true, - })}\n\n`, - ); - res.end(); - releaseLease(slotResult); - return; - } - - const reader = upstream.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (payload === "[DONE]") { - res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); - continue; - } - try { - const chunk = JSON.parse(payload); - const delta = chunk.choices?.[0]?.delta?.content || ""; - if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); - } catch {} - } - } - - res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); - res.end(); - releaseLease(slotResult); - } catch (streamErr) { - if (streamErr.name !== "AbortError") { - res.write( - `data: ${JSON.stringify({ - error: sanitizeUpstreamError(streamErr.message), - done: true, - })}\n\n`, - ); - } - res.end(); - releaseLease(slotResult); - } - } else { - const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody }); - const text = await upstream.text().catch(() => ""); - releaseLease(slotResult); - - let json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch { - return res.status(502).json({ - error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), - }); - } - - if (!upstream.ok || json.error) { - return res.status(502).json({ - error: sanitizeUpstreamError( - json.error?.message || json.message || json.error || text, - `文本服务返回 HTTP ${upstream.status}`, - ), - }); - } - - const content = json.choices?.[0]?.message?.content || ""; - const usage = json.usage || {}; - res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); - } - } catch (err) { - releaseLease(slotResult); - console.error("[ai/chat] error:", err.message); - res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks", requireAuth, async (req, res) => { - try { - const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); - const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); - const status = String(req.query.status || "").trim(); - const type = String(req.query.type || "").trim(); - const projectId = String(req.query.projectId || req.query.project_id || "").trim(); - const params = [req.user.id]; - const where = ["user_id = $1"]; - - if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { - params.push(status); - where.push(`status = $${params.length}`); - } - if (["image", "video"].includes(type)) { - params.push(type); - where.push(`type = $${params.length}`); - } - if (projectId) { - params.push(projectId); - where.push(`project_id = $${params.length}`); - } - - params.push(limit, offset); - const { rows } = await pool.query( - ` - SELECT * - FROM generation_tasks - WHERE ${where.join(" AND ")} - ORDER BY updated_at DESC - LIMIT $${params.length - 1} - OFFSET $${params.length} - `, - params, - ); - res.json({ tasks: rows.map(formatAiTaskRow) }); - } catch (err) { - console.error("[ai/tasks] list failed:", err.message); - res.status(500).json({ error: "Failed to load task history" }); - } - }); - - router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { - const taskId = Number(req.params.taskId); - const conversationId = Number(req.body?.conversationId); - - if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { - return res.status(400).json({ error: "Invalid task or conversation id" }); - } - - try { - const { rows: conversationRows } = await pool.query( - "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", - [conversationId, req.user.id], - ); - if (conversationRows.length === 0) { - return res.status(404).json({ error: "Conversation not found" }); - } - - const { rows } = await pool.query( - `UPDATE generation_tasks - SET conversation_id = $1, updated_at = NOW() - WHERE id = $2 AND user_id = $3 - RETURNING id, conversation_id`, - [conversationId, taskId, req.user.id], - ); - if (rows.length === 0) { - return res.status(404).json({ error: "Task not found" }); - } - - res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { - const { taskId } = req.params; - try { - const { rows } = await pool.query( - "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); - - res.json(formatAiTaskRow(rows[0])); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { - const taskId = Number(req.params.taskId); - if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); - - try { - const { rows } = await pool.query( - "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); - res.json({ id: rows[0].id, status: rows[0].status }); - } catch (err) { - console.error("[ai/task-cancel] error:", err.message); - res.status(500).json({ error: "取消任务失败" }); - } - }); - - router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { - const { taskId } = req.params; - try { - const { rows } = await pool.query( - "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", - [taskId, req.user.id], - ); - if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); - - const task = rows[0]; - const resultUrl = String(task.result_url || "").trim(); - if (!/^https?:\/\//i.test(resultUrl)) { - return res.status(400).json({ error: "Task result is not downloadable" }); - } - - const upstream = await fetch(resultUrl, { method: "GET" }); - if (!upstream.ok || !upstream.body) { - return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); - } - - const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); - if (isErrorContentType(contentType)) { - const text = await upstream.text().catch(() => ""); - return res.status(502).json({ - error: text.includes("Expired") || text.includes("AccessDenied") - ? "结果链接已过期,请重新生成后再下载" - : "结果链接返回了错误内容,请重新生成后再下载", - }); - } - const buffer = Buffer.from(await upstream.arrayBuffer()); - if (!buffer.length) { - return res.status(502).json({ error: "Result download returned empty content" }); - } - - const extension = extensionFromContentType(contentType, task.type); - const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); - - res.setHeader("Content-Type", contentType); - res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); - res.setHeader("Content-Length", String(buffer.length)); - res.setHeader("Cache-Control", "no-store"); - res.end(buffer); - } catch (err) { - console.error("[ai/tasks/download] failed:", err.message); - if (!res.headersSent) res.status(500).json({ error: err.message }); - } - }); -} - -async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { - const errors = [...previousErrors]; - const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; - - for (let index = 0; index < candidates.length; index += 1) { - const providerConfig = candidates[index]; - const provider = providerConfig?.provider; - let slotResult = null; - - if (!provider) continue; - - try { - if (index > 0 && !(await providerPoolExists(provider))) { - throw new Error(`${provider} provider pool is not configured`); - } - - slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); - if (!slotResult) { - throw new Error(`${provider} concurrency pool is full`); - } - - await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { - onTaskFailed: async (failureMessage) => { - const providerError = `${provider}: ${failureMessage}`; - const remainingCandidates = candidates.slice(index + 1); - if (remainingCandidates.length === 0) { - await updateTaskInDb(taskDbId, { - status: "failed", - error: `All image providers failed: ${[...errors, providerError].join(" | ")}`, - }); - return true; - } - - console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); - await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); - try { - await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [ - ...errors, - providerError, - ]); - return true; - } catch (fallbackErr) { - await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); - return true; - } - }, - }); - if (index > 0) { - console.info(`[ai/image] task ${taskDbId} switched provider to ${provider}`); - } - return; - } catch (err) { - const message = err?.message || String(err); - errors.push(`${provider}: ${message}`); - console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); - releaseLease(slotResult); - - if (index < candidates.length - 1) { - await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); - } - } - } - - throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); -} - -async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { - const url = getPostUrl(providerConfig); - const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); - - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const submitTimeout = providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; - const response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `图片服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - - // Synchronous transports — extract image URL directly, no polling - if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { - let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); - const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; - console.info( - `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, - ); - if (!directUrl) throw new Error(`${tag} did not return an image url`); - - // Gemini may return base64 data URL — too large for DB, upload to OSS first - if (directUrl.startsWith("data:") && isOssConfigured()) { - const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); - if (match) { - const mimeType = match[1]; - const buffer = Buffer.from(match[2], "base64"); - const ext = mimeType.split("/")[1] || "png"; - const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; - await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); - const bucket = process.env.OSS_BUCKET || ""; - const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); - directUrl = process.env.OSS_PUBLIC_BASE_URL - ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` - : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; - console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); - } - } - - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); - releaseLease(slotResult); - return; - } - - const directUrl = extractImageUrl(json); - - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("Provider did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "image", - providerConfig, - leaseToken: slotResult.leaseToken, - keyManager, - onTaskFailed: options.onTaskFailed, - }); -} - -async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { - const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; - const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); - - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - if (providerConfig.protocol === "wan-s2v") { - await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); - await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); - } - const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractVideoUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("Video provider did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "video", - providerConfig, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const body = buildDashscopeImageSuperResolveBody(params); - const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - Authorization: `Bearer ${slotResult.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractImageUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "image", - providerConfig: { transport: "dashscope-image" }, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); - const body = buildDashscopeVideoStyleTransformBody(params); - const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - Authorization: `Bearer ${slotResult.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch(() => "provider error"); - throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); - } - - const json = await response.json(); - const directUrl = extractVideoUrl(json); - const providerTaskId = extractProviderTaskId(json); - if (directUrl && !providerTaskId) { - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); - releaseLease(slotResult); - return; - } - if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); - - await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); - startPolling(taskDbId, { - providerTaskId, - apiKey: slotResult.apiKey, - type: "video", - providerConfig: { - protocol: "wan-i2v", - baseUrl: "https://dashscope.aliyuncs.com", - }, - leaseToken: slotResult.leaseToken, - keyManager, - }); -} - -async function submitVideoSuperResolveTask(taskDbId, params) { - await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); - const submitResult = await callAliyunRpc("SuperResolveVideo", { - VideoUrl: params.videoUrl, - BitRate: String(params.bitRate || 10), - }); - const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; - if (!jobId) { - throw new Error("Aliyun SuperResolveVideo did not return a job id"); - } - - await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); - - for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); - } - - const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); - const data = result.Data || result.data || {}; - const status = normalizeAliyunJobStatus(data.Status || data.status); - const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); - - if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { - const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; - const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; - if (!videoUrl) { - throw new Error("Aliyun super-resolution completed without a video url"); - } - await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); - return; - } - - if ( - status === "PROCESS_FAILED" || - status === "FAIL" || - status === "FAILED" || - status === "TIMEOUT_FAILED" || - status === "LIMIT_RETRY_FAILED" - ) { - throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); - } - - await updateTaskInDb(taskDbId, { status: "running", progress }); - } - - throw new Error("Aliyun video super-resolution timed out"); -} - -module.exports = { registerAiRoutes }; diff --git a/src/routes/enterprise.js.enterprise-usage-fix.20260526013026 b/src/routes/enterprise.js.enterprise-usage-fix.20260526013026 deleted file mode 100644 index e7f23c3..0000000 --- a/src/routes/enterprise.js.enterprise-usage-fix.20260526013026 +++ /dev/null @@ -1,456 +0,0 @@ -const { - requireAuth, - requireEnterpriseAdmin, - distributeCredits, - getEnterpriseFinancials, - getEnterpriseName, - pool, - getPeriodStart, - generateOrderNo, - clampPositiveInteger, - clampNonNegativeInteger, -} = require("./context"); - -function registerEnterpriseRoutes(router) { - // ── Enterprise: Dashboard & Financials ──────────────────────────────── - - router.get("/enterprise/dashboard", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const financials = await getEnterpriseFinancials(req.user.enterpriseId); - const { - rows: [countRow], - } = await pool.query( - "SELECT COUNT(*) AS count FROM users WHERE enterprise_id = $1 AND enabled = 1 AND is_enterprise_admin = 0", - [req.user.enterpriseId], - ); - - res.json({ - enterpriseName: req.user.enterpriseName, - enterpriseCode: req.user.enterpriseCode, - balanceCents: financials.balanceCents, - activePackages: financials.activePackages, - subAccountCount: Number(countRow?.count || 0), - }); - }); - - router.get("/enterprise/financials", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const financials = await getEnterpriseFinancials(req.user.enterpriseId); - res.json({ - balanceCents: financials.balanceCents, - activePackages: financials.activePackages.map((p) => ({ - id: p.id, - packageName: p.package_name, - remainingImage: p.remaining_image, - remainingVideo: p.remaining_video, - remainingText: p.remaining_text, - expiresAt: p.expires_at, - activatedAt: p.activated_at, - })), - recentTransactions: financials.recentTransactions.map((t) => ({ - id: t.id, - type: t.type, - amountCents: t.amount_cents, - balanceAfterCents: t.balance_after_cents, - description: t.description, - createdAt: t.created_at, - })), - }); - }); - - router.get("/enterprise/usage/summary", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const { period = "30d" } = req.query; - const periodStart = getPeriodStart(period); - const ledgerDateJoin = periodStart ? `AND cl.created_at >= ${periodStart}` : ""; - const ledgerDateWhere = periodStart ? `AND created_at >= ${periodStart}` : ""; - const recordsLimit = clampPositiveInteger(req.query.limit, 50, 200); - - try { - const { - rows: [enterprise], - } = await pool.query( - "SELECT id, name, balance_cents FROM enterprises WHERE id = $1 AND enabled = 1 LIMIT 1", - [req.user.enterpriseId], - ); - if (!enterprise) return res.status(404).json({ error: "企业不存在或已禁用" }); - - const { rows: members } = await pool.query( - ` - SELECT - u.id AS user_id, - u.username, - COALESCE(em.role, CASE WHEN u.is_enterprise_admin = 1 THEN 'admin' ELSE 'employee' END) AS role, - COALESCE(SUM(CASE WHEN cl.status IN ('reserved', 'charged') THEN cl.amount_cents ELSE 0 END), 0) AS used_cents, - COUNT(CASE WHEN cl.status IN ('reserved', 'charged') THEN 1 END) AS task_count, - MAX(cl.created_at) AS last_used_at - FROM users u - LEFT JOIN enterprise_members em ON em.enterprise_id = u.enterprise_id AND em.user_id = u.id - LEFT JOIN credit_ledger cl ON cl.enterprise_id = u.enterprise_id AND cl.user_id = u.id ${ledgerDateJoin} - WHERE u.enterprise_id = $1 AND u.enabled = 1 - GROUP BY u.id, u.username, role - ORDER BY used_cents DESC, u.id ASC - `, - [req.user.enterpriseId], - ); - - const { rows: modelBreakdown } = await pool.query( - ` - SELECT - COALESCE(model, 'unknown') AS model, - COALESCE(SUM(amount_cents), 0) AS used_cents, - COUNT(*) AS task_count - FROM credit_ledger - WHERE enterprise_id = $1 - AND status IN ('reserved', 'charged') - ${ledgerDateWhere} - GROUP BY COALESCE(model, 'unknown') - ORDER BY used_cents DESC - LIMIT 50 - `, - [req.user.enterpriseId], - ); - - const { - rows: [totalRow], - } = await pool.query( - ` - SELECT COALESCE(SUM(amount_cents), 0) AS total_used_cents - FROM credit_ledger - WHERE enterprise_id = $1 - AND status IN ('reserved', 'charged') - ${ledgerDateWhere} - `, - [req.user.enterpriseId], - ); - - const { rows: records } = await pool.query( - ` - SELECT cl.*, u.username - FROM credit_ledger cl - LEFT JOIN users u ON u.id = cl.user_id - WHERE cl.enterprise_id = $1 - AND cl.status IN ('reserved', 'charged') - ${periodStart ? `AND cl.created_at >= ${periodStart}` : ""} - ORDER BY cl.created_at DESC - LIMIT $2 - `, - [req.user.enterpriseId, recordsLimit], - ); - - res.json({ - enterpriseId: String(enterprise.id), - enterpriseName: enterprise.name, - balanceCents: Number(enterprise.balance_cents || 0), - totalUsedCents: Number(totalRow?.total_used_cents || 0), - members: members.map((row) => ({ - userId: Number(row.user_id), - username: row.username, - role: row.role || "employee", - usedCents: Number(row.used_cents || 0), - taskCount: Number(row.task_count || 0), - lastUsedAt: row.last_used_at || null, - })), - modelBreakdown: modelBreakdown.map((row) => ({ - model: row.model, - usedCents: Number(row.used_cents || 0), - taskCount: Number(row.task_count || 0), - })), - records: records.map((row) => ({ - id: String(row.id), - userId: row.user_id == null ? "" : Number(row.user_id), - username: row.username || "", - model: row.model || "", - taskType: row.task_type, - resolution: row.resolution || null, - durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds), - amountCents: Number(row.amount_cents || 0), - status: row.status, - createdAt: row.created_at, - })), - }); - } catch (error) { - console.error("[enterprise/usage/summary] failed", error); - res.status(500).json({ error: "企业用量汇总加载失败" }); - } - }); - - router.get("/enterprise/usage/records", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const limit = clampPositiveInteger(req.query.limit, 50, 200); - const offset = clampNonNegativeInteger(req.query.offset, 0, 100000); - const userId = req.query.userId || req.query.user_id; - const model = String(req.query.model || "").trim(); - const dateFrom = req.query.from || req.query.date_from; - const dateTo = req.query.to || req.query.date_to; - const where = ["cl.enterprise_id = $1"]; - const params = [req.user.enterpriseId]; - - if (userId) { - params.push(userId); - where.push(`cl.user_id = $${params.length}`); - } - if (model) { - params.push(model); - where.push(`cl.model = $${params.length}`); - } - if (dateFrom) { - params.push(`${dateFrom}T00:00:00.000Z`); - where.push(`cl.created_at >= $${params.length}`); - } - if (dateTo) { - params.push(`${dateTo}T23:59:59.999Z`); - where.push(`cl.created_at <= $${params.length}`); - } - - const whereSql = `WHERE ${where.join(" AND ")}`; - - try { - const { - rows: [countRow], - } = await pool.query(`SELECT COUNT(*) AS total FROM credit_ledger cl ${whereSql}`, params); - const { rows } = await pool.query( - ` - SELECT cl.*, u.username - FROM credit_ledger cl - LEFT JOIN users u ON u.id = cl.user_id - ${whereSql} - ORDER BY cl.created_at DESC - LIMIT $${params.length + 1} - OFFSET $${params.length + 2} - `, - [...params, limit, offset], - ); - - res.json({ - items: rows.map((row) => ({ - id: String(row.id), - userId: row.user_id == null ? "" : Number(row.user_id), - username: row.username || "", - model: row.model || "", - taskType: row.task_type, - resolution: row.resolution || null, - durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds), - amountCents: Number(row.amount_cents || 0), - status: row.status, - createdAt: row.created_at, - })), - total: Number(countRow?.total || 0), - limit, - offset, - }); - } catch (error) { - console.error("[enterprise/usage/records] failed", error); - res.status(500).json({ error: "企业用量记录加载失败" }); - } - }); - - router.post("/enterprise/recharge", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const { amountCents, paymentMethod = "wechat" } = req.body; - if (!amountCents || amountCents <= 0) - return res.status(400).json({ error: "充值金额必须大于0" }); - - const orderNo = generateOrderNo(); - const enterpriseName = await getEnterpriseName(req.user.enterpriseId); - - try { - await pool.query( - ` - INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, payment_method, status) - VALUES ($1, $2, $3, 'recharge', $4, $5, 'pending') - `, - [orderNo, req.user.enterpriseId, enterpriseName, Number(amountCents), paymentMethod], - ); - - res.json({ orderNo, amountCents, paymentMethod }); - } catch (error) { - console.error("[enterprise/recharge] failed", error); - res.status(500).json({ error: "创建充值订单失败" }); - } - }); - - router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const { userId, amountCents, distributions } = req.body; - - try { - if (distributions && Array.isArray(distributions)) { - for (const d of distributions) { - if (!d.userId || !d.amountCents || d.amountCents <= 0) { - return res - .status(400) - .json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" }); - } - await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id); - } - res.json({ success: true, count: distributions.length }); - } else if (userId && amountCents) { - if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); - const result = await distributeCredits( - req.user.enterpriseId, - userId, - amountCents, - req.user.id, - ); - res.json({ success: true, ...result }); - } else { - return res.status(400).json({ error: "缺少分发参数" }); - } - } catch (error) { - console.error("[enterprise/distribute] failed", error); - res.status(400).json({ error: "分发参数处理失败" }); - } - }); - - router.get( - "/enterprise/employee-consumption", - requireAuth, - requireEnterpriseAdmin, - async (req, res) => { - const { period = "30d" } = req.query; - const periodStart = getPeriodStart(period); - const whereClause = periodStart ? `AND acl.created_at >= ${periodStart}` : ""; - - const { rows } = await pool.query( - ` - SELECT - u.id AS user_id, - u.username, - u.balance_cents AS current_balance_cents, - COUNT(acl.id) AS total_calls, - COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents, - MAX(acl.created_at) AS last_active - FROM users u - LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success' - WHERE u.enterprise_id = $1 AND u.enabled = 1 AND u.is_enterprise_admin = 0 ${whereClause} - GROUP BY u.id, u.username, u.balance_cents - ORDER BY total_cost_cents DESC - `, - [req.user.enterpriseId], - ); - - res.json( - rows.map((r) => ({ - userId: Number(r.user_id), - username: r.username, - currentBalanceCents: Number(r.current_balance_cents), - totalCalls: Number(r.total_calls), - totalCostCents: Number(r.total_cost_cents), - lastActive: r.last_active, - })), - ); - }, - ); - - router.post( - "/enterprise/purchase-package", - requireAuth, - requireEnterpriseAdmin, - async (req, res) => { - const { packageId, paymentMethod = "wechat" } = req.body; - if (!packageId) return res.status(400).json({ error: "缺少套餐ID" }); - - const { - rows: [pkg], - } = await pool.query("SELECT * FROM packages WHERE id = $1 AND enabled = 1", [packageId]); - if (!pkg) return res.status(404).json({ error: "套餐不存在或已下架" }); - - const orderNo = generateOrderNo(); - const enterpriseName = await getEnterpriseName(req.user.enterpriseId); - - try { - await pool.query( - ` - INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, package_id, payment_method, status) - VALUES ($1, $2, $3, 'package', $4, $5, $6, 'pending') - `, - [ - orderNo, - req.user.enterpriseId, - enterpriseName, - pkg.price_cents, - packageId, - paymentMethod, - ], - ); - - res.json({ orderNo, amountCents: pkg.price_cents, packageId, paymentMethod }); - } catch (error) { - console.error("[enterprise/purchase-package] failed", error); - res.status(500).json({ error: "创建套餐订单失败" }); - } - }, - ); - - // ── Enterprise: Invoices ────────────────────────────────────────────── - - router.post( - "/enterprise/invoice-apply", - requireAuth, - requireEnterpriseAdmin, - async (req, res) => { - const { paymentOrderId, type = "general", title, taxNo } = req.body; - if (!title) return res.status(400).json({ error: "缺少发票抬头" }); - - let amountCents = 0; - if (paymentOrderId) { - const { - rows: [order], - } = await pool.query( - "SELECT * FROM payment_orders WHERE id = $1 AND enterprise_id = $2 AND status = $3", - [paymentOrderId, req.user.enterpriseId, "paid"], - ); - if (!order) return res.status(404).json({ error: "支付订单不存在或未支付" }); - amountCents = order.amount_cents; - } - - const enterpriseName = await getEnterpriseName(req.user.enterpriseId); - - try { - const { - rows: [row], - } = await pool.query( - ` - INSERT INTO invoices (enterprise_id, enterprise_name, payment_order_id, type, title, tax_no, amount_cents, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending') - RETURNING id - `, - [ - req.user.enterpriseId, - enterpriseName, - paymentOrderId || null, - type, - title, - taxNo || null, - amountCents, - ], - ); - - res.json({ id: row.id, success: true }); - } catch (error) { - console.error("[enterprise/invoice-apply] failed", error); - res.status(500).json({ error: "申请发票失败" }); - } - }, - ); - - router.get("/enterprise/invoices", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const { rows } = await pool.query( - "SELECT * FROM invoices WHERE enterprise_id = $1 ORDER BY id DESC", - [req.user.enterpriseId], - ); - res.json( - rows.map((row) => ({ - id: Number(row.id), - type: row.type, - title: row.title, - taxNo: row.tax_no, - amountCents: row.amount_cents, - status: row.status, - invoiceNo: row.invoice_no, - invoiceUrl: row.invoice_url, - issuedAt: row.issued_at, - createdAt: row.created_at, - })), - ); - }); -} - -module.exports = { - registerEnterpriseRoutes, -};