import { createServer } from "node:http"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { createHash, randomBytes } from "node:crypto"; import { adapters } from "../src/data/sampleProject.js"; import { createProjectShell } from "../src/data/projectShell.js"; import { buildAllExports, buildModelRequest } from "../src/lib/exporters.js"; import { projectQa } from "../src/lib/qa.js"; import { dbAll, dbGet, dbRun, databaseInfo, withTransaction } from "./db.mjs"; import { addAudit, addUsage, acceptInvitation, bindAssetToShot, buildContextPayload, createAsset, createAssetVersion, ensureOrganizationEntitlements, getAsset, httpError, orgMembers, organizationEntitlements, organizationRolePolicies, updateOrganizationRolePolicy, previewInvitation, registerInvitedUser, parseModelRow, pendingInvitations, userInvitations, projectMembers, requirePermission, resolveContext, scopedAuditLog, scopedAssets, scopedModels, systemSettings, featureFlags, hasPermission, requireApiScope, normalizeApiClientScopes, API_CLIENT_SCOPE_CATALOG, notificationChannels, apiClients, identityCenter, publicIdentityProviders, createModelCatalogEntry, createModelRoute, updateIdentityPolicy, updateModelCatalogEntry, updateModelRoute, saveIdentityProvider, probeIdentityProvider, createDirectorySync, updateDirectorySync, rotateDirectorySyncToken, systemUserDetail, systemUsers, createSystemUser, resetSystemUserPassword, resetSystemUserMfa, updateSystemUserMemberships, updateSystemUser, scimListUsers, scimCreateUser, scimPatchUser, scimDeleteUser, serviceHealth, scanAssetGovernance, updateAssetLock, updateAssetRights, listAssetGovernanceReviews, restoreAssetVersion, updateServiceHealth, usageSummary, organizationUsage, exportOrganizationUsage, organizationCommercial, exportOrganizationCommercial, updateOrganizationBilling, updateQuotaAllocation, updateCostCenter, organizationInvoices, organizationInvoice, exportOrganizationInvoices, generateOrganizationInvoice, updateOrganizationInvoiceStatus, assertOrganizationSeatAvailable, resendOrganizationInvitation, revokeOrganizationInvitation, workspaceMembers, listAuditEvents, getAuditEvent, exportAuditEvents, scopedModelCatalog, scopedModelRoutes, subscriptionPlanTemplates, createSubscriptionPlanTemplate, updateSubscriptionPlanTemplate, updateOrganizationEntitlement, requireEntitlement, listCommercialApprovalRequests, createCommercialApprovalRequest, decideCommercialApprovalRequest } from "./tenant.mjs"; import { platformData, platformResearchMatrix } from "../src/platform/platformData.js"; import { authMode, authenticate, cancelMfaSetup, changePassword, completeMfaChallenge, completeMfaEnrollment, createMfaChallenge, createMfaEnrollmentChallenge, createSession, disableMfa, enableMfa, listSecurityEvents, listUserDevices, listUserSessions, mfaRequiredForUser, mfaStatus, recordSecurityEvent, revokeAllUserSessions, revokeOtherUserSessions, revokeSession, revokeUserSession, safeUser, sessionIdentity, startMfaEnrollment, startMfaSetup, trustUserDevice, untrustUserDevice } from "./auth.mjs"; import { issueApiClientKey, apiClientStorageMarker } from "./api-client-secrets.mjs"; import { addReviewComment, activateDeliveryBatch, approveDelivery, createDelivery, createDeliveryChannel, createDeliveryBatch, createDeliveryRelease, createEpisode, createSeason, createShot, decideReview, getDeliveryClearance, importScript, listDeliveryBatches, listDeliveryChannels, listDeliveries, listDeliveryReleases, listShotVersions, materializeScript, productionGraph, qaReviews, runAutomatedQa, runMediaQa, restoreShotVersion, rollbackDeliveryBatch, runDeliveryClearance, publishDeliveryRelease, decideDeliveryRelease, savePromptVersion, updateBible, updateDeliveryChannel, updateEpisode, updateJob, updateShot } from "./production.mjs"; import { createGenerationJob, previewGenerationJob, executeGenerationJob, getGenerationJob, listGenerationJobs, decideModelRouteApproval, endpointInfo, listModelRouteApprovalRequests, previewModelRouteResolution, probeModelConnector, requestModelRouteApproval, updateModelConnector } from "./execution.mjs"; import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSummary } from "./storage.mjs"; import { backupSummary, createDatabaseBackup } from "./backup.mjs"; import { systemReadiness } from "./readiness.mjs"; import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.mjs"; import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs"; import { composeProject, listCompositions } from "./composition.mjs"; import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs"; import { runWorkerOnce, startLocalWorker, workerStatus } from "./worker.mjs"; import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin } from "./oidc.mjs"; import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs"; import { listWorkItems } from "./work-items.mjs"; import { createWorkflowTemplate, instantiateWorkflow, listWorkflowRuns, listWorkflowTemplates, updateWorkflowTemplate } from "./workflows.mjs"; import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs"; import { searchPlatform } from "./search.mjs"; import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs"; import { createDeliveryAccessLink, listDeliveryAccessFeedback, listDeliveryAccessLinks, readPublicDeliveryFile, resolvePublicDeliveryPortal, revokeDeliveryAccessLink, submitPublicDeliveryFeedback } from "./delivery-portal.mjs"; import { archiveKnowledgeDocument, createKnowledgeContextPack, getKnowledgeContextPack, getKnowledgeDocument, importKnowledgeDocument, listKnowledgeDocumentVersions, listKnowledgeContextPacks, listKnowledgeDocuments, materializeKnowledgeContextPack, materializeKnowledgeDocument, restoreKnowledgeDocument, restoreKnowledgeDocumentVersion, reviewKnowledgeDocument, updateKnowledgeDocument, searchKnowledge } from "./knowledge.mjs"; const port = Number(process.env.AI_DRAMA_API_PORT || 8787); const root = resolve(import.meta.dirname, ".."); const exportBaseRoot = resolve(root, "exports"); const frontendOrigin = String(process.env.AI_DRAMA_FRONTEND_ORIGIN || "http://127.0.0.1:5173").replace(/\/$/, ""); const apiOrigin = String(process.env.AI_DRAMA_API_ORIGIN || `http://127.0.0.1:${port}`).replace(/\/$/, ""); const oidcRedirectUri = String(process.env.AI_DRAMA_OIDC_REDIRECT_URI || `${apiOrigin}/api/auth/sso/callback`); const samlAcsUri = String(process.env.AI_DRAMA_SAML_ACS_URI || `${apiOrigin}/api/auth/sso/saml/acs`); const MAX_ASSET_BYTES = 12 * 1024 * 1024; const MAX_JSON_BODY_BYTES = 20 * 1024 * 1024; function responseHeaders(res, extra = {}) { return { ...(res.rateLimitHeaders || {}), ...extra }; } function send(res, status, value, headers = {}) { const body = JSON.stringify(value, null, 2); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "content-type,authorization,x-session-token,x-user-id,x-organization-id,x-workspace-id,x-project-id,x-device-id,x-device-label", ...responseHeaders(res, headers) }); res.end(body); } function sendText(res, status, body, contentType = "text/plain; charset=utf-8", headers = {}) { const payload = Buffer.from(String(body || ""), "utf8"); res.writeHead(status, { "content-type": contentType, "content-length": payload.length, "cache-control": "no-store", "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "content-type,authorization,x-session-token,x-user-id,x-organization-id,x-workspace-id,x-project-id,x-device-id,x-device-label", ...responseHeaders(res, headers) }); res.end(payload); } function auditContextParams(url) { const params = new URLSearchParams(url.searchParams); for (const key of ["organizationId", "workspaceId", "projectId"]) params.delete(key); return params; } function auditOptions(url) { return Object.fromEntries(url.searchParams.entries()); } function csvCell(value) { const text = typeof value === "object" && value !== null ? JSON.stringify(value) : String(value ?? ""); return `"${text.replaceAll('"', '""').replaceAll("\n", " ").replaceAll("\r", " " )}"`; } function auditCsv(events = []) { const columns = ["id", "created_at", "organization_name", "workspace_name", "project_name", "actor_name", "actor_email", "action", "target_type", "target_id", "result", "metadata"]; const rows = [columns, ...events.map((event) => columns.map((column) => event[column]))]; return rows.map((row) => row.map(csvCell).join(",")).join("\n") + "\n"; } function usageCsv(items = []) { const columns = ["id", "created_at", "workspace_id", "workspace_name", "project_id", "project_name", "user_id", "user_name", "user_email", "kind", "units", "unit_name", "estimated_cost", "metadata"]; const rows = [columns, ...items.map((item) => [ item.id, item.createdAt, item.workspaceId, item.workspaceName, item.projectId, item.projectName, item.userId, item.userName, item.userEmail, item.kind, item.units, item.unitName, item.estimatedCost, item.metadata ])]; return rows.map((row) => row.map(csvCell).join(",")).join("\n") + "\n"; } function invoiceCsv(items = []) { const columns = ["id", "invoice_number", "status", "currency", "billing_cycle", "period_start", "period_end", "issued_at", "due_at", "paid_at", "subtotal", "tax_rate", "tax_amount", "total_amount", "created_at", "updated_at"]; const rows = [columns, ...items.map((item) => [ item.id, item.invoiceNumber, item.status, item.currency, item.billingCycle, item.periodStart, item.periodEnd, item.issuedAt, item.dueAt, item.paidAt, item.subtotal, item.taxRate, item.taxAmount, item.totalAmount, item.createdAt, item.updatedAt ])]; return rows.map((row) => row.map(csvCell).join(",")).join("\n") + "\n"; } function sendBinary(res, status, buffer, contentType, fileName = "asset", headers = {}) { res.writeHead(status, { "content-type": contentType || "application/octet-stream", "content-length": buffer.length, "cache-control": "no-store", "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`, "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "content-type,authorization,x-session-token,x-user-id,x-organization-id,x-workspace-id,x-project-id,x-device-id,x-device-label", ...responseHeaders(res, headers) }); res.end(buffer); } async function readBody(req) { const chunks = []; let totalBytes = 0; for await (const chunk of req) { totalBytes += chunk.length; if (totalBytes > MAX_JSON_BODY_BYTES) throw httpError(413, "request_too_large", "JSON 请求体不能超过 20MB"); chunks.push(chunk); } if (!chunks.length) return {}; const raw = Buffer.concat(chunks).toString("utf8"); try { return JSON.parse(raw); } catch { throw httpError(400, "invalid_json", "请求体不是有效 JSON"); } } async function readFormBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); const raw = Buffer.concat(chunks).toString("utf8"); const contentType = String(req.headers["content-type"] || "").toLowerCase(); if (contentType && !contentType.startsWith("application/x-www-form-urlencoded")) { throw httpError(415, "saml_acs_content_type_invalid", "SAML ACS 只接受 application/x-www-form-urlencoded POST"); } return Object.fromEntries(new URLSearchParams(raw).entries()); } function parseStoredJson(value, fallback) { try { return JSON.parse(value); } catch { return fallback; } } function bearerToken(req) { const header = Array.isArray(req.headers.authorization) ? req.headers.authorization[0] : req.headers.authorization; const match = String(header || "").match(/^Bearer\s+(.+)$/i); return match?.[1]?.trim() || ""; } function configuredApiRateLimit() { const row = dbGet("SELECT value_json FROM system_settings WHERE key = 'api.rate_limit_per_minute'"); const value = Number(parseStoredJson(row?.value_json, 120)); if (!Number.isFinite(value)) return 120; return Math.min(100000, Math.max(0, Math.floor(value))); } function enforceApiRateLimit(req, res, pathname) { if (req.method === "OPTIONS" || !pathname.startsWith("/api/") || pathname === "/api/health") return; const limit = configuredApiRateLimit(); const decision = consumeRateLimit({ key: rateLimitIdentity({ token: bearerToken(req), ipAddress: req.socket.remoteAddress }), limit }); if (!decision) return; res.rateLimitHeaders = rateLimitHeaders(decision); if (!decision.allowed) { const error = httpError(429, "rate_limit_exceeded", "请求过于频繁,请稍后再试", { limit: decision.limit, remaining: decision.remaining, resetAt: new Date(decision.resetAt).toISOString(), retryAfter: decision.retryAfter }); error.headers = rateLimitHeaders(decision); throw error; } } function safeReturnTo(value) { const candidate = String(value || "/").trim(); if (!candidate.startsWith("/") || candidate.startsWith("//") || candidate.includes("\r") || candidate.includes("\n")) return "/"; return candidate; } function ssoFrontendRedirect(returnTo, params = {}) { const target = new URL(safeReturnTo(returnTo), frontendOrigin); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== "") target.searchParams.set(key, String(value)); } return target.toString(); } function redirect(res, location) { res.writeHead(302, { location, "cache-control": "no-store", "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "content-type,authorization,x-session-token,x-device-id,x-device-label", ...responseHeaders(res) }); res.end(); } function selectionParams(selection = {}) { const params = new URLSearchParams(); for (const key of ["organizationId", "workspaceId", "projectId"]) if (selection[key]) params.set(key, selection[key]); return params; } function requestAuthMetadata(req) { return { ipAddress: req.socket.remoteAddress, userAgent: req.headers["user-agent"], deviceId: req.headers["x-device-id"], deviceLabel: req.headers["x-device-label"] }; } function loginResponseForSession(session, selection = {}, auditContext = null, auditAction = "auth.login") { const authHeaders = { authorization: `Bearer ${session.token}` }; const identity = sessionIdentity(authHeaders); const context = resolveContext(authHeaders, selectionParams(selection)); if (auditContext) addAudit({ context, action: auditAction, targetType: "user", targetId: identity.user.id, metadata: { sessionExpiresAt: session.expiresAt } }); return { session, user: identity.user, context: platformPayload(context).context }; } function contextWith(params, req) { const values = params instanceof URLSearchParams ? Object.fromEntries(params.entries()) : params; const headers = { ...req.headers }; const headerNames = { userId: "x-user-id", organizationId: "x-organization-id", workspaceId: "x-workspace-id", projectId: "x-project-id" }; for (const [key, headerName] of Object.entries(headerNames)) { if (Object.prototype.hasOwnProperty.call(values, key)) headers[headerName] = values[key] ?? ""; } return resolveContext(headers, new URLSearchParams()); } function projectForRecord(record) { return createProjectShell(record); } function seriesPayload(shell, row = {}) { return { ...shell, id: row.id || shell.id, title: row.title || shell.title, format: row.format || shell.format, logline: row.logline ?? shell.logline, visualStyle: row.visual_style ?? row.visualStyle ?? shell.visualStyle, continuityRule: row.continuity_rule ?? row.continuityRule ?? shell.continuityRule, showEngine: row.show_engine ?? row.showEngine ?? shell.showEngine }; } function episodePayload(shell, row = {}) { return { ...shell, id: row.id || shell.id, seasonId: row.season_id || row.seasonId || shell.seasonId, episodeNumber: row.episode_number ?? row.episodeNumber ?? shell.episodeNumber, title: row.title || shell.title, status: row.status || shell.status, targetDurationSec: Number(row.target_duration_sec ?? row.targetDurationSec ?? shell.targetDurationSec ?? 0), hook: row.hook ?? shell.hook, cliffhanger: row.cliffhanger ?? shell.cliffhanger }; } function seasonPayload(row = {}) { if (!row?.id) return null; return { ...row, id: row.id, seriesId: row.series_id || row.seriesId || "", seasonNumber: Number(row.season_number || row.seasonNumber || 1), title: row.title || "第一季" }; } function projectForContext(context) { const project = projectForRecord(context.project); if (!context.project) return project; const graph = productionGraph(context); const jobs = jobsForProject(context.project.id); const latestDocument = graph.documents?.[0]; const analysis = latestDocument?.analysis || {}; const series = seriesPayload(project.series, graph.series); const episode = episodePayload(project.episode, graph.episode); const qaEvidence = (graph.reviews || []).map((review) => { const evidence = review.evidence || {}; const result = review.status === "approved" ? "pass" : review.status === "changes_requested" ? "fail" : "warn"; const blockers = Array.isArray(evidence.blockers) ? evidence.blockers.filter(Boolean) : []; return { id: review.id, label: evidence.label || review.lane, result, evidence: blockers.length ? blockers.join(";") : evidence.detail || `审核状态:${review.status || "pending"}` }; }); const ledger = [ { time: episode.id || "project", item: "系列连续性规则", state: series.continuityRule }, ...(graph.shots || []).flatMap((shot) => { const continuity = shot.continuity || {}; const entries = Object.entries(continuity).filter(([, value]) => value !== undefined && value !== null && value !== ""); return entries.length ? entries.map(([item, state]) => ({ time: shot.id, item, state: typeof state === "string" ? state : JSON.stringify(state) })) : [{ time: shot.id, item: "镜头状态", state: `${shot.firstFrame || "episode-start"} → ${shot.lastFrame || "pending-actual-last-frame"}` }]; }) ]; const next = { ...project, series, season: seasonPayload(graph.season), episode, scriptStudio: { ...project.scriptStudio, chapters: Array.isArray(analysis.chapters) ? analysis.chapters : [], extracted: Array.isArray(analysis.extracted) ? analysis.extracted : [] }, shots: graph.shots || [], characters: graph.characters || [], locations: graph.locations || [], props: graph.props || [], productionJobs: jobs, qaEvidence, ledger, pipeline: project.pipeline.map((item) => { const related = jobs.filter((job) => item.id === "video" ? job.kind.includes("视频") : item.id === "voice" ? job.kind.includes("TTS") || job.kind.includes("配音") : item.id === "qa" ? job.kind.includes("QA") || job.kind.includes("ASR") : false); const completed = related.filter((job) => job.status === "completed").length; return related.length ? { ...item, progress: Math.round((completed / related.length) * 100), status: completed === related.length ? "ready" : "running" } : item; }) }; return next; } function jobsForProject(projectId) { return dbAll("SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at DESC", [projectId]).map((job) => ({ id: job.id, kind: job.kind, shotId: job.shot_id || "E01", adapter: job.adapter_id, status: job.status, costPolicy: job.cost_policy, output: job.output_path, qa: job.qa_status, errorMessage: job.error_message || "", result: parseStoredJson(job.result_json, {}), startedAt: job.started_at, finishedAt: job.finished_at })); } function scopedProjectPayload(context) { const project = projectForContext(context); const jobs = context.project ? jobsForProject(context.project.id) : []; project.productionJobs = jobs; return { project, jobs }; } function runtimeRunners() { return serviceHealth().filter((service) => service.kind === "runner").map((service) => ({ id: service.service_key, serviceKey: service.service_key, name: service.label, status: service.status, queueDepth: Number(service.queue_depth || 0), lastHeartbeat: service.last_heartbeat || "not-connected", endpoint: service.endpoint, version: service.version, metadata: service.metadata })); } function platformPayload(context) { const payload = buildContextPayload(context); const usage = payload.platform.usage; const models = payload.platform.modelRegistry; const canViewRuntime = context.systemAdmin || hasPermission(context, "queue:manage"); const canViewUsage = Boolean(usage); const canViewCompliance = context.systemAdmin || hasPermission(context, "usage:view") || hasPermission(context, "compliance:manage") || hasPermission(context, "audit:view"); const runners = canViewRuntime ? runtimeRunners() : []; const queueDepth = runners.reduce((sum, runner) => sum + runner.queueDepth, 0); return { ...payload, platform: { ...payload.platform, plan: payload.platform.billing, runnerHealth: runners, reviewLanes: canViewCompliance ? platformData.reviewLanes : [], compliancePolicies: canViewCompliance ? platformData.compliancePolicies : [], costCenters: canViewUsage ? platformData.costCenters : [], assetStorage: canViewUsage ? platformData.assetStorage : [], researchMatrix: platformResearchMatrix }, summary: { organization: context.organization, workspace: context.workspace, activeProjects: context.projects.filter((item) => item.status === "production").length, modelCount: models.length, runnerCount: runners.length, queueDepth, budget: { used: usage?.totalCost ?? null, total: Number(payload.platform.billing?.monthly_clip_quota || 0) || null }, complianceBlockers: canViewCompliance ? platformData.compliancePolicies.filter((item) => item.status !== "enforced").length : null, permissions: context.permissions } }; } function requirePathMatches(actual, expected, label) { if (actual !== expected) throw httpError(403, "scope_mismatch", `${label}不属于当前上下文`, { actual, expected }); } function createId(prefix) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } function decodeAssetData(body = {}) { let encoded = String(body.data || "").trim().replace(/\s+/g, ""); const dataUri = encoded.match(/^data:[^,;]+;base64,(.*)$/i); if (dataUri) encoded = dataUri[1]; if (!encoded) throw httpError(400, "asset_data_required", "本地资产内容不能为空"); if (encoded.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { throw httpError(400, "asset_data_invalid", "资产内容不是有效的 Base64 文件数据"); } const buffer = Buffer.from(encoded, "base64"); const canonical = buffer.toString("base64").replace(/=+$/, ""); if (!buffer.length || canonical !== encoded.replace(/=+$/, "")) { throw httpError(400, "asset_data_invalid", "资产内容不是有效的 Base64 文件数据"); } if (buffer.length > MAX_ASSET_BYTES) { throw httpError(413, "asset_too_large", "单次本地资产上传不能超过 12MB", { maxBytes: MAX_ASSET_BYTES, actualBytes: buffer.length }); } return buffer; } function batchQueueAction(context, body = {}) { requirePermission(context, "queue:manage"); const action = String(body.action || "").trim(); if (!["retry", "cancel", "priority"].includes(action)) throw httpError(400, "batch_job_action_invalid", "批量任务动作只能是 retry、cancel 或 priority"); const jobIds = [...new Set((Array.isArray(body.jobIds) ? body.jobIds : []).map((id) => String(id || "").trim()).filter(Boolean))].slice(0, 100); if (!jobIds.length) throw httpError(400, "job_ids_required", "至少选择一个任务"); const results = []; for (const jobId of jobIds) { const job = dbGet("SELECT id, project_id FROM generation_jobs WHERE id = ? AND organization_id = ? AND workspace_id = ?", [jobId, context.organization.id, context.workspace.id]); if (!job) { results.push({ jobId, ok: false, error: "job_not_found" }); continue; } try { const jobContext = { ...context, project: dbGet("SELECT * FROM projects WHERE id = ? AND workspace_id = ?", [job.project_id, context.workspace.id]) }; results.push({ jobId, ok: true, ...updateJob(jobContext, jobId, action, { priority: body.priority }) }); } catch (error) { results.push({ jobId, ok: false, error: error.code || "batch_action_failed", detail: error.message }); } } addAudit({ context, action: `generation_job.batch_${action}`, targetType: "generation_job_batch", targetId: `batch-${Date.now()}`, metadata: { jobIds, successCount: results.filter((item) => item.ok).length, failureCount: results.filter((item) => !item.ok).length } }); return { action, results, successCount: results.filter((item) => item.ok).length, failureCount: results.filter((item) => !item.ok).length }; } async function writeJson(exportRoot, relativePath, value) { const target = resolve(exportRoot, relativePath); await mkdir(resolve(target, ".."), { recursive: true }); await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, "utf8"); return target; } async function writeExports(context) { const project = projectForContext(context); const bundle = buildAllExports(project); const manifest = [ ["shots/shot-list.json", bundle.shotList], ["shots/prompt-pack.json", bundle.promptPack], ["voices/voice-lines.json", bundle.voiceTable], ["edit/edit-list.json", bundle.editList], ["qa/qa-results.json", bundle.qaResults], ["bible/series-bible.json", project.series], ["characters/character-locks.json", project.characters], ["locations/location-locks.json", project.locations], ["props/prop-locks.json", project.props] ]; const exportRoot = resolve(exportBaseRoot, context.project.id); const files = []; for (const [path, value] of manifest) files.push(await writeJson(exportRoot, path, value)); addAudit({ context, action: "exports.write", targetType: "project", targetId: context.project.id, metadata: { exportRoot } }); return { files, exportRoot }; } function createOrganization(context, body) { requirePermission(context, "organization:manage"); const name = String(body.name || "").trim(); if (!name) throw httpError(400, "name_required", "组织名称不能为空"); const slug = String(body.slug || name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")).trim() || `org-${Date.now()}`; const organizationId = body.id || createId("org"); const workspaceId = createId("ws"); const timestamp = new Date().toISOString(); withTransaction(() => { dbRun("INSERT INTO organizations(id, name, slug, owner_user_id, deployment_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'private-local', 'active', ?, ?)", [organizationId, name, slug, context.user.id, timestamp, timestamp]); dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, 'org_owner', 'active', ?, ?, ?)", [createId("om"), organizationId, context.user.id, timestamp, timestamp, timestamp]); dbRun("INSERT INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)", [workspaceId, organizationId, body.workspaceName || "主生产空间", "main", body.workspaceDescription || "", timestamp, timestamp]); dbRun("INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'producer', 'active', ?, ?)", [createId("wm"), workspaceId, context.user.id, timestamp, timestamp]); dbRun("INSERT INTO billing_accounts(id, organization_id, plan_name, seat_limit, storage_gb, monthly_clip_quota, local_runner_only, cloud_connectors_require_approval, created_at, updated_at) VALUES (?, ?, 'Studio Local', 12, 1024, 2400, 1, 1, ?, ?)", [createId("bill"), organizationId, timestamp, timestamp]); dbRun("INSERT INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'clip', 2400, 0, 'clips', datetime('now', 'start of month'), datetime('now', 'start of month', '+1 month', '-1 second'), ?, ?)", [createId("quota"), organizationId, workspaceId, timestamp, timestamp]); dbRun("INSERT INTO audit_logs(id, organization_id, workspace_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, 'organization.created', 'organization', ?, 'ok', ?, ?)", [createId("aud"), organizationId, workspaceId, context.user.id, organizationId, JSON.stringify({ createdFrom: context.organization.id }), timestamp]); }); ensureOrganizationEntitlements(organizationId); return dbGet("SELECT * FROM organizations WHERE id = ?", [organizationId]); } function updateOrganization(context, organizationId, body) { requirePathMatches(context.organization.id, organizationId, "组织"); requirePermission(context, "organization:manage"); const current = dbGet("SELECT * FROM organizations WHERE id = ? AND status = 'active'", [organizationId]); if (!current) throw httpError(404, "organization_not_found", "组织不存在或已停用"); const name = String(body.name ?? current.name).trim(); const slug = String(body.slug ?? current.slug).trim(); const deploymentMode = String(body.deploymentMode ?? current.deployment_mode).trim(); if (!name || !slug) throw httpError(400, "organization_fields_required", "组织名称和标识不能为空"); if (!/^[a-z0-9][a-z0-9-]{1,63}$/i.test(slug)) throw httpError(400, "organization_slug_invalid", "组织标识只能包含字母、数字和短横线"); const timestamp = new Date().toISOString(); dbRun("UPDATE organizations SET name = ?, slug = ?, deployment_mode = ?, updated_at = ? WHERE id = ?", [name, slug, deploymentMode, timestamp, organizationId]); addAudit({ context, action: "organization.updated", targetType: "organization", targetId: organizationId, metadata: { previous: { name: current.name, slug: current.slug, deploymentMode: current.deployment_mode }, next: { name, slug, deploymentMode } } }); return dbGet("SELECT * FROM organizations WHERE id = ?", [organizationId]); } function createWorkspace(context, body) { requirePermission(context, "workspace:create"); requireEntitlement(context, "limit.workspaces", 1); const name = String(body.name || "").trim(); if (!name) throw httpError(400, "name_required", "工作区名称不能为空"); const id = body.id || createId("ws"); const slug = String(body.slug || name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")).trim() || id; const timestamp = new Date().toISOString(); withTransaction(() => { dbRun("INSERT INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)", [id, context.organization.id, name, slug, body.description || "", timestamp, timestamp]); dbRun("INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'producer', 'active', ?, ?)", [createId("wm"), id, context.user.id, timestamp, timestamp]); dbRun("INSERT INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'clip', 2400, 0, 'clips', datetime('now', 'start of month'), datetime('now', 'start of month', '+1 month', '-1 second'), ?, ?)", [createId("quota"), context.organization.id, id, timestamp, timestamp]); }); addAudit({ context: { ...context, workspace: { id } }, action: "workspace.created", targetType: "workspace", targetId: id, metadata: { name } }); return dbGet("SELECT * FROM workspaces WHERE id = ?", [id]); } const PROJECT_STATUS_TRANSITIONS = { draft: new Set(["draft", "production", "paused", "review", "archived"]), production: new Set(["production", "paused", "review", "archived"]), paused: new Set(["paused", "production", "review", "archived"]), review: new Set(["review", "production", "paused", "archived"]), template: new Set(["template", "draft", "production", "archived"]), archived: new Set(["archived", "draft", "production", "paused", "review", "template"]) }; function projectLifecycleStatus(current, action) { const normalized = String(action || "").trim(); if (!normalized) return current.status; if (normalized === "archive") return "archived"; if (normalized === "restore") return current.archived_from_status && current.archived_from_status !== "archived" ? current.archived_from_status : "draft"; if (normalized === "pause") return "paused"; if (normalized === "resume" || normalized === "activate") return "production"; if (normalized === "submit-review") return "review"; throw httpError(400, "project_lifecycle_action_invalid", "项目生命周期动作无效", { action: normalized }); } function updateWorkspace(context, workspaceId, body) { requirePathMatches(context.workspace.id, workspaceId, "工作区"); requirePermission(context, "workspace:manage"); const current = dbGet("SELECT * FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, context.organization.id]); if (!current) throw httpError(404, "workspace_not_found", "工作区不存在或不属于当前组织"); const name = String(body.name ?? current.name).trim(); const slug = String(body.slug ?? current.slug).trim(); const description = String(body.description ?? current.description ?? "").trim(); const status = String(body.status ?? current.status).trim(); if (!name || !slug) throw httpError(400, "workspace_fields_required", "工作区名称和标识不能为空"); if (!/^[a-z0-9][a-z0-9-]{1,63}$/i.test(slug)) throw httpError(400, "workspace_slug_invalid", "工作区标识只能包含字母、数字和短横线"); if (!["active", "archived"].includes(status)) throw httpError(400, "workspace_status_invalid", "工作区状态无效"); const timestamp = new Date().toISOString(); dbRun("UPDATE workspaces SET name = ?, slug = ?, description = ?, status = ?, updated_at = ? WHERE id = ?", [name, slug, description, status, timestamp, workspaceId]); addAudit({ context, action: "workspace.updated", targetType: "workspace", targetId: workspaceId, metadata: { previous: { name: current.name, slug: current.slug, status: current.status }, next: { name, slug, status } } }); return dbGet("SELECT * FROM workspaces WHERE id = ?", [workspaceId]); } function createProject(context, body) { requirePermission(context, "project:create"); requireEntitlement(context, "limit.projects", 1); const name = String(body.name || "").trim(); if (!name) throw httpError(400, "name_required", "项目名称不能为空"); const id = body.id || createId("project"); const templateId = String(body.templateId || "ai-manhua-drama").trim() || "ai-manhua-drama"; const initialStatus = ["draft", "production", "template"].includes(String(body.status || "")) ? String(body.status) : "draft"; const timestamp = new Date().toISOString(); withTransaction(() => { dbRun("INSERT INTO projects(id, workspace_id, name, type, template_id, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'workspace', 0, 'low', ?, ?)", [id, context.workspace.id, name, body.type || "AI 漫剧", templateId, initialStatus, context.user.id, timestamp, timestamp]); dbRun("INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", [createId("pm"), id, context.user.id, timestamp, timestamp]); }); const project = dbGet("SELECT * FROM projects WHERE id = ?", [id]); productionGraph({ ...context, project }); addAudit({ context: { ...context, project }, action: "project.created", targetType: "project", targetId: id, metadata: { name, type: body.type || "AI 漫剧", templateId, status: initialStatus, productionRootInitialized: true } }); return project; } function updateProject(context, projectId, body) { requirePathMatches(context.project?.id, projectId, "项目"); requirePermission(context, "project:manage"); const current = dbGet("SELECT * FROM projects WHERE id = ? AND workspace_id = ?", [projectId, context.workspace.id]); if (!current) throw httpError(404, "project_not_found", "项目不存在或不属于当前工作区"); const lifecycleAction = String(body.lifecycleAction || "").trim(); const name = String(body.name ?? current.name).trim(); const type = String(body.type ?? current.type).trim(); const templateId = String(body.templateId ?? current.template_id ?? "ai-manhua-drama").trim() || "ai-manhua-drama"; const status = lifecycleAction ? projectLifecycleStatus(current, lifecycleAction) : String(body.status ?? current.status).trim(); const visibility = String(body.visibility ?? current.visibility).trim(); const readiness = Number(body.readiness ?? current.readiness); const risk = String(body.risk ?? current.risk).trim(); if (!name || !type) throw httpError(400, "project_fields_required", "项目名称和类型不能为空"); if (!["draft", "production", "paused", "review", "archived", "template"].includes(status)) throw httpError(400, "project_status_invalid", "项目状态无效"); if (!["workspace", "private"].includes(visibility)) throw httpError(400, "project_visibility_invalid", "项目可见范围无效"); if (!Number.isInteger(readiness) || readiness < 0 || readiness > 100) throw httpError(400, "project_readiness_invalid", "项目准备度必须是 0 到 100 的整数"); if (!["low", "medium", "high"].includes(risk)) throw httpError(400, "project_risk_invalid", "项目风险级别无效"); if (status !== current.status && !PROJECT_STATUS_TRANSITIONS[current.status]?.has(status)) { throw httpError(409, "project_status_transition_invalid", `项目不能从 ${current.status} 直接切换到 ${status}`, { from: current.status, to: status }); } if (status === "archived" && current.status !== "archived") { const activeJobs = dbAll("SELECT id, status FROM generation_jobs WHERE project_id = ? AND status IN ('queued', 'running', 'blocked') ORDER BY created_at", [projectId]); if (activeJobs.length) throw httpError(409, "project_has_active_jobs", "项目仍有未完成生成任务,请先处理队列后再归档", { jobs: activeJobs }); } const timestamp = new Date().toISOString(); const archivedAt = status === "archived" ? (current.archived_at || timestamp) : null; const archivedBy = status === "archived" ? (current.archived_by || context.user.id) : null; const archivedFromStatus = status === "archived" ? (current.archived_from_status || current.status) : null; dbRun("UPDATE projects SET name = ?, type = ?, template_id = ?, status = ?, visibility = ?, readiness = ?, risk = ?, archived_at = ?, archived_by = ?, archived_from_status = ?, updated_at = ? WHERE id = ?", [name, type, templateId, status, visibility, readiness, risk, archivedAt, archivedBy, archivedFromStatus, timestamp, projectId]); addAudit({ context, action: lifecycleAction ? `project.lifecycle.${lifecycleAction}` : "project.updated", targetType: "project", targetId: projectId, metadata: { previous: current, next: { name, type, templateId, status, visibility, readiness, risk, archivedAt, archivedBy, archivedFromStatus }, lifecycleAction: lifecycleAction || null } }); return dbGet("SELECT * FROM projects WHERE id = ?", [projectId]); } async function uploadAsset(context, body) { const fileName = String(body.fileName || "asset.bin").replace(/[^\w.\-\u4e00-\u9fa5]/g, "_").slice(0, 120) || "asset.bin"; const assetId = createId("asset"); const relativePath = `storage/assets/${context.project.id}/${assetId}/v1/${fileName}`; const targetPath = resolve(root, relativePath); const buffer = decodeAssetData(body); const contentSha256 = createHash("sha256").update(buffer).digest("hex"); await requireStorageQuota(context, buffer.length); await mkdir(resolve(targetPath, ".."), { recursive: true }); await writeFile(targetPath, buffer); return createAsset(context, { ...body, id: assetId, name: body.name || fileName, storagePath: relativePath, size: buffer.length, fileSize: buffer.length, fileName, contentSha256, mimeType: body.mimeType || "application/octet-stream" }); } async function uploadAssetVersion(context, assetId, body) { requirePermission(context, "asset:edit"); const asset = getAsset(context, assetId); if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目"); const fileName = String(body.fileName || asset.currentVersion?.file_name || "asset.bin").replace(/[^\w.\-\u4e00-\u9fa5]/g, "_").slice(0, 120) || "asset.bin"; const buffer = decodeAssetData(body); const contentSha256 = createHash("sha256").update(buffer).digest("hex"); await requireStorageQuota(context, buffer.length); const nextVersion = Math.max(0, ...asset.versions.map((version) => Number(version.version_number || 0))) + 1; const relativePath = `storage/assets/${context.project.id}/${assetId}/v${nextVersion}/${fileName}`; const targetPath = resolve(root, relativePath); await mkdir(resolve(targetPath, ".."), { recursive: true }); await writeFile(targetPath, buffer); return createAssetVersion(context, assetId, { storagePath: relativePath, fileName, fileSize: buffer.length, size: buffer.length, contentSha256, mimeType: body.mimeType || "application/octet-stream", rightsStatus: body.rightsStatus || "needs-evidence", versionNote: body.versionNote || "本地文件版本上传", provenance: body.provenance, risk: body.risk, tags: body.tags, licenseScope: body.licenseScope, expiresAt: body.expiresAt, metadata: body.metadata }); } function safeStorageTarget(storagePath) { const relativePath = String(storagePath || "").replace(/^[/\\]+/, ""); if (!relativePath || relativePath.includes("..")) throw httpError(400, "storage_path_invalid", "资产路径不是项目目录内的安全相对路径"); const target = resolve(root, relativePath); if (target !== root && !target.startsWith(`${root}/`)) throw httpError(400, "storage_path_invalid", "资产路径越过项目目录"); return { target, relativePath }; } function mimeForPath(pathname) { const extension = pathname.toLowerCase().split(".").pop(); return { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", svg: "image/svg+xml", wav: "audio/wav", mp3: "audio/mpeg", m4a: "audio/mp4", mp4: "video/mp4", webm: "video/webm", json: "application/json", txt: "text/plain; charset=utf-8" }[extension] || "application/octet-stream"; } async function assetContent(context, assetId) { if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) { throw httpError(403, "permission_denied", "当前角色没有资产预览权限"); } const asset = getAsset(context, assetId); if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目"); const version = asset.currentVersion; if (!version?.storage_path) throw httpError(404, "asset_content_missing", "当前资产版本没有文件路径"); const { target } = safeStorageTarget(version.storage_path); let content; try { content = await readFile(target); } catch (error) { if (error.code === "ENOENT") throw httpError(404, "asset_content_missing", "资产文件尚未写入本地存储", { storagePath: version.storage_path }); throw error; } const metadata = version.metadata || {}; return { content, contentType: version.mime_type || metadata.mimeType || mimeForPath(target), fileName: version.file_name || target.split("/").pop() || asset.name, contentSha256: version.content_sha256 || createHash("sha256").update(content).digest("hex"), fileSize: Number(version.file_size || content.length) }; } async function verifyAssetContent(context, assetId) { if (!hasPermission(context, "asset:edit") && !hasPermission(context, "compliance:manage")) { requirePermission(context, "asset:edit"); } const asset = getAsset(context, assetId); if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目"); const version = asset.currentVersion; if (!version?.storage_path) throw httpError(404, "asset_content_missing", "当前资产版本没有文件路径"); const { target } = safeStorageTarget(version.storage_path); let content; try { content = await readFile(target); } catch (error) { if (error.code === "ENOENT") throw httpError(404, "asset_content_missing", "资产文件尚未写入本地存储", { storagePath: version.storage_path }); throw error; } const actualHash = createHash("sha256").update(content).digest("hex"); const expectedHash = version.content_sha256 || ""; const verified = Boolean(expectedHash) && expectedHash === actualHash && Number(version.file_size || content.length) === content.length; addAudit({ context, action: "asset.content.verified", targetType: "asset_version", targetId: version.id, result: verified ? "pass" : "error", metadata: { expectedHash, actualHash, expectedSize: Number(version.file_size || 0), actualSize: content.length } }); return { verified, assetId, versionId: version.id, expectedHash, actualHash, expectedSize: Number(version.file_size || 0), actualSize: content.length }; } function assistantQuery(context, body) { const question = String(body.question || "").trim(); if (!question) throw httpError(400, "question_required", "助手问题不能为空"); const project = projectForContext(context); const normalized = question.toLowerCase(); let answer = "已读取当前项目的剧本、资产锁、分镜、任务和交付数据。当前平台不会生成多格画面,不会跳过实际末帧,也不会把未确认声线直接用于批量配音。"; let suggestedTabs = ["director", "jobs"]; if (normalized.includes("连续") || normalized.includes("衔接") || normalized.includes("末帧")) { answer = `当前 ${project.shots.length} 个镜头中,${project.shots.filter((shot) => shot.transitionFromPrevious === "actual-last-frame").length} 个镜头声明使用上一段实际末帧;请在审片中心补齐真实视频末帧证据后再交付。`; suggestedTabs = ["qa", "director"]; } else if (normalized.includes("声音") || normalized.includes("配音") || normalized.includes("字幕") || normalized.includes("asr")) { answer = `当前共有 ${project.shots.flatMap((shot) => shot.voiceLines).length} 句对白;角色正式参考音频仍需用户确认,建议先做单句试听,再用 ASR 生成中文词级时间轴。`; suggestedTabs = ["voice-studio", "jobs"]; } else if (normalized.includes("交付") || normalized.includes("导出")) { const ready = project.deliverables.filter((item) => item.status === "ready").length; answer = `当前交付清单已有 ${ready}/${project.deliverables.length} 项 ready;剪辑清单、最终视频工程和真实 QA 证据仍是交付前置条件。`; suggestedTabs = ["export", "qa"]; } else if (normalized.includes("资产") || normalized.includes("角色") || normalized.includes("场景") || normalized.includes("道具")) { answer = `当前项目包含 ${project.characters.length} 个角色、${project.locations.length} 个场景和 ${project.props.length} 个道具;生成时应从资产库绑定版本,不要只把文字写在 prompt 里。`; suggestedTabs = ["asset-library", "casting"]; } addAudit({ context, action: "assistant.query", targetType: "project", targetId: context.project?.id || "none", metadata: { question, mode: "local-rule-assistant" } }); return { mode: "local-rule-assistant", answer, suggestedTabs, evidence: { projectId: context.project?.id || null, shotCount: project.shots.length, ledgerCount: project.ledger.length } }; } function createInvitation(context, organizationId, body) { requirePathMatches(context.organization.id, organizationId, "组织"); requirePermission(context, "organization:members:invite"); const email = String(body.email || "").trim().toLowerCase(); if (!email || !email.includes("@")) throw httpError(400, "email_required", "请输入有效邮箱"); if (dbGet( `SELECT om.user_id FROM organization_members om JOIN users u ON u.id = om.user_id WHERE om.organization_id = ? AND lower(u.email) = lower(?) AND om.status = 'active'`, [organizationId, email] )) throw httpError(409, "invitation_recipient_already_member", "该邮箱已经是组织成员", { email }); if (dbGet("SELECT id FROM invitations WHERE organization_id = ? AND lower(email) = lower(?) AND status = 'pending'", [organizationId, email])) { throw httpError(409, "invitation_already_pending", "该邮箱已有待处理邀请,请重新发送或先撤销旧邀请", { email }); } assertOrganizationSeatAvailable(organizationId, { email }); const workspaceId = body.workspaceId || null; if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, organizationId])) throw httpError(400, "workspace_invalid", "邀请目标工作区不属于当前组织"); const projectId = body.projectId || null; if (projectId && !workspaceId) throw httpError(400, "workspace_required_for_project_invitation", "项目级邀请必须同时指定所属工作区"); if (projectId && !dbGet("SELECT id FROM projects WHERE id = ? AND workspace_id = ?", [projectId, workspaceId])) throw httpError(400, "project_invalid", "邀请目标项目不属于当前工作区"); const invitationScope = projectId ? "project" : workspaceId ? "workspace" : "organization"; const defaultRole = invitationScope === "project" ? "project_editor" : invitationScope === "workspace" ? "writer" : "org_member"; const requestedRoleKey = String(body.roleKey || defaultRole).trim(); const role = roleForScope(requestedRoleKey, invitationScope); if (invitationScope === "organization" && role.key === "org_owner") throw httpError(400, "owner_invitation_not_allowed", "组织所有者不能通过普通邀请授予,请在成员管理中完成所有者转移"); const roleKey = role.key; const id = createId("invite"); const inviteToken = `invite-${randomBytes(24).toString("base64url")}`; const tokenHash = createHash("sha256").update(inviteToken).digest("hex"); const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString(); const createdAt = new Date().toISOString(); dbRun("INSERT INTO invitations(id, organization_id, workspace_id, project_id, email, role_key, invited_by, status, expires_at, created_at, token_hash, token_hint) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)", [id, organizationId, workspaceId, projectId, email, roleKey, context.user.id, expiresAt, createdAt, tokenHash, inviteToken.slice(0, 14)]); addAudit({ context, action: "organization.invitation.created", targetType: "invitation", targetId: id, metadata: { email, roleKey, workspaceId, projectId } }); const existingRecipient = dbGet("SELECT id FROM users WHERE lower(email) = lower(?) AND status = 'active'", [email]); void dispatchNotificationEvent({ context, eventKey: "invitation.created", payload: { invitationId: id, organizationName: context.organization.name, roleKey, roleName: dbGet("SELECT name FROM roles WHERE key = ?", [roleKey])?.name || roleKey, workspaceId, projectId, targetId: id, recipientUserId: existingRecipient?.id || "" } }); return { ...dbGet("SELECT id, organization_id, workspace_id, project_id, email, role_key, invited_by, status, expires_at, created_at, token_hint FROM invitations WHERE id = ?", [id]), inviteToken, acceptUrl: `/register?invite=${encodeURIComponent(inviteToken)}` }; } function roleForScope(roleKey, scope) { const role = dbGet("SELECT key, scope, name FROM roles WHERE key = ? AND scope = ?", [roleKey, scope]); if (!role) throw httpError(400, "role_invalid", "角色不存在或不适用于当前层级", { roleKey, scope }); return role; } function updateOrganizationMember(context, organizationId, userId, body) { requirePathMatches(context.organization.id, organizationId, "组织"); requirePermission(context, "organization:manage"); const member = dbGet("SELECT * FROM organization_members WHERE organization_id = ? AND user_id = ?", [organizationId, userId]); if (!member) throw httpError(404, "member_not_found", "组织成员不存在"); const roleKey = String(body.roleKey || member.role_key); const status = String(body.status || member.status); roleForScope(roleKey, "organization"); if (!["active", "suspended"].includes(status)) throw httpError(400, "member_status_invalid", "成员状态无效"); const ownerCount = Number(dbGet("SELECT COUNT(*) AS count FROM organization_members WHERE organization_id = ? AND role_key = 'org_owner' AND status = 'active'", [organizationId])?.count || 0); if (member.role_key === "org_owner" && (roleKey !== "org_owner" || status !== "active") && ownerCount <= 1) { throw httpError(400, "last_owner_protected", "组织必须至少保留一名活跃组织所有者"); } const timestamp = new Date().toISOString(); dbRun("UPDATE organization_members SET role_key = ?, status = ?, updated_at = ? WHERE organization_id = ? AND user_id = ?", [roleKey, status, timestamp, organizationId, userId]); addAudit({ context, action: "organization.member.updated", targetType: "organization_member", targetId: `${organizationId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousStatus: member.status, status } }); return { members: orgMembers(organizationId) }; } function updateWorkspaceMember(context, workspaceId, userId, body) { requirePathMatches(context.workspace.id, workspaceId, "工作区"); requirePermission(context, "workspace:members:manage"); const member = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspaceId, userId]); if (!member) throw httpError(404, "member_not_found", "工作区成员不存在"); const roleKey = String(body.roleKey || member.role_key); const accessMode = String(body.accessMode || member.access_mode || "all"); const status = String(body.status || member.status); roleForScope(roleKey, "workspace"); if (!["all", "project-only"].includes(accessMode)) throw httpError(400, "workspace_access_mode_invalid", "工作区访问范围只能是 all 或 project-only"); if (!["active", "suspended"].includes(status)) throw httpError(400, "member_status_invalid", "成员状态无效"); const timestamp = new Date().toISOString(); dbRun("UPDATE workspace_members SET role_key = ?, access_mode = ?, status = ?, updated_at = ? WHERE workspace_id = ? AND user_id = ?", [roleKey, accessMode, status, timestamp, workspaceId, userId]); addAudit({ context, action: "workspace.member.updated", targetType: "workspace_member", targetId: `${workspaceId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousAccessMode: member.access_mode || "all", accessMode, previousStatus: member.status, status } }); return { members: workspaceMembers(workspaceId) }; } function updateProjectMember(context, projectId, userId, body) { requirePathMatches(context.project?.id, projectId, "项目"); requirePermission(context, "project:members:manage"); const member = dbGet("SELECT * FROM project_members WHERE project_id = ? AND user_id = ?", [projectId, userId]); if (!member) throw httpError(404, "member_not_found", "项目成员不存在"); const roleKey = String(body.roleKey || member.role_key); const status = String(body.status || member.status); roleForScope(roleKey, "project"); if (!["active", "suspended"].includes(status)) throw httpError(400, "member_status_invalid", "成员状态无效"); const timestamp = new Date().toISOString(); dbRun("UPDATE project_members SET role_key = ?, status = ?, updated_at = ? WHERE project_id = ? AND user_id = ?", [roleKey, status, timestamp, projectId, userId]); addAudit({ context, action: "project.member.updated", targetType: "project_member", targetId: `${projectId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousStatus: member.status, status } }); return { members: projectMembers(projectId) }; } function registerModel(context, body) { requirePermission(context, "model:manage"); requireEntitlement(context, "limit.model_connectors", 1); const id = body.id || createId("model"); const timestamp = new Date().toISOString(); const label = String(body.label || "未命名本地模型").trim(); const endpoint = String(body.endpoint || "http://127.0.0.1:7860").trim(); const costMode = String(body.costMode || "local").trim(); const kind = String(body.kind || "http-json").trim(); if (!label || !endpoint) throw httpError(400, "adapter_fields_required", "连接器名称和地址不能为空"); if (!["local", "mixed", "cloud"].includes(costMode)) throw httpError(400, "cost_mode_invalid", "成本策略无效"); const { local } = endpointInfo(endpoint); if (costMode === "local" && !local) throw httpError(403, "local_only_endpoint_required", "local-only 连接器只能指向本机或私有局域网地址"); const requestedApproval = Boolean(body.approvalRequired); if (costMode !== "local" && !requestedApproval) throw httpError(400, "external_connector_approval_required", "混合或外部连接器必须开启审批策略"); const approvalRequired = costMode === "local" ? requestedApproval : true; if (costMode !== "local") requireEntitlement(context, "feature.external_cloud_connectors", 1); if (kind === "comfyui") requireEntitlement(context, "feature.comfyui_adapter", 1); const protocol = body.protocol && typeof body.protocol === "object" ? body.protocol : {}; const authEnv = String(body.authEnv || protocol.authEnv || "").trim(); dbRun("INSERT INTO model_connectors(id, organization_id, workspace_id, label, kind, capabilities_json, endpoint, status, cost_mode, approval_required, protocol_json, auth_env, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'not-connected', ?, ?, ?, ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, label, kind, JSON.stringify(body.capability || ["custom"]), endpoint, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, context.user.id, timestamp, timestamp]); addAudit({ context, action: "model.registered", targetType: "model_connector", targetId: id, metadata: { label, endpoint, costMode, approvalRequired } }); return parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [id])); } function systemConfigPayload() { return { settings: systemSettings(), featureFlags: featureFlags(), planTemplates: subscriptionPlanTemplates({ includeArchived: true }), notifications: notificationChannels(), notificationDeliveries: [], apiClients: apiClients(), apiClientScopes: API_CLIENT_SCOPE_CATALOG }; } function updateSystemConfig(context, body) { requirePermission(context, "system:settings:edit"); const updates = Array.isArray(body.settings) ? body.settings : [body]; const changed = []; const timestamp = new Date().toISOString(); withTransaction(() => { for (const item of updates) { const key = String(item.key || "").trim(); if (!key || !/^[-a-z0-9_.]+$/i.test(key)) throw httpError(400, "setting_key_invalid", "系统配置键名无效", { key }); const existing = dbGet("SELECT * FROM system_settings WHERE key = ?", [key]); if (!existing) throw httpError(404, "setting_not_found", "系统配置不存在", { key }); const value = item.value; if (value === undefined) throw httpError(400, "setting_value_required", "系统配置值不能为空", { key }); dbRun("UPDATE system_settings SET value_json = ?, updated_by = ?, updated_at = ? WHERE key = ?", [JSON.stringify(value), context.user.id, timestamp, key]); changed.push({ key, previous: JSON.parse(existing.value_json), value }); addAudit({ context, action: "system.setting.updated", targetType: "system_setting", targetId: key, metadata: { previous: JSON.parse(existing.value_json), value } }); } }); return { changed, ...systemConfigPayload() }; } function updateFeatureFlag(context, body) { requirePermission(context, "feature_flag:manage"); const key = String(body.key || "").trim(); const current = dbGet("SELECT * FROM feature_flags WHERE key = ?", [key]); if (!current) throw httpError(404, "feature_flag_not_found", "功能开关不存在", { key }); const enabled = Boolean(body.enabled); const timestamp = new Date().toISOString(); dbRun("UPDATE feature_flags SET enabled = ?, updated_by = ?, updated_at = ? WHERE key = ?", [enabled ? 1 : 0, context.user.id, timestamp, key]); addAudit({ context, action: "system.feature_flag.updated", targetType: "feature_flag", targetId: key, metadata: { previous: Boolean(current.enabled), enabled } }); return featureFlags(); } function updateNotificationChannel(context, body) { requirePermission(context, "notification:manage"); const timestamp = new Date().toISOString(); const id = body.id || `notify-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const existing = dbGet("SELECT * FROM notification_channels WHERE id = ?", [id]); const name = String(body.name ?? existing?.name ?? "未命名通知渠道").trim(); const kind = String(body.kind ?? existing?.kind ?? "webhook").trim(); const endpoint = String(body.endpoint ?? existing?.endpoint ?? "").trim(); const enabled = body.enabled === undefined ? Boolean(existing?.enabled) : Boolean(body.enabled); const events = Array.isArray(body.events) ? body.events : parseStoredJson(existing?.events_json || "[]", []); const secretRef = String(body.secretRef ?? existing?.secret_ref ?? "").trim(); const values = [ name, kind, endpoint, enabled ? 1 : 0, JSON.stringify(events), secretRef, context.user.id, timestamp ]; if (existing) { dbRun("UPDATE notification_channels SET name = ?, kind = ?, endpoint = ?, enabled = ?, events_json = ?, secret_ref = ?, updated_at = ? WHERE id = ?", [...values.slice(0, 6), timestamp, id]); } else { dbRun("INSERT INTO notification_channels(id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, ...values.slice(0, 7), timestamp, timestamp]); } addAudit({ context, action: existing ? "system.notification.updated" : "system.notification.created", targetType: "notification_channel", targetId: id, metadata: { kind: values[1], enabled: Boolean(values[3]) } }); return notificationChannels(); } function createApiClient(context, body) { requirePermission(context, "api_client:manage"); requireEntitlement(context, "limit.api_clients", 1); const timestamp = new Date().toISOString(); const id = `client-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const issued = issueApiClientKey(); const name = String(body.name || "本地 API 客户端").trim(); const scopeResult = normalizeApiClientScopes(body.scopes); if (scopeResult.invalid.length) throw httpError(400, "api_client_scopes_invalid", "API 客户端包含不支持的 scope", { invalidScopes: scopeResult.invalid, allowedScopes: API_CLIENT_SCOPE_CATALOG.map((scope) => scope.key) }); const scopes = scopeResult.scopes; dbRun("INSERT INTO api_clients(id, name, client_key, client_key_hash, client_key_prefix, key_version, organization_id, workspace_id, status, scopes_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 2, ?, ?, 'active', ?, ?, ?, ?)", [id, name, apiClientStorageMarker(issued.hash), issued.hash, issued.prefix, context.organization.id, context.workspace.id, JSON.stringify(scopes), context.user.id, timestamp, timestamp]); addAudit({ context, action: "system.api_client.created", targetType: "api_client", targetId: id, metadata: { scopes } }); return { client: dbGet("SELECT id, name, status, scopes_json, created_by, created_at, updated_at FROM api_clients WHERE id = ?", [id]), clientKey: issued.secret, apiClients: apiClients() }; } function rotateApiClientKey(context, clientId) { requirePermission(context, "api_client:manage"); const current = dbGet("SELECT * FROM api_clients WHERE id = ?", [clientId]); if (!current) throw httpError(404, "api_client_not_found", "API 客户端不存在"); const issued = issueApiClientKey(); const timestamp = new Date().toISOString(); dbRun("UPDATE api_clients SET client_key = ?, client_key_hash = ?, client_key_prefix = ?, key_version = 2, status = 'active', updated_at = ? WHERE id = ?", [apiClientStorageMarker(issued.hash), issued.hash, issued.prefix, timestamp, clientId]); addAudit({ context, action: "system.api_client.key_rotated", targetType: "api_client", targetId: clientId, metadata: { previousStatus: current.status, status: "active" } }); return { client: apiClients().find((item) => item.id === clientId), clientKey: issued.secret, apiClients: apiClients() }; } function updateApiClient(context, clientId, body) { requirePermission(context, "api_client:manage"); const current = dbGet("SELECT * FROM api_clients WHERE id = ?", [clientId]); if (!current) throw httpError(404, "api_client_not_found", "API 客户端不存在"); const status = String(body.status ?? current.status).trim(); const name = String(body.name ?? current.name).trim(); if (!["active", "revoked", "suspended"].includes(status)) throw httpError(400, "api_client_status_invalid", "API 客户端状态无效"); if (!name) throw httpError(400, "api_client_name_required", "API 客户端名称不能为空"); const currentScopes = parseStoredJson(current.scopes_json, ["jobs:read"]); const scopeResult = normalizeApiClientScopes(body.scopes === undefined ? currentScopes : body.scopes, currentScopes); if (scopeResult.invalid.length) throw httpError(400, "api_client_scopes_invalid", "API 客户端包含不支持的 scope", { invalidScopes: scopeResult.invalid, allowedScopes: API_CLIENT_SCOPE_CATALOG.map((scope) => scope.key) }); const timestamp = new Date().toISOString(); dbRun("UPDATE api_clients SET name = ?, status = ?, scopes_json = ?, updated_at = ? WHERE id = ?", [name, status, JSON.stringify(scopeResult.scopes), timestamp, clientId]); addAudit({ context, action: "system.api_client.updated", targetType: "api_client", targetId: clientId, metadata: { previousStatus: current.status, status, previousScopes: currentScopes, scopes: scopeResult.scopes } }); return { apiClients: apiClients(), client: apiClients().find((item) => item.id === clientId) }; } createServer(async (req, res) => { try { if (req.method === "OPTIONS") return send(res, 200, { ok: true }); const url = new URL(req.url, `http://${req.headers.host}`); const pathname = url.pathname; enforceApiRateLimit(req, res, pathname); if (req.method === "GET" && pathname === "/api/health") { return send(res, 200, { ok: true, service: "ai-drama-local-api", mode: "local-only", authMode: authMode(), projectRoot: root, exportRoot: exportBaseRoot, database: databaseInfo }); } const publicDeliveryFileMatch = pathname.match(/^\/api\/public\/delivery\/([^/]+)\/file$/); if (req.method === "GET" && publicDeliveryFileMatch) { const token = decodeURIComponent(publicDeliveryFileMatch[1]); const payload = await readPublicDeliveryFile(token, url.searchParams.get("kind") || "", requestAuthMetadata(req)); return sendBinary(res, 200, payload.content, payload.contentType, payload.fileName, { "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(payload.fileName)}` }); } const publicDeliveryFeedbackMatch = pathname.match(/^\/api\/public\/delivery\/([^/]+)\/feedback$/); if (req.method === "POST" && publicDeliveryFeedbackMatch) { const token = decodeURIComponent(publicDeliveryFeedbackMatch[1]); return send(res, 200, submitPublicDeliveryFeedback(token, await readBody(req), requestAuthMetadata(req))); } const publicDeliveryMatch = pathname.match(/^\/api\/public\/delivery\/([^/]+)$/); if (req.method === "GET" && publicDeliveryMatch) { const token = decodeURIComponent(publicDeliveryMatch[1]); return send(res, 200, resolvePublicDeliveryPortal(token, requestAuthMetadata(req))); } if (req.method === "GET" && pathname === "/api/auth/sso/providers") { return send(res, 200, { providers: publicIdentityProviders() }); } if (req.method === "GET" && pathname === "/api/auth/sso/saml/metadata") { const providerId = url.searchParams.get("providerId"); if (!providerId) throw httpError(400, "saml_provider_required", "SAML Metadata 请求必须指定 providerId"); return sendText(res, 200, samlServiceProviderMetadata(providerId, { callbackUrl: samlAcsUri }), "application/samlmetadata+xml; charset=utf-8"); } if (req.method === "GET" && pathname === "/api/auth/sso/start") { const selection = {}; for (const key of ["organizationId", "workspaceId", "projectId"]) if (url.searchParams.get(key)) selection[key] = url.searchParams.get(key); if (isSamlProvider(url.searchParams.get("providerId"))) { const result = await startSamlLogin(url.searchParams.get("providerId"), { callbackUrl: samlAcsUri, apiOrigin, returnTo: safeReturnTo(url.searchParams.get("returnTo")), selection, ipAddress: req.socket.remoteAddress, userAgent: req.headers["user-agent"], host: req.headers.host }); return redirect(res, result.authorizationUrl); } const result = startOidcLogin(url.searchParams.get("providerId"), { redirectUri: oidcRedirectUri, returnTo: safeReturnTo(url.searchParams.get("returnTo")), selection, ipAddress: req.socket.remoteAddress, userAgent: req.headers["user-agent"] }); return redirect(res, result.authorizationUrl); } if (req.method === "GET" && pathname === "/api/auth/sso/callback") { const state = url.searchParams.get("state"); const returnTo = "/"; try { if (url.searchParams.get("error")) throw httpError(401, "sso_authorization_denied", url.searchParams.get("error_description") || "企业身份登录被取消"); const result = await handleOidcCallback({ code: url.searchParams.get("code"), state }); const ticket = createSsoTicket(result.user.id, result.selection, { ipAddress: req.socket.remoteAddress, userAgent: req.headers["user-agent"] }); return redirect(res, ssoFrontendRedirect(result.returnTo || returnTo, { sso_ticket: ticket.ticket })); } catch (error) { const status = error.status || 500; if (url.searchParams.get("format") === "json") return send(res, status, { error: error.code || "sso_callback_failed", detail: error.message }); return redirect(res, ssoFrontendRedirect(returnTo, { sso_error: error.code || "sso_callback_failed", sso_error_description: error.message })); } } if (req.method === "POST" && pathname === "/api/auth/sso/saml/acs") { const body = await readFormBody(req); try { const result = await handleSamlCallback({ samlResponse: body.SAMLResponse, relayState: body.RelayState, callbackUrl: samlAcsUri }); const ticket = createSsoTicket(result.user.id, result.selection, { ipAddress: req.socket.remoteAddress, userAgent: req.headers["user-agent"] }); return redirect(res, ssoFrontendRedirect(result.returnTo || "/", { sso_ticket: ticket.ticket })); } catch (error) { const status = error.status || 500; if (url.searchParams.get("format") === "json") return send(res, status, { error: error.code || "saml_acs_failed", detail: error.message }); return redirect(res, ssoFrontendRedirect("/", { sso_error: error.code || "saml_acs_failed", sso_error_description: error.message })); } } if (req.method === "POST" && pathname === "/api/auth/sso/redeem") { const body = await readBody(req); const result = redeemSsoTicket(body.ticket, requestAuthMetadata(req)); if (result.mfaRequired) { if (result.mfaEnrollmentRequired) return send(res, 200, { mfaRequired: true, mfaEnrollmentRequired: true, enrollmentToken: result.enrollment.token, enrollmentExpiresAt: result.enrollment.expiresAt, user: result.user }); return send(res, 200, { mfaRequired: true, challengeToken: result.challenge.token, challengeExpiresAt: result.challenge.expiresAt, user: result.user }); } const payload = loginResponseForSession(result.session, result.selection, true, "auth.sso.login"); return send(res, 200, payload); } const scimUsersMatch = pathname.match(/^\/scim\/v2\.0\/([^/]+)\/Users(?:\/([^/]+))?$/i); if (scimUsersMatch) { const directorySyncId = decodeURIComponent(scimUsersMatch[1]); const userId = scimUsersMatch[2] ? decodeURIComponent(scimUsersMatch[2]) : null; const token = bearerToken(req); if (!token) throw httpError(401, "scim_token_required", "SCIM 接口需要 Bearer 令牌"); if (req.method === "GET" && !userId) return send(res, 200, scimListUsers(directorySyncId, token, url.searchParams.get("startIndex"), url.searchParams.get("count"))); if (req.method === "POST" && !userId) { const result = scimCreateUser(directorySyncId, token, await readBody(req)); return send(res, result.status, result.user); } if (req.method === "PATCH" && userId) return send(res, 200, scimPatchUser(directorySyncId, token, userId, await readBody(req))); if (req.method === "DELETE" && userId) return send(res, 200, scimDeleteUser(directorySyncId, token, userId)); } if (req.method === "GET" && pathname === "/api/invitations/preview") { return send(res, 200, { invitation: previewInvitation(url.searchParams.get("token")) }); } if (req.method === "POST" && pathname === "/api/auth/register") { const body = await readBody(req); const registered = registerInvitedUser(body); const session = createSession(registered.user.id, { ...requestAuthMetadata(req), authMethod: "invitation" }); const authHeaders = { authorization: `Bearer ${session.token}` }; const selection = new URLSearchParams(); for (const key of ["organizationId", "workspaceId", "projectId"]) { if (registered.invitation[`${key.replace("Id", "_id")}`]) selection.set(key, registered.invitation[`${key.replace("Id", "_id")}`]); } const context = resolveContext(authHeaders, selection); recordSecurityEvent({ userId: registered.user.id, eventType: "account.created", result: "success", ...requestAuthMetadata(req), metadata: { source: "invitation", invitationId: registered.invitation.id } }); recordSecurityEvent({ userId: registered.user.id, eventType: "login.success", result: "success", ...requestAuthMetadata(req), metadata: { method: "invitation" } }); addAudit({ context, action: "auth.registered_from_invitation", targetType: "user", targetId: registered.user.id, metadata: { invitationId: registered.invitation.id } }); return send(res, 201, { session, user: sessionIdentity(authHeaders)?.user, context: platformPayload(context).context, invitation: registered.invitation }); } if (req.method === "POST" && pathname === "/api/auth/login") { const body = await readBody(req); const request = requestAuthMetadata(req); const user = authenticate(body.email, body.password, request); if (mfaStatus(user.id).enabled) { const challenge = createMfaChallenge(user.id, request); return send(res, 200, { mfaRequired: true, challengeToken: challenge.token, challengeExpiresAt: challenge.expiresAt, user: safeUser(user) }); } if (mfaRequiredForUser(user.id)) { const enrollment = createMfaEnrollmentChallenge(user.id, request); return send(res, 200, { mfaRequired: true, mfaEnrollmentRequired: true, enrollmentToken: enrollment.token, enrollmentExpiresAt: enrollment.expiresAt, user: safeUser(user) }); } const session = createSession(user.id, { ...request, authMethod: "password" }); const authHeaders = { authorization: `Bearer ${session.token}` }; const selection = new URLSearchParams(); for (const key of ["organizationId", "workspaceId", "projectId"]) if (body[key]) selection.set(key, body[key]); const context = resolveContext(authHeaders, selection); recordSecurityEvent({ userId: user.id, eventType: "login.success", result: "success", ...request, metadata: { method: "password" } }); addAudit({ context, action: "auth.login", targetType: "user", targetId: user.id, metadata: { sessionExpiresAt: session.expiresAt } }); return send(res, 200, { session, user: sessionIdentity(authHeaders)?.user, context: platformPayload(context).context }); } if (req.method === "POST" && pathname === "/api/auth/login/mfa") { const body = await readBody(req); const request = requestAuthMetadata(req); const session = completeMfaChallenge(body.challengeToken, body.code, { ...request, authMethod: "password+mfa" }); const authHeaders = { authorization: `Bearer ${session.token}` }; const identity = sessionIdentity(authHeaders); const context = resolveContext(authHeaders, new URLSearchParams()); recordSecurityEvent({ userId: identity.user.id, eventType: "login.success", result: "success", ...request, metadata: { method: "password+mfa" } }); addAudit({ context, action: "auth.mfa.completed", targetType: "user", targetId: identity.user.id, metadata: { sessionExpiresAt: session.expiresAt } }); return send(res, 200, { session, user: identity.user, context: platformPayload(context).context }); } if (req.method === "POST" && pathname === "/api/auth/mfa/enroll/setup") { const body = await readBody(req); return send(res, 201, startMfaEnrollment(body.enrollmentToken, requestAuthMetadata(req))); } if (req.method === "POST" && pathname === "/api/auth/mfa/enroll/enable") { const body = await readBody(req); const request = requestAuthMetadata(req); const result = completeMfaEnrollment(body.enrollmentToken, body.methodId, body.code, { ...request, authMethod: "password+mfa-enrollment" }); const authHeaders = { authorization: `Bearer ${result.session.token}` }; const identity = sessionIdentity(authHeaders); const context = resolveContext(authHeaders, new URLSearchParams()); recordSecurityEvent({ userId: identity.user.id, eventType: "login.success", result: "success", ...request, metadata: { method: "password+mfa-enrollment" } }); addAudit({ context, action: "auth.mfa.enrollment.completed", targetType: "user_mfa_method", targetId: body.methodId, metadata: { sessionExpiresAt: result.session.expiresAt } }); return send(res, 200, { ...result, user: identity.user, context: platformPayload(context).context }); } if (req.method === "GET" && pathname === "/api/auth/mfa") { const identity = sessionIdentity(req.headers); if (!identity?.sessionId) throw httpError(403, "mfa_session_required", "MFA 管理需要浏览器登录会话"); return send(res, 200, mfaStatus(identity.userId)); } if (req.method === "POST" && pathname === "/api/auth/mfa/setup") { const identity = sessionIdentity(req.headers); if (!identity?.sessionId) throw httpError(403, "mfa_session_required", "MFA 管理需要浏览器登录会话"); const context = resolveContext(req.headers, url.searchParams); const setup = startMfaSetup(identity.userId, requestAuthMetadata(req)); addAudit({ context, action: "auth.mfa.setup.started", targetType: "user_mfa_method", targetId: setup.methodId, metadata: { type: setup.type } }); return send(res, 201, { setup }); } if (req.method === "POST" && pathname === "/api/auth/mfa/enable") { const identity = sessionIdentity(req.headers); if (!identity?.sessionId) throw httpError(403, "mfa_session_required", "MFA 管理需要浏览器登录会话"); const body = await readBody(req); const context = resolveContext(req.headers, url.searchParams); const status = enableMfa(identity.userId, body.methodId, body.code, requestAuthMetadata(req)); addAudit({ context, action: "auth.mfa.enabled", targetType: "user_mfa_method", targetId: body.methodId, metadata: { type: "totp" } }); return send(res, 200, status); } if (req.method === "POST" && pathname === "/api/auth/mfa/setup/cancel") { const identity = sessionIdentity(req.headers); if (!identity?.sessionId) throw httpError(403, "mfa_session_required", "MFA 管理需要浏览器登录会话"); const body = await readBody(req); const context = resolveContext(req.headers, url.searchParams); const status = cancelMfaSetup(identity.userId, body.methodId, requestAuthMetadata(req)); addAudit({ context, action: "auth.mfa.setup.cancelled", targetType: "user_mfa_method", targetId: body.methodId, metadata: { type: "totp" } }); return send(res, 200, status); } if (req.method === "POST" && pathname === "/api/auth/mfa/disable") { const identity = sessionIdentity(req.headers); if (!identity?.sessionId) throw httpError(403, "mfa_session_required", "MFA 管理需要浏览器登录会话"); const body = await readBody(req); const context = resolveContext(req.headers, url.searchParams); const status = disableMfa(identity.userId, body.currentPassword, body.code, requestAuthMetadata(req)); addAudit({ context, action: "auth.mfa.disabled", targetType: "user_mfa_method", targetId: identity.userId, metadata: { type: "totp" } }); return send(res, 200, status); } if (req.method === "GET" && pathname === "/api/auth/session") { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { session: { id: identity.sessionId, expiresAt: identity.expiresAt }, user: identity.user, context: platformPayload(context).context }); } if (req.method === "POST" && pathname === "/api/auth/logout") { const identity = sessionIdentity(req.headers); const revoked = revokeSession(req.headers, { ...requestAuthMetadata(req), reason: "logout" }); if (revoked && identity?.sessionId) recordSecurityEvent({ userId: identity.userId, eventType: "session.logout", result: "success", ...requestAuthMetadata(req), metadata: { sessionId: identity.sessionId } }); return send(res, 200, { ok: true }); } if (req.method === "POST" && pathname === "/api/auth/password") { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); const body = await readBody(req); const result = changePassword(identity.userId, body.currentPassword, body.nextPassword, requestAuthMetadata(req)); const context = resolveContext(req.headers, url.searchParams); addAudit({ context, action: "auth.password.changed", targetType: "user", targetId: identity.userId, metadata: { changedAt: result.changedAt } }); return send(res, 200, { ok: true, ...result }); } if (req.method === "GET" && pathname === "/api/auth/sessions") { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); if (!identity.sessionId) throw httpError(403, "session_management_unavailable", "API 客户端不能管理浏览器登录会话"); return send(res, 200, { sessions: listUserSessions(identity.userId, identity.sessionId) }); } const sessionRevokeMatch = pathname.match(/^\/api\/auth\/sessions\/([^/]+)\/revoke$/); if (req.method === "POST" && sessionRevokeMatch) { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); if (!identity.sessionId) throw httpError(403, "session_management_unavailable", "API 客户端不能管理浏览器登录会话"); const sessionId = decodeURIComponent(sessionRevokeMatch[1]); if (sessionId === identity.sessionId) throw httpError(400, "current_session_revoke_requires_logout", "当前会话请使用退出登录操作"); const revoked = revokeUserSession(identity.userId, sessionId, requestAuthMetadata(req)); if (!revoked) throw httpError(404, "session_not_found", "登录会话不存在或已经撤销"); const context = resolveContext(req.headers, url.searchParams); addAudit({ context, action: "auth.session.revoked", targetType: "auth_session", targetId: sessionId, metadata: { revokedBy: identity.userId } }); return send(res, 200, { ok: true, sessions: listUserSessions(identity.userId, identity.sessionId) }); } if (req.method === "POST" && pathname === "/api/auth/sessions/revoke-others") { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); if (!identity.sessionId) throw httpError(403, "session_management_unavailable", "API 客户端不能管理浏览器登录会话"); const revokedCount = revokeOtherUserSessions(identity.userId, identity.sessionId, requestAuthMetadata(req)); const context = resolveContext(req.headers, url.searchParams); addAudit({ context, action: "auth.sessions.revoked_others", targetType: "user", targetId: identity.userId, metadata: { revokedCount } }); return send(res, 200, { ok: true, revokedCount, sessions: listUserSessions(identity.userId, identity.sessionId) }); } if (req.method === "GET" && pathname === "/api/auth/devices") { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); if (!identity.sessionId) throw httpError(403, "device_management_unavailable", "API 客户端不能管理浏览器设备"); return send(res, 200, { devices: listUserDevices(identity.userId) }); } const deviceActionMatch = pathname.match(/^\/api\/auth\/devices\/([^/]+)\/(trust|untrust)$/); if (req.method === "POST" && deviceActionMatch) { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); if (!identity.sessionId) throw httpError(403, "device_management_unavailable", "API 客户端不能管理浏览器设备"); const deviceId = decodeURIComponent(deviceActionMatch[1]); const action = deviceActionMatch[2]; const device = action === "trust" ? trustUserDevice(identity.userId, deviceId, requestAuthMetadata(req)) : untrustUserDevice(identity.userId, deviceId, requestAuthMetadata(req)); const context = resolveContext(req.headers, url.searchParams); addAudit({ context, action: `auth.device.${action}`, targetType: "auth_device", targetId: deviceId, metadata: { userId: identity.userId } }); return send(res, 200, { ok: true, device, devices: listUserDevices(identity.userId) }); } if (req.method === "GET" && pathname === "/api/auth/security-events") { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); if (!identity.sessionId) throw httpError(403, "security_events_session_required", "账号安全事件只能通过浏览器登录会话查看"); return send(res, 200, { events: listSecurityEvents(identity.userId, { limit: url.searchParams.get("limit") || 80, eventType: url.searchParams.get("eventType") || "" }) }); } if (req.method === "GET" && pathname === "/api/context") return send(res, 200, platformPayload(resolveContext(req.headers, url.searchParams))); if (req.method === "GET" && pathname === "/api/search") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, searchPlatform(context, { query: url.searchParams.get("q") || url.searchParams.get("query") || "", scope: url.searchParams.get("scope") || "workspace", limit: url.searchParams.get("limit") || 40 })); } if (req.method === "GET" && pathname === "/api/work-items") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listWorkItems(context, { limit: url.searchParams.get("limit") })); } if (req.method === "GET" && pathname === "/api/tasks") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listProjectTasks(context, { status: url.searchParams.get("status") || "all", assignedTo: url.searchParams.get("assignedTo") || "", limit: url.searchParams.get("limit") })); } if (req.method === "POST" && pathname === "/api/tasks") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createProjectTask(context, await readBody(req))); } const taskMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/); if (req.method === "PATCH" && taskMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateProjectTask(context, decodeURIComponent(taskMatch[1]), await readBody(req))); } const taskCommentsMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/comments$/); if (req.method === "GET" && taskCommentsMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listTaskComments(context, decodeURIComponent(taskCommentsMatch[1]))); } if (req.method === "POST" && taskCommentsMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createTaskComment(context, decodeURIComponent(taskCommentsMatch[1]), await readBody(req))); } const taskLinksMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/links$/); if (req.method === "POST" && taskLinksMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, addTaskLink(context, decodeURIComponent(taskLinksMatch[1]), await readBody(req))); } const taskLinkMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/links\/([^/]+)$/); if (req.method === "DELETE" && taskLinkMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, removeTaskLink(context, decodeURIComponent(taskLinkMatch[1]), decodeURIComponent(taskLinkMatch[2]))); } const taskDetailMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/detail$/); if (req.method === "GET" && taskDetailMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, getProjectTask(context, decodeURIComponent(taskDetailMatch[1]))); } if (req.method === "GET" && pathname === "/api/project-activity") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listProjectActivity(context, { limit: url.searchParams.get("limit") })); } if (req.method === "GET" && pathname === "/api/notifications") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listUserNotifications(context, { limit: url.searchParams.get("limit"), unreadOnly: url.searchParams.get("unreadOnly") === "1" })); } if (req.method === "GET" && pathname === "/api/notification-preferences") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listUserNotificationPreferences(context)); } const notificationPreferenceMatch = pathname.match(/^\/api\/notification-preferences\/([^/]+)$/); if (req.method === "PATCH" && notificationPreferenceMatch) { const context = resolveContext(req.headers, url.searchParams); const category = decodeURIComponent(notificationPreferenceMatch[1]); const body = await readBody(req); const result = updateUserNotificationPreference(context, category, Boolean(body.enabled)); addAudit({ context, action: "user.notification_preference.updated", targetType: "notification_preference", targetId: category, metadata: { enabled: Boolean(body.enabled) } }); return send(res, 200, result); } if (req.method === "POST" && pathname === "/api/notifications/read-all") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, markAllUserNotificationsRead(context)); } const userNotificationMatch = pathname.match(/^\/api\/notifications\/([^/]+)$/); if (req.method === "PATCH" && userNotificationMatch) { const context = resolveContext(req.headers, url.searchParams); const body = await readBody(req); return send(res, 200, markUserNotificationRead(context, decodeURIComponent(userNotificationMatch[1]), body.read !== false)); } if (req.method === "GET" && pathname === "/api/organizations") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { organizations: buildContextPayload(context).platform.organizations }); } if (req.method === "POST" && pathname === "/api/organizations") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, { organization: createOrganization(context, await readBody(req)) }); } const organizationMatch = pathname.match(/^\/api\/organizations\/([^/]+)$/); if (req.method === "GET" && organizationMatch) { const organizationId = decodeURIComponent(organizationMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); requirePermission(context, "organization:manage"); const payload = buildContextPayload(context); return send(res, 200, { organization: context.organization, workspaces: payload.platform.workspaces, members: orgMembers(organizationId), invitations: pendingInvitations(organizationId), billing: payload.platform.billing }); } if (req.method === "PATCH" && organizationMatch) { const context = resolveContext(req.headers, url.searchParams); const organization = updateOrganization(context, decodeURIComponent(organizationMatch[1]), await readBody(req)); return send(res, 200, { organization }); } const organizationCommercialMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial$/); if (req.method === "GET" && organizationCommercialMatch) { const organizationId = decodeURIComponent(organizationCommercialMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, organizationCommercial(context)); } const organizationEntitlementsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements$/); if (req.method === "GET" && organizationEntitlementsMatch) { const organizationId = decodeURIComponent(organizationEntitlementsMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) { throw httpError(403, "permission_denied", "当前角色没有查看组织套餐权益的权限"); } return send(res, 200, organizationEntitlements(organizationId)); } const organizationEntitlementMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements\/([^/]+)$/); if (req.method === "PATCH" && organizationEntitlementMatch) { const organizationId = decodeURIComponent(organizationEntitlementMatch[1]); const entitlementKey = decodeURIComponent(organizationEntitlementMatch[2]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, updateOrganizationEntitlement(context, organizationId, entitlementKey, await readBody(req))); } const organizationCommercialApprovalsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial-approvals$/); if (req.method === "GET" && organizationCommercialApprovalsMatch) { const organizationId = decodeURIComponent(organizationCommercialApprovalsMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, listCommercialApprovalRequests(context, organizationId, { status: url.searchParams.get("status") || "", type: url.searchParams.get("type") || "", mine: url.searchParams.get("mine") === "1", limit: url.searchParams.get("limit") || 80 })); } if (req.method === "POST" && organizationCommercialApprovalsMatch) { const organizationId = decodeURIComponent(organizationCommercialApprovalsMatch[1]); const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createCommercialApprovalRequest(context, organizationId, await readBody(req))); } const organizationCommercialApprovalDecisionMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial-approvals\/([^/]+)\/decision$/); if (req.method === "POST" && organizationCommercialApprovalDecisionMatch) { const organizationId = decodeURIComponent(organizationCommercialApprovalDecisionMatch[1]); const approvalId = decodeURIComponent(organizationCommercialApprovalDecisionMatch[2]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, decideCommercialApprovalRequest(context, organizationId, approvalId, await readBody(req))); } const organizationCommercialExportMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial\/export$/); if (req.method === "GET" && organizationCommercialExportMatch) { const organizationId = decodeURIComponent(organizationCommercialExportMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, exportOrganizationCommercial(context, organizationId)); } const organizationUsageMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/usage$/); if (req.method === "GET" && organizationUsageMatch) { const organizationId = decodeURIComponent(organizationUsageMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, organizationUsage(context, organizationId, Object.fromEntries(url.searchParams.entries()))); } const organizationUsageExportMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/usage\/export$/); if (req.method === "GET" && organizationUsageExportMatch) { const organizationId = decodeURIComponent(organizationUsageExportMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); const exported = exportOrganizationUsage(context, organizationId, Object.fromEntries(url.searchParams.entries())); if (url.searchParams.get("format") === "csv") { return sendText(res, 200, usageCsv(exported.items), "text/csv; charset=utf-8", { "content-disposition": `attachment; filename*=UTF-8''usage-${organizationId}-${new Date().toISOString().slice(0, 10)}.csv` }); } return send(res, 200, exported); } const organizationBillingMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/billing$/); if (req.method === "PATCH" && organizationBillingMatch) { const organizationId = decodeURIComponent(organizationBillingMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, updateOrganizationBilling(context, organizationId, await readBody(req))); } const organizationInvoiceExportMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/invoices\/export$/); if (req.method === "GET" && organizationInvoiceExportMatch) { const organizationId = decodeURIComponent(organizationInvoiceExportMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); const exported = exportOrganizationInvoices(context, organizationId, Object.fromEntries(url.searchParams.entries())); if (url.searchParams.get("format") === "csv") { return sendText(res, 200, invoiceCsv(exported.invoices), "text/csv; charset=utf-8", { "content-disposition": `attachment; filename*=UTF-8''invoices-${organizationId}-${new Date().toISOString().slice(0, 10)}.csv` }); } return send(res, 200, exported); } const organizationInvoiceGenerateMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/invoices\/generate$/); if (req.method === "POST" && organizationInvoiceGenerateMatch) { const organizationId = decodeURIComponent(organizationInvoiceGenerateMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); const result = generateOrganizationInvoice(context, organizationId, await readBody(req)); return send(res, result.idempotent ? 200 : 201, result); } const organizationInvoiceStatusMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/invoices\/([^/]+)\/status$/); if (req.method === "POST" && organizationInvoiceStatusMatch) { const organizationId = decodeURIComponent(organizationInvoiceStatusMatch[1]); const invoiceId = decodeURIComponent(organizationInvoiceStatusMatch[2]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, updateOrganizationInvoiceStatus(context, organizationId, invoiceId, await readBody(req))); } const organizationInvoiceDetailMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/invoices\/([^/]+)$/); if (req.method === "GET" && organizationInvoiceDetailMatch) { const organizationId = decodeURIComponent(organizationInvoiceDetailMatch[1]); const invoiceId = decodeURIComponent(organizationInvoiceDetailMatch[2]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, organizationInvoice(context, organizationId, invoiceId)); } const organizationInvoicesMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/invoices$/); if (req.method === "GET" && organizationInvoicesMatch) { const organizationId = decodeURIComponent(organizationInvoicesMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, organizationInvoices(context, organizationId, Object.fromEntries(url.searchParams.entries()))); } const organizationQuotaMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/quotas\/([^/]+)$/); if (req.method === "PATCH" && organizationQuotaMatch) { const organizationId = decodeURIComponent(organizationQuotaMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, updateQuotaAllocation(context, organizationId, decodeURIComponent(organizationQuotaMatch[2]), await readBody(req))); } const organizationCostCenterMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/cost-centers\/([^/]+)$/); if (req.method === "PATCH" && organizationCostCenterMatch) { const organizationId = decodeURIComponent(organizationCostCenterMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); return send(res, 200, updateCostCenter(context, organizationId, decodeURIComponent(organizationCostCenterMatch[2]), await readBody(req))); } const organizationRolePolicyMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/role-policies(?:\/([^/]+))?$/); if (req.method === "GET" && organizationRolePolicyMatch) { const organizationId = decodeURIComponent(organizationRolePolicyMatch[1]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); requirePermission(context, "organization:roles:manage"); return send(res, 200, { organizationId, ...organizationRolePolicies(organizationId) }); } if (req.method === "PATCH" && organizationRolePolicyMatch?.[2]) { const organizationId = decodeURIComponent(organizationRolePolicyMatch[1]); const roleKey = decodeURIComponent(organizationRolePolicyMatch[2]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); const policy = updateOrganizationRolePolicy(context, organizationId, roleKey, await readBody(req)); return send(res, 200, { organizationId, ...policy }); } const orgMemberMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/members$/); if (req.method === "GET" && orgMemberMatch) { const context = resolveContext(req.headers, url.searchParams); requirePathMatches(context.organization.id, decodeURIComponent(orgMemberMatch[1]), "组织"); requirePermission(context, "organization:members:invite"); return send(res, 200, { members: orgMembers(context.organization.id), invitations: pendingInvitations(context.organization.id) }); } const orgMemberEditMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/members\/([^/]+)$/); if (req.method === "PATCH" && orgMemberEditMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateOrganizationMember(context, decodeURIComponent(orgMemberEditMatch[1]), decodeURIComponent(orgMemberEditMatch[2]), await readBody(req))); } const invitationMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/invitations$/); if (req.method === "POST" && invitationMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, { invitation: createInvitation(context, decodeURIComponent(invitationMatch[1]), await readBody(req)) }); } const invitationActionMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/invitations\/([^/]+)\/(resend|revoke)$/); if (req.method === "POST" && invitationActionMatch) { const organizationId = decodeURIComponent(invitationActionMatch[1]); const invitationId = decodeURIComponent(invitationActionMatch[2]); const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req); const action = invitationActionMatch[3]; return send(res, 200, action === "resend" ? resendOrganizationInvitation(context, organizationId, invitationId) : revokeOrganizationInvitation(context, organizationId, invitationId)); } const invitationAcceptMatch = pathname.match(/^\/api\/invitations\/([^/]+)\/accept$/); if (req.method === "POST" && invitationAcceptMatch) { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); const context = { user: identity.user }; return send(res, 200, { invitation: acceptInvitation(context, decodeURIComponent(invitationAcceptMatch[1])) }); } if (req.method === "GET" && pathname === "/api/invitations") { const identity = sessionIdentity(req.headers); if (!identity) throw httpError(401, "auth_required", "请先登录"); return send(res, 200, { invitations: userInvitations(identity.user.email) }); } if (req.method === "GET" && pathname === "/api/workspaces") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { workspaces: buildContextPayload(context).platform.workspaces }); } if (req.method === "POST" && pathname === "/api/workspaces") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, { workspace: createWorkspace(context, await readBody(req)) }); } const workspaceMatch = pathname.match(/^\/api\/workspaces\/([^/]+)$/); if (req.method === "PATCH" && workspaceMatch) { const context = resolveContext(req.headers, url.searchParams); const workspace = updateWorkspace(context, decodeURIComponent(workspaceMatch[1]), await readBody(req)); return send(res, 200, { workspace }); } const workspaceMembersMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/members$/); if (req.method === "GET" && workspaceMembersMatch) { const workspaceId = decodeURIComponent(workspaceMembersMatch[1]); const context = contextWith({ ...Object.fromEntries(url.searchParams), workspaceId }, req); requirePathMatches(context.workspace.id, workspaceId, "工作区"); requirePermission(context, "workspace:members:manage"); return send(res, 200, { members: workspaceMembers(workspaceId) }); } const workspaceMemberEditMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/members\/([^/]+)$/); if (req.method === "PATCH" && workspaceMemberEditMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateWorkspaceMember(context, decodeURIComponent(workspaceMemberEditMatch[1]), decodeURIComponent(workspaceMemberEditMatch[2]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/projects") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { projects: buildContextPayload(context).platform.projects }); } if (req.method === "POST" && pathname === "/api/projects") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, { project: createProject(context, await readBody(req)) }); } const projectLifecycleMatch = pathname.match(/^\/api\/projects\/([^/]+)\/lifecycle$/); if (req.method === "POST" && projectLifecycleMatch) { const projectId = decodeURIComponent(projectLifecycleMatch[1]); const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req); const body = await readBody(req); return send(res, 200, { project: updateProject(context, projectId, { lifecycleAction: body.action }) }); } const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)$/); if (req.method === "GET" && projectMatch) { const projectId = decodeURIComponent(projectMatch[1]); const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req); requirePathMatches(context.project?.id, projectId, "项目"); return send(res, 200, { project: context.project, members: hasPermission(context, "project:members:manage") ? projectMembers(projectId) : [], jobs: jobsForProject(projectId) }); } if (req.method === "PATCH" && projectMatch) { const projectId = decodeURIComponent(projectMatch[1]); const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req); return send(res, 200, { project: updateProject(context, projectId, await readBody(req)) }); } const projectStyleKitMatch = pathname.match(/^\/api\/projects\/([^/]+)\/style-kit$/); if (req.method === "GET" && projectStyleKitMatch) { const projectId = decodeURIComponent(projectStyleKitMatch[1]); const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req); return send(res, 200, getProjectStyleKit(context, projectId)); } if (req.method === "POST" && projectStyleKitMatch) { const projectId = decodeURIComponent(projectStyleKitMatch[1]); const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req); return send(res, 200, bindProjectStyleKit(context, projectId, await readBody(req))); } const projectMemberMatch = pathname.match(/^\/api\/projects\/([^/]+)\/members$/); if (req.method === "GET" && projectMemberMatch) { const context = resolveContext(req.headers, url.searchParams); requirePathMatches(context.project?.id, decodeURIComponent(projectMemberMatch[1]), "项目"); requirePermission(context, "project:members:manage"); return send(res, 200, { members: projectMembers(context.project.id) }); } if (req.method === "POST" && projectMemberMatch) { const projectId = decodeURIComponent(projectMemberMatch[1]); const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req); requirePermission(context, "project:members:manage"); const body = await readBody(req); const userId = body.userId; if (!userId || !dbGet("SELECT id FROM users WHERE id = ? AND status = 'active'", [userId])) throw httpError(400, "user_invalid", "项目成员用户不存在"); const roleKey = body.roleKey || "project_editor"; roleForScope(roleKey, "project"); dbRun("INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at", [createId("pm"), projectId, userId, roleKey, new Date().toISOString(), new Date().toISOString()]); addAudit({ context, action: "project.member.upserted", targetType: "project_member", targetId: `${projectId}:${userId}`, metadata: { roleKey } }); return send(res, 200, { members: projectMembers(projectId) }); } const projectMemberEditMatch = pathname.match(/^\/api\/projects\/([^/]+)\/members\/([^/]+)$/); if (req.method === "PATCH" && projectMemberEditMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateProjectMember(context, decodeURIComponent(projectMemberEditMatch[1]), decodeURIComponent(projectMemberEditMatch[2]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/usage") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "usage:view"); return send(res, 200, { usage: usageSummary(context) }); } if (req.method === "GET" && pathname === "/api/usage/storage") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "usage:view"); return send(res, 200, { storage: await storageSummary(context) }); } if (req.method === "GET" && pathname === "/api/system/storage/cleanup-preview") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, { cleanup: await storageCleanupPreview(context, { olderThanDays: url.searchParams.get("olderThanDays") || "" }) }); } if (req.method === "POST" && pathname === "/api/system/storage/reclaim") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:edit"); const result = await reclaimStorage(context, await readBody(req)); addAudit({ context, action: "system.storage.reclaimed", targetType: "storage_cleanup", targetId: `cleanup-${Date.now()}`, metadata: { deletedCount: result.deleted.length, deletedBytes: result.deletedBytes, retentionDays: result.policy.retentionDays } }); return send(res, 200, { cleanup: result, storage: await storageSummary(context) }); } if (req.method === "GET" && pathname === "/api/billing") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "usage:view"); const payload = buildContextPayload(context); return send(res, 200, { billing: payload.platform.billing, usage: payload.platform.usage }); } const auditDetailMatch = pathname.match(/^\/api\/audit\/([^/]+)$/); if (req.method === "GET" && auditDetailMatch && auditDetailMatch[1] !== "export") { const context = resolveContext(req.headers, auditContextParams(url)); return send(res, 200, getAuditEvent(context, decodeURIComponent(auditDetailMatch[1]))); } if (req.method === "GET" && pathname === "/api/audit") { const context = resolveContext(req.headers, auditContextParams(url)); return send(res, 200, listAuditEvents(context, auditOptions(url))); } if (req.method === "GET" && pathname === "/api/audit/export") { const context = resolveContext(req.headers, auditContextParams(url)); const exported = exportAuditEvents(context, auditOptions(url)); if (url.searchParams.get("format") === "csv") { return sendText(res, 200, auditCsv(exported.auditLog), "text/csv; charset=utf-8", { "content-disposition": `attachment; filename*=UTF-8''audit-${new Date().toISOString().slice(0, 10)}.csv` }); } return send(res, 200, exported); } if (req.method === "GET" && pathname === "/api/permissions") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "organization:manage"); const payload = buildContextPayload(context); return send(res, 200, { roles: payload.roles, permissions: payload.permissions, effective: context.permissions }); } if (req.method === "GET" && pathname === "/api/style-kits") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listStyleKits(context, { includeArchived: url.searchParams.get("includeArchived") === "1" })); } if (req.method === "POST" && pathname === "/api/style-kits") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createStyleKit(context, await readBody(req))); } if (req.method === "GET" && pathname === "/api/style-kits/effective") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, getProjectStyleKit(context, url.searchParams.get("projectId") || "")); } const styleKitMatch = pathname.match(/^\/api\/style-kits\/([^/]+)$/); if (req.method === "GET" && styleKitMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, getStyleKit(context, decodeURIComponent(styleKitMatch[1]))); } if (req.method === "PATCH" && styleKitMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateStyleKit(context, decodeURIComponent(styleKitMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/project") { const context = resolveContext(req.headers, url.searchParams); const { project, jobs } = scopedProjectPayload(context); const platform = platformPayload(context); return send(res, 200, { project, adapters: platform.platform.adapterCatalog, jobs, context: platform.context }); } if (req.method === "GET" && pathname === "/api/production/catalog") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "script:read"); return send(res, 200, { catalog: productionGraph(context).catalog }); } if (req.method === "POST" && pathname === "/api/production/seasons") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createSeason(context, await readBody(req))); } if (req.method === "POST" && pathname === "/api/production/episodes") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createEpisode(context, await readBody(req))); } const productionEpisodeMatch = pathname.match(/^\/api\/production\/episodes\/([^/]+)$/); if (req.method === "PATCH" && productionEpisodeMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateEpisode(context, productionEpisodeMatch[1], await readBody(req))); } if (req.method === "GET" && pathname === "/api/production/graph") { const context = resolveContext(req.headers, url.searchParams); if (!["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "job:create", "qa:review", "delivery:view", "delivery:approve"].some((permission) => hasPermission(context, permission))) { throw httpError(403, "permission_denied", "当前角色没有生产图谱访问权限"); } return send(res, 200, { graph: productionGraph(context, { episodeId: url.searchParams.get("episodeId") || undefined }) }); } if (req.method === "POST" && pathname === "/api/production/script/import") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, importScript(context, await readBody(req))); } if (req.method === "POST" && pathname === "/api/production/script/materialize") { const context = resolveContext(req.headers, url.searchParams); const body = await readBody(req); if (!body.documentId) throw httpError(400, "document_required", "物化镜头草稿必须指定剧本版本"); return send(res, 201, materializeScript(context, body.documentId, body)); } if (req.method === "PATCH" && pathname === "/api/production/bible") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateBible(context, await readBody(req))); } if (req.method === "POST" && pathname === "/api/production/shots") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createShot(context, await readBody(req))); } const productionShotMatch = pathname.match(/^\/api\/production\/shots\/([^/]+)$/); if (req.method === "PATCH" && productionShotMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateShot(context, decodeURIComponent(productionShotMatch[1]), await readBody(req))); } const productionShotVersionsMatch = pathname.match(/^\/api\/production\/shots\/([^/]+)\/versions$/); if (req.method === "GET" && productionShotVersionsMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listShotVersions(context, decodeURIComponent(productionShotVersionsMatch[1]))); } const productionShotVersionRestoreMatch = pathname.match(/^\/api\/production\/shots\/([^/]+)\/versions\/([^/]+)\/restore$/); if (req.method === "POST" && productionShotVersionRestoreMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, restoreShotVersion(context, decodeURIComponent(productionShotVersionRestoreMatch[1]), decodeURIComponent(productionShotVersionRestoreMatch[2]))); } const productionPromptMatch = pathname.match(/^\/api\/production\/shots\/([^/]+)\/prompt-versions$/); if (req.method === "POST" && productionPromptMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, savePromptVersion(context, decodeURIComponent(productionPromptMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/assets") { const context = resolveContext(req.headers, url.searchParams); if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) { throw httpError(403, "permission_denied", "当前角色没有资产库访问权限"); } return send(res, 200, { assets: scopedAssets(context) }); } if (req.method === "POST" && pathname === "/api/assets") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, { asset: createAsset(context, await readBody(req)) }); } if (req.method === "POST" && pathname === "/api/assets/upload") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "asset:edit"); return send(res, 201, { asset: await uploadAsset(context, await readBody(req)) }); } const assetMatch = pathname.match(/^\/api\/assets\/([^/]+)$/); if (req.method === "GET" && assetMatch) { const context = resolveContext(req.headers, url.searchParams); if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) { throw httpError(403, "permission_denied", "当前角色没有资产库访问权限"); } const asset = getAsset(context, decodeURIComponent(assetMatch[1])); if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目"); return send(res, 200, { asset }); } const assetContentMatch = pathname.match(/^\/api\/assets\/([^/]+)\/content$/); if (req.method === "GET" && assetContentMatch) { const context = resolveContext(req.headers, url.searchParams); const payload = await assetContent(context, decodeURIComponent(assetContentMatch[1])); return sendBinary(res, 200, payload.content, payload.contentType, payload.fileName, { etag: `"${payload.contentSha256}"` }); } const assetVersionUploadMatch = pathname.match(/^\/api\/assets\/([^/]+)\/versions\/upload$/); if (req.method === "POST" && assetVersionUploadMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, { asset: await uploadAssetVersion(context, decodeURIComponent(assetVersionUploadMatch[1]), await readBody(req)) }); } const assetVersionMatch = pathname.match(/^\/api\/assets\/([^/]+)\/versions$/); if (req.method === "POST" && assetVersionMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, { asset: createAssetVersion(context, decodeURIComponent(assetVersionMatch[1]), await readBody(req)) }); } const assetVerifyMatch = pathname.match(/^\/api\/assets\/([^/]+)\/verify$/); if (req.method === "POST" && assetVerifyMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { verification: await verifyAssetContent(context, decodeURIComponent(assetVerifyMatch[1])) }); } const assetGovernanceMatch = pathname.match(/^\/api\/assets\/([^/]+)\/governance$/); if (req.method === "GET" && assetGovernanceMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listAssetGovernanceReviews(context, decodeURIComponent(assetGovernanceMatch[1]))); } const assetGovernanceScanMatch = pathname.match(/^\/api\/assets\/([^/]+)\/governance\/scan$/); if (req.method === "POST" && assetGovernanceScanMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, scanAssetGovernance(context, decodeURIComponent(assetGovernanceScanMatch[1]), await readBody(req))); } const assetVersionRestoreMatch = pathname.match(/^\/api\/assets\/([^/]+)\/versions\/([^/]+)\/restore$/); if (req.method === "POST" && assetVersionRestoreMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { asset: restoreAssetVersion(context, decodeURIComponent(assetVersionRestoreMatch[1]), decodeURIComponent(assetVersionRestoreMatch[2])) }); } const assetLockMatch = pathname.match(/^\/api\/assets\/([^/]+)\/lock$/); if (req.method === "POST" && assetLockMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { asset: updateAssetLock(context, decodeURIComponent(assetLockMatch[1]), await readBody(req)) }); } const assetRightsMatch = pathname.match(/^\/api\/assets\/([^/]+)\/rights$/); if (req.method === "POST" && assetRightsMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { asset: updateAssetRights(context, decodeURIComponent(assetRightsMatch[1]), await readBody(req)) }); } const assetBindingMatch = pathname.match(/^\/api\/assets\/([^/]+)\/bindings$/); if (req.method === "POST" && assetBindingMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { asset: bindAssetToShot(context, decodeURIComponent(assetBindingMatch[1]), await readBody(req)) }); } if (req.method === "POST" && pathname === "/api/assistant/query") { const context = resolveContext(req.headers, url.searchParams); if (!["script:edit", "job:create", "usage:view"].some((permission) => hasPermission(context, permission))) { throw httpError(403, "permission_denied", "当前角色没有项目助手访问权限"); } return send(res, 200, assistantQuery(context, await readBody(req))); } if (req.method === "GET" && pathname === "/api/platform") return send(res, 200, platformPayload(resolveContext(req.headers, url.searchParams))); if (req.method === "GET" && pathname === "/api/platform/research-matrix") { resolveContext(req.headers, url.searchParams); return send(res, 200, { matrix: platformResearchMatrix }); } if (req.method === "GET" && pathname === "/api/platform/models") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:read"); else requirePermission(context, "model:manage"); return send(res, 200, { models: scopedModels(context), runners: runtimeRunners() }); } if (req.method === "GET" && pathname === "/api/platform/model-catalog") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:read"); else requirePermission(context, "model:manage"); return send(res, 200, { catalog: scopedModelCatalog(context), connectors: scopedModels(context) }); } if (req.method === "GET" && pathname === "/api/platform/model-routes") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:read"); else requirePermission(context, "model:manage"); return send(res, 200, { routes: scopedModelRoutes(context), catalog: scopedModelCatalog(context) }); } const modelConnectorMatch = pathname.match(/^\/api\/platform\/models\/([^/]+)$/); if (req.method === "PATCH" && modelConnectorMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); const model = updateModelConnector(context, decodeURIComponent(modelConnectorMatch[1]), await readBody(req)); return send(res, 200, { model, models: scopedModels(context) }); } const modelProbeMatch = pathname.match(/^\/api\/platform\/models\/([^/]+)\/probe$/); if (req.method === "POST" && modelProbeMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); const result = await probeModelConnector(context, decodeURIComponent(modelProbeMatch[1])); return send(res, 200, { ...result, models: scopedModels(context) }); } if (req.method === "GET" && pathname === "/api/platform/costs") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "usage:view"); const payload = buildContextPayload(context); return send(res, 200, { plan: payload.platform.billing, costCenters: platformData.costCenters, usage: payload.platform.usage }); } if (req.method === "GET" && pathname === "/api/platform/compliance") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "compliance:manage"); return send(res, 200, { policies: platformData.compliancePolicies, reviewLanes: platformData.reviewLanes, auditLog: scopedAuditLog(context) }); } if (req.method === "GET" && pathname === "/api/admin/queue") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "queue:manage"); const jobs = dbAll( `SELECT j.*, p.name AS project_name, u.display_name AS creator_name, m.cost_mode, m.approval_required, m.status AS adapter_status, (SELECT COUNT(*) FROM job_attempts a WHERE a.job_id = j.id) AS attempts FROM generation_jobs j JOIN projects p ON p.id = j.project_id LEFT JOIN users u ON u.id = j.created_by LEFT JOIN model_connectors m ON m.id = j.adapter_id WHERE j.organization_id = ? AND j.workspace_id = ? ORDER BY CASE j.status WHEN 'running' THEN 0 WHEN 'queued' THEN 1 WHEN 'failed' THEN 2 ELSE 3 END, j.priority DESC, j.created_at DESC LIMIT ?`, [context.organization.id, context.workspace.id, Number(url.searchParams.get("limit") || 100)] ); return send(res, 200, { jobs: jobs.map((job) => ({ ...job, attempts: Number(job.attempts || 0) })), runners: runtimeRunners(), summary: { queued: jobs.filter((job) => job.status === "queued").length, running: jobs.filter((job) => job.status === "running").length, failed: jobs.filter((job) => job.status === "failed").length, completed: jobs.filter((job) => job.status === "completed").length } }); } if (req.method === "POST" && pathname === "/api/admin/queue/batch") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, batchQueueAction(context, await readBody(req))); } const runnerActionMatch = pathname.match(/^\/api\/system\/health\/([^/]+)\/action$/); if (req.method === "POST" && runnerActionMatch) { const context = resolveContext(req.headers, url.searchParams); const result = updateServiceHealth(context, decodeURIComponent(runnerActionMatch[1]), await readBody(req)); return send(res, 200, { ...result, runners: runtimeRunners() }); } if (req.method === "GET" && pathname === "/api/system/config") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, { ...systemConfigPayload(), context: { organizationId: context.organization.id, userId: context.user.id } }); } if (req.method === "POST" && pathname === "/api/system/config") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateSystemConfig(context, await readBody(req))); } if (req.method === "GET" && pathname === "/api/system/identity") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, identityCenter()); } if (req.method === "GET" && pathname === "/api/system/security-events") { const identity = sessionIdentity(req.headers); if (!identity?.sessionId) throw httpError(403, "security_events_session_required", "系统安全事件只能通过浏览器登录会话查看"); const context = resolveContext(req.headers, url.searchParams); if (!context.systemAdmin) throw httpError(403, "system_admin_required", "只有系统管理员可以查看全局安全事件"); const userId = url.searchParams.get("userId") || null; if (userId && !dbGet("SELECT id FROM users WHERE id = ?", [userId])) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); return send(res, 200, { userId, events: listSecurityEvents(userId, { limit: url.searchParams.get("limit") || 120, eventType: url.searchParams.get("eventType") || "" }) }); } if (req.method === "GET" && pathname === "/api/system/users") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, systemUsers(context, { query: url.searchParams.get("query") || "", status: url.searchParams.get("status") || "", limit: url.searchParams.get("limit") || 100 })); } if (req.method === "POST" && pathname === "/api/system/users") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createSystemUser(context, await readBody(req))); } const systemUserMatch = pathname.match(/^\/api\/system\/users\/([^/]+)$/); if (req.method === "GET" && systemUserMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, systemUserDetail(context, decodeURIComponent(systemUserMatch[1]))); } if (req.method === "PATCH" && systemUserMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateSystemUser(context, decodeURIComponent(systemUserMatch[1]), await readBody(req))); } const systemUserPasswordMatch = pathname.match(/^\/api\/system\/users\/([^/]+)\/reset-password$/); if (req.method === "POST" && systemUserPasswordMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, resetSystemUserPassword(context, decodeURIComponent(systemUserPasswordMatch[1]), await readBody(req), requestAuthMetadata(req))); } const systemUserMfaMatch = pathname.match(/^\/api\/system\/users\/([^/]+)\/reset-mfa$/); if (req.method === "POST" && systemUserMfaMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, resetSystemUserMfa(context, decodeURIComponent(systemUserMfaMatch[1]), requestAuthMetadata(req))); } const systemUserMembershipMatch = pathname.match(/^\/api\/system\/users\/([^/]+)\/memberships$/); if (req.method === "POST" && systemUserMembershipMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateSystemUserMemberships(context, decodeURIComponent(systemUserMembershipMatch[1]), await readBody(req))); } const systemUserSessionsMatch = pathname.match(/^\/api\/system\/users\/([^/]+)\/revoke-sessions$/); if (req.method === "POST" && systemUserSessionsMatch) { const context = resolveContext(req.headers, url.searchParams); if (!context.systemAdmin) throw httpError(403, "system_admin_required", "只有系统管理员可以撤销全局用户会话"); const userId = decodeURIComponent(systemUserSessionsMatch[1]); if (!dbGet("SELECT id FROM users WHERE id = ?", [userId])) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); const revokedCount = revokeAllUserSessions(userId, { ...requestAuthMetadata(req), reason: "system_admin_revoke", actorUserId: context.user.id }); addAudit({ context, action: "system.user.sessions_revoked", targetType: "user", targetId: userId, metadata: { revokedCount } }); return send(res, 200, { ...systemUserDetail(context, userId), revokedSessionCount: revokedCount }); } if (req.method === "PATCH" && pathname === "/api/system/identity/policy") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateIdentityPolicy(context, await readBody(req))); } if (req.method === "POST" && pathname === "/api/system/identity/providers") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, saveIdentityProvider(context, await readBody(req))); } const identityProviderProbeMatch = pathname.match(/^\/api\/system\/identity\/providers\/([^/]+)\/probe$/); if (req.method === "POST" && identityProviderProbeMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, await probeIdentityProvider(context, decodeURIComponent(identityProviderProbeMatch[1]))); } const identityProviderMatch = pathname.match(/^\/api\/system\/identity\/providers\/([^/]+)$/); if (req.method === "PATCH" && identityProviderMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, saveIdentityProvider(context, await readBody(req), decodeURIComponent(identityProviderMatch[1]))); } if (req.method === "POST" && pathname === "/api/system/identity/directory-syncs") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createDirectorySync(context, await readBody(req))); } const directoryTokenMatch = pathname.match(/^\/api\/system\/identity\/directory-syncs\/([^/]+)\/rotate-token$/); if (req.method === "POST" && directoryTokenMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, rotateDirectorySyncToken(context, decodeURIComponent(directoryTokenMatch[1]))); } const directorySyncMatch = pathname.match(/^\/api\/system\/identity\/directory-syncs\/([^/]+)$/); if (req.method === "PATCH" && directorySyncMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateDirectorySync(context, decodeURIComponent(directorySyncMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/system/health") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "service:health:view"); const services = serviceHealth(); return send(res, 200, { services, summary: { total: services.length, ready: services.filter((item) => item.status === "ready").length, attention: services.filter((item) => item.status !== "ready").length, queueDepth: services.reduce((sum, item) => sum + Number(item.queue_depth || 0), 0) }, checkedAt: new Date().toISOString() }); } if (req.method === "GET" && pathname === "/api/system/readiness") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, await systemReadiness()); } if (req.method === "GET" && pathname === "/api/system/backups") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, await backupSummary()); } if (req.method === "POST" && pathname === "/api/system/backups") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:edit"); const result = await createDatabaseBackup(); addAudit({ context, action: "system.database.backup_created", targetType: "database_backup", targetId: result.backup.name, metadata: { relativePath: result.backup.relativePath, bytes: result.backup.bytes } }); return send(res, 201, result); } if (req.method === "GET" && pathname === "/api/system/worker") { const context = resolveContext(req.headers, url.searchParams); if (!hasPermission(context, "service:health:view") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "没有查看 Worker 状态的权限"); return send(res, 200, { worker: workerStatus(), checkedAt: new Date().toISOString() }); } if (req.method === "POST" && pathname === "/api/system/worker/dispatch") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "queue:manage"); return send(res, 200, { worker: await runWorkerOnce(), dispatchedBy: context.user.id }); } if (req.method === "GET" && pathname === "/api/system/feature-flags") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, { featureFlags: featureFlags() }); } if (req.method === "GET" && pathname === "/api/system/plan-templates") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, { planTemplates: subscriptionPlanTemplates({ includeArchived: true }) }); } if (req.method === "POST" && pathname === "/api/system/plan-templates") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createSubscriptionPlanTemplate(context, await readBody(req))); } const planTemplateMatch = pathname.match(/^\/api\/system\/plan-templates\/([^/]+)$/); if (req.method === "PATCH" && planTemplateMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateSubscriptionPlanTemplate(context, decodeURIComponent(planTemplateMatch[1]), await readBody(req))); } if (req.method === "POST" && pathname === "/api/system/feature-flags") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { featureFlags: updateFeatureFlag(context, await readBody(req)) }); } if (req.method === "GET" && pathname === "/api/system/notifications") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, { notifications: notificationChannels(), deliveries: notificationDeliveries(context) }); } if (req.method === "POST" && pathname === "/api/system/notifications") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, { notifications: updateNotificationChannel(context, await readBody(req)), deliveries: notificationDeliveries(context) }); } if (req.method === "POST" && pathname === "/api/system/notifications/test") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "notification:manage"); const body = await readBody(req); const eventKey = String(body.event || "system.changed").trim(); const result = await dispatchNotificationEvent({ context, eventKey, payload: body.payload || { source: "manual-test" } }); addAudit({ context, action: "system.notification.tested", targetType: "notification_event", targetId: eventKey, metadata: { deliveryCount: result.deliveries.length, userNotificationCount: result.userNotifications.length } }); return send(res, 200, { event: eventKey, created: result.deliveries, userNotifications: result.userNotifications, deliveries: notificationDeliveries(context) }); } if (req.method === "GET" && pathname === "/api/system/notifications/deliveries") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, { deliveries: notificationDeliveries(context, Number(url.searchParams.get("limit") || 80)) }); } if (req.method === "GET" && pathname === "/api/system/api-clients") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "system:settings:view"); return send(res, 200, { apiClients: apiClients() }); } if (req.method === "POST" && pathname === "/api/system/api-clients") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createApiClient(context, await readBody(req))); } const apiClientMatch = pathname.match(/^\/api\/system\/api-clients\/([^/]+)$/); const apiClientRotateMatch = pathname.match(/^\/api\/system\/api-clients\/([^/]+)\/rotate$/); if (req.method === "POST" && apiClientRotateMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, rotateApiClientKey(context, decodeURIComponent(apiClientRotateMatch[1]))); } if (req.method === "PATCH" && apiClientMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateApiClient(context, decodeURIComponent(apiClientMatch[1]), await readBody(req))); } if (req.method === "POST" && pathname === "/api/platform/models/register") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); const model = registerModel(context, await readBody(req)); return send(res, 201, { model, models: scopedModels(context) }); } if (req.method === "POST" && pathname === "/api/platform/model-catalog") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); const entry = createModelCatalogEntry(context, await readBody(req)); return send(res, 201, { entry, catalog: scopedModelCatalog(context) }); } const modelCatalogMatch = pathname.match(/^\/api\/platform\/model-catalog\/([^/]+)$/); if (req.method === "PATCH" && modelCatalogMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); const entry = updateModelCatalogEntry(context, decodeURIComponent(modelCatalogMatch[1]), await readBody(req)); return send(res, 200, { entry, catalog: scopedModelCatalog(context) }); } if (req.method === "POST" && pathname === "/api/platform/model-routes") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); const route = createModelRoute(context, await readBody(req)); return send(res, 201, { route, routes: scopedModelRoutes(context) }); } if (req.method === "POST" && pathname === "/api/platform/model-routes/resolve") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:read"); return send(res, 200, { resolution: previewModelRouteResolution(context, await readBody(req)) }); } if (req.method === "GET" && pathname === "/api/platform/model-route-approvals") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:read"); return send(res, 200, listModelRouteApprovalRequests(context, { status: url.searchParams.get("status") || "", mine: url.searchParams.get("mine") === "1", limit: url.searchParams.get("limit") || 80 })); } if (req.method === "POST" && pathname === "/api/platform/model-route-approvals") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); return send(res, 201, requestModelRouteApproval(context, await readBody(req))); } const modelRouteApprovalDecisionMatch = pathname.match(/^\/api\/platform\/model-route-approvals\/([^/]+)\/decision$/); if (req.method === "POST" && modelRouteApprovalDecisionMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); return send(res, 200, decideModelRouteApproval(context, decodeURIComponent(modelRouteApprovalDecisionMatch[1]), await readBody(req))); } const modelRouteMatch = pathname.match(/^\/api\/platform\/model-routes\/([^/]+)$/); if (req.method === "PATCH" && modelRouteMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "models:write"); const route = updateModelRoute(context, decodeURIComponent(modelRouteMatch[1]), await readBody(req)); return send(res, 200, { route, routes: scopedModelRoutes(context) }); } if (req.method === "GET" && pathname === "/api/knowledge/library") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listKnowledgeDocuments(context)); } if (req.method === "POST" && pathname === "/api/knowledge/library/import") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, importKnowledgeDocument(context, await readBody(req))); } if (req.method === "GET" && pathname === "/api/knowledge/search") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, searchKnowledge(context, { query: url.searchParams.get("q") || url.searchParams.get("query") || "", sourceType: url.searchParams.get("sourceType") || url.searchParams.get("source_type") || "all", scopeMode: url.searchParams.get("scopeMode") || url.searchParams.get("scope_mode") || "all", limit: url.searchParams.get("limit") || 24 })); } if (req.method === "GET" && pathname === "/api/knowledge/context-packs") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listKnowledgeContextPacks(context)); } if (req.method === "POST" && pathname === "/api/knowledge/context-packs") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createKnowledgeContextPack(context, await readBody(req))); } const knowledgePackMatch = pathname.match(/^\/api\/knowledge\/context-packs\/([^/]+)$/); if (req.method === "GET" && knowledgePackMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, getKnowledgeContextPack(context, decodeURIComponent(knowledgePackMatch[1]))); } const knowledgePackMaterializeMatch = pathname.match(/^\/api\/knowledge\/context-packs\/([^/]+)\/materialize$/); if (req.method === "POST" && knowledgePackMaterializeMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, materializeKnowledgeContextPack(context, decodeURIComponent(knowledgePackMaterializeMatch[1]), await readBody(req))); } const knowledgeDocumentMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)$/); if (req.method === "GET" && knowledgeDocumentMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, getKnowledgeDocument(context, decodeURIComponent(knowledgeDocumentMatch[1]))); } if (req.method === "PATCH" && knowledgeDocumentMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateKnowledgeDocument(context, decodeURIComponent(knowledgeDocumentMatch[1]), await readBody(req))); } const knowledgeReviewMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/review$/); if (req.method === "POST" && knowledgeReviewMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, reviewKnowledgeDocument(context, decodeURIComponent(knowledgeReviewMatch[1]), await readBody(req))); } const knowledgeArchiveMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/archive$/); if (req.method === "POST" && knowledgeArchiveMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, archiveKnowledgeDocument(context, decodeURIComponent(knowledgeArchiveMatch[1]))); } const knowledgeRestoreMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/restore$/); if (req.method === "POST" && knowledgeRestoreMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, restoreKnowledgeDocument(context, decodeURIComponent(knowledgeRestoreMatch[1]))); } const knowledgeVersionsMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/versions$/); if (req.method === "GET" && knowledgeVersionsMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listKnowledgeDocumentVersions(context, decodeURIComponent(knowledgeVersionsMatch[1]))); } const knowledgeVersionRestoreMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/versions\/([^/]+)\/restore$/); if (req.method === "POST" && knowledgeVersionRestoreMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, restoreKnowledgeDocumentVersion(context, decodeURIComponent(knowledgeVersionRestoreMatch[1]), decodeURIComponent(knowledgeVersionRestoreMatch[2]))); } const knowledgeMaterializeMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/materialize$/); if (req.method === "POST" && knowledgeMaterializeMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, materializeKnowledgeDocument(context, decodeURIComponent(knowledgeMaterializeMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/qa") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "qa:review"); const { project } = scopedProjectPayload(context); return send(res, 200, { results: projectQa(project), evidence: project.qaEvidence, ...(qaReviews(context)) }); } if (req.method === "GET" && pathname === "/api/production/reviews") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, qaReviews(context)); } if (req.method === "GET" && pathname === "/api/production/compositions") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "delivery:view"); return send(res, 200, { compositions: listCompositions(context, Number(url.searchParams.get("limit") || 40)) }); } if (req.method === "GET" && pathname === "/api/production/delivery-channels") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listDeliveryChannels(context)); } if (req.method === "POST" && pathname === "/api/production/delivery-channels") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createDeliveryChannel(context, await readBody(req))); } const deliveryChannelMatch = pathname.match(/^\/api\/production\/delivery-channels\/([^/]+)$/); if (req.method === "PATCH" && deliveryChannelMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, updateDeliveryChannel(context, decodeURIComponent(deliveryChannelMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/production/media-artifacts") { const context = resolveContext(req.headers, url.searchParams); if (!hasPermission(context, "delivery:view") && !hasPermission(context, "qa:review")) throw httpError(403, "permission_denied", "当前角色没有媒体证据访问权限"); return send(res, 200, { artifacts: listProjectArtifacts(context, { shotId: url.searchParams.get("shotId") || "", jobId: url.searchParams.get("jobId") || "", limit: url.searchParams.get("limit") || 200 }) }); } const mediaArtifactContentMatch = pathname.match(/^\/api\/production\/media-artifacts\/([^/]+)\/content$/); if (req.method === "GET" && mediaArtifactContentMatch) { const context = resolveContext(req.headers, url.searchParams); if (!hasPermission(context, "delivery:view") && !hasPermission(context, "qa:review")) throw httpError(403, "permission_denied", "当前角色没有媒体证据访问权限"); try { const payload = await readArtifactContent(context, decodeURIComponent(mediaArtifactContentMatch[1]), url.searchParams.get("frame") || ""); return sendBinary(res, 200, payload.content, payload.contentType, payload.fileName, { etag: `"${payload.contentSha256}"` }); } catch (error) { if (error.message === "media_artifact_not_found") throw httpError(404, "media_artifact_not_found", "媒体证据不存在或不属于当前项目"); if (error.message === "media_artifact_path_invalid") throw httpError(422, "media_artifact_path_invalid", "媒体证据路径不是 storage/ 下的安全路径"); if (error.code === "ENOENT") throw httpError(404, "media_artifact_content_missing", "媒体文件尚未写入本地存储", { path: error.path || "" }); throw error; } } if (req.method === "GET" && pathname === "/api/workflows/templates") { const context = resolveContext(req.headers, url.searchParams); if (!hasPermission(context, "workflow:manage") && !hasPermission(context, "job:create") && !hasPermission(context, "script:read") && !hasPermission(context, "project:create")) throw httpError(403, "permission_denied", "当前角色没有流程模板访问权限"); return send(res, 200, { templates: listWorkflowTemplates(context, { includeArchived: url.searchParams.get("includeArchived") === "1" }), runs: context.project ? listWorkflowRuns(context, { limit: 20 }) : [] }); } if (req.method === "POST" && pathname === "/api/workflows/templates") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "workflow:manage"); return send(res, 201, { template: createWorkflowTemplate(context, await readBody(req)), templates: listWorkflowTemplates(context) }); } const workflowTemplateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)$/); if (req.method === "PATCH" && workflowTemplateMatch) { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "workflow:manage"); return send(res, 200, { template: updateWorkflowTemplate(context, decodeURIComponent(workflowTemplateMatch[1]), await readBody(req)), templates: listWorkflowTemplates(context) }); } const workflowInstantiateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)\/instantiate$/); if (req.method === "POST" && workflowInstantiateMatch) { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "job:create"); return send(res, 201, await instantiateWorkflow(context, decodeURIComponent(workflowInstantiateMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/workflows/runs") { const context = resolveContext(req.headers, url.searchParams); if (!hasPermission(context, "job:create") && !hasPermission(context, "workflow:manage")) throw httpError(403, "permission_denied", "当前角色没有流程运行记录访问权限"); return send(res, 200, { runs: listWorkflowRuns(context, { status: url.searchParams.get("status") || "", limit: url.searchParams.get("limit") || 50 }) }); } if (req.method === "POST" && pathname === "/api/production/compose") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, await composeProject(context, await readBody(req))); } if (req.method === "POST" && pathname === "/api/production/qa/run") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, runAutomatedQa(context)); } if (req.method === "POST" && pathname === "/api/production/qa/media/run") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, await runMediaQa(context)); } const reviewDecisionMatch = pathname.match(/^\/api\/production\/reviews\/([^/]+)\/decision$/); if (req.method === "POST" && reviewDecisionMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, decideReview(context, decodeURIComponent(reviewDecisionMatch[1]), await readBody(req))); } const reviewCommentMatch = pathname.match(/^\/api\/production\/reviews\/([^/]+)\/comments$/); if (req.method === "POST" && reviewCommentMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, addReviewComment(context, decodeURIComponent(reviewCommentMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/production/deliveries") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listDeliveries(context)); } if (req.method === "POST" && pathname === "/api/production/deliveries") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createDelivery(context, await readBody(req))); } const deliveryClearanceMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/clearance$/); if (req.method === "GET" && deliveryClearanceMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, getDeliveryClearance(context, decodeURIComponent(deliveryClearanceMatch[1]))); } if (req.method === "POST" && deliveryClearanceMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, runDeliveryClearance(context, decodeURIComponent(deliveryClearanceMatch[1]), await readBody(req))); } const deliveryReleasesMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/releases$/); if (req.method === "GET" && deliveryReleasesMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listDeliveryReleases(context, decodeURIComponent(deliveryReleasesMatch[1]))); } if (req.method === "POST" && deliveryReleasesMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createDeliveryRelease(context, decodeURIComponent(deliveryReleasesMatch[1]), await readBody(req))); } const deliveryReleaseActionMatch = pathname.match(/^\/api\/production\/releases\/([^/]+)\/(decision|publish)$/); if (req.method === "POST" && deliveryReleaseActionMatch) { const context = resolveContext(req.headers, url.searchParams); const releaseId = decodeURIComponent(deliveryReleaseActionMatch[1]); const action = deliveryReleaseActionMatch[2]; if (action === "decision") return send(res, 200, decideDeliveryRelease(context, releaseId, await readBody(req))); return send(res, 200, await publishDeliveryRelease(context, releaseId)); } const deliveryAccessLinksMatch = pathname.match(/^\/api\/production\/releases\/([^/]+)\/access-links$/); if (req.method === "GET" && deliveryAccessLinksMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listDeliveryAccessLinks(context, decodeURIComponent(deliveryAccessLinksMatch[1]))); } if (req.method === "POST" && deliveryAccessLinksMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createDeliveryAccessLink(context, decodeURIComponent(deliveryAccessLinksMatch[1]), await readBody(req), { portalOrigin: frontendOrigin })); } const deliveryAccessFeedbackMatch = pathname.match(/^\/api\/production\/releases\/([^/]+)\/access-feedback$/); if (req.method === "GET" && deliveryAccessFeedbackMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listDeliveryAccessFeedback(context, decodeURIComponent(deliveryAccessFeedbackMatch[1]))); } const deliveryAccessLinkRevokeMatch = pathname.match(/^\/api\/production\/access-links\/([^/]+)\/revoke$/); if (req.method === "POST" && deliveryAccessLinkRevokeMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, revokeDeliveryAccessLink(context, decodeURIComponent(deliveryAccessLinkRevokeMatch[1]))); } const deliveryBatchesMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/batches$/); if (req.method === "GET" && deliveryBatchesMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listDeliveryBatches(context, decodeURIComponent(deliveryBatchesMatch[1]))); } if (req.method === "POST" && deliveryBatchesMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, createDeliveryBatch(context, decodeURIComponent(deliveryBatchesMatch[1]), await readBody(req))); } const deliveryBatchActionMatch = pathname.match(/^\/api\/production\/delivery-batches\/([^/]+)\/(activate|rollback)$/); if (req.method === "POST" && deliveryBatchActionMatch) { const context = resolveContext(req.headers, url.searchParams); const batchId = decodeURIComponent(deliveryBatchActionMatch[1]); const action = deliveryBatchActionMatch[2]; return send(res, 200, action === "activate" ? activateDeliveryBatch(context, batchId) : rollbackDeliveryBatch(context, batchId)); } const deliveryApproveMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/approve$/); if (req.method === "POST" && deliveryApproveMatch) { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, approveDelivery(context, decodeURIComponent(deliveryApproveMatch[1]), await readBody(req))); } if (req.method === "GET" && pathname === "/api/jobs") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "jobs:read"); else if (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "当前角色没有任务队列访问权限"); return send(res, 200, { jobs: listGenerationJobs(context, { status: url.searchParams.get("status"), limit: url.searchParams.get("limit") }) }); } if (req.method === "POST" && pathname === "/api/jobs") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "jobs:write"); return send(res, 201, await createGenerationJob(context, await readBody(req))); } if (req.method === "POST" && pathname === "/api/jobs/preview") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "jobs:read"); return send(res, 200, previewGenerationJob(context, await readBody(req))); } const jobDetailMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/); if (req.method === "GET" && jobDetailMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "jobs:read"); else if (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "当前角色没有任务详情访问权限"); return send(res, 200, { job: getGenerationJob(context, decodeURIComponent(jobDetailMatch[1])) }); } const jobRunMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/run$/); if (req.method === "POST" && jobRunMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "jobs:write"); return send(res, 200, await executeGenerationJob(context, decodeURIComponent(jobRunMatch[1]), await readBody(req))); } const jobActionMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/(retry|cancel|priority)$/); if (req.method === "POST" && jobActionMatch) { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "jobs:write"); return send(res, 200, updateJob(context, decodeURIComponent(jobActionMatch[1]), jobActionMatch[2], await readBody(req))); } if (req.method === "POST" && pathname === "/api/adapters/dry-run") { const context = resolveContext(req.headers, url.searchParams); if (context.apiClient) requireApiScope(context, "jobs:write"); requirePermission(context, "job:create"); const body = await readBody(req); const { project } = scopedProjectPayload(context); const shot = project.shots.find((item) => item.id === body.shotId) || project.shots[0]; const adapter = adapters.find((item) => item.id === body.adapterId) || adapters[0]; return send(res, 200, { ok: true, dryRun: true, request: buildModelRequest(project, shot, adapter), scope: { organizationId: context.organization.id, workspaceId: context.workspace.id, projectId: context.project?.id } }); } if (req.method === "POST" && pathname === "/api/exports/write") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "delivery:approve"); if (!context.project) throw httpError(400, "project_required", "导出必须绑定项目"); return send(res, 200, { ok: true, ...(await writeExports(context)) }); } return send(res, 404, { error: "not_found", path: pathname }); } catch (error) { const status = error.status || 500; return send(res, status, { error: error.code || "internal_error", detail: error.message, ...(error.details ? { details: error.details } : {}) }, error.headers || {}); } }).listen(port, "127.0.0.1", () => { startLocalWorker(); console.log(`AI drama local API ready: http://127.0.0.1:${port}`); console.log(`SQLite tenant database: ${databaseInfo.path}`); });