Files
ai-drama-platform/server/local-api.mjs
T
2026-09-01 14:38:42 +08:00

3148 lines
178 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { evaluateAndRecordOrganizationPolicies, listOrganizationPolicies, updateOrganizationPolicy } from "./organization-policies.mjs";
import { bindContractProject, createContract, createCustomer, evaluateProjectContractClearance, listContracts, listCustomers, updateContract, updateCustomer, upsertCustomerContact } from "./contracts.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 organizationCustomersMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers$/);
if (req.method === "GET" && organizationCustomersMatch) {
const organizationId = decodeURIComponent(organizationCustomersMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, listCustomers(context, organizationId, Object.fromEntries(url.searchParams.entries())));
}
if (req.method === "POST" && organizationCustomersMatch) {
const organizationId = decodeURIComponent(organizationCustomersMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 201, createCustomer(context, organizationId, await readBody(req)));
}
const organizationCustomerMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers\/([^/]+)$/);
if (req.method === "PATCH" && organizationCustomerMatch) {
const organizationId = decodeURIComponent(organizationCustomerMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, updateCustomer(context, organizationId, decodeURIComponent(organizationCustomerMatch[2]), await readBody(req)));
}
const organizationCustomerContactsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers\/([^/]+)\/contacts$/);
if (req.method === "POST" && organizationCustomerContactsMatch) {
const organizationId = decodeURIComponent(organizationCustomerContactsMatch[1]);
const customerId = decodeURIComponent(organizationCustomerContactsMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 201, upsertCustomerContact(context, organizationId, customerId, await readBody(req)));
}
const organizationContractClearanceMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/clearance$/);
if (req.method === "GET" && organizationContractClearanceMatch) {
const organizationId = decodeURIComponent(organizationContractClearanceMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, { clearance: evaluateProjectContractClearance(context, url.searchParams.get("projectId") || url.searchParams.get("project_id") || "", Object.fromEntries(url.searchParams.entries())) });
}
const organizationContractsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts$/);
if (req.method === "GET" && organizationContractsMatch) {
const organizationId = decodeURIComponent(organizationContractsMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, listContracts(context, organizationId, Object.fromEntries(url.searchParams.entries())));
}
if (req.method === "POST" && organizationContractsMatch) {
const organizationId = decodeURIComponent(organizationContractsMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 201, createContract(context, organizationId, await readBody(req)));
}
const organizationContractBindMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/([^/]+)\/bind-project$/);
if (req.method === "POST" && organizationContractBindMatch) {
const organizationId = decodeURIComponent(organizationContractBindMatch[1]);
const contractId = decodeURIComponent(organizationContractBindMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, bindContractProject(context, organizationId, contractId, await readBody(req)));
}
const organizationContractMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/([^/]+)$/);
if (req.method === "PATCH" && organizationContractMatch) {
const organizationId = decodeURIComponent(organizationContractMatch[1]);
const contractId = decodeURIComponent(organizationContractMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, updateContract(context, organizationId, contractId, await readBody(req)));
}
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 organizationPoliciesEvaluateMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/policies\/evaluate$/);
if (req.method === "POST" && organizationPoliciesEvaluateMatch) {
const organizationId = decodeURIComponent(organizationPoliciesEvaluateMatch[1]);
const context = contextWith({
organizationId,
workspaceId: req.headers["x-workspace-id"] || url.searchParams.get("workspaceId") || "",
projectId: req.headers["x-project-id"] || url.searchParams.get("projectId") || ""
}, req);
return send(res, 200, evaluateAndRecordOrganizationPolicies(context, organizationId, await readBody(req)));
}
const organizationPoliciesMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/policies(?:\/([^/]+))?$/);
if (req.method === "GET" && organizationPoliciesMatch && !organizationPoliciesMatch[2]) {
const organizationId = decodeURIComponent(organizationPoliciesMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, listOrganizationPolicies(context, organizationId, { limit: url.searchParams.get("limit") || 40 }));
}
if (req.method === "PATCH" && organizationPoliciesMatch?.[2]) {
const organizationId = decodeURIComponent(organizationPoliciesMatch[1]);
const policyKey = decodeURIComponent(organizationPoliciesMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, updateOrganizationPolicy(context, organizationId, policyKey, 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}`);
});