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