From ffb27d845b1d146725d289c94edfae7cbe2a9e68 Mon Sep 17 00:00:00 2001 From: xz Date: Mon, 24 Aug 2026 10:24:34 +0800 Subject: [PATCH] feat: bootstrap commercial AI drama platform --- .gitignore | 42 + README.md | 270 + config/adapters.example.json | 74 + deploy/.env.production.example | 23 + deploy/README.md | 18 + deploy/api.Dockerfile | 13 + deploy/docker-compose.production.yml | 132 + deploy/frontend.Dockerfile | 15 + deploy/nginx.conf | 31 + docs/API_REFERENCE.md | 316 + docs/ARCHITECTURE.md | 63 + docs/COMMERCIAL_PLATFORM_BLUEPRINT.md | 45 + docs/MARKET_RESEARCH_2026.md | 51 + docs/MULTI_TENANT_DESIGN.md | 106 + docs/NEWAPI_AUDIO_WORKFLOW.md | 72 + ...6-08-19-commercial-multitenant-platform.md | 59 + .../2026-08-21-commercial-creator-suite.md | 40 + exports/project-template/README.md | 15 + .../project-template/bible/series-bible.json | 10 + .../characters/character-locks.json | 80 + exports/project-template/edit/edit-list.json | 61 + .../final-video/delivery-manifest.json | 8 + .../locations/location-locks.json | 9 + .../project-template/props/prop-locks.json | 22 + exports/project-template/qa/qa-results.json | 92 + .../project-template/shots/prompt-pack.json | 146 + exports/project-template/shots/shot-list.json | 127 + .../project-template/voices/voice-lines.json | 134 + index.html | 12 + package-lock.json | 1937 ++++++ package.json | 58 + scripts/smoke-all.mjs | 57 + scripts/smoke-api-clients.mjs | 124 + scripts/smoke-api-media.mjs | 66 + scripts/smoke-api-rate-limit.mjs | 81 + scripts/smoke-audit.mjs | 60 + scripts/smoke-billing-ledger.mjs | 126 + scripts/smoke-commercial-governance.mjs | 100 + scripts/smoke-commercial-ops.mjs | 197 + scripts/smoke-compose-evidence.mjs | 46 + scripts/smoke-creator-suite.mjs | 214 + scripts/smoke-delivery-portal.mjs | 261 + scripts/smoke-device-risk.mjs | 119 + scripts/smoke-identity.mjs | 161 + scripts/smoke-invitations.mjs | 114 + scripts/smoke-media-evidence.mjs | 50 + scripts/smoke-mfa.mjs | 140 + scripts/smoke-model-connectors.mjs | 112 + scripts/smoke-notifications.mjs | 121 + scripts/smoke-oidc.mjs | 254 + scripts/smoke-ops.mjs | 118 + scripts/smoke-production-catalog.mjs | 80 + scripts/smoke-production-controls.mjs | 102 + scripts/smoke-project-isolation.mjs | 127 + scripts/smoke-project-lifecycle.mjs | 107 + scripts/smoke-readiness.mjs | 53 + scripts/smoke-release-workflow.mjs | 173 + scripts/smoke-security-events.mjs | 57 + scripts/smoke-system-users.mjs | 86 + scripts/smoke-task-collaboration.mjs | 106 + scripts/smoke-tasks.mjs | 110 + scripts/smoke-tenant.mjs | 197 + scripts/smoke-work-items.mjs | 99 + scripts/smoke-worker.mjs | 142 + scripts/write-sample-exports.mjs | 42 + server/api-client-secrets.mjs | 25 + server/auth.mjs | 760 +++ server/backup.mjs | 63 + server/composition.mjs | 131 + server/db.mjs | 595 ++ server/delivery-portal.mjs | 546 ++ server/execution.mjs | 605 ++ server/local-api.mjs | 2632 +++++++ server/media-artifacts.mjs | 291 + server/media-qa.mjs | 203 + server/notifications.mjs | 409 ++ server/oidc.mjs | 397 ++ server/production.mjs | 1546 +++++ server/rate-limit.mjs | 48 + server/readiness.mjs | 98 + server/saml.mjs | 205 + server/schema.sql | 1197 ++++ server/storage.mjs | 105 + server/tasks.mjs | 531 ++ server/tenant.mjs | 2738 ++++++++ server/work-items.mjs | 302 + server/worker.mjs | 282 + src/App.jsx | 1739 +++++ src/components/CreatorSuitePages.jsx | 701 ++ src/components/DeliveryPortalPage.jsx | 117 + src/components/EnterprisePages.jsx | 2538 +++++++ src/components/ProductionPages.jsx | 896 +++ src/components/ScenePreview.jsx | 27 + src/lib/api.js | 919 +++ src/lib/exporters.js | 153 + src/lib/qa.js | 104 + src/main.jsx | 10 + src/platform/platformData.js | 264 + src/styles.css | 6048 +++++++++++++++++ vite.config.js | 6 + 100 files changed, 35314 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/adapters.example.json create mode 100644 deploy/.env.production.example create mode 100644 deploy/README.md create mode 100644 deploy/api.Dockerfile create mode 100644 deploy/docker-compose.production.yml create mode 100644 deploy/frontend.Dockerfile create mode 100644 deploy/nginx.conf create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/COMMERCIAL_PLATFORM_BLUEPRINT.md create mode 100644 docs/MARKET_RESEARCH_2026.md create mode 100644 docs/MULTI_TENANT_DESIGN.md create mode 100644 docs/NEWAPI_AUDIO_WORKFLOW.md create mode 100644 docs/superpowers/plans/2026-08-19-commercial-multitenant-platform.md create mode 100644 docs/superpowers/plans/2026-08-21-commercial-creator-suite.md create mode 100644 exports/project-template/README.md create mode 100644 exports/project-template/bible/series-bible.json create mode 100644 exports/project-template/characters/character-locks.json create mode 100644 exports/project-template/edit/edit-list.json create mode 100644 exports/project-template/final-video/delivery-manifest.json create mode 100644 exports/project-template/locations/location-locks.json create mode 100644 exports/project-template/props/prop-locks.json create mode 100644 exports/project-template/qa/qa-results.json create mode 100644 exports/project-template/shots/prompt-pack.json create mode 100644 exports/project-template/shots/shot-list.json create mode 100644 exports/project-template/voices/voice-lines.json create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/smoke-all.mjs create mode 100644 scripts/smoke-api-clients.mjs create mode 100644 scripts/smoke-api-media.mjs create mode 100644 scripts/smoke-api-rate-limit.mjs create mode 100644 scripts/smoke-audit.mjs create mode 100644 scripts/smoke-billing-ledger.mjs create mode 100644 scripts/smoke-commercial-governance.mjs create mode 100644 scripts/smoke-commercial-ops.mjs create mode 100644 scripts/smoke-compose-evidence.mjs create mode 100644 scripts/smoke-creator-suite.mjs create mode 100644 scripts/smoke-delivery-portal.mjs create mode 100644 scripts/smoke-device-risk.mjs create mode 100644 scripts/smoke-identity.mjs create mode 100644 scripts/smoke-invitations.mjs create mode 100644 scripts/smoke-media-evidence.mjs create mode 100644 scripts/smoke-mfa.mjs create mode 100644 scripts/smoke-model-connectors.mjs create mode 100644 scripts/smoke-notifications.mjs create mode 100644 scripts/smoke-oidc.mjs create mode 100644 scripts/smoke-ops.mjs create mode 100644 scripts/smoke-production-catalog.mjs create mode 100644 scripts/smoke-production-controls.mjs create mode 100644 scripts/smoke-project-isolation.mjs create mode 100644 scripts/smoke-project-lifecycle.mjs create mode 100644 scripts/smoke-readiness.mjs create mode 100644 scripts/smoke-release-workflow.mjs create mode 100644 scripts/smoke-security-events.mjs create mode 100644 scripts/smoke-system-users.mjs create mode 100644 scripts/smoke-task-collaboration.mjs create mode 100644 scripts/smoke-tasks.mjs create mode 100644 scripts/smoke-tenant.mjs create mode 100644 scripts/smoke-work-items.mjs create mode 100644 scripts/smoke-worker.mjs create mode 100644 scripts/write-sample-exports.mjs create mode 100644 server/api-client-secrets.mjs create mode 100644 server/auth.mjs create mode 100644 server/backup.mjs create mode 100644 server/composition.mjs create mode 100644 server/db.mjs create mode 100644 server/delivery-portal.mjs create mode 100644 server/execution.mjs create mode 100644 server/local-api.mjs create mode 100644 server/media-artifacts.mjs create mode 100644 server/media-qa.mjs create mode 100644 server/notifications.mjs create mode 100644 server/oidc.mjs create mode 100644 server/production.mjs create mode 100644 server/rate-limit.mjs create mode 100644 server/readiness.mjs create mode 100644 server/saml.mjs create mode 100644 server/schema.sql create mode 100644 server/storage.mjs create mode 100644 server/tasks.mjs create mode 100644 server/tenant.mjs create mode 100644 server/work-items.mjs create mode 100644 server/worker.mjs create mode 100644 src/App.jsx create mode 100644 src/components/CreatorSuitePages.jsx create mode 100644 src/components/DeliveryPortalPage.jsx create mode 100644 src/components/EnterprisePages.jsx create mode 100644 src/components/ProductionPages.jsx create mode 100644 src/components/ScenePreview.jsx create mode 100644 src/lib/api.js create mode 100644 src/lib/exporters.js create mode 100644 src/lib/qa.js create mode 100644 src/main.jsx create mode 100644 src/platform/platformData.js create mode 100644 src/styles.css create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..658013d --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies and build output +node_modules/ +dist/ +.vite/ + +# Local configuration and secrets +.env +.env.* +!.env.example +!deploy/.env.production.example +*.local + +# Runtime state and generated media +data/ +storage/ +uploads/ +backups/ +*.sqlite +*.sqlite-shm +*.sqlite-wal +*.db +*.db-shm +*.db-wal + +# Local QA artifacts +qa-*.png +qa-*.jpg +qa-*.jpeg +playwright-report/ +test-results/ + +# OS/editor files +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo + +# Logs and temporary files +*.log +*.tmp +*.bak diff --git a/README.md b/README.md new file mode 100644 index 0000000..0538735 --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +# AI 短剧本地生产平台 + +这是一个面向私有部署和商业团队的本地化 AI 短剧/漫剧生产平台 MVP,不是单剧集页面。平台把组织、工作区、成员、角色、项目访问、剧本、角色锁、场景锁、道具锁、分镜、台词、生成任务、质检门、成本、合规、审计和剪辑交付放进同一个生产系统。 + +当前已经具备: + +- 三类产品入口:创作空间、管理员后台、系统设置控制面;分别服务创作者日常生产、组织治理和部署级配置。 +- 多组织:一个用户可以属于多个组织,组织之间的项目、成员、模型和审计数据隔离。 +- 多工作区:制作部、素材实验室、客户空间可以在同一组织下独立管理。 +- 多用户与角色:组织所有者、组织管理员、制片、编剧、资产美术、配音/字幕、审片、项目编辑和项目查看者。 +- 真实 scope API:所有项目、任务、模型、审片、导出请求都经过 user → organization → workspace → project 检查。 +- 平台级系统治理:系统配置、功能开关、通知渠道、API 客户端、服务健康和队列运营;系统权限独立于组织管理员。 +- 企业身份中心:TOTP MFA、登录策略、OIDC/SAML 提供商登记与发现探测、SCIM 目录令牌轮换和受令牌保护的用户同步接口。 +- 用户通知中心:按用户、组织、工作区隔离的通知收件箱、未读计数、单条/全部已读、组织级通知偏好和服务端收件人过滤。 +- 协作任务中心:项目内任务创建、负责人分派、优先级、截止时间、状态、本人任务更新、任务通知和审计记录。 +- 资产版本台账:资产版本不可覆盖,支持当前版本切换、历史版本恢复、锁定状态和镜头绑定审计。 +- 本地持久化:Node 24 内置 SQLite 自动初始化到 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/data/platform.sqlite`。 +- 本地模型优先:自有模型平台是主适配器,OpenAI-compatible / 自定义 HTTP JSON 可接入,ComfyUI 只是 optional adapter。 +- 本地执行器:API 启动时自动拉起 Worker,支持租约、并发限制、心跳、依赖阻塞、重试记录和管理员手动领取。 + +## 运行 + +```bash +cd /Users/xz/Documents/daima/ai短剧/ai-drama-platform +npm install +npm run dev +``` + +本地 API 服务: + +```bash +cd /Users/xz/Documents/daima/ai短剧/ai-drama-platform +npm run api +``` + +需要保持两个进程:Vite 前端和本地 API。API 进程会自动启动本地 Worker;只有需要独立调试 Worker 时才单独使用 `npm run worker`。启动后访问: + +```text +前端:http://127.0.0.1:5173/ +API:http://127.0.0.1:8787/api/health +``` + +完整回归(必须串行执行,共 25 项,避免 SQLite 写入竞争): + +```bash +npm run smoke:all +``` + +API 默认每个令牌或匿名来源每分钟允许 120 次请求,可在系统配置中修改 `api.rate_limit_per_minute`;设置为 `0` 表示关闭限流。超限响应为 `429 rate_limit_exceeded`,并返回 `Retry-After` 与 `X-RateLimit-*` 响应头。当前本地模式使用单 API 进程内固定窗口计数,多实例部署应切换到 Redis/共享限流存储。 + +该命令覆盖租户隔离、MFA、OIDC、系统用户、商业配额、邀请、模型/Runner、API 客户端密钥轮换、任务队列、媒体证据、合成证据和项目生命周期;每个临时 smoke 用户、邀请和任务都会在成功或失败后清理。 + +API 默认地址: + +```text +http://127.0.0.1:8787/api/health +http://127.0.0.1:8787/api/project +http://127.0.0.1:8787/api/qa +POST http://127.0.0.1:8787/api/auth/login +GET http://127.0.0.1:8787/api/auth/session +POST http://127.0.0.1:8787/api/auth/logout +GET http://127.0.0.1:8787/api/auth/security-events +GET http://127.0.0.1:8787/api/system/security-events?userId= +POST http://127.0.0.1:8787/api/auth/login/mfa +GET http://127.0.0.1:8787/api/auth/mfa +POST http://127.0.0.1:8787/api/auth/mfa/setup +POST http://127.0.0.1:8787/api/auth/mfa/enable +POST http://127.0.0.1:8787/api/auth/mfa/disable +GET http://127.0.0.1:8787/api/auth/sso/providers +GET http://127.0.0.1:8787/api/auth/sso/start?providerId=:id&returnTo=/ +GET http://127.0.0.1:8787/api/auth/sso/callback +GET http://127.0.0.1:8787/api/auth/sso/saml/metadata?providerId=:id +POST http://127.0.0.1:8787/api/auth/sso/saml/acs +POST http://127.0.0.1:8787/api/auth/sso/redeem +GET http://127.0.0.1:8787/api/auth/sessions +POST http://127.0.0.1:8787/api/auth/sessions/:sessionId/revoke +POST http://127.0.0.1:8787/api/auth/sessions/revoke-others +POST http://127.0.0.1:8787/api/auth/password +GET http://127.0.0.1:8787/api/context +GET http://127.0.0.1:8787/api/tasks?status=all&assignedTo=me&limit=100 +POST http://127.0.0.1:8787/api/tasks +PATCH http://127.0.0.1:8787/api/tasks/:taskId +GET http://127.0.0.1:8787/api/notifications?limit=60&unreadOnly=1 +PATCH http://127.0.0.1:8787/api/notifications/:notificationId +POST http://127.0.0.1:8787/api/notifications/read-all +GET http://127.0.0.1:8787/api/notification-preferences +PATCH http://127.0.0.1:8787/api/notification-preferences/:category +GET http://127.0.0.1:8787/api/organizations +GET http://127.0.0.1:8787/api/organizations/:orgId +POST http://127.0.0.1:8787/api/organizations/:orgId/invitations +GET http://127.0.0.1:8787/api/organizations/:orgId/members +PATCH http://127.0.0.1:8787/api/organizations/:orgId/members/:userId +GET http://127.0.0.1:8787/api/invitations +POST http://127.0.0.1:8787/api/invitations/:invitationId/accept +GET http://127.0.0.1:8787/api/workspaces +POST http://127.0.0.1:8787/api/workspaces +GET http://127.0.0.1:8787/api/workspaces/:workspaceId/members +PATCH http://127.0.0.1:8787/api/workspaces/:workspaceId/members/:userId +GET http://127.0.0.1:8787/api/projects +POST http://127.0.0.1:8787/api/projects +GET http://127.0.0.1:8787/api/projects/:projectId/members +PATCH http://127.0.0.1:8787/api/projects/:projectId/members/:userId +GET http://127.0.0.1:8787/api/usage +GET http://127.0.0.1:8787/api/billing +GET http://127.0.0.1:8787/api/audit +GET http://127.0.0.1:8787/api/audit/:auditId +GET http://127.0.0.1:8787/api/audit/export +GET http://127.0.0.1:8787/api/permissions +GET http://127.0.0.1:8787/api/assets?kind=voice +POST http://127.0.0.1:8787/api/assets/upload +GET http://127.0.0.1:8787/api/assets/:assetId/content +POST http://127.0.0.1:8787/api/assets/:assetId/versions +POST http://127.0.0.1:8787/api/assets/:assetId/versions/upload +POST http://127.0.0.1:8787/api/assets/:assetId/verify +POST http://127.0.0.1:8787/api/assets/:assetId/versions/:versionId/restore +POST http://127.0.0.1:8787/api/assets/:assetId/lock +POST http://127.0.0.1:8787/api/assets/:assetId/rights +POST http://127.0.0.1:8787/api/assets/:assetId/bindings +GET http://127.0.0.1:8787/api/production/graph +GET http://127.0.0.1:8787/api/production/catalog +POST http://127.0.0.1:8787/api/production/seasons +POST http://127.0.0.1:8787/api/production/episodes +PATCH http://127.0.0.1:8787/api/production/episodes/:episodeId +POST http://127.0.0.1:8787/api/production/script/import +POST http://127.0.0.1:8787/api/production/script/materialize +PATCH http://127.0.0.1:8787/api/production/bible +POST http://127.0.0.1:8787/api/production/shots +PATCH http://127.0.0.1:8787/api/production/shots/:shotId +POST http://127.0.0.1:8787/api/production/shots/:shotId/prompt-versions +GET http://127.0.0.1:8787/api/production/reviews +POST http://127.0.0.1:8787/api/production/qa/run +POST http://127.0.0.1:8787/api/production/reviews/:reviewId/decision +POST http://127.0.0.1:8787/api/production/reviews/:reviewId/comments +GET http://127.0.0.1:8787/api/production/deliveries +POST http://127.0.0.1:8787/api/production/deliveries +POST http://127.0.0.1:8787/api/production/deliveries/:deliveryId/approve +GET http://127.0.0.1:8787/api/admin/queue +GET http://127.0.0.1:8787/api/platform/models +POST http://127.0.0.1:8787/api/platform/models/register +PATCH http://127.0.0.1:8787/api/platform/models/:modelId +POST http://127.0.0.1:8787/api/platform/models/:modelId/probe +GET http://127.0.0.1:8787/api/system/config +POST http://127.0.0.1:8787/api/system/config +GET http://127.0.0.1:8787/api/system/health +GET http://127.0.0.1:8787/api/system/readiness +GET http://127.0.0.1:8787/api/system/backups +POST http://127.0.0.1:8787/api/system/backups +POST http://127.0.0.1:8787/api/system/health/:serviceKey/action +GET http://127.0.0.1:8787/api/system/feature-flags +POST http://127.0.0.1:8787/api/system/feature-flags +GET http://127.0.0.1:8787/api/system/notifications +POST http://127.0.0.1:8787/api/system/notifications +GET http://127.0.0.1:8787/api/system/api-clients +POST http://127.0.0.1:8787/api/system/api-clients +PATCH http://127.0.0.1:8787/api/system/api-clients/:clientId +POST http://127.0.0.1:8787/api/system/api-clients/:clientId/rotate +GET http://127.0.0.1:8787/api/jobs +GET http://127.0.0.1:8787/api/jobs/:jobId +POST http://127.0.0.1:8787/api/jobs +POST http://127.0.0.1:8787/api/jobs/:jobId/run +POST http://127.0.0.1:8787/api/jobs/:jobId/retry +POST http://127.0.0.1:8787/api/jobs/:jobId/cancel +POST http://127.0.0.1:8787/api/jobs/:jobId/priority +POST http://127.0.0.1:8787/api/adapters/dry-run +POST http://127.0.0.1:8787/api/exports/write +GET http://127.0.0.1:8787/api/system/worker +POST http://127.0.0.1:8787/api/system/worker/dispatch +``` + +前端模块支持 `#creator-home`、`#factory`、`#script`、`#casting`、`#director`、`#jobs`、`#bible`、`#qa`、`#export`、`#admin-*` 和 `#system-*` 深链接;移动端使用抽屉导航,不会把整套后台菜单堆在内容之前。 + +## 季 / 集主数据 + +系列 Bible 页面现在按商业剧集目录管理 `series → seasons → episodes → shots`: + +- 一个项目可以创建多季、多集,每集创建时自动初始化一个镜头草稿,避免空集无法进入生产。 +- 当前集选择会通过 `episodeId` 传给生产图谱;剧本版本、镜头、Prompt 和返回的图谱都按当前集隔离。 +- 分集状态支持草稿、制作中、审片中、已通过和已归档;标题、时长、开场钩子和结尾悬念都写入 SQLite 并记录审计日志。 +- 服务端创建季、创建集、修改集都经过 `script:edit` 权限检查,不能靠前端隐藏绕过。 +- `npm run smoke:production-catalog` 会验证目录读取、创建季、创建集、首个镜头初始化、集级图谱切换和分集更新;该脚本会清理自己的临时数据。 + +## 用户鉴权与访问边界 + +平台默认使用真实的邮箱 + 密码登录和 Bearer session。会话默认有效 12 小时,失败 5 次会暂时锁定;所有业务 API 都先解析 session,再执行用户 → 组织 → 工作区 → 项目 → 权限检查。浏览器端不会因为知道组织 ID 就获得跨组织访问权。 + +仅在本地接口调试或冒烟测试时,显式设置 `AI_DRAMA_ALLOW_DEV_CONTEXT=1` 才会启用请求头上下文 bypass;默认值是 session-only,正式部署应保持 `AI_DRAMA_ALLOW_DEV_CONTEXT=0` 或不设置。 + +认证接口: + +```text +POST http://127.0.0.1:8787/api/auth/login +GET http://127.0.0.1:8787/api/auth/session +POST http://127.0.0.1:8787/api/auth/logout +``` + +本地演示账号统一密码:`Demo@123456`。 + +```text +系统管理员:producer@local.test +组织管理员 / 制片:producer2@local.test +普通编剧:writer@local.test +``` + +访问范围: + +- 普通创作者:我的工作台,以及被授予的剧本、资产、导演、生成、审片或交付页面;未授权的入口、按钮和敏感数据会隐藏,API 同时返回 403。 +- 制片 / 组织管理员:本组织的组织、工作区、成员、项目、模型、队列、用量和审计能力,数据不会跨组织返回。 +- 系统管理员:在组织权限之上,额外管理部署、存储、全局生成策略、通知、API 客户端、功能开关和系统健康。 + +系统级权限单独记录在 `system_admins` 表。组织管理员不会因为组织角色自动获得全局部署策略权限。 + +API 客户端密钥:完整密钥只在创建或轮换成功响应中返回一次;SQLite 仅保存 SHA-256 摘要、版本号和不可用的前缀预览,客户端目录不会再次返回完整密钥。轮换会立即撤销旧密钥,撤销或暂停状态的客户端不能调用业务 API。 + +API 客户端 scope 由后端白名单强制执行:`jobs:read` 只能读取任务,`jobs:write` 才能创建/执行/重试/取消任务,`models:read` 只能读取连接器,`models:write` 才能登记/修改/探测连接器,`audit:read` 才能读取和导出审计日志。普通 Bearer session 不受 API 客户端 scope 规则影响。 + +平台管理页顶部的组织、工作区和项目切换器会实际刷新 API scope;邀请成员、创建工作区、创建项目和生成任务都会写入 SQLite,并产生审计/用量记录。 + +通知中心按当前用户和组织保存消息,工作区/项目消息会继续经过服务端作用域过滤。用户可以按生成任务、审片、交付、协作任务、组织访问、用量配额和系统通知分别关闭站内提醒;关闭偏好后,服务端不会继续写入该用户的对应收件箱。 + +协作任务中心与“我的待办”互通:项目负责人可以创建和分派任务,普通成员只能更新自己负责任务的状态,任务状态会回写到项目审计和站内通知。 + +账号安全页支持修改密码、查看最近登录设备、撤销单个其他会话、撤销其他全部会话和接受组织邀请;会话撤销操作由后端执行并写入审计日志。独立的 `auth_security_events` 台账记录登录成功/失败、账号锁定、MFA 挑战与验证、MFA 开关、会话创建/撤销/退出和密码变更;普通用户只能读取自己的事件,系统管理员可按用户读取全局事件,API 客户端不能读取个人安全事件。 + +系统设置中的“企业身份”页面支持保存登录策略、登记 OIDC/SAML 提供商、用 OIDC discovery 或 SAML 环境配置探测提供商、创建和轮换 SCIM 令牌。SAML 使用 HTTP-Redirect AuthnRequest + HTTP-POST ACS:IdP 证书只从服务端环境变量引用读取,断言必须通过签名、Audience、时间窗口和 `InResponseTo` 校验,再进入组织/工作区入组、MFA 和一次性 Bearer ticket。ACS 不接受前端提交的用户身份。SCIM 接口为 `/scim/v2.0/:directoryId/Users`,支持用户列表、新增、部分更新和停用;目录令牌只在创建或轮换响应中返回一次,数据库只保存哈希。MFA 密钥使用 AES-GCM 加密存储,可通过 `AI_DRAMA_MFA_ENCRYPTION_KEY` 指定独立加密密钥。 + +登录后入口读取当前用户有权访问的组织、工作区和项目;新组织或空工作区会进入空项目工厂,不会自动复制其他项目的角色、资产、镜头或任务。仓库内的《雷雨口》只用于本地种子数据和回归测试。当前平台不会主动调用任何云端或付费节点;生成适配器配置在: + +```text +/Users/xz/Documents/daima/ai短剧/ai-drama-platform/config/adapters.example.json +``` + +## 平台边界 + +- 主适配器是用户自有模型平台,支持 HTTP、自定义 JSON、OpenAI-compatible 形态。 +- 已确认的音频生产链路走 NewAPI OpenAI-compatible 中转:`IndexTTS-2.5` 用于中文角色配音和情绪控制,`paraformer-zh-long` 用于中文 ASR、台词校验和字幕时间轴;密钥只从 `NEWAPI_API_KEY` 环境变量读取,不写入仓库。 +- ComfyUI 只是 optional adapter,用来复用旧的 Qwen/QwenEdit/H3 首尾帧桥接经验。 +- 一次生成只允许一张完整单画面;分镜图、接触表、边界表只能审核,不能喂回生成。 +- 声音作为独立资产锁定,不把 MiniMax H3 随机原生声音作为最终角色声线。 +- 当前平台底座已具备真实任务合同、队列、重试、取消、租约、并发控制和 HTTP Runner 执行接口;API 启动时自动运行本地 Worker,只会领取本地 `ready` 连接器任务。若未配置可用的自有图片/视频/TTS/ASR Runner,任务会明确显示 `not-connected`/`blocked`,不会伪称已经生成真实媒体成片。 +- 模型协议、Worker 环境变量、状态字段和接口返回约定见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/API_REFERENCE.md`。 +- 本地验证脚本会修改 SQLite,因此多个 smoke 脚本应顺序执行;并行写入会触发 SQLite 的正常写锁保护。 + +## 私有商业部署 + +生产编排 profile 位于 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/deploy/`,包含 API、独立 Worker、Nginx 前端、PostgreSQL、Redis 和 MinIO/S3-compatible 对象存储。当前业务代码的数据库真源仍是 Node 24 SQLite;compose 会先把目标基础设施和连接契约部署起来,但不会伪称已经完成 PostgreSQL、Redis 或对象存储运行时切换。系统管理员可以在系统总览查看生产就绪度,并通过 `/api/system/backups` 创建可审计的 SQLite 快照。具体边界和启动命令见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/deploy/README.md`。 + +NewAPI 音频请求格式见: + +```text +/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/NEWAPI_AUDIO_WORKFLOW.md +``` + +## 商业平台研究 + +功能研究与本地版能力映射见: + +```text +/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/MARKET_RESEARCH_2026.md +/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/COMMERCIAL_PLATFORM_BLUEPRINT.md +/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/MULTI_TENANT_DESIGN.md +/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/superpowers/plans/2026-08-19-commercial-multitenant-platform.md +``` + +## 输出目录 + +```text +/Users/xz/Documents/daima/ai短剧/ai-drama-platform/exports/ +``` + +每个项目单独拥有一个子目录,子目录对应 series bible、角色、场景、道具、分镜、配音、QA、剪辑工程和最终视频交付;服务端不会把一个项目的导出写进另一个项目目录。 diff --git a/config/adapters.example.json b/config/adapters.example.json new file mode 100644 index 0000000..393e847 --- /dev/null +++ b/config/adapters.example.json @@ -0,0 +1,74 @@ +{ + "defaultAdapter": "owned-model-platform", + "adapters": [ + { + "id": "owned-model-platform", + "label": "用户自有模型平台", + "kind": "custom-json-http", + "enabled": true, + "baseUrl": "http://127.0.0.1:7860", + "routes": { + "textToShot": "/api/drama/shot", + "image": "/api/generate/image", + "imageEdit": "/api/generate/image-edit", + "imageToVideo": "/api/generate/i2v", + "tts": "/api/voice/tts", + "compose": "/api/video/compose" + }, + "auth": { + "type": "none" + }, + "notes": "主适配器。允许 HTTP/OpenAI-compatible/自定义 JSON 映射,不默认依赖云端。" + }, + { + "id": "openai-compatible-local", + "label": "OpenAI-compatible 本地网关", + "kind": "openai-compatible", + "enabled": false, + "baseUrl": "http://127.0.0.1:8000/v1", + "models": { + "script": "local-qwen-longwriter", + "vision": "local-qwen-vl", + "tts": "local-tts-voice-lock" + }, + "notes": "用于把自有网关伪装成 OpenAI API 形态。" + }, + { + "id": "newapi-audio-production", + "label": "NewAPI 音频中转", + "kind": "openai-compatible-audio", + "enabled": false, + "baseUrl": "https://newapi.ysblack.com/v1", + "auth": { + "type": "bearer", + "env": "NEWAPI_API_KEY" + }, + "models": { + "tts": "IndexTTS-2.5", + "asr": "paraformer-zh-long" + }, + "routes": { + "tts": "/audio/speech", + "asr": "/audio/transcriptions" + }, + "requestContracts": { + "tts": "multipart/form-data with model, input, voice, response_format, optional speed, optional kwargs, and prompt_speech file", + "asr": "multipart/form-data with model, file, language=zh, response_format=verbose_json" + }, + "notes": "用户已部署的 Xinference 模型通过 NewAPI 中转。IndexTTS-2.5 用 prompt_speech 参考音频锁定角色声线;paraformer-zh-long 返回中文识别文本和词级时间戳。示例只声明 NEWAPI_API_KEY 环境变量,不保存密钥。" + }, + { + "id": "comfyui-optional", + "label": "ComfyUI 可选适配器", + "kind": "comfyui", + "enabled": false, + "baseUrl": "http://127.0.0.1:8188", + "workflowRefs": { + "qwenImage": "../codex_qu_2026-08-15/work/user-comfy-workflows-ai-drama-painting/AI短剧__绘画__04_QwenImage2512高质量_官方.json", + "qwenEdit": "../codex_qu_2026-08-15/work/06_QwenEdit2511_动漫连续分镜_FP8Mixed_40步_本地.json", + "h3Bridge": "../codex_qu_2026-08-15/work/rebuild_no_narration_v1.py" + }, + "notes": "仅作为可选桥接。默认不使用付费或外部 API 节点。" + } + ] +} diff --git a/deploy/.env.production.example b/deploy/.env.production.example new file mode 100644 index 0000000..6526f1b --- /dev/null +++ b/deploy/.env.production.example @@ -0,0 +1,23 @@ +COMPOSE_PROJECT_NAME=ai-drama-platform +PUBLIC_ORIGIN=http://localhost +HTTP_PORT=80 +VITE_API_BASE= + +# Application secrets. Generate long random values; do not commit the real file. +AI_DRAMA_SESSION_SECRET=replace-with-a-long-random-secret +AI_DRAMA_OIDC_STORAGE_KEY=replace-with-a-different-long-random-secret +AI_DRAMA_MFA_ENCRYPTION_KEY=replace-with-32-byte-base64-or-hex-key + +# Optional SAML IdP certificate. It can be PEM text or base64 certificate content. +AI_DRAMA_SAML_IDP_CERT= + +# Provisioned provider contract. Current business runtime remains SQLite. +POSTGRES_DB=ai_drama +POSTGRES_USER=ai_drama +POSTGRES_PASSWORD=replace-with-postgres-password +REDIS_PASSWORD=replace-with-redis-password +MINIO_ROOT_USER=ai-drama-admin +MINIO_ROOT_PASSWORD=replace-with-minio-password +MINIO_BUCKET=ai-drama +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..77adb68 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,18 @@ +# 私有商业部署 Profile + +`docker-compose.production.yml` 提供一套可审计的私有部署边界:前端 Nginx、API、独立 Worker、PostgreSQL、Redis 和 MinIO/S3-compatible 对象存储。 + +当前代码的业务数据库仍由 `server/db.mjs` 使用 Node 24 SQLite 驱动,生产 compose 会把 PostgreSQL、Redis 和对象存储启动起来并注入连接契约,但不会把 SQLite 伪装成 PostgreSQL 运行时。完成 PostgreSQL/Redis/对象存储 provider 迁移后,才可以把对应 `PLATFORM_*` 变量切换为业务真源。 + +## 启动 + +```bash +cd /Users/xz/Documents/daima/ai短剧/ai-drama-platform +cp deploy/.env.production.example .env.production +# 编辑 .env.production,替换全部 replace-with-* 值 +docker compose --env-file .env.production -f deploy/docker-compose.production.yml up -d --build +``` + +默认入口是 `http://localhost/`。第一次上线前应额外完成 HTTPS 反向代理、备份策略、把 MinIO 镜像替换为经过验证的固定 digest、容器镜像签名、日志收集、PostgreSQL/Redis/对象存储 provider 迁移,以及自有模型 Runner 的网络隔离和审计批准。 + +本地 SQLite 运行时提供系统管理员快照接口:`POST /api/system/backups`。它只创建 `data/backups/` 下的 SQLite 文件并写审计,不替代生产环境的 PostgreSQL、对象存储和异地备份策略;部署上线前应验证快照可读性,并配置独立备份保留和恢复演练。 diff --git a/deploy/api.Dockerfile b/deploy/api.Dockerfile new file mode 100644 index 0000000..7117c0a --- /dev/null +++ b/deploy/api.Dockerfile @@ -0,0 +1,13 @@ +FROM node:24-bookworm-slim + +WORKDIR /app +ENV NODE_ENV=production + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . +RUN mkdir -p /app/data /app/storage /app/exports + +EXPOSE 8787 +CMD ["node", "server/local-api.mjs"] diff --git a/deploy/docker-compose.production.yml b/deploy/docker-compose.production.yml new file mode 100644 index 0000000..f19ba5f --- /dev/null +++ b/deploy/docker-compose.production.yml @@ -0,0 +1,132 @@ +name: ai-drama-platform + +# Deployment boundary for a commercial private installation. +# The current application runtime still uses SQLite through AI_DRAMA_DB_PATH. +# PostgreSQL, Redis and S3-compatible storage are provisioned here as the +# target provider contract; switching business runtime adapters is a separate +# implementation step and must not be inferred from this compose file. + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-ai_drama} + POSTGRES_USER: ${POSTGRES_USER:-ai_drama} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + object-storage: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD} + volumes: + - object-storage-data:/data + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + + api: + build: + context: .. + dockerfile: deploy/api.Dockerfile + restart: unless-stopped + environment: + AI_DRAMA_API_PORT: 8787 + AI_DRAMA_API_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost} + AI_DRAMA_FRONTEND_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost} + AI_DRAMA_DB_PATH: /app/data/platform.sqlite + AI_DRAMA_ALLOW_DEV_CONTEXT: "0" + AI_DRAMA_SESSION_SECRET: ${AI_DRAMA_SESSION_SECRET:?set AI_DRAMA_SESSION_SECRET} + AI_DRAMA_OIDC_STORAGE_KEY: ${AI_DRAMA_OIDC_STORAGE_KEY:?set AI_DRAMA_OIDC_STORAGE_KEY} + AI_DRAMA_MFA_ENCRYPTION_KEY: ${AI_DRAMA_MFA_ENCRYPTION_KEY:?set AI_DRAMA_MFA_ENCRYPTION_KEY} + AI_DRAMA_SAML_IDP_CERT: ${AI_DRAMA_SAML_IDP_CERT:-} + PLATFORM_POSTGRES_URL: postgresql://${POSTGRES_USER:-ai_drama}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ai_drama} + PLATFORM_REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 + PLATFORM_OBJECT_STORAGE_ENDPOINT: http://object-storage:9000 + PLATFORM_OBJECT_STORAGE_BUCKET: ${MINIO_BUCKET:-ai-drama} + PLATFORM_OBJECT_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER} + PLATFORM_OBJECT_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD} + volumes: + - platform-data:/app/data + - platform-storage:/app/storage + - platform-exports:/app/exports + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + object-storage: + condition: service_healthy + expose: + - "8787" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/api/health').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 12 + + worker: + build: + context: .. + dockerfile: deploy/api.Dockerfile + restart: unless-stopped + command: ["npm", "run", "worker"] + environment: + AI_DRAMA_DB_PATH: /app/data/platform.sqlite + AI_DRAMA_SESSION_SECRET: ${AI_DRAMA_SESSION_SECRET:?set AI_DRAMA_SESSION_SECRET} + PLATFORM_REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 + volumes: + - platform-data:/app/data + - platform-storage:/app/storage + - platform-exports:/app/exports + depends_on: + api: + condition: service_healthy + + frontend: + build: + context: .. + dockerfile: deploy/frontend.Dockerfile + args: + VITE_API_BASE: ${VITE_API_BASE:-} + restart: unless-stopped + ports: + - "${HTTP_PORT:-80}:80" + depends_on: + api: + condition: service_healthy + +volumes: + postgres-data: + redis-data: + object-storage-data: + platform-data: + platform-storage: + platform-exports: diff --git a/deploy/frontend.Dockerfile b/deploy/frontend.Dockerfile new file mode 100644 index 0000000..b8e5561 --- /dev/null +++ b/deploy/frontend.Dockerfile @@ -0,0 +1,15 @@ +FROM node:24-bookworm-slim AS build + +WORKDIR /app +ARG VITE_API_BASE= +ENV VITE_API_BASE=$VITE_API_BASE + +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..cbf71b0 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 2g; + } + + location /scim/ { + proxy_pass http://api:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 20m; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..e2f7cc6 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,316 @@ +# API 与本地执行器参考 + +## 认证 + +除健康检查和登录接口外,业务接口要求: + +```http +Authorization: Bearer +``` + +服务端会按以下顺序解析访问边界: + +```text +user → organization → workspace → project → permission +``` + +请求头中的 `x-user-id`、`x-organization-id`、`x-workspace-id` 和 `x-project-id` 只有在显式设置 `AI_DRAMA_ALLOW_DEV_CONTEXT=1` 时才可用于本地接口调试。正式部署不要启用该 bypass。 + +## 业务 API 分组 + +| 分组 | 主要接口 | 典型权限 | +| --- | --- | --- | +| 认证与会话 | `/api/auth/login`、`/api/auth/session`、`/api/auth/logout`、`/api/auth/password`、`/api/auth/sessions/*` | 登录用户 | +| MFA 与企业身份 | `/api/auth/login/mfa`、`/api/auth/mfa/*`、`/api/auth/sso/*`、`/api/system/identity/*` | MFA 管理或 `system:settings:*` | +| 租户上下文 | `/api/context`、`/api/organizations/*`、`/api/workspaces/*`、`/api/projects/*` | 按组织 / 工作区 / 项目角色 | +| 用户通知中心 | `/api/notifications`、`/api/notifications/:id`、`/api/notifications/read-all`、`/api/notification-preferences` | 登录用户 / 当前组织 | +| 内容生产 | `/api/production/catalog`、`/api/production/graph`、`/api/production/script/*`、`/api/production/bible`、`/api/production/shots/*` | `script:edit`、`asset:edit`、`job:create` | +| 资产与声音 | `/api/assets/*`、`/api/assets/:assetId/versions/upload`、`/api/assets/:assetId/verify`、`/api/assets/:assetId/versions/:versionId/restore` | `asset:edit`、`voice:edit` | +| 生成任务 | `/api/jobs`、`/api/jobs/:jobId/*`、`/api/adapters/dry-run` | `job:create` 或 `queue:manage` | +| 审片与交付 | `/api/production/reviews/*`、`/api/production/qa/run`、`/api/production/deliveries/*`、`/api/exports/write` | `qa:review`、`delivery:approve` | +| 组织运营 | `/api/usage`、`/api/billing`、`/api/organizations/:id/commercial`、`/api/organizations/:id/usage`、`/api/organizations/:id/usage/export`、`/api/organizations/:id/billing`、`/api/organizations/:id/quotas/:quotaId`、`/api/audit`、`/api/audit/export` | `usage:view`、`billing:manage`、`quota:manage`、`audit:view` | +| 模型中台 | `/api/platform/models`、`/api/platform/models/register`、`/api/platform/models/:id/probe` | `model:manage` | +| 全局用户治理 | `/api/system/users`、`/api/system/users/:userId`、`/api/system/users/:userId/revoke-sessions` | 仅系统管理员 | +| 系统设置 | `/api/system/config`、`/api/system/health`、`/api/system/readiness`、`/api/system/backups`、`/api/system/feature-flags`、`/api/system/notifications`、`/api/system/api-clients` | 系统管理员 / 对应系统权限 | + +未登录返回 `401`;已登录但没有作用域或权限返回 `403`。接口不会因为前端菜单隐藏就跳过后端检查。 + +### 审计与合规中心 + +```http +GET /api/audit?page=1&pageSize=25&query=&action=&targetType=&targetId=&result=&actorUserId=&from=YYYY-MM-DD&to=YYYY-MM-DD +GET /api/audit/:auditId +GET /api/audit/export?pageSize=500&format=json +GET /api/audit/export?pageSize=500&format=csv +``` + +审计列表由服务端分页并返回 `auditLog`、`pagination`、`facets` 和 `scope`。`org_owner` / `org_admin` 默认只能看当前组织;其他拥有审计读取权限的角色只能看当前工作区 / 项目及组织级事件;系统管理员获得全局视图。查询参数只负责缩小范围,不能扩大当前作用域;跨组织、跨工作区或跨项目筛选会返回 `403`。详情接口同时返回同一对象的关联审计和可关联的账号安全事件。导出支持 JSON 和 UTF-8 CSV,并复用相同的服务端权限与筛选条件。 + +### 用户通知中心与组织级偏好 + +通知中心是用户在当前组织、当前工作区内的收件箱,不等同于管理员配置的邮件或 Webhook 投递渠道。通知事件由服务端按组织成员、工作区/项目成员和事件类型计算收件人,前端不能扩大收件范围。 + +```http +GET /api/notifications?limit=60&unreadOnly=1 +PATCH /api/notifications/:notificationId # { "read": true | false } +POST /api/notifications/read-all +GET /api/notification-preferences +PATCH /api/notification-preferences/:category # { "enabled": true | false } +``` + +`limit` 会被服务端限制在 1-200;`unreadOnly=1` 只改变列表,不改变返回的 `unreadCount`。单条已读/未读和“全部已读”都只作用于当前用户、当前组织以及当前工作区可见的通知,跨用户、跨组织或不属于当前作用域的通知返回 `404`。 + +通知偏好按“用户 + 组织 + 类别”保存,切换组织后使用独立配置。当前类别包括:生成任务、审片、交付、协作任务、组织访问、用量配额和系统通知。服务端在创建用户通知前真正检查偏好,关闭某类后不会只在前端隐藏,而是不会写入该用户的收件箱;重新开启只影响后续事件,不会自动补发历史消息。 + +### 项目协作任务 + +协作任务是可分派、可追踪的项目记录,不等同于由生成任务或审片状态推导出来的 `/api/work-items` 待办。任务只能落在当前组织 / 工作区 / 项目作用域内,并保存标题、说明、类型、优先级、负责人、截止时间、关联页面、状态、完成时间和审计记录。 + +```http +GET /api/tasks?status=all&assignedTo=me&limit=100 +POST /api/tasks +PATCH /api/tasks/:taskId +``` + +`task:manage` 可以创建、分派和编辑任务;`task:complete` 的成员只能更新自己负责任务的状态,不能修改标题、负责人、优先级或截止时间;`task:view` 只能读取当前项目任务。任务分派会按用户通知偏好创建站内消息,任务状态变更和跨组织访问也会写入权限边界和审计流。项目归档后任务写操作返回 `409 project_archived`。 + +系统管理员配置的 `/api/system/notifications` 仍负责组织级本地日志/Webhook 渠道和投递审计;Webhook 默认只允许投递到本地私有 HTTP 地址,云端或付费节点不会被平台自动启用。 + +### 项目生命周期与只读归档 + +项目是可审计的生产边界,不是只有名称和状态的目录项。创建项目时会持久化 `template_id`,并立即初始化系列、第一季、试播集和一个 starter shot;新项目默认处于 `draft`。 + +```http +PATCH /api/projects/:projectId +POST /api/projects/:projectId/lifecycle +``` + +生命周期动作包括:`pause`、`resume`、`activate`、`submit-review`、`archive`、`restore`。归档前服务端会检查 `queued`、`running` 和 `blocked` 生成任务;存在未完成任务时返回 `409 project_has_active_jobs`。归档会保存 `archived_at`、`archived_by` 和 `archived_from_status`,恢复时回到归档前状态。 + +归档项目仍可读取生产图谱、任务历史、审计和交付资料,但剧本、资产、声音、生成、QA、交付审批等写操作由后端统一返回 `409 project_archived`。前端项目工厂只负责呈现可用操作,不能替代服务端权限边界。 + +### 系统生产就绪度与数据库快照 + +系统管理员可读取部署边界和当前运行时状态: + +```text +GET /api/system/readiness +GET /api/system/backups +POST /api/system/backups +``` + +`/api/system/readiness` 会明确区分当前激活运行时与目标 provider:业务数据库当前返回 `node:sqlite`,PostgreSQL、Redis、S3-compatible 对象存储只有在对应 `PLATFORM_*` 环境变量存在时标记为“已配置”,不会把 compose 注入误报成已经完成业务迁移。`POST /api/system/backups` 使用 SQLite `VACUUM INTO` 创建 `data/backups/` 下的快照,并写入 `system.database.backup_created` 审计事件;接口不提供在线恢复,恢复必须经过停机审查和人工确认。 + +### 商业运营、席位与配额 + +组织管理员或组织所有者可以读取当前组织的套餐、席位预留、工作区配额和本月用量: + +```text +GET /api/organizations/:organizationId/commercial +GET /api/organizations/:organizationId/commercial/export +PATCH /api/organizations/:organizationId/billing +PATCH /api/organizations/:organizationId/quotas/:quotaId +PATCH /api/organizations/:organizationId/cost-centers/:costCenterId +``` + +`billing` 更新接受 `planName`、`billingCycle`(monthly / quarterly / annual)、`currency`、`baseFee`、`seatUnitPrice`、`storageUnitPrice`、`clipUnitPrice`、`seatLimit`、`storageGb`、`monthlyClipQuota`、`quotaWarningPercent`、`localRunnerOnly` 和 `cloudConnectorsRequireApproval`。服务端会阻止席位低于活跃成员加待处理邀请、片段额度低于本月已用量、存储额度低于已记录用量的修改,并记录 `billing.account.updated` 审计事件和可查询的 `billing_account_events` 变更记录。 + +工作区配额不能超过组织套餐上限,也不能低于已用量;违反时分别返回 `409 quota_above_plan` 或 `409 quota_below_usage`。普通成员即使知道接口路径,也会收到 `403 permission_denied`,前端菜单隐藏不构成权限边界。 + +商业运营响应额外包含 `quotaWarnings`、`costCenters`、按工作区拆分的 `costCenterDetail`、`billingHistory` 和 `usageTrend`。`usageTrend` 是最近 31 天按自然日聚合的数组,每项为 `{ day, units, estimatedCost, events }`;没有事件的日期不会伪造为零值。成本中心预算由 `billing:manage` 控制,导出接口返回可归档的完整 JSON,不会把云端或付费连接器伪装成本地成本。 + +事件级用量中心用于账务核对、配额追踪和成本归属: + +```text +GET /api/organizations/:organizationId/usage?page=1&pageSize=25&from=YYYY-MM-DD&to=YYYY-MM-DD&workspaceId=&projectId=&userId=&kind=&unitName=&costCenter=&query= +GET /api/organizations/:organizationId/usage/export?format=json&from=YYYY-MM-DD&to=YYYY-MM-DD&workspaceId=&projectId=&userId=&kind=&unitName=&costCenter=&query= +GET /api/organizations/:organizationId/usage/export?format=csv&from=YYYY-MM-DD&to=YYYY-MM-DD&workspaceId=&projectId=&userId=&kind=&unitName=&costCenter=&query= +``` + +明细接口返回 `items`、`summary`、`pagination` 和 `facets`。每条事件包含时间、工作区、项目、操作者、事件类型、计量单位、估算成本、成本中心和元数据;筛选、分页和导出都在服务端执行。组织管理员可查看本组织范围,普通成员即使直接调用路径也会收到 `403`;成本中心和配额预警在管理台可以回跳到同一组明细筛选条件。 + +组织账单台账用于把套餐、席位、存储、片段和事件级本地计量固化为可审计的账期快照: + +```text +GET /api/organizations/:organizationId/invoices?status=&query=&page=1&pageSize=25 +GET /api/organizations/:organizationId/invoices/:invoiceId +POST /api/organizations/:organizationId/invoices/generate +POST /api/organizations/:organizationId/invoices/:invoiceId/status +GET /api/organizations/:organizationId/invoices/export?format=json|csv&status=&query= +``` + +只有拥有 `billing:manage` 的组织管理员或组织所有者可以生成账单、修改账单状态和编辑计价参数;拥有 `usage:view` 的角色可以读取账单台账。普通成员直接调用接口也会收到 `403 permission_denied`,跨组织读取会收到 `403 organization_forbidden`。 + +`generate` 默认按组织套餐周期生成当前账期草稿,也接受成对的 `periodStart` / `periodEnd`、`taxRate` 和 `dueDays`。同一组织同一账期通过数据库唯一约束保证幂等,重复生成返回原账单而不会覆盖原快照。账单状态机为 `draft -> issued -> paid`,`issued` 可以转为 `overdue` 或 `void`,`overdue` 可以补记为 `paid`;已支付和已作废账单不可逆修改。每次生成和状态变更都会写入账单事件及组织审计日志,详情接口会返回 `lines` 明细。 + +套餐接口的计价字段包括 `baseFee`、`seatUnitPrice`、`storageUnitPrice` 和 `clipUnitPrice`,默认值为 0;本地环境不会凭空产生收费。生成账单时会保存套餐、席位、工作区存储、片段用量、成本中心和税率快照,JSON/CSV 导出可用于后续财务系统适配。 + +### 组织邀请生命周期 + +组织管理员或拥有 `organization:members:invite` 的角色可以管理成员邀请: + +```text +GET /api/organizations/:organizationId +GET /api/organizations/:organizationId/members +POST /api/organizations/:organizationId/invitations +POST /api/organizations/:organizationId/invitations/:invitationId/resend +POST /api/organizations/:organizationId/invitations/:invitationId/revoke +GET /api/invitations/preview?token=:token +POST /api/auth/register +``` + +创建或重发邀请时,服务端只在当前响应返回一次性 `inviteToken` 和 `acceptUrl`;数据库保存的是令牌哈希和短提示,不保存明文令牌。邀请默认 7 天过期,过期邀请不会继续占用席位。重发会替换令牌哈希,使旧注册链接立即返回 `404 invitation_not_found`;撤销会清空令牌哈希、返回 `revoked`,并释放待入组席位。未登录预览仍可读取邀请范围,但注册时会再次校验令牌、邮箱和有效期。 + +普通成员调用组织邀请接口返回 `403 permission_denied`;重复邀请同一邮箱返回 `409 invitation_already_pending`,已是组织成员的邮箱返回 `409 invitation_recipient_already_member`。 + +### MFA + +- `POST /api/auth/login` 在账号启用 TOTP 后返回 `mfaRequired` 和短时 `challengeToken`,不创建正式会话。 +- `POST /api/auth/login/mfa` 使用 `challengeToken + code` 完成二次验证并创建 Bearer session;挑战 5 分钟过期,连续错误 5 次锁定。 +- `GET /api/auth/mfa`、`POST /api/auth/mfa/setup`、`POST /api/auth/mfa/enable`、`POST /api/auth/mfa/setup/cancel`、`POST /api/auth/mfa/disable` 只接受真实浏览器 session。 +- MFA 密钥使用 AES-GCM 加密;生产部署应设置独立的 `AI_DRAMA_MFA_ENCRYPTION_KEY`,不要依赖默认开发密钥。 + +### 企业身份中心 + +系统管理员可通过以下接口管理身份控制面: + +```text +GET /api/system/identity +PATCH /api/system/identity/policy +POST /api/system/identity/providers +PATCH /api/system/identity/providers/:providerId +POST /api/system/identity/providers/:providerId/probe +POST /api/system/identity/directory-syncs +PATCH /api/system/identity/directory-syncs/:directoryId +POST /api/system/identity/directory-syncs/:directoryId/rotate-token +GET /api/auth/sso/providers +GET /api/auth/sso/start?providerId=:id&returnTo=/ +GET /api/auth/sso/callback +POST /api/auth/sso/redeem +``` + +OIDC 已实现 Authorization Code + PKCE、state/nonce 一次性状态、ID Token 签名校验(JWKS)、claims 映射、按 Provider 绑定组织/工作区、自动创建成员和正式 Bearer session。回调不会把 session token 放进 URL,而是跳转到前端兑换 60 秒一次性票据;票据重放返回 `401`。如果用户启用了 MFA,票据兑换会返回现有 MFA challenge/enrollment challenge,再复用 `/api/auth/login/mfa` 或 `/api/auth/mfa/enroll/*`。 + +提供商配置只保存 `clientSecretRef` 或 `idpCertRef` 环境变量引用,不保存 Client Secret 或 SAML 证书正文。OIDC 已实现 discovery 探测、Authorization Code + PKCE、state/nonce、JWKS 签名校验和一次性 SSO ticket。SAML 已实现 HTTP-Redirect AuthnRequest、持久化 RelayState、HTTP-POST ACS、签名/Audience/时间窗口/`InResponseTo` 校验、组织/工作区入组、MFA 和一次性 SSO ticket;ACS 不接受前端提交的用户身份。 + +### 全局用户治理 + +系统管理员可以跨组织查询用户、查看组织/工作区/项目归属、MFA 状态、最近登录和活跃会话: + +```text +GET /api/system/users?query=&status=&limit=100 +POST /api/system/users +GET /api/system/users/:userId +PATCH /api/system/users/:userId # status: active | suspended +POST /api/system/users/:userId/reset-password +POST /api/system/users/:userId/reset-mfa +POST /api/system/users/:userId/memberships +POST /api/system/users/:userId/revoke-sessions +``` + +系统管理员可以手动创建账号并一次性取得初始密码、重置密码或 MFA、授予/移除系统管理员身份,并将用户加入指定组织、工作区和项目。加入关系会经过组织席位、层级归属和角色 scope 校验。停用或安全重置会立即撤销该用户的全部 Bearer session;不能停用当前系统管理员,也不能停用最后一个系统管理员或组织唯一所有者。所有状态变化、密码/MFA 重置、归属变化和强制会话撤销都会写入审计日志。组织管理员仍只能访问本组织成员接口,不能读取全局目录。 + +生产部署至少应设置:`AI_DRAMA_SESSION_SECRET`、`AI_DRAMA_MFA_ENCRYPTION_KEY`、`AI_DRAMA_OIDC_STORAGE_KEY`、`AI_DRAMA_FRONTEND_ORIGIN` 和每个 Provider 引用的 Client Secret 环境变量。默认回调地址为 `http://127.0.0.1:8787/api/auth/sso/callback`,多实例部署请使用固定的 `AI_DRAMA_OIDC_REDIRECT_URI`。 + +SCIM 目录由管理员创建后得到一次性 Bearer 令牌;令牌只保存 SHA-256 哈希。启用目录后,使用该令牌调用: + +```text +GET /scim/v2.0/:directoryId/Users +POST /scim/v2.0/:directoryId/Users +PATCH /scim/v2.0/:directoryId/Users/:userId +DELETE /scim/v2.0/:directoryId/Users/:userId +``` + +SCIM 用户只会进入该目录绑定的组织,停用操作会同时停用组织成员资格,不能跨组织写入。 + +资产文件版本会登记 `fileName`、`mimeType`、`fileSize` 和 `contentSha256`。`POST /api/assets/:assetId/versions/upload` 写入新的真实文件版本,`POST /api/assets/:assetId/verify` 重新读取文件并比较哈希/大小,验证结果写入审计日志;`GET /api/assets/:assetId/content` 返回 ETag,便于下游缓存和交付校验。 + +## Worker API + +本地 API 启动时会自动启动本地 Worker;不需要额外启动第二个 Worker 进程。Worker 会: + +- 使用数据库租约 `leased_by` / `leased_at` 领取任务,避免多个 Runner 重复执行。 +- 受 `AI_DRAMA_WORKER_CONCURRENCY` 限制,默认并发为 `2`,最大为 `8`。 +- 按 `AI_DRAMA_WORKER_POLL_MS` 轮询,默认 `1200ms`。 +- 只领取 `status=queued`、依赖已完成、连接器 `status=ready` 且 `cost_mode=local` 的任务。 +- 连接器异常时保留 attempt、错误信息、审计事件和通知事件;未达到 `max_attempts` 会按指数退避自动重新排队,超过上限才保持 `failed`。 +- 发现 `leased_at` 超时的 running 任务时会回收租约,按最大尝试次数重新排队或标记失败,避免 Worker 进程退出后任务永久卡住。 +- 心跳、队列最老任务等待时长和告警级别会写入 Worker 状态,管理员可以批量重试或取消任务。 + +```text +GET /api/admin/queue +POST /api/admin/queue/batch # action: retry | cancel | priority +GET /api/system/worker +POST /api/system/worker/dispatch +``` + +`GET /api/system/worker` 返回 `workerId`、`healthStatus`、心跳年龄、过期阈值、并发数、轮询间隔、队列深度、队列告警、当前执行数、最近领取 / 完成 / 失败 / 回收 / 重试时间和最后错误。`POST /api/system/worker/dispatch` 用于管理员立即触发一次领取,适合排障和演示。 + +可选环境变量: + +```text +AI_DRAMA_WORKER_ENABLED=1 +AI_DRAMA_WORKER_ID=local-worker- +AI_DRAMA_WORKER_POLL_MS=1200 +AI_DRAMA_WORKER_CONCURRENCY=2 +AI_DRAMA_WORKER_LEASE_MS=300000 +AI_DRAMA_WORKER_STALE_MS=30000 +``` + +如果设置 `AI_DRAMA_WORKER_ENABLED=0`,任务不会自动执行,后台仍会显示 Worker 已暂停。 + +## 模型协议适配器 + +模型连接器在模型中台注册,密钥只保存为环境变量名,不保存密钥本身: + +```json +{ + "label": "本地单画面图片服务", + "endpoint": "http://127.0.0.1:7860/api/generate/image", + "kind": "http-json", + "capability": ["text-to-image", "single-frame"], + "costMode": "local", + "authEnv": "LOCAL_IMAGE_TOKEN", + "protocol": { + "healthRoute": "health", + "routes": { "image": "generate" }, + "models": { "image": "qwen-image-local" } + } +} +``` + +支持三种协议: + +- `http-json`:向 `endpoint` POST 完整生产合同和 `execution` 元数据。 +- `openai-compatible`:按 `image`、`video`、`tts`、`asr`、`chat` 选择路由和模型;图片请求固定发送 `n: 1`。 +- `comfyui`:向 `prompt` 路由提交工作流,作为可选桥接,不是默认生产链路。 + +图片任务会检查响应中的 `data`、`images` 或 `outputs` 数组,必须恰好返回一项;返回多张、拼图、分屏或 contact sheet 的结果会进入失败状态,不会进入后续视频链路。 + +外部或混合成本连接器默认需要管理员显式审批;本地 Worker 不会自动领取 `mixed` / `cloud` 任务。 + +## 启动与验证 + +```bash +cd /Users/xz/Documents/daima/ai短剧/ai-drama-platform +npm run api +npm run dev +``` + +验证顺序建议: + +```bash +npm run build +npm run smoke:identity +npm run smoke:system-users +npm run smoke:commercial-ops +npm run smoke:oidc +npm run smoke:tenant +npm run smoke:creator-suite +npm run smoke:ops +npm run smoke:production-catalog +npm run smoke:worker +npm run smoke:all +``` + +多个 smoke 会写入同一个 SQLite 文件,应顺序执行。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..fe3f635 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,63 @@ +# 本地化 AI 短剧/漫剧平台架构 + +## 产品模块 + +### 三类产品面 + +- **创作空间**:我的工作台、项目工厂、剧本拆解、Series Bible、资产与选角、Continuity Ledger、导演工作台、生成任务、审片和交付。 +- **管理员后台**:管理概览、组织与工作区、用户与权限、模型与 Runner、队列与任务、用量与成本、审计与合规。 +- **系统设置**:系统健康、部署模式、存储、队列并发、单画面策略、真实末帧策略、固定声线、QA 门、通知、API 客户端和功能开关。 + +三类产品面共享同一套上下文选择器,但权限边界不同。组织管理员治理租户内资源;系统管理员治理本地部署实例和平台级策略。 + +- 前端生产台:生产总控、剧本拆解、资产选角、导演工作台、生成队列、审片中心、交付运营。 +- 数据核心:series bible、character locks、location locks、prop locks、shot list、voice lines、continuity ledger、generation jobs、QA results。 +- 生成适配器:默认对接用户自有模型平台;支持自定义 HTTP JSON、OpenAI-compatible 本地网关和 NewAPI 音频中转;ComfyUI 为可选适配器。 +- 质检门:一图一画面、连续性、IndexTTS-2.5 固定配音、paraformer-zh-long 声音/字幕/ASR 对齐、片段衔接。 +- 交付目录:prompt pack、配音台词表、剪辑合成清单、QA 结果和最终视频工程目录。 + +## 系统治理数据 + +- `system_settings`:部署和生成策略的持久化键值,支持类型化编辑和审计。 +- `feature_flags`:系统范围功能开关,默认关闭外部云连接器和非必要适配器。 +- `notification_channels`:本地事件日志和可选 Webhook。 +- `api_clients`:本地 Runner/企业集成客户端及 scope。 +- `service_health`:本地 API、SQLite 和各类 Runner 的健康、延迟、队列深度。 +- `system_admins`:独立于组织角色的系统管理员身份。 + +## 本地执行层 + +API 进程启动时自动初始化 `server/worker.mjs`,Worker 通过数据库租约领取可执行任务,并把执行状态、attempt、错误、用量和审计事件写回 SQLite。领取条件由服务端强制执行:任务必须是 `queued`,前置依赖必须完成,连接器必须 `ready` 且 `cost_mode=local`。因此管理员可以在队列页看到真实的在线状态、并发、队列深度、最近心跳和失败原因,而不会把云端任务伪装成本地自动执行。 + +Worker 运营层还维护租约超时回收、最大尝试次数、指数退避、队列最老等待时长和心跳失效判断。管理员可通过 `/api/admin/queue/batch` 批量重试/取消,并通过 `/api/system/worker` 读取告警状态;这部分状态不是前端计算,而是由本地 Worker 和数据库共同写回。 + +商业运营数据按组织保存账单周期、配额预警阈值、账单变更历史和成本中心。系统管理员用户治理与组织管理员成员治理分开:系统管理员可以创建全局账号、重置密码/MFA、调整组织/工作区/项目归属;所有动作都经过后端 session、scope 和 role 校验并写入审计。 + +模型执行请求统一经过 `server/execution.mjs` 的协议层:自定义 HTTP JSON 发送生产合同,OpenAI-compatible 按操作映射标准路由,ComfyUI 只负责 optional workflow bridge。认证信息通过 `authEnv` 指向进程环境变量,数据库和导出包不保存密钥。 + +## 推荐流水线 + +1. 写 series bible 和 episode card。 +2. 锁定角色、服装、声音、场景、道具、天气和固定机位。 +3. 生成每个镜头的一张完整单画面关键帧,输出数量必须等于 1。 +4. 后续镜头优先用上一段视频的实际末帧作为首帧;大跨度场景先插入过渡镜头。 +5. 台词先走 NewAPI/IndexTTS-2.5 固定 TTS 声线,使用 `prompt_speech` 参考音频锁定角色;无法口型同步时使用侧脸、背影、低头、远景、反应镜头、环境插入镜头。 +6. 音频生成后走 NewAPI/paraformer-zh-long 的 `verbose_json` 识别,用 ASR 文本校验台词准确性,用词级时间戳生成字幕时间轴。 +7. 视频片段通过 QA 后进入剪辑合成。 + +生成任务在进入 Worker 前还会经过单画面、连续性、成本策略和外部连接审批门;图片响应中 `data`、`images`、`outputs` 任一数组都必须恰好有一项。 + +## 旧资产复用 + +- `/Users/xz/Documents/daima/ai短剧/codex_qu_2026-08-15/work/rebuild_no_narration_v1.py`:单画面正负约束、Qwen Image、QwenEdit、H3 首尾帧桥接、输出数量检查。 +- `/Users/xz/Documents/daima/ai短剧/generate_thunderstorm_drama_locked_midshot_v3_cn_dialogue.py`:雷暴样片的角色锁、场景锁、对白、实际末帧串联。 +- `/Users/xz/Documents/daima/ai短剧/outputs/voice_lock_strategy/voice_lock_plan.md`:固定 TTS 声音加规避嘴形的声音策略。 +- `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/NEWAPI_AUDIO_WORKFLOW.md`:NewAPI 中转 `IndexTTS-2.5` 和 `paraformer-zh-long` 的音频请求格式。 + +## 禁止默认行为 + +- 不默认使用付费或云端节点。 +- 不在配置、导出文件或日志里保存 NewAPI 密钥;需要认证时只读取环境变量。 +- 不把 MiniMax H3 每次随机采样的原生声音当角色最终声线。 +- 不把 storyboard、contact sheet、collage、九宫格、边界表作为生成输入。 +- 不让同一张生成图出现多个地点、多个时间点、多个机位或多个连续动作。 diff --git a/docs/COMMERCIAL_PLATFORM_BLUEPRINT.md b/docs/COMMERCIAL_PLATFORM_BLUEPRINT.md new file mode 100644 index 0000000..3663da2 --- /dev/null +++ b/docs/COMMERCIAL_PLATFORM_BLUEPRINT.md @@ -0,0 +1,45 @@ +# 商业 AI 短剧/漫剧平台对标蓝图 + +## 对标结论 + +商业平台的核心不是单点生成,而是把内容生产拆成稳定流水线: + +```text +项目工厂 → 剧本导入/拆解 → 角色与资产选角 → 分镜/导演台 → 生成队列 → 审片质检 → 合成交付 → 运营复用 +``` + +本地版要保留用户的硬约束:本地/自有模型平台优先、ComfyUI optional、单画面生成、固定声线、连续性 ledger。 + +## 当前已落入 MVP 的平台能力 + +- 生产总控:商用准备度、批次、流水线、任务队列、QA 证据、交付物。 +- 剧本拆解:长文本导入、章节拆分、角色/场景/道具抽取、风格预设。 +- 资产选角:角色定妆、三视图占位、衣橱、声线锁、复用范围、场景库、道具库。 +- 导演工作台:镜头表、首尾帧、声音锁、QA、实际末帧衔接、批量动作。 +- 生成队列:本地适配器请求包、local-only 成本策略、dry-run contract。 +- 执行运营:本地 Worker 自动领取、租约防重、并发上限、心跳、依赖阻塞、失败 attempt 和管理员手动 dispatch。 +- 模型中台:自定义 HTTP JSON、OpenAI-compatible 和 ComfyUI optional 协议路由;密钥只引用环境变量。 +- 审片中心:一图一画面、连续性、声音字幕、片段衔接。 +- 交付运营:分镜 JSON、prompt pack、配音台词表、剪辑清单、视频工程目录。 +- 企业身份:真实 Bearer session、TOTP MFA、系统级身份策略、OIDC Authorization Code + PKCE/JWKS 登录运行时、Provider 绑定组织/工作区/默认角色、SSO 自动入组、组织绑定的 SCIM Users 同步接口和令牌轮换。 +- 通知中心:按用户、组织、工作区隔离的站内收件箱、未读计数、单条/全部已读、组织级通知偏好和服务端收件人过滤。 +- 协作任务中心:项目内任务创建、负责人分派、优先级、截止时间、状态流转、本人任务更新、站内通知和审计记录;与派生待办中心联动。 +- 版本台账:资产版本不可覆盖,支持恢复历史版本并将恢复动作写入审计流;镜头和交付批次的完整回滚仍需继续接入。 +- 项目生命周期:项目创建即初始化系列 / 第一季 / 试播集 / starter shot;支持草稿、制作中、暂停、审片中、归档和恢复,归档前阻断未完成任务,归档后的生产写操作由后端统一锁定。 +- 可审计运维:系统就绪度、SQLite 快照、队列租约、Worker 心跳、通知投递记录、API Client 撤销和跨组织审计查询。 + +## 下一阶段的商用增强 + +- 登录风险策略、设备信任、SSO 会话撤销联动和更细的 IdP claims/组到角色映射。 +- PostgreSQL / 对象存储 / Redis 的生产部署 profile,以及多实例 Worker 的分布式锁和水平扩展。 +- 对象存储迁移:将当前本地文件资产、首尾帧、视频片段、音频、字幕和审片证据迁移到可配置的 S3-compatible provider,并补齐生命周期与保留策略。 +- 版本系统增强:角色锁 v1/v2、场景锁、镜头改稿、批次回滚,以及将资产恢复扩展到镜头/交付批次。 +- 证据化 QA:图像检测、ASR 字幕对齐、声纹相似度、首尾帧差异、镜头连续性分数。 +- 合成器:本地 ffmpeg 工程生成、字幕、固定音轨、环境声、最终 master。 +- 商业运营增强:真实计费系统、成本分摊结算、客户交付审批和发布渠道连接器。 + +当前 MVP 的接口清单、Worker 生命周期和协议 JSON 示例见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/API_REFERENCE.md`。 + +## 本地版商业定位 + +这个项目不做云平台壳子,而做“私有部署的 AI 漫剧生产中台”:把用户已有模型、脚本、ComfyUI 工作流、TTS、口型模型和剪辑工具统一纳管。默认不调用付费云端节点,所有外部适配器必须显式启用。 diff --git a/docs/MARKET_RESEARCH_2026.md b/docs/MARKET_RESEARCH_2026.md new file mode 100644 index 0000000..0419315 --- /dev/null +++ b/docs/MARKET_RESEARCH_2026.md @@ -0,0 +1,51 @@ +# 2026 商业 AI 视频/短剧平台功能研究 + +调研目标:不是做一个生成页面,而是抽象商业平台必须具备的生产能力,并落成本地/私有部署 AI 短剧平台。 + +## 公开产品信号 + +- Runway Dev:强调 Workflows、自定义 pipeline、私有 API endpoint、开发者集成、企业支持。 + - https://dev.runwayml.com/ + - https://help.runwayml.com/hc/en-us/articles/50085269258643-Publishing-a-Workflow-as-an-Endpoint + - https://runway.com/news/company-news/introducing-runway-dev +- Kling AI:一体化 AI 视频/图片创作,图生视频、motion control、lip sync、avatar、元素和声音控制。 + - https://kling.ai/ + - https://kling.ai/explore/klingai_lipsync + - https://kling.ai/blog/kling-video-3-omni-native-lip-sync-audio-guide +- Vidu:Reference to Video、多参考图、角色/物体/场景一致性、保存 references 复用。 + - https://www.vidu.com/ + - https://www.vidu.com/ai-reference-to-video + - https://www.vidu.com/tools/consistent-character-ai +- Pika:AI 视频创作、effects、trend workflow、自动化、图片到视频、面向社交传播的快速生成。 + - https://pika.art/ + +## 商业平台必须覆盖的功能域 + +1. 工作区与团队:tenant、seat、成员、角色、权限、审计日志。 +2. 项目工厂:创建项目、模板、剧集、季、工程目录、版本。 +3. 剧本拆解:长文本导入、章节/场次/镜头拆分、角色/道具/场景抽取。 +4. 资产一致性:角色定妆、三视图、衣橱、道具、场景、声线、参考图保存和复用。 +5. 导演工作台:镜头表、首尾帧、镜头依赖、动作/机位、批量操作。 +6. 模型中台:模型注册、能力标签、endpoint、runner health、队列深度、路由策略。 +7. 生成编排:批量任务、优先级、依赖、重试、取消、失败边界、成本策略。 +8. 审片质检:单画面、角色一致性、声音字幕、ASR、镜头衔接、人工审核。 +9. 成本额度:套餐、配额、用量、成本中心、预算预警、外部云审批。 +10. 合规版权:原创/IP、真人脸权、声音授权、参考素材来源、禁止默认云端付费节点。 +11. 合成交付:剪辑清单、字幕、音轨、封面、最终视频、发布渠道、模板复用。 +12. 开发者/企业接口:私有 API、workflow endpoint、自定义 runner、权限鉴权。 + +## 本项目已落地的本地版能力 + +- `src/platform/platformData.js`:平台级 tenant、plan、members、permissions、model registry、runner health、projects、asset storage、review lanes、cost centers、compliance policies、audit log。 +- `server/local-api.mjs`:平台摘要、模型、成本、合规、审计、模型注册、任务队列、导出写盘接口。 +- `src/App.jsx`:生产总控、项目工厂、剧本拆解、资产选角、导演工作台、生成队列、模型中台、审片中心、成本合规、交付运营、平台管理。 + +## 下一步工程化优先级 + +1. 把静态平台数据迁移到 SQLite/Postgres。 +2. 前端所有页面改为 API 读写,保留本地 fallback。 +3. 接入真实 job runner:任务状态、依赖、重试、取消、失败日志。 +4. 对接自有模型平台 HTTP runner;ComfyUI 继续 optional。 +5. 补本地鉴权和角色权限。 +6. 引入文件资产索引:图片、视频、音频、字幕、QA 截图。 +7. 把 QA 从规则检查升级为证据检查:图像检测、ASR、声纹、首尾帧差异。 diff --git a/docs/MULTI_TENANT_DESIGN.md b/docs/MULTI_TENANT_DESIGN.md new file mode 100644 index 0000000..08be623 --- /dev/null +++ b/docs/MULTI_TENANT_DESIGN.md @@ -0,0 +1,106 @@ +# 商业化多组织、多工作区设计 + +## 目标 + +`ai-drama-platform` 的商业形态不是“一个剧集的生产页面”,而是一个可以私有部署、承载多个内容公司的 AI 短剧/漫剧生产中台。所有生产数据都必须从组织和工作区上下文开始,任务、资产、审片和交付都不能脱离租户边界。 + +本地 MVP 使用 Node 24 内置 `node:sqlite`,已经接入邮箱 + 密码登录、scrypt 密码哈希和 Bearer session。仅在显式设置 `AI_DRAMA_ALLOW_DEV_CONTEXT=1` 的本地调试场景下,才允许请求头模拟上下文;默认运行模式是 session-only。后续替换为 SSO/OIDC 时,只需要替换身份解析层,不改变业务表和 scope 查询。 + +## 产品层级 + +```text +用户 User + └── 组织 Organization(公司 / 内容厂牌 / 客户租户) + ├── 组织成员 Organization Member + ├── 套餐、账单、配额、策略 + └── 工作区 Workspace(制作部 / 项目组 / 客户空间) + ├── 工作区成员 Workspace Member + ├── 项目 Project(系列 / 模板 / 客户项目) + │ ├── 季 / 集 / 剧本 / 资产锁 / 分镜 + │ ├── 生成任务 / 尝试记录 / 审片 / 合规 + │ └── 交付版本 / 发布渠道 + └── 模型连接器、Runner、资产存储策略 +``` + +组织解决客户和计费隔离,工作区解决团队协作隔离,项目解决内容访问和交付隔离。一个用户可以加入多个组织,在同一组织中进入多个工作区;项目默认继承工作区成员,但敏感项目可以额外配置 project member。 + +## 角色模型 + +| 作用域 | 角色 | 典型能力 | +| --- | --- | --- | +| 组织 | `org_owner` | 组织设置、成员、账单、模型策略、全项目可见 | +| 组织 | `org_admin` | 成员、工作区、模型和审计管理 | +| 工作区 | `producer` | 建项目、排队、批次、成本、交付审批 | +| 工作区 | `writer` | 剧本、分集、对白、镜头草稿 | +| 工作区 | `art_director` | 角色/场景/道具锁、参考图、prompt、连续性 | +| 工作区 | `voice_editor` | 声线锁、TTS、字幕、ASR 对齐 | +| 工作区 | `reviewer` | QA、审片意见、通过/驳回 | +| 项目 | `project_editor` | 指定项目内容编辑 | +| 项目 | `project_viewer` | 只读查看、下载被授权的交付物 | + +权限不是写死在前端。API 根据 `organization_members`、`workspace_members`、`project_members` 合并角色权限,并在每个项目、任务、模型、交付接口执行检查。前端显示权限矩阵只是帮助用户理解,不能作为安全边界。 + +## 数据表 + +### 身份和租户 + +- `users`:用户身份、显示名、邮箱、状态。 +- `organizations`:租户、slug、部署模式、所有者。 +- `organization_members`:用户加入组织的角色、邀请状态。 +- `workspaces`:组织下的生产空间。 +- `workspace_members`:工作区角色。 +- `projects`:工作区下的系列、模板或客户项目。 +- `project_members`:项目级额外授权。 +- `invitations`:待接受邀请,不把邀请误当作已加入成员。 + +### 生产和中台 + +- `series`、`seasons`、`episodes`:剧集结构。 +- `assets`、`asset_versions`:角色、场景、道具、参考图、首尾帧、音频和视频资产版本。 +- `shots`:分镜、首帧/末帧、镜头状态和 continuity lock 引用。 +- `generation_jobs`、`job_attempts`:任务、重试、取消、执行器和输出。 +- `reviews`、`review_comments`:审片 lane、QA gate、意见和决策。 +- `model_connectors`:自有模型平台、HTTP JSON、OpenAI-compatible、ComfyUI optional adapter。 +- `deliveries`:剪辑清单、字幕、封面、master 和发布渠道。 + +### 商业治理 + +- `billing_accounts`:套餐、席位、存储、片段额度和云连接策略。 +- `quota_allocations`、`usage_events`:配额和按任务的用量计量。 +- `compliance_records`:原创/IP、肖像权、声音权、参考素材来源、单画面检查。 +- `audit_logs`:谁在什么组织/工作区/项目中做了什么操作,以及结果和元数据。 + +## 请求上下文 + +本地调试 bypass 使用以下请求头,缺省时使用种子账号和种子工作区;真实浏览器请求必须携带登录后的 Bearer session: + +```text +x-user-id: u-owner +x-organization-id: org-studio-lab +x-workspace-id: ws-local-aidrama +x-project-id: thunder-mouth +``` + +真实部署必须由登录会话或网关注入用户身份,禁止让客户端直接提交任意组织 ID 后绕过 membership 检查。API 的最小检查顺序是: + +1. 用户存在且状态为 active。 +2. 用户是组织成员,且组织状态可用。 +3. 工作区属于当前组织,用户拥有工作区 membership。 +4. 项目属于当前工作区,用户拥有项目 membership 或工作区角色允许继承访问。 +5. 当前角色拥有该动作所需权限。 +6. 记录 audit log,涉及生成、下载、模型调用和导出时记录 usage event。 + +## AI 短剧生产的业务硬门 + +这些约束属于组织策略或项目策略,不能只放在 prompt 文本里: + +- `single_frame_only`:每个图片生成任务只能输出一张连续完整画面,拒绝 split-screen、漫画多格、storyboard、collage、contact sheet。 +- `continuity_lock_required`:角色、服装、道具、场景、天气、镜头角度、声线必须有 lock 和 ledger。 +- `actual_last_frame_chain`:后续视频优先引用上一段真实末帧,不能只引用描述性文本。 +- `voice_lock_required`:对白使用固定声线;MiniMax H3 随机原生声音不作为最终角色声线。 +- `mouth_safe_shot_policy`:口型不稳定时优先侧脸、背影、低头、远景、反应镜头或环境插入镜头。 +- `local_runner_only`:默认只允许本地/自有模型;云端节点必须显式审批并留下审计记录。 + +## MVP 与后续 + +当前可运行 MVP 先交付真实的租户、成员、权限、项目 scope、邀请、审计、用量和模型注册接口,生产资产表和任务表已经预留。下一阶段可以把认证、Redis/BullMQ 队列、对象存储、ffmpeg 合成和真实 Runner 接入,不需要重做组织模型。 diff --git a/docs/NEWAPI_AUDIO_WORKFLOW.md b/docs/NEWAPI_AUDIO_WORKFLOW.md new file mode 100644 index 0000000..f17b135 --- /dev/null +++ b/docs/NEWAPI_AUDIO_WORKFLOW.md @@ -0,0 +1,72 @@ +# NewAPI 音频工作流 + +本项目的中文声音链路使用用户自有 NewAPI 中转到 Xinference,不在仓库内保存密钥。 + +## 环境变量 + +```bash +export NEWAPI_API_KEY="..." +``` + +`GET https://newapi.ysblack.com/v1/models` 未带认证时返回 `401 Invalid token` 是预期行为。所有真实请求必须带 `Authorization: Bearer $NEWAPI_API_KEY`。 + +## TTS 配音 + +- 模型:`IndexTTS-2.5` +- 端点:`POST https://newapi.ysblack.com/v1/audio/speech` +- 请求:`multipart/form-data` +- 用途:中文角色配音、固定声线、情绪参考 + +`IndexTTS-2.5` 的角色声线不是预设 voice 列表,而是由 `prompt_speech` 参考音频锁定。每个角色必须有授权参考音频,并在逐句生成时重复使用同一角色参考音频。 + +当前工程没有用户确认的正式角色参考音频。此前用临时/机器化参考音频生成的 IndexTTS 样音已经作废。禁止使用以下音频作为 `prompt_speech`: + +- macOS `say` 生成的本地离线 TTS +- MiniMax H3 原生音轨 +- 旧 probe / 临时测试 wav +- 听感机器化、带混响、带背景声、无授权或未经用户试听确认的声音 + +正式流程必须先获得干净自然的人声参考,或用 `Qwen3-TTS VoiceDesign`、`CosyVoice` 等自然声线先做候选试听。候选试听一次只生成单句,用户确认后才能把该声音登记为角色锁并进入批量生成。 + +```bash +curl -sS -o line_001.wav \ + -X POST "https://newapi.ysblack.com/v1/audio/speech" \ + -H "Authorization: Bearer $NEWAPI_API_KEY" \ + -H "Accept: application/json, audio/*" \ + -F "model=IndexTTS-2.5" \ + -F "input=先别出去,树下和金属牌旁都危险。" \ + -F "voice=default" \ + -F "response_format=wav" \ + -F "speed=1.0" \ + -F "kwargs={\"language\":\"ZH\"}" \ + -F "prompt_speech=@voices/chen_yu/approved_ref_01.wav;type=audio/wav;filename=prompt.wav" +``` + +## ASR 校验 + +- 模型:`paraformer-zh-long` +- 端点:`POST https://newapi.ysblack.com/v1/audio/transcriptions` +- 请求:`multipart/form-data` +- 用途:中文识别、台词准确性校验、字幕和时间轴对齐 + +```bash +curl -sS \ + -X POST "https://newapi.ysblack.com/v1/audio/transcriptions" \ + -H "Authorization: Bearer $NEWAPI_API_KEY" \ + -F "model=paraformer-zh-long" \ + -F "file=@voices/chen_yu/line_001.wav;type=audio/wav" \ + -F "language=zh" \ + -F "response_format=verbose_json" +``` + +`verbose_json` 返回识别文本、词级时间戳和音频时长。生产字幕时以原始台词表为准,ASR 文本用于发现漏字错字,ASR 时间戳用于对齐字幕。 + +## 生产顺序 + +1. 收集或生成候选角色声线,一次只做单句试听。 +2. 用户试听确认后,把正式参考音频登记为 `approved_ref_01.wav`。 +3. 用 `IndexTTS-2.5` 按角色正式参考音频生成逐句 wav。 +4. 标准化音频采样率、响度和命名,并写入缓存。 +5. 用 `paraformer-zh-long` 对逐句 wav 或整集混音做 `verbose_json` 识别。 +6. 比对原始台词和 ASR 文本,异常句重生成或人工复核。 +7. 用原始台词文本加 ASR 时间戳生成字幕,再进入剪辑合成。 diff --git a/docs/superpowers/plans/2026-08-19-commercial-multitenant-platform.md b/docs/superpowers/plans/2026-08-19-commercial-multitenant-platform.md new file mode 100644 index 0000000..f088be1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-commercial-multitenant-platform.md @@ -0,0 +1,59 @@ +# Commercial Multi-Tenant Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将单工作区演示台升级为具有真实组织、工作区、用户、角色、项目 scope、邀请、审计和用量边界的本地商业化 AI 短剧生产平台 MVP。 + +**Architecture:** 使用 Node 24 内置 SQLite 持久化身份、租户、成员、项目、任务和治理数据;HTTP API 通过请求上下文解析 membership 和权限,再为 React 前端提供可切换的 organization/workspace/project context。既有剧本、资产、导演台和 QA 继续复用,但所有平台管理数据优先来自 SQLite,静态数据只作为无 API 时的回退。 + +**Tech Stack:** React 19 + Vite 7 + lucide-react;Node 24 `node:sqlite`;原生 Node HTTP;现有 Playwright 依赖用于渲染验证。 + +--- + +### Task 1: 建立租户数据库和种子数据 + +**Files:** +- Create: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/server/schema.sql` +- Create: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/server/db.mjs` +- Create: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/data/platform.sqlite` (运行时生成) + +- [ ] 创建外键、索引、时间戳齐全的租户、成员、项目、任务、模型、配额、合规、审计表。 +- [ ] 用两个组织、三个工作区、多个角色和两个项目建立可切换种子数据。 +- [ ] 用 `DatabaseSync` 的 `prepare().run/all/get()` 封装参数化查询和事务。 +- [ ] 运行 `node -e 'import("./server/db.mjs")'`,确认数据库和种子数据可读。 + +### Task 2: 增加 scope-aware API + +**Files:** +- Create: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/server/tenant.mjs` +- Modify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/server/local-api.mjs` + +- [ ] 解析 `x-user-id`、`x-organization-id`、`x-workspace-id`、`x-project-id`。 +- [ ] 在所有组织、工作区、项目、任务、模型、审片和导出接口执行 membership 和权限检查。 +- [ ] 新增 context、组织、工作区、项目、邀请、用量、账单、权限和审计接口。 +- [ ] 让新增任务、模型、项目和邀请写入 SQLite,并同步写 audit log / usage event。 + +### Task 3: 前端接入真实组织上下文 + +**Files:** +- Create: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/lib/api.js` +- Modify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/App.jsx` +- Modify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/styles.css` + +- [ ] 启动时请求 `/api/context`,失败时显示明确的本地回退状态。 +- [ ] 顶栏增加组织、工作区、项目和当前用户上下文切换。 +- [ ] 平台管理页展示成员、待邀请、工作区、项目访问、权限矩阵、配额和审计。 +- [ ] 邀请成员、创建工作区、创建项目和注册模型走真实 API,成功后刷新上下文。 + +### Task 4: 验证商业 MVP + +**Files:** +- Verify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/package.json` +- Verify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/server/local-api.mjs` +- Verify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/App.jsx` + +- [ ] 运行 `npm run build`。 +- [ ] 用 curl 验证默认组织、切换组织、邀请、创建工作区和越权请求。 +- [ ] 在 `http://127.0.0.1:5173/` 验证桌面端和移动端,完成组织切换、平台管理和生成任务路径。 +- [ ] 确认控制台无错误、页面不横向溢出,API 和 Vite 服务保持运行。 + diff --git a/docs/superpowers/plans/2026-08-21-commercial-creator-suite.md b/docs/superpowers/plans/2026-08-21-commercial-creator-suite.md new file mode 100644 index 0000000..a61a258 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-commercial-creator-suite.md @@ -0,0 +1,40 @@ +# Commercial Creator Suite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在已经具备认证、租户和生产流水线的本地平台上,补齐商业创作套件的资产、声音、批量生产和项目助手入口。 + +**Architecture:** 新增独立的 React 创作模块文件,复用现有项目 API、SQLite 任务队列和 RBAC,不复制竞品品牌或页面。所有批量动作拆成带项目/镜头范围的独立 job;声音试听保持单句约束;资产状态以项目 continuity lock 为主数据。 + +**Tech Stack:** React 19 + Vite + lucide-react + 现有 Node HTTP API / SQLite。 + +--- + +### Task 1: 创作套件信息架构 + +**Files:** +- Modify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/App.jsx` +- Create: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/components/CreatorSuitePages.jsx` + +- [ ] 增加资产库、声音与字幕、批量生产、项目助手四个受现有项目权限保护的入口。 +- [ ] 用项目角色、场景、道具、voice lines、generation jobs 和 ledger 生成可操作视图。 + +### Task 2: 生产动作接线 + +**Files:** +- Modify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/App.jsx` +- Modify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/styles.css` + +- [ ] 批量生产按镜头逐条写入现有 `/api/jobs`,保留 local-only 和 adapter 选择。 +- [ ] 单句试听只允许当前选中台词写入一个 job,禁止批量生成声音。 +- [ ] 增加桌面/移动端布局、筛选、选择态、成功和错误反馈。 + +### Task 3: 回归验证 + +**Files:** +- Verify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/components/CreatorSuitePages.jsx` +- Verify: `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/src/App.jsx` + +- [ ] 运行 `npm run build`、`npm run smoke:tenant`、`npm run write:exports`。 +- [ ] 登录系统管理员、组织管理员、普通编剧,确认菜单和后台权限不越权。 +- [ ] 用浏览器检查 1280px 和 390px 视口,确认无横向溢出和控制台错误。 diff --git a/exports/project-template/README.md b/exports/project-template/README.md new file mode 100644 index 0000000..ab83ae8 --- /dev/null +++ b/exports/project-template/README.md @@ -0,0 +1,15 @@ +# 项目工程目录模板 + +每个正式剧集项目可复制本目录结构: + +```text +bible/ series bible、世界观、季纲、集纲 +characters/ 角色锁、服装状态、声音锁、参考图 +locations/ 场景锁、固定机位、天气与灯光状态 +props/ 道具锁、关键物状态 +shots/ 分镜 JSON、prompt pack、首尾帧、视频片段 +voices/ 台词表、TTS wav、角色参考音频 +qa/ 单画面、连续性、声音字幕、片段衔接质检结果 +edit/ 剪辑清单、字幕、音轨、转场说明 +final-video/ 最终成片与发布版本 +``` diff --git a/exports/project-template/bible/series-bible.json b/exports/project-template/bible/series-bible.json new file mode 100644 index 0000000..d71b719 --- /dev/null +++ b/exports/project-template/bible/series-bible.json @@ -0,0 +1,10 @@ +{ + "id": "thunder-mouth", + "title": "雷雨口", + "format": "竖屏 AI 漫剧", + "logline": "暴雨夜,冷静的地铁口志愿者和急着回家的女学生,用三段避险选择逃过一次雷暴事故。", + "originalityNote": "原创灾害安全短剧;只参考短剧节奏和本地生成工艺,不复制现成 IP、角色或名场面。", + "visualStyle": "原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,傍晚雷雨,稳定侧向中远景机位。", + "showEngine": "每集一个日常危险现场,角色用具体行动拆解风险,结尾留下下一处隐患。", + "continuityRule": "角色、服装、道具、场景、天气、机位、声音全部入 ledger;下一段视频优先使用上一段实际末帧。" +} diff --git a/exports/project-template/characters/character-locks.json b/exports/project-template/characters/character-locks.json new file mode 100644 index 0000000..1ac96ee --- /dev/null +++ b/exports/project-template/characters/character-locks.json @@ -0,0 +1,80 @@ +[ + { + "id": "chen-yu", + "name": "陈宇", + "role": "青年志愿者", + "visualLock": "23岁中国青年,短黑发,深青色连帽雨衣,黑色双肩包,折叠蓝伞低握,表情冷静,站姿克制。", + "costumeState": "深青色雨衣始终半湿,背包不离身,蓝伞未打开。", + "voiceLock": { + "voiceId": "local-male-calm-chenyu-v1", + "status": "needs-user-approved-reference", + "tone": "青年男声,普通话,中低音,镇定,不播音腔", + "ttsModel": "IndexTTS-2.5", + "asrModel": "paraformer-zh-long", + "referenceAudio": null, + "referencePolicy": "必须先获得用户确认的干净自然人声参考;禁止使用 macOS say、H3 原生音频、旧 probe 或任何机器化临时音频作为 prompt_speech。", + "candidateModels": [ + "Qwen3-TTS VoiceDesign", + "CosyVoice", + "IndexTTS-2.5" + ], + "auditionPolicy": "一次只生成单句试听,用户试听确认后再锁定角色声线;未确认前禁止批量生成。", + "emotionRefs": {}, + "ttsRequest": "multipart/form-data: model, input, voice, response_format, speed, kwargs, prompt_speech", + "asrRequest": "multipart/form-data: model, file, language=zh, response_format=verbose_json" + }, + "casting": { + "baseLook": "定妆照待生成", + "turnarounds": [ + "正面", + "侧面", + "背面" + ], + "wardrobe": [ + "雨衣常服", + "室内干燥版", + "后续志愿者马甲" + ], + "reuseScope": "S01 全季" + } + }, + { + "id": "tang-xia", + "name": "唐夏", + "role": "高中女生", + "visualLock": "16岁中国女生,黑色马尾,蓝白校服运动套装,小号海军蓝背包,黄色雨披折在双臂上。", + "costumeState": "校服袖口被雨打湿,黄色雨披只抱在手里不穿上。", + "voiceLock": { + "voiceId": "local-girl-natural-tangxia-v1", + "status": "needs-user-approved-reference", + "tone": "少女普通话,自然干净,不娃娃音,不尖锐", + "ttsModel": "IndexTTS-2.5", + "asrModel": "paraformer-zh-long", + "referenceAudio": null, + "referencePolicy": "必须先获得用户确认的干净自然人声参考;禁止使用 macOS say、H3 原生音频、旧 probe 或任何机器化临时音频作为 prompt_speech。", + "candidateModels": [ + "Qwen3-TTS VoiceDesign", + "CosyVoice", + "IndexTTS-2.5" + ], + "auditionPolicy": "一次只生成单句试听,用户试听确认后再锁定角色声线;未确认前禁止批量生成。", + "emotionRefs": {}, + "ttsRequest": "multipart/form-data: model, input, voice, response_format, speed, kwargs, prompt_speech", + "asrRequest": "multipart/form-data: model, file, language=zh, response_format=verbose_json" + }, + "casting": { + "baseLook": "定妆照待生成", + "turnarounds": [ + "正面", + "侧面", + "背面" + ], + "wardrobe": [ + "校服湿袖版", + "雨披展开版", + "室内等待版" + ], + "reuseScope": "S01 前 6 集" + } + } +] diff --git a/exports/project-template/edit/edit-list.json b/exports/project-template/edit/edit-list.json new file mode 100644 index 0000000..bf9a0d2 --- /dev/null +++ b/exports/project-template/edit/edit-list.json @@ -0,0 +1,61 @@ +{ + "schema": "ai-drama-platform.edit-list.v1", + "episodeId": "E01", + "frameRate": 24, + "clips": [ + { + "order": 1, + "shotId": "shot-01", + "sourceVideo": "shots/shot-01/shot-01.mp4", + "durationSec": 6, + "audioTracks": [ + "voices/chen_yu/line_001.wav", + "voices/tang_xia/line_002.wav" + ], + "subtitleSource": "voices/shot-01.srt", + "transition": "insert-bridge-shot", + "qaRequiredBeforeEdit": [ + "single-frame", + "continuity-lock", + "voice-subtitle-asr", + "clip-bridge" + ] + }, + { + "order": 2, + "shotId": "shot-02", + "sourceVideo": "shots/shot-02/shot-02.mp4", + "durationSec": 6, + "audioTracks": [ + "voices/chen_yu/line_003.wav", + "voices/tang_xia/line_004.wav" + ], + "subtitleSource": "voices/shot-02.srt", + "transition": "match-last-frame", + "qaRequiredBeforeEdit": [ + "single-frame", + "continuity-lock", + "voice-subtitle-asr", + "clip-bridge" + ] + }, + { + "order": 3, + "shotId": "shot-03", + "sourceVideo": "shots/shot-03/shot-03.mp4", + "durationSec": 6, + "audioTracks": [ + "voices/tang_xia/line_005.wav", + "voices/chen_yu/line_006.wav" + ], + "subtitleSource": "voices/shot-03.srt", + "transition": "match-last-frame", + "qaRequiredBeforeEdit": [ + "single-frame", + "continuity-lock", + "voice-subtitle-asr", + "clip-bridge" + ] + } + ] +} diff --git a/exports/project-template/final-video/delivery-manifest.json b/exports/project-template/final-video/delivery-manifest.json new file mode 100644 index 0000000..1586420 --- /dev/null +++ b/exports/project-template/final-video/delivery-manifest.json @@ -0,0 +1,8 @@ +{ + "schema": "ai-drama-platform.delivery-manifest.v1", + "episodeId": "E01", + "expectedFinal": "final-video/E01_别往树下跑_master.mp4", + "sourceEditList": "edit/edit-list.json", + "subtitles": "edit/E01_别往树下跑.zh-CN.srt", + "audioPolicy": "固定 TTS 声线替换随机原生音轨;环境声可独立保留。" +} diff --git a/exports/project-template/locations/location-locks.json b/exports/project-template/locations/location-locks.json new file mode 100644 index 0000000..4139796 --- /dev/null +++ b/exports/project-template/locations/location-locks.json @@ -0,0 +1,9 @@ +[ + { + "id": "metro-canopy-rain", + "name": "地铁口玻璃连廊", + "lock": "同一个地铁站与商场玻璃入口,混凝土雨棚,暖色顶灯,右侧玻璃门,左侧雨街,外面有树、金属指示牌、积水、路锥和警戒线。", + "weather": "傍晚雷暴,强降雨,地面反光,远处有闪电。", + "cameraLock": "稳定中远景侧向机位,角色比例不跳变,不突然进室内,不切正脸大特写。" + } +] diff --git a/exports/project-template/props/prop-locks.json b/exports/project-template/props/prop-locks.json new file mode 100644 index 0000000..2117638 --- /dev/null +++ b/exports/project-template/props/prop-locks.json @@ -0,0 +1,22 @@ +[ + { + "id": "blue-umbrella", + "name": "折叠蓝伞", + "lock": "陈宇右手低握,未打开,不作为避雷动作道具。" + }, + { + "id": "yellow-poncho", + "name": "黄色雨披", + "lock": "唐夏折在双臂上,颜色稳定,不突然穿上。" + }, + { + "id": "warning-cones", + "name": "路锥和警戒线", + "lock": "远处积水旁,任何角色都不跨越。" + }, + { + "id": "phone", + "name": "手机", + "lock": "第三段陈宇低位拿出,不遮挡脸。" + } +] diff --git a/exports/project-template/qa/qa-results.json b/exports/project-template/qa/qa-results.json new file mode 100644 index 0000000..34e59b7 --- /dev/null +++ b/exports/project-template/qa/qa-results.json @@ -0,0 +1,92 @@ +[ + { + "shotId": "shot-01", + "score": 90, + "gates": [ + { + "id": "single-frame", + "label": "一图一画面", + "status": "fail", + "detail": "发现禁用词:split screen" + }, + { + "id": "continuity-lock", + "label": "角色/场景/道具连续性", + "status": "pass", + "detail": "角色、场景、道具和首尾帧都已绑定。" + }, + { + "id": "voice-subtitle-asr", + "label": "声音/字幕/ASR 对齐", + "status": "pass", + "detail": "对白角色均有固定声线 ID,后续可用 ASR 校对字幕时间。" + }, + { + "id": "clip-bridge", + "label": "片段衔接", + "status": "warn", + "detail": "场景跨度较大或未指定实际末帧衔接,需要中间过渡镜头。" + } + ] + }, + { + "shotId": "shot-02", + "score": 100, + "gates": [ + { + "id": "single-frame", + "label": "一图一画面", + "status": "pass", + "detail": "提示词包含单画面约束,未发现分屏/拼图/漫画页风险词。" + }, + { + "id": "continuity-lock", + "label": "角色/场景/道具连续性", + "status": "pass", + "detail": "角色、场景、道具和首尾帧都已绑定。" + }, + { + "id": "voice-subtitle-asr", + "label": "声音/字幕/ASR 对齐", + "status": "pass", + "detail": "对白角色均有固定声线 ID,后续可用 ASR 校对字幕时间。" + }, + { + "id": "clip-bridge", + "label": "片段衔接", + "status": "pass", + "detail": "使用上一段实际末帧作为下一段首帧。" + } + ] + }, + { + "shotId": "shot-03", + "score": 100, + "gates": [ + { + "id": "single-frame", + "label": "一图一画面", + "status": "pass", + "detail": "提示词包含单画面约束,未发现分屏/拼图/漫画页风险词。" + }, + { + "id": "continuity-lock", + "label": "角色/场景/道具连续性", + "status": "pass", + "detail": "角色、场景、道具和首尾帧都已绑定。" + }, + { + "id": "voice-subtitle-asr", + "label": "声音/字幕/ASR 对齐", + "status": "pass", + "detail": "对白角色均有固定声线 ID,后续可用 ASR 校对字幕时间。" + }, + { + "id": "clip-bridge", + "label": "片段衔接", + "status": "pass", + "detail": "使用上一段实际末帧作为下一段首帧。" + } + ] + } +] diff --git a/exports/project-template/shots/prompt-pack.json b/exports/project-template/shots/prompt-pack.json new file mode 100644 index 0000000..e9445a0 --- /dev/null +++ b/exports/project-template/shots/prompt-pack.json @@ -0,0 +1,146 @@ +{ + "schema": "ai-drama-platform.prompt-pack.v1", + "adapter": "owned-model-platform", + "global": { + "style": "原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,傍晚雷雨,稳定侧向中远景机位。", + "singleFrameRule": "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY. One complete continuous scene, one location, one time point, one camera angle, one clear action. Fill the entire portrait canvas with this uninterrupted composition.", + "negativePrompt": "no subtitles, no readable text, no logo, no watermark, no new characters, no split screen, no comic panel, no collage, no contact sheet, no storyboard, no multiple scenes, no multiple time points, no picture-in-picture, no front-facing talking close-up" + }, + "characterLocks": [ + { + "id": "chen-yu", + "name": "陈宇", + "role": "青年志愿者", + "visualLock": "23岁中国青年,短黑发,深青色连帽雨衣,黑色双肩包,折叠蓝伞低握,表情冷静,站姿克制。", + "costumeState": "深青色雨衣始终半湿,背包不离身,蓝伞未打开。", + "voiceLock": { + "voiceId": "local-male-calm-chenyu-v1", + "status": "needs-user-approved-reference", + "tone": "青年男声,普通话,中低音,镇定,不播音腔", + "ttsModel": "IndexTTS-2.5", + "asrModel": "paraformer-zh-long", + "referenceAudio": null, + "referencePolicy": "必须先获得用户确认的干净自然人声参考;禁止使用 macOS say、H3 原生音频、旧 probe 或任何机器化临时音频作为 prompt_speech。", + "candidateModels": [ + "Qwen3-TTS VoiceDesign", + "CosyVoice", + "IndexTTS-2.5" + ], + "auditionPolicy": "一次只生成单句试听,用户试听确认后再锁定角色声线;未确认前禁止批量生成。", + "emotionRefs": {}, + "ttsRequest": "multipart/form-data: model, input, voice, response_format, speed, kwargs, prompt_speech", + "asrRequest": "multipart/form-data: model, file, language=zh, response_format=verbose_json" + }, + "casting": { + "baseLook": "定妆照待生成", + "turnarounds": [ + "正面", + "侧面", + "背面" + ], + "wardrobe": [ + "雨衣常服", + "室内干燥版", + "后续志愿者马甲" + ], + "reuseScope": "S01 全季" + } + }, + { + "id": "tang-xia", + "name": "唐夏", + "role": "高中女生", + "visualLock": "16岁中国女生,黑色马尾,蓝白校服运动套装,小号海军蓝背包,黄色雨披折在双臂上。", + "costumeState": "校服袖口被雨打湿,黄色雨披只抱在手里不穿上。", + "voiceLock": { + "voiceId": "local-girl-natural-tangxia-v1", + "status": "needs-user-approved-reference", + "tone": "少女普通话,自然干净,不娃娃音,不尖锐", + "ttsModel": "IndexTTS-2.5", + "asrModel": "paraformer-zh-long", + "referenceAudio": null, + "referencePolicy": "必须先获得用户确认的干净自然人声参考;禁止使用 macOS say、H3 原生音频、旧 probe 或任何机器化临时音频作为 prompt_speech。", + "candidateModels": [ + "Qwen3-TTS VoiceDesign", + "CosyVoice", + "IndexTTS-2.5" + ], + "auditionPolicy": "一次只生成单句试听,用户试听确认后再锁定角色声线;未确认前禁止批量生成。", + "emotionRefs": {}, + "ttsRequest": "multipart/form-data: model, input, voice, response_format, speed, kwargs, prompt_speech", + "asrRequest": "multipart/form-data: model, file, language=zh, response_format=verbose_json" + }, + "casting": { + "baseLook": "定妆照待生成", + "turnarounds": [ + "正面", + "侧面", + "背面" + ], + "wardrobe": [ + "校服湿袖版", + "雨披展开版", + "室内等待版" + ], + "reuseScope": "S01 前 6 集" + } + } + ], + "locationLocks": [ + { + "id": "metro-canopy-rain", + "name": "地铁口玻璃连廊", + "lock": "同一个地铁站与商场玻璃入口,混凝土雨棚,暖色顶灯,右侧玻璃门,左侧雨街,外面有树、金属指示牌、积水、路锥和警戒线。", + "weather": "傍晚雷暴,强降雨,地面反光,远处有闪电。", + "cameraLock": "稳定中远景侧向机位,角色比例不跳变,不突然进室内,不切正脸大特写。" + } + ], + "propLocks": [ + { + "id": "blue-umbrella", + "name": "折叠蓝伞", + "lock": "陈宇右手低握,未打开,不作为避雷动作道具。" + }, + { + "id": "yellow-poncho", + "name": "黄色雨披", + "lock": "唐夏折在双臂上,颜色稳定,不突然穿上。" + }, + { + "id": "warning-cones", + "name": "路锥和警戒线", + "lock": "远处积水旁,任何角色都不跨越。" + }, + { + "id": "phone", + "name": "手机", + "lock": "第三段陈宇低位拿出,不遮挡脸。" + } + ], + "prompts": [ + { + "id": "shot-01", + "imagePrompt": "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY. One complete continuous scene, one location, one time point, one camera angle, one clear action. Fill the entire portrait canvas with this uninterrupted composition. 原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,傍晚雷雨,稳定侧向中远景机位。 陈宇在地铁口雨棚内叫停唐夏,二人保持同一侧向中远景,外面树和金属牌处于雨幕中。单一地点、单一时间点、单一动作。", + "negativePrompt": "no subtitles, no readable text, no logo, no watermark, no new characters, no split screen, no comic panel, no collage, no contact sheet, no storyboard, no multiple scenes, no multiple time points, no picture-in-picture, no front-facing talking close-up, no standing under tree, no touching metal sign, no indoor jump, no new characters, no split screen", + "imageToVideoPrompt": "5-6 秒连续动作:陈宇叫停,唐夏停步回头,两人看向树和金属牌。对白自然普通话,嘴型不做正脸特写。", + "seed": 2608196101, + "durationSec": 6 + }, + { + "id": "shot-02", + "imagePrompt": "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY. One complete continuous scene, one location, one time point, one camera angle, one clear action. Fill the entire portrait canvas with this uninterrupted composition. 原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,傍晚雷雨,稳定侧向中远景机位。 从上一段实际末帧继续,唐夏在雨棚内退半步,远处积水、电线风险、路锥和警戒线保持位置稳定。", + "negativePrompt": "no subtitles, no readable text, no logo, no watermark, no new characters, no split screen, no comic panel, no collage, no contact sheet, no storyboard, no multiple scenes, no multiple time points, no picture-in-picture, no front-facing talking close-up, no stepping into puddle, no crossing cones, no touching fallen cable, no sudden zoom", + "imageToVideoPrompt": "5-6 秒连续动作:人物只移动半步,注意力转向远处积水和警戒线。不要跨线,不要切换地点。", + "seed": 2608196102, + "durationSec": 6 + }, + { + "id": "shot-03", + "imagePrompt": "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY. One complete continuous scene, one location, one time point, one camera angle, one clear action. Fill the entire portrait canvas with this uninterrupted composition. 原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,傍晚雷雨,稳定侧向中远景机位。 同一地铁口玻璃门旁,两人后退到入口内侧等待,陈宇低位拿手机,唐夏抱着黄色雨披安静点头。", + "negativePrompt": "no subtitles, no readable text, no logo, no watermark, no new characters, no split screen, no comic panel, no collage, no contact sheet, no storyboard, no multiple scenes, no multiple time points, no picture-in-picture, no front-facing talking close-up, no new room, no bright sunny weather, no front mouth close-up, no unsafe behavior", + "imageToVideoPrompt": "5-6 秒连续动作:雨声变闷,人物靠近入口内侧,低头看手机,结尾仍留在同一雨棚场景。", + "seed": 2608196103, + "durationSec": 6 + } + ] +} diff --git a/exports/project-template/shots/shot-list.json b/exports/project-template/shots/shot-list.json new file mode 100644 index 0000000..2087c3d --- /dev/null +++ b/exports/project-template/shots/shot-list.json @@ -0,0 +1,127 @@ +{ + "schema": "ai-drama-platform.shot-list.v1", + "seriesId": "thunder-mouth", + "episodeId": "E01", + "aspectRatio": "9:16", + "shots": [ + { + "id": "shot-01", + "title": "叫停树下近路", + "durationSec": 6, + "camera": "稳定侧向中远景,避免正脸大段说话,人物在雨棚内。", + "action": "陈宇半步伸手叫停唐夏,两人一起看向外面的树和金属指示牌。", + "characters": [ + "chen-yu", + "tang-xia" + ], + "location": "metro-canopy-rain", + "props": [ + "blue-umbrella", + "yellow-poncho" + ], + "firstFrame": "shots/shot-01/first.png", + "lastFrame": "shots/shot-01/last.png", + "transitionFromPrevious": "episode-start", + "voiceLines": [ + { + "id": "line-001", + "characterId": "chen-yu", + "text": "等一下,先别出去。雷暴来了,树下和金属牌旁边都不安全。", + "emotion": "冷静但有一点急", + "targetDurationSec": 3.2, + "audioFile": "voices/chen_yu/line_001.wav", + "mouthPlan": "规避嘴形" + }, + { + "id": "line-002", + "characterId": "tang-xia", + "text": "那我现在该往哪走?", + "emotion": "疑惑,压低声音", + "targetDurationSec": 1.6, + "audioFile": "voices/tang_xia/line_002.wav", + "mouthPlan": "规避嘴形" + } + ] + }, + { + "id": "shot-02", + "title": "避开积水电线", + "durationSec": 6, + "camera": "沿用上一段实际末帧,机位不换,镜头只轻微跟随视线。", + "action": "两人视线从树和金属牌转向远处积水与警戒线,唐夏向后退半步。", + "characters": [ + "chen-yu", + "tang-xia" + ], + "location": "metro-canopy-rain", + "props": [ + "blue-umbrella", + "yellow-poncho", + "warning-cones" + ], + "firstFrame": "AUTO_PREVIOUS_ACTUAL_LAST_FRAME", + "lastFrame": "shots/shot-02/last.png", + "transitionFromPrevious": "actual-last-frame", + "voiceLines": [ + { + "id": "line-003", + "characterId": "chen-yu", + "text": "看左边,积水旁可能有落地电线。别靠近,也别跨警戒线。", + "emotion": "明确提醒", + "targetDurationSec": 3.4, + "audioFile": "voices/chen_yu/line_003.wav", + "mouthPlan": "规避嘴形" + }, + { + "id": "line-004", + "characterId": "tang-xia", + "text": "我退回来,在门口等。", + "emotion": "听懂后配合", + "targetDurationSec": 1.5, + "audioFile": "voices/tang_xia/line_004.wav", + "mouthPlan": "规避嘴形" + } + ] + }, + { + "id": "shot-03", + "title": "门内等待报平安", + "durationSec": 6, + "camera": "沿用上一段实际末帧,稍微向玻璃门内侧构图,不进入新房间。", + "action": "两人靠近玻璃门内侧等待,陈宇低位拿出手机,唐夏情绪稳定。", + "characters": [ + "chen-yu", + "tang-xia" + ], + "location": "metro-canopy-rain", + "props": [ + "blue-umbrella", + "yellow-poncho", + "phone" + ], + "firstFrame": "AUTO_PREVIOUS_ACTUAL_LAST_FRAME", + "lastFrame": "shots/shot-03/last.png", + "transitionFromPrevious": "actual-last-frame", + "voiceLines": [ + { + "id": "line-005", + "characterId": "tang-xia", + "text": "我给家里报个平安。", + "emotion": "松一口气", + "targetDurationSec": 1.6, + "audioFile": "voices/tang_xia/line_005.wav", + "mouthPlan": "规避嘴形" + }, + { + "id": "line-006", + "characterId": "chen-yu", + "text": "对,先待在建筑入口内侧,等雷声远了再走。", + "emotion": "稳定收束", + "targetDurationSec": 2.8, + "audioFile": "voices/chen_yu/line_006.wav", + "mouthPlan": "规避嘴形" + } + ] + } + ] +} diff --git a/exports/project-template/voices/voice-lines.json b/exports/project-template/voices/voice-lines.json new file mode 100644 index 0000000..bbacf58 --- /dev/null +++ b/exports/project-template/voices/voice-lines.json @@ -0,0 +1,134 @@ +{ + "schema": "ai-drama-platform.voice-lines.v1", + "episodeId": "E01", + "audioPipeline": { + "adapter": "newapi-audio-production", + "tts": { + "model": "IndexTTS-2.5", + "endpoint": "/audio/speech", + "request": "multipart/form-data", + "voiceLockField": "prompt_speech", + "referenceStatus": "requires-user-approved-natural-reference", + "blockedReferenceSources": [ + "macOS say", + "MiniMax H3 native audio", + "old probe wav", + "temporary machine voice" + ] + }, + "asr": { + "model": "paraformer-zh-long", + "endpoint": "/audio/transcriptions", + "request": "multipart/form-data", + "responseFormat": "verbose_json" + } + }, + "characters": [ + { + "id": "chen-yu", + "name": "陈宇", + "voiceLock": { + "voiceId": "local-male-calm-chenyu-v1", + "status": "needs-user-approved-reference", + "tone": "青年男声,普通话,中低音,镇定,不播音腔", + "ttsModel": "IndexTTS-2.5", + "asrModel": "paraformer-zh-long", + "referenceAudio": null, + "referencePolicy": "必须先获得用户确认的干净自然人声参考;禁止使用 macOS say、H3 原生音频、旧 probe 或任何机器化临时音频作为 prompt_speech。", + "candidateModels": [ + "Qwen3-TTS VoiceDesign", + "CosyVoice", + "IndexTTS-2.5" + ], + "auditionPolicy": "一次只生成单句试听,用户试听确认后再锁定角色声线;未确认前禁止批量生成。", + "emotionRefs": {}, + "ttsRequest": "multipart/form-data: model, input, voice, response_format, speed, kwargs, prompt_speech", + "asrRequest": "multipart/form-data: model, file, language=zh, response_format=verbose_json" + } + }, + { + "id": "tang-xia", + "name": "唐夏", + "voiceLock": { + "voiceId": "local-girl-natural-tangxia-v1", + "status": "needs-user-approved-reference", + "tone": "少女普通话,自然干净,不娃娃音,不尖锐", + "ttsModel": "IndexTTS-2.5", + "asrModel": "paraformer-zh-long", + "referenceAudio": null, + "referencePolicy": "必须先获得用户确认的干净自然人声参考;禁止使用 macOS say、H3 原生音频、旧 probe 或任何机器化临时音频作为 prompt_speech。", + "candidateModels": [ + "Qwen3-TTS VoiceDesign", + "CosyVoice", + "IndexTTS-2.5" + ], + "auditionPolicy": "一次只生成单句试听,用户试听确认后再锁定角色声线;未确认前禁止批量生成。", + "emotionRefs": {}, + "ttsRequest": "multipart/form-data: model, input, voice, response_format, speed, kwargs, prompt_speech", + "asrRequest": "multipart/form-data: model, file, language=zh, response_format=verbose_json" + } + } + ], + "lines": [ + { + "shotId": "shot-01", + "lineId": "line-001", + "characterId": "chen-yu", + "text": "等一下,先别出去。雷暴来了,树下和金属牌旁边都不安全。", + "emotion": "冷静但有一点急", + "targetDurationSec": 3.2, + "audioFile": "voices/chen_yu/line_001.wav", + "mouthPlan": "规避嘴形" + }, + { + "shotId": "shot-01", + "lineId": "line-002", + "characterId": "tang-xia", + "text": "那我现在该往哪走?", + "emotion": "疑惑,压低声音", + "targetDurationSec": 1.6, + "audioFile": "voices/tang_xia/line_002.wav", + "mouthPlan": "规避嘴形" + }, + { + "shotId": "shot-02", + "lineId": "line-003", + "characterId": "chen-yu", + "text": "看左边,积水旁可能有落地电线。别靠近,也别跨警戒线。", + "emotion": "明确提醒", + "targetDurationSec": 3.4, + "audioFile": "voices/chen_yu/line_003.wav", + "mouthPlan": "规避嘴形" + }, + { + "shotId": "shot-02", + "lineId": "line-004", + "characterId": "tang-xia", + "text": "我退回来,在门口等。", + "emotion": "听懂后配合", + "targetDurationSec": 1.5, + "audioFile": "voices/tang_xia/line_004.wav", + "mouthPlan": "规避嘴形" + }, + { + "shotId": "shot-03", + "lineId": "line-005", + "characterId": "tang-xia", + "text": "我给家里报个平安。", + "emotion": "松一口气", + "targetDurationSec": 1.6, + "audioFile": "voices/tang_xia/line_005.wav", + "mouthPlan": "规避嘴形" + }, + { + "shotId": "shot-03", + "lineId": "line-006", + "characterId": "chen-yu", + "text": "对,先待在建筑入口内侧,等雷声远了再走。", + "emotion": "稳定收束", + "targetDurationSec": 2.8, + "audioFile": "voices/chen_yu/line_006.wav", + "mouthPlan": "规避嘴形" + } + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..55a13da --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + AI 短剧本地生产台 + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8906e89 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1937 @@ +{ + "name": "ai-drama-platform", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-drama-platform", + "version": "0.1.0", + "dependencies": { + "@node-saml/node-saml": "^5.1.0", + "@vitejs/plugin-react": "^5.0.0", + "lucide-react": "^0.468.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "vite": "^7.1.0" + }, + "devDependencies": { + "playwright": "^1.62.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@node-saml/node-saml": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@node-saml/node-saml/-/node-saml-5.1.0.tgz", + "integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.12", + "@types/qs": "^6.9.18", + "@types/xml-encryption": "^1.2.4", + "@types/xml2js": "^0.4.14", + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "debug": "^4.4.0", + "xml-crypto": "^6.1.2", + "xml-encryption": "^3.1.0", + "xml2js": "^0.6.2", + "xmlbuilder": "^15.1.1", + "xpath": "^0.0.34" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/xml-encryption": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", + "integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmmirror.com/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.411", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", + "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmmirror.com/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml-encryption": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/xml-encryption/-/xml-encryption-3.1.0.tgz", + "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.5", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + } + }, + "node_modules/xml-encryption/node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmmirror.com/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmmirror.com/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..08932ba --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "ai-drama-platform", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "api": "node server/local-api.mjs", + "worker": "node --input-type=module -e \"import('./server/worker.mjs').then(({startLocalWorker}) => { startLocalWorker(); console.log('AI drama local worker started'); setInterval(() => {}, 1 << 30); })\"", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1", + "write:exports": "node scripts/write-sample-exports.mjs", + "smoke:tenant": "node scripts/smoke-tenant.mjs", + "smoke:mfa": "node scripts/smoke-mfa.mjs", + "smoke:security-events": "node scripts/smoke-security-events.mjs", + "smoke:device-risk": "node scripts/smoke-device-risk.mjs", + "smoke:audit": "node scripts/smoke-audit.mjs", + "smoke:identity": "node scripts/smoke-identity.mjs", + "smoke:system-users": "node scripts/smoke-system-users.mjs", + "smoke:commercial-governance": "node scripts/smoke-commercial-governance.mjs", + "smoke:invitations": "node scripts/smoke-invitations.mjs", + "smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs", + "smoke:billing-ledger": "node scripts/smoke-billing-ledger.mjs", + "smoke:release-workflow": "node scripts/smoke-release-workflow.mjs", + "smoke:delivery-portal": "node scripts/smoke-delivery-portal.mjs", + "smoke:oidc": "node scripts/smoke-oidc.mjs", + "smoke:creator-suite": "node scripts/smoke-creator-suite.mjs", + "smoke:model-connectors": "node scripts/smoke-model-connectors.mjs", + "smoke:api-clients": "node scripts/smoke-api-clients.mjs", + "smoke:api-rate-limit": "node scripts/smoke-api-rate-limit.mjs", + "smoke:ops": "node scripts/smoke-ops.mjs", + "smoke:production-catalog": "node scripts/smoke-production-catalog.mjs", + "smoke:production-controls": "node scripts/smoke-production-controls.mjs", + "smoke:worker": "node scripts/smoke-worker.mjs", + "smoke:readiness": "node scripts/smoke-readiness.mjs", + "smoke:media-evidence": "node scripts/smoke-media-evidence.mjs", + "smoke:api-media": "node scripts/smoke-api-media.mjs", + "smoke:compose-evidence": "node scripts/smoke-compose-evidence.mjs", + "smoke:work-items": "node scripts/smoke-work-items.mjs", + "smoke:tasks": "node scripts/smoke-tasks.mjs", + "smoke:task-collaboration": "node scripts/smoke-task-collaboration.mjs", + "smoke:notifications": "node scripts/smoke-notifications.mjs", + "smoke:project-isolation": "node scripts/smoke-project-isolation.mjs", + "smoke:project-lifecycle": "node scripts/smoke-project-lifecycle.mjs", + "smoke:all": "node scripts/smoke-all.mjs" + }, + "dependencies": { + "@node-saml/node-saml": "^5.1.0", + "@vitejs/plugin-react": "^5.0.0", + "lucide-react": "^0.468.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "vite": "^7.1.0" + }, + "devDependencies": { + "playwright": "^1.62.1" + } +} diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs new file mode 100644 index 0000000..f45609f --- /dev/null +++ b/scripts/smoke-all.mjs @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; + +const scripts = [ + "smoke:tenant", + "smoke:mfa", + "smoke:security-events", + "smoke:device-risk", + "smoke:audit", + "smoke:identity", + "smoke:system-users", + "smoke:commercial-governance", + "smoke:invitations", + "smoke:commercial-ops", + "smoke:billing-ledger", + "smoke:release-workflow", + "smoke:delivery-portal", + "smoke:oidc", + "smoke:creator-suite", + "smoke:model-connectors", + "smoke:api-clients", + "smoke:api-rate-limit", + "smoke:ops", + "smoke:production-catalog", + "smoke:production-controls", + "smoke:worker", + "smoke:readiness", + "smoke:media-evidence", + "smoke:api-media", + "smoke:compose-evidence", + "smoke:work-items", + "smoke:tasks", + "smoke:notifications", + "smoke:project-isolation", + "smoke:project-lifecycle" +]; + +function run(script) { + return new Promise((resolve, reject) => { + const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["run", script], { + cwd: process.cwd(), + env: process.env, + stdio: "inherit" + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${script} failed${signal ? ` with ${signal}` : ` with exit code ${code}`}`)); + }); + }); +} + +for (const script of scripts) { + console.log(`\n[smoke ${scripts.indexOf(script) + 1}/${scripts.length}] ${script}`); + await run(script); +} + +console.log(`\nall smoke tests passed: ${scripts.length}`); diff --git a/scripts/smoke-api-clients.mjs b/scripts/smoke-api-clients.mjs new file mode 100644 index 0000000..f81eb4d --- /dev/null +++ b/scripts/smoke-api-clients.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { dbGet, dbRun, withTransaction } from "../server/db.mjs"; +import { hashApiClientKey } from "../server/api-client-secrets.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, headers = {}, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email) { + const result = await request("/api/auth/login", {}, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assert.equal(result.response.ok, true, `${email} 登录失败`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const owner = { ...(await login("producer@local.test")), ...scope }; +const writer = { ...(await login("writer@local.test")), ...scope }; +let clientId = ""; +let firstKey = ""; +let modelId = ""; +try { + const denied = await request("/api/system/api-clients", writer); + assert.equal(denied.response.status, 403, "普通用户不应访问 API 客户端目录"); + + const invalidScopes = await request("/api/system/api-clients", owner, { + method: "POST", + body: JSON.stringify({ name: `Invalid Scope ${Date.now()}`, scopes: ["admin:all"] }) + }); + assert.equal(invalidScopes.response.status, 400, "不支持的 API scope 必须被拒绝"); + assert.equal(invalidScopes.payload.error, "api_client_scopes_invalid", "不支持的 API scope 错误码必须稳定"); + + const created = await request("/api/system/api-clients", owner, { + method: "POST", + body: JSON.stringify({ name: `Smoke API Client ${Date.now()}`, scopes: ["jobs:read", "models:read"] }) + }); + assert.equal(created.response.status, 201, "系统管理员创建 API 客户端失败"); + clientId = created.payload.client.id; + firstKey = created.payload.clientKey; + assert.ok(firstKey.startsWith("local-") && firstKey.length > 24, "新 API 密钥熵不足或格式错误"); + const stored = dbGet("SELECT client_key, client_key_hash, client_key_prefix, key_version FROM api_clients WHERE id = ?", [clientId]); + assert.ok(stored, "新 API 客户端没有写入数据库"); + assert.equal(stored.client_key_hash, hashApiClientKey(firstKey), "数据库中的 API 密钥摘要不匹配"); + assert.equal(stored.key_version, 2, "API 客户端密钥版本没有升级"); + assert.equal(stored.client_key_prefix, firstKey.slice(0, 12), "API 密钥预览前缀不匹配"); + assert.notEqual(stored.client_key, firstKey, "数据库不能保存 API 密钥明文"); + assert.ok(!String(stored.client_key).includes(firstKey), "数据库密钥字段不能包含可用密钥"); + + const listed = await request("/api/system/api-clients", owner); + assert.equal(listed.response.ok, true, "系统管理员读取 API 客户端目录失败"); + const listedClient = listed.payload.apiClients.find((item) => item.id === clientId); + assert.ok(listedClient, "新 API 客户端没有出现在目录中"); + assert.ok(!JSON.stringify(listedClient).includes(firstKey), "API 客户端目录不能返回完整密钥"); + assert.deepEqual(listedClient.scopes, ["jobs:read", "models:read"], "API 客户端目录必须返回真实 scope"); + + const clientJobs = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope }); + assert.equal(clientJobs.response.ok, true, "新 API 密钥不能访问已授权任务读取接口"); + const firstJobId = clientJobs.payload.jobs?.[0]?.id; + if (firstJobId) { + const clientJobDetail = await request(`/api/jobs/${encodeURIComponent(firstJobId)}`, { authorization: `Bearer ${firstKey}`, ...scope }); + assert.equal(clientJobDetail.response.ok, true, "jobs:read 必须允许读取任务详情"); + } + const readOnlyCreate = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope }, { method: "POST", body: "{}" }); + assert.equal(readOnlyCreate.response.status, 403, "jobs:read 不能创建生成任务"); + assert.equal(readOnlyCreate.payload.error, "api_client_scope_denied", "缺少 jobs:write 时必须返回 scope 错误"); + + const modelRead = await request("/api/platform/models", { authorization: `Bearer ${firstKey}`, ...scope }); + assert.equal(modelRead.response.ok, true, "models:read 必须允许读取模型连接器"); + const modelWriteDenied = await request("/api/platform/models/owned-image", { authorization: `Bearer ${firstKey}`, ...scope }, { method: "PATCH", body: JSON.stringify({}) }); + assert.equal(modelWriteDenied.response.status, 403, "models:read 不能修改模型连接器"); + assert.equal(modelWriteDenied.payload.error, "api_client_scope_denied", "缺少 models:write 时必须返回 scope 错误"); + + const scopeUpdate = await request(`/api/system/api-clients/${encodeURIComponent(clientId)}`, owner, { + method: "PATCH", + body: JSON.stringify({ scopes: ["jobs:read", "jobs:write", "models:read", "models:write", "audit:read"] }) + }); + assert.equal(scopeUpdate.response.ok, true, "API 客户端 scope 更新失败"); + assert.deepEqual(scopeUpdate.payload.client.scopes, ["jobs:read", "jobs:write", "models:read", "models:write", "audit:read"], "API 客户端 scope 更新结果不正确"); + + const writeKey = firstKey; + const dryRun = await request("/api/adapters/dry-run", { authorization: `Bearer ${writeKey}`, ...scope }, { method: "POST", body: JSON.stringify({ shotId: "shot-01", adapterId: "owned-image" }) }); + assert.equal(dryRun.response.ok, true, "jobs:write 必须允许生成请求 dry-run"); + const auditRead = await request("/api/audit", { authorization: `Bearer ${writeKey}`, ...scope }); + assert.equal(auditRead.response.ok, true, "audit:read 必须允许读取审计日志"); + + modelId = `smoke-scope-model-${Date.now()}`; + const modelWrite = await request("/api/platform/models/register", { authorization: `Bearer ${writeKey}`, ...scope }, { + method: "POST", + body: JSON.stringify({ id: modelId, label: "Scope 回归本地模型", endpoint: "http://127.0.0.1:7879", kind: "http-json", capability: ["text-to-image"], costMode: "local" }) + }); + assert.equal(modelWrite.response.status, 201, "models:write 必须允许登记模型连接器"); + + const rotated = await request(`/api/system/api-clients/${encodeURIComponent(clientId)}/rotate`, owner, { method: "POST", body: "{}" }); + assert.equal(rotated.response.ok, true, "API 密钥轮换失败"); + assert.notEqual(rotated.payload.clientKey, firstKey, "轮换后的 API 密钥不能与旧密钥相同"); + + const oldKey = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope }); + assert.equal(oldKey.response.status, 401, "旧 API 密钥轮换后必须立即失效"); + const newKey = await request("/api/jobs", { authorization: `Bearer ${rotated.payload.clientKey}`, ...scope }); + assert.equal(newKey.response.ok, true, "轮换后的 API 密钥不能访问已授权任务读取接口"); + + console.log(`api client smoke passed: scoped access, one-time rotation, old-key revocation (${clientId})`); +} finally { + if (clientId) { + withTransaction(() => { + if (modelId) { + dbRun("DELETE FROM model_connectors WHERE id = ?", [modelId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [modelId]); + } + dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]); + }); + } +} diff --git a/scripts/smoke-api-media.mjs b/scripts/smoke-api-media.mjs new file mode 100644 index 0000000..6df996b --- /dev/null +++ b/scripts/smoke-api-media.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { execFileSync } from "node:child_process"; +import { mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbGet, dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const projectRoot = resolve(import.meta.dirname, ".."); +const scope = { "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" }; +const fixtureRelative = "storage/jobs/api-media-smoke/fixture.mp4"; +const fixtureAbsolute = resolve(projectRoot, fixtureRelative); + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}) } }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +const login = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); +assert.equal(login.response.ok, true, "media API smoke login must pass"); +const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope }; +const connector = dbGet("SELECT * FROM model_connectors WHERE id = 'owned-i2v'"); +const originalConnector = { endpoint: connector.endpoint, status: connector.status, errorMessage: connector.error_message }; +const server = createServer(async (req, res) => { + if (req.method !== "POST") { res.writeHead(405); res.end(); return; } + const jobId = req.headers["x-ai-drama-job-id"]; + assert.ok(jobId, "adapter request must carry job id"); + const outputRelative = `storage/jobs/${jobId}/output.mp4`; + await mkdir(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true }); + execFileSync("cp", [fixtureAbsolute, resolve(projectRoot, outputRelative)]); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ outputPath: outputRelative, mimeType: "video/mp4", provider: "local-smoke" })); +}); +await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); +const address = server.address(); +const endpoint = `http://127.0.0.1:${address.port}/generate`; +const createdJobIds = []; + +try { + await mkdir(resolve(projectRoot, "storage/jobs/api-media-smoke"), { recursive: true }); + execFileSync("ffmpeg", ["-y", "-v", "error", "-f", "lavfi", "-i", "color=c=0x8b3d59:s=320x568:d=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", fixtureAbsolute], { stdio: "pipe" }); + dbRun("UPDATE model_connectors SET endpoint = ?, status = 'ready', error_message = '', updated_at = ? WHERE id = 'owned-i2v'", [endpoint, new Date().toISOString()]); + const created = await request("/api/jobs", { method: "POST", headers, body: JSON.stringify({ adapter: "owned-i2v", kind: "视频片段", shotId: "shot-01", output: "storage/jobs/api-media-smoke/request-output.mp4" }) }); + assert.equal(created.response.ok, true, `job create failed: ${created.payload.detail || ""}`); + const jobId = created.payload.job.id; + createdJobIds.push(jobId); + const run = await request(`/api/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST", headers, body: "{}" }); + assert.equal(run.response.ok, true, `job run failed: ${run.payload.detail || ""}`); + assert.equal(run.payload.job.status, "completed", "HTTP generation job must complete"); + assert.equal(run.payload.job.result.artifacts?.[0]?.status, "inspected", "completed job must return inspected media evidence"); + assert.ok(run.payload.job.result.artifacts?.[0]?.sha256, "completed job must return SHA-256 evidence"); + const artifacts = await request(`/api/production/media-artifacts?jobId=${encodeURIComponent(jobId)}`, { headers }); + assert.equal(artifacts.response.ok, true, "media artifact API must be readable"); + assert.equal(artifacts.payload.artifacts?.[0]?.job_id, jobId, "artifact must be linked to the generation job"); + console.log(`api media smoke passed: ${jobId} -> ${artifacts.payload.artifacts[0].last_frame_path}`); +} finally { + for (const jobId of createdJobIds) { + dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]); + dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]); + await rm(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true, force: true }); + } + dbRun("UPDATE model_connectors SET endpoint = ?, status = ?, error_message = ?, updated_at = ? WHERE id = 'owned-i2v'", [originalConnector.endpoint, originalConnector.status, originalConnector.errorMessage, new Date().toISOString()]); + await new Promise((resolveClose) => server.close(resolveClose)); + await rm(resolve(projectRoot, "storage/jobs/api-media-smoke"), { recursive: true, force: true }); +} diff --git a/scripts/smoke-api-rate-limit.mjs b/scripts/smoke-api-rate-limit.mjs new file mode 100644 index 0000000..0815039 --- /dev/null +++ b/scripts/smoke-api-rate-limit.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { dbRun, withTransaction } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, headers = {}, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login() { + const result = await request("/api/auth/login", {}, { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); + assert.equal(result.response.ok, true, "限流回归登录失败"); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const owner = { ...(await login()), ...scope }; +const restoreOwner = { ...(await login()), ...scope }; +let clientId = ""; +let originalLimit = 120; +let changedLimit = false; + +try { + const config = await request("/api/system/config", owner); + assert.equal(config.response.ok, true, "读取系统配置失败"); + const setting = config.payload.settings.find((item) => item.key === "api.rate_limit_per_minute"); + originalLimit = Number(setting?.value ?? 120); + + const created = await request("/api/system/api-clients", owner, { + method: "POST", + body: JSON.stringify({ name: `Smoke Rate Limit Client ${Date.now()}`, scopes: ["jobs:read"] }) + }); + assert.equal(created.response.status, 201, "限流回归 API 客户端创建失败"); + clientId = created.payload.client.id; + const clientHeaders = { authorization: `Bearer ${created.payload.clientKey}`, ...scope }; + + const updated = await request("/api/system/config", owner, { + method: "POST", + body: JSON.stringify({ settings: [{ key: "api.rate_limit_per_minute", value: 2 }] }) + }); + assert.equal(updated.response.ok, true, "降低 API 限流配置失败"); + changedLimit = true; + + const first = await request("/api/jobs", clientHeaders); + const second = await request("/api/jobs", clientHeaders); + const third = await request("/api/jobs", clientHeaders); + assert.equal(first.response.status, 200, "限流窗口内第一次请求不应被阻断"); + assert.equal(second.response.status, 200, "限流窗口内第二次请求不应被阻断"); + assert.equal(first.response.headers.get("x-ratelimit-limit"), "2", "响应必须返回限流上限"); + assert.equal(second.response.headers.get("x-ratelimit-remaining"), "0", "第二次请求后剩余额度应为 0"); + assert.equal(third.response.status, 429, "超过限流上限必须返回 429"); + assert.equal(third.payload.error, "rate_limit_exceeded", "限流错误码必须稳定"); + assert.ok(Number(third.response.headers.get("retry-after")) > 0, "429 必须返回 Retry-After"); + assert.equal(third.response.headers.get("x-ratelimit-remaining"), "0", "429 响应剩余额度应为 0"); + + console.log("api rate-limit smoke passed: configured limit, headers, 429, retry-after"); +} finally { + if (changedLimit) { + const restored = await request("/api/system/config", restoreOwner, { + method: "POST", + body: JSON.stringify({ settings: [{ key: "api.rate_limit_per_minute", value: originalLimit }] }) + }); + assert.equal(restored.response.ok, true, "限流回归未能恢复原配置"); + } + if (clientId) { + withTransaction(() => { + dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]); + }); + } +} + diff --git a/scripts/smoke-audit.mjs b/scripts/smoke-audit.mjs new file mode 100644 index 0000000..8047ce0 --- /dev/null +++ b/scripts/smoke-audit.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; + +async function request(path, headers, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) } + }); + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("json") ? await response.json().catch(() => ({})) : await response.text(); + return { response, payload }; +} + +async function login(email) { + const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assert.equal(result.response.status, 200, `${email} 登录失败`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const systemAdmin = await login("producer@local.test"); +const organizationAdmin = await login("producer2@local.test"); +const ordinaryUser = await login("writer@local.test"); + +const globalList = await request("/api/audit?page=1&pageSize=2&result=ok", systemAdmin); +assert.equal(globalList.response.status, 200, "系统管理员读取审计列表失败"); +assert.equal(globalList.payload.scope.mode, "global", "系统管理员应获得全局审计范围"); +assert.equal(globalList.payload.auditLog.length, 2, "服务端分页未按 pageSize 返回"); +assert.ok(globalList.payload.pagination.total > 2 && globalList.payload.pagination.hasMore, "审计分页元数据不完整"); + +const auditId = globalList.payload.auditLog[0]?.id; +assert.ok(auditId, "审计列表缺少事件 ID"); +const detail = await request(`/api/audit/${encodeURIComponent(auditId)}`, systemAdmin); +assert.equal(detail.response.status, 200, "系统管理员读取审计详情失败"); +assert.equal(detail.payload.event.id, auditId, "审计详情 ID 不一致"); +assert.ok(Array.isArray(detail.payload.relatedAudit), "审计详情缺少关联审计列表"); +assert.ok(Array.isArray(detail.payload.relatedSecurityEvents), "审计详情缺少安全事件列表"); + +const exported = await request("/api/audit/export?query=project&pageSize=5", systemAdmin); +assert.equal(exported.response.status, 200, "审计 JSON 导出失败"); +assert.ok(Array.isArray(exported.payload.auditLog), "审计 JSON 导出缺少事件数组"); +assert.ok(Number.isInteger(exported.payload.total), "审计 JSON 导出缺少总数"); + +const csv = await request("/api/audit/export?format=csv&pageSize=2", systemAdmin); +assert.equal(csv.response.status, 200, "审计 CSV 导出失败"); +assert.match(csv.response.headers.get("content-type") || "", /text\/csv/); +assert.match(csv.response.headers.get("content-disposition") || "", /audit-/); +assert.match(csv.payload, /id,|"id"/); + +const orgList = await request("/api/audit?page=1&pageSize=5", organizationAdmin); +assert.equal(orgList.response.status, 200, "组织管理员读取审计列表失败"); +assert.equal(orgList.payload.scope.mode, "organization", "组织管理员应限制在组织范围"); +assert.equal(orgList.payload.scope.organizationId, "org-northstar", "组织管理员组织范围错误"); +const crossOrganization = await request("/api/audit?organizationId=org-studio-lab", organizationAdmin); +assert.equal(crossOrganization.response.status, 403, "组织管理员不应读取其他组织审计"); + +const denied = await request("/api/audit", ordinaryUser); +assert.equal(denied.response.status, 403, "普通用户不应读取审计中心"); + +console.log(`audit smoke passed: ${api}`); diff --git a/scripts/smoke-billing-ledger.mjs b/scripts/smoke-billing-ledger.mjs new file mode 100644 index 0000000..4228c38 --- /dev/null +++ b/scripts/smoke-billing-ledger.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const studioScope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; +const northstarScope = { + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main", + "x-project-id": "northstar-pilot" +}; +const periodStart = "2025-01-01"; +const periodEnd = "2025-01-31"; + +async function request(path, headers = {}, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function requestText(path, headers = {}, options = {}) { + const response = await fetch(`${api}${path}`, { ...options, headers: { ...headers, ...(options.headers || {}) } }); + return { response, body: await response.text() }; +} + +function assertOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +async function login(email) { + const result = await request("/api/auth/login", {}, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assertOk(result, `${email} 登录`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const ownerAuth = await login("producer@local.test"); +const writerAuth = await login("writer@local.test"); +const northstarAdminAuth = await login("producer2@local.test"); +const ownerHeaders = { ...ownerAuth, ...studioScope }; +const writerHeaders = { ...writerAuth, ...studioScope }; +const northstarHeaders = { ...northstarAdminAuth, ...northstarScope }; +let invoiceId = ""; + +try { + const writerList = await request("/api/organizations/org-studio-lab/invoices", writerHeaders); + assert.equal(writerList.response.status, 403, "普通成员不能读取账单台账"); + + const writerGenerate = await request("/api/organizations/org-studio-lab/invoices/generate", writerHeaders, { + method: "POST", + body: JSON.stringify({ periodStart, periodEnd }) + }); + assert.equal(writerGenerate.response.status, 403, "普通成员不能生成账单"); + + const generated = assertOk(await request("/api/organizations/org-studio-lab/invoices/generate", ownerHeaders, { + method: "POST", + body: JSON.stringify({ periodStart, periodEnd, taxRate: 6, dueDays: 15 }) + }), "管理员生成账单"); + assert.equal(generated.invoice.status, "draft", "新账单必须从草稿开始"); + assert.equal(generated.invoice.taxRate, 6, "税率快照没有保存"); + assert.ok(generated.lines.length >= 1, "账单必须包含套餐固定费或用量明细"); + invoiceId = generated.invoice.id; + + const duplicate = assertOk(await request("/api/organizations/org-studio-lab/invoices/generate", ownerHeaders, { + method: "POST", + body: JSON.stringify({ periodStart, periodEnd, taxRate: 99 }) + }), "重复生成账单"); + assert.equal(duplicate.idempotent, true, "重复生成必须幂等返回"); + assert.equal(duplicate.invoice.id, invoiceId, "重复生成不能创建第二张账单"); + assert.equal(duplicate.invoice.taxRate, 6, "幂等返回不能覆盖原账单快照"); + + const issued = assertOk(await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}/status`, ownerHeaders, { + method: "POST", + body: JSON.stringify({ status: "issued" }) + }), "账单开票"); + assert.equal(issued.invoice.status, "issued", "账单没有进入已开票状态"); + assert.ok(issued.invoice.issuedAt, "开票必须记录 issuedAt"); + + const paid = assertOk(await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}/status`, ownerHeaders, { + method: "POST", + body: JSON.stringify({ status: "paid" }) + }), "账单标记支付"); + assert.equal(paid.invoice.status, "paid", "账单没有进入已支付状态"); + assert.ok(paid.invoice.paidAt, "支付必须记录 paidAt"); + + const invalidTransition = await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}/status`, ownerHeaders, { + method: "POST", + body: JSON.stringify({ status: "overdue" }) + }); + assert.equal(invalidTransition.response.status, 409, "已支付账单不能退回逾期"); + assert.equal(invalidTransition.payload.error, "invoice_transition_invalid", "账单状态机错误码不稳定"); + + const detail = assertOk(await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}`, ownerHeaders), "管理员读取账单详情"); + assert.equal(detail.invoice.status, "paid", "详情状态与状态流转不一致"); + assert.ok(Array.isArray(detail.lines), "账单详情必须包含明细"); + + const writerDetail = await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}`, writerHeaders); + assert.equal(writerDetail.response.status, 403, "普通成员不能读取账单详情"); + + const jsonExport = assertOk(await request(`/api/organizations/org-studio-lab/invoices/export?format=json&status=paid`, ownerHeaders), "导出账单 JSON"); + assert.ok(jsonExport.invoices.some((invoice) => invoice.id === invoiceId), "JSON 导出缺少账单"); + const csvExport = await requestText(`/api/organizations/org-studio-lab/invoices/export?format=csv&status=paid`, ownerHeaders); + assert.equal(csvExport.response.status, 200, "账单 CSV 导出应成功"); + assert.match(csvExport.response.headers.get("content-type") || "", /text\/csv/, "账单 CSV content-type 不正确"); + assert.match(csvExport.body, /invoice_number/, "账单 CSV 表头不完整"); + assert.match(csvExport.body, new RegExp(invoiceId), "账单 CSV 缺少当前账单"); + + const crossOrganization = await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}`, northstarHeaders); + assert.equal(crossOrganization.response.status, 403, "跨组织账单详情必须被阻断"); + const northstarList = assertOk(await request("/api/organizations/org-northstar/invoices", northstarHeaders), "读取北辰组织账单"); + assert.ok(!northstarList.invoices.some((invoice) => invoice.id === invoiceId), "北辰组织不能看到星河账单"); + + console.log(`billing ledger smoke passed: ${api}`); +} finally { + if (invoiceId) { + dbRun("DELETE FROM billing_account_events WHERE event_type LIKE 'invoice.%' AND next_json LIKE ?", [`%${invoiceId}%`]); + dbRun("DELETE FROM audit_logs WHERE target_type = 'organization_invoice' AND target_id = ?", [invoiceId]); + dbRun("DELETE FROM organization_invoices WHERE id = ?", [invoiceId]); + } +} diff --git a/scripts/smoke-commercial-governance.mjs b/scripts/smoke-commercial-governance.mjs new file mode 100644 index 0000000..a68de40 --- /dev/null +++ b/scripts/smoke-commercial-governance.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; + +async function request(path, headers, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email) { + const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assert.equal(result.response.ok, true, `${email} 登录失败`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const owner = await login("producer@local.test"); +const writer = await login("writer@local.test"); +const createdEmail = `commercial-governance-${Date.now()}@local.test`; +let createdUserId = ""; +let originalBilling = null; +let originalCostCenter = null; + +try { + const deniedCreate = await request("/api/system/users", writer, { method: "POST", body: JSON.stringify({ displayName: "越权用户", email: `denied-${Date.now()}@local.test` }) }); + assert.equal(deniedCreate.response.status, 403, "普通用户不应手动创建全局账号"); + + const created = await request("/api/system/users", owner, { + method: "POST", + body: JSON.stringify({ displayName: "治理回归用户", email: createdEmail, organizationId: "org-studio-lab", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" }) + }); + assert.equal(created.response.status, 201, `系统管理员创建用户失败:${JSON.stringify(created.payload)}`); + assert.ok(created.payload.temporaryPassword, "留空密码时必须返回一次性初始密码"); + createdUserId = created.payload.user.id; + assert.ok(created.payload.user.organizations.some((item) => item.id === "org-studio-lab"), "创建用户时组织归属未落库"); + assert.ok(created.payload.user.workspaces.some((item) => item.id === "ws-local-aidrama"), "创建用户时工作区归属未落库"); + + const resetPassword = await request(`/api/system/users/${encodeURIComponent(createdUserId)}/reset-password`, owner, { method: "POST", body: "{}" }); + assert.equal(resetPassword.response.ok, true, "管理员重置密码失败"); + assert.ok(resetPassword.payload.temporaryPassword, "自动重置密码必须返回一次性密码"); + const resetMfa = await request(`/api/system/users/${encodeURIComponent(createdUserId)}/reset-mfa`, owner, { method: "POST", body: "{}" }); + assert.equal(resetMfa.response.ok, true, "管理员重置 MFA 失败"); + + const memberships = await request(`/api/system/users/${encodeURIComponent(createdUserId)}/memberships`, owner, { method: "POST", body: JSON.stringify({ organizationId: "org-studio-lab", organizationRoleKey: "org_member", workspaceId: "ws-pilot", workspaceRoleKey: "producer", projectId: "template-original-manhua", projectRoleKey: "project_viewer" }) }); + assert.equal(memberships.response.ok, true, "管理员调整用户归属失败"); + assert.ok(memberships.payload.user.workspaces.some((item) => item.id === "ws-pilot"), "工作区归属调整未生效"); + + const commercial = await request("/api/organizations/org-studio-lab/commercial", owner); + assert.equal(commercial.response.ok, true, "读取商业运营数据失败"); + assert.ok(commercial.payload.billing.billing_cycle && Array.isArray(commercial.payload.costCenters), "商业数据缺少账期或成本中心"); + assert.ok(Array.isArray(commercial.payload.quotaWarnings) && Array.isArray(commercial.payload.billingHistory), "商业数据缺少预警或账单变更记录"); + assert.ok(Array.isArray(commercial.payload.usageTrend), "商业数据缺少按日用量趋势"); + assert.ok(commercial.payload.usageTrend.every((row) => row.day && Number.isFinite(row.units) && Number.isFinite(row.estimatedCost) && Number.isFinite(row.events)), "按日用量趋势字段不完整"); + originalBilling = { + planName: commercial.payload.billing.plan_name, + billingCycle: commercial.payload.billing.billing_cycle, + currency: commercial.payload.billing.currency, + seatLimit: Number(commercial.payload.billing.seat_limit), + storageGb: Number(commercial.payload.billing.storage_gb), + monthlyClipQuota: Number(commercial.payload.billing.monthly_clip_quota), + quotaWarningPercent: Number(commercial.payload.billing.quota_warning_percent), + localRunnerOnly: Boolean(commercial.payload.billing.local_runner_only), + cloudConnectorsRequireApproval: Boolean(commercial.payload.billing.cloud_connectors_require_approval) + }; + originalCostCenter = commercial.payload.costCenters[0]; + const updatedBilling = await request("/api/organizations/org-studio-lab/billing", owner, { method: "PATCH", body: JSON.stringify({ ...originalBilling, billingCycle: "quarterly", quotaWarningPercent: 85 }) }); + assert.equal(updatedBilling.response.ok, true, "账单周期更新失败"); + assert.equal(updatedBilling.payload.billing.billing_cycle, "quarterly", "账单周期未保存"); + assert.ok(Array.isArray(updatedBilling.payload.usageTrend), "保存套餐后没有返回用量趋势"); + const updatedCenter = await request(`/api/organizations/org-studio-lab/cost-centers/${encodeURIComponent(originalCostCenter.id)}`, owner, { method: "PATCH", body: JSON.stringify({ monthlyBudget: Number(originalCostCenter.monthly_budget) + 1 }) }); + assert.equal(updatedCenter.response.ok, true, "成本中心预算更新失败"); + const exported = await request("/api/organizations/org-studio-lab/commercial/export", owner); + assert.equal(exported.response.ok, true, "商业运营数据导出失败"); + assert.ok(exported.payload.costCenterDetail && exported.payload.billingHistory && Array.isArray(exported.payload.usageTrend), "导出数据不完整"); + + const workerStatus = await request("/api/system/worker", owner); + assert.equal(workerStatus.response.ok, true, "Worker 状态接口失败"); + assert.ok(workerStatus.payload.worker.healthStatus, "Worker 状态缺少心跳健康字段"); + const batch = await request("/api/admin/queue/batch", owner, { method: "POST", body: JSON.stringify({ action: "retry", jobIds: ["missing-governance-job"] }) }); + assert.equal(batch.response.ok, true, "批量队列运维接口失败"); + assert.equal(batch.payload.failureCount, 1, "批量队列应返回逐任务失败结果"); + const deniedBatch = await request("/api/admin/queue/batch", writer, { method: "POST", body: JSON.stringify({ action: "retry", jobIds: ["missing-governance-job"] }) }); + assert.equal(deniedBatch.response.status, 403, "普通用户不应执行批量队列运维"); + + console.log(`commercial governance smoke passed: ${api}`); +} finally { + if (originalBilling) { + await request("/api/organizations/org-studio-lab/billing", owner, { method: "PATCH", body: JSON.stringify(originalBilling) }); + } + if (originalCostCenter) { + await request(`/api/organizations/org-studio-lab/cost-centers/${encodeURIComponent(originalCostCenter.id)}`, owner, { method: "PATCH", body: JSON.stringify({ monthlyBudget: Number(originalCostCenter.monthly_budget) }) }); + } + if (createdUserId) { + dbRun("DELETE FROM users WHERE id = ?", [createdUserId]); + } +} diff --git a/scripts/smoke-commercial-ops.mjs b/scripts/smoke-commercial-ops.mjs new file mode 100644 index 0000000..71c7b31 --- /dev/null +++ b/scripts/smoke-commercial-ops.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const studioScope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; +const northstarScope = { + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main", + "x-project-id": "northstar-pilot" +}; + +async function request(path, headers = {}, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function requestText(path, headers = {}, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { ...headers, ...(options.headers || {}) } + }); + return { response, body: await response.text() }; +} + +function assertOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +function billingSnapshot(billing) { + return { + planName: billing.plan_name, + seatLimit: Number(billing.seat_limit), + storageGb: Number(billing.storage_gb), + monthlyClipQuota: Number(billing.monthly_clip_quota), + localRunnerOnly: Boolean(billing.local_runner_only), + cloudConnectorsRequireApproval: Boolean(billing.cloud_connectors_require_approval) + }; +} + +function quotaSnapshots(quotas) { + return (quotas || []).map((quota) => ({ id: quota.id, limitValue: Number(quota.limitValue ?? quota.limit_value) })); +} + +function mutatedBilling(original, commercial) { + const reserved = Number(commercial.seat?.reserved || 0); + const clipUsed = Math.ceil(Math.max(...(commercial.quotas || []).filter((quota) => quota.metric === "clip").map((quota) => Number(quota.usedValue || 0)), 0)); + const storageUsed = Math.ceil(Math.max(...(commercial.quotas || []).filter((quota) => quota.metric === "storage").map((quota) => Number(quota.usedValue || 0)), 0)); + return { + planName: `${original.planName} · Smoke`, + seatLimit: Math.max(original.seatLimit + 1, reserved + 1), + storageGb: Math.max(original.storageGb + 1, storageUsed + 1), + monthlyClipQuota: Math.max(original.monthlyClipQuota + 1, clipUsed + 1), + localRunnerOnly: !original.localRunnerOnly, + cloudConnectorsRequireApproval: !original.cloudConnectorsRequireApproval + }; +} + +async function login(email) { + const result = await request("/api/auth/login", {}, { + method: "POST", + body: JSON.stringify({ email, password: "Demo@123456" }) + }); + assertOk(result, `${email} 登录`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +async function patchBilling(headers, organizationId, body) { + return request(`/api/organizations/${encodeURIComponent(organizationId)}/billing`, headers, { + method: "PATCH", + body: JSON.stringify(body) + }); +} + +async function restoreOrganization(label, headers, organizationId, originalBilling, originalQuotas) { + const failures = []; + for (const quota of originalQuotas) { + const result = await request(`/api/organizations/${encodeURIComponent(organizationId)}/quotas/${encodeURIComponent(quota.id)}`, headers, { + method: "PATCH", + body: JSON.stringify({ limitValue: quota.limitValue }) + }); + if (!result.response.ok) failures.push(`${label} quota ${quota.id}: ${result.response.status} ${result.payload.error || ""}`); + } + const billingResult = await patchBilling(headers, organizationId, originalBilling); + if (!billingResult.response.ok) failures.push(`${label} billing: ${billingResult.response.status} ${billingResult.payload.error || ""}`); + if (failures.length) throw new Error(`恢复 ${label} 配置失败:${failures.join("; ")}`); +} + +const ownerAuth = await login("producer@local.test"); +const writerAuth = await login("writer@local.test"); +const orgAdminAuth = await login("producer2@local.test"); +const ownerHeaders = { ...ownerAuth, ...studioScope }; +const writerHeaders = { ...writerAuth, ...studioScope }; +const orgAdminHeaders = { ...orgAdminAuth, ...northstarScope }; + +let studioOriginal = null; +let studioOriginalQuotas = []; +let northstarOriginal = null; +let northstarOriginalQuotas = []; + +try { + const studioBefore = assertOk(await request("/api/organizations/org-studio-lab/commercial", ownerHeaders), "系统管理员读取星河商业配置"); + assert.ok(studioBefore.billing && studioBefore.seat && Array.isArray(studioBefore.quotas), "商业配置必须包含套餐、席位和工作区配额"); + studioOriginal = billingSnapshot(studioBefore.billing); + studioOriginalQuotas = quotaSnapshots(studioBefore.quotas); + + const writerCommercial = await request("/api/organizations/org-studio-lab/commercial", writerHeaders); + assert.equal(writerCommercial.response.status, 403, "普通编剧不应读取组织商业配置"); + const writerBilling = await patchBilling(writerHeaders, "org-studio-lab", studioOriginal); + assert.equal(writerBilling.response.status, 403, "普通编剧不应修改组织套餐"); + + const usageBefore = assertOk(await request("/api/organizations/org-studio-lab/usage?from=2026-01-01&to=2026-12-31&page=1&pageSize=10", ownerHeaders), "系统管理员读取事件级用量"); + assert.ok(Array.isArray(usageBefore.items), "用量明细必须返回事件列表"); + assert.ok(usageBefore.pagination && Number.isInteger(usageBefore.pagination.total), "用量明细必须返回分页信息"); + assert.ok(usageBefore.summary && Array.isArray(usageBefore.summary.byKind), "用量明细必须返回分类汇总"); + assert.ok(usageBefore.facets && Array.isArray(usageBefore.facets.workspaces) && Array.isArray(usageBefore.facets.users), "用量明细必须返回筛选选项"); + const workspaceUsage = assertOk(await request("/api/organizations/org-studio-lab/usage?workspaceId=ws-local-aidrama&from=2026-01-01&to=2026-12-31", ownerHeaders), "按工作区筛选用量"); + assert.ok(workspaceUsage.items.every((item) => item.workspaceId === "ws-local-aidrama"), "工作区筛选不能返回其他工作区事件"); + const costCenterUsage = assertOk(await request("/api/organizations/org-studio-lab/usage?costCenter=local-gpu&from=2026-01-01&to=2026-12-31", ownerHeaders), "按成本中心筛选用量"); + assert.ok(costCenterUsage.items.every((item) => item.costCenter === "local-gpu"), "成本中心筛选不能返回其他成本中心事件"); + const writerUsage = await request("/api/organizations/org-studio-lab/usage", writerHeaders); + assert.equal(writerUsage.response.status, 403, "普通编剧不应读取组织用量明细"); + const writerUsageExport = await request("/api/organizations/org-studio-lab/usage/export?format=json", writerHeaders); + assert.equal(writerUsageExport.response.status, 403, "普通编剧不应导出组织用量明细"); + const usageJsonExport = assertOk(await request("/api/organizations/org-studio-lab/usage/export?format=json&from=2026-01-01&to=2026-12-31", ownerHeaders), "导出用量 JSON"); + assert.ok(Array.isArray(usageJsonExport.items), "JSON 用量导出必须包含事件列表"); + const usageCsvExport = await requestText("/api/organizations/org-studio-lab/usage/export?format=csv&from=2026-01-01&to=2026-12-31", ownerHeaders); + assert.equal(usageCsvExport.response.status, 200, "导出用量 CSV 应成功"); + assert.match(usageCsvExport.response.headers.get("content-type") || "", /text\/csv/, "用量 CSV content-type 不正确"); + assert.match(usageCsvExport.body, /"id","created_at","workspace_id"/, "用量 CSV 表头不完整"); + + const studioMutation = mutatedBilling(studioOriginal, studioBefore); + const studioAfterUpdate = assertOk(await patchBilling(ownerHeaders, "org-studio-lab", studioMutation), "系统管理员更新星河套餐"); + assert.equal(studioAfterUpdate.billing.plan_name, studioMutation.planName, "套餐名称未保存"); + assert.equal(Number(studioAfterUpdate.billing.seat_limit), studioMutation.seatLimit, "席位上限未保存"); + assert.equal(Boolean(studioAfterUpdate.billing.local_runner_only), studioMutation.localRunnerOnly, "本地 Runner 策略未保存"); + assert.equal(Boolean(studioAfterUpdate.billing.cloud_connectors_require_approval), studioMutation.cloudConnectorsRequireApproval, "外部连接器审批策略未保存"); + + const studioClipQuota = studioBefore.quotas.find((quota) => quota.metric === "clip"); + assert.ok(studioClipQuota, "星河组织缺少片段配额"); + const nextClipLimit = Math.max(Number(studioClipQuota.usedValue || 0), Math.min(studioMutation.monthlyClipQuota, Number(studioClipQuota.limitValue || 0) + 1)); + const clipQuotaUpdate = assertOk(await request(`/api/organizations/org-studio-lab/quotas/${encodeURIComponent(studioClipQuota.id)}`, ownerHeaders, { + method: "PATCH", + body: JSON.stringify({ limitValue: nextClipLimit }) + }), "更新星河片段配额"); + assert.equal(Number(clipQuotaUpdate.quotas.find((quota) => quota.id === studioClipQuota.id)?.limitValue), nextClipLimit, "片段配额未保存"); + + const overPlanQuota = await request(`/api/organizations/org-studio-lab/quotas/${encodeURIComponent(studioClipQuota.id)}`, ownerHeaders, { + method: "PATCH", + body: JSON.stringify({ limitValue: studioMutation.monthlyClipQuota + 1 }) + }); + assert.equal(overPlanQuota.response.status, 409, "工作区片段配额不能超过组织套餐"); + assert.equal(overPlanQuota.payload.error, "quota_above_plan", "超套餐配额错误码不稳定"); + + if (Number(studioBefore.seat?.reserved || 0) > 1) { + const belowReserved = await patchBilling(ownerHeaders, "org-studio-lab", { seatLimit: Number(studioBefore.seat.reserved) - 1 }); + assert.equal(belowReserved.response.status, 409, "席位上限不能低于已占用和待处理邀请"); + assert.equal(belowReserved.payload.error, "seat_limit_below_reserved", "席位冲突错误码不稳定"); + } + + const northstarBefore = assertOk(await request("/api/organizations/org-northstar/commercial", orgAdminHeaders), "组织管理员读取北辰商业配置"); + assert.ok(northstarBefore.billing && northstarBefore.seat, "组织管理员商业配置返回不完整"); + northstarOriginal = billingSnapshot(northstarBefore.billing); + northstarOriginalQuotas = quotaSnapshots(northstarBefore.quotas); + assert.ok((await request("/api/context", orgAdminHeaders)).payload.context.permissions.includes("billing:manage"), "组织管理员缺少套餐管理权限"); + + const northstarMutation = mutatedBilling(northstarOriginal, northstarBefore); + const northstarAfterUpdate = assertOk(await patchBilling(orgAdminHeaders, "org-northstar", northstarMutation), "组织管理员更新北辰套餐"); + assert.equal(northstarAfterUpdate.billing.plan_name, northstarMutation.planName, "组织管理员套餐名称未保存"); + assert.equal(Number(northstarAfterUpdate.billing.seat_limit), northstarMutation.seatLimit, "组织管理员席位上限未保存"); + + console.log(`commercial ops smoke passed: ${api}`); +} finally { + const failures = []; + if (studioOriginal) { + try { + await restoreOrganization("星河组织", ownerHeaders, "org-studio-lab", studioOriginal, studioOriginalQuotas); + } catch (error) { + failures.push(error.message); + } + } + if (northstarOriginal) { + try { + await restoreOrganization("北辰组织", orgAdminHeaders, "org-northstar", northstarOriginal, northstarOriginalQuotas); + } catch (error) { + failures.push(error.message); + } + } + if (failures.length) throw new Error(failures.join("; ")); +} diff --git a/scripts/smoke-compose-evidence.mjs b/scripts/smoke-compose-evidence.mjs new file mode 100644 index 0000000..38178da --- /dev/null +++ b/scripts/smoke-compose-evidence.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, rm, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const projectRoot = resolve(import.meta.dirname, ".."); +const scope = { "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" }; +const rootRelative = "storage/compositions/smoke-compose-evidence"; +const rootAbsolute = resolve(projectRoot, rootRelative); +const clips = [`${rootRelative}/a.mp4`, `${rootRelative}/b.mp4`]; +const outputPath = `${rootRelative}/final.mp4`; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}) } }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +const login = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); +assert.equal(login.response.ok, true, "compose smoke login must pass"); +const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope }; +let compositionId = ""; + +try { + await mkdir(rootAbsolute, { recursive: true }); + for (const [index, clip] of clips.entries()) { + execFileSync("ffmpeg", ["-y", "-v", "error", "-f", "lavfi", "-i", `color=c=${index ? "0x3d7f54" : "0x7f4b2f"}:s=320x568:d=0.6`, "-c:v", "libx264", "-pix_fmt", "yuv420p", resolve(projectRoot, clip)], { stdio: "pipe" }); + } + const composed = await request("/api/production/compose", { method: "POST", headers, body: JSON.stringify({ version: "smoke-compose-evidence", clips, outputPath }) }); + assert.equal(composed.response.ok, true, `compose failed: ${composed.payload.detail || ""}`); + compositionId = composed.payload.composition.id; + assert.equal(composed.payload.composition.status, "completed", "FFmpeg 合成必须完成"); + assert.equal(composed.payload.composition.result.artifact.status, "inspected", "合成结果必须登记媒体证据"); + assert.ok(composed.payload.composition.result.artifact.sha256, "合成结果必须登记 SHA-256"); + assert.ok(composed.payload.composition.result.artifact.last_frame_path, "合成结果必须提取实际末帧"); + await stat(resolve(projectRoot, outputPath)); + console.log(`compose evidence smoke passed: ${outputPath} -> ${composed.payload.composition.result.artifact.last_frame_path}`); +} finally { + if (compositionId) { + dbRun("DELETE FROM media_artifacts WHERE composition_id = ?", [compositionId]); + dbRun("DELETE FROM media_compositions WHERE id = ?", [compositionId]); + } + await rm(rootAbsolute, { recursive: true, force: true }); +} diff --git a/scripts/smoke-creator-suite.mjs b/scripts/smoke-creator-suite.mjs new file mode 100644 index 0000000..e5a43e4 --- /dev/null +++ b/scripts/smoke-creator-suite.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbAll, dbRun, withTransaction } from "../server/db.mjs"; + +const api = "http://127.0.0.1:8787"; +const runId = Date.now(); +let createdAssetId = ""; +let uploadedAssetId = ""; +let jobId = ""; +let originalSeries = null; +let originalEpisode = null; + +async function cleanup() { + const assetIds = [createdAssetId, uploadedAssetId].filter(Boolean); + const assetPaths = assetIds.length + ? dbAll(`SELECT storage_path FROM asset_versions WHERE asset_id IN (${assetIds.map(() => "?").join(",")})`, assetIds) + : []; + + withTransaction(() => { + if (assetIds.length) { + const placeholders = assetIds.map(() => "?").join(","); + dbRun(`DELETE FROM asset_bindings WHERE asset_id IN (${placeholders})`, assetIds); + dbRun(`DELETE FROM asset_versions WHERE asset_id IN (${placeholders})`, assetIds); + dbRun(`DELETE FROM assets WHERE id IN (${placeholders})`, assetIds); + } + if (jobId) { + dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]); + dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]); + dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]); + dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]); + dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]); + } + if (originalSeries?.id) { + dbRun("UPDATE series SET title = ?, logline = ?, format = ?, visual_style = ?, continuity_rule = ?, show_engine = ?, updated_at = ? WHERE id = ?", [originalSeries.title, originalSeries.logline, originalSeries.format, originalSeries.visual_style, originalSeries.continuity_rule, originalSeries.show_engine, originalSeries.updated_at, originalSeries.id]); + } + if (originalEpisode?.id) { + dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [originalEpisode.title, originalEpisode.status, originalEpisode.target_duration_sec, originalEpisode.hook, originalEpisode.cliffhanger, originalEpisode.updated_at, originalEpisode.id]); + } + }); + + await Promise.all(assetPaths.map((asset) => rm(resolve(import.meta.dirname, "..", asset.storage_path), { force: true }).catch(() => {}))); + for (const assetId of assetIds) { + await rm(resolve(import.meta.dirname, "..", "storage", "assets", "thunder-mouth", assetId), { recursive: true, force: true }); + } +} + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json(); + assert.equal(response.ok, true, `${options.method || "GET"} ${path}: ${response.status} ${payload.detail || payload.error || ""}`); + return payload; +} + +async function rawRequest(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + return { response, payload: await response.json().catch(() => ({})) }; +} + +try { + const login = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) + }); + const headers = { + authorization: `Bearer ${login.session.token}`, + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" + }; + + const reviewerLogin = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" }) + }); + const reviewerHeaders = { + authorization: `Bearer ${reviewerLogin.session.token}`, + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" + }; + const reviewerAssets = await rawRequest("/api/assets", { headers: reviewerHeaders }); + assert.equal(reviewerAssets.response.status, 403, "没有资产权限的审片用户不能读取资产库"); + const reviewerUpload = await rawRequest("/api/assets/upload", { + method: "POST", + headers: reviewerHeaders, + body: JSON.stringify({ data: Buffer.from("reviewer-must-be-blocked").toString("base64"), fileName: "blocked.txt" }) + }); + assert.equal(reviewerUpload.response.status, 403, "没有资产权限的审片用户不能上传资产"); + + const assets = await request("/api/assets", { headers }); + assert.ok(Array.isArray(assets.assets), "资产列表必须是数组"); + assert.ok(assets.assets.length > 0, "示例项目必须有资产"); + + const invalidUpload = await rawRequest("/api/assets/upload", { + method: "POST", + headers, + body: JSON.stringify({ data: "not-base64@@", fileName: "invalid.txt" }) + }); + assert.equal(invalidUpload.response.status, 400, "非法 Base64 必须被服务端拒绝"); + assert.equal(invalidUpload.payload.error, "asset_data_invalid", "非法 Base64 必须返回明确错误码"); + +const created = await request("/api/assets", { + method: "POST", + headers, + body: JSON.stringify({ name: `smoke-asset-${Date.now()}`, kind: "reference", subtitle: "smoke" }) +}); +assert.ok(created.asset?.id, "创建资产必须返回 id"); +createdAssetId = created.asset.id; + +const bound = await request(`/api/assets/${encodeURIComponent(created.asset.id)}/bindings`, { + method: "POST", + headers, + body: JSON.stringify({ shotId: "shot-01", usageRole: "reference" }) +}); +assert.equal(bound.asset.bindings.some((item) => item.shot_id === "shot-01"), true, "资产绑定必须写入镜头"); + +const uploaded = await request("/api/assets/upload", { + method: "POST", + headers, + body: JSON.stringify({ data: Buffer.from("creator-suite-smoke").toString("base64"), fileName: "smoke-reference.txt", kind: "reference" }) +}); +assert.ok(uploaded.asset?.currentVersion?.storage_path?.includes("storage/assets/"), "本地上传必须写入 storage/assets"); +uploadedAssetId = uploaded.asset.id; +const firstHash = createHash("sha256").update("creator-suite-smoke").digest("hex"); +assert.equal(uploaded.asset.currentVersion.contentSha256, firstHash, "上传资产必须登记 SHA-256"); + +const uploadedVersion = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/upload`, { + method: "POST", + headers, + body: JSON.stringify({ data: Buffer.from("creator-suite-smoke-v2").toString("base64"), fileName: "smoke-reference-v2.txt", mimeType: "text/plain", versionNote: "smoke file version" }) +}); +const secondHash = createHash("sha256").update("creator-suite-smoke-v2").digest("hex"); +assert.equal(uploadedVersion.asset.currentVersion.version_number, 2, "文件上传版本必须递增"); +assert.equal(uploadedVersion.asset.currentVersion.contentSha256, secondHash, "文件版本必须登记新的 SHA-256"); +const verification = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/verify`, { method: "POST", headers, body: JSON.stringify({}) }); +assert.equal(verification.verification.verified, true, "当前资产文件完整性校验必须通过"); +const contentResponse = await fetch(`${api}/api/assets/${encodeURIComponent(uploaded.asset.id)}/content`, { headers }); +assert.equal(contentResponse.status, 200, "资产内容读取必须成功"); +assert.equal(contentResponse.headers.get("etag"), `"${secondHash}"`, "资产内容响应必须返回 ETag"); +assert.equal(await contentResponse.text(), "creator-suite-smoke-v2", "资产内容必须与当前文件版本一致"); + +const versioned = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions`, { + method: "POST", + headers, + body: JSON.stringify({ versionNote: "smoke version" }) +}); +assert.equal(versioned.asset.currentVersion.version_number, 3, "资产版本必须递增"); + +const restored = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/${encodeURIComponent(versioned.asset.versions.find((version) => version.version_number === 1).id)}/restore`, { + method: "POST", + headers, + body: JSON.stringify({}) +}); +assert.equal(restored.asset.currentVersion.version_number, 1, "资产版本必须支持恢复到历史版本"); + +const locked = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/lock`, { + method: "POST", + headers, + body: JSON.stringify({ lockStatus: "locked" }) +}); +assert.equal(locked.asset.lockStatus, "locked", "资产锁定状态必须持久化"); + +const assistant = await request("/api/assistant/query", { + method: "POST", + headers, + body: JSON.stringify({ question: "检查连续性", actionId: "continuity" }) +}); +assert.ok(assistant.answer?.length > 10, "项目助手必须返回真实答案"); + +const originalGraph = await request("/api/production/graph", { headers }); +originalSeries = originalGraph.graph.series; +originalEpisode = originalGraph.graph.episode; +const bibleUpdate = await request("/api/production/bible", { + method: "PATCH", + headers, + body: JSON.stringify({ + logline: "smoke test:雷暴来临前的原创避险选择。", + continuityRule: "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。" + }) +}); +assert.equal(bibleUpdate.bible.series.logline, "smoke test:雷暴来临前的原创避险选择。", "系列 Bible 更新必须写入数据库"); +assert.equal(bibleUpdate.bible.series.continuity_rule, "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。", "连续性规则必须写入数据库"); + +const graphAfterBibleUpdate = await request("/api/production/graph", { headers }); +assert.equal(graphAfterBibleUpdate.graph.series.logline, "smoke test:雷暴来临前的原创避险选择。", "生产图谱必须读回最新系列 Bible"); + +const usageBeforeJob = await request("/api/usage", { headers }); +const job = await request("/api/jobs", { + method: "POST", + headers, + body: JSON.stringify({ adapter: "local-tts", kind: "单句 TTS 试听", shotId: "shot-01", output: `voices/auditions/smoke-${runId}-line.wav` }) +}); +jobId = job.job?.id || ""; +assert.ok(["queued", "blocked"].includes(job.job?.status), "单句试听必须写入可追踪任务"); +if (job.job?.status === "blocked") assert.match(job.job.errorMessage || "", /连接器|planned|not-connected/i, "连接器未就绪时必须记录阻塞原因"); +assert.ok(job.jobs.some((item) => item.output === `voices/auditions/smoke-${runId}-line.wav`), "任务输出路径必须可追踪"); +const usageAfterJob = await request("/api/usage", { headers }); +const beforeClip = usageBeforeJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0; +const afterClip = usageAfterJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0; +assert.ok(afterClip > beforeClip, "生成任务写入后必须递增片段额度用量"); + + console.log("creator-suite smoke passed"); +} finally { + await cleanup(); +} diff --git a/scripts/smoke-delivery-portal.mjs b/scripts/smoke-delivery-portal.mjs new file mode 100644 index 0000000..7c50d42 --- /dev/null +++ b/scripts/smoke-delivery-portal.mjs @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { access, mkdir, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbGet, dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const projectRoot = resolve(import.meta.dirname, ".."); +const organizationId = "org-studio-lab"; +const workspaceId = "ws-local-aidrama"; +const projectId = "thunder-mouth"; +const runId = Date.now(); +const sourceRoot = `storage/smoke-delivery-portal-${runId}`; +const sourcePath = `${sourceRoot}/clip.mp4`; +const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`; +const manifestPath = `${sourceRoot}/manifest.json`; +const deliveryVersion = `portal-${runId}`; +const deliveryDraftVersion = `portal-draft-${runId}`; +const batchId = `smoke-portal-batch-${runId}`; +const deliveryLabel = `smoke-portal-delivery-${runId}`; +const draftDeliveryLabel = `smoke-portal-draft-${runId}`; +const auditTargetIds = new Set(); +const accessLinkIds = []; +const releaseIds = []; +const deliveryIds = []; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function requestBinary(path) { + const response = await fetch(`${api}${path}`); + return { response, body: Buffer.from(await response.arrayBuffer()), contentType: response.headers.get("content-type") || "" }; +} + +function expectStatus(result, status, label) { + assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload?.detail || result.payload?.error || ""}`); + return result.payload; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload?.detail || result.payload?.error || ""}`); + return result.payload; +} + +function isoFromNow(days) { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +const producerLogin = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) +}), "producer login"); +const reviewerLogin = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" }) +}), "reviewer login"); + +const producerHeaders = { + authorization: `Bearer ${producerLogin.session.token}`, + "x-organization-id": organizationId, + "x-workspace-id": workspaceId, + "x-project-id": projectId +}; +const reviewerHeaders = { + authorization: `Bearer ${reviewerLogin.session.token}`, + "x-organization-id": organizationId, + "x-workspace-id": workspaceId, + "x-project-id": projectId +}; +const crossOrganizationHeaders = { + authorization: `Bearer ${producerLogin.session.token}`, + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main", + "x-project-id": "northstar-pilot" +}; + +const projectShot = dbGet("SELECT id FROM shots WHERE episode_id IN (SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE sr.project_id = ?) ORDER BY shot_number LIMIT 1", [projectId]); +const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]); +assert.ok(projectShot?.id, "smoke project must have a starter shot"); +assert.ok(producerUser?.id, "producer user must exist"); + +let channelId = ""; +let publishedReleaseId = ""; +let primaryToken = ""; +let expiredToken = ""; +let revokedToken = ""; +let seeded = false; + +try { + const channels = expectOk(await request("/api/production/delivery-channels", { headers: reviewerHeaders }), "reviewer can view delivery channels"); + channelId = channels.channels.find((channel) => channel.kind === "local-file")?.id || ""; + assert.ok(channelId, "workspace must expose a local-file delivery channel"); + expectStatus(await request(`/api/production/releases/unknown/access-links`, { method: "POST", headers: reviewerHeaders, body: JSON.stringify({}) }), 403, "reviewer cannot create access link"); + + const draft = expectStatus(await request("/api/production/deliveries", { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ version: deliveryDraftVersion, channel: draftDeliveryLabel }) + }), 201, "producer creates unpublished delivery"); + deliveryIds.push(draft.delivery.id); + const draftRelease = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(draft.delivery.id)}/releases`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ channelId, submit: false }) + }), 201, "producer creates unpublished release draft"); + releaseIds.push(draftRelease.release.id); + + const delivery = expectStatus(await request("/api/production/deliveries", { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ version: deliveryVersion, channel: deliveryLabel }) + }), 201, "producer creates published delivery draft"); + deliveryIds.push(delivery.delivery.id); + + await mkdir(resolve(projectRoot, sourceRoot), { recursive: true }); + await writeFile(resolve(projectRoot, sourcePath), Buffer.from("fake local video evidence")); + await writeFile(resolve(projectRoot, lastFramePath), Buffer.from("fake actual last frame evidence")); + await writeFile(resolve(projectRoot, manifestPath), JSON.stringify({ schema: "smoke-delivery-portal-manifest", deliveryId: delivery.delivery.id }, null, 2)); + const timestamp = new Date().toISOString(); + dbRun("INSERT INTO delivery_batches(id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 'smoke portal batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, manifestPath, JSON.stringify({ blockers: [], manifestWritten: true }), producerUser.id, timestamp, timestamp]); + dbRun("INSERT INTO delivery_batch_items(id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, '{}', ?)", [`smoke-portal-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), timestamp]); + dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]); + seeded = true; + + const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ channelId, submit: true, idempotencyKey: `portal-release-${runId}` }) + }), 201, "producer submits portal release"); + publishedReleaseId = release.release.id; + releaseIds.push(publishedReleaseId); + const approved = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/decision`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ status: "approved", note: "portal smoke approval" }) + }), "producer approves portal release"); + assert.equal(approved.release.status, "approved", "release must be approved before publishing"); + const published = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/publish`, { method: "POST", headers: producerHeaders, body: "{}" }), "producer publishes portal release"); + assert.equal(published.release.status, "published", "release must be published"); + await access(resolve(projectRoot, published.release.output_path)); + + const unpublishedLinkAttempt = await request(`/api/production/releases/${encodeURIComponent(draftRelease.release.id)}/access-links`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ maxDownloads: 1 }) + }); + expectStatus(unpublishedLinkAttempt, 409, "unpublished release cannot be shared"); + + const reviewerList = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { headers: reviewerHeaders }), "reviewer can list access links"); + assert.deepEqual(reviewerList.accessLinks, [], "new release must have no access links"); + expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { + method: "POST", + headers: reviewerHeaders, + body: JSON.stringify({ maxDownloads: 1 }) + }), 403, "reviewer cannot create access link"); + + const link = expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ recipientName: "星河发行部", recipientEmail: "release@example.test", expiresAt: isoFromNow(2), maxDownloads: 2 }) + }), 201, "producer creates primary access link"); + primaryToken = link.token; + accessLinkIds.push(link.link.id); + auditTargetIds.add(link.link.id); + assert.ok(primaryToken, "create response must return plaintext token once"); + assert.match(link.portalUrl, /\/portal\//, "create response must return portal URL"); + const storedLink = dbGet("SELECT token_hash, token_hint FROM delivery_access_links WHERE id = ?", [link.link.id]); + assert.notEqual(storedLink.token_hash, primaryToken, "database must not store plaintext token"); + assert.equal(storedLink.token_hash, createHash("sha256").update(primaryToken).digest("hex"), "stored token hash must match SHA-256"); + assert.ok(storedLink.token_hint && !storedLink.token_hint.includes(primaryToken), "stored token hint must not contain the full token"); + + const publicPortal = expectOk(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}`, { headers: { "user-agent": "portal-smoke" } }), "public portal metadata works"); + assert.equal(publicPortal.delivery.version, deliveryVersion, "portal must identify the published delivery version"); + assert.equal(publicPortal.portal.downloadsRemaining, 2, "portal must expose the initial download budget"); + assert.equal(publicPortal.review.status, "pending", "portal must start in a pending client review state"); + assert.ok(!JSON.stringify(publicPortal).includes("storage/"), "public metadata must not expose internal storage paths"); + const changesRequested = expectOk(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}/feedback`, { + method: "POST", + headers: { "user-agent": "portal-feedback-smoke" }, + body: JSON.stringify({ decision: "changes_requested", reviewerName: "星河发行部", reviewerEmail: "release@example.test", message: "请复核第 02 镜头的字幕安全区。" }) + }), "client can request delivery changes"); + assert.equal(changesRequested.review.status, "changes_requested", "portal must expose the latest client change request"); + assert.equal(changesRequested.review.count, 1, "portal must count client feedback submissions"); + const approvedFeedback = expectOk(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}/feedback`, { + method: "POST", + headers: { "user-agent": "portal-feedback-smoke" }, + body: JSON.stringify({ decision: "approved", reviewerName: "星河发行部", reviewerEmail: "release@example.test", message: "已确认当前交付版本。" }) + }), "client can accept delivery"); + assert.equal(approvedFeedback.review.status, "approved", "portal must expose the latest client acceptance"); + assert.equal(approvedFeedback.review.count, 2, "portal must retain the append-only feedback count"); + expectStatus(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}/feedback`, { + method: "POST", + headers: { "user-agent": "portal-feedback-smoke" }, + body: JSON.stringify({ decision: "changes_requested", message: "" }) + }), 400, "change request without a message must be rejected"); + const internalFeedback = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-feedback`, { headers: reviewerHeaders }), "internal users can inspect client feedback"); + assert.equal(internalFeedback.feedback.length, 2, "internal feedback ledger must expose both client decisions"); + assert.equal(internalFeedback.feedback[0].decision, "approved", "internal feedback must be newest first"); + const releaseFile = await requestBinary(`/api/public/delivery/${encodeURIComponent(primaryToken)}/file?kind=release`); + assert.equal(releaseFile.response.status, 200, "public release file must download"); + assert.match(releaseFile.contentType, /application\/json/, "release file must be JSON"); + assert.match(releaseFile.body.toString("utf8"), /ai-drama-platform\.delivery-release\.v1/, "release JSON must be delivered"); + const manifestFile = await requestBinary(`/api/public/delivery/${encodeURIComponent(primaryToken)}/file?kind=manifest`); + assert.equal(manifestFile.response.status, 200, "public manifest file must download"); + assert.match(manifestFile.body.toString("utf8"), /smoke-delivery-portal-manifest/, "manifest JSON must be delivered"); + const limitedDownload = await requestBinary(`/api/public/delivery/${encodeURIComponent(primaryToken)}/file?kind=release`); + assert.equal(limitedDownload.response.status, 429, "download budget must be enforced"); + + const expired = expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ recipientName: "过期链接", expiresAt: isoFromNow(2), maxDownloads: 1 }) + }), 201, "producer creates expiring access link"); + expiredToken = expired.token; + accessLinkIds.push(expired.link.id); + auditTargetIds.add(expired.link.id); + dbRun("UPDATE delivery_access_links SET expires_at = ?, status = 'active' WHERE id = ?", ["2000-01-01T00:00:00.000Z", expired.link.id]); + expectStatus(await request(`/api/public/delivery/${encodeURIComponent(expiredToken)}`, { headers: { "user-agent": "portal-smoke" } }), 410, "expired link must return 410"); + + const revoked = expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ recipientName: "撤销链接", expiresAt: isoFromNow(2), maxDownloads: 1 }) + }), 201, "producer creates revocable access link"); + revokedToken = revoked.token; + accessLinkIds.push(revoked.link.id); + auditTargetIds.add(revoked.link.id); + expectStatus(await request(`/api/production/access-links/${encodeURIComponent(revoked.link.id)}/revoke`, { method: "POST", headers: reviewerHeaders, body: "{}" }), 403, "reviewer cannot revoke access link"); + expectOk(await request(`/api/production/access-links/${encodeURIComponent(revoked.link.id)}/revoke`, { method: "POST", headers: producerHeaders, body: "{}" }), "producer revokes access link"); + expectStatus(await request(`/api/public/delivery/${encodeURIComponent(revokedToken)}`, { headers: { "user-agent": "portal-smoke" } }), 404, "revoked link must return 404"); + + const crossOrganization = await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { headers: crossOrganizationHeaders }); + assert.ok([403, 404].includes(crossOrganization.response.status), `cross organization access links must be hidden or denied: ${crossOrganization.response.status}`); + + const eventCount = dbGet("SELECT COUNT(*) AS count FROM delivery_access_events WHERE link_id IN (?, ?, ?)", accessLinkIds); + assert.ok(Number(eventCount?.count || 0) >= 6, "portal view/download/rejection activity must be audited"); + console.log(`delivery portal smoke passed: ${publishedReleaseId} / ${accessLinkIds.length} links`); +} finally { + for (const targetId of auditTargetIds) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [targetId]); + for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_events WHERE link_id = ?", [linkId]); + for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_links WHERE id = ?", [linkId]); + for (const releaseId of releaseIds) { + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]); + dbRun("DELETE FROM delivery_releases WHERE id = ?", [releaseId]); + } + if (seeded) { + dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]); + dbRun("DELETE FROM delivery_batches WHERE id = ?", [batchId]); + } + for (const deliveryId of deliveryIds) { + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [deliveryId]); + dbRun("DELETE FROM deliveries WHERE id = ?", [deliveryId]); + } + await rm(resolve(projectRoot, sourceRoot), { recursive: true, force: true }); +} diff --git a/scripts/smoke-device-risk.mjs b/scripts/smoke-device-risk.mjs new file mode 100644 index 0000000..4c06d7c --- /dev/null +++ b/scripts/smoke-device-risk.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { dbGet, dbRun, withTransaction } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email, deviceId, deviceLabel) { + const result = await request("/api/auth/login", { + method: "POST", + headers: { "x-device-id": deviceId, "x-device-label": deviceLabel }, + body: JSON.stringify({ email, password: "Demo@123456" }) + }); + assert.equal(result.response.status, 200, `${email} 登录失败`); + assert.ok(result.payload.session?.token, `${email} 未返回登录会话`); + return { + authorization: `Bearer ${result.payload.session.token}`, + session: result.payload.session + }; +} + +const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; +const ownerDeviceId = `smoke-owner-device-${suffix}`; +const secondDeviceId = `smoke-owner-device-2-${suffix}`; +const ownerLabel = "Device Risk Smoke Browser"; +const owner = await login("producer@local.test", ownerDeviceId, ownerLabel); +let clientId = ""; +let clientKey = ""; + +try { + assert.equal(owner.session.riskLevel, "high", "首次出现的设备应该标记为高风险"); + assert.ok(owner.session.riskScore >= 75, "首次设备风险分数不足"); + assert.ok(!JSON.stringify(owner.session).includes(ownerDeviceId), "登录响应不能返回原始设备 ID"); + + const firstDevices = await request("/api/auth/devices", { headers: owner }); + assert.equal(firstDevices.response.status, 200, "登录用户无法读取自己的设备列表"); + assert.ok(Array.isArray(firstDevices.payload.devices), "设备接口必须返回 devices 数组"); + const firstDevice = firstDevices.payload.devices.find((device) => device.label === ownerLabel); + assert.ok(firstDevice, "首次登录没有登记设备"); + assert.equal(firstDevice.status, "known", "首次设备默认不能直接标记为信任"); + assert.ok(!JSON.stringify(firstDevice).includes(ownerDeviceId), "设备接口不能返回原始设备 ID"); + assert.ok(!Object.keys(firstDevice).some((key) => /(hash|token|secret|key)/i.test(key)), "设备接口不能返回设备摘要或令牌字段"); + const storedDevice = dbGet("SELECT device_key_hash, fingerprint_hash FROM auth_devices WHERE id = ?", [firstDevice.id]); + assert.ok(storedDevice && /^[a-f0-9]{64}$/i.test(storedDevice.device_key_hash), "数据库必须保存设备 ID 的摘要"); + assert.notEqual(storedDevice.device_key_hash, ownerDeviceId, "数据库不能保存原始设备 ID"); + + const secondLogin = await login("producer@local.test", ownerDeviceId, ownerLabel); + assert.ok(secondLogin.session.riskScore < owner.session.riskScore, "同设备再次登录的风险分数应该下降"); + assert.equal(secondLogin.session.riskLevel, "medium", "已知但未信任的设备应该是中风险"); + + const trusted = await request(`/api/auth/devices/${encodeURIComponent(firstDevice.id)}/trust`, { method: "POST", headers: owner, body: "{}" }); + assert.equal(trusted.response.status, 200, "信任设备失败"); + assert.equal(trusted.payload.device.status, "trusted", "设备信任状态没有落库"); + assert.ok(trusted.payload.devices.some((device) => device.id === firstDevice.id && device.status === "trusted"), "设备列表没有返回信任状态"); + + const trustedLogin = await login("producer@local.test", ownerDeviceId, ownerLabel); + assert.equal(trustedLogin.session.riskLevel, "low", "信任设备再次登录应该是低风险"); + assert.ok(trustedLogin.session.riskScore < secondLogin.session.riskScore, "信任设备风险分数应该继续下降"); + + const untrusted = await request(`/api/auth/devices/${encodeURIComponent(firstDevice.id)}/untrust`, { method: "POST", headers: owner, body: "{}" }); + assert.equal(untrusted.response.status, 200, "取消设备信任失败"); + assert.equal(untrusted.payload.device.status, "known", "取消信任后设备状态不正确"); + + const secondDeviceLogin = await login("producer@local.test", secondDeviceId, "Second Smoke Browser"); + assert.equal(secondDeviceLogin.session.riskLevel, "high", "新设备应该重新标记为高风险"); + + const ownerEvents = await request("/api/auth/security-events", { headers: owner }); + assert.equal(ownerEvents.response.status, 200, "无法读取设备安全事件"); + const eventTypes = new Set(ownerEvents.payload.events.map((event) => event.eventType)); + assert.ok(eventTypes.has("device.first_seen"), "安全事件缺少 device.first_seen"); + assert.ok(eventTypes.has("device.trusted"), "安全事件缺少 device.trusted"); + assert.ok(eventTypes.has("device.untrusted"), "安全事件缺少 device.untrusted"); + assert.ok(!JSON.stringify(ownerEvents.payload.events).includes(ownerDeviceId), "安全事件不能记录原始设备 ID"); + + const systemDetail = await request("/api/system/users/u-owner", { headers: owner }); + assert.equal(systemDetail.response.status, 200, "系统管理员无法读取自己的设备风险详情"); + assert.ok(Array.isArray(systemDetail.payload.devices), "系统用户详情缺少设备风险列表"); + assert.ok(systemDetail.payload.devices.some((device) => device.latestRiskLevel === "high"), "系统用户详情没有显示高风险设备"); + assert.ok(systemDetail.payload.sessions.some((session) => session.riskLevel && session.device), "系统用户详情会话缺少设备风险字段"); + assert.ok(!JSON.stringify(systemDetail.payload).includes(ownerDeviceId), "系统用户详情不能返回原始设备 ID"); + + const deniedWriter = await login("writer@local.test", `smoke-writer-device-${suffix}`, "Writer Smoke Browser"); + const ordinarySystemDenied = await request("/api/system/users/u-owner", { headers: deniedWriter }); + assert.equal(ordinarySystemDenied.response.status, 403, "普通用户不能访问系统用户设备详情"); + + const createdClient = await request("/api/system/api-clients", { + method: "POST", + headers: { + authorization: owner.authorization, + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama" + }, + body: JSON.stringify({ name: `Device Risk Smoke ${suffix}`, scopes: ["jobs:read"] }) + }); + assert.equal(createdClient.response.status, 201, "创建设备风险 smoke API Client 失败"); + clientId = createdClient.payload.client.id; + clientKey = createdClient.payload.clientKey; + assert.ok(clientKey, "设备风险 smoke 没有拿到 API Client 密钥"); + + const apiClientDenied = await request("/api/auth/devices", { headers: { authorization: `Bearer ${clientKey}` } }); + assert.equal(apiClientDenied.response.status, 403, "API Client 不能管理浏览器设备"); + assert.equal(apiClientDenied.payload.error, "device_management_unavailable", "设备管理 API Client 错误码不稳定"); + + console.log(`device risk smoke passed: registration, trust boundary, risk scoring, and sensitive-field checks (${firstDevice.id})`); +} finally { + if (clientId) { + withTransaction(() => { + dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]); + }); + } +} diff --git a/scripts/smoke-identity.mjs b/scripts/smoke-identity.mjs new file mode 100644 index 0000000..17b95ce --- /dev/null +++ b/scripts/smoke-identity.mjs @@ -0,0 +1,161 @@ +import { createHmac } from "node:crypto"; +import { dbRun } from "../server/db.mjs"; + +const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; + +async function request(path, options = {}) { + const response = await fetch(`${base}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function totp(secret, timestamp = Date.now()) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const normalized = String(secret).replace(/=+$/g, "").toUpperCase(); + let buffer = 0; + let bits = 0; + const bytes = []; + for (const character of normalized) { + buffer = (buffer << 5) | alphabet.indexOf(character); + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + } + } + const counter = Math.floor(timestamp / 30000); + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac("sha1", Buffer.from(bytes)).update(counterBuffer).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); + return String(value % 1000000).padStart(6, "0"); +} + +const ownerLogin = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) +}); +assert(ownerLogin.response.ok && ownerLogin.payload.session?.token, "identity smoke owner login failed"); +const ownerHeaders = { authorization: `Bearer ${ownerLogin.payload.session.token}` }; + +const writerLogin = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" }) +}); +assert(writerLogin.response.ok && writerLogin.payload.session?.token, "identity smoke writer login failed"); +const writerHeaders = { authorization: `Bearer ${writerLogin.payload.session.token}` }; + +const writerDenied = await request("/api/system/identity", { headers: writerHeaders }); +assert(writerDenied.response.status === 403, "普通成员读取身份中心必须被后端拒绝"); + +const initial = await request("/api/system/identity", { headers: ownerHeaders }); +assert(initial.response.ok && initial.payload.policy && Array.isArray(initial.payload.providers), "管理员读取身份中心失败"); + +const provider = await request("/api/system/identity/providers", { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ + name: `Smoke OIDC ${Date.now()}`, + kind: "oidc", + issuerUrl: "http://127.0.0.1:8799/issuer", + clientId: "smoke-client", + clientSecretRef: "SMOKE_OIDC_CLIENT_SECRET", + scopes: ["openid", "profile", "email"], + enabled: true + }) +}); +assert(provider.response.status === 201 && provider.payload.providers.some((item) => item.enabled), "OIDC 提供商登记失败"); +const smokeProvider = provider.payload.providers.at(-1); + +const policy = await request("/api/system/identity/policy", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ ssoEnabled: true, localLoginFallback: true, mfaRequiredForAdmins: true }) +}); +assert(policy.response.ok && policy.payload.policy.ssoEnabled && policy.payload.policy.mfaRequiredForAdmins, "身份策略保存失败"); + +const enrollmentLogin = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) +}); +assert(enrollmentLogin.response.ok && enrollmentLogin.payload.mfaEnrollmentRequired && enrollmentLogin.payload.enrollmentToken, "强制管理员 MFA 必须进入首次绑定流程"); +const enrollmentSetup = await request("/api/auth/mfa/enroll/setup", { + method: "POST", + body: JSON.stringify({ enrollmentToken: enrollmentLogin.payload.enrollmentToken }) +}); +assert(enrollmentSetup.response.status === 201 && enrollmentSetup.payload.setup?.secret, "MFA enrollment setup failed"); +const enrollmentComplete = await request("/api/auth/mfa/enroll/enable", { + method: "POST", + body: JSON.stringify({ enrollmentToken: enrollmentLogin.payload.enrollmentToken, methodId: enrollmentSetup.payload.setup.methodId, code: totp(enrollmentSetup.payload.setup.secret) }) +}); +assert(enrollmentComplete.response.ok && enrollmentComplete.payload.session?.token, "MFA enrollment completion failed"); + +const publicProviders = await request("/api/auth/sso/providers"); +assert(publicProviders.response.ok && publicProviders.payload.providers.some((item) => item.id === smokeProvider.id), "公开 SSO 提供商目录读取失败"); + +const directory = await request("/api/system/identity/directory-syncs", { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ name: `Smoke SCIM ${Date.now()}`, syncMode: "provision-and-deprovision", schedule: "manual" }) +}); +assert(directory.response.status === 201 && directory.payload.token && directory.payload.directorySync?.endpointPath, "SCIM 目录创建失败"); +const directoryId = directory.payload.directorySync.id; +const enabledDirectory = await request(`/api/system/identity/directory-syncs/${encodeURIComponent(directoryId)}`, { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ enabled: true }) +}); +assert(enabledDirectory.response.ok && enabledDirectory.payload.directorySyncs.some((item) => item.id === directoryId && item.enabled), "SCIM 目录启用失败"); + +const scimHeaders = { authorization: `Bearer ${directory.payload.token}` }; +const scimList = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { headers: scimHeaders }); +assert(scimList.response.ok && Array.isArray(scimList.payload.Resources), "SCIM 用户列表读取失败"); +const scimEmail = `scim-${Date.now()}@local.test`; +const scimCreate = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { + method: "POST", + headers: scimHeaders, + body: JSON.stringify({ userName: scimEmail, displayName: "SCIM 测试成员", active: true }) +}); +assert(scimCreate.response.status === 201 && scimCreate.payload.userName === scimEmail, "SCIM 用户创建失败"); +const scimUserId = scimCreate.payload.id; +const scimPatch = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users/${encodeURIComponent(scimUserId)}`, { + method: "PATCH", + headers: scimHeaders, + body: JSON.stringify({ Operations: [{ op: "Replace", path: "active", value: false }] }) +}); +assert(scimPatch.response.ok && scimPatch.payload.active === false, "SCIM 用户停用失败"); +const invalidScim = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { headers: { authorization: "Bearer invalid-token" } }); +assert(invalidScim.response.status === 401, "无效 SCIM 令牌必须被拒绝"); + +const cleanupMfa = await request("/api/auth/mfa/disable", { + method: "POST", + headers: { authorization: `Bearer ${enrollmentComplete.payload.session.token}` }, + body: JSON.stringify({ currentPassword: "Demo@123456", code: totp(enrollmentSetup.payload.setup.secret) }) +}); +assert(cleanupMfa.response.ok && cleanupMfa.payload.enabled === false, "identity smoke cleanup MFA failed"); + +await request(`/api/system/identity/providers/${encodeURIComponent(smokeProvider.id)}`, { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ enabled: false }) +}); +await request("/api/system/identity/policy", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ ssoEnabled: false, mfaRequiredForAdmins: false }) +}); + +dbRun("DELETE FROM directory_sync_tokens WHERE directory_sync_id IN (SELECT id FROM directory_syncs WHERE name LIKE 'Smoke SCIM %')"); +dbRun("DELETE FROM directory_syncs WHERE name LIKE 'Smoke SCIM %'"); +dbRun("DELETE FROM identity_providers WHERE name LIKE 'Smoke OIDC %'"); +dbRun("DELETE FROM users WHERE email = ?", [scimEmail]); + +console.log(`identity smoke passed: ${base}`); diff --git a/scripts/smoke-invitations.mjs b/scripts/smoke-invitations.mjs new file mode 100644 index 0000000..1939688 --- /dev/null +++ b/scripts/smoke-invitations.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; + +const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, options = {}) { + const response = await fetch(`${base}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function assertOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +async function login(email) { + const result = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password: "Demo@123456" }) + }); + assertOk(result, `${email} login`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const ownerHeaders = { ...(await login("producer@local.test")), ...scope }; +const writerHeaders = { ...(await login("writer@local.test")), ...scope }; +const inviteEmail = `invitation-smoke-${Date.now()}@local.test`; +let invitationId = ""; +let invitationState = ""; + +try { + const writerAttempt = await request("/api/organizations/org-studio-lab/invitations", { + method: "POST", + headers: writerHeaders, + body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" }) + }); + assert.equal(writerAttempt.response.status, 403, "普通编剧不应创建组织邀请"); + assert.equal(writerAttempt.payload.error, "permission_denied", "普通编剧越权邀请需要稳定错误码"); + + const before = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取邀请前商业席位"); + const created = assertOk(await request("/api/organizations/org-studio-lab/invitations", { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" }) + }), "管理员创建邀请"); + const firstInvitation = created.invitation; + invitationId = firstInvitation.id; + invitationState = firstInvitation.status; + assert.ok(firstInvitation.inviteToken, "创建邀请必须返回一次性令牌"); + assert.ok(firstInvitation.acceptUrl?.startsWith("/register?invite="), "创建邀请必须返回注册链接"); + + const afterCreate = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取创建后商业席位"); + assert.equal(Number(afterCreate.seat.pending), Number(before.seat.pending) + 1, "创建邀请后必须预留一个待入组席位"); + + const firstPreview = assertOk(await request(`/api/invitations/preview?token=${encodeURIComponent(firstInvitation.inviteToken)}`), "预览首次注册链接"); + assert.equal(firstPreview.invitation.email, inviteEmail, "首次注册链接邮箱不匹配"); + + const duplicate = await request("/api/organizations/org-studio-lab/invitations", { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" }) + }); + assert.equal(duplicate.response.status, 409, "重复邀请必须被阻止"); + assert.equal(duplicate.payload.error, "invitation_already_pending", "重复邀请错误码不稳定"); + + const resent = assertOk(await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(invitationId)}/resend`, { + method: "POST", + headers: ownerHeaders, + body: "{}" + }), "管理员重发邀请"); + const secondInvitation = resent.invitation; + invitationState = secondInvitation.status; + assert.ok(secondInvitation.inviteToken, "重发邀请必须返回新令牌"); + assert.notEqual(secondInvitation.inviteToken, firstInvitation.inviteToken, "重发不能复用旧令牌"); + + const oldPreview = await request(`/api/invitations/preview?token=${encodeURIComponent(firstInvitation.inviteToken)}`); + assert.equal(oldPreview.response.status, 404, "重发后旧注册链接必须失效"); + const secondPreview = assertOk(await request(`/api/invitations/preview?token=${encodeURIComponent(secondInvitation.inviteToken)}`), "预览新注册链接"); + assert.equal(secondPreview.invitation.email, inviteEmail, "新注册链接邮箱不匹配"); + + const revoked = assertOk(await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(invitationId)}/revoke`, { + method: "POST", + headers: ownerHeaders, + body: "{}" + }), "管理员撤销邀请"); + invitationState = revoked.invitation.status; + assert.equal(invitationState, "revoked", "撤销后邀请状态必须为 revoked"); + + const revokedPreview = await request(`/api/invitations/preview?token=${encodeURIComponent(secondInvitation.inviteToken)}`); + assert.equal(revokedPreview.response.status, 404, "撤销后注册链接必须失效"); + const afterRevoke = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取撤销后商业席位"); + assert.equal(Number(afterRevoke.seat.pending), Number(before.seat.pending), "撤销邀请后必须释放待入组席位"); + + const listed = assertOk(await request("/api/organizations/org-studio-lab", { headers: ownerHeaders }), "读取组织邀请列表"); + assert.ok(!listed.invitations.some((item) => item.id === invitationId), "撤销邀请不能继续出现在待处理列表"); +} finally { + if (invitationId && invitationState === "pending") { + await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(invitationId)}/revoke`, { + method: "POST", + headers: ownerHeaders, + body: "{}" + }); + } +} + +console.log(`invitations smoke passed: ${base}`); diff --git a/scripts/smoke-media-evidence.mjs b/scripts/smoke-media-evidence.mjs new file mode 100644 index 0000000..b4ea2ee --- /dev/null +++ b/scripts/smoke-media-evidence.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, rm, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbGet, dbRun, withTransaction } from "../server/db.mjs"; +import { registerJobArtifacts } from "../server/media-artifacts.mjs"; + +const projectRoot = resolve(import.meta.dirname, ".."); +const orgId = "org-studio-lab"; +const workspaceId = "ws-local-aidrama"; +const projectId = "thunder-mouth"; +const shotId = "shot-01"; +const shot = dbGet("SELECT * FROM shots WHERE id = ?", [shotId]); +assert.ok(shot, "smoke shot must exist"); +const nextShot = dbGet("SELECT id, first_frame_path FROM shots WHERE episode_id = ? AND shot_number = ?", [shot.episode_id, Number(shot.shot_number) + 1]); +const originalLastFrame = shot.last_frame_path; +const originalNextFirstFrame = nextShot?.first_frame_path || ""; +const jobId = `smoke-media-${Date.now()}`; +const relative = `storage/jobs/${jobId}/output.mp4`; +const absolute = resolve(projectRoot, relative); +const context = { + organization: { id: orgId }, + workspace: { id: workspaceId }, + project: { id: projectId }, + user: { id: "u-owner" } +}; + +try { + await mkdir(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true }); + execFileSync("ffmpeg", ["-y", "-v", "error", "-f", "lavfi", "-i", "color=c=0x1f5f8b:s=320x568:d=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", absolute], { stdio: "pipe" }); + const timestamp = new Date().toISOString(); + dbRun("INSERT INTO generation_jobs(id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, status, output_path, qa_status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, '视频片段', 'owned-i2v', 'completed', ?, 'wait', 'u-owner', ?, ?)", [jobId, orgId, workspaceId, projectId, shot.episode_id, shotId, relative, timestamp, timestamp]); + const artifacts = await registerJobArtifacts(context, { id: jobId, episode_id: shot.episode_id, shot_id: shotId, kind: "视频片段", output_path: relative, created_by: "u-owner" }, { outputPath: relative }); + const artifact = artifacts.find((item) => item.kind === "video"); + assert.equal(artifact?.status, "inspected", "视频文件必须通过 FFprobe 检查"); + assert.equal((artifact.sha256 || "").length, 64, "视频证据必须登记 SHA-256"); + assert.ok(artifact.last_frame_path, "视频证据必须提取实际末帧路径"); + await stat(resolve(projectRoot, artifact.last_frame_path)); + assert.equal(dbGet("SELECT last_frame_path FROM shots WHERE id = ?", [shotId]).last_frame_path, artifact.last_frame_path, "镜头必须继承实际末帧"); + if (nextShot) assert.equal(dbGet("SELECT first_frame_path FROM shots WHERE id = ?", [nextShot.id]).first_frame_path, artifact.last_frame_path, "下一镜头必须继承上一镜头实际末帧"); + console.log(`media evidence smoke passed: ${artifact.path} -> ${artifact.last_frame_path}`); +} finally { + withTransaction(() => { + dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]); + dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]); + dbRun("UPDATE shots SET last_frame_path = ?, updated_at = ? WHERE id = ?", [originalLastFrame, new Date().toISOString(), shotId]); + if (nextShot) dbRun("UPDATE shots SET first_frame_path = ?, updated_at = ? WHERE id = ?", [originalNextFirstFrame, new Date().toISOString(), nextShot.id]); + }); + await rm(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true, force: true }); +} diff --git a/scripts/smoke-mfa.mjs b/scripts/smoke-mfa.mjs new file mode 100644 index 0000000..fafdf16 --- /dev/null +++ b/scripts/smoke-mfa.mjs @@ -0,0 +1,140 @@ +import { createHmac } from "node:crypto"; +import { dbAll, dbRun, withTransaction } from "../server/db.mjs"; + +const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scopeHeaders = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +const runId = Date.now(); +const email = `mfa-${runId}@local.test`; + +function cleanup() { + const smokeUsers = dbAll("SELECT id FROM users WHERE email = ?", [email]); + withTransaction(() => { + for (const user of smokeUsers) { + dbRun("DELETE FROM invitations WHERE email = ? OR accepted_user_id = ?", [email, user.id]); + dbRun("UPDATE audit_logs SET actor_user_id = NULL WHERE actor_user_id = ?", [user.id]); + dbRun("UPDATE usage_events SET user_id = NULL WHERE user_id = ?", [user.id]); + dbRun("DELETE FROM users WHERE id = ?", [user.id]); + } + dbRun("DELETE FROM invitations WHERE email = ?", [email]); + }); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function request(path, headers = {}, init = {}) { + const response = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function decodeBase32(input) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const normalized = String(input || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, ""); + let bits = 0; + let value = 0; + const bytes = []; + for (const character of normalized) { + value = (value << 5) | alphabet.indexOf(character); + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes.push((value >> bits) & 0xff); + } + } + return Buffer.from(bytes); +} + +function totp(secret, timestamp = Date.now()) { + const counter = Math.floor(timestamp / 30000); + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); + return String(value % 1000000).padStart(6, "0"); +} + +try { + const ownerLogin = await request("/api/auth/login", {}, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) + }); + assert(ownerLogin.response.ok, "系统管理员登录失败"); + const ownerHeaders = { authorization: `Bearer ${ownerLogin.payload.session.token}`, ...scopeHeaders }; + const invitation = await request("/api/organizations/org-studio-lab/invitations", ownerHeaders, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, roleKey: "writer", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" }) + }); + assert(invitation.response.status === 201 && invitation.payload.invitation.inviteToken, "MFA 测试用户邀请创建失败"); + + const registration = await request("/api/auth/register", {}, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ inviteToken: invitation.payload.invitation.inviteToken, displayName: "MFA 测试用户", password: "MfaSmoke@123456" }) + }); + assert(registration.response.status === 201, "MFA 测试用户注册失败"); + const userHeaders = { authorization: `Bearer ${registration.payload.session.token}`, ...scopeHeaders }; + + const initialStatus = await request("/api/auth/mfa", userHeaders); + assert(initialStatus.response.ok && initialStatus.payload.enabled === false, "新用户 MFA 初始状态必须是关闭"); + const firstSetup = await request("/api/auth/mfa/setup", userHeaders, { method: "POST", body: "{}", headers: { "content-type": "application/json" } }); + assert(firstSetup.response.status === 201 && firstSetup.payload.setup.methodId, "MFA 取消测试初始化失败"); + const cancelledSetup = await request("/api/auth/mfa/setup/cancel", userHeaders, { + method: "POST", + body: JSON.stringify({ methodId: firstSetup.payload.setup.methodId }), + headers: { "content-type": "application/json" } + }); + assert(cancelledSetup.response.ok && cancelledSetup.payload.enabled === false && cancelledSetup.payload.method === null, "取消 MFA 初始化必须清理未启用密钥"); + const setup = await request("/api/auth/mfa/setup", userHeaders, { method: "POST", body: "{}", headers: { "content-type": "application/json" } }); + assert(setup.response.status === 201 && setup.payload.setup.secret && setup.payload.setup.methodId, "MFA 初始化必须返回一次性密钥和方法 ID"); + const secret = setup.payload.setup.secret; + const code = totp(secret); + const enabled = await request("/api/auth/mfa/enable", userHeaders, { + method: "POST", + body: JSON.stringify({ methodId: setup.payload.setup.methodId, code }), + headers: { "content-type": "application/json" } + }); + assert(enabled.response.ok && enabled.payload.enabled === true, "MFA 启用失败"); + + const passwordLogin = await request("/api/auth/login", {}, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: "MfaSmoke@123456" }) + }); + assert(passwordLogin.response.ok && passwordLogin.payload.mfaRequired === true && passwordLogin.payload.challengeToken, "启用 MFA 后密码登录必须进入二次验证挑战"); + const wrongCode = await request("/api/auth/login/mfa", {}, { + method: "POST", + body: JSON.stringify({ challengeToken: passwordLogin.payload.challengeToken, code: "000000" }), + headers: { "content-type": "application/json" } + }); + assert(wrongCode.response.status === 401, "错误 MFA 验证码必须被拒绝"); + const completedLogin = await request("/api/auth/login/mfa", {}, { + method: "POST", + body: JSON.stringify({ challengeToken: passwordLogin.payload.challengeToken, code: totp(secret) }), + headers: { "content-type": "application/json" } + }); + assert(completedLogin.response.ok && completedLogin.payload.session?.token && completedLogin.payload.context?.currentUser?.email === email, "正确 MFA 验证码必须建立登录会话"); + + const activeHeaders = { authorization: `Bearer ${completedLogin.payload.session.token}`, ...scopeHeaders }; + const activeStatus = await request("/api/auth/mfa", activeHeaders); + assert(activeStatus.response.ok && activeStatus.payload.enabled === true, "已登录用户应能读取 MFA 状态"); + const disabled = await request("/api/auth/mfa/disable", activeHeaders, { + method: "POST", + body: JSON.stringify({ currentPassword: "MfaSmoke@123456", code: totp(secret) }), + headers: { "content-type": "application/json" } + }); + assert(disabled.response.ok && disabled.payload.enabled === false, "MFA 关闭失败"); + + console.log(`mfa smoke passed: ${base}`); +} finally { + cleanup(); +} diff --git a/scripts/smoke-model-connectors.mjs b/scripts/smoke-model-connectors.mjs new file mode 100644 index 0000000..0e6d2ca --- /dev/null +++ b/scripts/smoke-model-connectors.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { withTransaction, dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const runnerPort = Number(process.env.AI_DRAMA_SMOKE_MODEL_PORT || 8792); +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, headers = {}, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email) { + const result = await request("/api/auth/login", {}, { + method: "POST", + body: JSON.stringify({ email, password: "Demo@123456" }) + }); + assert.equal(result.response.ok, true, `${email} 登录失败`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const runner = createServer((req, res) => { + if (req.url === "/v1/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not_found" })); +}); + +await new Promise((resolve) => runner.listen(runnerPort, "127.0.0.1", resolve)); +let modelId = ""; +try { + const owner = { ...(await login("producer@local.test")), ...scope }; + const writer = { ...(await login("writer@local.test")), ...scope }; + + const writerModels = await request("/api/platform/models", writer); + assert.equal(writerModels.response.status, 403, "普通用户不应访问模型中台"); + + const publicLocal = await request("/api/platform/models/register", owner, { + method: "POST", + body: JSON.stringify({ label: "smoke invalid local", endpoint: "https://example.com/v1", kind: "http-json", capability: ["image"], costMode: "local" }) + }); + assert.equal(publicLocal.response.status, 403, "local 连接器指向公网时必须被拒绝"); + assert.equal(publicLocal.payload.error, "local_only_endpoint_required", "local 公网地址错误码不正确"); + + const externalWithoutApproval = await request("/api/platform/models/register", owner, { + method: "POST", + body: JSON.stringify({ label: "smoke invalid external", endpoint: "https://example.com/v1", kind: "openai-compatible", capability: ["image"], costMode: "mixed", approvalRequired: false }) + }); + assert.equal(externalWithoutApproval.response.status, 400, "混合连接器未开启审批时必须被拒绝"); + assert.equal(externalWithoutApproval.payload.error, "external_connector_approval_required", "外部审批错误码不正确"); + + const created = await request("/api/platform/models/register", owner, { + method: "POST", + body: JSON.stringify({ + label: `Smoke Editable Runner ${Date.now()}`, + endpoint: `http://127.0.0.1:${runnerPort}/v1`, + kind: "http-json", + capability: ["text-to-image", "single-frame"], + costMode: "local", + protocol: { healthRoute: "health" } + }) + }); + assert.equal(created.response.status, 201, "本地自定义 Runner 注册失败"); + modelId = created.payload.model.id; + assert.equal(created.payload.model.approvalRequired, false, "本地连接器不应被强制标记为外部审批"); + + const edited = await request(`/api/platform/models/${encodeURIComponent(modelId)}`, owner, { + method: "PATCH", + body: JSON.stringify({ label: "Smoke Edited Local Runner", capability: ["text-to-image", "single-frame", "continuity-lock"], protocol: { healthRoute: "health" } }) + }); + assert.equal(edited.response.ok, true, "本地连接器编辑失败"); + assert.equal(edited.payload.model.label, "Smoke Edited Local Runner", "连接器名称编辑未保存"); + assert.ok(edited.payload.model.capability.includes("continuity-lock"), "连接器能力标签编辑未保存"); + + const invalidEditEndpoint = await request(`/api/platform/models/${encodeURIComponent(modelId)}`, owner, { + method: "PATCH", + body: JSON.stringify({ endpoint: "https://example.com/v1", costMode: "local" }) + }); + assert.equal(invalidEditEndpoint.response.status, 403, "编辑时 local 公网地址必须被拒绝"); + + const invalidEditApproval = await request(`/api/platform/models/${encodeURIComponent(modelId)}`, owner, { + method: "PATCH", + body: JSON.stringify({ endpoint: "https://example.com/v1", costMode: "mixed", approvalRequired: false }) + }); + assert.equal(invalidEditApproval.response.status, 400, "编辑时 mixed 未审批必须被拒绝"); + + const probed = await request(`/api/platform/models/${encodeURIComponent(modelId)}/probe`, owner, { method: "POST", body: "{}" }); + assert.equal(probed.response.ok, true, `本地连接器探活失败:${JSON.stringify(probed.payload)}`); + assert.equal(probed.payload.model.status, "ready", "本地连接器探活后必须进入 ready"); + + console.log(`model connector smoke passed: RBAC, local-only, approval gate, edit, probe (${modelId})`); +} finally { + await new Promise((resolve) => runner.close(resolve)); + if (modelId) { + withTransaction(() => { + dbRun("DELETE FROM model_connectors WHERE id = ?", [modelId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [modelId]); + }); + } +} diff --git a/scripts/smoke-notifications.mjs b/scripts/smoke-notifications.mjs new file mode 100644 index 0000000..9197bd2 --- /dev/null +++ b/scripts/smoke-notifications.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const localScope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; +const northstarScope = { + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main", + "x-project-id": "northstar-pilot" +}; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function headers(token, scope) { + return { authorization: `Bearer ${token}`, ...scope }; +} + +async function login(email) { + const result = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password: "Demo@123456" }) + }); + assert.equal(result.response.ok, true, `${email} login failed: ${JSON.stringify(result.payload)}`); + return result.payload.session.token; +} + +const tokens = []; +try { + const anonymous = await request("/api/notifications"); + assert.equal(anonymous.response.status, 401, "anonymous notifications must be rejected"); + + const ownerToken = await login("producer@local.test"); + tokens.push(ownerToken); + const ownerHeaders = headers(ownerToken, localScope); + const preferences = await request("/api/notification-preferences", { headers: ownerHeaders }); + assert.equal(preferences.response.ok, true, JSON.stringify(preferences.payload)); + assert.equal(preferences.payload.preferences.length, 7, "notification preference catalog must be complete"); + assert.equal(preferences.payload.preferences.find((item) => item.category === "billing")?.enabled, true, "billing preference must default to enabled"); + const disabledPreferences = await request("/api/notification-preferences/billing", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ enabled: false }) + }); + assert.equal(disabledPreferences.response.ok, true, JSON.stringify(disabledPreferences.payload)); + const preferenceMarker = `preference-${Date.now()}`; + const preferenceEvent = await request("/api/system/notifications/test", { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ event: "quota.warning", payload: { targetId: preferenceMarker, detail: "通知偏好 smoke 事件" } }) + }); + assert.equal(preferenceEvent.response.ok, true, JSON.stringify(preferenceEvent.payload)); + const ownerAfterPreference = await request("/api/notifications?limit=80", { headers: ownerHeaders }); + assert.equal(ownerAfterPreference.response.ok, true, JSON.stringify(ownerAfterPreference.payload)); + assert.ok(!ownerAfterPreference.payload.notifications.some((item) => item.targetId === preferenceMarker), "disabled category must not create a notification for that user"); + const restoredPreferences = await request("/api/notification-preferences/billing", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ enabled: true }) + }); + assert.equal(restoredPreferences.response.ok, true, JSON.stringify(restoredPreferences.payload)); + const marker = `smoke-${Date.now()}`; + const event = await request("/api/system/notifications/test", { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ event: "system.changed", payload: { targetId: marker, detail: "通知 smoke 验证事件" } }) + }); + assert.equal(event.response.ok, true, `notification event failed: ${JSON.stringify(event.payload)}`); + assert.ok(event.payload.userNotifications?.length >= 1, "event must create in-app notifications for eligible recipients"); + + const ownerInbox = await request("/api/notifications?limit=40", { headers: ownerHeaders }); + assert.equal(ownerInbox.response.ok, true, JSON.stringify(ownerInbox.payload)); + const created = ownerInbox.payload.notifications.find((item) => item.targetId === marker); + assert.ok(created, "owner must see the notification in the current workspace"); + assert.ok(ownerInbox.payload.unreadCount >= 1, "new notification must increase unread count"); + + const writerToken = await login("writer@local.test"); + tokens.push(writerToken); + const writerHeaders = headers(writerToken, localScope); + const writerReadOwnerMessage = await request(`/api/notifications/${encodeURIComponent(created.id)}`, { + method: "PATCH", + headers: writerHeaders, + body: JSON.stringify({ read: true }) + }); + assert.equal(writerReadOwnerMessage.response.status, 404, "a different user cannot mark another user's notification read"); + + const ownerRead = await request(`/api/notifications/${encodeURIComponent(created.id)}`, { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ read: true }) + }); + assert.equal(ownerRead.response.ok, true, JSON.stringify(ownerRead.payload)); + assert.ok(ownerRead.payload.notifications.some((item) => item.id === created.id && item.read), "single notification must become read"); + + const ownerReadAll = await request("/api/notifications/read-all", { method: "POST", headers: ownerHeaders, body: "{}" }); + assert.equal(ownerReadAll.response.ok, true, JSON.stringify(ownerReadAll.payload)); + assert.equal(ownerReadAll.payload.unreadCount, 0, "read-all must clear the current user's unread count"); + + const northstar = await request("/api/notifications?limit=40", { headers: headers(ownerToken, northstarScope) }); + assert.equal(northstar.response.ok, true, JSON.stringify(northstar.payload)); + assert.ok(northstar.payload.notifications.every((item) => item.organizationId === "org-northstar"), "cross-organization notifications must be isolated"); + assert.ok(!northstar.payload.notifications.some((item) => item.targetId === marker), "northstar must not see local notification IDs"); + const northstarPreferences = await request("/api/notification-preferences", { headers: headers(ownerToken, northstarScope) }); + assert.equal(northstarPreferences.response.ok, true, JSON.stringify(northstarPreferences.payload)); + assert.equal(northstarPreferences.payload.preferences.find((item) => item.category === "billing")?.enabled, true, "notification preferences must be organization scoped"); + + console.log(`notifications smoke passed: owner=${ownerInbox.payload.notifications.length}, preference-filtered=true, writer-cross-user=blocked, northstar=${northstar.payload.notifications.length}`); +} finally { + for (const token of tokens) { + await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${token}` } }).catch(() => {}); + } +} diff --git a/scripts/smoke-oidc.mjs b/scripts/smoke-oidc.mjs new file mode 100644 index 0000000..d08a958 --- /dev/null +++ b/scripts/smoke-oidc.mjs @@ -0,0 +1,254 @@ +import { spawn } from "node:child_process"; +import { createHmac, generateKeyPairSync, createSign } from "node:crypto"; +import { createServer } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const apiPort = 8797; +const issuerPort = 8798; +const api = `http://127.0.0.1:${apiPort}`; +const issuer = `http://127.0.0.1:${issuerPort}`; +const secret = `smoke-secret-${Date.now()}`; +const tempRoot = await mkdtemp(`${tmpdir()}/ai-drama-oidc-`); +const dbPath = resolve(tempRoot, "platform.sqlite"); +const runId = Date.now(); +const mockEmail = `sso-${runId}@local.test`; +const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const publicJwk = { ...publicKey.export({ format: "jwk" }), kid: "smoke-key", use: "sig", alg: "RS256" }; +let authorizationRequest = null; +let authorizationCode = null; +let apiProcess = null; +let providerServer = null; +let childLogs = ""; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function json(res, status, payload) { + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + res.end(JSON.stringify(payload)); +} + +function redirect(res, location) { + res.writeHead(302, { location, "cache-control": "no-store" }); + res.end(); +} + +function base64url(value) { + return Buffer.from(value).toString("base64url"); +} + +function decodeBase32(value) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const normalized = String(value || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, ""); + let bits = 0; + let buffer = 0; + const output = []; + for (const character of normalized) { + buffer = (buffer << 5) | alphabet.indexOf(character); + bits += 5; + if (bits >= 8) { + bits -= 8; + output.push((buffer >> bits) & 0xff); + } + } + return Buffer.from(output); +} + +function totp(secret, timestamp = Date.now()) { + const counter = Math.floor(timestamp / 30000); + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); + return String(value % 1000000).padStart(6, "0"); +} + +function signIdToken(claims) { + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid: "smoke-key" })); + const payload = base64url(JSON.stringify(claims)); + const input = `${header}.${payload}`; + const signer = createSign("RSA-SHA256"); + signer.update(input); + signer.end(); + return `${input}.${signer.sign(privateKey).toString("base64url")}`; +} + +async function readRequestBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +async function startProvider() { + providerServer = createServer(async (req, res) => { + const url = new URL(req.url, issuer); + if (req.method === "GET" && url.pathname === "/.well-known/openid-configuration") { + return json(res, 200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + userinfo_endpoint: `${issuer}/userinfo`, + jwks_uri: `${issuer}/jwks` + }); + } + if (req.method === "GET" && url.pathname === "/jwks") return json(res, 200, { keys: [publicJwk] }); + if (req.method === "GET" && url.pathname === "/authorize") { + authorizationRequest = Object.fromEntries(url.searchParams.entries()); + authorizationCode = `smoke-code-${runId}`; + return redirect(res, `${api}/api/auth/sso/callback?code=${encodeURIComponent(authorizationCode)}&state=${encodeURIComponent(authorizationRequest.state)}`); + } + if (req.method === "POST" && url.pathname === "/token") { + const body = new URLSearchParams(await readRequestBody(req)); + assert(body.get("code") === authorizationCode, "Mock OIDC code 不匹配"); + assert(body.get("client_id") === "smoke-client", "Mock OIDC client_id 不匹配"); + assert(body.get("code_verifier"), "Mock OIDC 缺少 PKCE verifier"); + const now = Math.floor(Date.now() / 1000); + return json(res, 200, { + token_type: "Bearer", + access_token: `smoke-access-${runId}`, + id_token: signIdToken({ + iss: issuer, + sub: `mock-sub-${runId}`, + aud: "smoke-client", + nonce: authorizationRequest.nonce, + email: mockEmail, + email_verified: true, + name: "本地 SSO 测试用户", + preferred_username: mockEmail, + iat: now, + exp: now + 300 + }) + }); + } + if (req.method === "GET" && url.pathname === "/userinfo") return json(res, 200, { name: "本地 SSO 测试用户" }); + return json(res, 404, { error: "not_found" }); + }); + await new Promise((resolvePromise, reject) => providerServer.listen(issuerPort, "127.0.0.1", resolvePromise).on("error", reject)); +} + +async function waitFor(url, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + throw new Error(`等待服务超时:${url}`); +} + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { ...options, redirect: "manual", headers: { "content-type": "application/json", ...(options.headers || {}) } }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function main() { + await startProvider(); + apiProcess = spawn(process.execPath, ["server/local-api.mjs"], { + cwd: root, + env: { + ...process.env, + AI_DRAMA_API_PORT: String(apiPort), + AI_DRAMA_API_ORIGIN: api, + AI_DRAMA_FRONTEND_ORIGIN: "http://127.0.0.1:5173", + AI_DRAMA_DB_PATH: dbPath, + AI_DRAMA_OIDC_SMOKE_SECRET: secret + }, + stdio: ["ignore", "pipe", "pipe"] + }); + apiProcess.stdout.on("data", (chunk) => { childLogs += chunk.toString(); }); + apiProcess.stderr.on("data", (chunk) => { childLogs += chunk.toString(); }); + await waitFor(`${api}/api/health`); + + const owner = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); + assert(owner.response.ok && owner.payload.session?.token, "OIDC smoke 管理员登录失败"); + const ownerHeaders = { authorization: `Bearer ${owner.payload.session.token}` }; + + const created = await request("/api/system/identity/providers", { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ + name: `Smoke OIDC Runtime ${runId}`, + kind: "oidc", + organizationId: "org-studio-lab", + workspaceId: "ws-local-aidrama", + issuerUrl: issuer, + clientId: "smoke-client", + clientSecretRef: "AI_DRAMA_OIDC_SMOKE_SECRET", + autoProvision: true, + defaultRoleKey: "org_member", + defaultWorkspaceRoleKey: "writer", + enabled: true + }) + }); + assert(created.response.status === 201, `OIDC smoke 提供商登记失败:${JSON.stringify(created.payload)}`); + const provider = created.payload.providers.find((item) => item.name.includes(`Smoke OIDC Runtime ${runId}`)); + assert(provider, "OIDC smoke 找不到新建提供商"); + + const probed = await request(`/api/system/identity/providers/${encodeURIComponent(provider.id)}/probe`, { method: "POST", headers: ownerHeaders, body: "{}" }); + assert(probed.response.ok && probed.payload.provider?.status === "ready", `OIDC smoke discovery 探测失败:${JSON.stringify(probed.payload)}`); + const policy = await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: true }) }); + assert(policy.response.ok, `OIDC smoke 启用 SSO 失败:${JSON.stringify(policy.payload)}`); + + const start = await request(`/api/auth/sso/start?providerId=${encodeURIComponent(provider.id)}&returnTo=%2F%23creator-home`); + assert(start.response.status === 302, `OIDC smoke 登录发起失败:${JSON.stringify(start.payload)}`); + const authorizeUrl = start.response.headers.get("location"); + assert(authorizeUrl?.startsWith(`${issuer}/authorize`), "OIDC smoke 没有跳转到 Mock Provider"); + const authorize = await fetch(authorizeUrl, { redirect: "manual" }); + assert(authorize.status === 302, "Mock Provider authorize 没有返回 callback"); + const callbackUrl = authorize.headers.get("location"); + const callback = await fetch(callbackUrl, { redirect: "manual" }); + assert(callback.status === 302, `OIDC callback 失败:${await callback.text()}`); + const frontendUrl = new URL(callback.headers.get("location")); + const ticket = frontendUrl.searchParams.get("sso_ticket"); + assert(ticket, "OIDC callback 没有签发一次性票据"); + + const redeemed = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) }); + assert(redeemed.response.ok && redeemed.payload.session?.token, `OIDC 票据兑换失败:${JSON.stringify(redeemed.payload)}`); + assert(redeemed.payload.user?.email === mockEmail, "OIDC 自动创建用户邮箱不匹配"); + assert(redeemed.payload.context?.currentOrganization?.id === "org-studio-lab", "OIDC 用户没有进入绑定组织"); + + const replay = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) }); + assert(replay.response.status === 401, "OIDC 票据重放没有被拒绝"); + + const enforced = await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: true, mfaRequiredForAll: true }) }); + assert(enforced.response.ok, `OIDC smoke 启用全员 MFA 失败:${JSON.stringify(enforced.payload)}`); + const secondStart = await request(`/api/auth/sso/start?providerId=${encodeURIComponent(provider.id)}`); + const secondAuthorize = await fetch(secondStart.response.headers.get("location"), { redirect: "manual" }); + const secondCallback = await fetch(secondAuthorize.headers.get("location"), { redirect: "manual" }); + const secondTicket = new URL(secondCallback.headers.get("location")).searchParams.get("sso_ticket"); + const enrollment = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket: secondTicket }) }); + assert(enrollment.response.ok && enrollment.payload.mfaEnrollmentRequired && enrollment.payload.enrollmentToken, "SSO 用户命中强制 MFA 时必须返回 enrollment challenge"); + const setup = await request("/api/auth/mfa/enroll/setup", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollment.payload.enrollmentToken }) }); + assert(setup.response.status === 201 && setup.payload.setup?.methodId, "SSO MFA enrollment setup 失败"); + const enrolled = await request("/api/auth/mfa/enroll/enable", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollment.payload.enrollmentToken, methodId: setup.payload.setup.methodId, code: totp(setup.payload.setup.secret) }) }); + assert(enrolled.response.ok && enrolled.payload.session?.token, "SSO MFA enrollment 完成后必须签发正式 session"); + + const invalid = await request(`/api/auth/sso/callback?format=json&code=bad&state=${"tampered"}`); + assert(invalid.response.status === 401, "OIDC 篡改 state 没有被拒绝"); + + console.log(`oidc smoke passed: ${api} user=${mockEmail}`); + await cleanup(); +} + +async function cleanup() { + if (providerServer) await new Promise((resolvePromise) => providerServer.close(resolvePromise)); + if (apiProcess && !apiProcess.killed) apiProcess.kill("SIGTERM"); + await rm(tempRoot, { recursive: true, force: true }); +} + +try { + await main(); +} catch (error) { + console.error(error.message); + if (apiProcess) console.error(childLogs || "OIDC smoke API 没有输出日志"); + await cleanup(); + process.exitCode = 1; +} diff --git a/scripts/smoke-ops.mjs b/scripts/smoke-ops.mjs new file mode 100644 index 0000000..4d49811 --- /dev/null +++ b/scripts/smoke-ops.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { dbAll, dbRun, withTransaction } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const runId = Date.now(); +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +const inviteEmail = `ops-${runId}@local.test`; +const outputPrefix = `qa/ops/${runId}/`; + +function cleanup() { + const users = dbAll("SELECT id FROM users WHERE email = ?", [inviteEmail]); + const userIds = users.map((user) => user.id); + const jobs = dbAll("SELECT id FROM generation_jobs WHERE output_path LIKE ?", [`${outputPrefix}%`]); + const jobIds = jobs.map((job) => job.id); + + withTransaction(() => { + if (jobIds.length) { + const placeholders = jobIds.map(() => "?").join(","); + dbRun(`DELETE FROM media_artifacts WHERE job_id IN (${placeholders})`, jobIds); + dbRun(`DELETE FROM job_dependencies WHERE job_id IN (${placeholders}) OR depends_on_job_id IN (${placeholders})`, [...jobIds, ...jobIds]); + dbRun(`DELETE FROM job_attempts WHERE job_id IN (${placeholders})`, jobIds); + dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${runId}%`]); + dbRun(`DELETE FROM audit_logs WHERE target_id IN (${placeholders})`, jobIds); + dbRun(`DELETE FROM generation_jobs WHERE id IN (${placeholders})`, jobIds); + } + dbRun("DELETE FROM notification_deliveries WHERE request_json LIKE ?", [`%\"runId\":${runId}%`]); + for (const userId of userIds) { + dbRun("DELETE FROM invitations WHERE email = ? OR accepted_user_id = ?", [inviteEmail, userId]); + dbRun("UPDATE audit_logs SET actor_user_id = NULL WHERE actor_user_id = ?", [userId]); + dbRun("UPDATE usage_events SET user_id = NULL WHERE user_id = ?", [userId]); + dbRun("DELETE FROM users WHERE id = ?", [userId]); + } + dbRun("DELETE FROM invitations WHERE email = ?", [inviteEmail]); + }); +} + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +try { + const login = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) + }), "owner login"); + const headers = { authorization: `Bearer ${login.session.token}`, ...scope }; + + const invite = expectOk(await request("/api/organizations/org-studio-lab/invitations", { + method: "POST", + headers, + body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" }) + }), "create invitation"); + assert.ok(invite.invitation?.inviteToken, "invitation must return a one-time registration token"); + + const preview = expectOk(await request(`/api/invitations/preview?token=${encodeURIComponent(invite.invitation.inviteToken)}`), "preview invitation"); + assert.equal(preview.invitation.email, inviteEmail, "invitation preview email mismatch"); + + const registration = expectOk(await request("/api/auth/register", { + method: "POST", + body: JSON.stringify({ inviteToken: invite.invitation.inviteToken, displayName: "运营测试用户", password: "Ops@123456" }) + }), "register invited user"); + assert.ok(registration.session?.token, "registration must create a session"); + assert.equal(registration.context.currentOrganization.id, "org-studio-lab", "registered user must enter invited organization"); + + const firstJob = expectOk(await request("/api/jobs", { + method: "POST", + headers, + body: JSON.stringify({ adapter: "owned-image", kind: "依赖测试关键帧", shotId: "shot-01", output: `${outputPrefix}first.json` }) + }), "create first job"); + const dependentJob = expectOk(await request("/api/jobs", { + method: "POST", + headers, + body: JSON.stringify({ adapter: "owned-image", kind: "依赖测试任务", shotId: "shot-02", dependsOnJobIds: [firstJob.job.id], output: `${outputPrefix}dependent.json` }) + }), "create dependent job"); + assert.equal(dependentJob.job.dependencies.length, 1, "dependent job must expose dependency records"); + assert.equal(dependentJob.job.status, "blocked", "dependent job must wait for incomplete dependency"); + const dependencyRun = await request(`/api/jobs/${encodeURIComponent(dependentJob.job.id)}/run`, { method: "POST", headers, body: "{}" }); + assert.equal(dependencyRun.response.status, 409, "incomplete dependency must block execution"); + assert.equal(dependencyRun.payload.error, "job_dependencies_unresolved", "dependency block must have a stable error code"); + + const storage = expectOk(await request("/api/usage/storage", { headers }), "storage usage"); + assert.ok(Number.isFinite(storage.storage.usedBytes), "storage usage must return measured bytes"); + assert.ok(Number.isFinite(storage.storage.limitBytes), "storage usage must return a byte quota"); + + const notificationTest = expectOk(await request("/api/system/notifications/test", { + method: "POST", + headers, + body: JSON.stringify({ event: "job.failed", payload: { source: "smoke-ops", runId } }) + }), "notification test"); + assert.ok(notificationTest.deliveries?.length >= 1, "notification test must create delivery records"); + + const compose = expectOk(await request("/api/production/compose", { + method: "POST", + headers, + body: JSON.stringify({ dryRun: true, clips: ["storage/jobs/missing-a.mp4", "storage/jobs/missing-b.mp4"], outputPath: `storage/compositions/${runId}.mp4` }) + }), "compose dry-run"); + assert.equal(compose.composition.dryRun, true, "compose dry-run must not claim a rendered video"); + assert.equal(compose.composition.tool, "ffmpeg", "local composition must use ffmpeg"); + + console.log(`ops smoke passed: ${api}`); +} finally { + cleanup(); +} diff --git a/scripts/smoke-production-catalog.mjs b/scripts/smoke-production-catalog.mjs new file mode 100644 index 0000000..665bcaf --- /dev/null +++ b/scripts/smoke-production-catalog.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { dbRun, withTransaction } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +const login = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) +}); +assert.equal(login.response.ok, true, "catalog smoke login failed"); +const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope }; +let seasonId = ""; +let episodeId = ""; + +try { + const catalog = await request("/api/production/catalog", { headers }); + assert.equal(catalog.response.ok, true, "production catalog must be readable"); + assert.ok(catalog.payload.catalog.seasons.length >= 1, "catalog must include at least one season"); + assert.ok(catalog.payload.catalog.seasons[0].episodes.length >= 1, "catalog must include at least one episode"); + + const createdSeason = await request("/api/production/seasons", { + method: "POST", + headers, + body: JSON.stringify({ title: `Smoke Season ${Date.now()}` }) + }); + assert.equal(createdSeason.response.status, 201, "season creation must return 201"); + seasonId = createdSeason.payload.season.id; + + const createdEpisode = await request("/api/production/episodes", { + method: "POST", + headers, + body: JSON.stringify({ seasonId, title: "Smoke Episode" }) + }); + assert.equal(createdEpisode.response.status, 201, "episode creation must return 201"); + episodeId = createdEpisode.payload.episode.id; + assert.equal(createdEpisode.payload.episode.shotCount, 1, "new episode must initialize one shot draft"); + + const graph = await request(`/api/production/graph?episodeId=${encodeURIComponent(episodeId)}`, { headers }); + assert.equal(graph.response.ok, true, "episode graph must be readable"); + assert.equal(graph.payload.graph.episode.id, episodeId, "graph must switch to requested episode"); + assert.equal(graph.payload.graph.shots.length, 1, "new episode graph must contain starter shot"); + assert.equal(graph.payload.graph.catalog.activeEpisodeId, episodeId, "catalog must reflect active episode"); + + const updated = await request(`/api/production/episodes/${encodeURIComponent(episodeId)}`, { + method: "PATCH", + headers, + body: JSON.stringify({ status: "production", hook: "Smoke hook" }) + }); + assert.equal(updated.response.ok, true, "episode update must succeed"); + assert.equal(updated.payload.episode.status, "production", "episode status must persist"); + console.log("production catalog smoke passed"); +} finally { + if (seasonId) { + withTransaction(() => { + dbRun("DELETE FROM review_comments WHERE review_id IN (SELECT id FROM reviews WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?))", [episodeId]); + dbRun("DELETE FROM reviews WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?)", [episodeId]); + dbRun("DELETE FROM voice_lines WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?)", [episodeId]); + dbRun("DELETE FROM shot_versions WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?)", [episodeId]); + dbRun("DELETE FROM shots WHERE episode_id = ?", [episodeId]); + dbRun("DELETE FROM script_documents WHERE episode_id = ?", [episodeId]); + dbRun("DELETE FROM episodes WHERE id = ?", [episodeId]); + dbRun("DELETE FROM seasons WHERE id = ?", [seasonId]); + dbRun("DELETE FROM audit_logs WHERE target_id IN (?, ?)", [seasonId, episodeId]); + }); + } +} diff --git a/scripts/smoke-production-controls.mjs b/scripts/smoke-production-controls.mjs new file mode 100644 index 0000000..fb42fb8 --- /dev/null +++ b/scripts/smoke-production-controls.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { dbRun, withTransaction } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +const login = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) +}), "production controls login"); +const headers = { authorization: `Bearer ${login.session.token}`, ...scope }; +const graph = expectOk(await request("/api/production/graph", { headers }), "read production graph"); +const shotId = graph.graph.shots[0]?.id; +assert.ok(shotId, "production graph must have a shot"); + +let createdVersionId = ""; +let deliveryId = ""; +const batchIds = []; +const manifestPaths = []; +try { + const beforeVersions = expectOk(await request(`/api/production/shots/${encodeURIComponent(shotId)}/versions`, { headers }), "read shot versions"); + const originalVersion = beforeVersions.versions.at(-1) || beforeVersions.versions[0]; + assert.ok(originalVersion?.id, "shot version history must include a version"); + + const saved = expectOk(await request(`/api/production/shots/${encodeURIComponent(shotId)}/prompt-versions`, { + method: "POST", + headers, + body: JSON.stringify({ imagePrompt: "smoke version prompt", negativePrompt: "no collage", videoPrompt: "smoke version motion" }) + }), "create shot version"); + createdVersionId = saved.shot.versionId; + assert.notEqual(createdVersionId, originalVersion.id, "saving a prompt must create a new current version"); + + const restored = expectOk(await request(`/api/production/shots/${encodeURIComponent(shotId)}/versions/${encodeURIComponent(originalVersion.id)}/restore`, { + method: "POST", + headers, + body: "{}" + }), "restore shot version"); + assert.equal(restored.shot.versionId, originalVersion.id, "restore must move the current version pointer"); + + const createdDelivery = expectOk(await request("/api/production/deliveries", { + method: "POST", + headers, + body: JSON.stringify({ version: `smoke-${Date.now()}`, channel: "internal" }) + }), "create delivery"); + deliveryId = createdDelivery.delivery.id; + + const firstBatch = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, { + method: "POST", + headers, + body: JSON.stringify({ label: "Smoke Batch 1" }) + }), "create first delivery batch"); + batchIds.push(firstBatch.batch.id); + manifestPaths.push(firstBatch.batch.manifest_path); + assert.equal(firstBatch.batch.result.manifestWritten, true, "delivery batch must write a real manifest"); + assert.ok(firstBatch.batch.manifest_path.startsWith("storage/"), "delivery manifest must be inside storage"); + const secondBatch = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, { + method: "POST", + headers, + body: JSON.stringify({ label: "Smoke Batch 2" }) + }), "create second delivery batch"); + batchIds.push(secondBatch.batch.id); + manifestPaths.push(secondBatch.batch.manifest_path); + assert.equal(secondBatch.batch.result.manifestWritten, true, "second delivery batch must write a real manifest"); + + expectOk(await request(`/api/production/delivery-batches/${encodeURIComponent(firstBatch.batch.id)}/activate`, { method: "POST", headers, body: "{}" }), "activate first delivery batch"); + expectOk(await request(`/api/production/delivery-batches/${encodeURIComponent(secondBatch.batch.id)}/activate`, { method: "POST", headers, body: "{}" }), "activate second delivery batch"); + const rolledBack = expectOk(await request(`/api/production/delivery-batches/${encodeURIComponent(secondBatch.batch.id)}/rollback`, { method: "POST", headers, body: "{}" }), "rollback second delivery batch"); + assert.equal(rolledBack.restoredBatch.id, firstBatch.batch.id, "rollback must restore the previous active batch"); + + const mediaQa = expectOk(await request("/api/production/qa/media/run", { method: "POST", headers, body: "{}" }), "run media QA"); + assert.ok(mediaQa.report?.totals && Number.isInteger(mediaQa.report.totals.gates), "media QA must return measurable gate totals"); + console.log(`production controls smoke passed: ${shotId}, ${deliveryId}`); +} finally { + withTransaction(() => { + if (createdVersionId) dbRun("DELETE FROM shot_versions WHERE id = ?", [createdVersionId]); + if (shotId) dbRun("UPDATE shots SET current_version_id = (SELECT id FROM shot_versions WHERE shot_id = ? ORDER BY version_number ASC LIMIT 1) WHERE id = ?", [shotId, shotId]); + for (const batchId of batchIds) dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]); + for (const batchId of batchIds) dbRun("DELETE FROM delivery_batches WHERE id = ?", [batchId]); + if (deliveryId) dbRun("DELETE FROM deliveries WHERE id = ?", [deliveryId]); + }); + for (const manifestPath of manifestPaths) await rm(dirname(resolve(import.meta.dirname, "..", manifestPath)), { recursive: true, force: true }); +} diff --git a/scripts/smoke-project-isolation.mjs b/scripts/smoke-project-isolation.mjs new file mode 100644 index 0000000..39daa86 --- /dev/null +++ b/scripts/smoke-project-isolation.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const projectRoot = resolve(import.meta.dirname, ".."); +const organizationId = "org-studio-lab"; +const workspaceId = "ws-local-aidrama"; +const projectId = `smoke-isolation-${Date.now()}`; +const projectName = `隔离验收项目 ${projectId.slice(-6)}`; +const emptyOrganizationId = `smoke-empty-org-${Date.now()}`; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +let created = false; +let emptyOrganizationCreated = false; +let isolatedAssetPath = ""; +try { + const login = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) + }), "owner login"); + const headers = { + authorization: `Bearer ${login.session.token}`, + "x-organization-id": organizationId, + "x-workspace-id": workspaceId, + "x-project-id": "thunder-mouth" + }; + + const original = expectOk(await request("/api/project", { headers }), "read original project"); + const originalShotIds = new Set((original.project.shots || []).map((shot) => shot.id)); + const originalCharacterIds = new Set((original.project.characters || []).map((asset) => asset.id)); + + const creation = expectOk(await request("/api/projects", { + method: "POST", + headers, + body: JSON.stringify({ id: projectId, name: projectName, type: "AI 漫剧" }) + }), "create isolated project"); + assert.equal(creation.project.id, projectId, "project factory must return the created project"); + created = true; + + const projectHeaders = { ...headers, "x-project-id": projectId }; + const scoped = expectOk(await request("/api/project", { headers: projectHeaders }), "read isolated project"); + assert.equal(scoped.project.id, projectId, "project response must stay inside requested project"); + assert.equal(scoped.project.series.title, projectName, "series title must come from the new project"); + assert.notEqual(scoped.project.series.title, original.project.series.title, "new project must not inherit the seeded series title"); + assert.equal(scoped.project.characters.length, 0, "new project must not inherit character locks"); + assert.equal(scoped.project.locations.length, 0, "new project must not inherit location locks"); + assert.equal(scoped.project.props.length, 0, "new project must not inherit prop locks"); + assert.equal(scoped.project.productionJobs.length, 0, "new project must start with an empty job list"); + assert.ok(scoped.project.episode.id, "new project must initialize a first episode"); + assert.equal(scoped.project.shots.length, 1, "new project must initialize exactly one starter shot"); + assert.ok(!scoped.project.shots.some((shot) => originalShotIds.has(shot.id)), "starter shot must not reuse another project's shot"); + assert.ok(!scoped.project.characters.some((asset) => originalCharacterIds.has(asset.id)), "assets must not cross project boundaries"); + + const isolatedAsset = expectOk(await request("/api/assets/upload", { + method: "POST", + headers: projectHeaders, + body: JSON.stringify({ data: Buffer.from(`isolated-asset-${projectId}`).toString("base64"), fileName: "isolated.txt", kind: "reference", mimeType: "text/plain" }) + }), "upload isolated asset"); + isolatedAssetPath = isolatedAsset.asset.currentVersion.storage_path; + const isolatedAssets = expectOk(await request("/api/assets", { headers: projectHeaders }), "read isolated assets"); + assert.equal(isolatedAssets.assets.length, 1, "新项目资产列表只能包含本项目刚上传的资产"); + const originalAssetContent = await request(`/api/assets/${encodeURIComponent([...originalCharacterIds][0])}/content`, { headers: projectHeaders }); + assert.equal(originalAssetContent.response.status, 404, "跨项目读取资产内容必须返回 404"); + + const graph = expectOk(await request("/api/production/graph", { headers: projectHeaders }), "read isolated production graph"); + assert.equal(graph.graph.series.title, projectName, "production graph must use the new series"); + assert.equal(graph.graph.shots.length, 1, "production graph must contain the new starter shot only"); + assert.equal(graph.graph.characters.length, 0, "production graph must not include seeded characters"); + + const job = expectOk(await request("/api/jobs", { + method: "POST", + headers: projectHeaders, + body: JSON.stringify({ adapter: "owned-image", kind: "隔离验收关键帧", shotId: scoped.project.shots[0].id, output: `qa/${projectId}/frame.json` }) + }), "create isolated job"); + const afterJob = expectOk(await request("/api/project", { headers: projectHeaders }), "read isolated project after job"); + assert.equal(afterJob.jobs.length, 1, "new project job list must contain only its own job"); + assert.equal(afterJob.jobs[0].id, job.job.id, "job must be scoped to the new project"); + + const exports = expectOk(await request("/api/exports/write", { method: "POST", headers: projectHeaders, body: "{}" }), "write isolated exports"); + assert.ok(exports.exportRoot.endsWith(`/exports/${projectId}`), "exports must use a project-specific root"); + assert.ok(exports.files.every((file) => file.startsWith(exports.exportRoot)), "every export file must stay in the project root"); + + const originalAfter = expectOk(await request("/api/project", { headers }), "read original project after isolation test"); + assert.ok(originalAfter.project.characters.length > 0, "original project's assets must remain intact"); + assert.ok(originalAfter.project.productionJobs.length >= original.project.productionJobs.length, "original project jobs must remain separate"); + assert.ok(originalAfter.project.shots.some((shot) => originalShotIds.has(shot.id)), "original project's shots must remain intact"); + + const emptyOrganization = expectOk(await request("/api/organizations", { + method: "POST", + headers, + body: JSON.stringify({ id: emptyOrganizationId, name: `空租户 ${emptyOrganizationId.slice(-6)}`, workspaceName: "空生产空间" }) + }), "create empty organization"); + emptyOrganizationCreated = true; + const emptyScope = { authorization: headers.authorization, "x-organization-id": emptyOrganization.organization.id }; + const emptyContext = expectOk(await request("/api/context", { headers: emptyScope }), "read empty organization context"); + assert.equal(emptyContext.context.currentProject, null, "new organization must not invent a current project"); + const emptyProject = expectOk(await request("/api/project", { headers: emptyScope }), "read empty organization project shell"); + assert.equal(emptyProject.project.id, "", "empty organization must return an empty project shell"); + assert.equal(emptyProject.project.episode.id, "", "empty organization must not invent an episode"); + assert.equal(emptyProject.project.shots.length, 0, "empty organization must not inherit a starter shot"); + assert.equal(emptyProject.project.characters.length, 0, "empty organization must not inherit character locks"); + + console.log(`project isolation smoke passed: ${projectId}`); +} finally { + if (created) { + dbRun("DELETE FROM projects WHERE id = ?", [projectId]); + await rm(resolve(projectRoot, "exports", projectId), { recursive: true, force: true }); + if (isolatedAssetPath) await rm(resolve(projectRoot, isolatedAssetPath), { force: true }); + await rm(resolve(projectRoot, "storage", "assets", projectId), { recursive: true, force: true }); + } + if (emptyOrganizationCreated) dbRun("DELETE FROM organizations WHERE id = ?", [emptyOrganizationId]); +} diff --git a/scripts/smoke-project-lifecycle.mjs b/scripts/smoke-project-lifecycle.mjs new file mode 100644 index 0000000..a6aac24 --- /dev/null +++ b/scripts/smoke-project-lifecycle.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const projectRoot = resolve(import.meta.dirname, ".."); +const organizationId = "org-studio-lab"; +const workspaceId = "ws-local-aidrama"; +const projectId = `smoke-lifecycle-${Date.now()}`; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +let created = false; +let jobId = ""; +try { + const login = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) + }), "owner login"); + const headers = { + authorization: `Bearer ${login.session.token}`, + "x-organization-id": organizationId, + "x-workspace-id": workspaceId, + "x-project-id": "thunder-mouth" + }; + const writerLogin = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" }) + }), "writer login"); + + const creation = expectOk(await request("/api/projects", { + method: "POST", + headers, + body: JSON.stringify({ id: projectId, name: `生命周期验收 ${projectId.slice(-6)}`, type: "AI 漫剧" }) + }), "create lifecycle project"); + created = true; + assert.equal(creation.project.status, "draft", "new projects must begin in draft status"); + const projectHeaders = { ...headers, "x-project-id": projectId }; + const writerProjectHeaders = { authorization: `Bearer ${writerLogin.session.token}`, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId }; + const writerArchive = await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: writerProjectHeaders, body: JSON.stringify({ action: "archive" }) }); + assert.equal(writerArchive.response.status, 403, "ordinary writers must not change project lifecycle"); + assert.equal(writerArchive.payload.error, "permission_denied", "lifecycle denial must be a backend permission denial"); + + const paused = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "pause" }) }), "pause project"); + assert.equal(paused.project.status, "paused", "pause action must transition draft to paused"); + const resumed = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "resume" }) }), "resume project"); + assert.equal(resumed.project.status, "production", "resume action must transition to production"); + + const project = expectOk(await request("/api/project", { headers: projectHeaders }), "read lifecycle project"); + const job = await request("/api/jobs", { + method: "POST", + headers: projectHeaders, + body: JSON.stringify({ adapter: "owned-image", kind: "生命周期归档保护任务", shotId: project.project.shots[0].id, output: `qa/${projectId}/frame.json` }) + }); + assert.equal(job.response.status, 201, `job setup failed: ${job.payload.detail || job.payload.error || ""}`); + jobId = job.payload.job.id; + + const blockedArchive = await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "archive" }) }); + assert.equal(blockedArchive.response.status, 409, "project archive must reject active or blocked jobs"); + assert.equal(blockedArchive.payload.error, "project_has_active_jobs", "archive rejection must identify active jobs"); + + dbRun("UPDATE generation_jobs SET status = 'cancelled', updated_at = ? WHERE id = ?", [new Date().toISOString(), jobId]); + dbRun("UPDATE job_attempts SET status = 'cancelled', finished_at = ? WHERE job_id = ? AND status IN ('blocked', 'queued', 'running')", [new Date().toISOString(), jobId]); + + const archived = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "archive" }) }), "archive project"); + assert.equal(archived.project.status, "archived", "archive action must persist archived status"); + assert.ok(archived.project.archived_at, "archive action must persist archive timestamp"); + + const archivedRead = expectOk(await request("/api/project", { headers: projectHeaders }), "read archived project"); + assert.equal(archivedRead.project.status, "archived", "archived project remains readable"); + const archivedQa = await request("/api/qa", { headers: projectHeaders }); + assert.equal(archivedQa.response.status, 200, "archived project QA history must remain readable"); + const archivedCompliance = await request("/api/platform/compliance", { headers: projectHeaders }); + assert.equal(archivedCompliance.response.status, 200, "archived project compliance evidence must remain readable"); + const archivedQaRun = await request("/api/production/qa/run", { method: "POST", headers: projectHeaders, body: "{}" }); + assert.equal(archivedQaRun.response.status, 409, "archived project must reject new QA writes"); + const archivedJob = await request("/api/jobs", { method: "POST", headers: projectHeaders, body: JSON.stringify({ adapter: "owned-image", kind: "归档项目禁止生成", output: `qa/${projectId}/blocked.json` }) }); + assert.equal(archivedJob.response.status, 409, "archived project must reject new jobs at the backend"); + assert.equal(archivedJob.payload.error, "project_archived", "archived job rejection must be explicit"); + const archivedAsset = await request("/api/assets", { method: "POST", headers: projectHeaders, body: JSON.stringify({ name: "归档项目禁止资产修改", kind: "reference" }) }); + assert.equal(archivedAsset.response.status, 409, "archived project must reject asset mutations at the backend"); + + const restored = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "restore" }) }), "restore project"); + assert.equal(restored.project.status, "production", "restore must return to the pre-archive status"); + assert.equal(restored.project.archived_at, null, "restore must clear archive timestamp"); + console.log(`project lifecycle smoke passed: ${projectId}`); +} finally { + if (created) { + dbRun("DELETE FROM usage_events WHERE project_id = ?", [projectId]); + dbRun("DELETE FROM projects WHERE id = ?", [projectId]); + await rm(resolve(projectRoot, "exports", projectId), { recursive: true, force: true }); + if (jobId) await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true }); + } +} diff --git a/scripts/smoke-readiness.mjs b/scripts/smoke-readiness.mjs new file mode 100644 index 0000000..83b1b2f --- /dev/null +++ b/scripts/smoke-readiness.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { unlink } from "node:fs/promises"; +import { resolve } from "node:path"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; + +async function request(path, headers, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email) { + const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assert.equal(result.response.ok, true, `${email} 登录失败`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const owner = await login("producer@local.test"); +const writer = await login("writer@local.test"); +let createdRelativePath = ""; + +try { + const denied = await request("/api/system/readiness", writer); + assert.equal(denied.response.status, 403, "普通用户不应读取系统生产就绪度"); + + const readiness = await request("/api/system/readiness", owner); + assert.equal(readiness.response.ok, true, "系统管理员读取生产就绪度失败"); + assert.ok(Array.isArray(readiness.payload.checks), "生产就绪度缺少检查项"); + assert.equal(readiness.payload.activeRuntime.database, "node:sqlite", "当前业务数据库运行时记录不准确"); + assert.ok(readiness.payload.checks.some((check) => check.key === "database-backup"), "生产就绪度缺少备份检查"); + + const created = await request("/api/system/backups", owner, { method: "POST", body: "{}" }); + assert.equal(created.response.status, 201, `创建数据库快照失败:${JSON.stringify(created.payload)}`); + assert.ok(created.payload.backup?.relativePath, "数据库快照缺少相对路径"); + assert.ok(Number(created.payload.backup.bytes) > 0, "数据库快照文件为空"); + createdRelativePath = created.payload.backup.relativePath; + + const backups = await request("/api/system/backups", owner); + assert.equal(backups.response.ok, true, "读取数据库快照列表失败"); + assert.ok(backups.payload.backups.some((backup) => backup.relativePath === createdRelativePath), "数据库快照没有出现在列表中"); + + const readinessAfterBackup = await request("/api/system/readiness", owner); + assert.equal(readinessAfterBackup.response.ok, true, "创建快照后读取生产就绪度失败"); + assert.ok(readinessAfterBackup.payload.backups.latest?.relativePath === createdRelativePath, "生产就绪度没有反映最近快照"); + + console.log(`readiness smoke passed: ${api}`); +} finally { + if (createdRelativePath) await unlink(resolve(process.cwd(), createdRelativePath)).catch(() => {}); +} diff --git a/scripts/smoke-release-workflow.mjs b/scripts/smoke-release-workflow.mjs new file mode 100644 index 0000000..ab212df --- /dev/null +++ b/scripts/smoke-release-workflow.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbAll, dbGet, dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const projectRoot = resolve(import.meta.dirname, ".."); +const organizationId = "org-studio-lab"; +const workspaceId = "ws-local-aidrama"; +const projectId = "thunder-mouth"; +const runId = Date.now(); +const sourceRoot = `storage/smoke-release-${runId}`; +const deliveryLabel = `smoke-release-delivery-${runId}`; +const batchId = `smoke-release-batch-${runId}`; +const channelName = `smoke-release-channel-${runId}`; +const version = `smoke-${runId}`; +const sourcePath = `${sourceRoot}/clip.mp4`; +const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`; +const manifestPath = `${sourceRoot}/manifest.json`; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function expectStatus(result, status, label) { + assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +const producerLogin = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) +}), "producer login"); +const reviewerLogin = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" }) +}), "reviewer login"); + +const producerHeaders = { + authorization: `Bearer ${producerLogin.session.token}`, + "x-organization-id": organizationId, + "x-workspace-id": workspaceId, + "x-project-id": projectId +}; +const reviewerHeaders = { + authorization: `Bearer ${reviewerLogin.session.token}`, + "x-organization-id": organizationId, + "x-workspace-id": workspaceId, + "x-project-id": projectId +}; + +const projectShot = dbGet("SELECT id FROM shots WHERE episode_id IN (SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE sr.project_id = ?) ORDER BY shot_number LIMIT 1", [projectId]); +assert.ok(projectShot?.id, "smoke project must have a starter shot"); +const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]); +assert.ok(producerUser?.id, "producer user must exist"); +const channelPath = `/api/production/delivery-channels`; +let channelId = ""; +let releaseId = ""; +let createdDeliveryId = ""; +let seeded = false; + +try { + const channels = expectOk(await request(channelPath, { headers: reviewerHeaders }), "reviewer can view channels"); + assert.ok(channels.channels.some((channel) => channel.kind === "local-file"), "workspace must expose the default local-file channel"); + + expectStatus(await request(channelPath, { + method: "POST", + headers: reviewerHeaders, + body: JSON.stringify({ name: "reviewer must not create", kind: "local-file" }) + }), 403, "reviewer cannot create a channel"); + + expectStatus(await request(channelPath, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ name: "public webhook must be rejected", kind: "local-webhook", endpoint: "https://example.com/release" }) + }), 400, "public webhook is rejected"); + + const channel = expectStatus(await request(channelPath, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ name: channelName, kind: "local-file", endpoint: "storage/releases", requireApproval: true }) + }), 201, "producer creates local channel"); + channelId = channel.channel.id; + + const delivery = expectStatus(await request("/api/production/deliveries", { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ version, channel: deliveryLabel }) + }), 201, "producer creates delivery draft"); + createdDeliveryId = delivery.delivery.id; + assert.equal(delivery.delivery.status, "draft", "new delivery must start as draft"); + + await mkdir(resolve(projectRoot, sourceRoot), { recursive: true }); + await writeFile(resolve(projectRoot, sourcePath), Buffer.from("fake local video evidence")); + await writeFile(resolve(projectRoot, lastFramePath), Buffer.from("fake actual last frame evidence")); + await writeFile(resolve(projectRoot, manifestPath), JSON.stringify({ schema: "smoke-manifest", deliveryId: createdDeliveryId }, null, 2)); + const timestamp = new Date().toISOString(); + dbRun("INSERT INTO delivery_batches(id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 'smoke active batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, manifestPath, JSON.stringify({ blockers: [], manifestWritten: true }), producerUser.id, timestamp, timestamp]); + dbRun("INSERT INTO delivery_batch_items(id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, '{}', ?)", [`smoke-release-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), timestamp]); + dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]); + seeded = true; + + const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ channelId, submit: true, idempotencyKey: `release-${runId}` }) + }), 201, "producer submits release"); + releaseId = release.release.id; + assert.equal(release.release.status, "submitted", "new release request must be submitted"); + + const reviewerReleases = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, { headers: reviewerHeaders }), "reviewer can view release records"); + assert.equal(reviewerReleases.releases.length, 1, "reviewer must see the submitted release"); + expectStatus(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, { + method: "POST", + headers: reviewerHeaders, + body: JSON.stringify({ status: "approved" }) + }), 403, "reviewer cannot approve release"); + + const approved = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, { + method: "POST", + headers: producerHeaders, + body: JSON.stringify({ status: "approved", note: "smoke approval" }) + }), "producer approves release"); + assert.equal(approved.release.status, "approved", "release must become approved"); + + const published = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, { + method: "POST", + headers: producerHeaders, + body: "{}" + }), "producer publishes release"); + assert.equal(published.release.status, "published", "release must become published"); + assert.ok(published.release.output_path, "published release must return an output path"); + await access(resolve(projectRoot, published.release.output_path)); + const publishedDocument = await readFile(resolve(projectRoot, published.release.output_path), "utf8"); + assert.match(publishedDocument, /ai-drama-platform\.delivery-release\.v1/, "release.json must contain the release schema"); + + const repeated = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, { + method: "POST", + headers: producerHeaders, + body: "{}" + }), "repeated publish is idempotent"); + assert.equal(repeated.idempotent, true, "published release retry must be idempotent"); + + const crossOrganization = await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, { + headers: { ...producerHeaders, "x-organization-id": "org-northstar", "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" } + }); + assert.ok([403, 404].includes(crossOrganization.response.status), `cross organization release access must be hidden or denied: ${crossOrganization.response.status}`); + + const audit = dbGet("SELECT COUNT(*) AS count FROM audit_logs WHERE target_id = ? AND action IN ('delivery.release.submitted', 'delivery.release.approved', 'delivery.release.published')", [releaseId]); + assert.equal(Number(audit?.count || 0), 3, "release lifecycle must write three audit records"); + console.log(`release workflow smoke passed: ${releaseId}`); +} finally { + if (releaseId) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]); + if (seeded) { + dbRun("DELETE FROM delivery_releases WHERE delivery_id = ?", [createdDeliveryId]); + dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]); + dbRun("DELETE FROM delivery_batches WHERE id = ?", [batchId]); + dbRun("DELETE FROM deliveries WHERE id = ?", [createdDeliveryId]); + } + if (channelId) dbRun("DELETE FROM delivery_channels WHERE id = ?", [channelId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [createdDeliveryId]); + await rm(resolve(projectRoot, sourceRoot), { recursive: true, force: true }); +} diff --git a/scripts/smoke-security-events.mjs b/scripts/smoke-security-events.mjs new file mode 100644 index 0000000..9139688 --- /dev/null +++ b/scripts/smoke-security-events.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email, password = "Demo@123456") { + const result = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }) + }); + assert.equal(result.response.status, 200, `${email} 登录失败`); + assert.ok(result.payload.session?.token, `${email} 未返回登录会话`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const ownerHeaders = await login("producer@local.test"); +const writerHeaders = await login("writer@local.test"); + +const badLogin = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "writer@local.test", password: "security-smoke-invalid-password" }) +}); +assert.equal(badLogin.response.status, 401, "错误密码必须被拒绝"); + +const ownEvents = await request("/api/auth/security-events", { headers: writerHeaders }); +assert.equal(ownEvents.response.status, 200, "普通用户无法读取自己的安全事件"); +assert.ok(Array.isArray(ownEvents.payload.events), "个人安全事件接口必须返回 events 数组"); +assert.ok(ownEvents.payload.events.some((event) => event.eventType === "login.success"), "个人安全事件缺少登录成功记录"); +assert.ok(ownEvents.payload.events.some((event) => event.eventType === "login.failure"), "个人安全事件缺少登录失败记录"); +assert.ok(!JSON.stringify(ownEvents.payload.events).includes("security-smoke-invalid-password"), "安全事件不能记录密码"); +const eventKeys = new Set(); +for (const event of ownEvents.payload.events) { + for (const key of Object.keys(event.metadata || {})) eventKeys.add(key); +} +assert.ok(![...eventKeys].some((key) => /(sessionToken|challengeToken|password|otp|secret|api.?key)/i.test(key)), "安全事件不能返回敏感字段"); + +const ordinarySystemDenied = await request("/api/system/security-events", { headers: writerHeaders }); +assert.equal(ordinarySystemDenied.response.status, 403, "普通用户不能查看全局安全事件"); +assert.equal(ordinarySystemDenied.payload.error, "system_admin_required", "系统安全事件权限错误码不稳定"); + +const systemEvents = await request("/api/system/security-events?userId=u-writer", { headers: ownerHeaders }); +assert.equal(systemEvents.response.status, 200, "系统管理员无法查看指定用户安全事件"); +assert.ok(systemEvents.payload.events.some((event) => event.userId === "u-writer"), "系统安全事件未按指定用户过滤"); + +const detail = await request("/api/system/users/u-writer", { headers: ownerHeaders }); +assert.equal(detail.response.status, 200, "系统管理员无法读取用户详情"); +assert.ok(Array.isArray(detail.payload.securityEvents), "用户详情缺少账号安全事件台账"); + +console.log(`security events smoke passed: ${api}`); diff --git a/scripts/smoke-system-users.mjs b/scripts/smoke-system-users.mjs new file mode 100644 index 0000000..bdd8e05 --- /dev/null +++ b/scripts/smoke-system-users.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email) { + const result = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password: "Demo@123456" }) + }); + assert.equal(result.response.ok, true, `${email} 登录失败`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const ownerHeaders = await login("producer@local.test"); +const writerHeaders = await login("writer@local.test"); + +try { + const directory = await request("/api/system/users", { headers: ownerHeaders }); + assert.equal(directory.response.ok, true, "系统管理员无法读取全局用户目录"); + assert.ok(directory.payload.users.some((user) => user.id === "u-owner"), "用户目录缺少系统管理员"); + assert.ok(directory.payload.users.every((user) => Array.isArray(user.organizations)), "用户目录缺少组织归属"); + + const detail = await request("/api/system/users/u-owner", { headers: ownerHeaders }); + assert.equal(detail.response.ok, true, "系统管理员无法读取用户详情"); + assert.ok(Array.isArray(detail.payload.sessions) && Array.isArray(detail.payload.recentAudit), "用户详情缺少会话或审计"); + + const ordinaryDenied = await request("/api/system/users", { headers: writerHeaders }); + assert.equal(ordinaryDenied.response.status, 403, "普通用户不应访问全局用户目录"); + assert.equal(ordinaryDenied.payload.error, "system_admin_required", "全局用户目录权限错误码不稳定"); + + const selfSuspend = await request("/api/system/users/u-owner", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ status: "suspended" }) + }); + assert.equal(selfSuspend.response.status, 400, "系统管理员不能停用自己"); + assert.equal(selfSuspend.payload.error, "cannot_suspend_self", "自停用保护错误码不稳定"); + + const suspended = await request("/api/system/users/u-writer", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ status: "suspended" }) + }); + assert.equal(suspended.response.ok, true, "停用普通用户失败"); + assert.equal(suspended.payload.user.status, "suspended", "用户停用状态未落库"); + assert.ok(suspended.payload.revokedSessionCount >= 1, "停用用户必须撤销全部会话"); + + const staleSession = await request("/api/auth/session", { headers: writerHeaders }); + assert.equal(staleSession.response.status, 401, "停用后的旧会话必须失效"); + + const reactivated = await request("/api/system/users/u-writer", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ status: "active" }) + }); + assert.equal(reactivated.response.ok, true, "恢复用户失败"); + assert.equal(reactivated.payload.user.status, "active", "用户恢复状态未落库"); + + const newWriterHeaders = await login("writer@local.test"); + const revoked = await request("/api/system/users/u-writer/revoke-sessions", { + method: "POST", + headers: ownerHeaders, + body: "{}" + }); + assert.equal(revoked.response.ok, true, "系统管理员强制撤销用户会话失败"); + assert.ok(revoked.payload.revokedSessionCount >= 1, "强制撤销必须返回撤销数量"); + const revokedSession = await request("/api/auth/session", { headers: newWriterHeaders }); + assert.equal(revokedSession.response.status, 401, "强制撤销后用户会话仍然有效"); + + console.log(`system users smoke passed: ${api}`); +} finally { + await request("/api/system/users/u-writer", { + method: "PATCH", + headers: ownerHeaders, + body: JSON.stringify({ status: "active" }) + }); +} diff --git a/scripts/smoke-task-collaboration.mjs b/scripts/smoke-task-collaboration.mjs new file mode 100644 index 0000000..0f33d1c --- /dev/null +++ b/scripts/smoke-task-collaboration.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { dbGet, dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const localScope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; +const northstarScope = { + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main", + "x-project-id": "northstar-pilot" +}; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function headers(token, scope = localScope) { + return { authorization: `Bearer ${token}`, ...scope }; +} + +async function login(email) { + const result = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assert.equal(result.response.ok, true, `${email} login failed: ${JSON.stringify(result.payload)}`); + return result.payload.session.token; +} + +const taskTitle = `collaboration-smoke-${Date.now()}`; +const shotId = dbGet("SELECT id FROM shots WHERE episode_id = 'episode-thunder-mouth-01' ORDER BY shot_number LIMIT 1")?.id; +assert.ok(shotId, "a seeded shot is required for task-link smoke"); +let ownerToken = ""; +let reviewerToken = ""; +let taskId = ""; +let commentId = ""; +let linkId = ""; +try { + const anonymous = await request("/api/project-activity"); + assert.equal(anonymous.response.status, 401, "anonymous activity access must be rejected"); + + ownerToken = await login("producer@local.test"); + reviewerToken = await login("review@local.test"); + + const created = await request("/api/tasks", { + method: "POST", + headers: headers(ownerToken), + body: JSON.stringify({ title: taskTitle, description: "验证商业协作详情、讨论、@通知、镜头关联和活动流", kind: "review", priority: "high", assigneeUserId: "u-review", targetTab: "qa" }) + }); + assert.equal(created.response.status, 201, JSON.stringify(created.payload)); + taskId = created.payload.task.id; + + const detail = await request(`/api/tasks/${encodeURIComponent(taskId)}/detail`, { headers: headers(ownerToken) }); + assert.equal(detail.response.ok, true, JSON.stringify(detail.payload)); + assert.equal(detail.payload.comments.length, 0, "new task should have no comments"); + + const comment = await request(`/api/tasks/${encodeURIComponent(taskId)}/comments`, { + method: "POST", + headers: headers(ownerToken), + body: JSON.stringify({ body: "请 @u-review 在审片中心确认实际末帧证据", mentionUserIds: ["u-review"] }) + }); + assert.equal(comment.response.status, 201, JSON.stringify(comment.payload)); + commentId = comment.payload.comment.id; + assert.equal(comment.payload.comments.length, 1, "comment must be returned in task detail"); + assert.equal(comment.payload.comments[0].mentions[0].id, "u-review", "mention must be resolved to a valid project member"); + + const reviewerNotifications = await request("/api/notifications?limit=200", { headers: headers(reviewerToken) }); + assert.equal(reviewerNotifications.response.ok, true, JSON.stringify(reviewerNotifications.payload)); + assert.ok(reviewerNotifications.payload.notifications.some((item) => item.eventKey === "task.commented" && item.targetId === taskId), "task comment must notify assignee"); + + const linked = await request(`/api/tasks/${encodeURIComponent(taskId)}/links`, { + method: "POST", + headers: headers(ownerToken), + body: JSON.stringify({ linkType: "shot", targetId: shotId }) + }); + assert.equal(linked.response.status, 201, JSON.stringify(linked.payload)); + linkId = linked.payload.link.id; + assert.equal(linked.payload.links.length, 1, "shot link must be returned in task detail"); + assert.equal(linked.payload.links[0].targetId, shotId, "shot link target mismatch"); + + const activity = await request("/api/project-activity?limit=100", { headers: headers(ownerToken) }); + assert.equal(activity.response.ok, true, JSON.stringify(activity.payload)); + assert.ok(activity.payload.activities.some((item) => item.action === "project.task.comment.created" && item.targetId === commentId), "comment must appear in project activity"); + assert.ok(activity.payload.activities.some((item) => item.action === "project.task.link.created" && item.targetId === linkId), "link must appear in project activity"); + + const crossTenant = await request(`/api/tasks/${encodeURIComponent(taskId)}/detail`, { headers: headers(ownerToken, northstarScope) }); + assert.equal(crossTenant.response.status, 404, "cross-organization task detail must be isolated"); + + const removed = await request(`/api/tasks/${encodeURIComponent(taskId)}/links/${encodeURIComponent(linkId)}`, { method: "DELETE", headers: headers(ownerToken) }); + assert.equal(removed.response.ok, true, JSON.stringify(removed.payload)); + assert.equal(removed.payload.links.length, 0, "task link must be removable"); + console.log(`task collaboration smoke passed: task=${taskId}, comment=${commentId}, shot=${shotId}`); +} finally { + if (linkId) dbRun("DELETE FROM task_links WHERE id = ?", [linkId]); + if (commentId) dbRun("DELETE FROM task_comments WHERE id = ?", [commentId]); + if (taskId) dbRun("DELETE FROM project_tasks WHERE id = ?", [taskId]); + if (taskId) dbRun("DELETE FROM user_notifications WHERE target_id = ?", [taskId]); + if (taskId) dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [taskId, `%${taskId}%`]); + if (ownerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${ownerToken}` } }).catch(() => {}); + if (reviewerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${reviewerToken}` } }).catch(() => {}); +} diff --git a/scripts/smoke-tasks.mjs b/scripts/smoke-tasks.mjs new file mode 100644 index 0000000..eae79c8 --- /dev/null +++ b/scripts/smoke-tasks.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function headers(token) { + return { authorization: `Bearer ${token}`, ...scope }; +} + +async function login(email) { + const result = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assert.equal(result.response.ok, true, `${email} login failed`); + return result.payload.session.token; +} + +const taskTitle = `smoke-task-${Date.now()}`; +let ownerToken = ""; +let reviewerToken = ""; +let taskId = ""; +try { + const anonymous = await request("/api/tasks"); + assert.equal(anonymous.response.status, 401, "anonymous task access must be rejected"); + + ownerToken = await login("producer@local.test"); + reviewerToken = await login("review@local.test"); + + const created = await request("/api/tasks", { + method: "POST", + headers: headers(ownerToken), + body: JSON.stringify({ + title: taskTitle, + description: "验证项目任务的负责人、状态、截止时间和审计链路", + kind: "review", + priority: "high", + assigneeUserId: "u-review", + targetTab: "qa", + dueAt: "2026-08-30T23:59:59.000Z" + }) + }); + assert.equal(created.response.status, 201, `task creation failed: ${JSON.stringify(created.payload)}`); + assert.equal(created.payload.task.title, taskTitle, "created task title mismatch"); + assert.equal(created.payload.task.assignee.id, "u-review", "task assignee not persisted"); + assert.equal(created.payload.task.priority, "high", "task priority not persisted"); + taskId = created.payload.task.id; + + const ownerList = await request("/api/tasks?status=all", { headers: headers(ownerToken) }); + assert.equal(ownerList.response.ok, true, "owner task list failed"); + assert.ok(ownerList.payload.tasks.some((task) => task.id === taskId), "owner should see created task"); + assert.ok(ownerList.payload.summary.total >= 1, "task summary must include created task"); + + const reviewerList = await request("/api/tasks?assignedTo=me", { headers: headers(reviewerToken) }); + assert.equal(reviewerList.response.ok, true, "reviewer assigned task list failed"); + assert.ok(reviewerList.payload.tasks.some((task) => task.id === taskId), "assignee should see own task"); + const reviewerNotifications = await request("/api/notifications?limit=200", { headers: headers(reviewerToken) }); + assert.equal(reviewerNotifications.response.ok, true, "reviewer notification list failed"); + assert.ok(reviewerNotifications.payload.notifications.some((notification) => notification.eventKey === "task.assigned" && notification.targetId === taskId), "task assignment must create an in-app notification"); + + const reviewerUpdate = await request(`/api/tasks/${encodeURIComponent(taskId)}`, { + method: "PATCH", + headers: headers(reviewerToken), + body: JSON.stringify({ status: "in_progress" }) + }); + assert.equal(reviewerUpdate.response.ok, true, "assignee status update failed"); + assert.equal(reviewerUpdate.payload.task.status, "in_progress", "assignee status was not saved"); + + const reviewerEscalation = await request(`/api/tasks/${encodeURIComponent(taskId)}`, { + method: "PATCH", + headers: headers(reviewerToken), + body: JSON.stringify({ title: "越权修改标题" }) + }); + assert.equal(reviewerEscalation.response.status, 403, "assignee must not edit task metadata"); + + const ownerUpdate = await request(`/api/tasks/${encodeURIComponent(taskId)}`, { + method: "PATCH", + headers: headers(ownerToken), + body: JSON.stringify({ status: "done", priority: "medium" }) + }); + assert.equal(ownerUpdate.response.ok, true, "manager task update failed"); + assert.equal(ownerUpdate.payload.task.status, "done", "manager status update was not saved"); + assert.ok(ownerUpdate.payload.task.completedAt, "completed task must have completedAt"); + + const northstar = await request("/api/tasks", { + headers: { authorization: `Bearer ${ownerToken}`, "x-organization-id": "org-northstar", "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" } + }); + assert.equal(northstar.response.ok, true, "northstar task scope request failed"); + assert.ok(northstar.payload.tasks.every((task) => task.id !== taskId), "cross-organization task must not leak"); + console.log(`tasks smoke passed: ${taskId}`); +} finally { + if (taskId) dbRun("DELETE FROM project_tasks WHERE id = ?", [taskId]); + if (taskId) { + dbRun("DELETE FROM user_notifications WHERE target_id = ?", [taskId]); + dbRun("DELETE FROM audit_logs WHERE target_type = 'project_task' AND target_id = ?", [taskId]); + } + if (ownerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${ownerToken}` } }).catch(() => {}); + if (reviewerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${reviewerToken}` } }).catch(() => {}); +} diff --git a/scripts/smoke-tenant.mjs b/scripts/smoke-tenant.mjs new file mode 100644 index 0000000..83ad557 --- /dev/null +++ b/scripts/smoke-tenant.mjs @@ -0,0 +1,197 @@ +const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scopeHeaders = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, headers = ownerHeaders, init = {}) { + const response = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } }); + const payload = await response.json(); + return { response, payload }; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function login(email) { + const result = await request("/api/auth/login", {}, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: "Demo@123456" }) + }); + assert(result.response.ok, `${email} 登录失败`); + assert(result.payload.user?.id && result.payload.user.id !== result.payload.session.id, `${email} 登录响应不能把 session id 当作 user id`); + assert(result.payload.user.id === result.payload.context.currentUser.id, `${email} 登录响应 user 与 context.currentUser 不一致`); + return { authorization: `Bearer ${result.payload.session.token}` }; +} + +const ownerAuth = await login("producer@local.test"); +const writerAuth = await login("writer@local.test"); +const ownerHeaders = { ...ownerAuth, ...scopeHeaders }; + +const sessionTestAuth = await login("producer2@local.test"); +const sessionTestHeaders = { ...sessionTestAuth }; +const ownerSessions = await request("/api/auth/sessions", sessionTestHeaders); +assert(ownerSessions.response.ok && ownerSessions.payload.sessions.some((session) => session.current), "当前用户必须能看到当前登录会话"); +const revokedOthers = await request("/api/auth/sessions/revoke-others", sessionTestHeaders, { method: "POST", body: "{}", headers: { "content-type": "application/json" } }); +assert(revokedOthers.response.ok && revokedOthers.payload.sessions.some((session) => session.current), "撤销其他会话后当前会话必须保持有效"); + +const context = await request("/api/context", {}); +assert(context.response.status === 401, "未登录请求不应访问 context"); + +const authenticatedContext = await request("/api/context", ownerHeaders); +assert(authenticatedContext.response.ok, "登录后的 context 不可访问"); +assert(authenticatedContext.payload.platform.organizations.length >= 2, "没有多组织数据"); +assert(authenticatedContext.payload.platform.workspaces.length >= 2, "没有多工作区数据"); +assert(authenticatedContext.payload.context.permissions.includes("job:create"), "所有者缺少生成任务权限"); +assert(authenticatedContext.payload.context.systemAdmin === true, "系统管理员身份未生效"); +assert(authenticatedContext.payload.rolePermissions.some((role) => role.name === "组织所有者" && role.permissions.includes("organization:manage")), "管理员必须能读取服务端角色权限矩阵"); + +const ownerRolePolicies = await request("/api/organizations/org-studio-lab/role-policies", ownerHeaders); +assert(ownerRolePolicies.response.ok, "组织管理员无法读取角色权限策略"); +assert(ownerRolePolicies.payload.roles.some((role) => role.key === "writer"), "角色策略目录缺少编剧角色"); +assert(ownerRolePolicies.payload.permissions.some((permission) => permission.key === "voice:approve"), "角色策略目录缺少声音审批权限"); +const writerRolePolicy = ownerRolePolicies.payload.roles.find((role) => role.key === "writer"); +const policyPermissionKey = "voice:approve"; +const originalWriterPermission = writerRolePolicy.permissions.includes(policyPermissionKey); +const writerRolePolicies = await request("/api/organizations/org-studio-lab/role-policies", { ...writerAuth, ...scopeHeaders }); +assert(writerRolePolicies.response.status === 403, "普通用户不应读取组织角色权限策略"); +try { + const grantedWriterPolicy = await request("/api/organizations/org-studio-lab/role-policies/writer", ownerHeaders, { + method: "PATCH", + body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: true }), + headers: { "content-type": "application/json" } + }); + assert(grantedWriterPolicy.response.ok && grantedWriterPolicy.payload.roles.find((role) => role.key === "writer")?.permissions.includes(policyPermissionKey), "管理员授予组织角色权限失败"); + const writerAfterGrant = await request("/api/context", { ...writerAuth, ...scopeHeaders }); + assert(writerAfterGrant.response.ok && writerAfterGrant.payload.context.permissions.includes(policyPermissionKey), "组织角色授权没有进入普通用户有效权限"); + + const revokedWriterPolicy = await request("/api/organizations/org-studio-lab/role-policies/writer", ownerHeaders, { + method: "PATCH", + body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: false }), + headers: { "content-type": "application/json" } + }); + assert(revokedWriterPolicy.response.ok && !revokedWriterPolicy.payload.roles.find((role) => role.key === "writer")?.permissions.includes(policyPermissionKey), "管理员撤销组织角色权限失败"); + const writerAfterRevoke = await request("/api/context", { ...writerAuth, ...scopeHeaders }); + assert(writerAfterRevoke.response.ok && !writerAfterRevoke.payload.context.permissions.includes(policyPermissionKey), "组织角色撤销没有从普通用户有效权限移除"); +} finally { + await request("/api/organizations/org-studio-lab/role-policies/writer", ownerHeaders, { + method: "PATCH", + body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: originalWriterPermission }), + headers: { "content-type": "application/json" } + }); +} +const lockedOwnerPolicy = await request("/api/organizations/org-studio-lab/role-policies/org_owner", ownerHeaders, { + method: "PATCH", + body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: false }), + headers: { "content-type": "application/json" } +}); +assert(lockedOwnerPolicy.response.status === 400 && lockedOwnerPolicy.payload.error === "owner_policy_locked", "组织所有者策略必须保持锁定"); + +const systemConfig = await request("/api/system/config", ownerHeaders); +assert(systemConfig.response.ok, "系统管理员无法访问系统配置"); + +const ownerModels = await request("/api/platform/models", ownerHeaders); +assert(ownerModels.response.ok, "系统管理员无法访问模型中台"); +const ownerCosts = await request("/api/platform/costs", ownerHeaders); +assert(ownerCosts.response.ok, "系统管理员无法访问成本中心"); +const ownerCompliance = await request("/api/platform/compliance", ownerHeaders); +assert(ownerCompliance.response.ok, "系统管理员无法访问合规中心"); +const ownerOrganizationPath = await request("/api/organizations/org-northstar", ownerHeaders); +assert(ownerOrganizationPath.response.ok && ownerOrganizationPath.payload.organization.id === "org-northstar", "组织路径没有优先于旧上下文头"); + +const writerContext = await request("/api/context", { ...writerAuth, ...scopeHeaders }); +assert(writerContext.response.ok, "普通用户 context 不可访问"); +assert(!writerContext.payload.context.permissions.includes("system:settings:view"), "普通用户不应拥有系统配置权限"); +assert(writerContext.payload.platform.members.length === 0, "普通用户不应收到组织成员明细"); +assert(writerContext.payload.platform.workspaceMembers.length === 0, "普通用户不应收到工作区成员明细"); +assert(writerContext.payload.platform.projectMembers.length === 0, "普通用户不应收到项目成员明细"); +assert(writerContext.payload.platform.invitations.length === 0, "普通用户不应收到组织邀请明细"); +assert(writerContext.payload.platform.billing === null, "普通用户不应收到计费账户"); +assert(writerContext.payload.platform.usage === null, "普通用户不应收到用量明细"); +assert(writerContext.payload.platform.auditLog.length === 0, "普通用户不应收到审计日志"); +assert(writerContext.payload.platform.modelRegistry.length === 0, "普通用户不应收到模型连接器明细"); +assert(writerContext.payload.platform.adapterCatalog.some((model) => model.id === "owned-i2v"), "有生成权限的普通用户必须收到可用适配器目录"); +assert(writerContext.payload.platform.adapterCatalog.find((model) => model.id === "newapi-audio-production")?.approvalRequired === true, "混合音频连接器必须携带审批标记"); +assert(writerContext.payload.platform.adapterCatalog.every((model) => !Object.prototype.hasOwnProperty.call(model, "endpoint")), "普通用户的适配器目录不应暴露连接器 Endpoint"); +assert(writerContext.payload.platform.runnerHealth?.length === 0, "普通用户不应收到 Runner 健康明细"); +const writerSystem = await request("/api/system/config", { ...writerAuth, ...scopeHeaders }); +assert(writerSystem.response.status === 403, "普通用户不应访问系统配置"); +const writerProjectPayload = await request("/api/project", { ...writerAuth, ...scopeHeaders }); +assert(writerProjectPayload.response.ok, "普通用户应能读取当前项目生产数据"); +assert((writerProjectPayload.payload.adapters || []).every((adapter) => !Object.prototype.hasOwnProperty.call(adapter, "endpoint") && !Object.prototype.hasOwnProperty.call(adapter, "baseUrl")), "项目接口不应向普通用户泄露连接器地址"); +const writerModels = await request("/api/platform/models", { ...writerAuth, ...scopeHeaders }); +assert(writerModels.response.status === 403, "普通用户不应访问模型中台"); +const writerCosts = await request("/api/platform/costs", { ...writerAuth, ...scopeHeaders }); +assert(writerCosts.response.status === 403, "普通用户不应访问成本中心"); +const writerCompliance = await request("/api/platform/compliance", { ...writerAuth, ...scopeHeaders }); +assert(writerCompliance.response.status === 403, "普通用户不应访问合规中心"); +const writerMembers = await request("/api/organizations/org-studio-lab/members", { ...writerAuth, ...scopeHeaders }); +assert(writerMembers.response.status === 403, "普通用户不应访问组织成员接口"); +const writerQa = await request("/api/qa", { ...writerAuth, ...scopeHeaders }); +assert(writerQa.response.status === 403, "普通用户不应执行审片接口"); +const writerCreateOrganization = await request("/api/organizations", { ...writerAuth, ...scopeHeaders }, { method: "POST", body: JSON.stringify({ name: "blocked-org" }), headers: { "content-type": "application/json" } }); +assert(writerCreateOrganization.response.status === 403, "普通用户不应创建组织"); + +const apiClientCreated = await request("/api/system/api-clients", ownerHeaders, { + method: "POST", + body: JSON.stringify({ name: `smoke-runner-${Date.now()}`, scopes: ["jobs:read", "jobs:write"] }), + headers: { "content-type": "application/json" } +}); +assert(apiClientCreated.response.status === 201 && apiClientCreated.payload.clientKey, "系统 API 客户端必须返回一次性 client key"); +const apiClientHeaders = { authorization: `Bearer ${apiClientCreated.payload.clientKey}`, ...scopeHeaders }; +const apiClientContext = await request("/api/context", apiClientHeaders); +assert(apiClientContext.response.ok && apiClientContext.payload.context.systemAdmin === false, "API 客户端不应继承系统管理员身份"); +assert(apiClientContext.payload.context.permissions.includes("job:create"), "API 客户端 jobs scope 未映射为生成权限"); +const apiClientSystem = await request("/api/system/config", apiClientHeaders); +assert(apiClientSystem.response.status === 403, "API 客户端不应访问系统配置"); +const revokeApiClient = await request(`/api/system/api-clients/${apiClientCreated.payload.client.id}`, ownerHeaders, { + method: "PATCH", + body: JSON.stringify({ status: "revoked" }), + headers: { "content-type": "application/json" } +}); +assert(revokeApiClient.response.ok, "API 客户端撤销失败"); +const revokedApiClient = await request("/api/context", apiClientHeaders); +assert(revokedApiClient.response.status === 401, "撤销 API 客户端后必须返回 401"); +const externalJob = await request("/api/jobs", { ...writerAuth, ...scopeHeaders }, { method: "POST", body: JSON.stringify({ adapter: "newapi-audio-production", kind: "smoke external gate", shotId: "shot-01", output: "qa/smoke/external-gate.json" }), headers: { "content-type": "application/json" } }); +assert(externalJob.response.status === 403 && externalJob.payload.error === "external_connector_requires_approval", "混合连接器未批准时必须由后端阻断"); + +const orgAdminAuth = await login("producer2@local.test"); +const orgAdminHeaders = { ...orgAdminAuth, "x-organization-id": "org-northstar", "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" }; +const orgAdminContext = await request("/api/context", orgAdminHeaders); +assert(orgAdminContext.response.ok, "组织管理员 context 不可访问"); +assert(orgAdminContext.payload.context.systemAdmin === false, "组织管理员不应被识别为系统管理员"); +assert(orgAdminContext.payload.context.permissions.includes("model:manage"), "组织管理员缺少模型管理权限"); +const orgAdminModels = await request("/api/platform/models", orgAdminHeaders); +assert(orgAdminModels.response.ok, "组织管理员无法访问本组织模型中台"); +const orgAdminQueue = await request("/api/admin/queue", orgAdminHeaders); +assert(orgAdminQueue.response.ok, "组织管理员无法访问本组织任务队列"); +const orgAdminSystem = await request("/api/system/config", orgAdminHeaders); +assert(orgAdminSystem.response.status === 403, "组织管理员不应访问系统配置"); + +const northstar = await request("/api/context", { + ...ownerAuth, + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main", + "x-project-id": "northstar-pilot" +}); +assert(northstar.response.ok, "组织切换失败"); +assert(northstar.payload.context.currentOrganization.id === "org-northstar", "组织 scope 没有切换"); +assert(northstar.payload.platform.modelRegistry.every((model) => model.id === "northstar-image"), "模型没有按组织隔离"); + +const writerInvite = await request("/api/organizations/org-studio-lab/invitations", { + ...writerAuth, + ...scopeHeaders +}, { method: "POST", body: JSON.stringify({ email: "blocked-smoke@local.test", roleKey: "writer" }), headers: { "content-type": "application/json" } }); +assert(writerInvite.response.status === 403 && writerInvite.payload.error === "permission_denied", "编剧越权邀请未被拒绝"); + +const crossOrg = await request("/api/context", { + ...writerAuth, + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main" +}); +assert(crossOrg.response.status === 403 && crossOrg.payload.error === "organization_forbidden", "跨组织访问未被拒绝"); + +console.log(`tenant smoke passed: ${base}`); diff --git a/scripts/smoke-work-items.mjs b/scripts/smoke-work-items.mjs new file mode 100644 index 0000000..1b7edf8 --- /dev/null +++ b/scripts/smoke-work-items.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const localScope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; +const northstarScope = { + "x-organization-id": "org-northstar", + "x-workspace-id": "ws-northstar-main", + "x-project-id": "northstar-pilot" +}; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +async function login(email) { + const result = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password: "Demo@123456" }) + }), `${email} login`); + assert.ok(result.session?.token, `${email} login must return a session token`); + return result.session.token; +} + +function scopedHeaders(token, scope) { + return { authorization: `Bearer ${token}`, ...scope }; +} + +function assertContract(payload, label) { + assert.ok(Array.isArray(payload.items), `${label} must return items[]`); + assert.ok(payload.summary && typeof payload.summary.total === "number", `${label} must return summary.total`); + assert.equal(payload.summary.total, payload.items.length, `${label} summary total must match visible items`); + for (const item of payload.items) { + assert.ok(item.id && item.kind && item.title && item.targetTab, `${label} work item must have identity and navigation`); + assert.ok(["high", "medium", "low"].includes(item.priority), `${label} work item priority must be normalized`); + assert.ok(item.updatedAt, `${label} work item must expose updatedAt`); + } +} + +const tokens = []; +try { + const anonymous = await request("/api/work-items"); + assert.equal(anonymous.response.status, 401, "anonymous work-items access must be rejected"); + + const ownerToken = await login("producer@local.test"); + tokens.push(ownerToken); + const ownerPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(ownerToken, localScope) }), "owner local work items"); + assertContract(ownerPayload, "owner local"); + assert.equal(ownerPayload.scope.organizationId, localScope["x-organization-id"], "owner scope must expose the requested organization"); + assert.equal(ownerPayload.scope.workspaceId, localScope["x-workspace-id"], "owner scope must expose the requested workspace"); + assert.equal(ownerPayload.scope.projectId, localScope["x-project-id"], "owner scope must expose the requested project"); + assert.ok(ownerPayload.items.some((item) => item.kind === "review"), "owner should see current-project review work items"); + assert.ok(ownerPayload.items.some((item) => item.kind === "job"), "owner should see current-project generation work items"); + assert.ok(!JSON.stringify(ownerPayload).includes("endpoint"), "work-items must not leak model endpoint data"); + + const limited = expectOk(await request("/api/work-items?limit=1", { headers: scopedHeaders(ownerToken, localScope) }), "limited work items"); + assert.equal(limited.items.length, 1, "work-items limit must be enforced"); + + const writerToken = await login("writer@local.test"); + tokens.push(writerToken); + const writerPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(writerToken, localScope) }), "writer local work items"); + assertContract(writerPayload, "writer local"); + assert.ok(writerPayload.items.some((item) => item.kind === "job"), "writer should see production jobs it can operate"); + assert.ok(writerPayload.items.every((item) => ["job", "asset", "review", "invitation"].includes(item.kind)), "writer must not see delivery, billing, audit, or model work items"); + assert.ok(!JSON.stringify(writerPayload).includes("billing"), "writer payload must not include billing data"); + + const reviewerToken = await login("review@local.test"); + tokens.push(reviewerToken); + const reviewerPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(reviewerToken, localScope) }), "reviewer local work items"); + assertContract(reviewerPayload, "reviewer local"); + assert.ok(reviewerPayload.items.some((item) => item.kind === "review"), "reviewer should see QA work items"); + assert.ok(reviewerPayload.items.every((item) => ["review", "asset", "delivery", "invitation"].includes(item.kind)), "reviewer must not see generation or model work items"); + + const northstarPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(ownerToken, northstarScope) }), "owner northstar work items"); + assertContract(northstarPayload, "owner northstar"); + assert.ok(northstarPayload.items.some((item) => item.metadata?.shotId === "shot-northstar-pilot-001" || item.metadata?.deliveryId), "northstar scope should expose northstar production records"); + assert.ok(northstarPayload.items.every((item) => !String(item.metadata?.shotId || "").startsWith("shot-01")), "northstar scope must not include local project shots"); + + const localAgain = expectOk(await request("/api/work-items", { headers: scopedHeaders(ownerToken, localScope) }), "owner local work items after scope switch"); + assert.ok(localAgain.items.every((item) => !String(item.metadata?.shotId || "").startsWith("shot-northstar")), "switching back must not retain northstar work items"); + console.log(`work-items smoke passed: owner=${ownerPayload.items.length}, writer=${writerPayload.items.length}, reviewer=${reviewerPayload.items.length}, northstar=${northstarPayload.items.length}`); +} finally { + for (const token of tokens) { + await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${token}` } }).catch(() => {}); + } +} diff --git a/scripts/smoke-worker.mjs b/scripts/smoke-worker.mjs new file mode 100644 index 0000000..a00d0bd --- /dev/null +++ b/scripts/smoke-worker.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { dbRun, withTransaction } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const runnerPort = Number(process.env.AI_DRAMA_SMOKE_RUNNER_PORT || 8791); +const scope = { + "x-organization-id": "org-studio-lab", + "x-workspace-id": "ws-local-aidrama", + "x-project-id": "thunder-mouth" +}; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +const runnerRequests = []; +const runner = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = Buffer.concat(chunks).toString("utf8"); + runnerRequests.push({ method: req.method, url: req.url, body: body ? JSON.parse(body) : null }); + res.writeHead(200, { "content-type": "application/json" }); + if (req.url === "/v1/images/generations") { + res.end(JSON.stringify({ created: Date.now(), data: [{ b64_json: "smoke-image" }] })); + return; + } + res.end(JSON.stringify({ outputPath: "storage/jobs/smoke-custom/output.json", data: [{ id: "single-output" }] })); +}); + +await new Promise((resolve) => runner.listen(runnerPort, "127.0.0.1", resolve)); +let customModelId = ""; +let openAiModelId = ""; +let jobIds = []; + +try { + const login = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) + }); + assert.equal(login.response.ok, true, "worker smoke login failed"); + const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope }; + + const customModel = await request("/api/platform/models/register", { + method: "POST", + headers, + body: JSON.stringify({ + label: `Smoke Custom Runner ${Date.now()}`, + endpoint: `http://127.0.0.1:${runnerPort}/custom/generate`, + kind: "http-json", + capability: ["text-to-image", "single-frame"], + costMode: "local", + protocol: { healthRoute: "custom/health" } + }) + }); + assert.equal(customModel.response.status, 201, "custom runner registration failed"); + customModelId = customModel.payload.model.id; + await request(`/api/platform/models/${encodeURIComponent(customModelId)}`, { + method: "PATCH", + headers, + body: JSON.stringify({ status: "ready" }) + }); + + const openAiModel = await request("/api/platform/models/register", { + method: "POST", + headers, + body: JSON.stringify({ + label: `Smoke OpenAI Runner ${Date.now()}`, + endpoint: `http://127.0.0.1:${runnerPort}/v1`, + kind: "openai-compatible", + capability: ["text-to-image", "single-frame"], + costMode: "local", + protocol: { models: { image: "smoke-image" }, routes: { image: "images/generations" } } + }) + }); + assert.equal(openAiModel.response.status, 201, "OpenAI-compatible runner registration failed"); + openAiModelId = openAiModel.payload.model.id; + await request(`/api/platform/models/${encodeURIComponent(openAiModelId)}`, { + method: "PATCH", + headers, + body: JSON.stringify({ status: "ready" }) + }); + + const customJob = await request("/api/jobs", { + method: "POST", + headers, + body: JSON.stringify({ adapter: customModelId, kind: "自定义单画面关键帧", shotId: "shot-01", output: "storage/jobs/smoke-custom/output.json" }) + }); + assert.equal(customJob.response.status, 201, "custom job creation failed"); + assert.equal(customJob.payload.job.status, "queued", "custom job should be queued for local Worker"); + + const openAiJob = await request("/api/jobs", { + method: "POST", + headers, + body: JSON.stringify({ adapter: openAiModelId, kind: "OpenAI-compatible 单画面关键帧", shotId: "shot-01", output: "storage/jobs/smoke-openai/output.png" }) + }); + assert.equal(openAiJob.response.status, 201, "OpenAI-compatible job creation failed"); + assert.equal(openAiJob.payload.job.status, "queued", "OpenAI-compatible job should be queued"); + jobIds = [customJob.payload.job.id, openAiJob.payload.job.id]; + + const deadline = Date.now() + 12_000; + const completed = new Map(); + while (Date.now() < deadline && completed.size < jobIds.length) { + for (const jobId of jobIds) { + const result = await request(`/api/jobs/${encodeURIComponent(jobId)}`, { headers }); + assert.equal(result.response.ok, true, `job ${jobId} detail request failed`); + if (result.payload.job.status === "completed") completed.set(jobId, result.payload.job); + if (result.payload.job.status === "failed") throw new Error(`${jobId} failed: ${result.payload.job.errorMessage}`); + } + if (completed.size < jobIds.length) await new Promise((resolve) => setTimeout(resolve, 400)); + } + assert.equal(completed.size, jobIds.length, "local Worker did not complete all smoke jobs in time"); + assert.ok(completed.get(customJob.payload.job.id).result?.data?.length === 1, "custom protocol must preserve exactly one image output"); + assert.ok(completed.get(openAiJob.payload.job.id).result?.data?.length === 1, "OpenAI-compatible protocol must preserve exactly one image output"); + assert.ok(runnerRequests.some((item) => item.url === "/v1/images/generations"), "OpenAI-compatible route was not called"); + assert.ok(runnerRequests.some((item) => item.url === "/custom/generate"), "custom JSON route was not called"); + assert.ok(completed.get(customJob.payload.job.id).leased_by == null, "completed job lease must be released"); + console.log(`worker smoke passed: ${jobIds.length} jobs completed through local protocols`); +} finally { + await new Promise((resolve) => runner.close(resolve)); + if (customModelId || openAiModelId || jobIds.length) { + withTransaction(() => { + for (const jobId of jobIds) { + dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]); + dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]); + dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]); + dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]); + dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]); + } + for (const modelId of [customModelId, openAiModelId].filter(Boolean)) { + dbRun("DELETE FROM model_connectors WHERE id = ?", [modelId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [modelId]); + } + }); + } +} diff --git a/scripts/write-sample-exports.mjs b/scripts/write-sample-exports.mjs new file mode 100644 index 0000000..875c495 --- /dev/null +++ b/scripts/write-sample-exports.mjs @@ -0,0 +1,42 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { sampleProject } from "../src/data/sampleProject.js"; +import { + buildEditList, + buildPromptPack, + buildShotList, + buildVoiceTable +} from "../src/lib/exporters.js"; +import { projectQa } from "../src/lib/qa.js"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const out = resolve(root, "exports/project-template"); + +const files = [ + ["bible/series-bible.json", sampleProject.series], + ["shots/shot-list.json", buildShotList(sampleProject)], + ["shots/prompt-pack.json", buildPromptPack(sampleProject)], + ["voices/voice-lines.json", buildVoiceTable(sampleProject)], + ["qa/qa-results.json", projectQa(sampleProject)], + ["edit/edit-list.json", buildEditList(sampleProject)], + ["characters/character-locks.json", sampleProject.characters], + ["locations/location-locks.json", sampleProject.locations], + ["props/prop-locks.json", sampleProject.props], + ["final-video/delivery-manifest.json", { + schema: "ai-drama-platform.delivery-manifest.v1", + episodeId: sampleProject.episode.id, + expectedFinal: "final-video/E01_别往树下跑_master.mp4", + sourceEditList: "edit/edit-list.json", + subtitles: "edit/E01_别往树下跑.zh-CN.srt", + audioPolicy: "固定 TTS 声线替换随机原生音轨;环境声可独立保留。" + }] +]; + +for (const [name, value] of files) { + const target = resolve(out, name); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +console.log(`sample exports written to ${out}`); diff --git a/server/api-client-secrets.mjs b/server/api-client-secrets.mjs new file mode 100644 index 0000000..2703813 --- /dev/null +++ b/server/api-client-secrets.mjs @@ -0,0 +1,25 @@ +import { createHash, randomBytes } from "node:crypto"; + +const KEY_PREFIX = "local-"; + +export function hashApiClientKey(value) { + return createHash("sha256").update(String(value || "")).digest("hex"); +} + +export function issueApiClientKey() { + const secret = `${KEY_PREFIX}${randomBytes(24).toString("base64url")}`; + return { + secret, + hash: hashApiClientKey(secret), + prefix: secret.slice(0, 12) + }; +} + +export function apiClientStorageMarker(hash) { + return `hash:${String(hash || "")}`; +} + +export function apiClientPreview(prefix) { + const value = String(prefix || ""); + return value ? `${value}****` : "仅创建或轮换时显示一次"; +} diff --git a/server/auth.mjs b/server/auth.mjs new file mode 100644 index 0000000..24962ca --- /dev/null +++ b/server/auth.mjs @@ -0,0 +1,760 @@ +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import { createPasswordRecord, dbAll, dbGet, dbRun, passwordHash } from "./db.mjs"; +import { hashApiClientKey } from "./api-client-secrets.mjs"; + +const SESSION_TTL_MS = 8 * 60 * 60 * 1000; +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_MS = 15 * 60 * 1000; +const MFA_STEP_MS = 30 * 1000; +const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; +const MFA_MAX_CHALLENGE_ATTEMPTS = 5; +const MFA_STORAGE_KEY = scryptSync(process.env.AI_DRAMA_MFA_ENCRYPTION_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-mfa-key-v1", "ai-drama-mfa", 32); +const DEVICE_HASH_KEY = process.env.AI_DRAMA_DEVICE_HASH_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-device-key-v1"; +const MAX_DEVICE_ID_LENGTH = 256; +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function authError(status, code, message, details = {}) { + const error = new Error(message); + error.status = status; + error.code = code; + error.details = details; + return error; +} + +function nowIso() { + return new Date().toISOString(); +} + +function boundedText(value, maxLength) { + return String(value || "").slice(0, maxLength); +} + +function requestMetadata(metadata = {}) { + return { + ipAddress: boundedText(metadata.ipAddress, 128), + userAgent: boundedText(metadata.userAgent, 512), + deviceId: boundedText(metadata.deviceId, MAX_DEVICE_ID_LENGTH), + deviceLabel: boundedText(metadata.deviceLabel, 120) + }; +} + +function hashDeviceValue(value) { + return createHmac("sha256", DEVICE_HASH_KEY).update(String(value || "")).digest("hex"); +} + +function normalizedDeviceId(value) { + const candidate = String(value || "").trim(); + return candidate.length >= 8 && candidate.length <= MAX_DEVICE_ID_LENGTH ? candidate : ""; +} + +function deviceFingerprint(metadata = {}) { + const request = requestMetadata(metadata); + return hashDeviceValue(request.userAgent); +} + +function riskLevelForScore(score) { + if (score >= 75) return "high"; + if (score >= 45) return "medium"; + return "low"; +} + +function devicePayload(row) { + if (!row?.auth_device_id && !row?.device_id) return null; + const trusted = Boolean(row.trusted_at); + const revoked = Boolean(row.revoked_at); + return { + id: row.auth_device_id || row.device_id, + label: row.device_label || row.label || "浏览器设备", + firstSeenAt: row.device_first_seen_at || row.first_seen_at || null, + lastSeenAt: row.device_last_seen_at || row.last_seen_at || null, + lastIpAddress: row.device_last_ip_address || row.last_ip_address || "", + userAgent: row.device_last_user_agent || row.last_user_agent || "", + trustedAt: row.trusted_at || null, + revokedAt: row.revoked_at || null, + status: revoked ? "revoked" : trusted ? "trusted" : "known", + activeSessionCount: Number(row.active_session_count || 0), + latestRiskLevel: row.latest_risk_level || "medium", + latestRiskScore: Number(row.latest_risk_score ?? 50) + }; +} + +function registerAuthDevice(userId, metadata = {}) { + const request = requestMetadata(metadata); + const rawDeviceId = normalizedDeviceId(request.deviceId); + if (!rawDeviceId) { + return { + device: null, + riskLevel: "high", + riskScore: 85, + firstSeen: false, + ipChanged: false, + fingerprintChanged: false + }; + } + + const deviceKeyHash = hashDeviceValue(rawDeviceId); + const fingerprintHash = deviceFingerprint(request); + const existing = dbGet("SELECT * FROM auth_devices WHERE user_id = ? AND device_key_hash = ?", [userId, deviceKeyHash]); + const timestamp = nowIso(); + const ipChanged = Boolean(existing?.last_ip_address && request.ipAddress && existing.last_ip_address !== request.ipAddress); + const fingerprintChanged = Boolean(existing?.fingerprint_hash && existing.fingerprint_hash !== fingerprintHash); + const firstSeen = !existing; + let device; + + if (existing) { + dbRun( + "UPDATE auth_devices SET label = ?, fingerprint_hash = ?, last_seen_at = ?, last_ip_address = ?, last_user_agent = ?, updated_at = ? WHERE id = ? AND user_id = ?", + [request.deviceLabel || existing.label || "浏览器设备", fingerprintHash, timestamp, request.ipAddress, request.userAgent, timestamp, existing.id, userId] + ); + device = dbGet("SELECT * FROM auth_devices WHERE id = ?", [existing.id]); + } else { + const id = `device-${Date.now()}-${randomBytes(4).toString("hex")}`; + dbRun( + "INSERT INTO auth_devices(id, user_id, device_key_hash, fingerprint_hash, label, first_seen_at, last_seen_at, last_ip_address, last_user_agent, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [id, userId, deviceKeyHash, fingerprintHash, request.deviceLabel || "浏览器设备", timestamp, timestamp, request.ipAddress, request.userAgent, timestamp, timestamp] + ); + device = dbGet("SELECT * FROM auth_devices WHERE id = ?", [id]); + } + + let riskScore = firstSeen ? 75 : device.trusted_at ? 10 : 50; + if (device.revoked_at) riskScore = 90; + if (ipChanged) riskScore += 20; + if (fingerprintChanged) riskScore += 15; + riskScore = Math.min(100, riskScore); + + if (firstSeen) { + recordSecurityEvent({ + userId, + eventType: "device.first_seen", + result: "success", + ...request, + metadata: { deviceRecordId: device.id, label: device.label, riskLevel: riskLevelForScore(riskScore), riskScore } + }); + } + + return { + device, + riskLevel: riskLevelForScore(riskScore), + riskScore, + firstSeen, + ipChanged, + fingerprintChanged + }; +} + +function safeSecurityMetadata(metadata = {}) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return {}; + return Object.fromEntries(Object.entries(metadata).filter(([key]) => !/(password|passcode|otp|code|token|secret|api.?key)/i.test(key))); +} + +export function recordSecurityEvent({ userId = null, eventType, result = "success", ipAddress = "", userAgent = "", metadata = {} } = {}) { + if (!eventType) return null; + const timestamp = nowIso(); + const id = `sec-${Date.now()}-${randomBytes(5).toString("hex")}`; + try { + dbRun( + "INSERT INTO auth_security_events(id, user_id, event_type, result, ip_address, user_agent, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [id, userId || null, String(eventType), String(result || "success"), boundedText(ipAddress, 128), boundedText(userAgent, 512), JSON.stringify(safeSecurityMetadata(metadata)), timestamp] + ); + return id; + } catch { + return null; + } +} + +function parseSecurityMetadata(value) { + try { + return JSON.parse(value || "{}"); + } catch { + return {}; + } +} + +export function listSecurityEvents(userId = null, options = {}) { + const limit = Math.min(200, Math.max(1, Number(options.limit || 80))); + const eventType = String(options.eventType || "").trim(); + const clauses = []; + const params = []; + if (userId) { + clauses.push("e.user_id = ?"); + params.push(userId); + } + if (eventType) { + clauses.push("e.event_type = ?"); + params.push(eventType); + } + const rows = dbAll( + `SELECT e.id, e.user_id, e.event_type, e.result, e.ip_address, e.user_agent, e.metadata_json, e.created_at, + u.display_name AS user_display_name, u.email AS user_email + FROM auth_security_events e + LEFT JOIN users u ON u.id = e.user_id + ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY e.created_at DESC LIMIT ?`, + [...params, limit] + ); + return rows.map((row) => ({ + id: row.id, + userId: row.user_id, + userDisplayName: row.user_display_name || "", + userEmail: row.user_email || "", + eventType: row.event_type, + result: row.result, + ipAddress: row.ip_address || "", + userAgent: row.user_agent || "", + metadata: parseSecurityMetadata(row.metadata_json), + createdAt: row.created_at + })); +} + +function hashToken(token) { + return createHash("sha256").update(token).digest("hex"); +} + +function readToken(headers) { + const authorization = headers.authorization || headers.Authorization; + if (authorization && /^Bearer\s+/i.test(authorization)) return authorization.replace(/^Bearer\s+/i, "").trim(); + const sessionToken = headers["x-session-token"]; + return Array.isArray(sessionToken) ? sessionToken[0] : sessionToken; +} + +export function safeUser(user) { + if (!user) return null; + return { + id: user.id, + display_name: user.display_name, + email: user.email, + avatar_color: user.avatar_color, + status: user.status + }; +} + +function encodeBase32(buffer) { + let value = 0; + let bits = 0; + let output = ""; + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + output += BASE32_ALPHABET[(value >> bits) & 31]; + } + } + if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + return output; +} + +function decodeBase32(value) { + const normalized = String(value || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, ""); + let bits = 0; + let buffer = 0; + const output = []; + for (const character of normalized) { + buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character); + bits += 5; + if (bits >= 8) { + bits -= 8; + output.push((buffer >> bits) & 0xff); + } + } + return Buffer.from(output); +} + +function encryptMfaSecret(secret) { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", MFA_STORAGE_KEY, iv); + const encrypted = Buffer.concat([cipher.update(String(secret), "utf8"), cipher.final()]); + return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join("."); +} + +function decryptMfaSecret(payload) { + const [ivValue, tagValue, encryptedValue] = String(payload || "").split("."); + if (!ivValue || !tagValue || !encryptedValue) throw authError(500, "mfa_secret_invalid", "MFA 密钥存储记录无效"); + const decipher = createDecipheriv("aes-256-gcm", MFA_STORAGE_KEY, Buffer.from(ivValue, "base64url")); + decipher.setAuthTag(Buffer.from(tagValue, "base64url")); + return Buffer.concat([decipher.update(Buffer.from(encryptedValue, "base64url")), decipher.final()]).toString("utf8"); +} + +function hotp(secret, counter) { + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); + return String(value % 1000000).padStart(6, "0"); +} + +function validTotp(secret, code, timestamp = Date.now()) { + const normalizedCode = String(code || "").replace(/\s/g, ""); + if (!/^\d{6}$/.test(normalizedCode)) return false; + const counter = Math.floor(timestamp / MFA_STEP_MS); + return [-1, 0, 1].some((offset) => hotp(secret, counter + offset) === normalizedCode); +} + +function identityPolicy() { + return dbGet("SELECT * FROM identity_policies WHERE id = 'default'") || { + password_login_enabled: 1, + mfa_required_for_admins: 0, + mfa_required_for_all: 0, + session_ttl_hours: 8, + max_sessions_per_user: 10 + }; +} + +function userIsPrivileged(userId) { + if (dbGet("SELECT user_id FROM system_admins WHERE user_id = ? AND status = 'active'", [userId])) return true; + return Boolean(dbGet("SELECT user_id FROM organization_members WHERE user_id = ? AND role_key IN ('org_owner', 'org_admin') AND status = 'active' LIMIT 1", [userId])); +} + +export function mfaRequiredForUser(userId) { + const policy = identityPolicy(); + return Boolean(policy.mfa_required_for_all || (policy.mfa_required_for_admins && userIsPrivileged(userId))); +} + +function passwordMatches(userId, password) { + const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [userId]); + if (!credential) return false; + const expected = Buffer.from(credential.password_hash, "hex"); + const actual = Buffer.from(passwordHash(password, credential.password_salt), "hex"); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +function clientIdentity(token) { + const client = dbGet( + `SELECT c.*, u.id AS user_id, u.display_name, u.email, u.avatar_color, u.status AS user_status + FROM api_clients c + JOIN users u ON u.id = c.created_by + WHERE c.client_key_hash = ? AND c.status = 'active'`, + [hashApiClientKey(token)] + ); + if (!client) return null; + if (client.user_status !== "active") throw authError(403, "api_client_owner_not_active", "API 客户端所属用户已停用"); + dbRun("UPDATE api_clients SET last_used_at = ?, updated_at = ? WHERE id = ?", [nowIso(), nowIso(), client.id]); + let scopes = []; + try { scopes = JSON.parse(client.scopes_json || "[]"); } catch { scopes = []; } + return { + userId: client.user_id, + sessionId: null, + apiClientId: client.id, + apiClient: { id: client.id, organizationId: client.organization_id, workspaceId: client.workspace_id, scopes }, + user: safeUser({ ...client, id: client.user_id, status: client.user_status }), + expiresAt: null + }; +} + +export function sessionIdentity(headers) { + const token = readToken(headers); + if (!token) return null; + const tokenHash = hashToken(token); + const session = dbGet( + `SELECT s.*, u.id AS user_id, u.display_name, u.email, u.avatar_color, u.status AS user_status, + d.id AS auth_device_id, d.label AS device_label, d.first_seen_at AS device_first_seen_at, + d.last_seen_at AS device_last_seen_at, d.last_ip_address AS device_last_ip_address, + d.last_user_agent AS device_last_user_agent, d.trusted_at, d.revoked_at + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + LEFT JOIN auth_devices d ON d.id = s.device_id + WHERE s.token_hash = ? AND s.revoked_at IS NULL`, + [tokenHash] + ); + if (!session) { + const apiClient = clientIdentity(token); + if (apiClient) return apiClient; + throw authError(401, "session_invalid", "登录会话无效,请重新登录"); + } + if (session.user_status !== "active") throw authError(403, "user_not_active", "当前用户已被停用"); + if (new Date(session.expires_at).getTime() <= Date.now()) { + dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id = ?", [nowIso(), nowIso(), session.id]); + throw authError(401, "session_expired", "登录会话已过期,请重新登录"); + } + dbRun("UPDATE auth_sessions SET last_seen_at = ? WHERE id = ?", [nowIso(), session.id]); + return { + userId: session.user_id, + sessionId: session.id, + user: safeUser({ ...session, id: session.user_id, status: session.user_status }), + expiresAt: session.expires_at, + riskLevel: session.risk_level || "medium", + riskScore: Number(session.risk_score ?? 50), + device: devicePayload(session) + }; +} + +export function authenticate(email, password, metadata = {}) { + const normalizedEmail = String(email || "").trim().toLowerCase(); + const request = requestMetadata(metadata); + const user = dbGet("SELECT * FROM users WHERE lower(email) = ?", [normalizedEmail]); + if (!user) { + recordSecurityEvent({ eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "unknown_email" } }); + throw authError(401, "invalid_credentials", "邮箱或密码不正确"); + } + const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [user.id]); + if (!credential) { + recordSecurityEvent({ userId: user.id, eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "credential_missing" } }); + throw authError(401, "invalid_credentials", "邮箱或密码不正确"); + } + if (credential.locked_until && new Date(credential.locked_until).getTime() > Date.now()) { + recordSecurityEvent({ userId: user.id, eventType: "account.locked", result: "blocked", ...request, metadata: { email: normalizedEmail, reason: "lockout_active", lockedUntil: credential.locked_until } }); + throw authError(429, "account_locked", "登录失败次数过多,请稍后再试"); + } + const valid = passwordMatches(user.id, password); + const timestamp = nowIso(); + if (!valid) { + const failedAttempts = Number(credential.failed_attempts || 0) + 1; + const lockedUntil = failedAttempts >= MAX_FAILED_ATTEMPTS ? new Date(Date.now() + LOCKOUT_MS).toISOString() : null; + dbRun("UPDATE user_credentials SET failed_attempts = ?, locked_until = ?, updated_at = ? WHERE user_id = ?", [failedAttempts, lockedUntil, timestamp, user.id]); + recordSecurityEvent({ userId: user.id, eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "invalid_credentials", failedAttempts, lockoutTriggered: Boolean(lockedUntil) } }); + if (lockedUntil) recordSecurityEvent({ userId: user.id, eventType: "account.locked", result: "blocked", ...request, metadata: { email: normalizedEmail, reason: "failed_login_threshold", failedAttempts, lockedUntil } }); + throw authError(401, "invalid_credentials", "邮箱或密码不正确"); + } + if (user.status !== "active") { + recordSecurityEvent({ userId: user.id, eventType: "login.blocked", result: "blocked", ...request, metadata: { reason: "user_not_active", status: user.status } }); + throw authError(403, "user_not_active", "当前用户未激活或已停用"); + } + if (!identityPolicy().password_login_enabled) { + recordSecurityEvent({ userId: user.id, eventType: "login.blocked", result: "blocked", ...request, metadata: { reason: "password_login_disabled" } }); + throw authError(403, "password_login_disabled", "平台已关闭本地密码登录,请使用企业身份登录"); + } + dbRun("UPDATE user_credentials SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE user_id = ?", [timestamp, timestamp, user.id]); + return user; +} + +export function mfaStatus(userId) { + const method = dbGet("SELECT id, method_type, label, enabled, setup_expires_at, last_used_at, created_at FROM user_mfa_methods WHERE user_id = ?", [userId]); + return { + enabled: Boolean(method?.enabled), + method: method ? { + id: method.id, + type: method.method_type, + label: method.label, + enabled: Boolean(method.enabled), + setupExpiresAt: method.setup_expires_at, + lastUsedAt: method.last_used_at, + createdAt: method.created_at + } : null + }; +} + +export function startMfaSetup(userId, metadata = {}) { + const current = dbGet("SELECT id, enabled FROM user_mfa_methods WHERE user_id = ?", [userId]); + if (current?.enabled) throw authError(409, "mfa_already_enabled", "MFA 已经启用"); + const user = dbGet("SELECT email FROM users WHERE id = ?", [userId]); + if (!user) throw authError(404, "user_not_found", "当前用户不存在"); + const id = current?.id || `mfa-${Date.now()}-${randomBytes(4).toString("hex")}`; + const secret = encodeBase32(randomBytes(20)); + const timestamp = nowIso(); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + if (current) { + dbRun("UPDATE user_mfa_methods SET secret_ciphertext = ?, label = ?, enabled = 0, setup_expires_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [encryptMfaSecret(secret), "身份验证器", expiresAt, timestamp, id, userId]); + } else { + dbRun("INSERT INTO user_mfa_methods(id, user_id, method_type, label, secret_ciphertext, enabled, setup_expires_at, created_at, updated_at) VALUES (?, ?, 'totp', ?, ?, 0, ?, ?, ?)", [id, userId, "身份验证器", encryptMfaSecret(secret), expiresAt, timestamp, timestamp]); + } + const request = requestMetadata(metadata); + recordSecurityEvent({ userId, eventType: "mfa.setup.started", result: "success", ...request, metadata: { methodId: id, expiresAt } }); + return { + methodId: id, + type: "totp", + label: "身份验证器", + secret, + otpauthUrl: `otpauth://totp/${encodeURIComponent(user.email)}?secret=${secret}&issuer=${encodeURIComponent("AI短剧生产平台")}`, + expiresAt + }; +} + +export function enableMfa(userId, methodId, code, metadata = {}) { + const request = requestMetadata(metadata); + const method = dbGet("SELECT * FROM user_mfa_methods WHERE id = ? AND user_id = ?", [methodId, userId]); + if (!method) throw authError(404, "mfa_method_not_found", "MFA 初始化记录不存在"); + if (method.enabled) return mfaStatus(userId); + if (method.setup_expires_at && new Date(method.setup_expires_at).getTime() <= Date.now()) { + recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "setup_expired", methodId } }); + throw authError(410, "mfa_setup_expired", "MFA 初始化已过期,请重新生成密钥"); + } + if (!validTotp(decryptMfaSecret(method.secret_ciphertext), code)) { + recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_setup_code", methodId } }); + throw authError(400, "mfa_code_invalid", "验证码不正确"); + } + const timestamp = nowIso(); + dbRun("UPDATE user_mfa_methods SET enabled = 1, setup_expires_at = NULL, last_used_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, timestamp, methodId, userId]); + recordSecurityEvent({ userId, eventType: "mfa.enabled", result: "success", ...request, metadata: { methodId, methodType: "totp" } }); + return mfaStatus(userId); +} + +export function cancelMfaSetup(userId, methodId, metadata = {}) { + const method = dbGet("SELECT id, enabled FROM user_mfa_methods WHERE id = ? AND user_id = ?", [methodId, userId]); + if (!method) throw authError(404, "mfa_method_not_found", "MFA 初始化记录不存在"); + if (method.enabled) throw authError(409, "mfa_already_enabled", "已启用的 MFA 不能通过取消初始化关闭"); + dbRun("DELETE FROM user_mfa_methods WHERE id = ? AND user_id = ? AND enabled = 0", [methodId, userId]); + const request = requestMetadata(metadata); + recordSecurityEvent({ userId, eventType: "mfa.setup.cancelled", result: "success", ...request, metadata: { methodId } }); + return mfaStatus(userId); +} + +export function disableMfa(userId, currentPassword, code, metadata = {}) { + const request = requestMetadata(metadata); + const method = dbGet("SELECT * FROM user_mfa_methods WHERE user_id = ? AND enabled = 1", [userId]); + if (!method) return mfaStatus(userId); + if (!passwordMatches(userId, currentPassword)) { + recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_current_password", methodId: method.id } }); + throw authError(400, "current_password_invalid", "当前密码不正确"); + } + if (!validTotp(decryptMfaSecret(method.secret_ciphertext), code)) { + recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_disable_code", methodId: method.id } }); + throw authError(400, "mfa_code_invalid", "验证码不正确"); + } + dbRun("DELETE FROM user_mfa_methods WHERE id = ? AND user_id = ?", [method.id, userId]); + recordSecurityEvent({ userId, eventType: "mfa.disabled", result: "success", ...request, metadata: { methodId: method.id, methodType: "totp" } }); + return mfaStatus(userId); +} + +export function createMfaChallenge(userId, metadata = {}) { + const token = randomBytes(32).toString("base64url"); + const timestamp = nowIso(); + const expiresAt = new Date(Date.now() + MFA_CHALLENGE_TTL_MS).toISOString(); + dbRun("INSERT INTO auth_mfa_challenges(id, challenge_hash, user_id, expires_at, attempts, created_at) VALUES (?, ?, ?, ?, 0, ?)", [`mfa-challenge-${Date.now()}-${randomBytes(4).toString("hex")}`, hashToken(token), userId, expiresAt, timestamp]); + const request = requestMetadata(metadata); + recordSecurityEvent({ userId, eventType: "mfa.challenge.created", result: "challenge", ...request, metadata: { expiresAt } }); + return { token, expiresAt }; +} + +export function createMfaEnrollmentChallenge(userId, metadata = {}) { + const token = randomBytes(32).toString("base64url"); + const timestamp = nowIso(); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + dbRun("INSERT INTO auth_mfa_enrollment_challenges(id, challenge_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", [`mfa-enroll-${Date.now()}-${randomBytes(4).toString("hex")}`, hashToken(token), userId, expiresAt, timestamp]); + const request = requestMetadata(metadata); + recordSecurityEvent({ userId, eventType: "mfa.enrollment.challenge.created", result: "challenge", ...request, metadata: { expiresAt } }); + return { token, expiresAt }; +} + +function enrollmentChallenge(token) { + const challenge = dbGet("SELECT c.*, u.email, u.display_name, u.avatar_color, u.status FROM auth_mfa_enrollment_challenges c JOIN users u ON u.id = c.user_id WHERE c.challenge_hash = ?", [hashToken(token)]); + if (!challenge || challenge.consumed_at) throw authError(401, "mfa_enrollment_invalid", "MFA 绑定挑战无效,请重新登录"); + if (new Date(challenge.expires_at).getTime() <= Date.now()) throw authError(401, "mfa_enrollment_expired", "MFA 绑定挑战已过期,请重新登录"); + if (challenge.status !== "active") throw authError(403, "user_not_active", "当前账号已停用"); + return challenge; +} + +export function startMfaEnrollment(token, metadata = {}) { + const challenge = enrollmentChallenge(token); + return { setup: startMfaSetup(challenge.user_id, metadata), user: safeUser(challenge) }; +} + +export function completeMfaEnrollment(token, methodId, code, metadata = {}) { + const challenge = enrollmentChallenge(token); + const status = enableMfa(challenge.user_id, methodId, code, metadata); + const timestamp = nowIso(); + dbRun("UPDATE auth_mfa_enrollment_challenges SET consumed_at = ? WHERE id = ?", [timestamp, challenge.id]); + const request = requestMetadata(metadata); + recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.enrollment.completed", result: "success", ...request, metadata: { methodId } }); + return { session: createSession(challenge.user_id, metadata), status }; +} + +export function completeMfaChallenge(challengeToken, code, metadata = {}) { + const request = requestMetadata(metadata); + const challenge = dbGet("SELECT c.*, m.id AS method_id, m.secret_ciphertext FROM auth_mfa_challenges c JOIN user_mfa_methods m ON m.user_id = c.user_id AND m.enabled = 1 WHERE c.challenge_hash = ?", [hashToken(challengeToken)]); + if (!challenge || challenge.consumed_at) { + recordSecurityEvent({ eventType: "mfa.challenge.failure", result: "failure", ...request, metadata: { reason: "challenge_invalid" } }); + throw authError(401, "mfa_challenge_invalid", "MFA 验证挑战无效,请重新登录"); + } + if (new Date(challenge.expires_at).getTime() <= Date.now()) { + recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: "failure", ...request, metadata: { reason: "challenge_expired" } }); + throw authError(401, "mfa_challenge_expired", "MFA 验证挑战已过期,请重新登录"); + } + if (Number(challenge.attempts || 0) >= MFA_MAX_CHALLENGE_ATTEMPTS) { + recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: "blocked", ...request, metadata: { reason: "challenge_locked", attempts: challenge.attempts } }); + throw authError(429, "mfa_challenge_locked", "MFA 验证失败次数过多,请重新登录"); + } + if (!validTotp(decryptMfaSecret(challenge.secret_ciphertext), code)) { + const attempts = Number(challenge.attempts || 0) + 1; + dbRun("UPDATE auth_mfa_challenges SET attempts = ?, consumed_at = CASE WHEN ? >= ? THEN ? ELSE consumed_at END WHERE id = ?", [attempts, attempts, MFA_MAX_CHALLENGE_ATTEMPTS, nowIso(), challenge.id]); + recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: attempts >= MFA_MAX_CHALLENGE_ATTEMPTS ? "blocked" : "failure", ...request, metadata: { reason: "invalid_code", attempts } }); + throw authError(attempts >= MFA_MAX_CHALLENGE_ATTEMPTS ? 429 : 401, "mfa_code_invalid", "验证码不正确"); + } + const timestamp = nowIso(); + dbRun("UPDATE auth_mfa_challenges SET consumed_at = ? WHERE id = ?", [timestamp, challenge.id]); + dbRun("UPDATE user_mfa_methods SET last_used_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, challenge.method_id]); + recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.success", result: "success", ...request, metadata: { methodId: challenge.method_id } }); + return createSession(challenge.user_id, metadata); +} + +export function createSession(userId, metadata = {}) { + const token = randomBytes(32).toString("base64url"); + const timestamp = nowIso(); + const policy = identityPolicy(); + const ttlMs = Math.max(1, Number(policy.session_ttl_hours || SESSION_TTL_MS / 3600000)) * 60 * 60 * 1000; + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + const id = `session-${Date.now()}-${randomBytes(4).toString("hex")}`; + const device = registerAuthDevice(userId, metadata); + dbRun("INSERT INTO auth_sessions(id, token_hash, user_id, expires_at, ip_address, user_agent, created_at, last_seen_at, device_id, risk_level, risk_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ + id, + hashToken(token), + userId, + expiresAt, + String(metadata.ipAddress || ""), + String(metadata.userAgent || ""), + timestamp, + timestamp, + device.device?.id || null, + device.riskLevel, + device.riskScore + ]); + const maxSessions = Math.max(1, Number(policy.max_sessions_per_user || 10)); + const staleSessions = dbAll("SELECT id FROM auth_sessions WHERE user_id = ? AND revoked_at IS NULL ORDER BY last_seen_at DESC", [userId]).slice(maxSessions); + if (staleSessions.length) dbRun(`UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id IN (${staleSessions.map(() => "?").join(",")})`, [timestamp, timestamp, ...staleSessions.map((row) => row.id)]); + const request = requestMetadata(metadata); + recordSecurityEvent({ userId, eventType: "session.created", result: "success", ...request, metadata: { sessionId: id, expiresAt, authMethod: metadata.authMethod || "session", deviceRecordId: device.device?.id || null, riskLevel: device.riskLevel, riskScore: device.riskScore, evictedSessionCount: staleSessions.length } }); + if (staleSessions.length) recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...request, metadata: { reason: "session_limit", revokedSessionCount: staleSessions.length } }); + return { id, token, expiresAt, deviceId: device.device?.id || null, riskLevel: device.riskLevel, riskScore: device.riskScore }; +} + +export function revokeSession(headers, metadata = {}) { + const token = readToken(headers); + if (!token) return false; + const timestamp = nowIso(); + const session = dbGet("SELECT id, user_id FROM auth_sessions WHERE token_hash = ? AND revoked_at IS NULL", [hashToken(token)]); + const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE token_hash = ? AND revoked_at IS NULL", [timestamp, timestamp, hashToken(token)]); + const revoked = Number(result.changes || 0) > 0; + if (revoked && session) recordSecurityEvent({ userId: session.user_id, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { sessionId: session.id, reason: metadata.reason || "logout" } }); + return revoked; +} + +function sessionPayload(row, currentSessionId) { + const expired = new Date(row.expires_at).getTime() <= Date.now(); + return { + id: row.id, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + expiresAt: row.expires_at, + ipAddress: row.ip_address || "", + userAgent: row.user_agent || "", + revokedAt: row.revoked_at || null, + deviceId: row.device_id || null, + device: devicePayload(row), + riskLevel: row.risk_level || "medium", + riskScore: Number(row.risk_score ?? 50), + current: row.id === currentSessionId, + status: row.revoked_at ? "revoked" : expired ? "expired" : row.id === currentSessionId ? "current" : "active" + }; +} + +export function listUserSessions(userId, currentSessionId) { + const rows = dbAll( + `SELECT s.id, s.created_at, s.last_seen_at, s.expires_at, s.ip_address, s.user_agent, s.revoked_at, + s.device_id, s.risk_level, s.risk_score, + d.id AS auth_device_id, d.label AS device_label, d.first_seen_at AS device_first_seen_at, + d.last_seen_at AS device_last_seen_at, d.last_ip_address AS device_last_ip_address, + d.last_user_agent AS device_last_user_agent, d.trusted_at, d.revoked_at + FROM auth_sessions s + LEFT JOIN auth_devices d ON d.id = s.device_id + WHERE s.user_id = ? ORDER BY s.last_seen_at DESC LIMIT 50`, + [userId] + ); + return rows.map((row) => sessionPayload(row, currentSessionId)); +} + +export function listUserDevices(userId) { + const timestamp = nowIso(); + const rows = dbAll( + `SELECT d.*, + (SELECT COUNT(*) FROM auth_sessions s WHERE s.device_id = d.id AND s.revoked_at IS NULL AND s.expires_at > ?) AS active_session_count, + (SELECT s.risk_level FROM auth_sessions s WHERE s.device_id = d.id ORDER BY s.created_at DESC LIMIT 1) AS latest_risk_level, + (SELECT s.risk_score FROM auth_sessions s WHERE s.device_id = d.id ORDER BY s.created_at DESC LIMIT 1) AS latest_risk_score + FROM auth_devices d + WHERE d.user_id = ? + ORDER BY d.last_seen_at DESC`, + [timestamp, userId] + ); + return rows.map((row) => devicePayload({ ...row, auth_device_id: row.id })); +} + +function getUserDevice(userId, deviceId) { + return dbGet("SELECT * FROM auth_devices WHERE id = ? AND user_id = ?", [deviceId, userId]); +} + +export function trustUserDevice(userId, deviceId, metadata = {}) { + const device = getUserDevice(userId, deviceId); + if (!device) throw authError(404, "device_not_found", "设备记录不存在"); + if (device.revoked_at) throw authError(409, "device_revoked", "已撤销的设备不能标记为信任"); + const timestamp = nowIso(); + dbRun("UPDATE auth_devices SET trusted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, timestamp, deviceId, userId]); + recordSecurityEvent({ userId, eventType: "device.trusted", result: "success", ...requestMetadata(metadata), metadata: { deviceRecordId: deviceId } }); + return listUserDevices(userId).find((item) => item.id === deviceId) || null; +} + +export function untrustUserDevice(userId, deviceId, metadata = {}) { + const device = getUserDevice(userId, deviceId); + if (!device) throw authError(404, "device_not_found", "设备记录不存在"); + const timestamp = nowIso(); + dbRun("UPDATE auth_devices SET trusted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, deviceId, userId]); + recordSecurityEvent({ userId, eventType: "device.untrusted", result: "success", ...requestMetadata(metadata), metadata: { deviceRecordId: deviceId } }); + return listUserDevices(userId).find((item) => item.id === deviceId) || null; +} + +export function revokeUserSession(userId, sessionId, metadata = {}) { + const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL", [nowIso(), nowIso(), sessionId, userId]); + const revoked = Number(result.changes || 0) > 0; + if (revoked) recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { sessionId, reason: metadata.reason || "self_service" } }); + return revoked; +} + +export function revokeOtherUserSessions(userId, currentSessionId, metadata = {}) { + const timestamp = nowIso(); + const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE user_id = ? AND id <> ? AND revoked_at IS NULL", [timestamp, timestamp, userId, currentSessionId]); + const revokedCount = Number(result.changes || 0); + recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { reason: "revoke_others", revokedSessionCount: revokedCount } }); + return revokedCount; +} + +export function revokeAllUserSessions(userId, metadata = {}) { + const timestamp = nowIso(); + const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE user_id = ? AND revoked_at IS NULL", [timestamp, timestamp, userId]); + const revokedCount = Number(result.changes || 0); + recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { reason: metadata.reason || "revoke_all", revokedSessionCount: revokedCount, actorUserId: metadata.actorUserId || null } }); + return revokedCount; +} + +export function changePassword(userId, currentPassword, nextPassword, metadata = {}) { + const request = requestMetadata(metadata); + const user = dbGet("SELECT * FROM users WHERE id = ?", [userId]); + const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [userId]); + if (!user || !credential) throw authError(404, "user_not_found", "当前用户不存在"); + const expected = Buffer.from(credential.password_hash, "hex"); + const actual = Buffer.from(passwordHash(currentPassword, credential.password_salt), "hex"); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + recordSecurityEvent({ userId, eventType: "password.failure", result: "failure", ...request, metadata: { reason: "current_password_invalid" } }); + throw authError(400, "current_password_invalid", "当前密码不正确"); + } + if (String(nextPassword || "").length < 12) throw authError(400, "password_too_short", "新密码至少需要 12 个字符"); + const record = createPasswordRecord(nextPassword); + const changedAt = nowIso(); + dbRun("UPDATE user_credentials SET password_salt = ?, password_hash = ?, failed_attempts = 0, locked_until = NULL, updated_at = ? WHERE user_id = ?", [record.salt, record.hash, changedAt, userId]); + recordSecurityEvent({ userId, eventType: "password.changed", result: "success", ...request, metadata: { changedAt } }); + return { changedAt }; +} + +export function resetPassword(userId, nextPassword, metadata = {}) { + const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]); + if (!user) throw authError(404, "user_not_found", "目标用户不存在"); + if (String(nextPassword || "").length < 12) throw authError(400, "password_too_short", "新密码至少需要 12 个字符"); + const record = createPasswordRecord(nextPassword); + const timestamp = nowIso(); + dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, failed_attempts, locked_until, created_at, updated_at) VALUES (?, ?, ?, 0, NULL, ?, ?) ON CONFLICT(user_id) DO UPDATE SET password_salt = excluded.password_salt, password_hash = excluded.password_hash, failed_attempts = 0, locked_until = NULL, updated_at = excluded.updated_at", [userId, record.salt, record.hash, timestamp, timestamp]); + const revokedSessionCount = revokeAllUserSessions(userId, metadata); + recordSecurityEvent({ userId, eventType: "password.reset", result: "success", ...requestMetadata(metadata), metadata: { revokedSessionCount, actorUserId: metadata.actorUserId || null } }); + return { resetAt: timestamp, revokedSessionCount }; +} + +export function resetMfa(userId, metadata = {}) { + const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]); + if (!user) throw authError(404, "user_not_found", "目标用户不存在"); + dbRun("DELETE FROM user_mfa_methods WHERE user_id = ?", [userId]); + dbRun("DELETE FROM auth_mfa_challenges WHERE user_id = ?", [userId]); + dbRun("DELETE FROM auth_mfa_enrollment_challenges WHERE user_id = ?", [userId]); + const revokedSessionCount = revokeAllUserSessions(userId, metadata); + recordSecurityEvent({ userId, eventType: "mfa.reset", result: "success", ...requestMetadata(metadata), metadata: { revokedSessionCount, actorUserId: metadata.actorUserId || null } }); + return { resetAt: nowIso(), revokedSessionCount }; +} + +export function authMode() { + return process.env.AI_DRAMA_ALLOW_DEV_CONTEXT === "1" ? "session-plus-scoped-api-client-plus-local-dev-context" : "session-or-scoped-api-client"; +} diff --git a/server/backup.mjs b/server/backup.mjs new file mode 100644 index 0000000..a8a6e55 --- /dev/null +++ b/server/backup.mjs @@ -0,0 +1,63 @@ +import { mkdir, readdir, stat, unlink } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { resolve } from "node:path"; +import { db, dbPath } from "./db.mjs"; + +const projectRoot = resolve(import.meta.dirname, ".."); +const backupRoot = resolve(projectRoot, "data", "backups"); +const relativeBackupRoot = "data/backups"; + +function backupEntry(name, fileStat) { + return { + name, + relativePath: `${relativeBackupRoot}/${name}`, + bytes: Number(fileStat.size || 0), + createdAt: fileStat.birthtime?.toISOString?.() || fileStat.mtime?.toISOString?.() || null, + modifiedAt: fileStat.mtime?.toISOString?.() || null + }; +} + +export async function listDatabaseBackups() { + let names = []; + try { + names = await readdir(backupRoot); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const entries = await Promise.all(names.filter((name) => /^platform-[A-Z0-9-]+\.sqlite$/i.test(name)).map(async (name) => { + try { + return backupEntry(name, await stat(resolve(backupRoot, name))); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + })); + return entries.filter(Boolean).sort((left, right) => String(right.modifiedAt || "").localeCompare(String(left.modifiedAt || ""))); +} + +export async function backupSummary() { + const backups = await listDatabaseBackups(); + return { + root: relativeBackupRoot, + source: dbPath, + count: backups.length, + latest: backups[0] || null, + backups + }; +} + +export async function createDatabaseBackup() { + await mkdir(backupRoot, { recursive: true }); + const stamp = new Date().toISOString().replace(/[^0-9TZ]/g, "-"); + const name = `platform-${stamp}-${randomBytes(4).toString("hex")}.sqlite`; + const target = resolve(backupRoot, name); + const escapedTarget = target.replaceAll("'", "''"); + try { + db.exec(`VACUUM INTO '${escapedTarget}'`); + const entry = backupEntry(name, await stat(target)); + return { backup: entry, ...(await backupSummary()) }; + } catch (error) { + await unlink(target).catch(() => {}); + throw error; + } +} diff --git a/server/composition.mjs b/server/composition.mjs new file mode 100644 index 0000000..2f2366f --- /dev/null +++ b/server/composition.mjs @@ -0,0 +1,131 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { resolve } from "node:path"; +import { dbAll, dbGet, dbRun } from "./db.mjs"; +import { addAudit, httpError, requirePermission } from "./tenant.mjs"; +import { registerCompositionArtifact } from "./media-artifacts.mjs"; + +const execFileAsync = promisify(execFile); +const projectRoot = resolve(import.meta.dirname, ".."); +const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg"; +const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + +function safeRelativePath(value, label) { + const relative = String(value || "").replace(/^[/\\]+/, ""); + if (!relative || relative.includes("..") || !relative.startsWith("storage/")) throw httpError(400, "composition_path_invalid", `${label}必须是 storage/ 下的安全相对路径`); + const absolute = resolve(projectRoot, relative); + if (!absolute.startsWith(`${projectRoot}/storage/`)) throw httpError(400, "composition_path_invalid", `${label}越过了本地存储根目录`); + return { relative, absolute }; +} + +async function exists(filePath) { + try { + const info = await stat(filePath); + return info.isFile(); + } catch { + return false; + } +} + +function concatLine(pathname) { + return `file '${pathname.replaceAll("'", "'\\''")}'`; +} + +function compositionPayload(row) { + if (!row) return null; + let source = {}; + let result = {}; + try { source = JSON.parse(row.source_json); } catch { source = {}; } + try { result = JSON.parse(row.result_json); } catch { result = {}; } + return { ...row, dryRun: Boolean(row.dry_run), source, result }; +} + +async function ffmpegVersion() { + try { + const result = await execFileAsync(ffmpegBinary, ["-version"], { timeout: 5000 }); + return String(result.stdout || "").split("\n")[0] || "ffmpeg"; + } catch (error) { + throw httpError(503, "ffmpeg_unavailable", `本地 FFmpeg 不可用:${String(error.message || error).slice(0, 300)}`); + } +} + +function scopedEpisode(context, episodeId) { + if (!episodeId) return null; + const row = dbGet( + `SELECT e.id FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series sr ON sr.id = se.series_id + WHERE e.id = ? AND sr.project_id = ?`, + [episodeId, context.project.id] + ); + if (!row) throw httpError(400, "episode_invalid", "合成分集不属于当前项目"); + return row; +} + +export async function composeProject(context, body = {}) { + requirePermission(context, "delivery:approve"); + if (!context.project) throw httpError(400, "project_required", "本地合成必须绑定项目"); + const clips = Array.isArray(body.clips) ? body.clips.map((item) => String(item || "").trim()).filter(Boolean) : []; + if (!clips.length || clips.length > 200) throw httpError(400, "composition_clips_invalid", "本地合成需要 1 到 200 个片段"); + const clipFiles = clips.map((clip) => safeRelativePath(clip, "片段路径")); + const audio = body.audioPath ? safeRelativePath(body.audioPath, "音频路径") : null; + const version = String(body.version || `local-${new Date().toISOString().slice(0, 10)}-${Date.now()}`).trim(); + const dryRun = Boolean(body.dryRun); + const compositionId = makeId("composition"); + const compositionRoot = resolve(projectRoot, "storage", "compositions", compositionId); + const manifestPath = `storage/compositions/${compositionId}/concat.txt`; + const outputPath = safeRelativePath(body.outputPath || `storage/compositions/${compositionId}/final.mp4`, "输出路径"); + const inputStatus = []; + for (const clip of clipFiles) inputStatus.push({ path: clip.relative, exists: await exists(clip.absolute) }); + if (audio) inputStatus.push({ path: audio.relative, kind: "audio", exists: await exists(audio.absolute) }); + const versionLabel = await ffmpegVersion(); + const source = { clips: clipFiles.map((item) => item.relative), audioPath: audio?.relative || null, version, inputStatus }; + const timestamp = new Date().toISOString(); + dbRun("INSERT INTO media_compositions(id, organization_id, workspace_id, project_id, episode_id, version, status, tool, dry_run, output_path, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'ffmpeg', ?, ?, ?, ?, '{}', ?, ?, ?)", [compositionId, context.organization.id, context.workspace.id, context.project.id, scopedEpisode(context, body.episodeId)?.id || null, version, dryRun ? "planned" : "running", dryRun ? 1 : 0, outputPath.relative, manifestPath, JSON.stringify(source), context.user.id, timestamp, timestamp]); + + if (dryRun) { + const result = { dryRun: true, tool: "ffmpeg", version: versionLabel, manifestPath, outputPath: outputPath.relative, command: [ffmpegBinary, "-f", "concat", "-safe", "0", "-i", manifestPath, "-c:v", "libx264", "-pix_fmt", "yuv420p", outputPath.relative], inputStatus }; + dbRun("UPDATE media_compositions SET result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(result), timestamp, compositionId]); + addAudit({ context, action: "media.composition.planned", targetType: "media_composition", targetId: compositionId, metadata: result }); + return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...result } }; + } + + const missing = inputStatus.filter((item) => !item.exists); + if (missing.length) { + const message = `输入片段不存在:${missing.map((item) => item.path).join(", ")}`; + dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), compositionId]); + throw httpError(422, "composition_input_missing", message, { compositionId, missing }); + } + await mkdir(compositionRoot, { recursive: true }); + await mkdir(resolve(outputPath.absolute, ".."), { recursive: true }); + await writeFile(resolve(projectRoot, manifestPath), `${clipFiles.map((item) => concatLine(item.absolute)).join("\n")}\n`, "utf8"); + const args = ["-y", "-f", "concat", "-safe", "0", "-i", resolve(projectRoot, manifestPath)]; + if (audio) args.push("-i", audio.absolute); + args.push("-map", "0:v:0", "-map", audio ? "1:a:0?" : "0:a:0?", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", "-shortest", outputPath.absolute); + try { + const result = await execFileAsync(ffmpegBinary, args, { timeout: 20 * 60 * 1000, maxBuffer: 2 * 1024 * 1024 }); + const outputInfo = await stat(outputPath.absolute); + const composition = dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId]); + const artifact = await registerCompositionArtifact(context, composition, outputPath.relative); + const payload = { dryRun: false, tool: "ffmpeg", version: versionLabel, outputPath: outputPath.relative, outputBytes: outputInfo.size, artifact, stderr: String(result.stderr || "").slice(-2000) }; + const finishedAt = new Date().toISOString(); + dbRun("UPDATE media_compositions SET status = 'completed', result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(payload), finishedAt, compositionId]); + addAudit({ context, action: "media.composition.completed", targetType: "media_composition", targetId: compositionId, metadata: payload }); + return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...payload } }; + } catch (error) { + const message = String(error.stderr || error.message || error).slice(-2000); + dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, new Date().toISOString(), compositionId]); + addAudit({ context, action: "media.composition.failed", targetType: "media_composition", targetId: compositionId, result: "error", metadata: { error: message } }); + throw httpError(502, "composition_failed", message, { compositionId }); + } +} + +function now() { + return new Date().toISOString(); +} + +export function listCompositions(context, limit = 40) { + if (!context.project) return []; + return dbAll("SELECT * FROM media_compositions WHERE organization_id = ? AND workspace_id = ? AND project_id = ? ORDER BY created_at DESC LIMIT ?", [context.organization.id, context.workspace.id, context.project.id, Math.max(1, Math.min(100, Number(limit || 40))) ]).map(compositionPayload); +} diff --git a/server/db.mjs b/server/db.mjs new file mode 100644 index 0000000..4e2f394 --- /dev/null +++ b/server/db.mjs @@ -0,0 +1,595 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { randomBytes, scryptSync } from "node:crypto"; +import { sampleProject } from "../src/data/sampleProject.js"; +import { apiClientStorageMarker, hashApiClientKey } from "./api-client-secrets.mjs"; + +const serverRoot = resolve(import.meta.dirname); +const projectRoot = resolve(serverRoot, ".."); +const dataRoot = resolve(projectRoot, "data"); +export const dbPath = resolve(process.env.AI_DRAMA_DB_PATH || dataRoot, process.env.AI_DRAMA_DB_PATH ? "" : "platform.sqlite"); + +await mkdir(process.env.AI_DRAMA_DB_PATH ? resolve(dbPath, "..") : dataRoot, { recursive: true }); + +const schema = await readFile(resolve(serverRoot, "schema.sql"), "utf8"); +export const db = new DatabaseSync(dbPath); +db.exec("PRAGMA busy_timeout = 5000"); +db.exec("PRAGMA journal_mode = WAL"); +db.exec(schema); + +function ensureColumn(table, column, definition) { + const columns = db.prepare(`PRAGMA table_info(${table})`).all(); + if (!columns.some((item) => item.name === column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } +} + +// Keep the seeded local SQLite database compatible with new platform features. +ensureColumn("model_connectors", "last_probe_at", "TEXT"); +ensureColumn("model_connectors", "latency_ms", "INTEGER"); +ensureColumn("model_connectors", "error_message", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("model_connectors", "protocol_json", "TEXT NOT NULL DEFAULT '{}'"); +ensureColumn("model_connectors", "auth_env", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("generation_jobs", "request_json", "TEXT NOT NULL DEFAULT '{}'"); +ensureColumn("generation_jobs", "result_json", "TEXT NOT NULL DEFAULT '{}'"); +ensureColumn("generation_jobs", "error_message", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("generation_jobs", "max_attempts", "INTEGER NOT NULL DEFAULT 3"); +ensureColumn("generation_jobs", "next_run_at", "TEXT"); +ensureColumn("generation_jobs", "leased_by", "TEXT"); +ensureColumn("generation_jobs", "leased_at", "TEXT"); +ensureColumn("generation_jobs", "started_at", "TEXT"); +ensureColumn("generation_jobs", "finished_at", "TEXT"); +ensureColumn("billing_accounts", "billing_cycle", "TEXT NOT NULL DEFAULT 'monthly'"); +ensureColumn("billing_accounts", "currency", "TEXT NOT NULL DEFAULT 'CNY'"); +ensureColumn("billing_accounts", "base_fee", "REAL NOT NULL DEFAULT 0"); +ensureColumn("billing_accounts", "seat_unit_price", "REAL NOT NULL DEFAULT 0"); +ensureColumn("billing_accounts", "storage_unit_price", "REAL NOT NULL DEFAULT 0"); +ensureColumn("billing_accounts", "clip_unit_price", "REAL NOT NULL DEFAULT 0"); +ensureColumn("billing_accounts", "quota_warning_percent", "INTEGER NOT NULL DEFAULT 80"); +ensureColumn("billing_accounts", "current_period_start", "TEXT"); +ensureColumn("billing_accounts", "current_period_end", "TEXT"); +ensureColumn("billing_accounts", "next_invoice_at", "TEXT"); +db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_dispatch ON generation_jobs(status, next_run_at, priority, created_at)"); +ensureColumn("series", "show_engine", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("episodes", "target_duration_sec", "REAL NOT NULL DEFAULT 0"); +ensureColumn("episodes", "hook", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("episodes", "cliffhanger", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("api_clients", "organization_id", "TEXT"); +ensureColumn("api_clients", "workspace_id", "TEXT"); +ensureColumn("api_clients", "client_key_hash", "TEXT"); +ensureColumn("api_clients", "client_key_prefix", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("api_clients", "key_version", "INTEGER NOT NULL DEFAULT 1"); +ensureColumn("invitations", "token_hash", "TEXT"); +ensureColumn("invitations", "token_hint", "TEXT"); +ensureColumn("invitations", "accepted_user_id", "TEXT"); +ensureColumn("invitations", "accepted_at", "TEXT"); +ensureColumn("invitations", "revoked_at", "TEXT"); +ensureColumn("identity_providers", "organization_id", "TEXT"); +ensureColumn("identity_providers", "workspace_id", "TEXT"); +ensureColumn("identity_providers", "jwks_url", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("identity_providers", "entry_point", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("identity_providers", "idp_cert_ref", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("identity_providers", "sp_issuer", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("identity_providers", "audience", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("identity_providers", "saml_name_id_format", "TEXT NOT NULL DEFAULT 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'"); +ensureColumn("identity_providers", "want_assertions_signed", "INTEGER NOT NULL DEFAULT 1"); +ensureColumn("identity_providers", "want_authn_response_signed", "INTEGER NOT NULL DEFAULT 1"); +ensureColumn("identity_providers", "validate_in_response_to", "TEXT NOT NULL DEFAULT 'ifPresent'"); +ensureColumn("identity_providers", "auto_provision", "INTEGER NOT NULL DEFAULT 1"); +ensureColumn("identity_providers", "default_role_key", "TEXT NOT NULL DEFAULT 'org_member'"); +ensureColumn("identity_providers", "default_workspace_role_key", "TEXT NOT NULL DEFAULT 'writer'"); +ensureColumn("asset_versions", "file_name", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("asset_versions", "mime_type", "TEXT NOT NULL DEFAULT 'application/octet-stream'"); +ensureColumn("asset_versions", "file_size", "INTEGER NOT NULL DEFAULT 0"); +ensureColumn("asset_versions", "content_sha256", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("shots", "current_version_id", "TEXT"); +ensureColumn("deliveries", "active_batch_id", "TEXT"); +ensureColumn("delivery_releases", "idempotency_key", "TEXT NOT NULL DEFAULT ''"); +ensureColumn("delivery_releases", "preflight_json", "TEXT NOT NULL DEFAULT '{}'"); +ensureColumn("delivery_releases", "result_json", "TEXT NOT NULL DEFAULT '{}'"); +ensureColumn("projects", "archived_at", "TEXT"); +ensureColumn("projects", "archived_by", "TEXT"); +ensureColumn("projects", "archived_from_status", "TEXT"); +ensureColumn("projects", "template_id", "TEXT NOT NULL DEFAULT 'ai-manhua-drama'"); +ensureColumn("auth_sessions", "device_id", "TEXT"); +ensureColumn("auth_sessions", "risk_level", "TEXT NOT NULL DEFAULT 'medium'"); +ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50"); +db.exec("CREATE INDEX IF NOT EXISTS idx_projects_lifecycle ON projects(workspace_id, status, updated_at)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_invites_token ON invitations(token_hash, status)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_identity_providers_org ON identity_providers(organization_id, enabled, status)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_oidc_login_states_hash ON oidc_login_states(state_hash, expires_at, consumed_at)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_saml_login_states_relay ON saml_login_states(relay_state_hash, expires_at, consumed_at)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_saml_login_states_request ON saml_login_states(request_id, expires_at, consumed_at)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_external_identities_user ON external_identities(user_id, provider_id)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sso_tickets_hash ON auth_sso_tickets(ticket_hash, expires_at, consumed_at)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_api_clients_key_hash ON api_clients(client_key_hash, status)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_auth_security_events_user_created ON auth_security_events(user_id, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_auth_security_events_type_created ON auth_security_events(event_type, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_auth_devices_user_last_seen ON auth_devices(user_id, last_seen_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_auth_devices_key_hash ON auth_devices(user_id, device_key_hash)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sessions_device ON auth_sessions(device_id, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC)"); +db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC)"); + +function migrateApiClientSecrets() { + const rows = dbAll("SELECT id, client_key, client_key_hash, client_key_prefix, key_version FROM api_clients"); + for (const row of rows) { + if (row.client_key_hash && row.client_key_prefix && Number(row.key_version || 0) >= 2) continue; + const storedValue = String(row.client_key || ""); + const existingHash = String(row.client_key_hash || ""); + const hash = /^[a-f0-9]{64}$/i.test(existingHash) + ? existingHash.toLowerCase() + : storedValue.startsWith("hash:") && /^[a-f0-9]{64}$/i.test(storedValue.slice(5)) + ? storedValue.slice(5).toLowerCase() + : hashApiClientKey(storedValue); + const prefix = row.client_key_prefix || (storedValue.startsWith("hash:") ? "local-" : storedValue.slice(0, 12)); + dbRun("UPDATE api_clients SET client_key = ?, client_key_hash = ?, client_key_prefix = ?, key_version = 2 WHERE id = ?", [apiClientStorageMarker(hash), hash, prefix, row.id]); + } +} + +migrateApiClientSecrets(); + +const now = () => new Date().toISOString(); +const isoDaysFromNow = (days) => new Date(Date.now() + days * 86400000).toISOString(); +const monthStart = () => { + const date = new Date(); + date.setUTCDate(1); + date.setUTCHours(0, 0, 0, 0); + return date.toISOString(); +}; +const monthEnd = () => { + const date = new Date(); + date.setUTCMonth(date.getUTCMonth() + 1, 0); + date.setUTCHours(23, 59, 59, 999); + return date.toISOString(); +}; + +export function dbGet(sql, params = []) { + return db.prepare(sql).get(...params) || null; +} + +export function dbAll(sql, params = []) { + return db.prepare(sql).all(...params); +} + +export function dbRun(sql, params = []) { + return db.prepare(sql).run(...params); +} + +export function createPasswordRecord(password) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(String(password), salt, 64).toString("hex"); + return { salt, hash }; +} + +export function passwordHash(password, salt) { + return scryptSync(String(password), salt, 64).toString("hex"); +} + +export function withTransaction(callback) { + db.exec("BEGIN IMMEDIATE"); + try { + const result = callback(); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +function insertIgnore(sql, params) { + dbRun(sql, params); +} + +function seedRoles() { + const roles = [ + ["org_owner", "organization", "组织所有者", "组织全局管理、账单、成员和模型策略"], + ["org_admin", "organization", "组织管理员", "组织成员、工作区、模型和审计管理"], + ["org_member", "organization", "组织成员", "组织内的基础成员身份,具体能力由工作区/项目角色决定"], + ["producer", "workspace", "制片", "项目、批次、队列、成本和交付管理"], + ["writer", "workspace", "编剧", "剧本、分集、对白和镜头草稿"], + ["art_director", "workspace", "资产美术", "角色、场景、道具、prompt 和连续性"], + ["voice_editor", "workspace", "配音/字幕", "固定声线、TTS、字幕和 ASR"], + ["reviewer", "workspace", "审片", "QA、审片意见和通过/驳回"], + ["project_editor", "project", "项目编辑", "指定项目的内容编辑"], + ["project_viewer", "project", "项目查看者", "只读查看和下载授权交付物"] + ]; + for (const role of roles) { + insertIgnore("INSERT OR IGNORE INTO roles(key, scope, name, description) VALUES (?, ?, ?, ?)", role); + } + + const permissions = [ + ["organization:manage", "修改组织设置"], + ["organization:members:invite", "邀请组织成员"], + ["organization:roles:manage", "管理组织角色权限策略"], + ["workspace:create", "创建工作区"], + ["workspace:manage", "管理工作区设置"], + ["workspace:members:manage", "管理工作区成员"], + ["project:create", "创建项目"], + ["project:manage", "管理项目设置"], + ["project:members:manage", "管理项目成员"], + ["task:view", "查看项目协作任务"], + ["task:manage", "创建、分派和管理项目协作任务"], + ["task:complete", "更新本人负责的协作任务状态"], + ["script:edit", "编辑剧本和对白"], + ["script:read", "查看剧本、分集和镜头"], + ["asset:edit", "编辑角色、场景和道具锁"], + ["prompt:edit", "编辑生成提示"], + ["voice:edit", "编辑固定声线和字幕"], + ["voice:approve", "审批角色参考音频和声音授权"], + ["job:create", "创建生成任务"], + ["job:prioritize", "调整任务优先级"], + ["model:manage", "注册和管理模型连接器"], + ["usage:view", "查看用量和成本"], + ["billing:manage", "管理套餐和账单"], + ["quota:manage", "管理组织席位与工作区配额"], + ["qa:review", "执行审片和 QA"], + ["delivery:approve", "批准交付版本"], + ["delivery:view", "查看交付版本和导出物"], + ["compliance:manage", "管理合规策略和证据"], + ["audit:view", "查看审计日志"], + ["system:settings:view", "查看系统配置"], + ["system:settings:edit", "修改系统配置"], + ["feature_flag:manage", "管理功能开关"], + ["service:health:view", "查看服务健康状态"], + ["api_client:manage", "管理 API 客户端"], + ["notification:manage", "管理通知渠道"], + ["queue:manage", "管理全局任务队列"], + ["runner:manage", "管理本地 Runner"] + ]; + for (const permission of permissions) { + insertIgnore("INSERT OR IGNORE INTO permissions(key, description) VALUES (?, ?)", permission); + } + + const rolePermissions = { + org_owner: permissions.map(([key]) => key), + org_admin: [ + "organization:manage", "organization:members:invite", "workspace:create", "workspace:manage", + "workspace:members:manage", "project:create", "project:manage", "project:members:manage", + "task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve", + "system:settings:view", "service:health:view", "organization:roles:manage" + ], + producer: ["project:create", "project:manage", "project:members:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"], + writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"], + art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"], + voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"], + reviewer: ["script:read", "task:view", "task:complete", "qa:review", "voice:approve", "delivery:view"], + project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "job:create", "delivery:view"], + project_viewer: ["script:read", "task:view", "delivery:view"] + }; + for (const [roleKey, permissionKeys] of Object.entries(rolePermissions)) { + for (const permissionKey of permissionKeys) { + insertIgnore("INSERT OR IGNORE INTO role_permissions(role_key, permission_key) VALUES (?, ?)", [roleKey, permissionKey]); + } + } +} + +function seedUsers() { + const timestamp = now(); + const users = [ + ["u-owner", "林制片", "producer@local.test", "#d97757", "active"], + ["u-producer", "周制片", "producer2@local.test", "#3f7f87", "active"], + ["u-writer", "白编剧", "writer@local.test", "#a77646", "active"], + ["u-art", "沈美术", "art@local.test", "#8e6a9f", "active"], + ["u-voice", "许配音", "voice@local.test", "#477d69", "active"], + ["u-review", "顾审片", "review@local.test", "#9a6b51", "active"], + ["u-local-worker", "本地 Worker", "local-worker@system.invalid", "#58656b", "suspended"] + ]; + for (const [id, displayName, email, color, status] of users) { + insertIgnore("INSERT OR IGNORE INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [id, displayName, email, color, status, timestamp, timestamp]); + } +} + +function seedCredentials() { + const timestamp = now(); + const demoPassword = "Demo@123456"; + const users = dbAll("SELECT id FROM users WHERE status = 'active'"); + for (const user of users) { + const existing = dbGet("SELECT user_id FROM user_credentials WHERE user_id = ?", [user.id]); + if (!existing) { + const record = createPasswordRecord(demoPassword); + insertIgnore("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [user.id, record.salt, record.hash, timestamp, timestamp]); + } + } +} + +function seedOrganization({ id, name, slug, ownerUserId, description, workspaceId, workspaceName, workspaceSlug, projectId, projectName, projectType, readiness, risk }) { + const timestamp = now(); + insertIgnore("INSERT OR IGNORE INTO organizations(id, name, slug, owner_user_id, deployment_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'private-local', 'active', ?, ?)", [id, name, slug, ownerUserId, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, 'org_owner', 'active', ?, ?, ?)", [`om-${id}`, id, ownerUserId, timestamp, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)", [workspaceId, id, workspaceName, workspaceSlug, description, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'producer', 'active', ?, ?)", [`wm-${workspaceId}-${ownerUserId}`, workspaceId, ownerUserId, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, ?, 'production', ?, 'workspace', ?, ?, ?, ?)", [projectId, workspaceId, projectName, projectType, ownerUserId, readiness, risk, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", [`pm-${projectId}-${ownerUserId}`, projectId, ownerUserId, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO billing_accounts(id, organization_id, plan_name, billing_cycle, currency, seat_limit, storage_gb, monthly_clip_quota, quota_warning_percent, local_runner_only, cloud_connectors_require_approval, current_period_start, current_period_end, next_invoice_at, created_at, updated_at) VALUES (?, ?, 'Studio Local', 'monthly', 'CNY', 12, 1024, 2400, 80, 1, 1, ?, ?, ?, ?, ?)", [`bill-${id}`, id, monthStart(), monthEnd(), monthEnd(), timestamp, timestamp]); + dbRun("UPDATE billing_accounts SET current_period_start = COALESCE(current_period_start, ?), current_period_end = COALESCE(current_period_end, ?), next_invoice_at = COALESCE(next_invoice_at, ?) WHERE organization_id = ?", [monthStart(), monthEnd(), monthEnd(), id]); + const costCenters = [ + [`cc-${id}-gpu`, "local-gpu", "本地 GPU / 电力", "本地推理与视频生成的运营成本", 500], + [`cc-${id}-storage`, "storage", "本地存储", "素材、缓存和交付归档存储", 200], + [`cc-${id}-ops`, "operations", "平台运营", "通知、审计和运维工作量", 300] + ]; + for (const [costCenterId, code, name, description, budget] of costCenters) { + insertIgnore("INSERT OR IGNORE INTO cost_centers(id, organization_id, code, name, description, monthly_budget, currency, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'CNY', 'active', ?, ?)", [costCenterId, id, code, name, description, budget, timestamp, timestamp]); + } + insertIgnore("INSERT OR IGNORE 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, 36, 'clips', ?, ?, ?, ?)", [`quota-${workspaceId}-clip`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'storage', 1024, 128, 'GB', ?, ?, ?, ?)", [`quota-${workspaceId}-storage`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]); +} + +function seedMembersAndProjects() { + const timestamp = now(); + const membershipRows = [ + ["om-studio-writer", "org-studio-lab", "u-writer", "org_admin"], + ["om-studio-art", "org-studio-lab", "u-art", "org_admin"], + ["om-studio-voice", "org-studio-lab", "u-voice", "org_admin"], + ["om-studio-review", "org-studio-lab", "u-review", "org_admin"], + ["om-northstar-producer", "org-northstar", "u-producer", "org_admin"] + ]; + for (const row of membershipRows) { + insertIgnore("INSERT OR IGNORE INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)", [...row, timestamp, timestamp, timestamp]); + } + dbRun("UPDATE organization_members SET role_key = 'org_member' WHERE organization_id = 'org-studio-lab' AND user_id IN ('u-writer', 'u-art', 'u-voice', 'u-review')"); + + const workspaceRows = [ + ["wm-local-writer", "ws-local-aidrama", "u-writer", "writer"], + ["wm-local-art", "ws-local-aidrama", "u-art", "art_director"], + ["wm-local-voice", "ws-local-aidrama", "u-voice", "voice_editor"], + ["wm-local-review", "ws-local-aidrama", "u-review", "reviewer"], + ["wm-pilot-owner", "ws-pilot", "u-owner", "producer"], + ["wm-northstar-producer", "ws-northstar-main", "u-producer", "producer"] + ]; + for (const row of workspaceRows) { + insertIgnore("INSERT OR IGNORE INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [...row, timestamp, timestamp]); + } + + insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, '模板', 'template', ?, 'workspace', 48, 'low', ?, ?)", ["template-original-manhua", "ws-pilot", "原创国漫短剧模板", "u-owner", timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", ["pm-template-owner", "template-original-manhua", "u-owner", timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, 'AI 漫剧', 'production', ?, 'workspace', 35, 'medium', ?, ?)", ["northstar-pilot", "ws-northstar-main", "山海志异·试播集", "u-producer", timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", ["pm-northstar-producer", "northstar-pilot", "u-producer", timestamp, timestamp]); +} + +function seedModels() { + const timestamp = now(); + const models = [ + ["owned-i2v", "org-studio-lab", "ws-local-aidrama", "自有图生视频平台", "http-json", ["image-to-video", "first-last-frame", "vertical-video"], "http://127.0.0.1:7860/api/generate/i2v", "not-connected", "local", 0, "u-owner"], + ["owned-image", "org-studio-lab", "ws-local-aidrama", "自有图片/改图平台", "http-json", ["text-to-image", "image-edit", "single-frame"], "http://127.0.0.1:7860/api/generate/image", "not-connected", "local", 0, "u-owner"], + ["local-tts", "org-studio-lab", "ws-local-aidrama", "本地固定声线 TTS", "http-json", ["tts", "voice-lock", "subtitle-timing"], "http://127.0.0.1:7861/api/tts", "planned", "local", 0, "u-owner"], + ["newapi-audio-production", "org-studio-lab", "ws-local-aidrama", "NewAPI 音频中转", "openai-compatible-audio", ["tts", "voice-lock", "emotion-control", "asr", "subtitle-timing"], "https://newapi.ysblack.com/v1", "ready", "mixed", 1, "u-owner"], + ["comfyui-optional", "org-studio-lab", "ws-local-aidrama", "ComfyUI 工作流桥接", "comfyui", ["workflow", "qwen-image", "qwen-edit"], "http://127.0.0.1:8188", "optional", "mixed", 1, "u-owner"], + ["northstar-image", "org-northstar", "ws-northstar-main", "北辰本地图像 Runner", "http-json", ["text-to-image", "single-frame"], "http://127.0.0.1:7960/api/image", "ready", "local", 0, "u-producer"] + ]; + for (const [id, organizationId, workspaceId, label, kind, capabilities, endpoint, status, costMode, approvalRequired, createdBy] of models) { + insertIgnore("INSERT OR IGNORE INTO model_connectors(id, organization_id, workspace_id, label, kind, capabilities_json, endpoint, status, cost_mode, approval_required, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, organizationId, workspaceId, label, kind, JSON.stringify(capabilities), endpoint, status, costMode, approvalRequired, createdBy, timestamp, timestamp]); + } +} + +function seedSystemGovernance() { + const timestamp = now(); + insertIgnore("INSERT OR IGNORE INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES ('u-owner', 'system_admin', 'active', ?, ?)", [timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO identity_policies(id, password_login_enabled, mfa_required_for_admins, mfa_required_for_all, sso_enabled, local_login_fallback, session_ttl_hours, max_sessions_per_user, updated_by, created_at, updated_at) VALUES ('default', 1, 0, 0, 0, 1, 12, 10, 'u-owner', ?, ?)", [timestamp, timestamp]); + const settings = [ + ["app.name", "general", "AI 短剧生产平台", "string", "平台显示名称", 0], + ["app.locale", "general", "zh-CN", "string", "默认界面语言", 0], + ["app.timezone", "general", "Asia/Shanghai", "string", "默认时区", 0], + ["deployment.mode", "deployment", "private-local", "string", "部署模式", 0], + ["deployment.local_runner_only", "deployment", true, "boolean", "默认只允许本地 Runner", 0], + ["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0], + ["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0], + ["queue.max_concurrency", "queue", 2, "number", "全局并发任务数", 0], + ["generation.single_frame_only", "generation", true, "boolean", "一图一画面策略", 0], + ["generation.require_actual_last_frame", "generation", true, "boolean", "视频片段必须使用真实末帧连续", 0], + ["generation.comfyui_optional", "generation", true, "boolean", "ComfyUI 仅作为可选适配器", 0], + ["voice.fixed_voice_required", "voice", true, "boolean", "角色必须绑定固定声线", 0], + ["qa.require_asr_alignment", "qa", true, "boolean", "配音任务必须通过 ASR/字幕对齐", 0], + ["qa.require_continuity_lock", "qa", true, "boolean", "镜头生成必须引用连续性锁", 0], + ["notifications.email_enabled", "notifications", false, "boolean", "启用邮件通知", 0], + ["api.rate_limit_per_minute", "api", 120, "number", "API 每分钟请求上限", 0] + ]; + for (const [key, category, value, valueType, description, sensitive] of settings) { + insertIgnore("INSERT OR IGNORE INTO system_settings(key, category, value_json, value_type, description, is_sensitive, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)", [key, category, JSON.stringify(value), valueType, description, sensitive, timestamp, timestamp]); + } + + const flags = [ + ["creator_portal", "用户创作空间", "项目、剧本、资产和交付工作台", 1], + ["admin_console", "管理员后台", "组织、成员、模型、队列和计量治理", 1], + ["system_settings", "系统配置中心", "部署、生成策略、存储和通知配置", 1], + ["batch_generation", "批量生成", "允许制片人创建批量生成批次", 1], + ["review_workflow", "审片审批流", "启用多人审片和交付审批", 1], + ["comfyui_adapter", "ComfyUI 适配器", "可选的本地 ComfyUI 工作流桥接", 0], + ["external_cloud_connectors", "外部云连接器", "允许接入付费或云端模型", 0] + ]; + for (const [key, label, description, enabled] of flags) { + insertIgnore("INSERT OR IGNORE INTO feature_flags(key, label, description, enabled, scope, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, 'system', 'u-owner', ?, ?)", [key, label, description, enabled, timestamp, timestamp]); + } + + insertIgnore("INSERT OR IGNORE INTO notification_channels(id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at) VALUES ('notify-local-log', '本地事件日志', 'local-log', '', 1, ?, '', 'u-owner', ?, ?)", [JSON.stringify(["job.failed", "review.blocked", "quota.warning", "system.changed"]), timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO notification_channels(id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at) VALUES ('notify-webhook-optional', '本地 Webhook(可选)', 'webhook', 'http://127.0.0.1:8790/hooks/ai-drama', 0, ?, '', 'u-owner', ?, ?)", [JSON.stringify(["job.completed", "delivery.approved"]), timestamp, timestamp]); + const seedApiKeyHash = hashApiClientKey("local-runner-demo-key"); + insertIgnore("INSERT OR IGNORE INTO api_clients(id, name, client_key, client_key_hash, client_key_prefix, key_version, status, scopes_json, created_by, created_at, updated_at) VALUES ('client-local-runner', '本地 Runner 客户端', ?, ?, 'local-runner-', 2, 'active', ?, 'u-owner', ?, ?)", [apiClientStorageMarker(seedApiKeyHash), seedApiKeyHash, JSON.stringify(["jobs:read", "jobs:write", "models:read"]), timestamp, timestamp]); + dbRun("UPDATE api_clients SET organization_id = COALESCE(organization_id, 'org-studio-lab'), workspace_id = COALESCE(workspace_id, 'ws-local-aidrama') WHERE id = 'client-local-runner'"); + + const services = [ + ["service-local-api", "local-api", "本地 API 服务", "local-service", "ready", "http://127.0.0.1:8787/api/health", 2, 0, "node-24"], + ["service-sqlite", "sqlite", "SQLite 租户数据库", "database", "ready", dbPath, 1, 0, "node:sqlite"], + ["service-script-runner", "script-runner", "剧本拆解 Runner", "runner", "ready", "local://script", 4, 0, "local"], + ["service-image-runner", "image-runner", "单画面 Runner", "runner", "waiting-model", "local://image", null, 3, "local"], + ["service-video-runner", "video-runner", "视频片段 Runner", "runner", "waiting-model", "local://video", null, 2, "local"], + ["service-voice-runner", "voice-runner", "固定配音 Runner", "runner", "planned", "local://voice", null, 6, "local"], + ["service-local-worker", "local-worker", "本地后台 Worker", "runner", "ready", "local://worker", 0, 0, "node-24"] + ]; + for (const [id, serviceKey, label, kind, status, endpoint, latency, queueDepth, version] of services) { + insertIgnore("INSERT OR IGNORE INTO service_health(id, service_key, label, kind, status, endpoint, latency_ms, queue_depth, version, last_heartbeat, metadata_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?)", [id, serviceKey, label, kind, status, endpoint, latency, queueDepth, version, status === "ready" ? timestamp : null, timestamp]); + } +} + +function seedUserNotifications() { + const timestamp = now(); + insertIgnore( + "INSERT OR IGNORE INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "user-notification-seed-review", + "u-owner", + "org-studio-lab", + "ws-local-aidrama", + "thunder-mouth", + "review", + "review.changes_requested", + "warning", + "E01-S02 连续性检查需要确认", + "角色服装与上一段实际末帧的连续性证据已提交,请在审片中心确认后再进入下一轮生成。", + "qa", + "review-thunder-mouth-continuity", + JSON.stringify({ source: "seed", lane: "continuity-lock" }), + timestamp + ] + ); + insertIgnore( + "INSERT OR IGNORE INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at, read_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "user-notification-seed-policy", + "u-owner", + "org-studio-lab", + "ws-local-aidrama", + null, + "system", + "system.changed", + "info", + "本地生产策略已生效", + "平台当前保持 local-only:ComfyUI 仅作为可选适配器,外部云连接器默认关闭。", + "system-generation", + "generation.single_frame_only", + JSON.stringify({ source: "seed", policy: "local-only" }), + timestamp, + timestamp + ] + ); +} + +function seedProductionGraph() { + const timestamp = now(); + insertIgnore("INSERT OR IGNORE INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ["series-thunder-mouth", "thunder-mouth", sampleProject.series.title, sampleProject.series.logline, sampleProject.series.format, sampleProject.series.visualStyle, sampleProject.series.continuityRule, sampleProject.series.showEngine, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", ["season-thunder-mouth-1", "series-thunder-mouth", timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, ?, 'production', ?, ?, ?, ?, ?)", ["episode-thunder-mouth-01", "season-thunder-mouth-1", sampleProject.episode.title, sampleProject.episode.targetDurationSec, sampleProject.episode.hook, sampleProject.episode.cliffhanger, timestamp, timestamp]); + dbRun("UPDATE series SET show_engine = COALESCE(NULLIF(show_engine, ''), ?) WHERE id = ?", [sampleProject.series.showEngine, "series-thunder-mouth"]); + dbRun("UPDATE episodes SET target_duration_sec = CASE WHEN target_duration_sec = 0 THEN ? ELSE target_duration_sec END, hook = COALESCE(NULLIF(hook, ''), ?), cliffhanger = COALESCE(NULLIF(cliffhanger, ''), ?) WHERE id = ?", [sampleProject.episode.targetDurationSec, sampleProject.episode.hook, sampleProject.episode.cliffhanger, "episode-thunder-mouth-01"]); + for (const shot of sampleProject.shots) { + insertIgnore("INSERT OR IGNORE INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [shot.id, "episode-thunder-mouth-01", Number(shot.id.split("-").pop()) || 1, shot.title, shot.firstFrame, shot.lastFrame, JSON.stringify({ transition: shot.transitionFromPrevious, camera: shot.camera }), timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'locked', 'u-owner', ?)", [`${shot.id}-v1`, shot.id, JSON.stringify(shot), timestamp]); + for (let index = 0; index < (shot.voiceLines || []).length; index += 1) { + const line = shot.voiceLines[index]; + insertIgnore("INSERT OR IGNORE INTO voice_lines(id, shot_id, line_number, character_key, text, emotion, mouth_plan, target_duration_sec, voice_id, audio_path, status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'u-owner', ?, ?)", [line.id, shot.id, index + 1, line.characterId, line.text, line.emotion || "", line.mouthPlan || "规避嘴形", line.targetDurationSec || 2, "", line.audioFile || "", timestamp, timestamp]); + } + } + insertIgnore("INSERT OR IGNORE INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, created_by, created_at, updated_at) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'episode-thunder-mouth-01', 1, ?, '原创短剧剧本', ?, 'analyzed', ?, 'u-owner', ?, ?)", [ + "script-thunder-mouth-v1", + `第 1 集《${sampleProject.episode.title}》`, + `第 1 集《${sampleProject.episode.title}》\n\n${sampleProject.episode.hook}\n\n陈宇:等一下,先别出去。雷暴来了,树下和金属牌旁边都不安全。\n唐夏:那我现在该往哪走?\n陈宇:看左边,积水旁可能有落地电线。别靠近,也别跨警戒线。\n唐夏:我给家里报个平安。`, + JSON.stringify({ chapters: sampleProject.scriptStudio.chapters, extracted: sampleProject.scriptStudio.extracted, parser: "local-rule-v1" }), + timestamp, + timestamp + ]); + const assetSeeds = [ + ["asset-character-chen-yu", "character", "陈宇", "青年志愿者", "locked", "approved", "assets/thunder-mouth/characters/chen-yu/v1/portrait.png", { initial: "陈", tags: ["角色锁", "衣橱", "声线"], usage: "S01 全季", detail: sampleProject.characters[0].visualLock, lock: sampleProject.characters[0].costumeState }], + ["asset-character-tang-xia", "character", "唐夏", "高中女生", "locked", "approved", "assets/thunder-mouth/characters/tang-xia/v1/portrait.png", { initial: "唐", tags: ["角色锁", "衣橱", "声线"], usage: "S01 前 6 集", detail: sampleProject.characters[1].visualLock, lock: sampleProject.characters[1].costumeState }], + ["asset-location-metro-canopy-rain", "location", "地铁口玻璃连廊", "傍晚雷暴场景", "locked", "approved", "assets/thunder-mouth/locations/metro-canopy-rain/v1/location.png", { initial: "景", tags: ["场景锁", "天气", "机位"], usage: "E01 全部镜头", detail: sampleProject.locations[0].lock, lock: sampleProject.locations[0].cameraLock }], + ["asset-prop-blue-umbrella", "prop", "折叠蓝伞", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/blue-umbrella/v1/prop.png", { initial: "伞", tags: ["道具锁", "陈宇"], usage: "shot-01 ~ shot-03", detail: sampleProject.props[0].lock, lock: "右手低握,未打开" }], + ["asset-prop-yellow-poncho", "prop", "黄色雨披", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/yellow-poncho/v1/prop.png", { initial: "披", tags: ["道具锁", "唐夏"], usage: "shot-01 ~ shot-03", detail: sampleProject.props[1].lock, lock: "折在双臂上,不能突然穿上" }], + ["asset-prop-warning-cones", "prop", "路锥和警戒线", "安全道具", "locked", "approved", "assets/thunder-mouth/props/warning-cones/v1/prop.png", { initial: "警", tags: ["道具锁", "安全"], usage: "shot-02", detail: sampleProject.props[2].lock, lock: "角色不跨越" }], + ["asset-prop-phone", "prop", "手机", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/phone/v1/prop.png", { initial: "机", tags: ["道具锁", "shot-03"], usage: "shot-03", detail: sampleProject.props[3].lock, lock: "低位拿出,不遮挡脸" }], + ["asset-voice-chen-yu", "voice", "陈宇固定参考音频", "自然男声 · 已授权", "locked", "approved", "assets/thunder-mouth/voices/chen-yu/v1/reference.wav", { initial: "声", tags: ["声线锁", "IndexTTS-2.5", "已授权"], usage: "S01 全季", detail: "自然、克制、年轻男声;只作为陈宇正式参考音频。", lock: "必须保留授权证据引用,不使用随机原生声线。", voiceId: "voice-chen-yu", ttsModel: "IndexTTS-2.5", consentRef: "local-consent/chen-yu-v1" }], + ["asset-voice-tang-xia", "voice", "唐夏固定参考音频", "自然女声 · 待补证据", "review", "needs-evidence", "assets/thunder-mouth/voices/tang-xia/v1/reference.wav", { initial: "声", tags: ["声线锁", "待授权证据"], usage: "S01 前 6 集", detail: "自然、清晰、带轻微紧张感的女声;正式批量配音前必须补齐授权证据。", lock: "单句试听可以排队,未审批前禁止批量 TTS。", voiceId: "voice-tang-xia", ttsModel: "IndexTTS-2.5", consentRef: "" }], + ["asset-style-cel-shading", "style", "国漫 2D 赛璐璐基线", "项目视觉基线", "locked", "approved", "assets/thunder-mouth/styles/cel-shading/v1/style.json", { initial: "风", tags: ["视觉锁", "9:16", "雨景"], usage: "全季复用", detail: sampleProject.series.visualStyle, lock: "禁止多格、拼图、多时间点" }], + ["asset-lora-rain-local", "lora", "雷雨口 · 本地一致性适配", "自有模型平台资产", "draft", "needs-evidence", "assets/thunder-mouth/lora/rain-local/v1/model.safetensors", { initial: "L", tags: ["local-only", "角色", "场景"], usage: "shot-01 ~ shot-03", detail: "仅作为自有模型平台可选增强,不替代角色、场景、道具锁。", lock: "美术负责人确认后才能进入生产" }] + ]; + for (const [id, kind, name, subtitle, lockStatus, rightsStatus, storagePath, metadata] of assetSeeds) { + insertIgnore("INSERT OR IGNORE INTO assets(id, project_id, kind, name, lock_status, created_by, created_at, updated_at) VALUES (?, 'thunder-mouth', ?, ?, ?, 'u-owner', ?, ?)", [id, kind, name, lockStatus, timestamp, timestamp]); + insertIgnore("INSERT OR IGNORE INTO asset_versions(id, asset_id, version_number, storage_path, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, 'u-owner', ?)", [`${id}-v1`, id, storagePath, rightsStatus, JSON.stringify({ subtitle, ...metadata }), timestamp]); + dbRun("UPDATE assets SET current_version_id = COALESCE(current_version_id, ?), updated_at = ? WHERE id = ?", [`${id}-v1`, timestamp, id]); + } + const bindingSeeds = [ + ["asset-character-chen-yu", ["shot-01", "shot-02", "shot-03"], "character"], + ["asset-character-tang-xia", ["shot-01", "shot-02", "shot-03"], "character"], + ["asset-location-metro-canopy-rain", ["shot-01", "shot-02", "shot-03"], "location"], + ["asset-prop-blue-umbrella", ["shot-01", "shot-02", "shot-03"], "prop"], + ["asset-prop-yellow-poncho", ["shot-01", "shot-02", "shot-03"], "prop"], + ["asset-prop-warning-cones", ["shot-02"], "prop"], + ["asset-prop-phone", ["shot-03"], "prop"] + ]; + for (const [assetId, shotIds, usageRole] of bindingSeeds) { + for (const shotId of shotIds) insertIgnore("INSERT OR IGNORE INTO asset_bindings(id, asset_id, shot_id, usage_role, created_by, created_at) VALUES (?, ?, ?, ?, 'u-owner', ?)", [`binding-${assetId}-${shotId}`, assetId, shotId, usageRole, timestamp]); + } + for (const job of sampleProject.productionJobs) { + const shotId = sampleProject.shots.some((shot) => shot.id === job.shotId) ? job.shotId : null; + insertIgnore("INSERT OR IGNORE INTO generation_jobs(id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, status, priority, cost_policy, output_path, qa_status, created_by, created_at, updated_at) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'episode-thunder-mouth-01', ?, ?, ?, ?, 50, ?, ?, ?, 'u-owner', ?, ?)", [job.id, shotId, job.kind, job.adapter, job.status, job.costPolicy, job.output, job.qa, timestamp, timestamp]); + } + insertIgnore("INSERT OR IGNORE INTO usage_events(id, organization_id, workspace_id, project_id, user_id, kind, units, unit_name, estimated_cost, metadata_json, created_at) VALUES ('usage-seed-001', 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'u-owner', 'image-generation', 36, 'clips', 86, '{\"source\":\"seed\"}', ?)", [timestamp]); + const audits = [ + ["aud-001", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "project.created", "project", "thunder-mouth", "ok", "{}"], + ["aud-002", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "policy.single-frame.checked", "shot", "shot-01", "pass", "{\"gate\":\"single_frame_only\"}"], + ["aud-003", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "exports.write", "delivery", "project-template", "ok", "{}"], + ["aud-004", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "cloud.connector.blocked", "model_connector", "paid-node", "requires-approval", "{\"policy\":\"local_runner_only\"}"] + ]; + for (const audit of audits) { + insertIgnore("INSERT OR IGNORE INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [...audit, timestamp]); + } +} + +withTransaction(() => { + seedRoles(); + seedUsers(); + seedCredentials(); + seedOrganization({ + id: "org-studio-lab", + name: "星河短剧实验室", + slug: "xinghe-studio", + ownerUserId: "u-owner", + description: "本地 AI 漫剧与短剧主生产空间", + workspaceId: "ws-local-aidrama", + workspaceName: "短剧生产中心", + workspaceSlug: "drama-production", + projectId: "thunder-mouth", + projectName: "雷雨口", + projectType: "AI 漫剧", + readiness: 72, + risk: "medium" + }); + seedOrganization({ + id: "org-northstar", + name: "北辰内容厂牌", + slug: "northstar-content", + ownerUserId: "u-owner", + description: "第二个客户组织,用于验证跨组织隔离", + workspaceId: "ws-northstar-main", + workspaceName: "北辰试制部", + workspaceSlug: "pilot-room", + projectId: "northstar-pilot", + projectName: "山海志异·试播集", + projectType: "AI 漫剧", + readiness: 35, + risk: "medium" + }); + insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES ('ws-pilot', 'org-studio-lab', '素材实验室', 'asset-lab', '角色、场景和模型试验空间', 'active', ?, ?)", [now(), now()]); + seedMembersAndProjects(); + seedModels(); + seedSystemGovernance(); + seedProductionGraph(); + seedUserNotifications(); +}); + +export function resetDatabaseForTests() { + db.exec("DELETE FROM audit_logs; DELETE FROM usage_events; DELETE FROM generation_jobs;"); +} + +export const databaseInfo = { + path: dbPath, + engine: "node:sqlite", + seededAt: now() +}; diff --git a/server/delivery-portal.mjs b/server/delivery-portal.mjs new file mode 100644 index 0000000..082374d --- /dev/null +++ b/server/delivery-portal.mjs @@ -0,0 +1,546 @@ +import { readFile } from "node:fs/promises"; +import { createHash, randomBytes } from "node:crypto"; +import { basename, dirname, relative, resolve } from "node:path"; +import { dbAll, dbGet, dbRun } from "./db.mjs"; +import { addAudit, hasPermission, httpError, requirePermission } from "./tenant.mjs"; + +const projectRoot = resolve(import.meta.dirname, ".."); +const defaultExpiryDays = 7; +const maxExpiryDays = 365; +const maxDownloadsLimit = 10000; + +function now() { + return new Date().toISOString(); +} + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function makeId(prefix) { + return `${prefix}-${Date.now()}-${randomBytes(7).toString("hex")}`; +} + +function hashToken(token) { + return createHash("sha256").update(String(token)).digest("hex"); +} + +function tokenFingerprint(tokenHash) { + return String(tokenHash || "").slice(0, 16); +} + +function safeStoragePath(value) { + const relativePath = String(value || "").trim(); + if (!relativePath || relativePath.startsWith("/") || !relativePath.startsWith("storage/")) return null; + const segments = relativePath.split(/[\\/]+/); + if (segments.includes("..")) return null; + const storageRoot = resolve(projectRoot, "storage"); + const absolute = resolve(projectRoot, relativePath); + const storageRelative = relative(storageRoot, absolute); + if (!storageRelative || storageRelative.startsWith("..") || storageRelative.includes("..")) return null; + return { relative: relativePath, absolute }; +} + +function safeFileSegment(value, fallback = "delivery") { + const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return normalized || fallback; +} + +function requestInfoOf(requestInfo = {}) { + return { + ipAddress: String(requestInfo.ipAddress || "").slice(0, 200), + userAgent: String(requestInfo.userAgent || "").slice(0, 1000) + }; +} + +function requireDeliveryView(context) { + if (!hasPermission(context, "delivery:view") && !hasPermission(context, "delivery:approve")) { + throw httpError(403, "permission_denied", "当前角色没有交付门户查看权限", { permission: "delivery:view" }); + } +} + +function releaseForContext(context, releaseId) { + if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目"); + const release = dbGet( + `SELECT dr.*, d.version AS delivery_version, d.status AS delivery_status, + d.manifest_path AS delivery_manifest_path, d.project_id AS delivery_project_id, + p.name AS project_name + FROM delivery_releases dr + JOIN deliveries d ON d.id = dr.delivery_id + JOIN projects p ON p.id = dr.project_id + WHERE dr.id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ? + AND d.organization_id = dr.organization_id AND d.workspace_id = dr.workspace_id + AND d.project_id = dr.project_id`, + [releaseId, context.organization.id, context.workspace.id, context.project.id] + ); + if (!release) throw httpError(404, "delivery_release_not_found", "发布记录不存在或不属于当前项目", { releaseId }); + return release; +} + +function linkForContext(context, linkId) { + if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目"); + const row = dbGet( + `SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at, + dr.delivery_id, d.version AS delivery_version, p.name AS project_name, + creator.display_name AS created_by_name, + (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count, + (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count, + (SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at + FROM delivery_access_links dal + JOIN delivery_releases dr ON dr.id = dal.release_id + JOIN deliveries d ON d.id = dr.delivery_id + JOIN projects p ON p.id = dal.project_id + LEFT JOIN users creator ON creator.id = dal.created_by + WHERE dal.id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ?`, + [linkId, context.organization.id, context.workspace.id, context.project.id] + ); + if (!row) throw httpError(404, "delivery_access_link_not_found", "交付访问链接不存在或不属于当前项目", { linkId }); + return row; +} + +function linkState(row, at = Date.now()) { + if (row.revoked_at || row.status === "revoked") return "revoked"; + if (row.status === "expired" || new Date(row.expires_at).getTime() <= at) return "expired"; + return "active"; +} + +function syncExpired(row) { + if (linkState(row) !== "expired" || row.status === "expired" || row.status === "revoked") return row; + const timestamp = now(); + dbRun("UPDATE delivery_access_links SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'active'", [timestamp, row.id]); + return { ...row, status: "expired", updated_at: timestamp }; +} + +function linkPayload(input) { + if (!input) return null; + const row = syncExpired(input); + const state = linkState(row); + const maxDownloads = Number(row.max_downloads || 0); + const downloadCount = Number(row.download_count || 0); + const feedback = feedbackSummaryForLink(row.id); + return { + id: row.id, + releaseId: row.release_id, + deliveryId: row.delivery_id, + deliveryVersion: row.delivery_version, + projectName: row.project_name, + recipientName: row.recipient_name, + recipientEmail: row.recipient_email, + status: state, + expiresAt: row.expires_at, + maxDownloads, + downloadCount, + downloadsRemaining: Math.max(0, maxDownloads - downloadCount), + viewCount: Number(row.view_count || 0), + downloadEventCount: Number(row.download_event_count || 0), + tokenHint: row.token_hint, + createdBy: row.created_by, + createdByName: row.created_by_name, + createdAt: row.created_at, + lastViewedAt: row.last_viewed_at, + lastDownloadedAt: row.last_downloaded_at, + lastAccessAt: row.last_access_at, + revokedAt: row.revoked_at, + clientReviewStatus: feedback.status, + clientReviewMessage: feedback.message, + clientReviewerName: feedback.reviewerName, + clientReviewerEmail: feedback.reviewerEmail, + clientReviewedAt: feedback.submittedAt, + clientReviewCount: feedback.count + }; +} + +function addAccessEvent(row, eventType, result, requestInfo, fileKind = "", metadata = {}) { + const info = requestInfoOf(requestInfo); + const tokenHash = row?.token_hash || String(metadata.tokenHash || ""); + dbRun( + `INSERT INTO delivery_access_events( + id, link_id, release_id, organization_id, workspace_id, project_id, + event_type, result, file_kind, token_fingerprint, ip_address, user_agent, + metadata_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + makeId("delivery-access-event"), + row?.id || null, + row?.release_id || null, + row?.organization_id || null, + row?.workspace_id || null, + row?.project_id || null, + eventType, + result, + fileKind, + tokenFingerprint(tokenHash), + info.ipAddress, + info.userAgent, + JSON.stringify(metadata), + now() + ] + ); +} + +function normalizeExpiry(value) { + const candidate = value ? new Date(String(value)) : new Date(Date.now() + defaultExpiryDays * 24 * 60 * 60 * 1000); + if (Number.isNaN(candidate.getTime())) throw httpError(400, "delivery_access_expiry_invalid", "访问链接有效期不是有效日期"); + if (candidate.getTime() <= Date.now()) throw httpError(400, "delivery_access_expiry_past", "访问链接有效期必须晚于当前时间"); + if (candidate.getTime() > Date.now() + maxExpiryDays * 24 * 60 * 60 * 1000) { + throw httpError(400, "delivery_access_expiry_too_long", `访问链接有效期不能超过 ${maxExpiryDays} 天`); + } + return candidate.toISOString(); +} + +function normalizeMaxDownloads(value) { + const number = Number(value ?? 10); + if (!Number.isInteger(number) || number < 1 || number > maxDownloadsLimit) { + throw httpError(400, "delivery_access_download_limit_invalid", `最大下载次数必须是 1-${maxDownloadsLimit} 的整数`); + } + return number; +} + +function normalizeRecipient(value, maxLength, label) { + const result = String(value || "").trim(); + if (result.length > maxLength) throw httpError(400, "delivery_access_recipient_invalid", `${label}不能超过 ${maxLength} 个字符`); + return result; +} + +function normalizeFeedbackMessage(value, required = false) { + const result = String(value || "").trim(); + if (result.length > 4000) throw httpError(400, "delivery_feedback_message_invalid", "客户反馈不能超过 4000 个字符"); + if (required && !result) throw httpError(400, "delivery_feedback_message_required", "提出修改意见时必须填写具体反馈"); + return result; +} + +function feedbackPayload(row) { + if (!row) return null; + return { + id: row.id, + linkId: row.link_id, + releaseId: row.release_id, + decision: row.decision, + message: row.message || "", + reviewerName: row.reviewer_name || "", + reviewerEmail: row.reviewer_email || "", + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function latestFeedbackForLink(linkId) { + return dbGet( + "SELECT * FROM delivery_access_feedback WHERE link_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1", + [linkId] + ); +} + +function feedbackSummaryForLink(linkId) { + const latest = latestFeedbackForLink(linkId); + const count = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [linkId]); + return { + status: latest?.decision || "pending", + message: latest?.message || "", + reviewerName: latest?.reviewer_name || "", + reviewerEmail: latest?.reviewer_email || "", + submittedAt: latest?.created_at || null, + count: Number(count?.count || 0) + }; +} + +export function listDeliveryAccessLinks(context, releaseId) { + requireDeliveryView(context); + const release = releaseForContext(context, releaseId); + return { + releaseId: release.id, + release: { + id: release.id, + status: release.status, + deliveryId: release.delivery_id, + deliveryVersion: release.delivery_version, + publishedAt: release.published_at, + outputPath: release.status === "published" ? release.output_path : "" + }, + accessLinks: dbAll( + `SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at, + dr.delivery_id, d.version AS delivery_version, p.name AS project_name, + creator.display_name AS created_by_name, + (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count, + (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count, + (SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at + FROM delivery_access_links dal + JOIN delivery_releases dr ON dr.id = dal.release_id + JOIN deliveries d ON d.id = dr.delivery_id + JOIN projects p ON p.id = dal.project_id + LEFT JOIN users creator ON creator.id = dal.created_by + WHERE dal.release_id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ? + ORDER BY dal.created_at DESC`, + [release.id, context.organization.id, context.workspace.id, context.project.id] + ).map(linkPayload) + }; +} + +export function createDeliveryAccessLink(context, releaseId, body = {}, options = {}) { + requirePermission(context, "delivery:approve"); + const release = releaseForContext(context, releaseId); + if (release.status !== "published" || !release.output_path) { + throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status }); + } + const releaseFile = safeStoragePath(release.output_path); + if (!releaseFile || basename(releaseFile.relative) !== "release.json") { + throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束"); + } + const recipientName = normalizeRecipient(body.recipientName, 120, "收件人名称"); + const recipientEmail = normalizeRecipient(body.recipientEmail, 240, "收件人邮箱"); + if (recipientEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) { + throw httpError(400, "delivery_access_email_invalid", "收件人邮箱格式无效"); + } + const expiresAt = normalizeExpiry(body.expiresAt); + const maxDownloads = normalizeMaxDownloads(body.maxDownloads); + const metadata = body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? body.metadata : {}; + const token = randomBytes(32).toString("base64url"); + const tokenHash = hashToken(token); + const timestamp = now(); + const id = makeId("delivery-access"); + dbRun( + `INSERT INTO delivery_access_links( + id, organization_id, workspace_id, project_id, release_id, token_hash, token_hint, + recipient_name, recipient_email, status, expires_at, max_downloads, download_count, + metadata_json, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)`, + [ + id, + context.organization.id, + context.workspace.id, + context.project.id, + release.id, + tokenHash, + `${token.slice(0, 10)}...`, + recipientName, + recipientEmail, + expiresAt, + maxDownloads, + JSON.stringify(metadata), + context.user.id, + timestamp, + timestamp + ] + ); + addAudit({ context, action: "delivery.access_link.created", targetType: "delivery_access_link", targetId: id, metadata: { releaseId, recipientName, recipientEmail, expiresAt, maxDownloads } }); + const portalPath = `/portal/${encodeURIComponent(token)}`; + const portalOrigin = String(options.portalOrigin || "").replace(/\/$/, ""); + return { + link: linkPayload(linkForContext(context, id)), + token, + tokenHint: `${token.slice(0, 10)}...`, + portalPath, + ...(portalOrigin ? { portalUrl: `${portalOrigin}${portalPath}` } : {}), + accessLinks: listDeliveryAccessLinks(context, release.id).accessLinks + }; +} + +export function revokeDeliveryAccessLink(context, linkId) { + requirePermission(context, "delivery:approve"); + const current = linkForContext(context, linkId); + const state = linkState(current); + if (state === "active") { + const timestamp = now(); + dbRun("UPDATE delivery_access_links SET status = 'revoked', revoked_by = ?, revoked_at = ?, updated_at = ? WHERE id = ? AND status = 'active'", [context.user.id, timestamp, timestamp, linkId]); + addAudit({ context, action: "delivery.access_link.revoked", targetType: "delivery_access_link", targetId: linkId, metadata: { releaseId: current.release_id } }); + } + const link = linkForContext(context, linkId); + return { link: linkPayload(link), accessLinks: listDeliveryAccessLinks(context, current.release_id).accessLinks }; +} + +export function listDeliveryAccessFeedback(context, releaseId) { + requireDeliveryView(context); + const release = releaseForContext(context, releaseId); + return { + releaseId: release.id, + feedback: dbAll( + `SELECT f.*, dal.recipient_name, dal.recipient_email, dal.token_hint, + creator.display_name AS created_by_name + FROM delivery_access_feedback f + JOIN delivery_access_links dal ON dal.id = f.link_id + LEFT JOIN users creator ON creator.id = dal.created_by + WHERE f.release_id = ? AND f.organization_id = ? AND f.workspace_id = ? AND f.project_id = ? + ORDER BY f.created_at DESC, f.rowid DESC`, + [release.id, context.organization.id, context.workspace.id, context.project.id] + ).map((row) => ({ + ...feedbackPayload(row), + recipientName: row.recipient_name, + recipientEmail: row.recipient_email, + tokenHint: row.token_hint, + createdByName: row.created_by_name + })) + }; +} + +function publicLinkForToken(token, requestInfo, eventType = "view", fileKind = "") { + const normalizedToken = String(token || "").trim(); + const tokenHash = hashToken(normalizedToken); + const row = dbGet( + `SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at, + dr.delivery_id, d.version AS delivery_version, p.name AS project_name + FROM delivery_access_links dal + JOIN delivery_releases dr ON dr.id = dal.release_id + JOIN deliveries d ON d.id = dr.delivery_id + JOIN projects p ON p.id = dal.project_id + WHERE dal.token_hash = ?`, + [tokenHash] + ); + if (!row) { + addAccessEvent({ token_hash: tokenHash }, eventType, "missing", requestInfo, fileKind, { reason: "token_not_found" }); + throw httpError(404, "delivery_access_not_found", "交付访问链接不存在"); + } + const state = linkState(row); + if (state === "revoked") { + addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "revoked" }); + throw httpError(404, "delivery_access_not_found", "交付访问链接不存在"); + } + if (state === "expired") { + syncExpired(row); + addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "expired" }); + throw httpError(410, "delivery_access_expired", "交付访问链接已过期"); + } + if (row.release_status !== "published" || !row.output_path) { + addAccessEvent(row, eventType, "missing", requestInfo, fileKind, { reason: "release_not_published" }); + throw httpError(404, "delivery_access_not_found", "交付版本不可用"); + } + return row; +} + +function publicPortalPayload(row, token) { + const maxDownloads = Number(row.max_downloads || 0); + const downloadCount = Number(row.download_count || 0); + const latestFeedback = latestFeedbackForLink(row.id); + const feedbackCount = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [row.id]); + return { + portal: { + status: "active", + recipientName: row.recipient_name, + expiresAt: row.expires_at, + maxDownloads, + downloadCount, + downloadsRemaining: Math.max(0, maxDownloads - downloadCount), + createdAt: row.created_at, + lastAccessAt: row.last_viewed_at || null + }, + delivery: { + version: row.delivery_version, + projectName: row.project_name, + publishedAt: row.published_at + }, + review: { + status: latestFeedback?.decision || "pending", + message: latestFeedback?.message || "", + reviewerName: latestFeedback?.reviewer_name || "", + submittedAt: latestFeedback?.created_at || null, + count: Number(feedbackCount?.count || 0) + }, + files: [ + { kind: "release", label: "发布元数据", fileName: "release.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=release` }, + { kind: "manifest", label: "交付清单", fileName: "delivery-manifest.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=manifest` } + ] + }; +} + +export function resolvePublicDeliveryPortal(token, requestInfo = {}) { + const row = publicLinkForToken(token, requestInfo, "view"); + const timestamp = now(); + dbRun("UPDATE delivery_access_links SET last_viewed_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, row.id]); + addAccessEvent(row, "view", "success", requestInfo, "", {}); + return publicPortalPayload({ ...row, last_viewed_at: timestamp }, token); +} + +export function submitPublicDeliveryFeedback(token, body = {}, requestInfo = {}) { + const row = publicLinkForToken(token, requestInfo, "view", "feedback"); + const decision = String(body.decision || "").trim(); + if (!["approved", "changes_requested"].includes(decision)) { + throw httpError(400, "delivery_feedback_decision_invalid", "客户反馈状态只能是 approved 或 changes_requested"); + } + const message = normalizeFeedbackMessage(body.message, decision === "changes_requested"); + const reviewerName = normalizeRecipient(body.reviewerName || row.recipient_name, 120, "反馈人名称"); + const reviewerEmail = normalizeRecipient(body.reviewerEmail || row.recipient_email, 240, "反馈人邮箱"); + if (reviewerEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail)) { + throw httpError(400, "delivery_feedback_email_invalid", "反馈人邮箱格式无效"); + } + const info = requestInfoOf(requestInfo); + const timestamp = now(); + dbRun( + `INSERT INTO delivery_access_feedback( + id, link_id, release_id, organization_id, workspace_id, project_id, + decision, message, reviewer_name, reviewer_email, ip_address, user_agent, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + makeId("delivery-feedback"), + row.id, + row.release_id, + row.organization_id, + row.workspace_id, + row.project_id, + decision, + message, + reviewerName, + reviewerEmail, + info.ipAddress, + info.userAgent, + timestamp, + timestamp + ] + ); + return publicPortalPayload(row, token); +} + +function publicFileTarget(row, kind) { + if (!['release', 'manifest'].includes(kind)) throw httpError(400, "delivery_access_file_kind_invalid", "只允许读取 release 或 manifest 文件"); + const releaseTarget = safeStoragePath(row.output_path); + if (!releaseTarget || basename(releaseTarget.relative) !== "release.json") return null; + const relativePath = kind === "release" ? releaseTarget.relative : `${dirname(releaseTarget.relative)}/delivery-manifest.json`; + return safeStoragePath(relativePath); +} + +export async function readPublicDeliveryFile(token, kind, requestInfo = {}) { + const row = publicLinkForToken(token, requestInfo, "download", kind); + const target = publicFileTarget(row, kind); + if (!target) { + addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "unsafe_path" }); + throw httpError(404, "delivery_access_file_not_found", "交付文件不可用"); + } + let content; + try { + content = await readFile(target.absolute); + } catch { + addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "file_missing" }); + throw httpError(404, "delivery_access_file_not_found", "交付文件不存在"); + } + const timestamp = now(); + const claimed = dbRun( + `UPDATE delivery_access_links + SET download_count = download_count + 1, last_downloaded_at = ?, updated_at = ? + WHERE id = ? AND status = 'active' AND expires_at > ? AND download_count < max_downloads`, + [timestamp, timestamp, row.id, timestamp] + ); + if (Number(claimed?.changes || 0) !== 1) { + const latest = dbGet("SELECT * FROM delivery_access_links WHERE id = ?", [row.id]); + const state = latest ? linkState(latest) : "missing"; + if (state === "expired") { + syncExpired(latest); + addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "expired" }); + throw httpError(410, "delivery_access_expired", "交付访问链接已过期"); + } + if (state === "revoked" || !latest) { + addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "revoked" }); + throw httpError(404, "delivery_access_not_found", "交付访问链接不存在"); + } + addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "download_limit" }); + throw httpError(429, "delivery_access_download_limit", "该交付访问链接已达到最大下载次数"); + } + addAccessEvent({ ...row, token_hash: row.token_hash }, "download", "success", requestInfo, kind, { downloadCount: Number(row.download_count || 0) + 1 }); + return { + content, + contentType: "application/json; charset=utf-8", + fileName: kind === "release" ? `${safeFileSegment(row.delivery_version)}-release.json` : `${safeFileSegment(row.delivery_version)}-delivery-manifest.json` + }; +} diff --git a/server/execution.mjs b/server/execution.mjs new file mode 100644 index 0000000..07f62d0 --- /dev/null +++ b/server/execution.mjs @@ -0,0 +1,605 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename } from "node:path"; +import { resolve } from "node:path"; +import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; +import { + addAudit, + addUsage, + hasPermission, + httpError, + parseModelRow, + requireQuota, + requirePermission, + requireProjectWritable +} from "./tenant.mjs"; +import { productionGraph } from "./production.mjs"; +import { dispatchNotificationEvent } from "./notifications.mjs"; +import { registerJobArtifacts } from "./media-artifacts.mjs"; + +const projectRoot = resolve(import.meta.dirname, ".."); +const jobStorageRoot = resolve(projectRoot, "storage", "jobs"); +const now = () => new Date().toISOString(); +const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function privateHost(hostname) { + const host = String(hostname || "").toLowerCase(); + if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true; + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; + return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168); +} + +export function endpointInfo(endpoint) { + let url; + try { + url = new URL(String(endpoint || "")); + } catch { + throw httpError(400, "endpoint_invalid", "模型连接器地址不是有效 HTTP URL"); + } + if (!["http:", "https:"].includes(url.protocol)) throw httpError(400, "endpoint_protocol_invalid", "模型连接器只支持 HTTP/HTTPS"); + return { url, local: privateHost(url.hostname) }; +} + +function resolveAdapterId(context, adapterId, kind = "") { + if (adapterId !== "owned-model-platform") return adapterId; + const normalizedKind = String(kind).toLowerCase(); + if (normalizedKind.includes("视频") || normalizedKind.includes("i2v") || normalizedKind.includes("video")) return "owned-i2v"; + if (normalizedKind.includes("asr") || normalizedKind.includes("字幕")) { + const preferred = dbGet("SELECT id FROM model_connectors WHERE id = 'newapi-audio-production' AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", [context.organization.id, context.workspace.id]); + if (preferred) return preferred.id; + } + if (normalizedKind.includes("tts") || normalizedKind.includes("配音")) return "local-tts"; + return "owned-image"; +} + +function modelForContext(context, adapterId, kind = "") { + const resolvedAdapterId = resolveAdapterId(context, adapterId, kind); + const row = dbGet( + `SELECT * FROM model_connectors + WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`, + [resolvedAdapterId, context.organization.id, context.workspace.id] + ); + if (!row) { + throw httpError(404, "adapter_not_found", "模型连接器不存在或不属于当前工作区", { adapterId: resolvedAdapterId }); + } + return parseModelRow(row); +} + +function jobRow(context, jobId) { + if (!context.project) throw httpError(400, "project_required", "任务操作必须绑定项目"); + const row = dbGet( + `SELECT j.*, p.name AS project_name, u.display_name AS creator_name + FROM generation_jobs j + JOIN projects p ON p.id = j.project_id + LEFT JOIN users u ON u.id = j.created_by + WHERE j.id = ? AND j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?`, + [jobId, context.organization.id, context.workspace.id, context.project.id] + ); + if (!row) throw httpError(404, "job_not_found", "任务不存在或不属于当前项目", { jobId }); + return row; +} + +function jobPayload(row) { + const attempts = dbAll( + "SELECT id, attempt_number, runner_id, status, error_message, started_at, finished_at, created_at FROM job_attempts WHERE job_id = ? ORDER BY attempt_number DESC", + [row.id] + ); + const dependencies = dbAll( + `SELECT d.job_id, d.depends_on_job_id, d.dependency_type, d.created_at, + j.kind, j.status, j.output_path + FROM job_dependencies d + JOIN generation_jobs j ON j.id = d.depends_on_job_id + WHERE d.job_id = ? + ORDER BY d.created_at`, + [row.id] + ); + return { + ...row, + shotId: row.shot_id || "E01", + adapter: row.adapter_id, + costPolicy: row.cost_policy, + output: row.output_path, + qa: row.qa_status, + request: parseJson(row.request_json, {}), + result: parseJson(row.result_json, {}), + errorMessage: row.error_message || "", + startedAt: row.started_at, + finishedAt: row.finished_at, + attempts: Number(row.attempts || attempts.length || 0), + attemptLog: attempts, + dependencies + }; +} + +function dependencyRows(context, dependencyIds) { + if (!dependencyIds.length) return []; + const placeholders = dependencyIds.map(() => "?").join(","); + const rows = dbAll( + `SELECT id, kind, status, organization_id, workspace_id, project_id + FROM generation_jobs + WHERE id IN (${placeholders})`, + dependencyIds + ); + if (rows.length !== dependencyIds.length || rows.some((row) => row.organization_id !== context.organization.id || row.workspace_id !== context.workspace.id || row.project_id !== context.project.id)) { + throw httpError(422, "job_dependency_scope_invalid", "任务前置依赖必须属于同一组织、工作区和项目"); + } + return rows; +} + +function assertNoDependencyCycle(jobId, dependencyIds) { + const visiting = new Set(); + const visited = new Set(); + function walk(currentId) { + if (currentId === jobId) throw httpError(422, "job_dependency_cycle", "任务前置依赖不能形成循环"); + if (visited.has(currentId)) return; + if (visiting.has(currentId)) throw httpError(422, "job_dependency_cycle", "任务前置依赖不能形成循环"); + visiting.add(currentId); + const parents = dbAll("SELECT depends_on_job_id FROM job_dependencies WHERE job_id = ?", [currentId]); + for (const parent of parents) walk(parent.depends_on_job_id); + visiting.delete(currentId); + visited.add(currentId); + } + for (const dependencyId of dependencyIds) walk(dependencyId); +} + +function unresolvedDependencies(jobId) { + return dbAll( + `SELECT d.depends_on_job_id, j.kind, j.status + FROM job_dependencies d + JOIN generation_jobs j ON j.id = d.depends_on_job_id + WHERE d.job_id = ? AND j.status <> 'completed' + ORDER BY d.created_at`, + [jobId] + ); +} + +function releaseReadyDependents(jobId) { + const dependents = dbAll("SELECT DISTINCT job_id FROM job_dependencies WHERE depends_on_job_id = ?", [jobId]); + for (const dependent of dependents) { + const unresolved = unresolvedDependencies(dependent.job_id); + if (unresolved.length) continue; + const row = dbGet("SELECT j.*, m.status AS adapter_status FROM generation_jobs j LEFT JOIN model_connectors m ON m.id = j.adapter_id WHERE j.id = ?", [dependent.job_id]); + if (row?.status === "blocked" && row.adapter_status === "ready" && /等待前置任务/.test(row.error_message || "")) { + const timestamp = now(); + dbRun("UPDATE generation_jobs SET status = 'queued', error_message = '', updated_at = ? WHERE id = ?", [timestamp, dependent.job_id]); + dbRun("UPDATE job_attempts SET status = 'queued', error_message = NULL WHERE job_id = ? AND attempt_number = (SELECT MAX(attempt_number) FROM job_attempts WHERE job_id = ?)", [dependent.job_id, dependent.job_id]); + } + } +} + +function shotForJob(context, shotId) { + if (!shotId) return null; + const graph = productionGraph(context); + return graph.shots.find((shot) => shot.id === shotId) || null; +} + +function buildContract(context, body, adapter) { + const shot = shotForJob(context, body.shotId); + const kind = String(body.kind || "自定义生成任务").trim(); + const blockedTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"]; + // Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here. + const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase(); + const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase())); + if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked }); + if (shot && kind.includes("视频") && shot.transitionFromPrevious !== "episode-start" && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame))) { + throw httpError(422, "actual_last_frame_required", "连续视频镜头必须先登记上一段实际末帧作为首帧输入", { shotId: shot.id }); + } + return { + schema: "ai-drama.job.v1", + createdAt: now(), + project: { id: context.project.id, name: context.project.name }, + job: { kind, shotId: body.shotId || null, adapterId: adapter.id, costPolicy: "local-only" }, + shot, + inputs: body.inputs || {}, + constraints: { + singleFrameOnly: true, + imageOutputCount: 1, + requireActualLastFrame: true, + localOnly: adapter.costMode === "local", + blockedTerms + } + }; +} + +async function writeJobRequest(jobId, contract) { + const directory = resolve(jobStorageRoot, jobId); + await mkdir(directory, { recursive: true }); + const relative = `storage/jobs/${jobId}/request.json`; + await writeFile(resolve(projectRoot, relative), `${JSON.stringify(contract, null, 2)}\n`, "utf8"); + return relative; +} + +function assertExternalAllowed(context, adapter, body = {}) { + if (adapter.costMode === "local") return; + if (!adapter.approvalRequired) return; + if (!body.approveExternal || !hasPermission(context, "model:manage")) { + throw httpError(403, "external_connector_requires_approval", "外部或混合连接器必须由具备模型管理权限的用户显式批准后才能执行", { adapterId: adapter.id }); + } +} + +export async function createGenerationJob(context, body) { + requirePermission(context, "job:create"); + if (!context.project) throw httpError(400, "project_required", "生成任务必须绑定项目"); + requireQuota(context, "clip", 1); + const requestedAdapterId = String(body.adapter || "").trim(); + if (!requestedAdapterId) throw httpError(400, "adapter_required", "生成任务必须选择模型连接器"); + const adapter = modelForContext(context, requestedAdapterId, body.kind); + assertExternalAllowed(context, adapter, body); + const dependencyIds = [...new Set((Array.isArray(body.dependsOnJobIds) ? body.dependsOnJobIds : []).map((id) => String(id || "").trim()).filter(Boolean))].slice(0, 12); + dependencyRows(context, dependencyIds); + assertNoDependencyCycle("__new_job__", dependencyIds); + const selectedShot = body.shotId ? shotForJob(context, body.shotId) : null; + if (body.shotId && !selectedShot) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId: body.shotId }); + const contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, adapter); + const jobId = makeId("job"); + const timestamp = now(); + const outputPath = String(body.output || `storage/jobs/${jobId}/output`); + if (outputPath.startsWith("/") || outputPath.includes("..")) throw httpError(400, "output_path_invalid", "输出路径必须是项目目录内的相对路径"); + const maxAttempts = Math.max(1, Math.min(10, Number(body.maxAttempts || 3))); + const dependencyBlocked = dependencyIds.some((dependencyId) => dbGet("SELECT status FROM generation_jobs WHERE id = ?", [dependencyId])?.status !== "completed"); + const initialStatus = dependencyBlocked ? "blocked" : adapter.status === "ready" && adapter.costMode === "local" ? "queued" : "blocked"; + const errorMessage = dependencyBlocked + ? `等待前置任务完成:${dependencyIds.join(", ")}` + : initialStatus === "blocked" ? `连接器当前状态为 ${adapter.status},请先在模型中台检测并启用连接器` : ""; + await writeJobRequest(jobId, contract); + withTransaction(() => { + dbRun( + `INSERT INTO generation_jobs( + id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, + status, priority, cost_policy, output_path, qa_status, request_json, result_json, + error_message, max_attempts, next_run_at, leased_by, leased_at, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = (SELECT id FROM series WHERE project_id = ?)) LIMIT 1), ?, ?, ?, ?, ?, 'local-only', ?, 'wait', ?, '{}', ?, ?, NULL, NULL, NULL, ?, ?, ?)`, + [jobId, context.organization.id, context.workspace.id, context.project.id, context.project.id, selectedShot?.id || null, body.kind || "自定义生成任务", adapter.id, initialStatus, Math.max(1, Math.min(100, Number(body.priority || 50))), outputPath, JSON.stringify(contract), errorMessage, maxAttempts, context.user.id, timestamp, timestamp] + ); + dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, error_message, created_at) VALUES (?, ?, 1, ?, ?, ?, ?)", [makeId("attempt"), jobId, adapter.id, initialStatus === "queued" ? "queued" : "blocked", errorMessage || null, timestamp]); + for (const dependencyId of dependencyIds) { + dbRun("INSERT INTO job_dependencies(job_id, depends_on_job_id, dependency_type, created_at) VALUES (?, ?, 'blocking', ?)", [jobId, dependencyId, timestamp]); + } + }); + addUsage({ context, kind: body.kind || "generation", units: 1, unitName: "job", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, status: initialStatus } }); + addAudit({ context, action: "generation_job.created", targetType: "generation_job", targetId: jobId, result: initialStatus === "queued" ? "ok" : "blocked", metadata: { kind: body.kind, shotId: body.shotId || null, adapter: adapter.id, status: initialStatus } }); + return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) }; +} + +export function listGenerationJobs(context, options = {}) { + if (!context.project) return []; + const status = String(options.status || "").trim(); + const params = [context.organization.id, context.workspace.id, context.project.id]; + let where = "j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?"; + if (status) { where += " AND j.status = ?"; params.push(status); } + const rows = dbAll( + `SELECT j.*, p.name AS project_name, u.display_name AS creator_name, + (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 + WHERE ${where} + ORDER BY j.created_at DESC LIMIT ?`, + [...params, Math.max(1, Math.min(500, Number(options.limit || 100)))] + ); + return rows.map(jobPayload); +} + +export function getGenerationJob(context, jobId) { + return jobPayload(jobRow(context, jobId)); +} + +async function fetchWithTimeout(url, options, timeoutMs = 20000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function normalizeResult(value, fallbackPath) { + if (!value || typeof value !== "object") return { raw: value, outputPath: fallbackPath }; + const outputPath = value.outputPath || value.output_path || value.path || value.file || fallbackPath; + return { ...value, outputPath }; +} + +function adapterProtocol(adapter) { + const configured = adapter.protocol && typeof adapter.protocol === "object" + ? adapter.protocol + : parseJson(adapter.protocol_json, {}); + return configured && typeof configured === "object" ? configured : {}; +} + +function joinEndpoint(baseEndpoint, route) { + if (!route) return new URL(baseEndpoint); + if (/^https?:\/\//i.test(route)) return new URL(route); + const base = String(baseEndpoint || "").replace(/\/+$/, ""); + const suffix = `/${String(route).replace(/^\/+/, "")}`; + return new URL(`${base}${suffix}`); +} + +function operationForJob(job) { + const kind = String(job.kind || "").toLowerCase(); + if (kind.includes("tts") || kind.includes("配音") || kind.includes("声音")) return "tts"; + if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) return "asr"; + if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) return "video"; + if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) return "image"; + return "chat"; +} + +function contractText(contract) { + return [ + contract?.shot?.prompt, + contract?.shot?.videoPrompt, + contract?.shot?.action, + contract?.inputs?.prompt, + contract?.inputs?.text, + contract?.inputs?.input + ].filter(Boolean).join("\n"); +} + +function authHeaders(adapter) { + const envKey = String(adapter.auth_env || adapter.authEnv || adapter.protocol?.authEnv || "").trim(); + const token = envKey ? process.env[envKey] : ""; + return token ? { authorization: `Bearer ${token}` } : {}; +} + +async function openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber) { + const inputPath = String(contract?.inputs?.inputFilePath || contract?.inputs?.audioPath || "").trim(); + if (!inputPath) { + return { + url, + options: { + method: "POST", + headers: { ...headers, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + model: protocol.models?.asr || protocol.model || contract?.inputs?.model || "local-asr", + language: contract?.inputs?.language || "zh", + response_format: contract?.inputs?.response_format || "verbose_json", + metadata: { jobId: job.id, attemptNumber, requestedAt: timestamp }, + input: contractText(contract) + }) + } + }; + } + if (inputPath.startsWith("/") || inputPath.includes("..")) throw httpError(400, "input_path_invalid", "音频输入必须是 storage/ 下的相对路径"); + const absolutePath = resolve(projectRoot, inputPath); + const audio = await readFile(absolutePath); + const form = new FormData(); + form.append("model", protocol.models?.asr || protocol.model || contract?.inputs?.model || "local-asr"); + form.append("language", contract?.inputs?.language || "zh"); + form.append("response_format", contract?.inputs?.response_format || "verbose_json"); + form.append("file", new Blob([audio], { type: contract?.inputs?.mimeType || "audio/wav" }), basename(absolutePath)); + return { url, options: { method: "POST", headers: { ...headers, accept: "application/json" }, body: form } }; +} + +async function buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp) { + const protocol = adapterProtocol(adapter); + const kind = String(adapter.kind || "http-json").toLowerCase(); + const operation = operationForJob(job); + const headers = { ...authHeaders(adapter), accept: "application/json", "x-ai-drama-job-id": job.id }; + const execution = { jobId: job.id, attemptNumber, requestedAt: timestamp, operation }; + + if (kind === "comfyui") { + const url = joinEndpoint(adapter.endpoint, protocol.promptRoute || "prompt"); + return { + url, + options: { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + prompt: contract?.inputs?.workflow || contract?.workflow || contract, + client_id: protocol.clientId || "ai-drama-platform", + extra_data: { aiDrama: execution } + }) + } + }; + } + + if (kind.includes("openai-compatible")) { + const defaultRoutes = { + tts: "audio/speech", + asr: "audio/transcriptions", + image: "images/generations", + video: "videos/generations", + chat: "chat/completions" + }; + const url = joinEndpoint(adapter.endpoint, protocol.routes?.[operation] || defaultRoutes[operation]); + if (operation === "asr") return openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber); + const model = protocol.models?.[operation] || protocol.models?.default || protocol.model || contract?.inputs?.model || "local-model"; + let body; + if (operation === "tts") { + body = { + model, + input: contract?.inputs?.text || contract?.inputs?.input || contractText(contract), + voice: contract?.inputs?.voice || contract?.inputs?.voiceId || "locked-voice", + response_format: contract?.inputs?.response_format || "wav", + speed: contract?.inputs?.speed || 1, + metadata: execution + }; + } else if (operation === "image") { + body = { + model, + prompt: contract?.shot?.prompt || contract?.inputs?.prompt || contractText(contract), + negative_prompt: contract?.shot?.negativePrompt || contract?.inputs?.negativePrompt || "", + n: 1, + size: contract?.inputs?.size || "928x1664", + response_format: contract?.inputs?.response_format || "b64_json", + metadata: execution + }; + } else if (operation === "video") { + body = { + model, + prompt: contract?.shot?.videoPrompt || contract?.inputs?.prompt || contractText(contract), + image: contract?.inputs?.firstFrame || contract?.shot?.firstFrame || undefined, + first_frame: contract?.inputs?.firstFrame || contract?.shot?.firstFrame || undefined, + last_frame: contract?.inputs?.lastFrame || contract?.shot?.lastFrame || undefined, + duration: contract?.shot?.durationSec, + n: 1, + metadata: execution + }; + } else { + body = { + model, + messages: [{ role: "user", content: contractText(contract) || JSON.stringify(contract) }], + temperature: 0.2, + metadata: execution + }; + } + return { url, options: { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(body) } }; + } + + return { + url: new URL(adapter.endpoint), + options: { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ ...contract, execution }) + } + }; +} + +async function readAdapterResponse(response, jobId) { + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + if (contentType.includes("json") || contentType.startsWith("text/")) { + const raw = await response.text(); + let parsed; + try { parsed = raw ? JSON.parse(raw) : {}; } catch { parsed = { raw }; } + return { parsed, contentType }; + } + const buffer = Buffer.from(await response.arrayBuffer()); + const resultRelative = `storage/jobs/${jobId}/output${contentType.includes("wav") ? ".wav" : contentType.includes("mp4") ? ".mp4" : ".bin"}`; + await mkdir(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true }); + await writeFile(resolve(projectRoot, resultRelative), buffer); + return { parsed: { outputPath: resultRelative, mimeType: contentType || "application/octet-stream", bytes: buffer.length, binary: true }, contentType }; +} + +function assertSingleFrameResult(job, result) { + const kind = String(job.kind || "").toLowerCase(); + if (!(kind.includes("图") || kind.includes("关键帧") || kind.includes("image"))) return; + const candidates = [result?.data, result?.images, result?.outputs].filter(Array.isArray); + for (const values of candidates) { + if (values.length !== 1) throw new Error(`一图一画面校验失败:模型返回了 ${values.length} 个图像结果,要求恰好 1 个`); + } +} + +export async function executeGenerationJob(context, jobId, body = {}) { + const job = jobRow(context, jobId); + if (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "没有执行生成任务的权限"); + requireProjectWritable(context); + if (!["queued", "blocked", "failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_runnable", "当前任务状态不能执行", { status: job.status }); + const unresolved = unresolvedDependencies(jobId); + if (unresolved.length) { + const message = `等待前置任务完成:${unresolved.map((item) => item.depends_on_job_id).join(", ")}`; + dbRun("UPDATE generation_jobs SET status = 'blocked', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), jobId]); + addAudit({ context, action: "generation_job.blocked_by_dependencies", targetType: "generation_job", targetId: jobId, result: "blocked", metadata: { dependencies: unresolved } }); + throw httpError(409, "job_dependencies_unresolved", message, { dependencies: unresolved }); + } + const adapter = modelForContext(context, job.adapter_id); + assertExternalAllowed(context, adapter, body); + if (body.approveExternal && adapter.costMode !== "local") { + addAudit({ context, action: "generation_job.external_approved", targetType: "generation_job", targetId: jobId, metadata: { adapter: adapter.id, costMode: adapter.costMode, approvalRequired: adapter.approvalRequired } }); + } + const { local } = endpointInfo(adapter.endpoint); + if (!local && adapter.costMode === "local") throw httpError(403, "local_only_endpoint_required", "local-only 任务只能调用本机或私有局域网 HTTP 连接器"); + if (adapter.status !== "ready") { + const message = `连接器当前状态为 ${adapter.status},请先完成探活并启用连接器`; + dbRun("UPDATE generation_jobs SET status = 'blocked', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), jobId]); + throw httpError(409, "adapter_not_ready", message, { adapterId: adapter.id }); + } + const timestamp = now(); + const attemptNumber = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0) + 1; + const attemptId = makeId("attempt"); + dbRun("UPDATE generation_jobs SET status = 'running', error_message = '', started_at = ?, finished_at = NULL, updated_at = ? WHERE id = ?", [timestamp, timestamp, jobId]); + dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, started_at, created_at) VALUES (?, ?, ?, ?, 'running', ?, ?)", [attemptId, jobId, attemptNumber, adapter.id, timestamp, timestamp]); + const contract = parseJson(job.request_json, {}); + try { + const request = await buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp); + const response = await fetchWithTimeout(request.url.toString(), request.options); + const { parsed } = await readAdapterResponse(response, jobId); + if (!response.ok) throw new Error(`模型连接器返回 HTTP ${response.status}: ${String(parsed?.detail || parsed?.error || parsed?.raw || "无响应内容").slice(0, 500)}`); + const result = normalizeResult(parsed, job.output_path); + assertSingleFrameResult(job, result); + const finishedAt = now(); + const resultRelative = `storage/jobs/${jobId}/result.json`; + await mkdir(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true }); + const artifacts = await registerJobArtifacts(context, job, result); + const storedResult = { ...result, resultFile: resultRelative, artifacts }; + await writeFile(resolve(projectRoot, resultRelative), `${JSON.stringify(storedResult, null, 2)}\n`, "utf8"); + dbRun("UPDATE generation_jobs SET status = 'completed', result_json = ?, output_path = ?, finished_at = ?, updated_at = ? WHERE id = ?", [JSON.stringify(storedResult), result.outputPath || job.output_path, finishedAt, finishedAt, jobId]); + dbRun("UPDATE job_attempts SET status = 'completed', finished_at = ? WHERE id = ?", [finishedAt, attemptId]); + dbRun("UPDATE model_connectors SET status = 'ready', last_probe_at = ?, latency_ms = ?, error_message = '' WHERE id = ?", [finishedAt, Math.max(0, Date.parse(finishedAt) - Date.parse(timestamp)), adapter.id]); + releaseReadyDependents(jobId); + addUsage({ context, kind: `${job.kind}:executed`, units: 1, unitName: "execution", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, attemptNumber } }); + addAudit({ context, action: "generation_job.completed", targetType: "generation_job", targetId: jobId, metadata: { adapter: adapter.id, attemptNumber, resultFile: resultRelative, artifactCount: artifacts.length } }); + void dispatchNotificationEvent({ context, eventKey: "job.completed", payload: { jobId, kind: job.kind, adapterId: adapter.id, resultFile: resultRelative, artifactCount: artifacts.length } }); + return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) }; + } catch (error) { + const finishedAt = now(); + const message = error.name === "AbortError" ? "模型连接器请求超时" : String(error.message || error).slice(0, 1000); + dbRun("UPDATE generation_jobs SET status = 'failed', error_message = ?, finished_at = ?, updated_at = ? WHERE id = ?", [message, finishedAt, finishedAt, jobId]); + dbRun("UPDATE job_attempts SET status = 'failed', error_message = ?, finished_at = ? WHERE id = ?", [message, finishedAt, attemptId]); + dbRun("UPDATE model_connectors SET status = 'error', last_probe_at = ?, error_message = ? WHERE id = ?", [finishedAt, message, adapter.id]); + addAudit({ context, action: "generation_job.failed", targetType: "generation_job", targetId: jobId, result: "error", metadata: { adapter: adapter.id, attemptNumber, error: message } }); + void dispatchNotificationEvent({ context, eventKey: "job.failed", payload: { jobId, kind: job.kind, adapterId: adapter.id, attemptNumber, error: message } }); + throw httpError(502, "adapter_execution_failed", message, { jobId, adapterId: adapter.id }); + } +} + +export async function probeModelConnector(context, modelId) { + requirePermission(context, "model:manage"); + const adapter = modelForContext(context, modelId); + const { local } = endpointInfo(adapter.endpoint); + if (!local && adapter.costMode === "local") throw httpError(403, "local_only_endpoint_required", "local-only 连接器只能指向本机或私有局域网地址"); + const protocol = adapterProtocol(adapter); + const probeRoute = protocol.healthRoute || (String(adapter.kind || "").toLowerCase().includes("openai-compatible") ? "models" : String(adapter.kind || "").toLowerCase() === "comfyui" ? "system_stats" : ""); + const probeUrl = joinEndpoint(adapter.endpoint, probeRoute); + const started = Date.now(); + let status = "error"; + let message = ""; + let httpStatus = null; + try { + const response = await fetchWithTimeout(probeUrl.toString(), { method: "GET", headers: { ...authHeaders(adapter), accept: "application/json" } }, 8000); + httpStatus = response.status; + if (response.ok || response.status === 401 || response.status === 403 || response.status === 405) status = "ready"; + else message = `探活返回 HTTP ${response.status}`; + } catch (error) { + message = error.name === "AbortError" ? "探活超时" : String(error.message || error).slice(0, 500); + } + const timestamp = now(); + dbRun("UPDATE model_connectors SET status = ?, last_probe_at = ?, latency_ms = ?, error_message = ?, updated_at = ? WHERE id = ?", [status, timestamp, Date.now() - started, message, timestamp, modelId]); + addAudit({ context, action: "model_connector.probed", targetType: "model_connector", targetId: modelId, result: status === "ready" ? "ok" : "error", metadata: { status, httpStatus, latencyMs: Date.now() - started, message } }); + return { model: parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [modelId])), httpStatus, message }; +} + +export function updateModelConnector(context, modelId, body) { + requirePermission(context, "model:manage"); + const current = dbGet("SELECT * FROM model_connectors WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", [modelId, context.organization.id, context.workspace.id]); + if (!current) throw httpError(404, "adapter_not_found", "模型连接器不存在或不属于当前工作区"); + const label = String(body.label ?? current.label).trim(); + const endpoint = String(body.endpoint ?? current.endpoint).trim(); + const kind = String(body.kind ?? current.kind).trim(); + const costMode = String(body.costMode ?? current.cost_mode).trim(); + const status = String(body.status ?? current.status).trim(); + if (!label || !endpoint) throw httpError(400, "adapter_fields_required", "连接器名称和地址不能为空"); + if (!["local", "mixed", "cloud"].includes(costMode)) throw httpError(400, "cost_mode_invalid", "成本策略无效"); + if (!["ready", "not-connected", "planned", "optional", "paused", "error"].includes(status)) throw httpError(400, "adapter_status_invalid", "连接器状态无效"); + const { local } = endpointInfo(endpoint); + if (costMode === "local" && !local) throw httpError(403, "local_only_endpoint_required", "local-only 连接器只能指向本机或私有局域网地址"); + const requestedApproval = body.approvalRequired === undefined ? Boolean(current.approval_required) : Boolean(body.approvalRequired); + if (costMode !== "local" && !requestedApproval) throw httpError(400, "external_connector_approval_required", "混合或外部连接器必须开启审批策略"); + const approvalRequired = costMode === "local" ? requestedApproval : true; + const capabilities = Array.isArray(body.capability) ? body.capability : parseJson(current.capabilities_json, []); + const currentProtocol = parseJson(current.protocol_json, {}); + const protocol = body.protocol && typeof body.protocol === "object" ? body.protocol : currentProtocol; + const authEnv = String(body.authEnv ?? current.auth_env ?? protocol.authEnv ?? "").trim(); + const timestamp = now(); + dbRun("UPDATE model_connectors SET label = ?, kind = ?, capabilities_json = ?, endpoint = ?, status = ?, cost_mode = ?, approval_required = ?, protocol_json = ?, auth_env = ?, error_message = ?, updated_at = ? WHERE id = ?", [label, kind, JSON.stringify(capabilities), endpoint, status, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, status === "error" ? current.error_message : "", timestamp, modelId]); + addAudit({ context, action: "model_connector.updated", targetType: "model_connector", targetId: modelId, metadata: { label, endpoint, status, costMode, approvalRequired } }); + return parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [modelId])); +} diff --git a/server/local-api.mjs b/server/local-api.mjs new file mode 100644 index 0000000..efdb9bd --- /dev/null +++ b/server/local-api.mjs @@ -0,0 +1,2632 @@ +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, + getAsset, + httpError, + orgMembers, + 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, + updateIdentityPolicy, + saveIdentityProvider, + probeIdentityProvider, + createDirectorySync, + updateDirectorySync, + rotateDirectorySyncToken, + systemUserDetail, + systemUsers, + createSystemUser, + resetSystemUserPassword, + resetSystemUserMfa, + updateSystemUserMemberships, + updateSystemUser, + scimListUsers, + scimCreateUser, + scimPatchUser, + scimDeleteUser, + serviceHealth, + updateAssetLock, + updateAssetRights, + restoreAssetVersion, + updateServiceHealth, + usageSummary, + organizationUsage, + exportOrganizationUsage, + organizationCommercial, + exportOrganizationCommercial, + updateOrganizationBilling, + updateQuotaAllocation, + updateCostCenter, + organizationInvoices, + organizationInvoice, + exportOrganizationInvoices, + generateOrganizationInvoice, + updateOrganizationInvoiceStatus, + assertOrganizationSeatAvailable, + resendOrganizationInvitation, + revokeOrganizationInvitation, + workspaceMembers, + listAuditEvents, + getAuditEvent, + exportAuditEvents +} 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, + importScript, + listDeliveryBatches, + listDeliveryChannels, + listDeliveries, + listDeliveryReleases, + listShotVersions, + materializeScript, + productionGraph, + qaReviews, + runAutomatedQa, + runMediaQa, + restoreShotVersion, + rollbackDeliveryBatch, + publishDeliveryRelease, + decideDeliveryRelease, + savePromptVersion, + updateBible, + updateDeliveryChannel, + updateEpisode, + updateJob, + updateShot +} from "./production.mjs"; +import { + createGenerationJob, + executeGenerationJob, + getGenerationJob, + listGenerationJobs, + endpointInfo, + probeModelConnector, + updateModelConnector +} from "./execution.mjs"; +import { requireStorageQuota, 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 } 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 { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs"; +import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs"; +import { createDeliveryAccessLink, listDeliveryAccessFeedback, listDeliveryAccessLinks, readPublicDeliveryFile, resolvePublicDeliveryPortal, revokeDeliveryAccessLink, submitPublicDeliveryFeedback } from "./delivery-portal.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]); + }); + 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"); + 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"); + 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 || "本地文件版本上传" + }); +} + +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")) { + 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) { + 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 roleKey = body.roleKey || "writer"; + if (!dbGet("SELECT key FROM roles WHERE key = ?", [roleKey])) throw httpError(400, "role_invalid", "邀请角色不存在", { roleKey }); + 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 && !dbGet("SELECT id FROM projects WHERE id = ? AND workspace_id = ?", [projectId, workspaceId])) throw httpError(400, "project_invalid", "邀请目标项目不属于当前工作区"); + 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 status = String(body.status || member.status); + roleForScope(roleKey, "workspace"); + if (!["active", "suspended"].includes(status)) throw httpError(400, "member_status_invalid", "成员状态无效"); + const timestamp = new Date().toISOString(); + dbRun("UPDATE workspace_members SET role_key = ?, status = ?, updated_at = ? WHERE workspace_id = ? AND user_id = ?", [roleKey, status, timestamp, workspaceId, userId]); + addAudit({ context, action: "workspace.member.updated", targetType: "workspace_member", targetId: `${workspaceId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, 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"); + 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(); + 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; + 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, body.kind || "http-json", 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(), + 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"); + 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/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 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/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")) { + 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")) { + 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 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() }); + } + + 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 === "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 === "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 }) }); + } + + 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 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))); + } + + 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}`); +}); diff --git a/server/media-artifacts.mjs b/server/media-artifacts.mjs new file mode 100644 index 0000000..828017e --- /dev/null +++ b/server/media-artifacts.mjs @@ -0,0 +1,291 @@ +import { access, mkdir, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { resolve, extname } from "node:path"; +import { promisify } from "node:util"; +import { dbAll, dbGet, dbRun } from "./db.mjs"; +import { inspectStoragePath } from "./media-qa.mjs"; + +const execFileAsync = promisify(execFile); +const projectRoot = resolve(import.meta.dirname, ".."); +const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg"; + +function parseJson(value, fallback) { + try { return JSON.parse(value); } catch { return fallback; } +} + +function now() { + return new Date().toISOString(); +} + +function makeId(prefix, value = "") { + const digest = createHash("sha1").update(String(value)).digest("hex").slice(0, 12); + return `${prefix}-${digest}`; +} + +function kindForJob(job) { + const kind = String(job?.kind || "").toLowerCase(); + if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) return "video"; + if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) return "image"; + if (kind.includes("tts") || kind.includes("配音") || kind.includes("声音") || kind.includes("audio")) return "audio"; + if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) return "json"; + return "unknown"; +} + +function extensionFor(kind, mimeType = "") { + const value = `${mimeType} ${kind}`.toLowerCase(); + if (value.includes("wav") || value.includes("audio")) return ".wav"; + if (value.includes("mp4") || value.includes("video")) return ".mp4"; + if (value.includes("webp")) return ".webp"; + if (value.includes("jpeg") || value.includes("jpg")) return ".jpg"; + if (value.includes("json")) return ".json"; + return ".png"; +} + +function candidatePath(value) { + if (typeof value === "string") return value.trim(); + if (!value || typeof value !== "object") return ""; + return String(value.outputPath || value.output_path || value.path || value.file || value.url || "").trim(); +} + +function candidateMime(value, fallback = "") { + if (!value || typeof value !== "object") return fallback; + return String(value.mimeType || value.mime_type || value.contentType || value.content_type || fallback).trim(); +} + +function safeOutputPath(value) { + const path = String(value || "").trim(); + if (!path || path.startsWith("/") || path.includes("..") || !path.startsWith("storage/")) return ""; + return path; +} + +async function materializeBase64(job, value, index, kind, mimeType = "") { + const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || ""; + if (!encoded) return ""; + const payload = encoded.includes(",") && encoded.startsWith("data:") ? encoded.split(",", 2)[1] : encoded; + let buffer; + try { buffer = Buffer.from(payload, "base64"); } catch { return ""; } + if (!buffer.length) return ""; + const relative = `storage/jobs/${job.id}/outputs/${kind}-${index + 1}${extensionFor(kind, mimeType)}`; + await mkdir(resolve(projectRoot, `storage/jobs/${job.id}/outputs`), { recursive: true }); + await writeFile(resolve(projectRoot, relative), buffer); + return relative; +} + +async function extractVideoFrames(owner, inputPath, kind) { + if (kind !== "video") return { first: "", last: "" }; + const prefix = owner.jobId ? `storage/jobs/${owner.jobId}` : `storage/compositions/${owner.compositionId}`; + if (!prefix || !inputPath) return { first: "", last: "" }; + const frameDirectory = resolve(projectRoot, prefix, "frames"); + await mkdir(frameDirectory, { recursive: true }); + const first = `${prefix}/frames/first.jpg`; + const last = `${prefix}/frames/last.jpg`; + const jobs = [ + ["first", ["-y", "-v", "error", "-i", resolve(projectRoot, inputPath), "-frames:v", "1", "-q:v", "2", resolve(projectRoot, first)]], + ["last", ["-y", "-v", "error", "-sseof", "-0.1", "-i", resolve(projectRoot, inputPath), "-frames:v", "1", "-q:v", "2", resolve(projectRoot, last)]] + ]; + const result = { first: "", last: "" }; + for (const [name, args] of jobs) { + try { + await execFileAsync(ffmpegBinary, args, { timeout: 20_000, maxBuffer: 1024 * 1024 }); + await access(resolve(projectRoot, name === "first" ? first : last)); + result[name] = name === "first" ? first : last; + } catch { + result[name] = ""; + } + } + return result; +} + +function artifactPayload(row) { + if (!row) return null; + return { + ...row, + metadata: parseJson(row.metadata_json, {}), + fileSize: Number(row.file_size || 0), + durationSec: Number(row.duration_sec || 0), + width: Number(row.width || 0), + height: Number(row.height || 0), + hasVideo: Boolean(row.has_video), + hasAudio: Boolean(row.has_audio) + }; +} + +async function registerPath(context, descriptor) { + const path = safeOutputPath(descriptor.path); + if (!path) return null; + const inspected = await inspectStoragePath(path, { mimeType: descriptor.mimeType || "" }); + const timestamp = now(); + const identity = `${descriptor.jobId || "no-job"}:${descriptor.compositionId || "no-composition"}:${descriptor.role || "output"}:${path}`; + const id = makeId("artifact", identity); + const metadata = { + ...(descriptor.metadata || {}), + inspectedAt: timestamp, + probeError: inspected.probeError || inspected.reason || "" + }; + dbRun( + `INSERT INTO media_artifacts( + id, organization_id, workspace_id, project_id, episode_id, shot_id, job_id, composition_id, + kind, role, status, path, mime_type, file_size, sha256, duration_sec, width, height, + has_video, has_audio, first_frame_path, last_frame_path, metadata_json, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + mime_type = excluded.mime_type, + file_size = excluded.file_size, + sha256 = excluded.sha256, + duration_sec = excluded.duration_sec, + width = excluded.width, + height = excluded.height, + has_video = excluded.has_video, + has_audio = excluded.has_audio, + first_frame_path = excluded.first_frame_path, + last_frame_path = excluded.last_frame_path, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at`, + [ + id, + context.organization.id, + context.workspace.id, + context.project.id, + descriptor.episodeId || null, + descriptor.shotId || null, + descriptor.jobId || null, + descriptor.compositionId || null, + descriptor.kind || "unknown", + descriptor.role || "output", + inspected.status, + path, + descriptor.mimeType || "", + Number(inspected.bytes || 0), + inspected.sha256 || "", + Number(inspected.durationSec || 0), + Number(inspected.width || 0), + Number(inspected.height || 0), + inspected.hasVideo ? 1 : 0, + inspected.hasAudio ? 1 : 0, + descriptor.firstFramePath || "", + descriptor.lastFramePath || "", + JSON.stringify(metadata), + descriptor.createdBy || context.user?.id || null, + timestamp, + timestamp + ] + ); + const row = dbGet("SELECT * FROM media_artifacts WHERE id = ?", [id]) || dbGet("SELECT * FROM media_artifacts WHERE job_id IS ? AND composition_id IS ? AND path = ? AND role = ? ORDER BY updated_at DESC LIMIT 1", [descriptor.jobId || null, descriptor.compositionId || null, path, descriptor.role || "output"]); + return { row, inspected }; +} + +export async function registerJobArtifacts(context, job, result = {}) { + const kind = kindForJob(job); + const candidates = []; + const seen = new Set(); + const add = async (value, role = "output", mimeType = "") => { + const path = candidatePath(value); + if (path) { + const safe = safeOutputPath(path); + if (!safe || seen.has(`${role}:${safe}`)) return; + seen.add(`${role}:${safe}`); + candidates.push({ path: safe, role, mimeType: candidateMime(value, mimeType) }); + return; + } + const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || ""; + if (!encoded) return; + const materialized = await materializeBase64(job, value, candidates.length, kind, candidateMime(value, mimeType)); + if (materialized && !seen.has(`${role}:${materialized}`)) { + seen.add(`${role}:${materialized}`); + candidates.push({ path: materialized, role, mimeType: candidateMime(value, mimeType) }); + } + }; + + const hasArrayOutputs = [result.data, result.images, result.outputs].some(Array.isArray); + const normalizedOutput = result.outputPath || result.output_path || result.path || result.file; + if (!hasArrayOutputs || normalizedOutput !== job.output_path) { + await add(normalizedOutput, "output", result.mimeType || result.contentType || ""); + } + await add(result.audioPath || result.audio_path, "audio", "audio/wav"); + await add(result.videoPath || result.video_path, "video", "video/mp4"); + await add(result.imagePath || result.image_path, "image", "image/png"); + for (const key of ["data", "images", "outputs"]) { + if (!Array.isArray(result[key])) continue; + for (const value of result[key]) await add(value, kind === "image" ? "image" : "output", candidateMime(value, kind === "image" ? "image/png" : "")); + } + if (!candidates.length && job.output_path) await add(job.output_path, "output", ""); + + const artifacts = []; + for (const candidate of candidates) { + const registered = await registerPath(context, { + ...candidate, + kind, + jobId: job.id, + episodeId: job.episode_id, + shotId: job.shot_id, + createdBy: job.created_by, + metadata: { source: "generation-job", resultKeys: Object.keys(result || {}) } + }); + if (!registered?.row) continue; + let row = registered.row; + if (kind === "video" && registered.inspected.status === "inspected") { + const frames = await extractVideoFrames({ jobId: job.id }, candidate.path, kind); + if (frames.first || frames.last) { + dbRun("UPDATE media_artifacts SET first_frame_path = ?, last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.first, frames.last, now(), row.id]); + row = dbGet("SELECT * FROM media_artifacts WHERE id = ?", [row.id]); + if (job.shot_id && frames.last) { + const currentShot = dbGet("SELECT last_frame_path FROM shots WHERE id = ?", [job.shot_id]); + const currentLastFrame = await inspectStoragePath(currentShot?.last_frame_path || ""); + const canKeepCurrentLastFrame = currentLastFrame.status === "inspected" && !/pending|auto_previous/i.test(currentShot?.last_frame_path || ""); + if (!canKeepCurrentLastFrame) dbRun("UPDATE shots SET last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.last, now(), job.shot_id]); + const nextShot = dbGet("SELECT next.id, next.first_frame_path FROM shots current_shot JOIN shots next ON next.episode_id = current_shot.episode_id AND next.shot_number = current_shot.shot_number + 1 WHERE current_shot.id = ?", [job.shot_id]); + const nextFirstFrame = await inspectStoragePath(nextShot?.first_frame_path || ""); + if (nextShot && (nextFirstFrame.status !== "inspected" || /pending|auto_previous|actual-last-frame/i.test(nextShot.first_frame_path || ""))) { + dbRun("UPDATE shots SET first_frame_path = ?, updated_at = ? WHERE id = ?", [frames.last, now(), nextShot.id]); + } + } + } + } + artifacts.push(artifactPayload(row)); + } + return artifacts; +} + +export async function registerCompositionArtifact(context, composition, path) { + const registered = await registerPath(context, { + path, + kind: "video", + role: "composition-output", + compositionId: composition.id, + episodeId: composition.episode_id, + createdBy: composition.created_by, + metadata: { source: "ffmpeg-composition", version: composition.version } + }); + if (!registered?.row) return null; + const frames = await extractVideoFrames({ compositionId: composition.id }, path, "video"); + if (frames.first || frames.last) { + dbRun("UPDATE media_artifacts SET first_frame_path = ?, last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.first, frames.last, now(), registered.row.id]); + } + return artifactPayload(dbGet("SELECT * FROM media_artifacts WHERE id = ?", [registered.row.id])); +} + +export async function syncProjectJobArtifacts(context) { + const jobs = dbAll("SELECT * FROM generation_jobs WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND status = 'completed' ORDER BY finished_at DESC", [context.organization.id, context.workspace.id, context.project.id]); + const artifacts = []; + for (const job of jobs) { + const result = parseJson(job.result_json, {}); + artifacts.push(...await registerJobArtifacts(context, job, result)); + } + return artifacts; +} + +export function listProjectArtifacts(context, options = {}) { + const params = [context.organization.id, context.workspace.id, context.project.id]; + let where = "organization_id = ? AND workspace_id = ? AND project_id = ?"; + if (options.shotId) { where += " AND shot_id = ?"; params.push(options.shotId); } + if (options.jobId) { where += " AND job_id = ?"; params.push(options.jobId); } + const limit = Math.max(1, Math.min(500, Number(options.limit || 200))); + return dbAll(`SELECT * FROM media_artifacts WHERE ${where} ORDER BY created_at DESC LIMIT ?`, [...params, limit]).map(artifactPayload); +} + +export function latestArtifactForShot(context, shotId, kind = "video") { + const row = dbGet("SELECT * FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND shot_id = ? AND kind = ? ORDER BY CASE WHEN status = 'inspected' THEN 0 ELSE 1 END, created_at DESC LIMIT 1", [context.organization.id, context.workspace.id, context.project.id, shotId, kind]); + return artifactPayload(row); +} diff --git a/server/media-qa.mjs b/server/media-qa.mjs new file mode 100644 index 0000000..1f294ae --- /dev/null +++ b/server/media-qa.mjs @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { createReadStream } from "node:fs"; +import { access, stat } from "node:fs/promises"; +import { promisify } from "node:util"; +import { resolve } from "node:path"; +import { dbAll, dbGet } from "./db.mjs"; + +const execFileAsync = promisify(execFile); +const projectRoot = resolve(import.meta.dirname, ".."); +const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg"; +const ffprobeBinary = process.env.FFPROBE_BIN || "ffprobe"; + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function safeStoragePath(value) { + const relative = String(value || "").trim(); + if (!relative || relative.startsWith("/") || relative.includes("..") || !relative.startsWith("storage/")) return null; + const absolute = resolve(projectRoot, relative); + if (!absolute.startsWith(`${projectRoot}/storage/`)) return null; + return { relative, absolute }; +} + +function mediaKind(pathname, mimeType = "") { + const value = `${pathname} ${mimeType}`.toLowerCase(); + if (value.includes("audio") || /\.(wav|mp3|m4a|aac|flac|ogg)$/.test(value)) return "audio"; + if (value.includes("video") || /\.(mp4|mov|webm|mkv|avi)$/.test(value)) return "video"; + if (value.includes("image") || /\.(png|jpg|jpeg|webp|gif)$/.test(value)) return "image"; + return "unknown"; +} + +async function fileSha256(pathname) { + return new Promise((resolveHash, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(pathname); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", reject); + stream.on("end", () => resolveHash(hash.digest("hex"))); + }); +} + +async function probeMedia(pathname) { + try { + const result = await execFileAsync(ffprobeBinary, [ + "-v", "error", + "-show_streams", + "-show_format", + "-of", "json", + pathname + ], { timeout: 12_000, maxBuffer: 2 * 1024 * 1024 }); + return JSON.parse(String(result.stdout || "{}")); + } catch (error) { + return { error: String(error.stderr || error.message || error).slice(0, 500) }; + } +} + +async function averageFrame(pathname, kind, position = "first") { + try { + const args = ["-v", "error"]; + if (kind === "video" && position === "last") args.push("-sseof", "-0.1"); + args.push("-i", pathname, "-vf", "scale=1:1,format=rgb24", "-frames:v", "1", "-f", "rawvideo", "pipe:1"); + const result = await execFileAsync(ffmpegBinary, args, { timeout: 12_000, maxBuffer: 1024, encoding: "buffer" }); + const bytes = Buffer.from(result.stdout || []); + if (bytes.length < 3) return null; + return [bytes[0], bytes[1], bytes[2]]; + } catch { + return null; + } +} + +function colorDistance(left, right) { + if (!left || !right) return null; + const distance = Math.sqrt(left.reduce((sum, value, index) => sum + ((value - right[index]) ** 2), 0)) / (255 * Math.sqrt(3)); + return Number(distance.toFixed(4)); +} + +export async function inspectStoragePath(value, options = {}) { + const target = safeStoragePath(value); + if (!target) return { path: String(value || ""), status: "unverified", reason: "路径不是 storage/ 下的安全相对路径" }; + try { + await access(target.absolute); + const info = await stat(target.absolute); + const kind = mediaKind(target.relative, options.mimeType); + const probe = await probeMedia(target.absolute); + const streams = Array.isArray(probe.streams) ? probe.streams : []; + const video = streams.find((stream) => stream.codec_type === "video"); + const audio = streams.find((stream) => stream.codec_type === "audio"); + return { + path: target.relative, + status: probe.error ? "unverified" : "inspected", + bytes: info.size, + sha256: await fileSha256(target.absolute), + kind, + durationSec: Number(probe.format?.duration || video?.duration || audio?.duration || 0), + width: Number(video?.width || 0), + height: Number(video?.height || 0), + hasVideo: Boolean(video), + hasAudio: Boolean(audio), + probeError: probe.error || "", + firstFingerprint: kind === "image" || kind === "video" ? await averageFrame(target.absolute, kind, "first") : null, + lastFingerprint: kind === "image" || kind === "video" ? await averageFrame(target.absolute, kind, "last") : null + }; + } catch (error) { + return { path: target.relative, status: "missing", reason: String(error.message || error).slice(0, 300) }; + } +} + +function completedJobForShot(shotId, matcher) { + const rows = dbAll("SELECT * FROM generation_jobs WHERE shot_id = ? AND status = 'completed' ORDER BY finished_at DESC, created_at DESC", [shotId]); + return rows.find((row) => matcher(String(row.kind || "").toLowerCase())) || null; +} + +function resultOutput(row) { + if (!row) return ""; + const result = parseJson(row.result_json, {}); + return String(row.output_path || result.outputPath || result.output_path || result.path || "").trim(); +} + +async function inspectShot(shot, previousShot) { + const imageJob = completedJobForShot(shot.id, (kind) => kind.includes("图") || kind.includes("关键帧") || kind.includes("image")); + const videoJob = completedJobForShot(shot.id, (kind) => kind.includes("视频") || kind.includes("i2v") || kind.includes("video")); + const asrJob = completedJobForShot(shot.id, (kind) => kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")); + const imageResult = parseJson(imageJob?.result_json, {}); + const imageArrays = [imageResult.data, imageResult.images, imageResult.outputs].filter(Array.isArray); + const videoPath = resultOutput(videoJob); + const imageArtifacts = imageJob + ? dbAll("SELECT * FROM media_artifacts WHERE job_id = ? ORDER BY created_at DESC", [imageJob.id]) + : []; + const videoArtifacts = videoJob + ? dbAll("SELECT * FROM media_artifacts WHERE job_id = ? ORDER BY created_at DESC", [videoJob.id]) + : []; + const imageArtifactCount = imageArtifacts.filter((artifact) => artifact.kind === "image" && artifact.status === "inspected").length; + const imageOutputCount = imageArtifactCount || (imageArrays.length ? imageArrays[0].length : null); + const selectedVideoArtifact = videoArtifacts.find((artifact) => artifact.kind === "video") || null; + const selectedVideoPath = selectedVideoArtifact?.path || videoPath; + const video = selectedVideoPath + ? await inspectStoragePath(selectedVideoPath, { mimeType: selectedVideoArtifact?.mime_type || "" }) + : { status: "unverified", reason: "没有已完成的视频任务" }; + const lastFrame = await inspectStoragePath(shot.lastFrame); + const firstFrame = await inspectStoragePath(shot.firstFrame); + const previousLastFrame = previousShot ? await inspectStoragePath(previousShot.lastFrame) : null; + const currentFirstFingerprint = firstFrame?.firstFingerprint || firstFrame?.lastFingerprint; + const previousLastFingerprint = previousLastFrame?.lastFingerprint || previousLastFrame?.firstFingerprint; + const bridgeDistance = colorDistance(previousLastFingerprint, currentFirstFingerprint); + const lines = Array.isArray(shot.voiceLines) ? shot.voiceLines : []; + const voiceEvidence = await Promise.all(lines.map(async (line) => ({ + ...line, + media: line.audioFile ? await inspectStoragePath(line.audioFile, { mimeType: "audio/wav" }) : { status: "missing", reason: "对白没有音频路径" } + }))); + const lockedVoice = lines.length === 0 || voiceEvidence.every((line) => line.voiceId && line.media.status === "inspected"); + const asrResult = parseJson(asrJob?.result_json, {}); + const segments = Array.isArray(asrResult.segments) ? asrResult.segments : Array.isArray(asrResult.result?.segments) ? asrResult.result.segments : []; + const hasAsrEvidence = segments.length > 0 || Boolean(asrJob?.result_json && asrJob.result_json !== "{}"); + const targetDialogueSec = lines.reduce((sum, line) => sum + Number(line.targetDurationSec || 0), 0); + + const singleFrame = imageOutputCount === null + ? { status: "pending", blockers: ["没有已完成的单画面图像任务,等待真实模型输出"], evidence: { imageJobId: imageJob?.id || null, imageOutputCount: null } } + : { status: imageOutputCount === 1 ? "approved" : "changes_requested", blockers: imageOutputCount === 1 ? [] : [`图像输出数量为 ${imageOutputCount},要求恰好 1 张`], evidence: { imageJobId: imageJob.id, imageOutputCount } }; + const continuity = lastFrame.status === "inspected" && (shot.shotNumber <= 1 || shot.transitionFromPrevious === "episode-start" || (bridgeDistance !== null && bridgeDistance <= 0.35)) + ? { status: "approved", blockers: [], evidence: { lastFrame, firstFrame, bridgeDistance, source: "frame-inspection" } } + : { status: "pending", blockers: [shot.shotNumber > 1 ? "无法确认上一段实际末帧与当前首帧的文件级衔接" : "当前镜头尚未产生可检查的实际末帧"], evidence: { lastFrame, firstFrame, previousLastFrame, bridgeDistance, source: "frame-inspection" } }; + const voice = lockedVoice && (lines.length === 0 || (hasAsrEvidence && segments.length > 0)) + ? { status: "approved", blockers: [], evidence: { lineCount: lines.length, lockedVoice, asrJobId: asrJob?.id || null, segmentCount: segments.length, targetDialogueSec, voiceEvidence } } + : { status: "pending", blockers: [lockedVoice ? "缺少可验证的 ASR/字幕对齐证据" : "对白缺少固定 voiceId 或可读取的音频文件"], evidence: { lineCount: lines.length, lockedVoice, asrJobId: asrJob?.id || null, segmentCount: segments.length, targetDialogueSec, voiceEvidence } }; + const clip = video.status === "inspected" && video.hasVideo && Number(video.durationSec || 0) > 0 + ? { status: "approved", blockers: [], evidence: { videoJobId: videoJob?.id || null, video } } + : { status: "pending", blockers: ["没有可验证的视频文件或 FFprobe 无法读取视频流"], evidence: { videoJobId: videoJob?.id || null, video } }; + return { + shotId: shot.id, + gates: [ + { lane: "single-frame", ...singleFrame }, + { lane: "continuity-lock", ...continuity }, + { lane: "voice-subtitle-asr", ...voice }, + { lane: "clip-bridge", ...clip } + ], + media: { imageJobId: imageJob?.id || null, videoJobId: videoJob?.id || null, asrJobId: asrJob?.id || null, imageArtifacts, videoArtifact: selectedVideoArtifact, video, firstFrame, lastFrame, bridgeDistance } + }; +} + +export async function inspectMediaForProject(context, shots) { + const reports = []; + for (let index = 0; index < shots.length; index += 1) reports.push(await inspectShot(shots[index], shots[index - 1] || null)); + const gates = reports.flatMap((report) => report.gates); + return { + reports, + totals: { + shots: reports.length, + gates: gates.length, + approved: gates.filter((gate) => gate.status === "approved").length, + changesRequested: gates.filter((gate) => gate.status === "changes_requested").length, + pending: gates.filter((gate) => gate.status === "pending").length + }, + checkedAt: new Date().toISOString(), + tools: { ffmpeg: ffmpegBinary, ffprobe: ffprobeBinary }, + scope: { organizationId: context.organization?.id, workspaceId: context.workspace?.id, projectId: context.project?.id } + }; +} diff --git a/server/notifications.mjs b/server/notifications.mjs new file mode 100644 index 0000000..45afa92 --- /dev/null +++ b/server/notifications.mjs @@ -0,0 +1,409 @@ +import { dbAll, dbGet, dbRun } from "./db.mjs"; + +const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function privateHost(hostname) { + const host = String(hostname || "").toLowerCase(); + if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true; + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; + return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168); +} + +async function fetchWithTimeout(url, options, timeoutMs = 8000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function eventMatches(channel, eventKey) { + const events = parseJson(channel.events_json, []); + return events.includes("*") || events.includes(eventKey); +} + +function notificationError(status, code, message, details = {}) { + const error = new Error(message); + error.status = status; + error.code = code; + error.details = details; + return error; +} + +const NOTIFICATION_PREFERENCE_CATALOG = [ + { category: "job", label: "生成任务", description: "本地任务完成、失败和重试状态" }, + { category: "review", label: "审片流程", description: "质检通过、驳回、修改和新评论" }, + { category: "delivery", label: "交付运营", description: "交付版本创建、批准和回滚" }, + { category: "task", label: "协作任务", description: "任务分派、状态变更和截止提醒" }, + { category: "access", label: "组织访问", description: "邀请、成员和权限相关事件" }, + { category: "billing", label: "用量与配额", description: "配额预警和成本边界" }, + { category: "system", label: "系统通知", description: "平台策略和系统范围变更" } +]; + +function notificationDefinition(eventKey, payload = {}) { + const kind = String(payload.kind || "生成任务"); + const shot = payload.shotTitle || payload.shotId || "当前镜头"; + const error = String(payload.error || "模型连接器返回了失败结果").slice(0, 180); + const lane = payload.lane || "连续性检查"; + const version = payload.version || "交付版本"; + const definitions = { + "job.completed": { + category: "job", + severity: "success", + title: `${kind}已完成`, + body: `${shot} 已完成本地执行,产出 ${Number(payload.artifactCount || 0)} 个可检查文件。`, + targetTab: "jobs" + }, + "job.failed": { + category: "job", + severity: "error", + title: `${kind}生成失败`, + body: `${shot} 的本地任务失败:${error}`, + targetTab: "jobs" + }, + "review.approved": { + category: "review", + severity: "success", + title: `${lane}已通过`, + body: `${shot} 的审片门已通过,可以继续下一步生产。`, + targetTab: "qa" + }, + "review.changes_requested": { + category: "review", + severity: "warning", + title: `${lane}需要修改`, + body: `${shot} 的审片门要求补充证据或修改后再提交。`, + targetTab: "qa" + }, + "review.rejected": { + category: "review", + severity: "error", + title: `${lane}已驳回`, + body: `${shot} 未通过审片,请查看阻断项并重新提交。`, + targetTab: "qa" + }, + "review.comment": { + category: "review", + severity: "info", + title: "审片中心有新评论", + body: `${shot} 收到新的审片意见,请回到审片中心查看。`, + targetTab: "qa" + }, + "delivery.created": { + category: "delivery", + severity: "info", + title: `${version} 已创建`, + body: "新的内部交付版本已建立,等待批次证据和审片结果。", + targetTab: "export" + }, + "delivery.approved": { + category: "delivery", + severity: "success", + title: `${version} 已批准交付`, + body: "交付版本已通过所有阻断门,可以进入本地发布或导出流程。", + targetTab: "export" + }, + "task.assigned": { + category: "task", + severity: "info", + title: "你收到一个协作任务", + body: `任务“${payload.taskTitle || "未命名任务"}”已分配给你,请在任务中心确认负责人和截止时间。`, + targetTab: "tasks" + }, + "task.updated": { + category: "task", + severity: payload.status === "blocked" ? "warning" : payload.status === "done" ? "success" : "info", + title: "协作任务状态已更新", + body: `任务“${payload.taskTitle || "未命名任务"}”当前状态:${payload.status || "已更新"}。`, + targetTab: "tasks" + }, + "task.commented": { + category: "task", + severity: "info", + title: "协作任务有新讨论", + body: `任务“${payload.taskTitle || "未命名任务"}”收到新的评论或 @提醒,请打开任务详情查看。`, + targetTab: "tasks" + }, + "invitation.created": { + category: "access", + severity: "info", + title: "你收到新的组织邀请", + body: `${payload.organizationName || "一个生产组织"} 邀请你以 ${payload.roleName || payload.roleKey || "成员"} 身份加入。`, + targetTab: "account" + }, + "system.changed": { + category: "system", + severity: "info", + title: "系统配置已更新", + body: payload.detail || "系统范围配置发生了变化,请确认当前生产策略。", + targetTab: payload.targetTab || "system-overview" + }, + "quota.warning": { + category: "billing", + severity: "warning", + title: "组织配额即将达到上限", + body: payload.detail || "请联系组织管理员调整配额或清理不再需要的产物。", + targetTab: "admin-usage" + } + }; + return definitions[eventKey] || { + category: String(payload.category || "system"), + severity: String(payload.severity || "info"), + title: String(payload.title || eventKey || "平台通知"), + body: String(payload.body || "生产平台收到一条新的事件通知。"), + targetTab: String(payload.targetTab || "creator-home") + }; +} + +function scopedRecipientIds(context, eventKey, payload = {}) { + const requested = [ + ...(Array.isArray(payload.recipientUserIds) ? payload.recipientUserIds : []), + ...(payload.recipientUserId ? [payload.recipientUserId] : []) + ].map((value) => String(value || "").trim()).filter(Boolean); + if (requested.length) { + const placeholders = requested.map(() => "?").join(","); + return dbAll( + `SELECT DISTINCT u.id + FROM users u + JOIN organization_members om ON om.user_id = u.id + WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active' + AND u.id IN (${placeholders})`, + [context.organization.id, ...requested] + ).map((row) => row.id); + } + + if (!context?.organization?.id || !context?.user?.id) return []; + if (eventKey.startsWith("system.")) return [context.user.id]; + if (eventKey === "invitation.created") return payload.recipientUserId ? [String(payload.recipientUserId)] : [context.user.id]; + + return dbAll( + `SELECT DISTINCT u.id + FROM users u + JOIN organization_members om ON om.user_id = u.id + LEFT JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active' + LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active' + WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active' + AND (om.role_key IN ('org_owner', 'org_admin') OR wm.user_id IS NOT NULL OR pm.user_id IS NOT NULL)`, + [context.workspace?.id || "", context.project?.id || "", context.organization.id] + ).map((row) => row.id); +} + +function userNotificationPayload(row) { + return { + id: row.id, + category: row.category, + eventKey: row.event_key, + severity: row.severity, + title: row.title, + body: row.body, + targetTab: row.target_tab, + targetId: row.target_id || "", + metadata: parseJson(row.metadata_json, {}), + createdAt: row.created_at, + readAt: row.read_at, + read: Boolean(row.read_at), + organizationId: row.organization_id, + workspaceId: row.workspace_id, + projectId: row.project_id + }; +} + +function preferenceCatalogItem(row) { + const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === row.category) || { category: row.category, label: row.category, description: "" }; + return { + ...definition, + enabled: row.in_app_enabled === undefined ? true : Boolean(row.in_app_enabled), + customized: row.in_app_enabled !== undefined + }; +} + +function inAppScope(context) { + if (!context?.user?.id || !context?.organization?.id) throw notificationError(401, "auth_required", "请先登录"); + return { + where: "user_id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", + params: [context.user.id, context.organization.id, context.workspace?.id || ""] + }; +} + +function createUserNotifications({ context, eventKey, payload = {}, definition }) { + const recipientIds = scopedRecipientIds(context, eventKey, payload); + if (!recipientIds.length) return []; + const timestamp = new Date().toISOString(); + const metadata = { ...payload }; + delete metadata.recipientUserId; + delete metadata.recipientUserIds; + const created = []; + for (const userId of recipientIds) { + const preference = dbGet("SELECT in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ? AND category = ?", [userId, context.organization.id, definition.category]); + if (preference && !Boolean(preference.in_app_enabled)) continue; + const id = makeId("user-notification"); + dbRun( + "INSERT INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + id, + userId, + context.organization.id, + context.workspace?.id || null, + context.project?.id || null, + definition.category, + eventKey, + definition.severity, + definition.title, + definition.body, + definition.targetTab, + String(payload.targetId || payload.reviewId || payload.jobId || payload.deliveryId || ""), + JSON.stringify(metadata), + timestamp + ] + ); + created.push(dbGet("SELECT * FROM user_notifications WHERE id = ?", [id])); + } + return created; +} + +export function listUserNotificationPreferences(context) { + const scope = inAppScope(context); + const rows = dbAll("SELECT category, in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ?", [scope.params[0], scope.params[1]]); + const byCategory = new Map(rows.map((row) => [row.category, row])); + return { + preferences: NOTIFICATION_PREFERENCE_CATALOG.map((definition) => preferenceCatalogItem(byCategory.get(definition.category) || { category: definition.category })), + scope: { organizationId: context.organization.id }, + generatedAt: new Date().toISOString() + }; +} + +export function updateUserNotificationPreference(context, category, enabled) { + inAppScope(context); + const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === String(category || "").trim()); + if (!definition) throw notificationError(400, "notification_category_invalid", "通知类别不存在", { category }); + dbRun( + "INSERT INTO user_notification_preferences(user_id, organization_id, category, in_app_enabled, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, organization_id, category) DO UPDATE SET in_app_enabled = excluded.in_app_enabled, updated_at = excluded.updated_at", + [context.user.id, context.organization.id, definition.category, enabled ? 1 : 0, new Date().toISOString()] + ); + return listUserNotificationPreferences(context); +} + +export function listUserNotifications(context, { limit = 60, unreadOnly = false } = {}) { + const scope = inAppScope(context); + const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 60))); + const unreadClause = unreadOnly ? " AND read_at IS NULL" : ""; + const rows = dbAll( + `SELECT * FROM user_notifications + WHERE ${scope.where}${unreadClause} + ORDER BY CASE WHEN read_at IS NULL THEN 0 ELSE 1 END, created_at DESC + LIMIT ?`, + [...scope.params, normalizedLimit] + ); + const unreadCount = Number(dbGet(`SELECT COUNT(*) AS count FROM user_notifications WHERE ${scope.where} AND read_at IS NULL`, scope.params)?.count || 0); + return { + notifications: rows.map(userNotificationPayload), + unreadCount, + total: rows.length, + scope: { organizationId: context.organization.id, workspaceId: context.workspace?.id || "" }, + generatedAt: new Date().toISOString() + }; +} + +export function markUserNotificationRead(context, notificationId, read = true) { + const scope = inAppScope(context); + const current = dbGet(`SELECT * FROM user_notifications WHERE id = ? AND ${scope.where}`, [notificationId, ...scope.params]); + if (!current) throw notificationError(404, "notification_not_found", "通知不存在或不属于当前工作区"); + dbRun(`UPDATE user_notifications SET read_at = ? WHERE id = ? AND ${scope.where}`, [read ? new Date().toISOString() : null, notificationId, ...scope.params]); + return listUserNotifications(context, { limit: 60 }); +} + +export function markAllUserNotificationsRead(context) { + const scope = inAppScope(context); + dbRun(`UPDATE user_notifications SET read_at = COALESCE(read_at, ?) WHERE ${scope.where} AND read_at IS NULL`, [new Date().toISOString(), ...scope.params]); + return listUserNotifications(context, { limit: 60 }); +} + +async function deliver(channel, eventKey, requestBody) { + const id = makeId("delivery"); + const timestamp = new Date().toISOString(); + dbRun("INSERT INTO notification_deliveries(id, channel_id, event_key, organization_id, workspace_id, project_id, status, attempt_count, request_json, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)", [id, channel.id, eventKey, requestBody.organizationId || null, requestBody.workspaceId || null, requestBody.projectId || null, JSON.stringify(requestBody), timestamp]); + let status = "delivered"; + let response = { kind: channel.kind }; + let errorMessage = ""; + let httpStatus = null; + try { + if (channel.kind === "local-log") { + response = { logged: true, event: eventKey }; + } else if (channel.kind === "webhook") { + const endpoint = new URL(String(channel.endpoint || "")); + if (endpoint.protocol !== "http:" || !privateHost(endpoint.hostname)) { + status = "blocked"; + errorMessage = "本地策略只允许投递到 HTTP 私有地址"; + } else { + const result = await fetchWithTimeout(endpoint.toString(), { + method: "POST", + headers: { "content-type": "application/json", "x-ai-drama-event": eventKey }, + body: JSON.stringify(requestBody) + }); + httpStatus = result.status; + const raw = await result.text(); + response = { httpStatus, body: raw.slice(0, 1000) }; + if (!result.ok) { + status = "failed"; + errorMessage = `Webhook 返回 HTTP ${result.status}`; + } + } + } else { + status = "blocked"; + errorMessage = `通知渠道类型 ${channel.kind} 暂不支持`; + } + } catch (error) { + status = "failed"; + errorMessage = error.name === "AbortError" ? "通知投递超时" : String(error.message || error).slice(0, 500); + } + const deliveredAt = status === "delivered" ? new Date().toISOString() : null; + dbRun("UPDATE notification_deliveries SET status = ?, attempt_count = 1, response_json = ?, error_message = ?, delivered_at = ? WHERE id = ?", [status, JSON.stringify({ ...response, httpStatus }), errorMessage, deliveredAt, id]); + return dbGet("SELECT * FROM notification_deliveries WHERE id = ?", [id]); +} + +export async function dispatchNotificationEvent({ context, eventKey, payload = {} }) { + const definition = notificationDefinition(eventKey, payload); + const userNotifications = createUserNotifications({ context, eventKey, payload, definition }); + const channels = dbAll("SELECT * FROM notification_channels WHERE enabled = 1 ORDER BY created_at").filter((channel) => eventMatches(channel, eventKey)); + const requestBody = { + schema: "ai-drama.notification.v1", + event: eventKey, + occurredAt: new Date().toISOString(), + actor: context?.user ? { id: context.user.id, name: context.user.display_name } : null, + organizationId: context?.organization?.id || null, + workspaceId: context?.workspace?.id || null, + projectId: context?.project?.id || null, + payload + }; + const deliveries = []; + for (const channel of channels) deliveries.push(await deliver(channel, eventKey, requestBody)); + return { deliveries, userNotifications: userNotifications.map(userNotificationPayload) }; +} + +export function notificationDeliveries(context, limit = 80) { + return dbAll( + `SELECT d.*, c.name AS channel_name, c.kind AS channel_kind + FROM notification_deliveries d + JOIN notification_channels c ON c.id = d.channel_id + WHERE d.organization_id = ? + AND (d.workspace_id IS NULL OR d.workspace_id = ?) + AND (d.project_id IS NULL OR d.project_id = ?) + ORDER BY d.created_at DESC LIMIT ?`, + [context.organization.id, context.workspace.id, context.project?.id || "", Math.max(1, Math.min(200, Number(limit || 80)))] + ).map((row) => ({ + ...row, + request: parseJson(row.request_json, {}), + response: parseJson(row.response_json, {}) + })); +} diff --git a/server/oidc.mjs b/server/oidc.mjs new file mode 100644 index 0000000..e4b036e --- /dev/null +++ b/server/oidc.mjs @@ -0,0 +1,397 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createPublicKey, + createVerify, + constants, + randomBytes, + scryptSync +} from "node:crypto"; +import { createPasswordRecord, dbAll, dbGet, dbRun } from "./db.mjs"; +import { createMfaChallenge, createMfaEnrollmentChallenge, createSession, mfaRequiredForUser, mfaStatus, safeUser } from "./auth.mjs"; + +const LOGIN_STATE_TTL_MS = 10 * 60 * 1000; +const TICKET_TTL_MS = 60 * 1000; +const CLOCK_SKEW_MS = 60 * 1000; +const OIDC_STORAGE_KEY = scryptSync(process.env.AI_DRAMA_OIDC_STORAGE_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-oidc-key-v1", "ai-drama-oidc-storage", 32); +const OIDC_JWKS_CACHE = new Map(); +const { RSA_PKCS1_PSS_PADDING, RSA_PSS_SALTLEN_DIGEST } = constants; + +function oidcError(status, code, message, details = {}) { + const error = new Error(message); + error.status = status; + error.code = code; + error.details = details; + return error; +} + +function nowIso() { + return new Date().toISOString(); +} + +function hashValue(value) { + return createHash("sha256").update(String(value || "")).digest("hex"); +} + +function base64url(buffer) { + return Buffer.from(buffer).toString("base64url"); +} + +function decodeBase64url(value) { + return Buffer.from(String(value || ""), "base64url"); +} + +function encryptOpaque(value) { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", OIDC_STORAGE_KEY, iv); + const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]); + return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join("."); +} + +function decryptOpaque(payload) { + const [ivValue, tagValue, encryptedValue] = String(payload || "").split("."); + if (!ivValue || !tagValue || !encryptedValue) throw oidcError(500, "oidc_state_invalid", "OIDC 登录状态存储记录无效"); + const decipher = createDecipheriv("aes-256-gcm", OIDC_STORAGE_KEY, decodeBase64url(ivValue)); + decipher.setAuthTag(decodeBase64url(tagValue)); + return Buffer.concat([decipher.update(decodeBase64url(encryptedValue)), decipher.final()]).toString("utf8"); +} + +function parseJson(value, fallback) { + try { return JSON.parse(value); } catch { return fallback; } +} + +function normalizeIssuer(value) { + try { + return new URL(String(value || "")).toString().replace(/\/+$/, ""); + } catch { + return String(value || "").replace(/\/+$/, ""); + } +} + +function claimValue(claims, path) { + const keys = String(path || "").split(".").filter(Boolean); + let current = claims; + for (const key of keys) { + if (!current || typeof current !== "object") return ""; + current = current[key]; + } + return Array.isArray(current) ? current[0] : current; +} + +function providerRow(providerId) { + const provider = dbGet("SELECT * FROM identity_providers WHERE id = ? AND enabled = 1", [providerId]); + if (!provider) throw oidcError(404, "sso_provider_not_found", "企业身份提供商不存在或未启用"); + if (provider.kind !== "oidc") throw oidcError(400, "sso_provider_kind_unsupported", "当前登录链路只支持 OIDC,SAML 仍需部署层断言消费适配"); + if (!provider.issuer_url || !provider.client_id) throw oidcError(400, "sso_provider_incomplete", "OIDC 提供商缺少 Issuer 或 Client ID"); + return provider; +} + +function identityPolicy() { + return dbGet("SELECT * FROM identity_policies WHERE id = 'default'") || {}; +} + +function ensureSsoEnabled() { + if (!identityPolicy().sso_enabled) throw oidcError(403, "sso_disabled", "平台当前未启用企业 SSO"); +} + +function cleanupExpired() { + const timestamp = nowIso(); + dbRun("DELETE FROM oidc_login_states WHERE expires_at <= ? OR consumed_at IS NOT NULL", [timestamp]); + dbRun("DELETE FROM auth_sso_tickets WHERE expires_at <= ? OR consumed_at IS NOT NULL", [timestamp]); +} + +async function fetchJson(url, options = {}, label = "OIDC 请求") { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + const raw = await response.text(); + const payload = raw ? parseJson(raw, {}) : {}; + if (!response.ok) throw oidcError(502, "oidc_upstream_error", `${label}失败:HTTP ${response.status}`, { status: response.status, response: payload }); + return payload; + } catch (error) { + if (error.status) throw error; + throw oidcError(502, "oidc_upstream_unreachable", `${label}失败:${error.message}`); + } finally { + clearTimeout(timeout); + } +} + +async function discover(provider) { + const discoveryUrl = `${normalizeIssuer(provider.issuer_url)}/.well-known/openid-configuration`; + const discovery = await fetchJson(discoveryUrl, { headers: { accept: "application/json" } }, "OIDC discovery"); + return { + issuer: discovery.issuer || provider.issuer_url, + authorizationEndpoint: provider.authorization_url || discovery.authorization_endpoint, + tokenEndpoint: provider.token_url || discovery.token_endpoint, + userinfoEndpoint: provider.userinfo_url || discovery.userinfo_endpoint || "", + jwksUri: provider.jwks_url || discovery.jwks_uri || "" + }; +} + +export function startOidcLogin(providerId, { redirectUri, returnTo = "/", selection = {}, ipAddress = "", userAgent = "" } = {}) { + ensureSsoEnabled(); + const provider = providerRow(providerId); + const authorizationUrl = String(provider.authorization_url || "").trim(); + if (!authorizationUrl) throw oidcError(400, "sso_authorization_endpoint_missing", "OIDC 提供商尚未配置 Authorization Endpoint,请先探测"); + const state = base64url(randomBytes(32)); + const nonce = base64url(randomBytes(32)); + const codeVerifier = base64url(randomBytes(48)); + const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest()); + const timestamp = nowIso(); + const expiresAt = new Date(Date.now() + LOGIN_STATE_TTL_MS).toISOString(); + const id = `oidc-state-${Date.now()}-${randomBytes(4).toString("hex")}`; + dbRun("INSERT INTO oidc_login_states(id, state_hash, provider_id, nonce_hash, code_verifier_ciphertext, redirect_uri, return_to, selection_json, ip_address, user_agent, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ + id, + hashValue(state), + provider.id, + hashValue(nonce), + encryptOpaque(codeVerifier), + redirectUri, + String(returnTo || "/"), + JSON.stringify(selection || {}), + String(ipAddress || ""), + String(userAgent || ""), + expiresAt, + timestamp + ]); + const url = new URL(authorizationUrl); + const scopes = parseJson(provider.scopes_json, ["openid", "profile", "email"]); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", provider.client_id); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", Array.isArray(scopes) ? scopes.join(" ") : "openid profile email"); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return { provider: { id: provider.id, name: provider.name }, authorizationUrl: url.toString(), expiresAt }; +} + +function consumeLoginState(state) { + cleanupExpired(); + const timestamp = nowIso(); + const result = dbRun("UPDATE oidc_login_states SET consumed_at = ? WHERE state_hash = ? AND consumed_at IS NULL AND expires_at > ?", [timestamp, hashValue(state), timestamp]); + if (!Number(result.changes || 0)) throw oidcError(401, "oidc_state_invalid", "OIDC 登录状态无效、已使用或已过期"); + const row = dbGet("SELECT * FROM oidc_login_states WHERE state_hash = ?", [hashValue(state)]); + if (!row) throw oidcError(401, "oidc_state_invalid", "OIDC 登录状态不存在"); + return row; +} + +function parseJwt(token) { + const parts = String(token || "").split("."); + if (parts.length !== 3) throw oidcError(502, "oidc_id_token_invalid", "OIDC 返回的 ID Token 格式无效"); + try { + return { header: JSON.parse(decodeBase64url(parts[0]).toString("utf8")), claims: JSON.parse(decodeBase64url(parts[1]).toString("utf8")), encodedHeader: parts[0], encodedPayload: parts[1], signature: decodeBase64url(parts[2]) }; + } catch { + throw oidcError(502, "oidc_id_token_invalid", "OIDC 返回的 ID Token 不是有效 JWT"); + } +} + +function ecdsaJoseToDer(signature) { + const half = Math.floor(signature.length / 2); + const encodeInteger = (value) => { + let output = Buffer.from(value); + while (output.length > 1 && output[0] === 0) output = output.subarray(1); + if (output[0] & 0x80) output = Buffer.concat([Buffer.from([0]), output]); + return Buffer.concat([Buffer.from([0x02, output.length]), output]); + }; + const sequence = Buffer.concat([encodeInteger(signature.subarray(0, half)), encodeInteger(signature.subarray(half))]); + if (sequence.length >= 128) return Buffer.concat([Buffer.from([0x30, 0x81, sequence.length]), sequence]); + return Buffer.concat([Buffer.from([0x30, sequence.length]), sequence]); +} + +async function verifyIdToken(token, provider, discovery) { + const parsed = parseJwt(token); + const { header, claims, encodedHeader, encodedPayload, signature } = parsed; + const algorithms = { + RS256: { hash: "SHA256", type: "rsa" }, + RS384: { hash: "SHA384", type: "rsa" }, + RS512: { hash: "SHA512", type: "rsa" }, + PS256: { hash: "SHA256", type: "pss" }, + PS384: { hash: "SHA384", type: "pss" }, + PS512: { hash: "SHA512", type: "pss" }, + ES256: { hash: "SHA256", type: "ecdsa" }, + ES384: { hash: "SHA384", type: "ecdsa" }, + ES512: { hash: "SHA512", type: "ecdsa" } + }; + const algorithm = algorithms[header.alg]; + if (!algorithm) throw oidcError(502, "oidc_algorithm_unsupported", `OIDC ID Token 签名算法不受支持:${header.alg || "未声明"}`); + if (!discovery.jwksUri) throw oidcError(502, "oidc_jwks_missing", "OIDC 提供商未返回 JWKS 地址"); + let cache = OIDC_JWKS_CACHE.get(discovery.jwksUri); + if (!cache || cache.expiresAt <= Date.now()) { + cache = { keys: (await fetchJson(discovery.jwksUri, { headers: { accept: "application/json" } }, "OIDC JWKS")).keys || [], expiresAt: Date.now() + 5 * 60 * 1000 }; + OIDC_JWKS_CACHE.set(discovery.jwksUri, cache); + } + let jwk = cache.keys.find((item) => item.kid && item.kid === header.kid); + if (!jwk && !header.kid && cache.keys.length === 1) jwk = cache.keys[0]; + if (!jwk) { + OIDC_JWKS_CACHE.delete(discovery.jwksUri); + const refreshed = (await fetchJson(discovery.jwksUri, { headers: { accept: "application/json" } }, "OIDC JWKS 刷新")).keys || []; + OIDC_JWKS_CACHE.set(discovery.jwksUri, { keys: refreshed, expiresAt: Date.now() + 5 * 60 * 1000 }); + jwk = refreshed.find((item) => item.kid && item.kid === header.kid) || (!header.kid && refreshed.length === 1 ? refreshed[0] : null); + } + if (!jwk) throw oidcError(502, "oidc_signing_key_not_found", "OIDC ID Token 的签名密钥不在 JWKS 中"); + let publicKey; + try { publicKey = createPublicKey({ key: jwk, format: "jwk" }); } catch (error) { throw oidcError(502, "oidc_jwk_invalid", `OIDC JWKS 公钥无效:${error.message}`); } + const verify = createVerify(algorithm.hash); + verify.update(`${encodedHeader}.${encodedPayload}`); + verify.end(); + const normalizedSignature = algorithm.type === "ecdsa" ? ecdsaJoseToDer(signature) : signature; + const verified = algorithm.type === "pss" + ? verify.verify({ key: publicKey, padding: RSA_PKCS1_PSS_PADDING, saltLength: RSA_PSS_SALTLEN_DIGEST }, normalizedSignature) + : verify.verify(publicKey, normalizedSignature); + if (!verified) throw oidcError(401, "oidc_signature_invalid", "OIDC ID Token 签名校验失败"); + const now = Date.now(); + if (normalizeIssuer(claims.iss) !== normalizeIssuer(provider.issuer_url)) throw oidcError(401, "oidc_issuer_invalid", "OIDC ID Token 的 Issuer 不匹配"); + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (!audiences.includes(provider.client_id)) throw oidcError(401, "oidc_audience_invalid", "OIDC ID Token 的 Audience 不匹配"); + if (claims.azp && claims.azp !== provider.client_id) throw oidcError(401, "oidc_authorized_party_invalid", "OIDC ID Token 的 azp 不匹配"); + if (!claims.nonce) throw oidcError(401, "oidc_nonce_missing", "OIDC ID Token 缺少 nonce"); + if (!claims.sub) throw oidcError(401, "oidc_subject_missing", "OIDC ID Token 缺少 subject"); + if (!claims.exp || Number(claims.exp) * 1000 + CLOCK_SKEW_MS < now) throw oidcError(401, "oidc_token_expired", "OIDC ID Token 已过期"); + if (claims.iat && Number(claims.iat) * 1000 - CLOCK_SKEW_MS > now) throw oidcError(401, "oidc_token_issued_in_future", "OIDC ID Token 的签发时间无效"); + return claims; +} + +async function exchangeCode(provider, stateRow, code) { + const discovery = await discover(provider); + if (!discovery.tokenEndpoint) throw oidcError(400, "sso_token_endpoint_missing", "OIDC 提供商尚未配置 Token Endpoint"); + const clientSecret = process.env[provider.client_secret_ref]; + if (!clientSecret) throw oidcError(503, "sso_client_secret_missing", `服务端环境变量 ${provider.client_secret_ref} 未配置`); + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: String(code || ""), + redirect_uri: stateRow.redirect_uri, + client_id: provider.client_id, + client_secret: clientSecret, + code_verifier: decryptOpaque(stateRow.code_verifier_ciphertext) + }); + const token = await fetchJson(discovery.tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body }, "OIDC code exchange"); + if (!token.id_token) throw oidcError(502, "oidc_id_token_missing", "OIDC Token Endpoint 未返回 ID Token"); + const claims = await verifyIdToken(token.id_token, provider, discovery); + if (hashValue(claims.nonce) !== stateRow.nonce_hash) throw oidcError(401, "oidc_nonce_invalid", "OIDC ID Token 的 nonce 不匹配"); + if (discovery.userinfoEndpoint && token.access_token) { + try { + const userInfo = await fetchJson(discovery.userinfoEndpoint, { headers: { authorization: `Bearer ${token.access_token}`, accept: "application/json" } }, "OIDC UserInfo"); + for (const [key, value] of Object.entries(userInfo || {})) if (claims[key] === undefined) claims[key] = value; + } catch { + // ID Token claims remain authoritative when an optional UserInfo call fails. + } + } + return { claims, token, discovery }; +} + +function avatarColorFor(email) { + const colors = ["#d97757", "#3f7f87", "#a77646", "#8e6a9f", "#477d69", "#9a6b51"]; + const digest = createHash("sha256").update(email).digest().readUInt16BE(0); + return colors[digest % colors.length]; +} + +function organizationFor(provider, existingUser) { + if (provider.organization_id) { + const organization = dbGet("SELECT * FROM organizations WHERE id = ? AND status = 'active'", [provider.organization_id]); + if (!organization) throw oidcError(403, "sso_organization_unavailable", "SSO 提供商绑定的组织不存在或已停用"); + return organization; + } + if (existingUser) { + const membership = dbGet("SELECT o.* FROM organizations o JOIN organization_members om ON om.organization_id = o.id WHERE om.user_id = ? AND om.status = 'active' AND o.status = 'active' ORDER BY om.joined_at ASC LIMIT 1", [existingUser.id]); + if (membership) return membership; + } + throw oidcError(403, "sso_organization_required", "该 SSO 提供商尚未绑定组织,不能自动创建企业成员"); +} + +function ensureOrganizationMembership(user, organization, provider) { + const current = dbGet("SELECT * FROM organization_members WHERE organization_id = ? AND user_id = ?", [organization.id, user.id]); + if (current?.status === "active") return current; + if (current && current.status !== "active" && !provider.auto_provision) throw oidcError(403, "sso_membership_inactive", "当前用户在目标组织中已被停用"); + if (!current && !provider.auto_provision) throw oidcError(403, "sso_membership_required", "当前企业账号尚未加入目标组织"); + const timestamp = nowIso(); + const roleKey = provider.default_role_key || "org_member"; + dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', joined_at = excluded.joined_at, updated_at = excluded.updated_at", [`om-sso-${organization.id}-${user.id}`, organization.id, user.id, roleKey, timestamp, timestamp, timestamp]); + return dbGet("SELECT * FROM organization_members WHERE organization_id = ? AND user_id = ?", [organization.id, user.id]); +} + +function ensureWorkspaceMembership(user, organization, provider) { + const workspace = provider.workspace_id + ? dbGet("SELECT * FROM workspaces WHERE id = ? AND organization_id = ? AND status = 'active'", [provider.workspace_id, organization.id]) + : dbGet("SELECT * FROM workspaces WHERE organization_id = ? AND status = 'active' ORDER BY created_at ASC LIMIT 1", [organization.id]); + if (!workspace) throw oidcError(403, "sso_workspace_required", "SSO 提供商没有可用的默认工作区"); + const current = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspace.id, user.id]); + if (current?.status === "active") return { workspace, membership: current }; + if (current && current.status !== "active" && !provider.auto_provision) throw oidcError(403, "sso_workspace_membership_inactive", "当前用户在默认工作区中已被停用"); + if (!current && !provider.auto_provision) throw oidcError(403, "sso_workspace_membership_required", "当前企业账号尚未加入默认工作区"); + const timestamp = nowIso(); + const roleKey = provider.default_workspace_role_key || "writer"; + dbRun("INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at", [`wm-sso-${workspace.id}-${user.id}`, workspace.id, user.id, roleKey, timestamp, timestamp]); + return { workspace, membership: dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspace.id, user.id]) }; +} + +export function resolveOidcUser(provider, claims) { + const mapping = parseJson(provider.claim_mapping_json, { email: "email", displayName: "name", externalId: "sub" }); + const subject = String(claimValue(claims, mapping.externalId || "sub") || claims.sub || "").trim(); + const email = String(claimValue(claims, mapping.email || "email") || claims.preferred_username || "").trim().toLowerCase(); + const displayName = String(claimValue(claims, mapping.displayName || "name") || claims.preferred_username || email || subject).trim(); + if (!subject) throw oidcError(401, "sso_subject_missing", "企业身份没有返回可用的 subject"); + if (!email || !email.includes("@")) throw oidcError(401, "sso_email_missing", "企业身份没有返回可用邮箱,无法完成平台账号绑定"); + const existingIdentity = dbGet("SELECT ei.*, u.* FROM external_identities ei JOIN users u ON u.id = ei.user_id WHERE ei.provider_id = ? AND ei.subject = ?", [provider.id, subject]); + let user = existingIdentity ? dbGet("SELECT * FROM users WHERE id = ?", [existingIdentity.user_id]) : dbGet("SELECT * FROM users WHERE lower(email) = ?", [email]); + const organization = organizationFor(provider, user); + if (existingIdentity && existingIdentity.email_at_login && existingIdentity.email_at_login !== email && user?.email !== email) throw oidcError(403, "sso_identity_mismatch", "企业身份 subject 与邮箱绑定不一致,需要管理员处理"); + if (!user) { + if (!provider.auto_provision) throw oidcError(403, "sso_auto_provision_disabled", "该企业账号尚未注册,且当前 SSO 提供商关闭了自动入组"); + const timestamp = nowIso(); + const id = `u-sso-${Date.now()}-${randomBytes(5).toString("hex")}`; + dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [id, displayName.slice(0, 120) || email, email, avatarColorFor(email), timestamp, timestamp]); + user = dbGet("SELECT * FROM users WHERE id = ?", [id]); + } + if (!user || user.status !== "active") throw oidcError(403, "user_not_active", "当前企业账号已停用"); + ensureOrganizationMembership(user, organization, provider); + ensureWorkspaceMembership(user, organization, provider); + const timestamp = nowIso(); + if (!existingIdentity) { + const identityId = `ext-${Date.now()}-${randomBytes(5).toString("hex")}`; + try { + dbRun("INSERT INTO external_identities(id, provider_id, user_id, subject, issuer, email_at_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [identityId, provider.id, user.id, subject, normalizeIssuer(claims.iss || provider.issuer_url), email, timestamp, timestamp]); + } catch (error) { + const conflict = dbGet("SELECT user_id FROM external_identities WHERE provider_id = ? AND subject = ?", [provider.id, subject]); + if (!conflict || conflict.user_id !== user.id) throw oidcError(409, "sso_identity_already_bound", "该企业身份已经绑定其他平台用户"); + } + } else { + dbRun("UPDATE external_identities SET email_at_login = ?, issuer = ?, updated_at = ? WHERE id = ?", [email, normalizeIssuer(claims.iss || provider.issuer_url), timestamp, existingIdentity.id]); + } + return { user: dbGet("SELECT * FROM users WHERE id = ?", [user.id]), organization, subject, email }; +} + +export async function handleOidcCallback({ code, state }) { + const stateRow = consumeLoginState(state); + const provider = providerRow(stateRow.provider_id); + const exchanged = await exchangeCode(provider, stateRow, code); + return { ...resolveOidcUser(provider, exchanged.claims), selection: parseJson(stateRow.selection_json, {}), returnTo: stateRow.return_to, provider }; +} + +export function createSsoTicket(userId, selection = {}, metadata = {}) { + cleanupExpired(); + const ticket = base64url(randomBytes(32)); + const timestamp = nowIso(); + const expiresAt = new Date(Date.now() + TICKET_TTL_MS).toISOString(); + const id = `sso-ticket-${Date.now()}-${randomBytes(4).toString("hex")}`; + dbRun("INSERT INTO auth_sso_tickets(id, ticket_hash, user_id, selection_json, ip_address, user_agent, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [id, hashValue(ticket), userId, JSON.stringify(selection || {}), String(metadata.ipAddress || ""), String(metadata.userAgent || ""), expiresAt, timestamp]); + return { ticket, expiresAt }; +} + +export function redeemSsoTicket(ticket, metadata = {}) { + cleanupExpired(); + const timestamp = nowIso(); + const result = dbRun("UPDATE auth_sso_tickets SET consumed_at = ? WHERE ticket_hash = ? AND consumed_at IS NULL AND expires_at > ?", [timestamp, hashValue(ticket), timestamp]); + if (!Number(result.changes || 0)) throw oidcError(401, "sso_ticket_invalid", "SSO 登录票据无效、已使用或已过期"); + const row = dbGet("SELECT * FROM auth_sso_tickets WHERE ticket_hash = ?", [hashValue(ticket)]); + if (!row) throw oidcError(401, "sso_ticket_invalid", "SSO 登录票据不存在"); + const user = dbGet("SELECT * FROM users WHERE id = ?", [row.user_id]); + if (!user || user.status !== "active") throw oidcError(403, "user_not_active", "当前企业账号已停用"); + const selection = parseJson(row.selection_json, {}); + if (mfaStatus(user.id).enabled) return { mfaRequired: true, challenge: createMfaChallenge(user.id), user: safeUser(user), selection }; + if (mfaRequiredForUser(user.id)) return { mfaRequired: true, mfaEnrollmentRequired: true, enrollment: createMfaEnrollmentChallenge(user.id), user: safeUser(user), selection }; + return { session: createSession(user.id, metadata), user: safeUser(user), selection }; +} diff --git a/server/production.mjs b/server/production.mjs new file mode 100644 index 0000000..e710eeb --- /dev/null +++ b/server/production.mjs @@ -0,0 +1,1546 @@ +import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; +import { addAudit, addUsage, hasPermission, httpError, requirePermission, requireProjectWritable } from "./tenant.mjs"; +import { inspectMediaForProject } from "./media-qa.mjs"; +import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs"; +import { dispatchNotificationEvent } from "./notifications.mjs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const projectRoot = resolve(import.meta.dirname, ".."); + +const REVIEW_LANES = [ + ["single-frame", "一图一画面"], + ["continuity-lock", "角色 / 场景 / 道具连续性"], + ["voice-subtitle-asr", "声音 / 字幕 / ASR 对齐"], + ["clip-bridge", "片段衔接 / 实际末帧"] +]; + +const DEFAULT_SERIES_TEMPLATE = { + visualStyle: "原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,稳定机位。", + continuityRule: "角色、服装、道具、场景、天气、机位、声音全部进入连续性台账;下一段视频优先使用上一段实际末帧。", + showEngine: "每集围绕一个可拍摄的冲突推进,结尾留下下一处行动钩子。" +}; + +const now = () => new Date().toISOString(); +const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function requireProject(context) { + if (!context.project) throw httpError(400, "project_required", "生产操作必须绑定项目"); + return context.project; +} + +function requireAnyPermission(context, permissions, { mutating = false } = {}) { + if (!permissions.some((permission) => hasPermission(context, permission))) { + throw httpError(403, "permission_denied", `缺少生产权限:${permissions.join(" / ")}`, { permissions }); + } + if (mutating) requireProjectWritable(context); +} + +function projectEpisode(context, episodeId) { + const project = requireProject(context); + const episode = dbGet( + `SELECT e.*, se.series_id, sr.project_id + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series sr ON sr.id = se.series_id + WHERE e.id = ? AND sr.project_id = ?`, + [episodeId, project.id] + ); + if (!episode) throw httpError(404, "episode_not_found", "分集不存在或不属于当前项目", { episodeId }); + return episode; +} + +function ensureProductionRoot(context) { + const project = requireProject(context); + const timestamp = now(); + let series = dbGet("SELECT * FROM series WHERE project_id = ?", [project.id]); + if (!series) { + const seriesId = `series-${project.id}`; + dbRun("INSERT INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, 'vertical-9:16', ?, ?, ?, ?, ?)", [ + seriesId, + project.id, + project.name, + `${project.name} 的本地化 AI 短剧生产项目。`, + DEFAULT_SERIES_TEMPLATE.visualStyle, + DEFAULT_SERIES_TEMPLATE.continuityRule, + DEFAULT_SERIES_TEMPLATE.showEngine, + timestamp, + timestamp + ]); + series = dbGet("SELECT * FROM series WHERE id = ?", [seriesId]); + } + let season = dbGet("SELECT * FROM seasons WHERE series_id = ? ORDER BY season_number LIMIT 1", [series.id]); + if (!season) { + const seasonId = `season-${project.id}-1`; + dbRun("INSERT INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", [seasonId, series.id, timestamp, timestamp]); + season = dbGet("SELECT * FROM seasons WHERE id = ?", [seasonId]); + } + let episode = dbGet("SELECT * FROM episodes WHERE season_id = ? ORDER BY episode_number LIMIT 1", [season.id]); + if (!episode) { + const episodeId = `episode-${project.id}-01`; + dbRun("INSERT INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, '试播集', 'draft', 0, '', '', ?, ?)", [episodeId, season.id, timestamp, timestamp]); + episode = dbGet("SELECT * FROM episodes WHERE id = ?", [episodeId]); + } + const shotCount = dbGet("SELECT COUNT(*) AS count FROM shots WHERE episode_id = ?", [episode.id]); + if (Number(shotCount?.count || 0) === 0) { + const shotId = `shot-${project.id}-001`; + const payload = { + id: shotId, + title: "新镜头 01", + durationSec: 6, + characterIds: [], + locationId: "", + propIds: [], + camera: "稳定中景,保持单一连续画面", + action: "待编剧填写镜头动作", + firstFrame: "episode-start", + lastFrame: "pending-actual-last-frame", + transitionFromPrevious: "episode-start", + prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.", + negativePrompt: "no split screen, no comic panel, no collage, no contact sheet", + videoPrompt: "待填写视频动作和实际末帧要求", + seed: null + }; + dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episode.id, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); + dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]); + dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]); + } + return { series, season, episode }; +} + +function episodeSummary(row) { + if (!row) return null; + return { + id: row.id, + seasonId: row.season_id, + episodeNumber: Number(row.episode_number), + title: row.title, + status: row.status, + targetDurationSec: Number(row.target_duration_sec || 0), + hook: row.hook || "", + cliffhanger: row.cliffhanger || "", + shotCount: Number(row.shot_count || 0), + scriptVersionCount: Number(row.script_version_count || 0), + updatedAt: row.updated_at + }; +} + +export function productionCatalog(context) { + const root = ensureProductionRoot(context); + const seasons = dbAll( + `SELECT se.*, COUNT(DISTINCT e.id) AS episode_count + FROM seasons se + LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.series_id = ? + GROUP BY se.id + ORDER BY se.season_number`, + [root.series.id] + ).map((season) => ({ + id: season.id, + seriesId: season.series_id, + seasonNumber: Number(season.season_number), + title: season.title, + episodeCount: Number(season.episode_count || 0), + episodes: dbAll( + `SELECT e.*, + (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, + (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count + FROM episodes e + WHERE e.season_id = ? + ORDER BY e.episode_number`, + [season.id] + ).map(episodeSummary) + })); + return { + series: root.series, + activeSeasonId: root.season.id, + activeEpisodeId: root.episode.id, + seasons + }; +} + +function insertStarterShot(context, episodeId, shotId, timestamp) { + const payload = { + id: shotId, + title: "新镜头 01", + durationSec: 6, + characterIds: [], + locationId: "", + propIds: [], + camera: "稳定中景,保持单一连续画面", + action: "待编剧填写镜头动作", + firstFrame: "episode-start", + lastFrame: "pending-actual-last-frame", + transitionFromPrevious: "episode-start", + prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.", + negativePrompt: "no split screen, no comic panel, no collage, no contact sheet", + videoPrompt: "待填写视频动作和实际末帧要求", + seed: null + }; + dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episodeId, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); + dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]); + dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]); +} + +export function createSeason(context, body = {}) { + requirePermission(context, "script:edit"); + const root = ensureProductionRoot(context); + const title = String(body.title || "").trim(); + if (title.length < 1 || title.length > 120) throw httpError(400, "season_title_invalid", "季名称不能为空且不能超过 120 个字符"); + const latest = dbGet("SELECT MAX(season_number) AS season_number FROM seasons WHERE series_id = ?", [root.series.id]); + const seasonNumber = Number(body.seasonNumber || Number(latest?.season_number || 0) + 1); + if (!Number.isInteger(seasonNumber) || seasonNumber < 1 || seasonNumber > 999) throw httpError(400, "season_number_invalid", "季编号必须是 1 到 999 的整数"); + if (dbGet("SELECT id FROM seasons WHERE series_id = ? AND season_number = ?", [root.series.id, seasonNumber])) throw httpError(409, "season_exists", "该季编号已经存在"); + const id = body.id || `season-${context.project.id}-${seasonNumber}-${Date.now().toString(36)}`; + const timestamp = now(); + dbRun("INSERT INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", [id, root.series.id, seasonNumber, title, timestamp, timestamp]); + addAudit({ context, action: "season.created", targetType: "season", targetId: id, metadata: { seasonNumber, title } }); + return { season: dbGet("SELECT * FROM seasons WHERE id = ?", [id]), catalog: productionCatalog(context) }; +} + +export function createEpisode(context, body = {}) { + requirePermission(context, "script:edit"); + const root = ensureProductionRoot(context); + const seasonId = String(body.seasonId || root.season.id); + const season = dbGet("SELECT * FROM seasons WHERE id = ? AND series_id = ?", [seasonId, root.series.id]); + if (!season) throw httpError(404, "season_not_found", "目标季不存在或不属于当前项目", { seasonId }); + const title = String(body.title || "").trim(); + if (title.length < 1 || title.length > 160) throw httpError(400, "episode_title_invalid", "集标题不能为空且不能超过 160 个字符"); + const latest = dbGet("SELECT MAX(episode_number) AS episode_number FROM episodes WHERE season_id = ?", [seasonId]); + const episodeNumber = Number(body.episodeNumber || Number(latest?.episode_number || 0) + 1); + if (!Number.isInteger(episodeNumber) || episodeNumber < 1 || episodeNumber > 9999) throw httpError(400, "episode_number_invalid", "集编号必须是 1 到 9999 的整数"); + if (dbGet("SELECT id FROM episodes WHERE season_id = ? AND episode_number = ?", [seasonId, episodeNumber])) throw httpError(409, "episode_exists", "该集编号已经存在"); + const targetDurationSec = Number(body.targetDurationSec || 0); + if (!Number.isFinite(targetDurationSec) || targetDurationSec < 0 || targetDurationSec > 3600) throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间"); + const id = body.id || `episode-${context.project.id}-${season.season_number}-${episodeNumber}-${Date.now().toString(36)}`; + const timestamp = now(); + const shotId = `${id}-shot-001`; + withTransaction(() => { + dbRun("INSERT INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, seasonId, episodeNumber, title, String(body.status || "draft"), targetDurationSec, String(body.hook || ""), String(body.cliffhanger || ""), timestamp, timestamp]); + insertStarterShot(context, id, shotId, timestamp); + }); + addAudit({ context, action: "episode.created", targetType: "episode", targetId: id, metadata: { seasonId, episodeNumber, title, starterShotId: shotId } }); + return { episode: episodeSummary(dbGet("SELECT e.*, (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count FROM episodes e WHERE e.id = ?", [id])), catalog: productionCatalog(context), graph: productionGraph(context, { episodeId: id }) }; +} + +export function updateEpisode(context, episodeId, body = {}) { + requirePermission(context, "script:edit"); + const episode = projectEpisode(context, episodeId); + const title = String(body.title ?? episode.title).trim(); + if (title.length < 1 || title.length > 160) throw httpError(400, "episode_title_invalid", "集标题不能为空且不能超过 160 个字符"); + const targetDurationSec = Number(body.targetDurationSec ?? episode.target_duration_sec ?? 0); + if (!Number.isFinite(targetDurationSec) || targetDurationSec < 0 || targetDurationSec > 3600) throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间"); + const status = String(body.status ?? episode.status).trim(); + if (!["draft", "production", "review", "approved", "archived"].includes(status)) throw httpError(400, "episode_status_invalid", "分集状态无效"); + const timestamp = now(); + dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [title, status, targetDurationSec, String(body.hook ?? episode.hook ?? ""), String(body.cliffhanger ?? episode.cliffhanger ?? ""), timestamp, episodeId]); + addAudit({ context, action: "episode.updated", targetType: "episode", targetId: episodeId, metadata: { fields: Object.keys(body) } }); + return { episode: episodeSummary(dbGet("SELECT e.*, (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count FROM episodes e WHERE e.id = ?", [episodeId])), catalog: productionCatalog(context), graph: productionGraph(context, { episodeId }) }; +} + +function shotRows(context, episodeId = null) { + const project = requireProject(context); + const params = [project.id]; + const episodeClause = episodeId ? " AND e.id = ?" : ""; + if (episodeId) params.push(episodeId); + return dbAll( + `SELECT s.*, e.episode_number, e.title AS episode_title + FROM shots s + JOIN episodes e ON e.id = s.episode_id + JOIN seasons se ON se.id = e.season_id + JOIN series sr ON sr.id = se.series_id + WHERE sr.project_id = ?${episodeClause} + ORDER BY e.episode_number, s.shot_number`, + params + ); +} + +function shotPayload(row) { + const latest = (row.current_version_id && dbGet("SELECT * FROM shot_versions WHERE id = ? AND shot_id = ?", [row.current_version_id, row.id])) + || dbGet("SELECT * FROM shot_versions WHERE shot_id = ? ORDER BY version_number DESC LIMIT 1", [row.id]); + const payload = latest ? parseJson(latest.payload_json, {}) : parseJson(row.continuity_json, {}); + const voiceLines = dbAll("SELECT * FROM voice_lines WHERE shot_id = ? ORDER BY line_number", [row.id]).map((line) => ({ + id: line.id, + characterId: line.character_key, + text: line.text, + emotion: line.emotion, + targetDurationSec: line.target_duration_sec, + audioFile: line.audio_path, + mouthPlan: line.mouth_plan, + voiceId: line.voice_id, + status: line.status + })); + return { + ...payload, + id: row.id, + title: row.title, + status: row.status, + episodeId: row.episode_id, + episodeNumber: row.episode_number, + shotNumber: row.shot_number, + firstFrame: row.first_frame_path || payload.firstFrame || "episode-start", + lastFrame: row.last_frame_path || payload.lastFrame || "pending-actual-last-frame", + continuity: parseJson(row.continuity_json, {}), + versionNumber: Number(latest?.version_number || 1), + versionId: latest?.id || "", + voiceLines + }; +} + +function scriptDocuments(context, episodeId = null) { + const project = requireProject(context); + const clause = episodeId ? " AND episode_id = ?" : ""; + const params = episodeId ? [project.id, episodeId] : [project.id]; + return dbAll(`SELECT * FROM script_documents WHERE project_id = ?${clause} ORDER BY version_number DESC`, params).map((row) => ({ + ...row, + analysis: parseJson(row.analysis_json, {}) + })); +} + +function reviewRows(context) { + const project = requireProject(context); + return dbAll( + `SELECT r.*, s.title AS shot_title, s.shot_number, u.display_name AS decision_by_name + FROM reviews r + LEFT JOIN shots s ON s.id = r.shot_id + LEFT JOIN users u ON u.id = r.decision_by + WHERE r.project_id = ? + ORDER BY COALESCE(s.shot_number, 999), r.lane`, + [project.id] + ).map((row) => ({ + ...row, + evidence: parseJson(row.evidence_json, {}), + comments: dbAll("SELECT c.*, u.display_name AS author_name FROM review_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.review_id = ? ORDER BY c.created_at", [row.id]) + })); +} + +function ensureReviews(context) { + const project = requireProject(context); + const shots = shotRows(context); + const timestamp = now(); + for (const shot of shots) { + for (const [lane, label] of REVIEW_LANES) { + const id = `review-${shot.id}-${lane}`; + dbRun("INSERT OR IGNORE INTO reviews(id, organization_id, workspace_id, project_id, shot_id, lane, status, evidence_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, shot.id, lane, JSON.stringify({ label, source: "production-gate" }), timestamp, timestamp]); + } + } + return reviewRows(context); +} + +function projectAssetLocks(context) { + const project = requireProject(context); + const rows = dbAll( + `SELECT a.*, av.version_number, av.storage_path, av.file_name, av.mime_type, av.file_size, + av.content_sha256, av.rights_status, av.metadata_json + FROM assets a + LEFT JOIN asset_versions av ON av.id = a.current_version_id + WHERE a.project_id = ? + ORDER BY CASE a.kind WHEN 'character' THEN 1 WHEN 'location' THEN 2 WHEN 'prop' THEN 3 ELSE 4 END, a.name`, + [project.id] + ); + return rows.map((row) => { + const metadata = parseJson(row.metadata_json, {}); + const base = { + id: row.id, + name: row.name, + kind: row.kind, + status: row.lock_status, + lockStatus: row.lock_status, + version: Number(row.version_number || 1), + storagePath: row.storage_path || "", + rightsStatus: row.rights_status || "needs-evidence", + contentSha256: row.content_sha256 || "", + metadata, + detail: metadata.detail || metadata.visualLock || metadata.description || "", + lock: metadata.lock || metadata.continuityLock || "", + cameraLock: metadata.cameraLock || metadata.camera || "", + bindings: dbAll("SELECT shot_id, usage_role FROM asset_bindings WHERE asset_id = ? ORDER BY shot_id", [row.id]) + }; + if (row.kind === "character") { + return { + ...base, + visualLock: metadata.visualLock || metadata.detail || "", + costumeState: metadata.costumeState || metadata.lock || "", + voiceLock: { + status: metadata.voiceStatus || row.rights_status || row.lock_status, + voiceId: metadata.voiceId || "", + tone: metadata.tone || "", + ttsModel: metadata.ttsModel || "", + asrModel: metadata.asrModel || "", + referencePolicy: metadata.referencePolicy || metadata.lock || "" + } + }; + } + if (row.kind === "location") return { ...base, visualLock: metadata.visualLock || metadata.detail || "" }; + if (row.kind === "prop") return { ...base, visualLock: metadata.visualLock || metadata.detail || "" }; + return base; + }); +} + +function deliveries(context) { + const project = requireProject(context); + return dbAll("SELECT d.*, u.display_name AS approved_by_name FROM deliveries d LEFT JOIN users u ON u.id = d.approved_by WHERE d.project_id = ? ORDER BY d.created_at DESC", [project.id]).map((row) => ({ + ...row, + manifest: row.manifest_path, + releaseCount: Number(dbGet("SELECT COUNT(*) AS count FROM delivery_releases WHERE delivery_id = ?", [row.id])?.count || 0), + publishedReleaseCount: Number(dbGet("SELECT COUNT(*) AS count FROM delivery_releases WHERE delivery_id = ? AND status = 'published'", [row.id])?.count || 0) + })); +} + +function safeStoragePath(value) { + const relative = String(value || "").trim(); + if (!relative || relative.startsWith("/") || relative.includes("..") || !relative.startsWith("storage/")) return null; + const storageRoot = resolve(projectRoot, "storage"); + const absolute = resolve(projectRoot, relative); + if (!absolute.startsWith(`${storageRoot}/`)) return null; + return { relative, absolute }; +} + +function safePathSegment(value, fallback = "item") { + const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return normalized || fallback; +} + +function isPrivateHostname(hostname) { + const host = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, ""); + if (["localhost", "::1"].includes(host) || host.endsWith(".local") || host.endsWith(".internal")) return true; + const octets = host.split(".").map((part) => Number(part)); + if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; + const [first, second] = octets; + return first === 10 || first === 127 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 169 && second === 254); +} + +function normalizeChannelEndpoint(kind, endpoint) { + const value = String(endpoint || "").trim(); + if (kind === "local-file") { + const path = safeStoragePath(value || "storage/releases"); + if (!path) throw httpError(400, "delivery_channel_endpoint_invalid", "本地文件渠道必须写入 storage/ 下的安全相对目录"); + return path.relative; + } + let parsed; + try { + parsed = new URL(value); + } catch { + throw httpError(400, "delivery_channel_endpoint_invalid", "本地 Webhook 渠道必须填写有效的 HTTP(S) 地址"); + } + if (!["http:", "https:"].includes(parsed.protocol) || !isPrivateHostname(parsed.hostname)) { + throw httpError(400, "delivery_channel_endpoint_public", "为遵守本地化约束,Webhook 只能指向 localhost、.local、.internal 或私网地址"); + } + return parsed.toString(); +} + +function normalizeAuthEnv(value) { + const authEnv = String(value || "").trim(); + if (authEnv && !/^[A-Z][A-Z0-9_]{0,99}$/.test(authEnv)) { + throw httpError(400, "delivery_channel_auth_env_invalid", "认证环境变量名只能使用大写字母、数字和下划线"); + } + return authEnv; +} + +function channelPayload(row) { + if (!row) return null; + return { + ...row, + enabled: Boolean(row.enabled), + requireApproval: Boolean(row.require_approval), + config: parseJson(row.config_json, {}) + }; +} + +function ensureDefaultDeliveryChannel(context) { + const existing = dbGet( + "SELECT * FROM delivery_channels WHERE organization_id = ? AND workspace_id = ? AND project_id IS NULL ORDER BY created_at LIMIT 1", + [context.organization.id, context.workspace.id] + ); + if (existing) return existing; + const timestamp = now(); + const id = `channel-${safePathSegment(context.organization.id)}-${safePathSegment(context.workspace.id)}-local`; + dbRun( + `INSERT OR IGNORE INTO delivery_channels( + id, organization_id, workspace_id, project_id, name, kind, enabled, endpoint, auth_env, + require_approval, config_json, created_by, created_at, updated_at + ) VALUES (?, ?, ?, NULL, '本地文件(默认)', 'local-file', 1, 'storage/releases', '', 1, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, JSON.stringify({ root: "storage/releases", localOnly: true }), context.user.id, timestamp, timestamp] + ); + return dbGet("SELECT * FROM delivery_channels WHERE id = ?", [id]); +} + +function channelForContext(context, channelId) { + const projectId = context.project?.id || null; + const channel = dbGet( + `SELECT * FROM delivery_channels + WHERE id = ? AND organization_id = ? AND workspace_id = ? + AND (project_id IS NULL OR project_id = ?)`, + [channelId, context.organization.id, context.workspace.id, projectId] + ); + if (!channel) throw httpError(404, "delivery_channel_not_found", "发布渠道不存在或不属于当前工作区", { channelId }); + return channel; +} + +export function listDeliveryChannels(context) { + requireAnyPermission(context, ["delivery:view", "delivery:approve"]); + ensureDefaultDeliveryChannel(context); + const projectId = context.project?.id || null; + return { + channels: dbAll( + `SELECT dc.*, u.display_name AS created_by_name + FROM delivery_channels dc + LEFT JOIN users u ON u.id = dc.created_by + WHERE dc.organization_id = ? AND dc.workspace_id = ? + AND (dc.project_id IS NULL OR dc.project_id = ?) + ORDER BY dc.enabled DESC, dc.created_at`, + [context.organization.id, context.workspace.id, projectId] + ).map(channelPayload) + }; +} + +export function createDeliveryChannel(context, body = {}) { + requirePermission(context, "delivery:approve"); + const projectId = body.projectId ? String(body.projectId).trim() : null; + if (projectId && (!context.project || context.project.id !== projectId)) { + throw httpError(403, "delivery_channel_project_scope", "渠道项目范围必须是当前项目或留空作为工作区渠道"); + } + const name = String(body.name || "").trim(); + if (!name || name.length > 80) throw httpError(400, "delivery_channel_name_invalid", "渠道名称不能为空且不能超过 80 个字符"); + const kind = String(body.kind || "local-file").trim(); + if (!["local-file", "local-webhook"].includes(kind)) throw httpError(400, "delivery_channel_kind_invalid", "只允许 local-file 或 local-webhook 渠道"); + const endpoint = normalizeChannelEndpoint(kind, body.endpoint); + const authEnv = normalizeAuthEnv(body.authEnv); + if (kind === "local-file" && authEnv) throw httpError(400, "delivery_channel_auth_env_invalid", "本地文件渠道不需要认证环境变量"); + const config = body.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {}; + const id = makeId("delivery-channel"); + const timestamp = now(); + dbRun( + `INSERT INTO delivery_channels( + id, organization_id, workspace_id, project_id, name, kind, enabled, endpoint, auth_env, + require_approval, config_json, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, projectId, name, kind, body.enabled === false ? 0 : 1, endpoint, authEnv, body.requireApproval === false ? 0 : 1, JSON.stringify(config), context.user.id, timestamp, timestamp] + ); + addAudit({ context, action: "delivery.channel.created", targetType: "delivery_channel", targetId: id, metadata: { name, kind, projectId, endpoint: kind === "local-webhook" ? endpoint : "local-file" } }); + return { channel: channelPayload(dbGet("SELECT * FROM delivery_channels WHERE id = ?", [id])), ...listDeliveryChannels(context) }; +} + +export function updateDeliveryChannel(context, channelId, body = {}) { + requirePermission(context, "delivery:approve"); + const channel = channelForContext(context, channelId); + const nextName = body.name === undefined ? channel.name : String(body.name || "").trim(); + if (!nextName || nextName.length > 80) throw httpError(400, "delivery_channel_name_invalid", "渠道名称不能为空且不能超过 80 个字符"); + const kind = body.kind === undefined ? channel.kind : String(body.kind || "").trim(); + if (kind !== channel.kind) throw httpError(409, "delivery_channel_kind_immutable", "渠道类型创建后不可修改,请新建渠道"); + const endpoint = body.endpoint === undefined ? channel.endpoint : normalizeChannelEndpoint(kind, body.endpoint); + const authEnv = body.authEnv === undefined ? channel.auth_env : normalizeAuthEnv(body.authEnv); + if (kind === "local-file" && authEnv) throw httpError(400, "delivery_channel_auth_env_invalid", "本地文件渠道不需要认证环境变量"); + const config = body.config === undefined ? parseJson(channel.config_json, {}) : (body.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {}); + const timestamp = now(); + dbRun( + `UPDATE delivery_channels + SET name = ?, enabled = ?, endpoint = ?, auth_env = ?, require_approval = ?, config_json = ?, updated_at = ? + WHERE id = ?`, + [nextName, body.enabled === undefined ? Number(channel.enabled) : (body.enabled ? 1 : 0), endpoint, authEnv, body.requireApproval === undefined ? Number(channel.require_approval) : (body.requireApproval ? 1 : 0), JSON.stringify(config), timestamp, channelId] + ); + addAudit({ context, action: "delivery.channel.updated", targetType: "delivery_channel", targetId: channelId, metadata: { name: nextName, enabled: body.enabled === undefined ? Boolean(channel.enabled) : Boolean(body.enabled) } }); + return { channel: channelPayload(dbGet("SELECT * FROM delivery_channels WHERE id = ?", [channelId])), ...listDeliveryChannels(context) }; +} + +function parseScript(content) { + const clean = String(content || "").replace(/\r/g, "").trim(); + const wordCount = clean.replace(/\s/g, "").length; + const headingMatches = [...clean.matchAll(/第\s*(\d+)\s*集[^\n]*/g)]; + const chapterBlocks = headingMatches.length + ? headingMatches.map((match, index) => { + const start = match.index + match[0].length; + const end = headingMatches[index + 1]?.index || clean.length; + const block = clean.slice(start, end).trim(); + return { id: `chapter-${index + 1}`, title: match[0].trim(), words: block.replace(/\s/g, "").length, scenes: Math.max(1, (block.match(/(?:内|外)[::]/g) || []).length), status: "已拆解" }; + }) + : clean.split(/\n\s*\n/).filter(Boolean).slice(0, 12).map((block, index) => ({ id: `chapter-${index + 1}`, title: `场次 ${String(index + 1).padStart(2, "0")}`, words: block.replace(/\s/g, "").length, scenes: 1, status: "已拆解" })); + const names = [...clean.matchAll(/([\u4e00-\u9fa5]{2,4})[::]/g)].map((match) => match[1]).filter((name, index, list) => list.indexOf(name) === index).slice(0, 12); + const locationKeywords = ["地铁口", "玻璃连廊", "雨棚", "教室", "客厅", "街道", "医院", "仓库", "山路", "门口"]; + const propKeywords = ["手机", "雨伞", "雨披", "路锥", "警戒线", "钥匙", "刀", "书包", "项链", "文件"]; + const locations = locationKeywords.filter((item) => clean.includes(item)).map((name) => ({ type: "场景", name, confidence: 0.88, target: "location-locks" })); + const props = propKeywords.filter((item) => clean.includes(item)).map((name) => ({ type: "道具", name, confidence: 0.82, target: "prop-locks" })); + const extracted = [ + ...names.map((name) => ({ type: "角色", name, confidence: 0.94, target: "character-locks" })), + ...locations, + ...props + ]; + const lines = clean.split("\n").map((line) => line.trim()).filter(Boolean); + const sceneHeaders = lines.map((line, index) => { + const match = line.match(/^(?:场景\s*)?(?:\d+[.、]\s*)?(内|外)[::]\s*(.+)$/); + return match ? { index, title: `${match[1]} · ${match[2]}` } : null; + }).filter(Boolean); + const sceneDrafts = (sceneHeaders.length ? sceneHeaders : [{ index: 0, title: "试播集 · 开场" }]).map((header, index) => { + const end = sceneHeaders[index + 1]?.index || lines.length; + const blockLines = lines.slice(header.index + (sceneHeaders.length ? 1 : 0), end); + const dialogueLines = blockLines.map((line) => { + const match = line.match(/^([\u4e00-\u9fa5]{2,4})[::]\s*(.+)$/); + return match ? { characterId: match[1], text: match[2], emotion: "自然、克制", mouthPlan: "侧脸/反应镜头", targetDurationSec: Math.max(1.5, Math.min(8, Math.round(match[2].length * 0.22 * 10) / 10)) } : null; + }).filter(Boolean); + const actionLines = blockLines.filter((line) => !/^[\u4e00-\u9fa5]{2,4}[::]/.test(line)); + return { + id: `scene-${index + 1}`, + sceneNumber: index + 1, + title: header.title, + action: actionLines.join(" ") || "人物在当前场景内完成一个连续、可拍摄的动作。", + camera: index === 0 ? "稳定中景,保留环境信息" : "沿用上一镜头轴线的连续中景", + dialogue: dialogueLines, + sourceLines: blockLines.length + }; + }); + return { + wordCount, + chapterCount: chapterBlocks.length, + chapters: chapterBlocks.length ? chapterBlocks : [{ id: "chapter-1", title: "未命名章节", words: wordCount, scenes: 1, status: "已拆解" }], + extracted, + sceneDrafts, + parser: "local-rule-v1" + }; +} + +export function productionGraph(context, options = {}) { + const root = ensureProductionRoot(context); + const project = requireProject(context); + const activeEpisode = options.episodeId ? projectEpisode(context, options.episodeId) : root.episode; + const shots = shotRows(context, activeEpisode.id).map(shotPayload); + const documents = scriptDocuments(context, activeEpisode.id); + const reviews = ensureReviews(context).filter((review) => !review.shot_id || shots.some((shot) => shot.id === review.shot_id)); + const locks = projectAssetLocks(context); + const characters = locks.filter((asset) => asset.kind === "character"); + const locations = locks.filter((asset) => asset.kind === "location"); + const props = locks.filter((asset) => asset.kind === "prop"); + return { + series: root.series, + season: root.season, + episode: activeEpisode, + activeEpisodeId: activeEpisode.id, + catalog: { ...productionCatalog(context), activeSeasonId: activeEpisode.season_id, activeEpisodeId: activeEpisode.id }, + documents, + shots, + characters, + locations, + props, + assets: locks, + reviews, + deliveries: deliveries(context), + constraints: { + singleFrameOnly: true, + requireActualLastFrame: true, + localOnlyDefault: true, + blockedImageTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图"] + } + }; +} + +export function updateBible(context, body = {}) { + requirePermission(context, "script:edit"); + const project = requireProject(context); + const root = ensureProductionRoot(context); + const episode = projectEpisode(context, body.episodeId || root.episode.id); + const timestamp = now(); + const seriesValues = { + title: body.title ?? body.seriesTitle ?? root.series.title, + logline: body.logline ?? root.series.logline, + format: body.format ?? root.series.format, + visualStyle: body.visualStyle ?? body.visual_style ?? root.series.visual_style, + continuityRule: body.continuityRule ?? body.continuity_rule ?? root.series.continuity_rule, + showEngine: body.showEngine ?? body.show_engine ?? root.series.show_engine + }; + const episodeValues = { + title: body.episodeTitle ?? episode.title, + status: body.episodeStatus ?? episode.status, + targetDurationSec: body.targetDurationSec ?? episode.target_duration_sec, + hook: body.hook ?? episode.hook, + cliffhanger: body.cliffhanger ?? episode.cliffhanger + }; + const targetDuration = Number(episodeValues.targetDurationSec); + if (!Number.isFinite(targetDuration) || targetDuration < 0 || targetDuration > 3600) { + throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间"); + } + withTransaction(() => { + dbRun("UPDATE series SET title = ?, logline = ?, format = ?, visual_style = ?, continuity_rule = ?, show_engine = ?, updated_at = ? WHERE id = ? AND project_id = ?", [ + String(seriesValues.title).trim(), + String(seriesValues.logline).trim(), + String(seriesValues.format).trim(), + String(seriesValues.visualStyle).trim(), + String(seriesValues.continuityRule).trim(), + String(seriesValues.showEngine).trim(), + timestamp, + root.series.id, + project.id + ]); + dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [ + String(episodeValues.title).trim(), + String(episodeValues.status).trim(), + targetDuration, + String(episodeValues.hook).trim(), + String(episodeValues.cliffhanger).trim(), + timestamp, + episode.id + ]); + }); + addAudit({ context, action: "series_bible.updated", targetType: "series", targetId: root.series.id, metadata: { episodeId: episode.id, fields: Object.keys(body) } }); + return { + bible: { + series: dbGet("SELECT * FROM series WHERE id = ?", [root.series.id]), + season: dbGet("SELECT * FROM seasons WHERE id = ?", [root.season.id]), + episode: dbGet("SELECT * FROM episodes WHERE id = ?", [episode.id]) + }, + graph: productionGraph(context, { episodeId: episode.id }) + }; +} + +export function importScript(context, body) { + requirePermission(context, "script:edit"); + const project = requireProject(context); + const content = String(body.content || "").trim(); + if (content.length < 10) throw httpError(400, "script_content_required", "剧本内容至少需要 10 个字符"); + const root = ensureProductionRoot(context); + const latest = dbGet("SELECT MAX(version_number) AS version_number FROM script_documents WHERE project_id = ?", [project.id]); + const versionNumber = Number(latest?.version_number || 0) + 1; + const analysis = parseScript(content); + const id = makeId("script"); + const timestamp = now(); + dbRun("INSERT INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'analyzed', ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, body.episodeId || root.episode.id, versionNumber, String(body.title || analysis.chapters[0]?.title || "未命名剧本"), String(body.sourceType || "原创短剧剧本"), content, JSON.stringify(analysis), context.user.id, timestamp, timestamp]); + dbRun("UPDATE episodes SET title = COALESCE(NULLIF(?, ''), title), updated_at = ? WHERE id = ?", [String(body.episodeTitle || "").trim(), timestamp, body.episodeId || root.episode.id]); + addAudit({ context, action: "script.imported", targetType: "script_document", targetId: id, metadata: { versionNumber, wordCount: analysis.wordCount, chapterCount: analysis.chapterCount } }); + addUsage({ context, kind: "script-analysis", units: 1, unitName: "document", metadata: { scriptId: id, parser: analysis.parser } }); + return { document: { ...dbGet("SELECT * FROM script_documents WHERE id = ?", [id]), analysis }, graph: productionGraph(context, { episodeId: body.episodeId || root.episode.id }) }; +} + +export function materializeScript(context, documentId, body = {}) { + requirePermission(context, "script:edit"); + const project = requireProject(context); + const document = dbGet("SELECT * FROM script_documents WHERE id = ? AND project_id = ?", [documentId, project.id]); + if (!document) throw httpError(404, "script_not_found", "剧本版本不存在或不属于当前项目", { documentId }); + const analysis = parseJson(document.analysis_json, {}); + const drafts = Array.isArray(analysis.sceneDrafts) && analysis.sceneDrafts.length ? analysis.sceneDrafts : [{ title: document.title, action: document.content, camera: "稳定中景,保持单一连续画面", dialogue: [] }]; + const episodeId = document.episode_id || ensureProductionRoot(context).episode.id; + projectEpisode(context, episodeId); + const existing = dbGet("SELECT MAX(shot_number) AS shot_number FROM shots WHERE episode_id = ?", [episodeId]); + let nextShotNumber = Number(existing?.shot_number || 0); + const created = []; + const timestamp = now(); + withTransaction(() => { + for (const draft of drafts) { + nextShotNumber += 1; + const shotId = `shot-${project.id}-${String(nextShotNumber).padStart(3, "0")}-${Date.now().toString(36)}-${nextShotNumber}`; + const payload = { + id: shotId, + title: String(draft.title || `场景 ${String(nextShotNumber).padStart(2, "0")}`), + durationSec: Number(body.durationSec || 6), + characterIds: [], + locationId: "", + propIds: [], + camera: String(draft.camera || "稳定中景,保持单一连续画面"), + action: String(draft.action || "待补充镜头动作"), + firstFrame: nextShotNumber === 1 ? "episode-start" : "AUTO_PREVIOUS_ACTUAL_LAST_FRAME", + lastFrame: "pending-actual-last-frame", + transitionFromPrevious: nextShotNumber === 1 ? "episode-start" : "actual-last-frame", + prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.", + negativePrompt: "no split screen, no comic panel, no collage, no contact sheet, no storyboard", + videoPrompt: "保持角色、服装、道具、天气和机位连续;结尾保留可作为下一段首帧的实际末帧。", + seed: null + }; + dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episodeId, nextShotNumber, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); + dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]); + dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]); + for (let index = 0; index < (draft.dialogue || []).length; index += 1) insertVoiceLine(context, shotId, index + 1, draft.dialogue[index], timestamp); + created.push(shotId); + } + }); + addAudit({ context, action: "script.materialized", targetType: "script_document", targetId: documentId, metadata: { shotsCreated: created.length, shotIds: created } }); + return { created, graph: productionGraph(context, { episodeId }) }; +} + +export function createShot(context, body) { + requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true }); + const project = requireProject(context); + const root = ensureProductionRoot(context); + const episodeId = body.episodeId || root.episode.id; + projectEpisode(context, episodeId); + const next = dbGet("SELECT MAX(shot_number) AS shot_number FROM shots WHERE episode_id = ?", [episodeId]); + const shotNumber = Number(next?.shot_number || 0) + 1; + const id = body.id || `shot-${project.id}-${String(shotNumber).padStart(3, "0")}-${Date.now().toString(36)}`; + const timestamp = now(); + const payload = { + id, + title: String(body.title || `新镜头 ${String(shotNumber).padStart(2, "0")}`), + durationSec: Number(body.durationSec || 6), + characterIds: Array.isArray(body.characterIds) ? body.characterIds : [], + locationId: String(body.locationId || ""), + propIds: Array.isArray(body.propIds) ? body.propIds : [], + camera: String(body.camera || "稳定中景,保持单一连续画面"), + action: String(body.action || "待填写镜头动作"), + firstFrame: String(body.firstFrame || (shotNumber === 1 ? "episode-start" : "AUTO_PREVIOUS_ACTUAL_LAST_FRAME")), + lastFrame: String(body.lastFrame || "pending-actual-last-frame"), + transitionFromPrevious: String(body.transitionFromPrevious || (shotNumber === 1 ? "episode-start" : "actual-last-frame")), + prompt: String(body.prompt || "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY."), + negativePrompt: String(body.negativePrompt || "no split screen, no comic panel, no collage, no contact sheet"), + videoPrompt: String(body.videoPrompt || "待填写视频动作和实际末帧要求"), + seed: body.seed === undefined || body.seed === "" ? null : Number(body.seed) + }; + withTransaction(() => { + dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [id, episodeId, shotNumber, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); + dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${id}-v1`, id, JSON.stringify(payload), context.user.id, timestamp]); + dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${id}-v1`, id]); + for (let index = 0; index < (body.voiceLines || []).length; index += 1) insertVoiceLine(context, id, index + 1, body.voiceLines[index], timestamp); + }); + addAudit({ context, action: "shot.created", targetType: "shot", targetId: id, metadata: { shotNumber, episodeId } }); + return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [id])), graph: productionGraph(context, { episodeId }) }; +} + +function ensureShot(context, shotId) { + const project = requireProject(context); + const row = dbGet( + `SELECT s.* FROM shots s + JOIN episodes e ON e.id = s.episode_id + JOIN seasons se ON se.id = e.season_id + JOIN series sr ON sr.id = se.series_id + WHERE s.id = ? AND sr.project_id = ?`, + [shotId, project.id] + ); + if (!row) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId }); + return row; +} + +function insertVoiceLine(context, shotId, lineNumber, line, timestamp = now()) { + const item = line || {}; + const id = item.id || `${shotId}-line-${lineNumber}`; + dbRun("INSERT INTO voice_lines(id, shot_id, line_number, character_key, text, emotion, mouth_plan, target_duration_sec, voice_id, audio_path, status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(shot_id, line_number) DO UPDATE SET character_key = excluded.character_key, text = excluded.text, emotion = excluded.emotion, mouth_plan = excluded.mouth_plan, target_duration_sec = excluded.target_duration_sec, voice_id = excluded.voice_id, audio_path = excluded.audio_path, status = excluded.status, updated_at = excluded.updated_at", [id, shotId, lineNumber, String(item.characterId || item.character_key || ""), String(item.text || ""), String(item.emotion || ""), String(item.mouthPlan || item.mouth_plan || "侧脸/反应镜头"), Number(item.targetDurationSec || item.target_duration_sec || 2), String(item.voiceId || item.voice_id || ""), String(item.audioFile || item.audio_path || ""), String(item.status || "draft"), context.user.id, timestamp, timestamp]); +} + +export function updateShot(context, shotId, body) { + requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true }); + const current = ensureShot(context, shotId); + const existing = shotPayload(current); + const timestamp = now(); + const payload = { + ...existing, + ...body, + id: shotId, + characterIds: Array.isArray(body.characterIds) ? body.characterIds : existing.characterIds || [], + propIds: Array.isArray(body.propIds) ? body.propIds : existing.propIds || [], + durationSec: Number(body.durationSec ?? existing.durationSec ?? 6) + }; + withTransaction(() => { + const nextVersion = Number(existing.versionNumber || 0) + 1; + dbRun("UPDATE shots SET title = ?, status = ?, first_frame_path = ?, last_frame_path = ?, continuity_json = ?, updated_at = ? WHERE id = ?", [payload.title, String(body.status || current.status || "draft"), payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, shotId]); + dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [`${shotId}-v${nextVersion}`, shotId, nextVersion, JSON.stringify(payload), String(body.status || current.status || "draft"), context.user.id, timestamp]); + dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v${nextVersion}`, shotId]); + if (Array.isArray(body.voiceLines)) { + dbRun("DELETE FROM voice_lines WHERE shot_id = ?", [shotId]); + for (let index = 0; index < body.voiceLines.length; index += 1) insertVoiceLine(context, shotId, index + 1, body.voiceLines[index], timestamp); + } + }); + addAudit({ context, action: "shot.updated", targetType: "shot", targetId: shotId, metadata: { version: Number(existing.versionNumber || 0) + 1 } }); + return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [shotId])), graph: productionGraph(context, { episodeId: current.episode_id }) }; +} + +export function savePromptVersion(context, shotId, body) { + requirePermission(context, "prompt:edit"); + const current = ensureShot(context, shotId); + const existing = shotPayload(current); + const timestamp = now(); + const nextVersion = Number(existing.versionNumber || 0) + 1; + const payload = { + ...existing, + prompt: String(body.imagePrompt ?? body.prompt ?? existing.prompt ?? ""), + negativePrompt: String(body.negativePrompt ?? existing.negativePrompt ?? ""), + videoPrompt: String(body.videoPrompt ?? existing.videoPrompt ?? ""), + seed: body.seed === undefined || body.seed === "" ? existing.seed : Number(body.seed) + }; + dbRun("UPDATE shots SET continuity_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(payload), timestamp, shotId]); + dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, ?, ?, 'draft', ?, ?)", [`${shotId}-v${nextVersion}`, shotId, nextVersion, JSON.stringify(payload), context.user.id, timestamp]); + dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v${nextVersion}`, shotId]); + addAudit({ context, action: "shot.prompt.version.created", targetType: "shot_version", targetId: `${shotId}-v${nextVersion}`, metadata: { shotId, version: nextVersion } }); + return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [shotId])), graph: productionGraph(context, { episodeId: current.episode_id }) }; +} + +export function listShotVersions(context, shotId) { + requireAnyPermission(context, ["script:read", "script:edit", "prompt:edit"]); + const shot = ensureShot(context, shotId); + return { + shotId, + currentVersionId: shot.current_version_id || "", + versions: dbAll( + `SELECT sv.*, u.display_name AS created_by_name + FROM shot_versions sv + LEFT JOIN users u ON u.id = sv.created_by + WHERE sv.shot_id = ? + ORDER BY sv.version_number DESC`, + [shotId] + ).map((version) => ({ + ...version, + payload: parseJson(version.payload_json, {}), + isCurrent: version.id === shot.current_version_id + })) + }; +} + +export function restoreShotVersion(context, shotId, versionId) { + requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true }); + const current = ensureShot(context, shotId); + const version = dbGet("SELECT * FROM shot_versions WHERE id = ? AND shot_id = ?", [versionId, shotId]); + if (!version) throw httpError(404, "shot_version_not_found", "镜头版本不存在或不属于当前镜头", { shotId, versionId }); + if (version.id === current.current_version_id) return { shot: shotPayload(current), versions: listShotVersions(context, shotId), graph: productionGraph(context, { episodeId: current.episode_id }) }; + const payload = parseJson(version.payload_json, {}); + const timestamp = now(); + withTransaction(() => { + dbRun( + `UPDATE shots + SET title = ?, status = ?, first_frame_path = ?, last_frame_path = ?, current_version_id = ?, continuity_json = ?, updated_at = ? + WHERE id = ?`, + [ + String(payload.title || current.title), + String(version.status || current.status || "draft"), + String(payload.firstFrame || current.first_frame_path || ""), + String(payload.lastFrame || current.last_frame_path || ""), + version.id, + JSON.stringify(payload), + timestamp, + shotId + ] + ); + if (Array.isArray(payload.voiceLines)) { + dbRun("DELETE FROM voice_lines WHERE shot_id = ?", [shotId]); + for (let index = 0; index < payload.voiceLines.length; index += 1) insertVoiceLine(context, shotId, index + 1, payload.voiceLines[index], timestamp); + } + }); + addAudit({ context, action: "shot.version.restored", targetType: "shot_version", targetId: version.id, metadata: { shotId, version: version.version_number, previousVersionId: current.current_version_id || null } }); + const refreshed = dbGet("SELECT * FROM shots WHERE id = ?", [shotId]); + return { shot: shotPayload(refreshed), versions: listShotVersions(context, shotId), graph: productionGraph(context, { episodeId: current.episode_id }) }; +} + +export function qaReviews(context) { + requirePermission(context, "qa:review"); + ensureProductionRoot(context); + return { reviews: ensureReviews(context), graph: productionGraph(context) }; +} + +export function runAutomatedQa(context) { + requirePermission(context, "qa:review"); + requireProjectWritable(context); + const project = requireProject(context); + ensureReviews(context); + const shots = shotRows(context).map(shotPayload); + const timestamp = now(); + const blockedTerms = ["storyboard", "comic panel", "collage", "contact sheet", "split screen", "多格", "拼图", "分屏", "故事板拼图"]; + const results = []; + for (const shot of shots) { + const bindings = dbAll( + `SELECT a.kind, a.lock_status, av.rights_status + FROM asset_bindings ab + JOIN assets a ON a.id = ab.asset_id + LEFT JOIN asset_versions av ON av.id = a.current_version_id + WHERE ab.shot_id = ?`, + [shot.id] + ); + // The negative prompt is the evidence of the block rule, not a request to generate that layout. + const promptText = [shot.prompt, shot.action, shot.camera].join(" ").toLowerCase(); + const blocked = blockedTerms.filter((term) => promptText.includes(term)); + const missingLocks = ["character", "location", "prop"].filter((kind) => !bindings.some((binding) => binding.kind === kind && binding.lock_status === "locked" && binding.rights_status === "approved")); + const hasVoiceIssue = shot.voiceLines.some((line) => !line.voiceId || !line.audioFile); + const needsActualLastFrame = shot.shotNumber > 1 && shot.transitionFromPrevious !== "episode-start"; + const bridgeIssue = needsActualLastFrame && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame)); + const checks = [ + { lane: "single-frame", ok: blocked.length === 0, blockers: blocked.length ? [`禁用词:${blocked.join("、")}`] : [] }, + { lane: "continuity-lock", ok: missingLocks.length === 0, blockers: missingLocks.length ? [`未锁定或未确认:${missingLocks.join("、")}`] : [] }, + { lane: "voice-subtitle-asr", ok: !hasVoiceIssue, blockers: hasVoiceIssue ? ["对白缺少固定 voiceId 或音频产物"] : [] }, + { lane: "clip-bridge", ok: !bridgeIssue, blockers: bridgeIssue ? ["连续片段缺少上一段实际末帧首帧"] : [] } + ]; + for (const check of checks) { + const reviewId = `review-${shot.id}-${check.lane}`; + const current = dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]); + const status = check.ok ? "approved" : "changes_requested"; + const evidence = { source: "automated-contract-qa", checkedAt: timestamp, blockers: check.blockers, shotId: shot.id }; + if (!current || current.status !== "approved" || parseJson(current.evidence_json, {}).source === "automated-contract-qa") { + dbRun("UPDATE reviews SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE id = ?", [status, check.ok ? 100 : 40, context.user.id, timestamp, JSON.stringify(evidence), timestamp, reviewId]); + } + results.push({ shotId: shot.id, lane: check.lane, status, blockers: check.blockers }); + } + } + addAudit({ context, action: "qa.automated.run", targetType: "project", targetId: project.id, metadata: { checks: results.length, passed: results.filter((item) => item.status === "approved").length } }); + return { results, reviews: ensureReviews(context), graph: productionGraph(context) }; +} + +export async function runMediaQa(context) { + requirePermission(context, "qa:review"); + requireProjectWritable(context); + const project = requireProject(context); + ensureReviews(context); + await syncProjectJobArtifacts(context); + const shots = shotRows(context).map(shotPayload); + const report = await inspectMediaForProject(context, shots); + const timestamp = now(); + for (const shotReport of report.reports) { + for (const gate of shotReport.gates) { + const reviewId = `review-${shotReport.shotId}-${gate.lane}`; + const current = dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]); + const currentEvidence = parseJson(current?.evidence_json, {}); + const isManualDecision = current?.status === "approved" && currentEvidence.source === "manual-review"; + if (!isManualDecision) { + dbRun( + `UPDATE reviews + SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? + WHERE id = ?`, + [gate.status, gate.status === "approved" ? 100 : gate.status === "changes_requested" ? 40 : null, context.user.id, gate.status === "pending" ? null : timestamp, JSON.stringify({ source: "media-inspection-v1", checkedAt: report.checkedAt, blockers: gate.blockers, evidence: gate.evidence, media: shotReport.media }), timestamp, reviewId] + ); + } + } + } + addAudit({ context, action: "qa.media_inspection.run", targetType: "project", targetId: project.id, metadata: report.totals }); + return { report, reviews: ensureReviews(context), graph: productionGraph(context) }; +} + +function ensureReview(context, reviewId) { + const project = requireProject(context); + const review = dbGet("SELECT * FROM reviews WHERE id = ? AND project_id = ?", [reviewId, project.id]); + if (!review) throw httpError(404, "review_not_found", "质检项不存在或不属于当前项目", { reviewId }); + return review; +} + +export function decideReview(context, reviewId, body) { + requirePermission(context, "qa:review"); + requireProjectWritable(context); + const review = ensureReview(context, reviewId); + const status = String(body.status || "").trim(); + if (!["approved", "rejected", "changes_requested", "pending"].includes(status)) throw httpError(400, "review_status_invalid", "质检状态无效"); + const timestamp = now(); + const evidence = body.evidence && typeof body.evidence === "object" ? body.evidence : parseJson(review.evidence_json, {}); + dbRun("UPDATE reviews SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE id = ?", [status, body.score === undefined ? null : Number(body.score), context.user.id, status === "pending" ? null : timestamp, JSON.stringify(evidence), timestamp, reviewId]); + addAudit({ context, action: `qa.review.${status}`, targetType: "review", targetId: reviewId, result: status === "approved" ? "pass" : status, metadata: { lane: review.lane, score: body.score ?? null, evidence } }); + const eventKey = status === "approved" ? "review.approved" : status === "changes_requested" ? "review.changes_requested" : status === "rejected" ? "review.rejected" : "review.updated"; + void dispatchNotificationEvent({ context, eventKey, payload: { reviewId, lane: review.lane, status, shotId: review.shot_id, targetId: reviewId } }); + return { review: dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]), reviews: ensureReviews(context) }; +} + +export function addReviewComment(context, reviewId, body) { + requirePermission(context, "qa:review"); + requireProjectWritable(context); + ensureReview(context, reviewId); + const bodyText = String(body.body || "").trim(); + if (!bodyText) throw httpError(400, "comment_required", "质检评论不能为空"); + const id = makeId("comment"); + dbRun("INSERT INTO review_comments(id, review_id, author_user_id, body, created_at) VALUES (?, ?, ?, ?, ?)", [id, reviewId, context.user.id, bodyText, now()]); + addAudit({ context, action: "qa.comment.created", targetType: "review_comment", targetId: id, metadata: { reviewId } }); + void dispatchNotificationEvent({ context, eventKey: "review.comment", payload: { reviewId, targetId: reviewId, body: bodyText } }); + return { comments: dbAll("SELECT c.*, u.display_name AS author_name FROM review_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.review_id = ? ORDER BY c.created_at", [reviewId]) }; +} + +export function listDeliveries(context) { + requireAnyPermission(context, ["delivery:view", "delivery:approve"]); + return { deliveries: deliveries(context) }; +} + +export function createDelivery(context, body) { + requirePermission(context, "delivery:approve"); + const project = requireProject(context); + const latest = dbGet("SELECT COUNT(*) AS count FROM deliveries WHERE project_id = ?", [project.id]); + const version = String(body.version || `v${Number(latest?.count || 0) + 1}.0`); + const id = makeId("delivery"); + const timestamp = now(); + const manifestPath = String(body.manifestPath || `storage/deliveries/${project.id}/${version}/delivery-manifest.json`); + if (!manifestPath.startsWith("storage/") || manifestPath.includes("..") || manifestPath.startsWith("/")) throw httpError(400, "manifest_path_invalid", "交付 manifest 必须写入 storage/ 下的安全相对路径"); + dbRun("INSERT INTO deliveries(id, organization_id, workspace_id, project_id, version, manifest_path, channel, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)", [id, context.organization.id, context.workspace.id, project.id, version, manifestPath, String(body.channel || "internal"), timestamp, timestamp]); + addAudit({ context, action: "delivery.created", targetType: "delivery", targetId: id, metadata: { version, manifestPath } }); + void dispatchNotificationEvent({ context, eventKey: "delivery.created", payload: { deliveryId: id, version, targetId: id } }); + return { delivery: dbGet("SELECT * FROM deliveries WHERE id = ?", [id]), deliveries: deliveries(context) }; +} + +function deliveryForContext(context, deliveryId) { + const project = requireProject(context); + const delivery = dbGet( + `SELECT * FROM deliveries + WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`, + [deliveryId, context.organization.id, context.workspace.id, project.id] + ); + if (!delivery) throw httpError(404, "delivery_not_found", "交付版本不存在或不属于当前项目", { deliveryId }); + return delivery; +} + +function releasePayload(row) { + if (!row) return null; + return { + ...row, + channel: { + id: row.channel_id, + name: row.channel_name, + kind: row.channel_kind, + enabled: Boolean(row.channel_enabled), + endpoint: row.channel_endpoint || "", + requireApproval: Boolean(row.channel_require_approval) + }, + preflight: parseJson(row.preflight_json, {}), + result: parseJson(row.result_json, {}) + }; +} + +function releaseForContext(context, releaseId) { + const project = requireProject(context); + const release = dbGet( + `SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled, + dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval, + d.version AS delivery_version, d.status AS delivery_status, + requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name, + published.display_name AS published_by_name + FROM delivery_releases dr + JOIN delivery_channels dc ON dc.id = dr.channel_id + JOIN deliveries d ON d.id = dr.delivery_id + LEFT JOIN users requested ON requested.id = dr.requested_by + LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by + LEFT JOIN users published ON published.id = dr.published_by + WHERE dr.id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ?`, + [releaseId, context.organization.id, context.workspace.id, project.id] + ); + if (!release) throw httpError(404, "delivery_release_not_found", "发布记录不存在或不属于当前项目", { releaseId }); + return release; +} + +function releasePreflight(context, delivery, channel) { + const blockers = []; + const project = requireProject(context); + if (!channel.enabled) blockers.push({ type: "channel", status: "disabled", reason: "发布渠道已停用" }); + if (delivery.status !== "approved") blockers.push({ type: "delivery", status: delivery.status, reason: "交付版本必须先通过交付审批" }); + + const activeBatch = delivery.active_batch_id + ? dbGet( + `SELECT * FROM delivery_batches + WHERE id = ? AND delivery_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`, + [delivery.active_batch_id, delivery.id, context.organization.id, context.workspace.id, project.id] + ) + : null; + if (!activeBatch) { + blockers.push({ type: "delivery_batch", status: "missing", reason: "交付版本没有当前生效批次" }); + } else { + if (activeBatch.status !== "active") blockers.push({ type: "delivery_batch", status: activeBatch.status, reason: "当前交付批次不是 active" }); + const batchResult = parseJson(activeBatch.result_json, {}); + if (Array.isArray(batchResult.blockers) && batchResult.blockers.length) blockers.push({ type: "delivery_batch", status: "blocked", reason: "批次创建时已登记媒体阻断项", items: batchResult.blockers }); + const manifest = safeStoragePath(activeBatch.manifest_path); + if (!manifest || !existsSync(manifest.absolute)) blockers.push({ type: "manifest", status: "missing", reason: "当前批次 Manifest 文件不存在" }); + + const items = dbAll( + `SELECT dbi.*, s.title AS shot_title + FROM delivery_batch_items dbi + LEFT JOIN shots s ON s.id = dbi.shot_id + WHERE dbi.batch_id = ? ORDER BY dbi.sequence_number`, + [activeBatch.id] + ); + if (!items.length) blockers.push({ type: "delivery_batch_items", status: "missing", reason: "当前批次没有镜头条目" }); + for (const item of items) { + const source = safeStoragePath(item.source_path); + const actualLastFrame = safeStoragePath(item.actual_last_frame_path); + if (!source || !existsSync(source.absolute)) blockers.push({ type: "media", shotId: item.shot_id, status: "missing", reason: "视频源文件不存在" }); + if (!item.source_sha256) blockers.push({ type: "media", shotId: item.shot_id, status: "unverified", reason: "视频源缺少 SHA-256" }); + if (!actualLastFrame || !existsSync(actualLastFrame.absolute)) blockers.push({ type: "actual-last-frame", shotId: item.shot_id, status: "missing", reason: "实际末帧证据不存在" }); + } + return { + ok: blockers.length === 0, + checkedAt: now(), + projectId: project.id, + deliveryId: delivery.id, + channelId: channel.id, + batchId: activeBatch.id, + manifestPath: activeBatch.manifest_path, + itemCount: items.length, + blockers + }; + } + return { + ok: false, + checkedAt: now(), + projectId: project.id, + deliveryId: delivery.id, + channelId: channel.id, + batchId: null, + manifestPath: "", + itemCount: 0, + blockers + }; +} + +export function listDeliveryReleases(context, deliveryId) { + requireAnyPermission(context, ["delivery:view", "delivery:approve"]); + deliveryForContext(context, deliveryId); + return { + deliveryId, + releases: dbAll( + `SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled, + dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval, + d.version AS delivery_version, d.status AS delivery_status, + requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name, + published.display_name AS published_by_name + FROM delivery_releases dr + JOIN delivery_channels dc ON dc.id = dr.channel_id + JOIN deliveries d ON d.id = dr.delivery_id + LEFT JOIN users requested ON requested.id = dr.requested_by + LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by + LEFT JOIN users published ON published.id = dr.published_by + WHERE dr.delivery_id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ? + ORDER BY dr.created_at DESC`, + [deliveryId, context.organization.id, context.workspace.id, context.project.id] + ).map(releasePayload) + }; +} + +export function createDeliveryRelease(context, deliveryId, body = {}) { + requirePermission(context, "delivery:approve"); + const delivery = deliveryForContext(context, deliveryId); + const requestedChannelId = String(body.channelId || "").trim(); + const channel = requestedChannelId ? channelForContext(context, requestedChannelId) : ensureDefaultDeliveryChannel(context); + const idempotencyKey = String(body.idempotencyKey || "").trim().slice(0, 160); + if (idempotencyKey) { + const existing = dbGet( + `SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled, + dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval, + d.version AS delivery_version, d.status AS delivery_status, + requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name, + published.display_name AS published_by_name + FROM delivery_releases dr + JOIN delivery_channels dc ON dc.id = dr.channel_id + JOIN deliveries d ON d.id = dr.delivery_id + LEFT JOIN users requested ON requested.id = dr.requested_by + LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by + LEFT JOIN users published ON published.id = dr.published_by + WHERE dr.organization_id = ? AND dr.project_id = ? AND dr.idempotency_key = ?`, + [context.organization.id, context.project.id, idempotencyKey] + ); + if (existing) return { release: releasePayload(existing), idempotent: true, ...listDeliveryReleases(context, deliveryId) }; + } + const submit = body.submit !== false; + const preflight = submit ? releasePreflight(context, delivery, channel) : { ok: false, checkedAt: now(), blockers: [{ type: "not_submitted", status: "draft", reason: "发布申请尚未提交" }] }; + if (submit && !preflight.ok) throw httpError(409, "release_blocked", "当前交付版本不满足发布申请条件", { blockers: preflight.blockers, preflight }); + const id = makeId("delivery-release"); + const timestamp = now(); + const status = submit ? "submitted" : "draft"; + dbRun( + `INSERT INTO delivery_releases( + id, organization_id, workspace_id, project_id, delivery_id, channel_id, status, idempotency_key, + requested_by, requested_at, preflight_json, result_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)`, + [id, context.organization.id, context.workspace.id, context.project.id, delivery.id, channel.id, status, idempotencyKey, submit ? context.user.id : null, submit ? timestamp : null, JSON.stringify(preflight), timestamp, timestamp] + ); + addAudit({ context, action: submit ? "delivery.release.submitted" : "delivery.release.created", targetType: "delivery_release", targetId: id, metadata: { deliveryId, channelId: channel.id, status, idempotencyKey: Boolean(idempotencyKey) } }); + void dispatchNotificationEvent({ context, eventKey: submit ? "delivery.release.submitted" : "delivery.release.created", payload: { releaseId: id, deliveryId, targetId: id, status } }); + return { release: releasePayload(releaseForContext(context, id)), ...listDeliveryReleases(context, deliveryId) }; +} + +export function decideDeliveryRelease(context, releaseId, body = {}) { + requirePermission(context, "delivery:approve"); + const release = releaseForContext(context, releaseId); + const status = String(body.status || "").trim(); + const note = String(body.note || "").trim().slice(0, 4000); + const delivery = deliveryForContext(context, release.delivery_id); + const channel = channelForContext(context, release.channel_id); + let preflight = parseJson(release.preflight_json, {}); + if (status === "draft") { + if (release.status !== "rejected") throw httpError(409, "release_transition_invalid", "只有已驳回的发布申请可以退回草稿"); + const timestamp = now(); + dbRun("UPDATE delivery_releases SET status = 'draft', decision_note = ?, reviewed_by = NULL, reviewed_at = NULL, updated_at = ? WHERE id = ?", [note, timestamp, releaseId]); + } else if (status === "submitted") { + if (!["draft", "rejected"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有草稿或已驳回的发布申请可以重新提交"); + preflight = releasePreflight(context, delivery, channel); + if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请仍未满足发布条件", { blockers: preflight.blockers, preflight }); + const timestamp = now(); + dbRun("UPDATE delivery_releases SET status = 'submitted', requested_by = ?, requested_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]); + } else if (status === "approved") { + if (release.status !== "submitted") throw httpError(409, "release_transition_invalid", "只有已提交的发布申请可以批准"); + preflight = releasePreflight(context, delivery, channel); + if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请审批被质量门阻断", { blockers: preflight.blockers, preflight }); + const timestamp = now(); + dbRun("UPDATE delivery_releases SET status = 'approved', reviewed_by = ?, reviewed_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]); + } else if (status === "rejected") { + if (release.status !== "submitted") throw httpError(409, "release_transition_invalid", "只有已提交的发布申请可以驳回"); + const timestamp = now(); + dbRun("UPDATE delivery_releases SET status = 'rejected', reviewed_by = ?, reviewed_at = ?, decision_note = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note || "审批人要求修改后重新提交", timestamp, releaseId]); + } else { + throw httpError(400, "release_status_invalid", "发布申请状态只能是 draft、submitted、approved 或 rejected"); + } + addAudit({ context, action: `delivery.release.${status}`, targetType: "delivery_release", targetId: releaseId, result: status === "approved" ? "pass" : status, metadata: { deliveryId: release.delivery_id, channelId: release.channel_id, note } }); + void dispatchNotificationEvent({ context, eventKey: `delivery.release.${status}`, payload: { releaseId, deliveryId: release.delivery_id, targetId: releaseId, status } }); + return { release: releasePayload(releaseForContext(context, releaseId)), ...listDeliveryReleases(context, release.delivery_id) }; +} + +export async function publishDeliveryRelease(context, releaseId) { + requirePermission(context, "delivery:approve"); + const release = releaseForContext(context, releaseId); + if (release.status === "published") return { release: releasePayload(release), idempotent: true, ...listDeliveryReleases(context, release.delivery_id) }; + if (!["approved", "failed"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有已批准或上次发布失败的申请可以发布"); + const delivery = deliveryForContext(context, release.delivery_id); + const channel = channelForContext(context, release.channel_id); + const preflight = releasePreflight(context, delivery, channel); + if (!preflight.ok) throw httpError(409, "release_blocked", "发布前复核未通过", { blockers: preflight.blockers, preflight }); + const outputDirectory = `storage/releases/${safePathSegment(context.project.id)}/${safePathSegment(delivery.version)}/${safePathSegment(release.id)}`; + const releaseOutputPath = `${outputDirectory}/release.json`; + const manifestOutputPath = `${outputDirectory}/delivery-manifest.json`; + const timestamp = now(); + const releaseDocument = { + schema: "ai-drama-platform.delivery-release.v1", + publishedAt: timestamp, + release: { id: release.id, status: "published", requestedBy: release.requested_by, reviewedBy: release.reviewed_by }, + delivery: { id: delivery.id, version: delivery.version, status: delivery.status, manifestPath: delivery.manifest_path }, + channel: { id: channel.id, name: channel.name, kind: channel.kind, endpoint: channel.kind === "local-file" ? channel.endpoint : "private-webhook" }, + batch: { id: preflight.batchId, manifestPath: preflight.manifestPath, itemCount: preflight.itemCount }, + preflight + }; + let result = { kind: channel.kind, outputPath: releaseOutputPath, manifestPath: manifestOutputPath, localOnly: true }; + try { + const outputDirectoryTarget = safeStoragePath(`${outputDirectory}/release.json`); + const manifestTarget = safeStoragePath(`${outputDirectory}/delivery-manifest.json`); + const sourceManifest = safeStoragePath(preflight.manifestPath); + if (!outputDirectoryTarget || !manifestTarget || !sourceManifest) throw httpError(500, "release_output_path_invalid", "发布输出路径不满足本地存储安全约束"); + mkdirSync(resolve(projectRoot, outputDirectory), { recursive: true }); + if (!existsSync(sourceManifest.absolute)) throw httpError(409, "release_manifest_missing", "发布时找不到当前批次 Manifest"); + writeFileSync(manifestTarget.absolute, readFileSync(sourceManifest.absolute)); + writeFileSync(outputDirectoryTarget.absolute, `${JSON.stringify(releaseDocument, null, 2)}\n`, "utf8"); + if (channel.kind === "local-webhook") { + const authValue = channel.auth_env ? process.env[channel.auth_env] : ""; + if (channel.auth_env && !authValue) throw httpError(409, "delivery_channel_auth_missing", `本地 Webhook 所需环境变量未设置:${channel.auth_env}`); + const headers = { "content-type": "application/json", accept: "application/json" }; + if (authValue) headers.authorization = `Bearer ${authValue}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch(channel.endpoint, { method: "POST", headers, body: JSON.stringify(releaseDocument), signal: controller.signal }); + const responseText = await response.text(); + if (!response.ok) throw httpError(502, "delivery_webhook_failed", `本地 Webhook 返回 HTTP ${response.status}`, { status: response.status, body: responseText.slice(0, 1000) }); + result = { ...result, webhookStatus: response.status, webhookBody: responseText.slice(0, 1000) }; + } finally { + clearTimeout(timeout); + } + } + dbRun("UPDATE delivery_releases SET status = 'published', published_by = ?, published_at = ?, output_path = ?, preflight_json = ?, result_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, releaseOutputPath, JSON.stringify(preflight), JSON.stringify(result), timestamp, releaseId]); + addUsage({ context, kind: "delivery_release_published", units: 1, unitName: "release", metadata: { releaseId, deliveryId: delivery.id, channelId: channel.id, channelKind: channel.kind } }); + addAudit({ context, action: "delivery.release.published", targetType: "delivery_release", targetId: releaseId, metadata: { deliveryId: delivery.id, channelId: channel.id, outputPath: releaseOutputPath, channelKind: channel.kind } }); + void dispatchNotificationEvent({ context, eventKey: "delivery.release.published", payload: { releaseId, deliveryId: delivery.id, targetId: releaseId, outputPath: releaseOutputPath } }); + } catch (error) { + const failure = { kind: channel.kind, error: error.message, code: error.code || "delivery_release_publish_failed" }; + dbRun("UPDATE delivery_releases SET status = 'failed', result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(failure), now(), releaseId]); + addAudit({ context, action: "delivery.release.publish_failed", targetType: "delivery_release", targetId: releaseId, result: "error", metadata: { deliveryId: delivery.id, channelId: channel.id, error: error.message, code: error.code || "delivery_release_publish_failed" } }); + if (error.status) throw error; + throw httpError(502, "delivery_release_publish_failed", `发布执行失败:${error.message}`); + } + return { release: releasePayload(releaseForContext(context, releaseId)), ...listDeliveryReleases(context, delivery.id) }; +} + +function deliveryBatchPayload(row) { + return { + ...row, + source: parseJson(row.source_json, {}), + result: parseJson(row.result_json, {}), + items: dbAll( + `SELECT dbi.*, s.title AS shot_title + FROM delivery_batch_items dbi + LEFT JOIN shots s ON s.id = dbi.shot_id + WHERE dbi.batch_id = ? + ORDER BY dbi.sequence_number`, + [row.id] + ).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) })) + }; +} + +export function listDeliveryBatches(context, deliveryId) { + requireAnyPermission(context, ["delivery:view", "delivery:approve"]); + deliveryForContext(context, deliveryId); + return { + deliveryId, + batches: dbAll( + `SELECT db.*, u.display_name AS created_by_name + FROM delivery_batches db + LEFT JOIN users u ON u.id = db.created_by + WHERE db.delivery_id = ? + ORDER BY db.batch_number DESC`, + [deliveryId] + ).map(deliveryBatchPayload) + }; +} + +export function createDeliveryBatch(context, deliveryId, body = {}) { + requirePermission(context, "delivery:approve"); + const delivery = deliveryForContext(context, deliveryId); + const compositionId = body.compositionId ? String(body.compositionId) : ""; + const composition = compositionId + ? dbGet("SELECT * FROM media_compositions WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [compositionId, context.organization.id, context.workspace.id, context.project.id]) + : null; + if (compositionId && !composition) throw httpError(404, "composition_not_found", "合成记录不存在或不属于当前项目", { compositionId }); + const latest = dbGet("SELECT MAX(batch_number) AS batch_number FROM delivery_batches WHERE delivery_id = ?", [deliveryId]); + const batchNumber = Number(latest?.batch_number || 0) + 1; + const id = makeId("delivery-batch"); + const timestamp = now(); + const shots = shotRows(context).map(shotPayload); + const shotArtifacts = shots.map((shot) => ({ shot, artifact: latestArtifactForShot(context, shot.id, "video") })); + const artifactBlockers = shotArtifacts + .filter(({ artifact }) => !artifact || artifact.status !== "inspected" || !artifact.sha256 || !artifact.path) + .map(({ shot, artifact }) => ({ shotId: shot.id, reason: artifact ? `视频文件${artifact.status === "missing" ? "不存在" : "未通过文件检查"}` : "没有已登记的视频媒体证据" })); + const source = { + schema: "ai-drama-platform.delivery-batch-source.v2", + capturedAt: timestamp, + deliveryId, + deliveryVersion: delivery.version, + compositionId: composition?.id || null, + blockers: artifactBlockers, + shots: shotArtifacts.map(({ shot, artifact }) => ({ + shotId: shot.id, + versionId: shot.versionId || null, + versionNumber: shot.versionNumber || 1, + firstFrame: shot.firstFrame || "", + lastFrame: artifact?.last_frame_path || shot.lastFrame || "", + transitionFromPrevious: shot.transitionFromPrevious || "", + artifactId: artifact?.id || null, + sourcePath: artifact?.path || "", + sourceSha256: artifact?.sha256 || "", + mediaStatus: artifact?.status || "missing" + })) + }; + const manifestPath = String(body.manifestPath || `storage/deliveries/${context.project.id}/${delivery.version}/batches/batch-${batchNumber}/manifest.json`); + if (!manifestPath.startsWith("storage/") || manifestPath.includes("..") || manifestPath.startsWith("/")) throw httpError(400, "manifest_path_invalid", "交付 manifest 必须写入 storage/ 下的安全相对路径"); + withTransaction(() => { + dbRun( + `INSERT INTO delivery_batches( + id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, + manifest_path, composition_id, source_json, result_json, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared', ?, ?, ?, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, context.project.id, deliveryId, batchNumber, String(body.label || `批次 ${batchNumber}`), manifestPath, composition?.id || null, JSON.stringify(source), JSON.stringify({ blockers: artifactBlockers, artifactCount: shotArtifacts.filter(({ artifact }) => artifact).length }), context.user.id, timestamp, timestamp] + ); + for (let index = 0; index < shotArtifacts.length; index += 1) { + const { shot, artifact } = shotArtifacts[index]; + dbRun( + `INSERT INTO delivery_batch_items( + id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [makeId("delivery-item"), id, shot.id, index + 1, String(artifact?.path || ""), String(artifact?.last_frame_path || shot.lastFrame || ""), String(artifact?.sha256 || ""), JSON.stringify({ versionId: shot.versionId || null, versionNumber: shot.versionNumber || 1, artifactId: artifact?.id || null, artifactStatus: artifact?.status || "missing", durationSec: artifact?.durationSec || 0, width: artifact?.width || 0, height: artifact?.height || 0 }), timestamp] + ); + } + }); + const manifest = { + schema: "ai-drama-platform.delivery-manifest.v2", + generatedAt: timestamp, + delivery: { id: delivery.id, version: delivery.version, channel: delivery.channel }, + batch: { id, number: batchNumber, label: String(body.label || `批次 ${batchNumber}`), compositionId: composition?.id || null }, + blockers: artifactBlockers, + items: source.shots + }; + mkdirSync(resolve(projectRoot, manifestPath, ".."), { recursive: true }); + writeFileSync(resolve(projectRoot, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + dbRun("UPDATE delivery_batches SET result_json = ? WHERE id = ?", [JSON.stringify({ blockers: artifactBlockers, artifactCount: shotArtifacts.filter(({ artifact }) => artifact).length, manifestWritten: true }), id]); + addAudit({ context, action: "delivery.batch.created", targetType: "delivery_batch", targetId: id, metadata: { deliveryId, batchNumber, shotCount: shots.length, manifestPath, artifactBlockers: artifactBlockers.length } }); + return { batch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [id])), batches: listDeliveryBatches(context, deliveryId).batches }; +} + +export function activateDeliveryBatch(context, batchId) { + requirePermission(context, "delivery:approve"); + const project = requireProject(context); + const batch = dbGet("SELECT * FROM delivery_batches WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [batchId, context.organization.id, context.workspace.id, project.id]); + if (!batch) throw httpError(404, "delivery_batch_not_found", "交付批次不存在或不属于当前项目", { batchId }); + if (batch.status === "rolled_back") throw httpError(409, "delivery_batch_rolled_back", "已回滚批次不能重新激活"); + const timestamp = now(); + withTransaction(() => { + dbRun("UPDATE delivery_batches SET status = 'superseded', updated_at = ? WHERE delivery_id = ? AND status = 'active'", [timestamp, batch.delivery_id]); + dbRun("UPDATE delivery_batches SET status = 'active', updated_at = ? WHERE id = ?", [timestamp, batchId]); + dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [batchId, timestamp, batch.delivery_id]); + }); + addAudit({ context, action: "delivery.batch.activated", targetType: "delivery_batch", targetId: batchId, metadata: { deliveryId: batch.delivery_id, batchNumber: batch.batch_number } }); + return { batch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [batchId])), batches: listDeliveryBatches(context, batch.delivery_id).batches }; +} + +export function rollbackDeliveryBatch(context, batchId) { + requirePermission(context, "delivery:approve"); + const project = requireProject(context); + const batch = dbGet("SELECT * FROM delivery_batches WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [batchId, context.organization.id, context.workspace.id, project.id]); + if (!batch) throw httpError(404, "delivery_batch_not_found", "交付批次不存在或不属于当前项目", { batchId }); + if (batch.status !== "active") throw httpError(409, "delivery_batch_not_active", "只有当前生效批次可以回滚", { status: batch.status }); + const previous = dbGet("SELECT * FROM delivery_batches WHERE delivery_id = ? AND batch_number < ? AND status IN ('superseded', 'prepared', 'active') ORDER BY batch_number DESC LIMIT 1", [batch.delivery_id, batch.batch_number]); + if (!previous) throw httpError(409, "delivery_batch_no_rollback_target", "当前批次没有可恢复的历史批次"); + const timestamp = now(); + withTransaction(() => { + dbRun("UPDATE delivery_batches SET status = 'rolled_back', rollback_of_batch_id = ?, updated_at = ? WHERE id = ?", [previous.id, timestamp, batch.id]); + dbRun("UPDATE delivery_batches SET status = 'active', updated_at = ? WHERE id = ?", [timestamp, previous.id]); + dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [previous.id, timestamp, batch.delivery_id]); + }); + addAudit({ context, action: "delivery.batch.rolled_back", targetType: "delivery_batch", targetId: batchId, metadata: { deliveryId: batch.delivery_id, fromBatch: batch.batch_number, toBatch: previous.batch_number, restoredBatchId: previous.id } }); + return { restoredBatch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [previous.id])), batches: listDeliveryBatches(context, batch.delivery_id).batches }; +} + +export function approveDelivery(context, deliveryId, body = {}) { + requirePermission(context, "delivery:approve"); + const project = requireProject(context); + const delivery = dbGet("SELECT * FROM deliveries WHERE id = ? AND project_id = ?", [deliveryId, project.id]); + if (!delivery) throw httpError(404, "delivery_not_found", "交付版本不存在或不属于当前项目", { deliveryId }); + const reviews = ensureReviews(context); + const reviewBlockers = reviews.filter((review) => review.status !== "approved").map((review) => ({ id: review.id, type: "review", lane: review.lane, status: review.status, shotId: review.shot_id })); + const jobBlockers = dbAll("SELECT id, kind, status, shot_id FROM generation_jobs WHERE project_id = ? AND status NOT IN ('completed', 'cancelled') ORDER BY created_at", [project.id]).map((job) => ({ id: job.id, type: "job", kind: job.kind, status: job.status, shotId: job.shot_id })); + const activeBatch = delivery.active_batch_id ? dbGet("SELECT * FROM delivery_batches WHERE id = ? AND delivery_id = ?", [delivery.active_batch_id, delivery.id]) : null; + const batchBlockers = !activeBatch + ? [{ id: delivery.id, type: "delivery_batch", status: "missing", reason: "交付版本尚未激活一个交付批次" }] + : dbAll("SELECT id, shot_id, source_path, actual_last_frame_path, source_sha256, metadata_json FROM delivery_batch_items WHERE batch_id = ? ORDER BY sequence_number", [activeBatch.id]) + .flatMap((item) => { + const metadata = parseJson(item.metadata_json, {}); + const missing = []; + if (!item.source_path || !item.source_sha256 || metadata.artifactStatus !== "inspected") missing.push("视频文件证据"); + if (!item.actual_last_frame_path) missing.push("实际末帧"); + return missing.length ? [{ id: item.id, type: "delivery_batch_item", shotId: item.shot_id, status: "blocked", reason: missing.join("、") }] : []; + }); + const blockers = [...reviewBlockers, ...jobBlockers, ...batchBlockers]; + if (blockers.length && !body.force) throw httpError(409, "delivery_blocked", "仍有质检项未通过,不能批准交付", { blockers }); + const timestamp = now(); + dbRun("UPDATE deliveries SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, timestamp, deliveryId]); + addAudit({ context, action: "delivery.approved", targetType: "delivery", targetId: deliveryId, metadata: { forced: Boolean(body.force), blockers: blockers.length } }); + void dispatchNotificationEvent({ context, eventKey: "delivery.approved", payload: { deliveryId, version: delivery.version, targetId: deliveryId, forced: Boolean(body.force) } }); + return { delivery: dbGet("SELECT * FROM deliveries WHERE id = ?", [deliveryId]), deliveries: deliveries(context), blockers }; +} + +function scopedJob(context, jobId) { + const project = requireProject(context); + const job = dbGet("SELECT * FROM generation_jobs WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [jobId, context.organization.id, context.workspace.id, project.id]); + if (!job) throw httpError(404, "job_not_found", "任务不存在或不属于当前项目", { jobId }); + return job; +} + +export function updateJob(context, jobId, action, body = {}) { + const job = scopedJob(context, jobId); + if (action === "priority") { + requireAnyPermission(context, ["job:prioritize", "queue:manage"]); + requireProjectWritable(context); + const priority = Number(body.priority); + if (!Number.isFinite(priority) || priority < 1 || priority > 100) throw httpError(400, "priority_invalid", "优先级必须在 1 到 100 之间"); + dbRun("UPDATE generation_jobs SET priority = ?, updated_at = ? WHERE id = ?", [priority, now(), jobId]); + addAudit({ context, action: "generation_job.priority.updated", targetType: "generation_job", targetId: jobId, metadata: { previous: job.priority, priority } }); + } else if (action === "retry") { + requireAnyPermission(context, ["job:prioritize", "queue:manage"]); + requireProjectWritable(context); + if (!["failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_retryable", "只有失败或已取消任务可以重试", { status: job.status }); + const timestamp = now(); + const attempt = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0) + 1; + dbRun("UPDATE generation_jobs SET status = 'queued', qa_status = 'wait', updated_at = ? WHERE id = ?", [timestamp, jobId]); + dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, created_at) VALUES (?, ?, ?, 'local-runner', 'queued', ?)", [makeId("attempt"), jobId, attempt, timestamp]); + addAudit({ context, action: "generation_job.retried", targetType: "generation_job", targetId: jobId, metadata: { attempt } }); + } else if (action === "cancel") { + requireAnyPermission(context, ["job:prioritize", "queue:manage"]); + requireProjectWritable(context); + if (!["queued", "running"].includes(job.status)) throw httpError(409, "job_not_cancellable", "当前任务状态不能取消", { status: job.status }); + dbRun("UPDATE generation_jobs SET status = 'cancelled', updated_at = ? WHERE id = ?", [now(), jobId]); + addAudit({ context, action: "generation_job.cancelled", targetType: "generation_job", targetId: jobId }); + } else { + throw httpError(400, "job_action_invalid", "不支持的任务动作", { action }); + } + return { job: dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId]) }; +} + +export function exportAudit(context, limit = 500) { + requirePermission(context, "audit:view"); + return dbAll( + `SELECT a.*, u.display_name AS actor_name + FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id + WHERE a.organization_id = ? AND (a.workspace_id IS NULL OR a.workspace_id = ?) AND (a.project_id IS NULL OR a.project_id = ?) + ORDER BY a.created_at DESC LIMIT ?`, + [context.organization.id, context.workspace.id, context.project?.id || "", limit] + ).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) })); +} diff --git a/server/rate-limit.mjs b/server/rate-limit.mjs new file mode 100644 index 0000000..568d668 --- /dev/null +++ b/server/rate-limit.mjs @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; + +const WINDOW_MS = 60 * 1000; +const buckets = new Map(); + +function prune(now) { + if (buckets.size < 2048) return; + for (const [key, bucket] of buckets) { + if (bucket.resetAt <= now) buckets.delete(key); + } +} + +export function rateLimitIdentity({ token = "", ipAddress = "" } = {}) { + const normalizedToken = String(token || "").trim(); + if (normalizedToken) return `token:${createHash("sha256").update(normalizedToken).digest("hex")}`; + return `ip:${String(ipAddress || "unknown").trim() || "unknown"}`; +} + +export function consumeRateLimit({ key, limit, now = Date.now() } = {}) { + const normalizedLimit = Math.max(0, Math.floor(Number(limit || 0))); + if (!normalizedLimit) return null; + const windowStart = Math.floor(now / WINDOW_MS) * WINDOW_MS; + const bucketKey = `${String(key || "unknown")}:${windowStart}`; + const resetAt = windowStart + WINDOW_MS; + const current = buckets.get(bucketKey) || { count: 0, resetAt }; + current.count += 1; + buckets.set(bucketKey, current); + prune(now); + return { + allowed: current.count <= normalizedLimit, + count: current.count, + limit: normalizedLimit, + remaining: Math.max(0, normalizedLimit - current.count), + resetAt, + retryAfter: Math.max(1, Math.ceil((resetAt - now) / 1000)) + }; +} + +export function rateLimitHeaders(result) { + if (!result) return {}; + return { + "x-ratelimit-limit": String(result.limit), + "x-ratelimit-remaining": String(result.remaining), + "x-ratelimit-reset": String(Math.ceil(result.resetAt / 1000)), + ...(result.allowed ? {} : { "retry-after": String(result.retryAfter) }) + }; +} + diff --git a/server/readiness.mjs b/server/readiness.mjs new file mode 100644 index 0000000..817a30d --- /dev/null +++ b/server/readiness.mjs @@ -0,0 +1,98 @@ +import { stat } from "node:fs/promises"; +import { dbPath } from "./db.mjs"; +import { backupSummary } from "./backup.mjs"; + +function envValue(name) { + return String(process.env[name] || "").trim(); +} + +function configuredCheck(key, label, envName, detail) { + const value = envValue(envName); + return { + key, + label, + status: value ? "configured" : "not-configured", + severity: value ? "info" : "warning", + blocking: false, + detail: value ? `${detail}:已读取 ${envName}` : `${detail}:未设置 ${envName}`, + envName + }; +} + +async function databaseCheck() { + try { + const file = await stat(dbPath); + return { + key: "business-database", + label: "业务数据库", + status: "active-local", + severity: "warning", + blocking: false, + provider: "Node 24 node:sqlite", + detail: "当前业务真源是本地 SQLite;尚未切换 PostgreSQL 高可用运行时。", + path: dbPath, + bytes: Number(file.size || 0), + modifiedAt: file.mtime?.toISOString?.() || null + }; + } catch (error) { + return { + key: "business-database", + label: "业务数据库", + status: "failed", + severity: "critical", + blocking: true, + provider: "Node 24 node:sqlite", + detail: `数据库文件不可读:${error.message}`, + path: dbPath + }; + } +} + +export async function systemReadiness() { + const backups = await backupSummary(); + const allowDevContext = process.env.AI_DRAMA_ALLOW_DEV_CONTEXT === "1"; + const sessionSecret = envValue("AI_DRAMA_SESSION_SECRET"); + const mfaKey = envValue("AI_DRAMA_MFA_ENCRYPTION_KEY"); + const oidcKey = envValue("AI_DRAMA_OIDC_STORAGE_KEY"); + const checks = [ + await databaseCheck(), + configuredCheck("postgres-target", "PostgreSQL 目标", "PLATFORM_POSTGRES_URL", "目标数据库连接"), + configuredCheck("redis-target", "Redis 目标", "PLATFORM_REDIS_URL", "目标队列/分布式锁连接"), + configuredCheck("object-storage-target", "对象存储目标", "PLATFORM_OBJECT_STORAGE_ENDPOINT", "S3-compatible 存储端点"), + { + key: "security-secrets", + label: "生产密钥", + status: sessionSecret.length >= 32 && mfaKey.length >= 16 && oidcKey.length >= 16 ? "ready" : "needs-config", + severity: sessionSecret.length >= 32 && mfaKey.length >= 16 && oidcKey.length >= 16 ? "info" : "critical", + blocking: sessionSecret.length < 32 || mfaKey.length < 16 || oidcKey.length < 16, + detail: "Session、MFA 和 OIDC 存储密钥必须通过环境变量注入,不写入数据库。", + configured: { sessionSecret: sessionSecret.length >= 32, mfaKey: mfaKey.length >= 16, oidcKey: oidcKey.length >= 16 } + }, + { + key: "dev-context", + label: "开发上下文旁路", + status: allowDevContext ? "unsafe" : "ready", + severity: allowDevContext ? "critical" : "info", + blocking: allowDevContext, + detail: allowDevContext ? "AI_DRAMA_ALLOW_DEV_CONTEXT=1,生产部署禁止启用。" : "请求头上下文旁路已关闭,使用真实 session/API client。" + }, + { + key: "database-backup", + label: "数据库快照", + status: backups.count ? "ready" : "missing", + severity: backups.count ? "info" : "warning", + blocking: false, + detail: backups.count ? `已有 ${backups.count} 个本地快照,最近一次 ${backups.latest?.modifiedAt || "未知"}。` : "还没有本地 SQLite 快照;上线前应先创建并验证备份。" + } + ]; + const blocking = checks.filter((check) => check.blocking && !["ready", "configured"].includes(check.status)).length; + const attention = checks.filter((check) => check.severity === "warning" || check.severity === "critical").length; + return { + profile: process.env.NODE_ENV === "production" ? "production" : "local-development", + activeRuntime: { database: "node:sqlite", queue: "database-lease-worker", objectStorage: "local-filesystem" }, + summary: { status: blocking ? "blocked" : attention ? "attention" : "ready", blocking, attention, ready: checks.length - attention }, + checks, + backups, + checkedAt: new Date().toISOString() + }; +} diff --git a/server/saml.mjs b/server/saml.mjs new file mode 100644 index 0000000..91b8c22 --- /dev/null +++ b/server/saml.mjs @@ -0,0 +1,205 @@ +import { createHash, randomBytes } from "node:crypto"; +import { SAML } from "@node-saml/node-saml"; +import { dbGet, dbRun } from "./db.mjs"; +import { resolveOidcUser } from "./oidc.mjs"; + +const LOGIN_STATE_TTL_MS = 10 * 60 * 1000; +const CLOCK_SKEW_MS = 5000; +const DEFAULT_NAME_ID_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; + +function samlError(status, code, message, details = {}) { + const error = new Error(message); + error.status = status; + error.code = code; + error.details = details; + return error; +} + +function nowIso() { + return new Date().toISOString(); +} + +function hashValue(value) { + return createHash("sha256").update(String(value || "")).digest("hex"); +} + +function base64url(buffer) { + return Buffer.from(buffer).toString("base64url"); +} + +function parseJson(value, fallback) { + try { return JSON.parse(value); } catch { return fallback; } +} + +function ensureSsoEnabled() { + const policy = dbGet("SELECT sso_enabled FROM identity_policies WHERE id = 'default'"); + if (!policy?.sso_enabled) throw samlError(403, "sso_disabled", "平台当前未启用企业 SSO"); +} + +function providerRow(providerId, { requireSso = true, allowDisabled = false } = {}) { + if (requireSso) ensureSsoEnabled(); + const provider = dbGet(`SELECT * FROM identity_providers WHERE id = ?${allowDisabled ? "" : " AND enabled = 1"}`, [providerId]); + if (!provider) throw samlError(404, "sso_provider_not_found", "企业身份提供商不存在或未启用"); + if (provider.kind !== "saml") throw samlError(400, "sso_provider_kind_unsupported", "当前登录链路不是 SAML 提供商"); + if (requireSso && (!provider.entry_point || !provider.idp_cert_ref || !provider.sp_issuer)) { + throw samlError(400, "saml_provider_incomplete", "SAML 提供商缺少 IdP Entry Point、证书环境变量名或 SP Issuer"); + } + if (!["never", "ifPresent", "always"].includes(provider.validate_in_response_to || "ifPresent")) { + throw samlError(400, "saml_validate_in_response_to_invalid", "SAML InResponseTo 校验策略无效"); + } + if (!allowDisabled && !["configured", "ready"].includes(provider.status)) { + throw samlError(400, "saml_provider_not_ready", "SAML 提供商尚未完成探测或已停用"); + } + if (allowDisabled && !["disabled", "configured", "ready"].includes(provider.status)) { + throw samlError(400, "saml_provider_not_ready", "SAML 提供商尚未完成探测或已停用"); + } + return provider; +} + +function envCertificate(ref) { + const key = String(ref || "").trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw samlError(400, "saml_certificate_ref_invalid", "SAML IdP 证书环境变量名无效"); + } + const value = String(process.env[key] || "").trim().replace(/\\n/g, "\n"); + if (!value) throw samlError(503, "saml_certificate_missing", `服务端环境变量 ${key} 未配置 IdP 证书`); + return value; +} + +function normalizeValidateInResponseTo(value) { + return ["never", "ifPresent", "always"].includes(value) ? value : "ifPresent"; +} + +function providerOptions(provider, { callbackUrl, cacheProvider, requireIdpCert = true }) { + const issuer = String(provider.sp_issuer || process.env.AI_DRAMA_SAML_SP_ISSUER || `${process.env.AI_DRAMA_API_ORIGIN || "http://127.0.0.1:8787"}/api/auth/sso/saml/metadata`).trim(); + return { + entryPoint: provider.entry_point, + idpCert: requireIdpCert ? envCertificate(provider.idp_cert_ref) : "AA==", + issuer, + callbackUrl, + audience: provider.audience || issuer, + idpIssuer: provider.issuer_url || undefined, + identifierFormat: provider.saml_name_id_format || DEFAULT_NAME_ID_FORMAT, + wantAssertionsSigned: provider.want_assertions_signed !== 0, + wantAuthnResponseSigned: provider.want_authn_response_signed !== 0, + validateInResponseTo: normalizeValidateInResponseTo(provider.validate_in_response_to), + acceptedClockSkewMs: CLOCK_SKEW_MS, + requestIdExpirationPeriodMs: LOGIN_STATE_TTL_MS, + cacheProvider, + disableRequestedAuthnContext: true, + signatureAlgorithm: "sha256", + providerName: "AI 短剧生产平台" + }; +} + +class DatabaseSamlCacheProvider { + constructor(relayStateHash) { + this.relayStateHash = relayStateHash; + } + + async saveAsync(key, value) { + const result = dbRun( + "UPDATE saml_login_states SET request_id = ?, request_issue_instant = ? WHERE relay_state_hash = ? AND consumed_at IS NULL AND expires_at > ?", + [String(key), String(value), this.relayStateHash, nowIso()] + ); + if (!Number(result.changes || 0)) throw samlError(401, "saml_state_invalid", "SAML 登录状态不存在或已过期"); + return { value: String(value), createdAt: Date.now() }; + } + + async getAsync(key) { + const row = dbGet( + "SELECT request_issue_instant FROM saml_login_states WHERE relay_state_hash = ? AND request_id = ? AND consumed_at IS NULL AND expires_at > ?", + [this.relayStateHash, String(key), nowIso()] + ); + return row?.request_issue_instant || null; + } + + // node-saml calls removeAsync during assertion processing. Final consumption is + // handled atomically after the whole signed response has passed validation. + async removeAsync(key) { + const row = dbGet("SELECT request_issue_instant FROM saml_login_states WHERE relay_state_hash = ? AND request_id = ?", [this.relayStateHash, String(key)]); + return row?.request_issue_instant || null; + } +} + +function cleanupExpiredSamlStates() { + const timestamp = nowIso(); + dbRun("DELETE FROM saml_login_states WHERE expires_at <= ? OR consumed_at IS NOT NULL", [timestamp]); +} + +function profileToClaims(profile, provider) { + const claims = { ...(profile?.attributes || {}), ...(profile || {}) }; + const first = (...values) => values.flatMap((value) => Array.isArray(value) ? value : [value]).find((value) => typeof value === "string" && value.trim()) || ""; + const nameId = first(profile?.nameID, profile?.nameId, profile?.uid, profile?.subject); + const email = first(profile?.email, profile?.mail, profile?.userPrincipalName, profile?.preferred_username, claims.email, claims.mail, claims["urn:oid:0.9.2342.19200300.100.1.3"]); + const displayName = first(profile?.displayName, profile?.name, profile?.cn, profile?.givenName, claims.displayName, claims.name, email, nameId); + claims.sub = first(claims.sub, nameId); + claims.email = first(claims.email, email); + claims.name = first(claims.name, displayName); + claims.iss = first(claims.iss, profile?.issuer, provider.issuer_url); + return claims; +} + +export function isSamlProvider(providerId) { + return Boolean(dbGet("SELECT id FROM identity_providers WHERE id = ? AND kind = 'saml'", [providerId])); +} + +export async function startSamlLogin(providerId, { callbackUrl, apiOrigin, returnTo = "/", selection = {}, ipAddress = "", userAgent = "", host = "" } = {}) { + cleanupExpiredSamlStates(); + const provider = providerRow(providerId); + const relayState = base64url(randomBytes(32)); + const timestamp = nowIso(); + const expiresAt = new Date(Date.now() + LOGIN_STATE_TTL_MS).toISOString(); + const id = `saml-state-${Date.now()}-${randomBytes(4).toString("hex")}`; + dbRun( + "INSERT INTO saml_login_states(id, relay_state_hash, provider_id, return_to, selection_json, ip_address, user_agent, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [id, hashValue(relayState), provider.id, String(returnTo || "/"), JSON.stringify(selection || {}), String(ipAddress || ""), String(userAgent || ""), expiresAt, timestamp] + ); + try { + const cacheProvider = new DatabaseSamlCacheProvider(hashValue(relayState)); + const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider })); + const authorizationUrl = await saml.getAuthorizeUrlAsync(relayState, host || apiOrigin, {}); + return { authorizationUrl, relayState, expiresAt, provider }; + } catch (error) { + dbRun("DELETE FROM saml_login_states WHERE relay_state_hash = ?", [hashValue(relayState)]); + if (error.status) throw error; + throw samlError(502, "saml_request_failed", `SAML 登录请求生成失败:${error.message}`); + } +} + +export async function handleSamlCallback({ samlResponse, relayState, callbackUrl } = {}) { + cleanupExpiredSamlStates(); + if (!samlResponse || !relayState) throw samlError(401, "saml_response_state_required", "SAML ACS 必须包含断言和 RelayState"); + const stateRow = dbGet( + "SELECT * FROM saml_login_states WHERE relay_state_hash = ? AND consumed_at IS NULL AND expires_at > ?", + [hashValue(relayState), nowIso()] + ); + if (!stateRow) throw samlError(401, "saml_state_invalid", "SAML 登录状态无效、已使用或已过期"); + const provider = providerRow(stateRow.provider_id); + const cacheProvider = new DatabaseSamlCacheProvider(hashValue(relayState)); + const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider })); + let result; + try { + result = await saml.validatePostResponseAsync({ SAMLResponse: String(samlResponse) }); + } catch (error) { + throw samlError(401, "saml_response_invalid", `SAML 断言校验失败:${error.message}`); + } + if (result.loggedOut || !result.profile) throw samlError(401, "saml_profile_missing", "SAML 响应没有可用的登录身份"); + const consumed = dbRun("UPDATE saml_login_states SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL AND expires_at > ?", [nowIso(), stateRow.id, nowIso()]); + if (!Number(consumed.changes || 0)) throw samlError(401, "saml_state_replayed", "SAML 登录状态已被使用"); + const claims = profileToClaims(result.profile, provider); + return { + ...resolveOidcUser(provider, claims), + selection: parseJson(stateRow.selection_json, {}), + returnTo: stateRow.return_to, + provider, + profile: result.profile + }; +} + +export function samlServiceProviderMetadata(providerId, { callbackUrl } = {}) { + const provider = providerRow(providerId, { requireSso: false, allowDisabled: true }); + const cacheProvider = new DatabaseSamlCacheProvider("metadata"); + const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider, requireIdpCert: false })); + return saml.generateServiceProviderMetadata(null, null); +} diff --git a/server/schema.sql b/server/schema.sql new file mode 100644 index 0000000..a623063 --- /dev/null +++ b/server/schema.sql @@ -0,0 +1,1197 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + avatar_color TEXT NOT NULL DEFAULT '#20252b', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'suspended')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS system_admins ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + role_key TEXT NOT NULL DEFAULT 'system_admin', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_credentials ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_salt TEXT NOT NULL, + password_hash TEXT NOT NULL, + last_login_at TEXT, + failed_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_key_hash TEXT NOT NULL, + fingerprint_hash TEXT NOT NULL DEFAULT '', + label TEXT NOT NULL DEFAULT '浏览器设备', + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + last_ip_address TEXT NOT NULL DEFAULT '', + last_user_agent TEXT NOT NULL DEFAULT '', + trusted_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (user_id, device_key_hash) +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + revoked_at TEXT, + device_id TEXT REFERENCES auth_devices(id) ON DELETE SET NULL, + risk_level TEXT NOT NULL DEFAULT 'medium', + risk_score INTEGER NOT NULL DEFAULT 50 +); + +CREATE INDEX IF NOT EXISTS idx_auth_devices_user_last_seen ON auth_devices(user_id, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_auth_devices_key_hash ON auth_devices(user_id, device_key_hash); + +CREATE TABLE IF NOT EXISTS user_mfa_methods ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + method_type TEXT NOT NULL DEFAULT 'totp' CHECK (method_type IN ('totp')), + label TEXT NOT NULL DEFAULT '身份验证器', + secret_ciphertext TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + setup_expires_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_mfa_challenges ( + id TEXT PRIMARY KEY, + challenge_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE TABLE IF NOT EXISTS auth_mfa_enrollment_challenges ( + id TEXT PRIMARY KEY, + challenge_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); + +-- Authentication security events are separate from organization audit logs. +-- They can contain a nullable user_id for failed logins against unknown emails. +CREATE TABLE IF NOT EXISTS auth_security_events ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + result TEXT NOT NULL DEFAULT 'success', + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_auth_security_events_user_created ON auth_security_events(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_auth_security_events_type_created ON auth_security_events(event_type, created_at DESC); + +-- Enterprise identity governance. Secrets are never stored here; providers and +-- directory connectors reference environment variables or one-way token hashes. +CREATE TABLE IF NOT EXISTS identity_policies ( + id TEXT PRIMARY KEY, + password_login_enabled INTEGER NOT NULL DEFAULT 1, + mfa_required_for_admins INTEGER NOT NULL DEFAULT 0, + mfa_required_for_all INTEGER NOT NULL DEFAULT 0, + sso_enabled INTEGER NOT NULL DEFAULT 0, + local_login_fallback INTEGER NOT NULL DEFAULT 1, + session_ttl_hours INTEGER NOT NULL DEFAULT 12, + max_sessions_per_user INTEGER NOT NULL DEFAULT 10, + updated_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS identity_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'oidc' CHECK (kind IN ('oidc', 'saml')), + organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL, + issuer_url TEXT NOT NULL DEFAULT '', + authorization_url TEXT NOT NULL DEFAULT '', + token_url TEXT NOT NULL DEFAULT '', + userinfo_url TEXT NOT NULL DEFAULT '', + jwks_url TEXT NOT NULL DEFAULT '', + entry_point TEXT NOT NULL DEFAULT '', + idp_cert_ref TEXT NOT NULL DEFAULT '', + sp_issuer TEXT NOT NULL DEFAULT '', + audience TEXT NOT NULL DEFAULT '', + saml_name_id_format TEXT NOT NULL DEFAULT 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + want_assertions_signed INTEGER NOT NULL DEFAULT 1, + want_authn_response_signed INTEGER NOT NULL DEFAULT 1, + validate_in_response_to TEXT NOT NULL DEFAULT 'ifPresent', + client_id TEXT NOT NULL DEFAULT '', + client_secret_ref TEXT NOT NULL DEFAULT '', + scopes_json TEXT NOT NULL DEFAULT '["openid", "profile", "email"]', + claim_mapping_json TEXT NOT NULL DEFAULT '{"email":"email","displayName":"name","externalId":"sub"}', + auto_provision INTEGER NOT NULL DEFAULT 1, + default_role_key TEXT NOT NULL DEFAULT 'org_member', + default_workspace_role_key TEXT NOT NULL DEFAULT 'writer', + enabled INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'not-configured', + last_probe_at TEXT, + error_message TEXT NOT NULL DEFAULT '', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS oidc_login_states ( + id TEXT PRIMARY KEY, + state_hash TEXT NOT NULL UNIQUE, + provider_id TEXT NOT NULL REFERENCES identity_providers(id) ON DELETE CASCADE, + nonce_hash TEXT NOT NULL, + code_verifier_ciphertext TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + return_to TEXT NOT NULL DEFAULT '/', + selection_json TEXT NOT NULL DEFAULT '{}', + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE TABLE IF NOT EXISTS saml_login_states ( + id TEXT PRIMARY KEY, + relay_state_hash TEXT NOT NULL UNIQUE, + request_id TEXT UNIQUE, + request_issue_instant TEXT, + provider_id TEXT NOT NULL REFERENCES identity_providers(id) ON DELETE CASCADE, + return_to TEXT NOT NULL DEFAULT '/', + selection_json TEXT NOT NULL DEFAULT '{}', + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE TABLE IF NOT EXISTS external_identities ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL REFERENCES identity_providers(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + issuer TEXT NOT NULL DEFAULT '', + email_at_login TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (provider_id, subject) +); + +CREATE TABLE IF NOT EXISTS auth_sso_tickets ( + id TEXT PRIMARY KEY, + ticket_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + selection_json TEXT NOT NULL DEFAULT '{}', + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE TABLE IF NOT EXISTS directory_syncs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'scim', + organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + provider_id TEXT REFERENCES identity_providers(id) ON DELETE SET NULL, + endpoint TEXT NOT NULL DEFAULT '', + token_hint TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0, + sync_mode TEXT NOT NULL DEFAULT 'provision-and-deprovision', + schedule TEXT NOT NULL DEFAULT 'manual', + last_sync_at TEXT, + last_status TEXT NOT NULL DEFAULT 'never-run', + last_synced_count INTEGER NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS directory_sync_tokens ( + id TEXT PRIMARY KEY, + directory_sync_id TEXT NOT NULL REFERENCES directory_syncs(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + token_hint TEXT NOT NULL, + created_by TEXT REFERENCES users(id), + last_used_at TEXT, + created_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + owner_user_id TEXT NOT NULL REFERENCES users(id), + deployment_mode TEXT NOT NULL DEFAULT 'private-local', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS organization_members ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'suspended')), + invited_at TEXT, + joined_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (organization_id, user_id) +); + +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (organization_id, slug) +); + +CREATE TABLE IF NOT EXISTS workspace_members ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'suspended')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (workspace_id, user_id) +); + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'AI 漫剧', + template_id TEXT NOT NULL DEFAULT 'ai-manhua-drama', + status TEXT NOT NULL DEFAULT 'production', + owner_user_id TEXT NOT NULL REFERENCES users(id), + visibility TEXT NOT NULL DEFAULT 'workspace', + readiness INTEGER NOT NULL DEFAULT 0 CHECK (readiness BETWEEN 0 AND 100), + risk TEXT NOT NULL DEFAULT 'low', + archived_at TEXT, + archived_by TEXT REFERENCES users(id) ON DELETE SET NULL, + archived_from_status TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS project_members ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (project_id, user_id) +); + +-- Persistent collaboration tasks are separate from derived work-items. A +-- task has an owner, lifecycle, due date and audit trail inside one project. +CREATE TABLE IF NOT EXISTS project_tasks ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'production', + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'blocked', 'done', 'cancelled')), + priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('high', 'medium', 'low')), + assignee_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + target_tab TEXT NOT NULL DEFAULT 'creator-home', + target_id TEXT NOT NULL DEFAULT '', + due_at TEXT, + completed_at TEXT, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Task collaboration records stay inside the same tenant and project scope. +-- Links point to existing production objects; file links are local path +-- references only and never imply an external/cloud upload. +CREATE TABLE IF NOT EXISTS task_comments ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES project_tasks(id) ON DELETE CASCADE, + parent_comment_id TEXT REFERENCES task_comments(id) ON DELETE CASCADE, + author_user_id TEXT NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + mentions_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS task_links ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES project_tasks(id) ON DELETE CASCADE, + link_type TEXT NOT NULL, + target_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + UNIQUE (task_id, link_type, target_id) +); + +CREATE TABLE IF NOT EXISTS invitations ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role_key TEXT NOT NULL, + invited_by TEXT NOT NULL REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'expired', 'revoked')), + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + token_hash TEXT, + token_hint TEXT, + accepted_user_id TEXT REFERENCES users(id), + accepted_at TEXT, + revoked_at TEXT +); + +CREATE TABLE IF NOT EXISTS roles ( + key TEXT PRIMARY KEY, + scope TEXT NOT NULL CHECK (scope IN ('organization', 'workspace', 'project')), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS permissions ( + key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_key TEXT NOT NULL REFERENCES roles(key) ON DELETE CASCADE, + permission_key TEXT NOT NULL REFERENCES permissions(key) ON DELETE CASCADE, + PRIMARY KEY (role_key, permission_key) +); + +-- Organization-scoped policy overrides keep the built-in role catalog stable +-- while allowing each tenant to grant or revoke non-system permissions. +CREATE TABLE IF NOT EXISTS organization_role_permissions ( + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + role_key TEXT NOT NULL REFERENCES roles(key) ON DELETE CASCADE, + permission_key TEXT NOT NULL REFERENCES permissions(key) ON DELETE CASCADE, + effect TEXT NOT NULL CHECK (effect IN ('grant', 'revoke')), + updated_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (organization_id, role_key, permission_key) +); + +CREATE TABLE IF NOT EXISTS billing_accounts ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, + plan_name TEXT NOT NULL, + billing_cycle TEXT NOT NULL DEFAULT 'monthly' CHECK (billing_cycle IN ('monthly', 'quarterly', 'annual')), + currency TEXT NOT NULL DEFAULT 'CNY', + base_fee REAL NOT NULL DEFAULT 0, + seat_unit_price REAL NOT NULL DEFAULT 0, + storage_unit_price REAL NOT NULL DEFAULT 0, + clip_unit_price REAL NOT NULL DEFAULT 0, + seat_limit INTEGER NOT NULL DEFAULT 5, + storage_gb INTEGER NOT NULL DEFAULT 100, + monthly_clip_quota INTEGER NOT NULL DEFAULT 100, + quota_warning_percent INTEGER NOT NULL DEFAULT 80, + local_runner_only INTEGER NOT NULL DEFAULT 1, + cloud_connectors_require_approval INTEGER NOT NULL DEFAULT 1, + current_period_start TEXT, + current_period_end TEXT, + next_invoice_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS billing_account_events ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + billing_account_id TEXT NOT NULL REFERENCES billing_accounts(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + previous_json TEXT NOT NULL DEFAULT '{}', + next_json TEXT NOT NULL DEFAULT '{}', + actor_user_id TEXT REFERENCES users(id), + created_at TEXT NOT NULL +); + +-- Local invoice ledger. This is intentionally payment-provider agnostic: the +-- platform records billing snapshots and lifecycle state, while an external +-- accounting or payment system can be connected later through an adapter. +CREATE TABLE IF NOT EXISTS organization_invoices ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + billing_account_id TEXT NOT NULL REFERENCES billing_accounts(id) ON DELETE CASCADE, + invoice_number TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'issued', 'paid', 'overdue', 'void')), + currency TEXT NOT NULL DEFAULT 'CNY', + billing_cycle TEXT NOT NULL DEFAULT 'monthly' CHECK (billing_cycle IN ('monthly', 'quarterly', 'annual')), + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + issued_at TEXT, + due_at TEXT, + paid_at TEXT, + voided_at TEXT, + subtotal REAL NOT NULL DEFAULT 0, + tax_rate REAL NOT NULL DEFAULT 0, + tax_amount REAL NOT NULL DEFAULT 0, + total_amount REAL NOT NULL DEFAULT 0, + snapshot_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (organization_id, period_start, period_end) +); + +CREATE TABLE IF NOT EXISTS invoice_lines ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL REFERENCES organization_invoices(id) ON DELETE CASCADE, + line_type TEXT NOT NULL, + description TEXT NOT NULL, + quantity REAL NOT NULL DEFAULT 0, + unit_name TEXT NOT NULL DEFAULT '项', + unit_price REAL NOT NULL DEFAULT 0, + amount REAL NOT NULL DEFAULT 0, + metadata_json TEXT NOT NULL DEFAULT '{}', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_period ON organization_invoices(organization_id, period_end DESC, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_status ON organization_invoices(organization_id, status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice_sort ON invoice_lines(invoice_id, sort_order, created_at); + +CREATE TABLE IF NOT EXISTS cost_centers ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + code TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + monthly_budget REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CNY', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (organization_id, code) +); + +CREATE TABLE IF NOT EXISTS quota_allocations ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + metric TEXT NOT NULL, + limit_value REAL NOT NULL, + used_value REAL NOT NULL DEFAULT 0, + unit TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (organization_id, workspace_id, metric, period_start) +); + +CREATE TABLE IF NOT EXISTS usage_events ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id), + kind TEXT NOT NULL, + units REAL NOT NULL DEFAULT 1, + unit_name TEXT NOT NULL DEFAULT 'event', + estimated_cost REAL NOT NULL DEFAULT 0, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS model_connectors ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + label TEXT NOT NULL, + kind TEXT NOT NULL, + capabilities_json TEXT NOT NULL DEFAULT '[]', + endpoint TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'not-connected', + cost_mode TEXT NOT NULL DEFAULT 'local', + approval_required INTEGER NOT NULL DEFAULT 0, + protocol_json TEXT NOT NULL DEFAULT '{}', + auth_env TEXT NOT NULL DEFAULT '', + last_probe_at TEXT, + latency_ms INTEGER, + error_message TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS series ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, + title TEXT NOT NULL, + logline TEXT NOT NULL DEFAULT '', + format TEXT NOT NULL DEFAULT 'vertical-9:16', + visual_style TEXT NOT NULL DEFAULT '', + continuity_rule TEXT NOT NULL DEFAULT '', + show_engine TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS seasons ( + id TEXT PRIMARY KEY, + series_id TEXT NOT NULL REFERENCES series(id) ON DELETE CASCADE, + season_number INTEGER NOT NULL, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (series_id, season_number) +); + +CREATE TABLE IF NOT EXISTS episodes ( + id TEXT PRIMARY KEY, + season_id TEXT NOT NULL REFERENCES seasons(id) ON DELETE CASCADE, + episode_number INTEGER NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + target_duration_sec REAL NOT NULL DEFAULT 0, + hook TEXT NOT NULL DEFAULT '', + cliffhanger TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (season_id, episode_number) +); + +CREATE TABLE IF NOT EXISTS script_documents ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL, + version_number INTEGER NOT NULL, + title TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT '原创短剧剧本', + content TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', + analysis_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (project_id, version_number) +); + +CREATE TABLE IF NOT EXISTS assets ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + name TEXT NOT NULL, + lock_status TEXT NOT NULL DEFAULT 'draft', + current_version_id TEXT, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS asset_versions ( + id TEXT PRIMARY KEY, + asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + storage_path TEXT NOT NULL, + file_name TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + file_size INTEGER NOT NULL DEFAULT 0, + content_sha256 TEXT NOT NULL DEFAULT '', + rights_status TEXT NOT NULL DEFAULT 'needs-evidence', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + UNIQUE (asset_id, version_number) +); + +CREATE TABLE IF NOT EXISTS shots ( + id TEXT PRIMARY KEY, + episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, + shot_number INTEGER NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + first_frame_path TEXT, + last_frame_path TEXT, + current_version_id TEXT, + continuity_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (episode_id, shot_number) +); + +CREATE TABLE IF NOT EXISTS shot_versions ( + id TEXT PRIMARY KEY, + shot_id TEXT NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'draft', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + UNIQUE (shot_id, version_number) +); + +CREATE TABLE IF NOT EXISTS voice_lines ( + id TEXT PRIMARY KEY, + shot_id TEXT NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + line_number INTEGER NOT NULL, + character_key TEXT NOT NULL, + text TEXT NOT NULL, + emotion TEXT NOT NULL DEFAULT '', + mouth_plan TEXT NOT NULL DEFAULT '侧脸/反应镜头', + target_duration_sec REAL NOT NULL DEFAULT 2, + voice_id TEXT NOT NULL DEFAULT '', + audio_path TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (shot_id, line_number) +); + +CREATE TABLE IF NOT EXISTS asset_bindings ( + id TEXT PRIMARY KEY, + asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + shot_id TEXT NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + usage_role TEXT NOT NULL DEFAULT 'continuity', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + UNIQUE (asset_id, shot_id, usage_role) +); + +CREATE TABLE IF NOT EXISTS generation_jobs ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL, + shot_id TEXT REFERENCES shots(id) ON DELETE SET NULL, + kind TEXT NOT NULL, + adapter_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + priority INTEGER NOT NULL DEFAULT 50, + cost_policy TEXT NOT NULL DEFAULT 'local-only', + output_path TEXT NOT NULL DEFAULT '', + qa_status TEXT NOT NULL DEFAULT 'wait', + request_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + max_attempts INTEGER NOT NULL DEFAULT 3, + next_run_at TEXT, + leased_by TEXT, + leased_at TEXT, + started_at TEXT, + finished_at TEXT, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS job_attempts ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE, + attempt_number INTEGER NOT NULL, + runner_id TEXT NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL, + UNIQUE (job_id, attempt_number) +); + +CREATE TABLE IF NOT EXISTS job_dependencies ( + job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE, + depends_on_job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE, + dependency_type TEXT NOT NULL DEFAULT 'blocking', + created_at TEXT NOT NULL, + PRIMARY KEY (job_id, depends_on_job_id), + CHECK (job_id <> depends_on_job_id) +); + +CREATE TABLE IF NOT EXISTS reviews ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + shot_id TEXT REFERENCES shots(id) ON DELETE CASCADE, + lane TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + score REAL, + decision_by TEXT REFERENCES users(id), + decision_at TEXT, + evidence_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS review_comments ( + id TEXT PRIMARY KEY, + review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + author_user_id TEXT NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS compliance_records ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + subject_type TEXT NOT NULL, + subject_id TEXT NOT NULL, + policy_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'needs-evidence', + evidence_json TEXT NOT NULL DEFAULT '{}', + reviewed_by TEXT REFERENCES users(id), + reviewed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS deliveries ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + version TEXT NOT NULL, + manifest_path TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'internal', + status TEXT NOT NULL DEFAULT 'draft', + active_batch_id TEXT, + approved_by TEXT REFERENCES users(id), + approved_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- A delivery version can have several immutable preparation batches. The +-- active batch pointer makes rollback explicit without rewriting old manifests. +CREATE TABLE IF NOT EXISTS delivery_batches ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + delivery_id TEXT NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + batch_number INTEGER NOT NULL, + label TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'prepared', + manifest_path TEXT NOT NULL DEFAULT '', + composition_id TEXT REFERENCES media_compositions(id) ON DELETE SET NULL, + source_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + rollback_of_batch_id TEXT REFERENCES delivery_batches(id) ON DELETE SET NULL, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (delivery_id, batch_number) +); + +CREATE TABLE IF NOT EXISTS delivery_batch_items ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES delivery_batches(id) ON DELETE CASCADE, + shot_id TEXT REFERENCES shots(id) ON DELETE SET NULL, + sequence_number INTEGER NOT NULL, + source_path TEXT NOT NULL DEFAULT '', + actual_last_frame_path TEXT NOT NULL DEFAULT '', + source_sha256 TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + UNIQUE (batch_id, sequence_number) +); + +-- Delivery channels are tenant-scoped destinations. Cloud connectors are +-- deliberately excluded from this MVP; local-file and private-network +-- webhook are the only publish paths allowed by the server. +CREATE TABLE IF NOT EXISTS delivery_channels ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('local-file', 'local-webhook')), + enabled INTEGER NOT NULL DEFAULT 1, + endpoint TEXT NOT NULL DEFAULT '', + auth_env TEXT NOT NULL DEFAULT '', + require_approval INTEGER NOT NULL DEFAULT 1, + config_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- A release is an auditable publish request over an immutable delivery batch. +-- The state machine is enforced in server/production.mjs rather than by +-- allowing arbitrary status writes from the client. +CREATE TABLE IF NOT EXISTS delivery_releases ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + delivery_id TEXT NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL REFERENCES delivery_channels(id) ON DELETE RESTRICT, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'published', 'failed')), + idempotency_key TEXT NOT NULL DEFAULT '', + requested_by TEXT REFERENCES users(id), + requested_at TEXT, + reviewed_by TEXT REFERENCES users(id), + reviewed_at TEXT, + decision_note TEXT NOT NULL DEFAULT '', + published_by TEXT REFERENCES users(id), + published_at TEXT, + output_path TEXT NOT NULL DEFAULT '', + preflight_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- External delivery portals are scoped to one published release. The bearer +-- token is never stored in plaintext; only its SHA-256 digest is persisted. +CREATE TABLE IF NOT EXISTS delivery_access_links ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + release_id TEXT NOT NULL REFERENCES delivery_releases(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + token_hint TEXT NOT NULL DEFAULT '', + recipient_name TEXT NOT NULL DEFAULT '', + recipient_email TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')), + expires_at TEXT NOT NULL, + max_downloads INTEGER NOT NULL DEFAULT 10 CHECK (max_downloads > 0), + download_count INTEGER NOT NULL DEFAULT 0 CHECK (download_count >= 0), + last_viewed_at TEXT, + last_downloaded_at TEXT, + created_by TEXT NOT NULL REFERENCES users(id), + revoked_by TEXT REFERENCES users(id) ON DELETE SET NULL, + revoked_at TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Public portal access is a separate audit stream so external activity can be +-- reviewed without exposing internal tenant audit records to the recipient. +CREATE TABLE IF NOT EXISTS delivery_access_events ( + id TEXT PRIMARY KEY, + link_id TEXT REFERENCES delivery_access_links(id) ON DELETE SET NULL, + release_id TEXT REFERENCES delivery_releases(id) ON DELETE SET NULL, + organization_id TEXT REFERENCES organizations(id) ON DELETE SET NULL, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL, + project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('view', 'download')), + result TEXT NOT NULL CHECK (result IN ('success', 'denied', 'missing')), + file_kind TEXT NOT NULL DEFAULT '', + token_fingerprint TEXT NOT NULL DEFAULT '', + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +-- External recipients can accept a published release or request changes from +-- the same scoped bearer portal. Feedback is append-only so the internal team +-- can see the complete client decision history without exposing tenant audit. +CREATE TABLE IF NOT EXISTS delivery_access_feedback ( + id TEXT PRIMARY KEY, + link_id TEXT NOT NULL REFERENCES delivery_access_links(id) ON DELETE CASCADE, + release_id TEXT NOT NULL REFERENCES delivery_releases(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + decision TEXT NOT NULL CHECK (decision IN ('approved', 'changes_requested')), + message TEXT NOT NULL DEFAULT '', + reviewer_name TEXT NOT NULL DEFAULT '', + reviewer_email TEXT NOT NULL DEFAULT '', + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + actor_user_id TEXT REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + result TEXT NOT NULL DEFAULT 'ok', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +-- Platform-wide administration data. These records are intentionally separate +-- from project content so deployment policy can be governed independently. +CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + category TEXT NOT NULL DEFAULT 'general', + value_json TEXT NOT NULL DEFAULT 'null', + value_type TEXT NOT NULL DEFAULT 'string', + description TEXT NOT NULL DEFAULT '', + is_sensitive INTEGER NOT NULL DEFAULT 0, + updated_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS feature_flags ( + key TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0, + scope TEXT NOT NULL DEFAULT 'system', + updated_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS notification_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + endpoint TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0, + events_json TEXT NOT NULL DEFAULT '[]', + secret_ref TEXT NOT NULL DEFAULT '', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS notification_deliveries ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, + event_key TEXT NOT NULL, + organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + request_json TEXT NOT NULL DEFAULT '{}', + response_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + delivered_at TEXT +); + +-- In-app notifications are separate from administrator-managed delivery +-- channels. They are addressed to a user and remain inside the tenant scope +-- so the creator portal can expose unread state without leaking webhooks. +CREATE TABLE IF NOT EXISTS user_notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + category TEXT NOT NULL DEFAULT 'system', + event_key TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + target_tab TEXT NOT NULL DEFAULT 'creator-home', + target_id TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + read_at TEXT, + expires_at TEXT +); + +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + category TEXT NOT NULL, + in_app_enabled INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, organization_id, category) +); + +CREATE TABLE IF NOT EXISTS media_compositions ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL, + version TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'planned', + tool TEXT NOT NULL DEFAULT 'ffmpeg', + dry_run INTEGER NOT NULL DEFAULT 0, + output_path TEXT NOT NULL DEFAULT '', + manifest_path TEXT NOT NULL DEFAULT '', + source_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Canonical file evidence for generated media and local compositions. A job +-- result is an API response; this table is the production record of the +-- actual file that can be inspected, delivered, or rolled back. +CREATE TABLE IF NOT EXISTS media_artifacts ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL, + shot_id TEXT REFERENCES shots(id) ON DELETE SET NULL, + job_id TEXT REFERENCES generation_jobs(id) ON DELETE SET NULL, + composition_id TEXT REFERENCES media_compositions(id) ON DELETE SET NULL, + kind TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'output', + status TEXT NOT NULL DEFAULT 'missing', + path TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + sha256 TEXT NOT NULL DEFAULT '', + duration_sec REAL NOT NULL DEFAULT 0, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + has_video INTEGER NOT NULL DEFAULT 0, + has_audio INTEGER NOT NULL DEFAULT 0, + first_frame_path TEXT NOT NULL DEFAULT '', + last_frame_path TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (job_id, composition_id, path, role) +); + +CREATE INDEX IF NOT EXISTS idx_media_artifacts_scope ON media_artifacts(project_id, shot_id, kind, status, created_at); +CREATE INDEX IF NOT EXISTS idx_media_artifacts_job ON media_artifacts(job_id, created_at); + +CREATE TABLE IF NOT EXISTS api_clients ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + client_key TEXT NOT NULL UNIQUE, + organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'active', + scopes_json TEXT NOT NULL DEFAULT '[]', + last_used_at TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS service_health ( + id TEXT PRIMARY KEY, + service_key TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'local-service', + status TEXT NOT NULL DEFAULT 'unknown', + endpoint TEXT NOT NULL DEFAULT '', + latency_ms INTEGER, + queue_depth INTEGER NOT NULL DEFAULT 0, + version TEXT NOT NULL DEFAULT '', + last_heartbeat TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_org_members_user ON organization_members(user_id, status); +CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id, status); +CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id, status); +CREATE INDEX IF NOT EXISTS idx_script_documents_project ON script_documents(project_id, version_number DESC); +CREATE INDEX IF NOT EXISTS idx_shot_versions_shot ON shot_versions(shot_id, version_number DESC); +CREATE INDEX IF NOT EXISTS idx_voice_lines_shot ON voice_lines(shot_id, line_number); +CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id, status); +CREATE INDEX IF NOT EXISTS idx_org_role_permissions_scope ON organization_role_permissions(organization_id, role_key, permission_key); +CREATE INDEX IF NOT EXISTS idx_asset_bindings_shot ON asset_bindings(shot_id, usage_role); +CREATE INDEX IF NOT EXISTS idx_jobs_scope ON generation_jobs(organization_id, workspace_id, project_id, status); +CREATE INDEX IF NOT EXISTS idx_usage_scope ON usage_events(organization_id, workspace_id, project_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_scope ON audit_logs(organization_id, workspace_id, project_id, created_at); +CREATE INDEX IF NOT EXISTS idx_invites_scope ON invitations(organization_id, status, created_at); +CREATE INDEX IF NOT EXISTS idx_job_dependencies_dependency ON job_dependencies(depends_on_job_id, job_id); +CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(category, updated_at); +CREATE INDEX IF NOT EXISTS idx_feature_flags_scope ON feature_flags(scope, enabled); +CREATE INDEX IF NOT EXISTS idx_notification_channels_enabled ON notification_channels(enabled, kind); +CREATE INDEX IF NOT EXISTS idx_notification_deliveries_scope ON notification_deliveries(organization_id, created_at); +CREATE INDEX IF NOT EXISTS idx_user_notifications_inbox ON user_notifications(user_id, organization_id, workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_user_notifications_unread ON user_notifications(user_id, organization_id, workspace_id, read_at, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_user_notification_preferences_scope ON user_notification_preferences(user_id, organization_id, category); +CREATE INDEX IF NOT EXISTS idx_project_tasks_scope ON project_tasks(organization_id, workspace_id, project_id, status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_project_tasks_assignee ON project_tasks(assignee_user_id, status, due_at); +CREATE INDEX IF NOT EXISTS idx_task_comments_scope ON task_comments(organization_id, workspace_id, project_id, task_id, created_at); +CREATE INDEX IF NOT EXISTS idx_task_comments_author ON task_comments(author_user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_task_links_scope ON task_links(organization_id, workspace_id, project_id, task_id, created_at); +CREATE INDEX IF NOT EXISTS idx_media_compositions_scope ON media_compositions(organization_id, workspace_id, project_id, created_at); +CREATE INDEX IF NOT EXISTS idx_delivery_batches_scope ON delivery_batches(organization_id, workspace_id, project_id, delivery_id, created_at); +CREATE INDEX IF NOT EXISTS idx_delivery_batch_items_batch ON delivery_batch_items(batch_id, sequence_number); +CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at); +CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key); +CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at); +CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_service_health_status ON service_health(status, updated_at); +CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id, expires_at, revoked_at); +CREATE INDEX IF NOT EXISTS idx_mfa_challenges_hash ON auth_mfa_challenges(challenge_hash, expires_at, consumed_at); +CREATE INDEX IF NOT EXISTS idx_mfa_enrollment_challenges_hash ON auth_mfa_enrollment_challenges(challenge_hash, expires_at, consumed_at); +CREATE INDEX IF NOT EXISTS idx_identity_providers_enabled ON identity_providers(enabled, status); +CREATE INDEX IF NOT EXISTS idx_oidc_login_states_hash ON oidc_login_states(state_hash, expires_at, consumed_at); +CREATE INDEX IF NOT EXISTS idx_saml_login_states_relay ON saml_login_states(relay_state_hash, expires_at, consumed_at); +CREATE INDEX IF NOT EXISTS idx_saml_login_states_request ON saml_login_states(request_id, expires_at, consumed_at); +CREATE INDEX IF NOT EXISTS idx_external_identities_user ON external_identities(user_id, provider_id); +CREATE INDEX IF NOT EXISTS idx_auth_sso_tickets_hash ON auth_sso_tickets(ticket_hash, expires_at, consumed_at); +CREATE INDEX IF NOT EXISTS idx_directory_syncs_org ON directory_syncs(organization_id, enabled, created_at); +CREATE INDEX IF NOT EXISTS idx_directory_sync_tokens_hash ON directory_sync_tokens(token_hash, revoked_at); diff --git a/server/storage.mjs b/server/storage.mjs new file mode 100644 index 0000000..12b92da --- /dev/null +++ b/server/storage.mjs @@ -0,0 +1,105 @@ +import { lstat, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbAll, dbGet, dbRun } from "./db.mjs"; +import { httpError } from "./tenant.mjs"; + +const projectRoot = resolve(import.meta.dirname, ".."); +const storageRoot = resolve(projectRoot, "storage"); +const GB = 1024 ** 3; + +async function walk(directory, entries = []) { + let children; + try { + children = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return entries; + throw error; + } + for (const child of children) { + const path = resolve(directory, child.name); + if (child.isDirectory()) { + await walk(path, entries); + continue; + } + if (!child.isFile()) continue; + const info = await lstat(path); + entries.push({ path, bytes: info.size, updatedAt: info.mtime.toISOString() }); + } + return entries; +} + +function quotaRow(context) { + return dbGet( + `SELECT * FROM quota_allocations + WHERE organization_id = ? AND metric = 'storage' + AND (workspace_id = ? OR workspace_id IS NULL) + AND julianday(period_start) <= julianday('now') + AND julianday(period_end) >= julianday('now') + ORDER BY CASE WHEN workspace_id = ? THEN 0 ELSE 1 END + LIMIT 1`, + [context.organization.id, context.workspace.id, context.workspace.id] + ); +} + +function scopedProjectIds(context) { + if (context.project?.id) return [context.project.id]; + return (context.projects || []).map((project) => project.id); +} + +async function projectUsage(projectId) { + const roots = [resolve(storageRoot, "assets", projectId)]; + const jobs = dbAll("SELECT id FROM generation_jobs WHERE project_id = ?", [projectId]); + roots.push(...jobs.map((job) => resolve(storageRoot, "jobs", job.id))); + const compositions = dbAll("SELECT id FROM media_compositions WHERE project_id = ?", [projectId]); + roots.push(...compositions.map((composition) => resolve(storageRoot, "compositions", composition.id))); + const entries = []; + for (const root of roots) await walk(root, entries); + const files = entries.map((entry) => ({ ...entry, projectId, relativePath: entry.path.replace(`${projectRoot}/`, "") })); + return { projectId, bytes: files.reduce((sum, file) => sum + file.bytes, 0), files }; +} + +export async function storageSummary(context) { + const projects = []; + for (const projectId of scopedProjectIds(context)) projects.push(await projectUsage(projectId)); + const usedBytes = projects.reduce((sum, project) => sum + project.bytes, 0); + const quota = quotaRow(context); + const billingLimitGb = Number(dbGet("SELECT storage_gb FROM billing_accounts WHERE organization_id = ?", [context.organization.id])?.storage_gb || 0); + const quotaLimitGb = Number(quota?.limit_value || 0); + const limitGb = billingLimitGb && quotaLimitGb ? Math.min(billingLimitGb, quotaLimitGb) : billingLimitGb || quotaLimitGb; + const limitBytes = limitGb * GB; + const timestamp = new Date().toISOString(); + if (quota) dbRun("UPDATE quota_allocations SET used_value = ?, updated_at = ? WHERE id = ?", [usedBytes / GB, timestamp, quota.id]); + const files = projects.flatMap((project) => project.files).sort((a, b) => b.bytes - a.bytes); + return { + usedBytes, + usedGb: Number((usedBytes / GB).toFixed(4)), + limitBytes, + limitGb, + remainingBytes: Math.max(0, limitBytes - usedBytes), + percent: limitBytes ? Math.min(100, Number(((usedBytes / limitBytes) * 100).toFixed(2))) : 0, + projects: projects.map(({ projectId, bytes }) => ({ projectId, bytes, usedGb: Number((bytes / GB).toFixed(4)) })), + largestFiles: files.slice(0, 12) + }; +} + +export async function requireStorageQuota(context, bytes) { + const requestedBytes = Math.max(0, Number(bytes || 0)); + const summary = await storageSummary(context); + if (summary.limitBytes && summary.usedBytes + requestedBytes > summary.limitBytes) { + throw httpError(429, "storage_quota_exceeded", "当前组织存储空间不足", { + usedBytes: summary.usedBytes, + limitBytes: summary.limitBytes, + requestedBytes, + remainingBytes: summary.remainingBytes + }); + } + return { ...summary, remainingBytes: Math.max(0, summary.remainingBytes - requestedBytes) }; +} + +export function bytesToHuman(bytes) { + const value = Number(bytes || 0); + if (value < 1024) return `${value} B`; + if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`; + if (value < GB) return `${(value / 1024 ** 2).toFixed(1)} MB`; + return `${(value / GB).toFixed(2)} GB`; +} diff --git a/server/tasks.mjs b/server/tasks.mjs new file mode 100644 index 0000000..83896a3 --- /dev/null +++ b/server/tasks.mjs @@ -0,0 +1,531 @@ +import { dbAll, dbGet, dbRun } from "./db.mjs"; +import { dispatchNotificationEvent } from "./notifications.mjs"; +import { addAudit, hasPermission, requirePermission, requireProjectWritable } from "./tenant.mjs"; + +const TASK_STATUSES = new Set(["open", "in_progress", "blocked", "done", "cancelled"]); +const TASK_PRIORITIES = new Set(["high", "medium", "low"]); +const TARGET_TABS = new Set(["creator-home", "tasks", "script", "casting", "director", "jobs", "qa", "export", "assistant"]); +const TASK_LINK_TYPES = new Set(["shot", "asset", "job", "review", "artifact", "delivery", "file"]); + +const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function taskError(status, code, message, details = {}) { + return Object.assign(new Error(message), { status, code, details }); +} + +function taskScope(context) { + if (!context?.organization?.id || !context?.workspace?.id || !context?.project?.id || !context?.user?.id) { + throw Object.assign(new Error("协作任务必须绑定当前组织、工作区和项目"), { status: 400, code: "task_scope_required" }); + } + return { + organizationId: context.organization.id, + workspaceId: context.workspace.id, + projectId: context.project.id + }; +} + +function parseDueAt(value) { + if (value === undefined || value === null || value === "") return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw Object.assign(new Error("截止时间不是有效日期"), { status: 400, code: "task_due_at_invalid" }); + } + return parsed.toISOString(); +} + +function validateTaskFields(body = {}) { + const title = String(body.title || "").trim(); + if (!title) throw Object.assign(new Error("任务标题不能为空"), { status: 400, code: "task_title_required" }); + if (title.length > 180) throw Object.assign(new Error("任务标题不能超过 180 个字符"), { status: 400, code: "task_title_too_long" }); + const description = String(body.description || "").trim(); + if (description.length > 4000) throw Object.assign(new Error("任务说明不能超过 4000 个字符"), { status: 400, code: "task_description_too_long" }); + const kind = String(body.kind || "production").trim().slice(0, 60) || "production"; + const priority = String(body.priority || "medium").trim(); + if (!TASK_PRIORITIES.has(priority)) throw Object.assign(new Error("任务优先级无效"), { status: 400, code: "task_priority_invalid" }); + const targetTab = String(body.targetTab || "creator-home").trim(); + if (!TARGET_TABS.has(targetTab)) throw Object.assign(new Error("任务关联页面无效"), { status: 400, code: "task_target_invalid" }); + return { title, description, kind, priority, targetTab, targetId: String(body.targetId || "").trim().slice(0, 160), dueAt: parseDueAt(body.dueAt) }; +} + +function projectMember(context, userId) { + if (!userId) return null; + return dbGet( + `SELECT u.id, u.display_name, u.email + FROM users u + JOIN organization_members om ON om.user_id = u.id AND om.organization_id = ? AND om.status = 'active' + JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active' + LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active' + WHERE u.id = ? AND u.status = 'active' + AND (wm.id IS NOT NULL OR pm.id IS NOT NULL OR om.role_key IN ('org_owner', 'org_admin'))`, + [context.organization.id, context.workspace.id, context.project.id, userId] + ); +} + +function ensureAssignee(context, userId) { + if (!userId) return null; + const member = projectMember(context, userId); + if (!member) throw Object.assign(new Error("负责人不是当前项目的有效成员"), { status: 400, code: "task_assignee_invalid" }); + return member; +} + +function taskPayload(row) { + const now = Date.now(); + const dueAt = row.due_at || null; + return { + id: row.id, + title: row.title, + description: row.description, + kind: row.kind, + status: row.status, + priority: row.priority, + dueAt, + overdue: Boolean(dueAt && !["done", "cancelled"].includes(row.status) && new Date(dueAt).getTime() < now), + completedAt: row.completed_at, + targetTab: row.target_tab, + targetId: row.target_id || "", + assignee: row.assignee_user_id ? { id: row.assignee_user_id, displayName: row.assignee_name || row.assignee_user_id, email: row.assignee_email || "" } : null, + createdBy: { id: row.created_by, displayName: row.creator_name || row.created_by }, + commentCount: Number(row.comment_count || 0), + linkCount: Number(row.link_count || 0), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function getTask(context, taskId) { + const scope = taskScope(context); + return dbGet( + `SELECT t.*, au.display_name AS assignee_name, au.email AS assignee_email, cu.display_name AS creator_name, + (SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comment_count, + (SELECT COUNT(*) FROM task_links tl WHERE tl.task_id = t.id) AS link_count + FROM project_tasks t + LEFT JOIN users au ON au.id = t.assignee_user_id + LEFT JOIN users cu ON cu.id = t.created_by + WHERE t.id = ? AND t.organization_id = ? AND t.workspace_id = ? AND t.project_id = ?`, + [taskId, scope.organizationId, scope.workspaceId, scope.projectId] + ); +} + +export function listProjectTasks(context, { status = "", assignedTo = "", limit = 100 } = {}) { + requirePermission(context, "task:view"); + const scope = taskScope(context); + const clauses = ["t.organization_id = ?", "t.workspace_id = ?", "t.project_id = ?"]; + const params = [scope.organizationId, scope.workspaceId, scope.projectId]; + if (status && status !== "all") { + if (!TASK_STATUSES.has(status)) throw Object.assign(new Error("任务状态无效"), { status: 400, code: "task_status_invalid" }); + clauses.push("t.status = ?"); + params.push(status); + } + if (assignedTo === "me") { + clauses.push("t.assignee_user_id = ?"); + params.push(context.user.id); + } else if (assignedTo) { + clauses.push("t.assignee_user_id = ?"); + params.push(String(assignedTo)); + } + const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 100))); + const rows = dbAll( + `SELECT t.*, au.display_name AS assignee_name, au.email AS assignee_email, cu.display_name AS creator_name, + (SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comment_count, + (SELECT COUNT(*) FROM task_links tl WHERE tl.task_id = t.id) AS link_count + FROM project_tasks t + LEFT JOIN users au ON au.id = t.assignee_user_id + LEFT JOIN users cu ON cu.id = t.created_by + WHERE ${clauses.join(" AND ")} + ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'open' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'done' THEN 3 ELSE 4 END, + CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, + COALESCE(t.due_at, '9999-12-31T23:59:59.999Z'), t.updated_at DESC + LIMIT ?`, + [...params, normalizedLimit] + ); + const tasks = rows.map(taskPayload); + const byStatus = {}; + for (const task of tasks) byStatus[task.status] = (byStatus[task.status] || 0) + 1; + return { + tasks, + summary: { + total: tasks.length, + open: tasks.filter((task) => ["open", "in_progress"].includes(task.status)).length, + blocked: tasks.filter((task) => task.status === "blocked").length, + done: tasks.filter((task) => task.status === "done").length, + overdue: tasks.filter((task) => task.overdue).length, + byStatus + }, + scope, + generatedAt: new Date().toISOString() + }; +} + +export function createProjectTask(context, body = {}) { + requirePermission(context, "task:manage"); + const project = requireProjectWritable(context); + const fields = validateTaskFields(body); + const assigneeUserId = body.assigneeUserId === undefined ? context.user.id : String(body.assigneeUserId || ""); + const assignee = ensureAssignee(context, assigneeUserId); + const status = String(body.status || "open").trim(); + if (!["open", "in_progress", "blocked"].includes(status)) throw Object.assign(new Error("新任务只能创建为打开、进行中或阻塞"), { status: 400, code: "task_create_status_invalid" }); + const timestamp = new Date().toISOString(); + const id = makeId("task"); + dbRun( + `INSERT INTO project_tasks(id, organization_id, workspace_id, project_id, title, description, kind, status, priority, assignee_user_id, target_tab, target_id, due_at, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, project.id, fields.title, fields.description, fields.kind, status, fields.priority, assignee?.id || null, fields.targetTab, fields.targetId, fields.dueAt, context.user.id, timestamp, timestamp] + ); + addAudit({ context, action: "project.task.created", targetType: "project_task", targetId: id, metadata: { title: fields.title, assigneeUserId: assignee?.id || null, priority: fields.priority, dueAt: fields.dueAt } }); + if (assignee?.id) { + void dispatchNotificationEvent({ context, eventKey: "task.assigned", payload: { recipientUserId: assignee.id, taskId: id, taskTitle: fields.title, targetId: id, targetTab: "tasks" } }); + } + return { task: taskPayload(getTask(context, id)) }; +} + +export function updateProjectTask(context, taskId, body = {}) { + const current = getTask(context, taskId); + if (!current) throw Object.assign(new Error("任务不存在或不属于当前项目"), { status: 404, code: "task_not_found" }); + const canManage = hasPermission(context, "task:manage"); + if (canManage) requireProjectWritable(context); + else { + requirePermission(context, "task:complete"); + requireProjectWritable(context); + if (current.assignee_user_id !== context.user.id) throw Object.assign(new Error("只能更新自己负责的任务"), { status: 403, code: "task_assignee_only" }); + const disallowed = ["title", "description", "kind", "priority", "assigneeUserId", "targetTab", "targetId", "dueAt"].some((key) => Object.prototype.hasOwnProperty.call(body, key)); + if (disallowed) throw Object.assign(new Error("当前角色只能更新本人任务状态"), { status: 403, code: "task_update_scope_denied" }); + } + const nextStatus = body.status === undefined ? current.status : String(body.status).trim(); + if (!TASK_STATUSES.has(nextStatus)) throw Object.assign(new Error("任务状态无效"), { status: 400, code: "task_status_invalid" }); + const next = canManage ? validateTaskFields({ + title: body.title === undefined ? current.title : body.title, + description: body.description === undefined ? current.description : body.description, + kind: body.kind === undefined ? current.kind : body.kind, + priority: body.priority === undefined ? current.priority : body.priority, + targetTab: body.targetTab === undefined ? current.target_tab : body.targetTab, + targetId: body.targetId === undefined ? current.target_id : body.targetId, + dueAt: body.dueAt === undefined ? current.due_at : body.dueAt + }) : { title: current.title, description: current.description, kind: current.kind, priority: current.priority, targetTab: current.target_tab, targetId: current.target_id, dueAt: current.due_at }; + let assigneeUserId = current.assignee_user_id; + if (canManage && Object.prototype.hasOwnProperty.call(body, "assigneeUserId")) assigneeUserId = body.assigneeUserId ? String(body.assigneeUserId) : null; + const assignee = ensureAssignee(context, assigneeUserId); + const timestamp = new Date().toISOString(); + const completedAt = nextStatus === "done" ? (current.completed_at || timestamp) : null; + dbRun( + `UPDATE project_tasks + SET title = ?, description = ?, kind = ?, status = ?, priority = ?, assignee_user_id = ?, target_tab = ?, target_id = ?, due_at = ?, completed_at = ?, updated_at = ? + WHERE id = ?`, + [next.title, next.description, next.kind, nextStatus, next.priority, assignee?.id || null, next.targetTab, next.targetId, next.dueAt, completedAt, timestamp, taskId] + ); + addAudit({ context, action: "project.task.updated", targetType: "project_task", targetId: taskId, metadata: { previousStatus: current.status, status: nextStatus, assigneeUserId: assignee?.id || null, changedByAssignee: !canManage } }); + if (assignee?.id && (assignee.id !== context.user.id || current.assignee_user_id !== assignee.id)) { + void dispatchNotificationEvent({ context, eventKey: current.assignee_user_id === assignee.id ? "task.updated" : "task.assigned", payload: { recipientUserId: assignee.id, taskId, taskTitle: next.title, status: nextStatus, targetId: taskId, targetTab: "tasks" } }); + } + return { task: taskPayload(getTask(context, taskId)) }; +} + +function ensureTask(context, taskId) { + const task = getTask(context, taskId); + if (!task) throw taskError(404, "task_not_found", "任务不存在或不属于当前项目"); + return task; +} + +function commentPayload(row) { + return { + id: row.id, + taskId: row.task_id, + parentCommentId: row.parent_comment_id || null, + body: row.body, + mentions: parseJson(row.mentions_json, []), + author: { id: row.author_user_id, displayName: row.author_name || row.author_user_id, email: row.author_email || "" }, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function linkPayload(row) { + return { + id: row.id, + taskId: row.task_id, + type: row.link_type, + targetId: row.target_id, + label: row.label || row.target_id, + metadata: parseJson(row.metadata_json, {}), + createdBy: { id: row.created_by, displayName: row.created_by_name || row.created_by }, + createdAt: row.created_at + }; +} + +function taskComments(context, taskId) { + ensureTask(context, taskId); + return dbAll( + `SELECT c.*, u.display_name AS author_name, u.email AS author_email + FROM task_comments c + LEFT JOIN users u ON u.id = c.author_user_id + WHERE c.task_id = ? AND c.organization_id = ? AND c.workspace_id = ? AND c.project_id = ? + ORDER BY c.created_at ASC`, + [taskId, context.organization.id, context.workspace.id, context.project.id] + ).map(commentPayload); +} + +function taskLinks(context, taskId) { + ensureTask(context, taskId); + return dbAll( + `SELECT l.*, u.display_name AS created_by_name + FROM task_links l + LEFT JOIN users u ON u.id = l.created_by + WHERE l.task_id = ? AND l.organization_id = ? AND l.workspace_id = ? AND l.project_id = ? + ORDER BY l.created_at DESC`, + [taskId, context.organization.id, context.workspace.id, context.project.id] + ).map(linkPayload); +} + +function taskDetail(context, taskId) { + const task = ensureTask(context, taskId); + return { task: taskPayload(task), comments: taskComments(context, taskId), links: taskLinks(context, taskId) }; +} + +function activeProjectMember(context, userId) { + return projectMember(context, String(userId || "").trim()); +} + +function mentionUserIds(context, bodyText, requestedIds = []) { + const candidateIds = Array.isArray(requestedIds) ? requestedIds.map((id) => String(id || "").trim()).filter(Boolean) : []; + const valid = new Map(); + for (const id of candidateIds) { + const member = activeProjectMember(context, id); + if (member) valid.set(member.id, member); + } + const text = String(bodyText || ""); + const tokens = text.match(/@[\u4e00-\u9fa5A-Za-z0-9_.-]+/g) || []; + const members = dbAll( + `SELECT DISTINCT u.id, u.display_name, u.email + FROM users u + JOIN organization_members om ON om.user_id = u.id AND om.organization_id = ? AND om.status = 'active' + LEFT JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active' + LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active' + WHERE u.status = 'active' AND (wm.user_id IS NOT NULL OR pm.user_id IS NOT NULL OR om.role_key IN ('org_owner', 'org_admin'))`, + [context.organization.id, context.workspace.id, context.project.id] + ); + for (const token of tokens) { + const needle = token.slice(1).toLowerCase(); + const member = members.find((item) => [item.id, item.display_name, item.email].some((value) => String(value || "").toLowerCase() === needle)); + if (member) valid.set(member.id, member); + } + return [...valid.values()]; +} + +export function getProjectTask(context, taskId) { + requirePermission(context, "task:view"); + return taskDetail(context, taskId); +} + +export function listTaskComments(context, taskId) { + requirePermission(context, "task:view"); + return { taskId, comments: taskComments(context, taskId), generatedAt: new Date().toISOString() }; +} + +export function createTaskComment(context, taskId, body = {}) { + requirePermission(context, "task:view"); + requireProjectWritable(context); + const task = ensureTask(context, taskId); + const bodyText = String(body.body || "").trim(); + if (!bodyText) throw taskError(400, "task_comment_required", "评论内容不能为空"); + if (bodyText.length > 4000) throw taskError(400, "task_comment_too_long", "评论不能超过 4000 个字符"); + const parentCommentId = body.parentCommentId ? String(body.parentCommentId).trim() : null; + if (parentCommentId) { + const parent = dbGet("SELECT id FROM task_comments WHERE id = ? AND task_id = ? AND project_id = ?", [parentCommentId, taskId, context.project.id]); + if (!parent) throw taskError(400, "task_comment_parent_invalid", "回复目标不存在或不属于当前任务"); + } + const mentions = mentionUserIds(context, bodyText, body.mentionUserIds); + const timestamp = new Date().toISOString(); + const id = makeId("task-comment"); + dbRun( + `INSERT INTO task_comments(id, organization_id, workspace_id, project_id, task_id, parent_comment_id, author_user_id, body, mentions_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, context.project.id, taskId, parentCommentId, context.user.id, bodyText, JSON.stringify(mentions.map((member) => ({ id: member.id, displayName: member.display_name, email: member.email }))), timestamp, timestamp] + ); + addAudit({ context, action: "project.task.comment.created", targetType: "task_comment", targetId: id, metadata: { taskId, parentCommentId, mentionUserIds: mentions.map((member) => member.id) } }); + const recipientIds = [...new Set([task.assignee_user_id, task.created_by, ...mentions.map((member) => member.id)].filter(Boolean))].filter((idValue) => idValue !== context.user.id); + if (recipientIds.length) { + void dispatchNotificationEvent({ context, eventKey: "task.commented", payload: { recipientUserIds: recipientIds, taskId, taskTitle: task.title, commentId: id, targetId: taskId, targetTab: "tasks", mentionUserIds: mentions.map((member) => member.id) } }); + } + return { ...taskDetail(context, taskId), comment: commentPayload(dbGet("SELECT c.*, u.display_name AS author_name, u.email AS author_email FROM task_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.id = ?", [id])) }; +} + +function resolveTaskLink(context, linkType, targetId, label, metadata = {}) { + const id = String(targetId || "").trim(); + if (!TASK_LINK_TYPES.has(linkType)) throw taskError(400, "task_link_type_invalid", "任务关联类型无效", { linkType }); + if (!id) throw taskError(400, "task_link_target_required", "关联对象不能为空"); + let resolvedLabel = String(label || "").trim().slice(0, 180); + let resolvedMetadata = metadata && typeof metadata === "object" ? metadata : {}; + if (linkType === "shot") { + const row = dbGet(`SELECT s.id, s.title, s.shot_number, e.episode_number FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE s.id = ? AND sr.project_id = ?`, [id, context.project.id]); + if (!row) throw taskError(400, "task_link_shot_invalid", "镜头不存在或不属于当前项目"); + resolvedLabel ||= `E${String(row.episode_number).padStart(2, "0")}-S${String(row.shot_number).padStart(2, "0")} ${row.title}`; + resolvedMetadata = { ...resolvedMetadata, shotId: row.id, episodeNumber: row.episode_number, shotNumber: row.shot_number }; + } else if (linkType === "asset") { + const row = dbGet("SELECT id, kind, name, lock_status FROM assets WHERE id = ? AND project_id = ?", [id, context.project.id]); + if (!row) throw taskError(400, "task_link_asset_invalid", "资产不存在或不属于当前项目"); + resolvedLabel ||= `${row.name} · ${row.kind}`; + resolvedMetadata = { ...resolvedMetadata, assetId: row.id, kind: row.kind, lockStatus: row.lock_status }; + } else if (linkType === "job") { + const row = dbGet("SELECT id, kind, status, shot_id FROM generation_jobs WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); + if (!row) throw taskError(400, "task_link_job_invalid", "生成任务不存在或不属于当前项目"); + resolvedLabel ||= `${row.kind} · ${row.status}`; + resolvedMetadata = { ...resolvedMetadata, jobId: row.id, status: row.status, shotId: row.shot_id || null }; + } else if (linkType === "review") { + const row = dbGet("SELECT id, lane, status, shot_id FROM reviews WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); + if (!row) throw taskError(400, "task_link_review_invalid", "审片记录不存在或不属于当前项目"); + resolvedLabel ||= `${row.lane} · ${row.status}`; + resolvedMetadata = { ...resolvedMetadata, reviewId: row.id, lane: row.lane, status: row.status, shotId: row.shot_id || null }; + } else if (linkType === "artifact") { + const row = dbGet("SELECT id, kind, path, shot_id FROM media_artifacts WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); + if (!row) throw taskError(400, "task_link_artifact_invalid", "媒体证据不存在或不属于当前项目"); + resolvedLabel ||= `${row.kind} · ${row.path}`; + resolvedMetadata = { ...resolvedMetadata, artifactId: row.id, kind: row.kind, path: row.path, shotId: row.shot_id || null }; + } else if (linkType === "delivery") { + const row = dbGet("SELECT id, version_label, status FROM deliveries WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); + if (!row) throw taskError(400, "task_link_delivery_invalid", "交付版本不存在或不属于当前项目"); + resolvedLabel ||= `${row.version_label || row.id} · ${row.status}`; + resolvedMetadata = { ...resolvedMetadata, deliveryId: row.id, status: row.status }; + } else if (linkType === "file") { + if (/^https?:\/\//i.test(id)) throw taskError(400, "task_link_external_blocked", "本地平台不允许把外部云端 URL 当作任务附件"); + resolvedLabel ||= id.split("/").pop() || id; + resolvedMetadata = { ...resolvedMetadata, path: id, localOnly: true }; + } + return { targetId: id, label: resolvedLabel || id, metadata: resolvedMetadata }; +} + +export function addTaskLink(context, taskId, body = {}) { + requirePermission(context, "task:manage"); + requireProjectWritable(context); + const task = ensureTask(context, taskId); + const linkType = String(body.linkType || body.type || "").trim(); + const resolved = resolveTaskLink(context, linkType, body.targetId, body.label, body.metadata); + const timestamp = new Date().toISOString(); + const id = makeId("task-link"); + dbRun( + `INSERT INTO task_links(id, organization_id, workspace_id, project_id, task_id, link_type, target_id, label, metadata_json, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id, link_type, target_id) DO UPDATE SET label = excluded.label, metadata_json = excluded.metadata_json`, + [id, context.organization.id, context.workspace.id, context.project.id, taskId, linkType, resolved.targetId, resolved.label, JSON.stringify(resolved.metadata), context.user.id, timestamp] + ); + const saved = dbGet("SELECT l.*, u.display_name AS created_by_name FROM task_links l LEFT JOIN users u ON u.id = l.created_by WHERE l.task_id = ? AND l.link_type = ? AND l.target_id = ?", [taskId, linkType, resolved.targetId]); + addAudit({ context, action: "project.task.link.created", targetType: "task_link", targetId: saved.id, metadata: { taskId, linkType, targetId: resolved.targetId } }); + return { ...taskDetail(context, taskId), link: linkPayload(saved) }; +} + +export function removeTaskLink(context, taskId, linkId) { + requirePermission(context, "task:manage"); + requireProjectWritable(context); + ensureTask(context, taskId); + const link = dbGet("SELECT * FROM task_links WHERE id = ? AND task_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [linkId, taskId, context.organization.id, context.workspace.id, context.project.id]); + if (!link) throw taskError(404, "task_link_not_found", "任务关联不存在或不属于当前项目"); + dbRun("DELETE FROM task_links WHERE id = ?", [linkId]); + addAudit({ context, action: "project.task.link.removed", targetType: "task_link", targetId: linkId, metadata: { taskId, linkType: link.link_type, targetId: link.target_id } }); + return taskDetail(context, taskId); +} + +const ACTIVITY_LABELS = { + "project.task.created": "创建协作任务", + "project.task.updated": "更新协作任务", + "project.task.comment.created": "添加任务评论", + "project.task.link.created": "关联生产对象", + "project.task.link.removed": "移除任务关联", + "shot.created": "创建镜头", + "shot.updated": "更新镜头", + "shot.prompt.version.created": "保存镜头提示版本", + "shot.version.restored": "恢复镜头版本", + "asset.created": "创建资产", + "asset.version.created": "创建资产版本", + "asset.version.restored": "恢复资产版本", + "asset.lock.updated": "更新资产锁", + "asset.bound": "绑定资产到镜头", + "asset.content.verified": "验证资产内容", + "generation_job.created": "创建生成任务", + "generation_job.completed": "生成任务完成", + "generation_job.failed": "生成任务失败", + "generation_job.retry": "重试生成任务", + "media.composition.planned": "规划合成版本", + "media.composition.completed": "合成版本完成", + "media.composition.failed": "合成版本失败", + "qa.media_inspection.run": "执行媒体深检", + "review.approved": "审片通过", + "review.changes_requested": "审片要求修改", + "review.rejected": "审片驳回", + "qa.comment.created": "添加审片评论", + "series_bible.updated": "更新系列 Bible", + "assistant.query": "使用项目助手", + "organization.invitation.created": "创建组织邀请", + "project.created": "创建项目", + "delivery.created": "创建交付版本", + "delivery.approved": "批准交付版本", + "delivery.batch.created": "创建交付批次", + "delivery.batch.activated": "激活交付批次", + "delivery.batch.rolled_back": "回滚交付批次" +}; + +function activityTarget(context, row, metadata) { + const type = row.target_type; + if (type === "project_task") return dbGet("SELECT title FROM project_tasks WHERE id = ? AND project_id = ?", [row.target_id, context.project.id])?.title || row.target_id; + if (type === "task_comment") return dbGet("SELECT t.title FROM task_comments c JOIN project_tasks t ON t.id = c.task_id WHERE c.id = ? AND c.project_id = ?", [row.target_id, context.project.id])?.title || row.target_id; + if (type === "task_link") return `${metadata.linkType || "关联"} · ${metadata.targetId || row.target_id}`; + if (type === "shot" || type === "shot_version") return dbGet("SELECT s.title FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE s.id = ? AND sr.project_id = ?", [metadata.shotId || row.target_id, context.project.id])?.title || metadata.shotId || row.target_id; + if (type === "asset" || type === "asset_version") return dbGet("SELECT name FROM assets WHERE id = ? AND project_id = ?", [metadata.assetId || row.target_id, context.project.id])?.name || metadata.assetId || row.target_id; + if (type === "generation_job") return dbGet("SELECT kind FROM generation_jobs WHERE id = ? AND project_id = ?", [row.target_id, context.project.id])?.kind || row.target_id; + if (type === "review") return `${metadata.lane || "审片"} · ${metadata.shotId || row.target_id}`; + if (type === "delivery" || type === "delivery_batch") return metadata.version || metadata.deliveryId || row.target_id; + return row.target_id; +} + +function activityTab(targetType) { + if (["project_task", "task_comment", "task_link"].includes(targetType)) return "tasks"; + if (["shot", "shot_version", "script_document"].includes(targetType)) return "director"; + if (["asset", "asset_version", "asset_binding"].includes(targetType)) return "casting"; + if (["generation_job", "media_composition"].includes(targetType)) return "jobs"; + if (targetType === "review" || targetType === "review_comment") return "qa"; + if (["delivery", "delivery_batch"].includes(targetType)) return "export"; + if (targetType === "invitation") return "admin-members"; + if (["series", "episode"].includes(targetType)) return "bible"; + return "creator-home"; +} + +export function listProjectActivity(context, { limit = 80 } = {}) { + requirePermission(context, "task:view"); + const scope = taskScope(context); + const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 80))); + const rows = dbAll( + `SELECT a.*, u.display_name AS actor_name + FROM audit_logs a + LEFT JOIN users u ON u.id = a.actor_user_id + WHERE a.organization_id = ? AND a.workspace_id = ? AND a.project_id = ? + AND a.action NOT LIKE 'auth.%' AND a.action NOT LIKE 'system.%' AND a.action NOT LIKE 'user.%' + ORDER BY a.created_at DESC + LIMIT ?`, + [scope.organizationId, scope.workspaceId, scope.projectId, normalizedLimit] + ); + return { + activities: rows.map((row) => { + const metadata = parseJson(row.metadata_json, {}); + return { + id: row.id, + action: row.action, + label: ACTIVITY_LABELS[row.action] || row.action, + result: row.result, + targetType: row.target_type, + targetId: row.target_id, + targetLabel: activityTarget(context, row, metadata), + targetTab: activityTab(row.target_type), + actor: { id: row.actor_user_id, displayName: row.actor_name || row.actor_user_id || "系统" }, + metadata, + createdAt: row.created_at + }; + }), + scope, + generatedAt: new Date().toISOString() + }; +} diff --git a/server/tenant.mjs b/server/tenant.mjs new file mode 100644 index 0000000..0b19750 --- /dev/null +++ b/server/tenant.mjs @@ -0,0 +1,2738 @@ +import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; +import { listSecurityEvents, listUserDevices, listUserSessions, resetMfa, resetPassword, revokeAllUserSessions, sessionIdentity } from "./auth.mjs"; +import { createHash, randomBytes } from "node:crypto"; +import { createPasswordRecord } from "./db.mjs"; + +export const DEFAULT_CONTEXT = { + userId: "u-owner", + organizationId: "org-studio-lab", + workspaceId: "ws-local-aidrama", + projectId: "" +}; + +const SYSTEM_ONLY_PERMISSIONS = new Set([ + "system:settings:view", + "system:settings:edit", + "feature_flag:manage", + "api_client:manage", + "notification:manage", + "service:health:view" +]); + +const PROJECT_MUTATION_PERMISSIONS = new Set([ + "script:edit", + "asset:edit", + "prompt:edit", + "voice:edit", + "voice:approve", + "job:create", + "job:prioritize", + "delivery:approve" +]); + +export const API_CLIENT_SCOPE_CATALOG = [ + { key: "jobs:read", label: "读取生成任务", description: "查看当前项目的生成任务和任务详情" }, + { key: "jobs:write", label: "写入生成任务", description: "创建、执行、重试、取消和调整生成任务" }, + { key: "models:read", label: "读取模型连接器", description: "查看当前组织工作区的模型连接器和 Runner" }, + { key: "models:write", label: "管理模型连接器", description: "登记、修改和探测模型连接器" }, + { key: "audit:read", label: "读取审计日志", description: "查看和导出当前组织范围的审计记录" } +]; + +export function normalizeApiClientScopes(value, fallback = ["jobs:read"]) { + const requested = Array.isArray(value) && value.length ? value : fallback; + const scopes = [...new Set(requested.map((scope) => String(scope || "").trim()).filter(Boolean))]; + const allowed = new Set(API_CLIENT_SCOPE_CATALOG.map((scope) => scope.key)); + return { scopes, invalid: scopes.filter((scope) => !allowed.has(scope)) }; +} + +export function hasApiScope(context, scope) { + return !context?.apiClient || context.apiClient.scopes.includes(scope); +} + +export function requireApiScope(context, scope) { + if (!context?.apiClient || context.apiClient.scopes.includes(scope)) return; + throw httpError(403, "api_client_scope_denied", `API 客户端缺少 scope:${scope}`, { + apiClientId: context.apiClient.id, + requiredScope: scope, + grantedScopes: context.apiClient.scopes + }); +} + +export function httpError(status, code, message, details = {}) { + const error = new Error(message); + error.status = status; + error.code = code; + error.details = details; + return error; +} + +function getHeader(headers, name) { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +function firstOrNull(rows) { + return rows[0] || null; +} + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function canSeeAllWorkspaceData(organizationId, userId) { + const row = dbGet( + "SELECT role_key FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", + [organizationId, userId] + ); + return row?.role_key === "org_owner" || row?.role_key === "org_admin"; +} + +function accessibleWorkspaces(organizationId, userId) { + if (canSeeAllWorkspaceData(organizationId, userId)) { + return dbAll("SELECT * FROM workspaces WHERE organization_id = ? AND status = 'active' ORDER BY created_at ASC", [organizationId]); + } + return dbAll( + `SELECT w.* + FROM workspaces w + JOIN workspace_members wm ON wm.workspace_id = w.id + WHERE w.organization_id = ? AND wm.user_id = ? AND wm.status = 'active' AND w.status = 'active' + ORDER BY w.created_at ASC`, + [organizationId, userId] + ); +} + +function accessibleProjects(workspaceId, userId, organizationId) { + if (canSeeAllWorkspaceData(organizationId, userId)) { + return dbAll("SELECT * FROM projects WHERE workspace_id = ? ORDER BY CASE WHEN status = 'archived' THEN 1 ELSE 0 END, updated_at DESC", [workspaceId]); + } + return dbAll( + `SELECT DISTINCT p.* + FROM projects p + LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ? AND pm.status = 'active' + JOIN workspace_members wm ON wm.workspace_id = p.workspace_id AND wm.user_id = ? AND wm.status = 'active' + WHERE p.workspace_id = ? AND (pm.user_id IS NOT NULL OR wm.user_id IS NOT NULL) + ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, p.updated_at DESC`, + [userId, userId, workspaceId] + ); +} + +function ensureUser(userId) { + const user = dbGet("SELECT * FROM users WHERE id = ?", [userId]); + if (!user || user.status !== "active") { + throw httpError(401, "user_not_active", "当前用户不存在或未激活", { userId }); + } + return user; +} + +function ensureOrganization(userId, organizationId) { + const organization = dbGet("SELECT * FROM organizations WHERE id = ? AND status = 'active'", [organizationId]); + if (!organization) { + throw httpError(404, "organization_not_found", "组织不存在或已停用", { organizationId }); + } + const membership = dbGet( + `SELECT om.*, r.name AS role_name, r.scope AS role_scope + FROM organization_members om + LEFT JOIN roles r ON r.key = om.role_key + WHERE om.organization_id = ? AND om.user_id = ? AND om.status = 'active'`, + [organizationId, userId] + ); + if (!membership) { + throw httpError(403, "organization_forbidden", "当前用户不是该组织成员", { organizationId, userId }); + } + return { organization, membership }; +} + +function ensureWorkspace(userId, organization, workspaceId) { + const workspace = dbGet("SELECT * FROM workspaces WHERE id = ? AND organization_id = ? AND status = 'active'", [workspaceId, organization.id]); + if (!workspace) { + throw httpError(404, "workspace_not_found", "工作区不存在或不属于当前组织", { workspaceId }); + } + const membership = dbGet( + `SELECT wm.*, r.name AS role_name, r.scope AS role_scope + FROM workspace_members wm + LEFT JOIN roles r ON r.key = wm.role_key + WHERE wm.workspace_id = ? AND wm.user_id = ? AND wm.status = 'active'`, + [workspaceId, userId] + ); + const orgElevated = canSeeAllWorkspaceData(organization.id, userId); + if (!membership && !orgElevated) { + throw httpError(403, "workspace_forbidden", "当前用户没有该工作区访问权", { workspaceId, userId }); + } + return { workspace, membership, orgElevated }; +} + +function ensureProject(userId, organization, workspace, projectId) { + if (!projectId) return { project: null, membership: null }; + const project = dbGet("SELECT * FROM projects WHERE id = ? AND workspace_id = ?", [projectId, workspace.id]); + if (!project) { + throw httpError(404, "project_not_found", "项目不存在或不属于当前工作区", { projectId }); + } + const membership = dbGet( + `SELECT pm.*, r.name AS role_name, r.scope AS role_scope + FROM project_members pm + LEFT JOIN roles r ON r.key = pm.role_key + WHERE pm.project_id = ? AND pm.user_id = ? AND pm.status = 'active'`, + [projectId, userId] + ); + const workspaceMember = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ? AND status = 'active'", [workspace.id, userId]); + if (!membership && !workspaceMember && !canSeeAllWorkspaceData(organization.id, userId)) { + throw httpError(403, "project_forbidden", "当前用户没有该项目访问权", { projectId, userId }); + } + return { project, membership }; +} + +export function resolveContext(headers, searchParams = new URLSearchParams()) { + const identity = sessionIdentity(headers); + const hasSessionToken = Boolean(getHeader(headers, "authorization") || getHeader(headers, "x-session-token")); + const allowDevContext = process.env.AI_DRAMA_ALLOW_DEV_CONTEXT === "1"; + if (hasSessionToken && !identity) throw httpError(401, "auth_required", "需要有效登录会话"); + const userId = identity?.userId || (allowDevContext ? getHeader(headers, "x-user-id") || searchParams.get("userId") || DEFAULT_CONTEXT.userId : null); + if (!userId) throw httpError(401, "auth_required", "请先登录"); + const organizationHint = getHeader(headers, "x-organization-id") || searchParams.get("organizationId"); + const clientOrganizationId = identity?.apiClient?.organizationId || null; + const clientWorkspaceId = identity?.apiClient?.workspaceId || null; + if (clientOrganizationId && organizationHint && clientOrganizationId !== organizationHint) throw httpError(403, "api_client_scope_mismatch", "API 客户端不能访问其他组织"); + if (clientWorkspaceId && (getHeader(headers, "x-workspace-id") || searchParams.get("workspaceId")) && clientWorkspaceId !== (getHeader(headers, "x-workspace-id") || searchParams.get("workspaceId"))) throw httpError(403, "api_client_scope_mismatch", "API 客户端不能访问其他工作区"); + const organizationId = clientOrganizationId || organizationHint || dbGet("SELECT organization_id FROM organization_members WHERE user_id = ? AND status = 'active' ORDER BY joined_at ASC LIMIT 1", [userId])?.organization_id || DEFAULT_CONTEXT.organizationId; + const workspaceHint = getHeader(headers, "x-workspace-id") || searchParams.get("workspaceId"); + const projectHint = getHeader(headers, "x-project-id") || searchParams.get("projectId"); + const user = ensureUser(userId); + const { organization, membership: organizationMembership } = ensureOrganization(userId, organizationId); + const workspaces = accessibleWorkspaces(organization.id, userId); + const workspaceId = clientWorkspaceId || workspaceHint || firstOrNull(workspaces)?.id; + if (!workspaceId) { + throw httpError(403, "workspace_missing", "当前组织没有可访问的工作区", { organizationId }); + } + const { workspace, membership: workspaceMembership, orgElevated } = ensureWorkspace(userId, organization, workspaceId); + const projects = accessibleProjects(workspace.id, userId, organization.id); + const projectId = projectHint || firstOrNull(projects)?.id || null; + const { project, membership: projectMembership } = ensureProject(userId, organization, workspace, projectId); + const roles = [organizationMembership, workspaceMembership, projectMembership].filter(Boolean).map((item) => ({ + key: item.role_key, + name: item.role_name || item.role_key, + scope: item.role_scope || "unknown" + })); + const systemAdmin = !identity?.apiClient && Boolean(dbGet("SELECT user_id FROM system_admins WHERE user_id = ? AND status = 'active'", [user.id])); + const permissionRows = dbAll( + `SELECT DISTINCT rp.permission_key + FROM role_permissions rp + JOIN roles r ON r.key = rp.role_key + WHERE rp.role_key IN (${roles.length ? roles.map(() => "?").join(",") : "''"})`, + roles.map((role) => role.key) + ); + const effectivePermissionKeys = new Set(permissionRows.map((row) => row.permission_key)); + if (!systemAdmin && roles.length) { + const overrides = dbAll( + `SELECT role_key, permission_key, effect + FROM organization_role_permissions + WHERE organization_id = ? AND role_key IN (${roles.map(() => "?").join(",")})`, + [organization.id, ...roles.map((role) => role.key)] + ); + for (const override of overrides) { + if (override.effect === "grant") effectivePermissionKeys.add(override.permission_key); + if (override.effect === "revoke") effectivePermissionKeys.delete(override.permission_key); + } + } + let permissions = [...effectivePermissionKeys].filter((permission) => systemAdmin || !SYSTEM_ONLY_PERMISSIONS.has(permission)); + if (identity?.apiClient) { + const scopes = new Set(identity.apiClient.scopes || []); + const apiPermissions = new Set(); + if (scopes.has("jobs:write")) { + apiPermissions.add("job:create"); + apiPermissions.add("job:prioritize"); + } + if (scopes.has("models:write")) apiPermissions.add("model:manage"); + if (scopes.has("audit:read")) apiPermissions.add("audit:view"); + permissions = permissions.filter((permission) => apiPermissions.has(permission)); + } + return { + user, + organization, + workspace, + project, + organizationMembership, + workspaceMembership, + projectMembership, + roles, + permissions, + orgElevated, + workspaces, + projects, + systemAdmin, + apiClient: identity?.apiClient || null + }; +} + +export function hasPermission(context, permission) { + return context.permissions.includes(permission); +} + +export function requirePermission(context, permission) { + if (!hasPermission(context, permission)) { + throw httpError(403, "permission_denied", `缺少权限:${permission}`, { permission, roles: context.roles }); + } + if (PROJECT_MUTATION_PERMISSIONS.has(permission)) requireProjectWritable(context); +} + +export function requireProjectWritable(context) { + if (!context?.project) throw httpError(400, "project_required", "该操作必须绑定项目"); + if (context.project.status === "archived") { + throw httpError(409, "project_archived", "项目已归档,当前仅支持查看、审计和恢复项目", { + projectId: context.project.id, + archivedAt: context.project.archived_at || null, + archivedFromStatus: context.project.archived_from_status || null + }); + } + return context.project; +} + +export function orgMembers(organizationId) { + return dbAll( + `SELECT om.id, om.user_id, u.display_name, u.email, u.avatar_color, om.role_key, r.name AS role_name, om.status, om.joined_at + FROM organization_members om + JOIN users u ON u.id = om.user_id + LEFT JOIN roles r ON r.key = om.role_key + WHERE om.organization_id = ? + ORDER BY CASE om.status WHEN 'active' THEN 0 ELSE 1 END, u.display_name`, + [organizationId] + ); +} + +export function workspaceMembers(workspaceId) { + return dbAll( + `SELECT wm.id, wm.user_id, u.display_name, u.email, u.avatar_color, wm.role_key, r.name AS role_name, wm.status + FROM workspace_members wm + JOIN users u ON u.id = wm.user_id + LEFT JOIN roles r ON r.key = wm.role_key + WHERE wm.workspace_id = ? + ORDER BY u.display_name`, + [workspaceId] + ); +} + +export function projectMembers(projectId) { + return dbAll( + `SELECT pm.id, pm.user_id, u.display_name, u.email, u.avatar_color, pm.role_key, r.name AS role_name, pm.status + FROM project_members pm + JOIN users u ON u.id = pm.user_id + LEFT JOIN roles r ON r.key = pm.role_key + WHERE pm.project_id = ? + ORDER BY u.display_name`, + [projectId] + ); +} + +export function pendingInvitations(organizationId) { + const rows = dbAll( + `SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.email, i.role_key, i.invited_by, i.status, i.expires_at, i.created_at, i.token_hint, + i.accepted_user_id, i.accepted_at, i.revoked_at, + w.name AS workspace_name, p.name AS project_name, u.display_name AS inviter_name, r.name AS role_name + FROM invitations i + LEFT JOIN workspaces w ON w.id = i.workspace_id + LEFT JOIN projects p ON p.id = i.project_id + LEFT JOIN users u ON u.id = i.invited_by + LEFT JOIN roles r ON r.key = i.role_key + WHERE i.organization_id = ? AND i.status = 'pending' + ORDER BY i.created_at DESC`, + [organizationId] + ); + const active = []; + for (const row of rows) { + if (new Date(row.expires_at).getTime() <= Date.now()) { + dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [row.id]); + } else { + active.push(row); + } + } + return active; +} + +function adminInvitationRow(organizationId, invitationId) { + return dbGet( + `SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.email, i.role_key, i.invited_by, i.status, i.expires_at, i.created_at, i.token_hint, + i.accepted_user_id, i.accepted_at, i.revoked_at, + w.name AS workspace_name, p.name AS project_name, u.display_name AS inviter_name, r.name AS role_name + FROM invitations i + LEFT JOIN workspaces w ON w.id = i.workspace_id + LEFT JOIN projects p ON p.id = i.project_id + LEFT JOIN users u ON u.id = i.invited_by + LEFT JOIN roles r ON r.key = i.role_key + WHERE i.organization_id = ? AND i.id = ?`, + [organizationId, invitationId] + ); +} + +function adminInvitationPayload(row, inviteToken = "") { + if (!row) return null; + const payload = { ...row }; + if (inviteToken) { + payload.inviteToken = inviteToken; + payload.acceptUrl = `/register?invite=${encodeURIComponent(inviteToken)}`; + } + return payload; +} + +export function resendOrganizationInvitation(context, organizationId, invitationId) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能操作其他组织的邀请", { organizationId }); + requirePermission(context, "organization:members:invite"); + const current = adminInvitationRow(organizationId, invitationId); + if (!current) throw httpError(404, "invitation_not_found", "组织邀请不存在", { invitationId }); + if (!["pending", "expired"].includes(current.status)) throw httpError(409, "invitation_not_pending", "只有待处理或已过期邀请可以重新发送", { status: current.status }); + const activeMember = 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, current.email] + ); + if (activeMember) throw httpError(409, "invitation_recipient_already_member", "该邮箱已经是组织成员", { email: current.email }); + const inviteToken = `invite-${randomBytes(24).toString("base64url")}`; + const timestamp = new Date().toISOString(); + const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString(); + dbRun( + "UPDATE invitations SET status = 'pending', expires_at = ?, token_hash = ?, token_hint = ?, revoked_at = NULL WHERE id = ? AND organization_id = ?", + [expiresAt, invitationTokenHash(inviteToken), inviteToken.slice(0, 14), invitationId, organizationId] + ); + addAudit({ context, action: "organization.invitation.resent", targetType: "invitation", targetId: invitationId, metadata: { email: current.email, previousStatus: current.status, expiresAt, sentAt: timestamp } }); + return { invitation: adminInvitationPayload(adminInvitationRow(organizationId, invitationId), inviteToken) }; +} + +export function revokeOrganizationInvitation(context, organizationId, invitationId) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能操作其他组织的邀请", { organizationId }); + requirePermission(context, "organization:members:invite"); + const current = adminInvitationRow(organizationId, invitationId); + if (!current) throw httpError(404, "invitation_not_found", "组织邀请不存在", { invitationId }); + if (current.status !== "pending") throw httpError(409, "invitation_not_pending", "只有待处理邀请可以撤销", { status: current.status }); + const timestamp = new Date().toISOString(); + dbRun("UPDATE invitations SET status = 'revoked', revoked_at = ?, token_hash = NULL WHERE id = ? AND organization_id = ? AND status = 'pending'", [timestamp, invitationId, organizationId]); + addAudit({ context, action: "organization.invitation.revoked", targetType: "invitation", targetId: invitationId, metadata: { email: current.email, previousStatus: current.status, revokedAt: timestamp } }); + return { invitation: adminInvitationPayload(adminInvitationRow(organizationId, invitationId)) }; +} + +export function userInvitations(email) { + const rows = dbAll( + `SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.email, i.role_key, i.invited_by, i.status, i.expires_at, i.created_at, i.token_hint, + i.accepted_user_id, i.accepted_at, i.revoked_at, + o.name AS organization_name, w.name AS workspace_name, p.name AS project_name, + u.display_name AS inviter_name, r.name AS role_name + FROM invitations i + JOIN organizations o ON o.id = i.organization_id + LEFT JOIN workspaces w ON w.id = i.workspace_id + LEFT JOIN projects p ON p.id = i.project_id + LEFT JOIN users u ON u.id = i.invited_by + LEFT JOIN roles r ON r.key = i.role_key + WHERE lower(i.email) = lower(?) AND i.status = 'pending' + ORDER BY i.created_at DESC`, + [email] + ); + const active = []; + for (const row of rows) { + if (new Date(row.expires_at).getTime() <= Date.now()) { + dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [row.id]); + } else { + active.push(row); + } + } + return active; +} + +function invitationTokenHash(token) { + return createHash("sha256").update(String(token || "")).digest("hex"); +} + +function invitationPayload(row) { + if (!row) return null; + return { + id: row.id, + organizationId: row.organization_id, + organizationName: row.organization_name, + workspaceId: row.workspace_id, + workspaceName: row.workspace_name, + projectId: row.project_id, + projectName: row.project_name, + email: row.email, + roleKey: row.role_key, + roleName: row.role_name, + expiresAt: row.expires_at, + inviterName: row.inviter_name + }; +} + +export function previewInvitation(token) { + const tokenValue = String(token || "").trim(); + if (tokenValue.length < 24) throw httpError(400, "invitation_token_invalid", "邀请注册链接无效"); + const row = dbGet( + `SELECT i.*, o.name AS organization_name, w.name AS workspace_name, p.name AS project_name, r.name AS role_name, + u.display_name AS inviter_name + FROM invitations i + JOIN organizations o ON o.id = i.organization_id + LEFT JOIN workspaces w ON w.id = i.workspace_id + LEFT JOIN projects p ON p.id = i.project_id + LEFT JOIN roles r ON r.key = i.role_key + LEFT JOIN users u ON u.id = i.invited_by + WHERE i.token_hash = ? AND i.status = 'pending'`, + [invitationTokenHash(tokenValue)] + ); + if (!row) throw httpError(404, "invitation_not_found", "邀请注册链接不存在、已使用或已撤销"); + if (new Date(row.expires_at).getTime() <= Date.now()) { + dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [row.id]); + throw httpError(410, "invitation_expired", "邀请注册链接已过期"); + } + return invitationPayload(row); +} + +function invitationRoleForWorkspace(roleKey) { + return roleKey === "writer" || roleKey === "voice_editor" || roleKey === "art_director" ? roleKey : "producer"; +} + +function applyInvitationMembership(invitation, userId, timestamp) { + assertOrganizationSeatAvailable(invitation.organization_id, { userId }); + const organizationRole = ["org_owner", "org_admin", "org_member"].includes(invitation.role_key) ? invitation.role_key : "org_member"; + dbRun( + "INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', joined_at = excluded.joined_at, updated_at = excluded.updated_at", + [`om-${invitation.organization_id}-${userId}`, invitation.organization_id, userId, organizationRole, timestamp, timestamp, timestamp] + ); + if (invitation.workspace_id) { + dbRun( + "INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at", + [`wm-${invitation.workspace_id}-${userId}`, invitation.workspace_id, userId, invitationRoleForWorkspace(invitation.role_key), timestamp, timestamp] + ); + } + if (invitation.project_id) { + dbRun( + "INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET status = 'active', updated_at = excluded.updated_at", + [`pm-${invitation.project_id}-${userId}`, invitation.project_id, userId, timestamp, timestamp] + ); + } +} + +export function registerInvitedUser({ inviteToken, displayName, password }) { + const tokenValue = String(inviteToken || "").trim(); + if (tokenValue.length < 24) throw httpError(400, "invitation_token_invalid", "邀请注册链接无效"); + const name = String(displayName || "").trim(); + if (name.length < 2 || name.length > 80) throw httpError(400, "display_name_invalid", "姓名长度应为 2 到 80 个字符"); + const passwordValue = String(password || ""); + if (passwordValue.length < 10) throw httpError(400, "password_weak", "密码至少需要 10 个字符"); + const invitation = dbGet("SELECT * FROM invitations WHERE token_hash = ? AND status = 'pending'", [invitationTokenHash(tokenValue)]); + if (!invitation) throw httpError(404, "invitation_not_found", "邀请注册链接不存在、已使用或已撤销"); + if (new Date(invitation.expires_at).getTime() <= Date.now()) { + dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [invitation.id]); + throw httpError(410, "invitation_expired", "邀请注册链接已过期"); + } + const normalizedEmail = invitation.email.toLowerCase(); + const existing = dbGet("SELECT * FROM users WHERE lower(email) = lower(?)", [normalizedEmail]); + if (existing?.status === "active") throw httpError(409, "account_exists_use_login", "该邮箱已有账号,请先登录后在账号安全页接受邀请"); + if (existing?.status === "suspended") throw httpError(403, "user_suspended", "该邮箱对应的账号已被停用"); + const userId = existing?.id || `u-${Date.now()}-${randomBytes(4).toString("hex")}`; + const timestamp = new Date().toISOString(); + const credentials = createPasswordRecord(passwordValue); + withTransaction(() => { + if (existing) { + dbRun("UPDATE users SET display_name = ?, status = 'active', updated_at = ? WHERE id = ?", [name, timestamp, existing.id]); + dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET password_salt = excluded.password_salt, password_hash = excluded.password_hash, failed_attempts = 0, locked_until = NULL, updated_at = excluded.updated_at", [existing.id, credentials.salt, credentials.hash, timestamp, timestamp]); + } else { + dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [userId, name, normalizedEmail, "#3f7f87", timestamp, timestamp]); + dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [userId, credentials.salt, credentials.hash, timestamp, timestamp]); + } + applyInvitationMembership(invitation, userId, timestamp); + dbRun("UPDATE invitations SET status = 'accepted', accepted_user_id = ?, accepted_at = ?, token_hash = NULL WHERE id = ?", [userId, timestamp, invitation.id]); + }); + return { + user: dbGet("SELECT id, display_name, email, avatar_color, status, created_at, updated_at FROM users WHERE id = ?", [userId]), + invitation: dbGet("SELECT id, organization_id, workspace_id, project_id, email, role_key, status, expires_at, accepted_user_id, accepted_at FROM invitations WHERE id = ?", [invitation.id]) + }; +} + +export function acceptInvitation(context, invitationId) { + const invitation = dbGet("SELECT * FROM invitations WHERE id = ? AND lower(email) = lower(?) AND status = 'pending'", [invitationId, context.user.email]); + if (!invitation) throw httpError(404, "invitation_not_found", "邀请不存在、已处理或邮箱不匹配"); + if (new Date(invitation.expires_at).getTime() <= Date.now()) { + dbRun("UPDATE invitations SET status = 'expired' WHERE id = ?", [invitationId]); + throw httpError(410, "invitation_expired", "邀请已过期"); + } + const timestamp = new Date().toISOString(); + const organizationRole = ["org_owner", "org_admin", "org_member"].includes(invitation.role_key) ? invitation.role_key : "org_member"; + withTransaction(() => { + dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', joined_at = excluded.joined_at, updated_at = excluded.updated_at", [`om-${invitation.organization_id}-${context.user.id}`, invitation.organization_id, context.user.id, organizationRole, timestamp, timestamp, timestamp]); + applyInvitationMembership(invitation, context.user.id, timestamp); + dbRun("UPDATE invitations SET status = 'accepted', accepted_user_id = ?, accepted_at = ?, token_hash = NULL WHERE id = ?", [context.user.id, timestamp, invitationId]); + }); + addAudit({ context: { ...context, organization: { id: invitation.organization_id }, workspace: invitation.workspace_id ? { id: invitation.workspace_id } : null, project: invitation.project_id ? { id: invitation.project_id } : null }, action: "organization.invitation.accepted", targetType: "invitation", targetId: invitationId, metadata: { roleKey: invitation.role_key } }); + return dbGet("SELECT * FROM invitations WHERE id = ?", [invitationId]); +} + +export function billingAccount(organizationId) { + const row = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]); + if (!row) return null; + return { + ...row, + billing_cycle: row.billing_cycle || "monthly", + currency: row.currency || "CNY", + base_fee: Number(row.base_fee || 0), + seat_unit_price: Number(row.seat_unit_price || 0), + storage_unit_price: Number(row.storage_unit_price || 0), + clip_unit_price: Number(row.clip_unit_price || 0), + quota_warning_percent: Number(row.quota_warning_percent || 80), + local_runner_only: Boolean(row.local_runner_only), + cloud_connectors_require_approval: Boolean(row.cloud_connectors_require_approval) + }; +} + +function billingHistory(organizationId) { + return dbAll( + `SELECT e.*, u.display_name AS actor_name + FROM billing_account_events e + LEFT JOIN users u ON u.id = e.actor_user_id + WHERE e.organization_id = ? + ORDER BY e.created_at DESC LIMIT 24`, + [organizationId] + ).map((row) => ({ + ...row, + previous: parseJson(row.previous_json, {}), + next: parseJson(row.next_json, {}) + })); +} + +function costCenterCodeForUsage(row) { + const metadata = parseJson(row.metadata_json, {}); + if (metadata.costCenter) return String(metadata.costCenter); + const kind = String(row.kind || "").toLowerCase(); + if (kind.includes("storage") || kind.includes("asset") || kind.includes("delivery") || kind.includes("export")) return "storage"; + if (kind.includes("image") || kind.includes("video") || kind.includes("i2v") || kind.includes("tts") || kind.includes("asr") || kind.includes("generation")) return "local-gpu"; + return "operations"; +} + +function organizationCostCenters(organizationId) { + const centers = dbAll("SELECT * FROM cost_centers WHERE organization_id = ? ORDER BY status, name", [organizationId]); + const workspaces = Object.fromEntries(dbAll("SELECT id, name FROM workspaces WHERE organization_id = ?", [organizationId]).map((row) => [row.id, row.name])); + const rows = dbAll("SELECT workspace_id, project_id, kind, units, estimated_cost, metadata_json, created_at FROM usage_events WHERE organization_id = ? AND created_at >= datetime('now', 'start of month') ORDER BY created_at DESC", [organizationId]); + const grouped = new Map(); + for (const row of rows) { + const code = costCenterCodeForUsage(row); + const key = `${code}:${row.workspace_id || "organization"}`; + const current = grouped.get(key) || { code, workspaceId: row.workspace_id || null, workspaceName: workspaces[row.workspace_id] || "组织级", units: 0, cost: 0, events: 0 }; + current.units += Number(row.units || 0); + current.cost += Number(row.estimated_cost || 0); + current.events += 1; + grouped.set(key, current); + } + return { + centers: centers.map((center) => { + const details = [...grouped.values()].filter((item) => item.code === center.code); + const used = details.reduce((sum, item) => sum + item.cost, 0); + const budget = Number(center.monthly_budget || 0); + return { ...center, monthly_budget: budget, used: used, remaining: Math.max(0, budget - used), utilization: budget ? Number(((used / budget) * 100).toFixed(2)) : 0 }; + }), + detail: [...grouped.values()].sort((a, b) => b.cost - a.cost) + }; +} + +function organizationUsageTrend(organizationId, days = 31) { + const limit = Math.min(90, Math.max(7, Number(days || 31))); + return dbAll( + `SELECT substr(created_at, 1, 10) AS day, + SUM(units) AS units, + SUM(estimated_cost) AS estimated_cost, + COUNT(*) AS events + FROM usage_events + WHERE organization_id = ? AND created_at >= datetime('now', ?) + GROUP BY substr(created_at, 1, 10) + ORDER BY day ASC`, + [organizationId, `-${limit} days`] + ).map((row) => ({ day: row.day, units: Number(row.units || 0), estimatedCost: Number(row.estimated_cost || 0), events: Number(row.events || 0) })); +} + +function quotaWarnings(organizationId, billing, quotas, seat) { + const threshold = Math.min(99, Math.max(50, Number(billing?.quota_warning_percent || 80))); + const warnings = []; + if (seat.limit && seat.utilization >= threshold) warnings.push({ scope: "organization", metric: "seat", label: "组织席位", used: seat.reserved, limit: seat.limit, utilization: seat.utilization, threshold, status: seat.reserved >= seat.limit ? "critical" : "warning" }); + for (const quota of quotas) { + const limit = Number(quota.limitValue || 0); + const used = Number(quota.usedValue || 0); + const utilization = limit ? Number(((used / limit) * 100).toFixed(2)) : 0; + if (limit && utilization >= threshold) warnings.push({ scope: quota.workspaceId ? "workspace" : "organization", workspaceId: quota.workspaceId, workspaceName: quota.workspaceName, metric: quota.metric, label: quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric, used, limit, utilization, threshold, status: used >= limit ? "critical" : "warning" }); + } + return warnings; +} + +export function organizationSeatSummary(organizationId) { + const billing = billingAccount(organizationId); + const activeMembers = Number(dbGet("SELECT COUNT(DISTINCT user_id) AS count FROM organization_members WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); + const pendingInvitations = Number(dbGet("SELECT COUNT(*) AS count FROM invitations WHERE organization_id = ? AND status = 'pending' AND julianday(expires_at) > julianday('now')", [organizationId])?.count || 0); + const seatLimit = Number(billing?.seat_limit || 0); + return { + limit: seatLimit, + active: activeMembers, + pending: pendingInvitations, + reserved: activeMembers + pendingInvitations, + remaining: Math.max(0, seatLimit - activeMembers - pendingInvitations), + utilization: seatLimit ? Number(((activeMembers / seatLimit) * 100).toFixed(2)) : 0 + }; +} + +export function assertOrganizationSeatAvailable(organizationId, { userId = "", email = "" } = {}) { + const existingUserId = userId || dbGet("SELECT id FROM users WHERE lower(email) = lower(?)", [String(email || "").trim()])?.id || ""; + if (existingUserId && dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [organizationId, existingUserId])) return organizationSeatSummary(organizationId); + const summary = organizationSeatSummary(organizationId); + if (summary.limit && summary.reserved >= summary.limit) { + throw httpError(409, "seat_limit_reached", "组织席位已用尽,请先提升席位额度或清理待处理邀请", { seatLimit: summary.limit, activeMembers: summary.active, pendingInvitations: summary.pending }); + } + return summary; +} + +function organizationQuotaRows(organizationId) { + return dbAll( + `SELECT q.*, w.name AS workspace_name, w.slug AS workspace_slug + FROM quota_allocations q + LEFT JOIN workspaces w ON w.id = q.workspace_id + WHERE q.organization_id = ? + ORDER BY CASE q.metric WHEN 'clip' THEN 0 WHEN 'storage' THEN 1 ELSE 2 END, w.name, q.metric`, + [organizationId] + ).map((row) => ({ + ...row, + workspaceId: row.workspace_id, + workspaceName: row.workspace_name || "组织级", + metric: row.metric, + limitValue: Number(row.limit_value || 0), + usedValue: Number(row.used_value || 0), + remainingValue: Math.max(0, Number(row.limit_value || 0) - Number(row.used_value || 0)) + })); +} + +export function organizationCommercial(context) { + if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) { + throw httpError(403, "permission_denied", "当前角色没有查看组织商业运营数据的权限"); + } + const billing = billingAccount(context.organization.id); + const seat = organizationSeatSummary(context.organization.id); + const quotas = organizationQuotaRows(context.organization.id); + const usage = usageSummary(context); + usage.quotas = quotas; + const costCenters = organizationCostCenters(context.organization.id); + return { + organization: context.organization, + billing, + seat, + quotas, + usage, + billingHistory: billingHistory(context.organization.id), + costCenters: costCenters.centers, + costCenterDetail: costCenters.detail, + usageTrend: organizationUsageTrend(context.organization.id), + quotaWarnings: quotaWarnings(context.organization.id, billing, quotas, seat), + workspaces: dbAll("SELECT id, name, slug, status FROM workspaces WHERE organization_id = ? ORDER BY name", [context.organization.id]) + }; +} + +export function updateOrganizationBilling(context, organizationId, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的套餐配置", { organizationId }); + requirePermission(context, "billing:manage"); + const current = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]); + if (!current) throw httpError(404, "billing_not_found", "组织套餐记录不存在", { organizationId }); + const seatLimit = Math.max(1, Math.floor(Number(body.seatLimit ?? current.seat_limit))); + const storageGb = Math.max(1, Number(body.storageGb ?? current.storage_gb)); + const monthlyClipQuota = Math.max(1, Math.floor(Number(body.monthlyClipQuota ?? current.monthly_clip_quota))); + const planName = String(body.planName ?? current.plan_name).trim(); + const billingCycle = String(body.billingCycle ?? current.billing_cycle ?? "monthly").trim(); + const currency = String(body.currency ?? current.currency ?? "CNY").trim().toUpperCase(); + const baseFee = Math.max(0, Number(body.baseFee ?? current.base_fee ?? 0)); + const seatUnitPrice = Math.max(0, Number(body.seatUnitPrice ?? current.seat_unit_price ?? 0)); + const storageUnitPrice = Math.max(0, Number(body.storageUnitPrice ?? current.storage_unit_price ?? 0)); + const clipUnitPrice = Math.max(0, Number(body.clipUnitPrice ?? current.clip_unit_price ?? 0)); + const quotaWarningPercent = Math.min(99, Math.max(50, Math.floor(Number(body.quotaWarningPercent ?? current.quota_warning_percent ?? 80)))); + if (!planName) throw httpError(400, "plan_name_required", "套餐名称不能为空"); + if (!["monthly", "quarterly", "annual"].includes(billingCycle)) throw httpError(400, "billing_cycle_invalid", "账单周期只能是 monthly、quarterly 或 annual"); + if (!/^[A-Z]{3}$/.test(currency)) throw httpError(400, "currency_invalid", "货币必须是三位字母代码"); + for (const [value, label] of [[baseFee, "套餐固定费"], [seatUnitPrice, "席位单价"], [storageUnitPrice, "存储单价"], [clipUnitPrice, "片段单价"]]) { + if (!Number.isFinite(value) || value < 0) throw httpError(400, "billing_price_invalid", `${label}必须是大于或等于 0 的数字`); + } + const seats = organizationSeatSummary(organizationId); + if (seatLimit < seats.reserved) throw httpError(409, "seat_limit_below_reserved", "席位额度不能低于已占用和待处理邀请", { reserved: seats.reserved, seatLimit }); + const clipUsage = Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", [organizationId])?.units || 0); + if (monthlyClipQuota < clipUsage) throw httpError(409, "clip_quota_below_usage", "月度片段额度不能低于本月已用量", { used: clipUsage, monthlyClipQuota }); + const maxStorageUsed = Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0); + if (storageGb < maxStorageUsed) throw httpError(409, "storage_quota_below_usage", "存储额度不能低于已记录用量", { usedGb: maxStorageUsed, storageGb }); + const localRunnerOnly = body.localRunnerOnly === undefined ? Boolean(current.local_runner_only) : Boolean(body.localRunnerOnly); + const cloudApproval = body.cloudConnectorsRequireApproval === undefined ? Boolean(current.cloud_connectors_require_approval) : Boolean(body.cloudConnectorsRequireApproval); + const timestamp = new Date().toISOString(); + const previous = { planName: current.plan_name, billingCycle: current.billing_cycle || "monthly", currency: current.currency || "CNY", baseFee: Number(current.base_fee || 0), seatUnitPrice: Number(current.seat_unit_price || 0), storageUnitPrice: Number(current.storage_unit_price || 0), clipUnitPrice: Number(current.clip_unit_price || 0), seatLimit: current.seat_limit, storageGb: current.storage_gb, monthlyClipQuota: current.monthly_clip_quota, quotaWarningPercent: Number(current.quota_warning_percent || 80), localRunnerOnly: Boolean(current.local_runner_only), cloudApproval: Boolean(current.cloud_connectors_require_approval) }; + const next = { planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly, cloudApproval }; + dbRun("UPDATE billing_accounts SET plan_name = ?, billing_cycle = ?, currency = ?, base_fee = ?, seat_unit_price = ?, storage_unit_price = ?, clip_unit_price = ?, seat_limit = ?, storage_gb = ?, monthly_clip_quota = ?, quota_warning_percent = ?, local_runner_only = ?, cloud_connectors_require_approval = ?, updated_at = ? WHERE organization_id = ?", [planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly ? 1 : 0, cloudApproval ? 1 : 0, timestamp, organizationId]); + dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'plan.updated', ?, ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, current.id, JSON.stringify(previous), JSON.stringify(next), context.user.id, timestamp]); + addAudit({ context, action: "billing.account.updated", targetType: "billing_account", targetId: current.id, metadata: { previous, next } }); + return organizationCommercial(context); +} + +export function updateQuotaAllocation(context, organizationId, quotaId, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的配额", { organizationId }); + requirePermission(context, "quota:manage"); + const current = dbGet("SELECT * FROM quota_allocations WHERE id = ? AND organization_id = ?", [quotaId, organizationId]); + if (!current) throw httpError(404, "quota_not_found", "工作区配额不存在", { quotaId }); + const limitValue = Number(body.limitValue); + if (!Number.isFinite(limitValue) || limitValue <= 0) throw httpError(400, "quota_limit_invalid", "配额上限必须是大于 0 的数字"); + if (limitValue < Number(current.used_value || 0)) throw httpError(409, "quota_below_usage", "配额上限不能低于已用量", { used: Number(current.used_value || 0), limitValue }); + const billing = billingAccount(organizationId); + if (current.metric === "clip" && limitValue > Number(billing?.monthly_clip_quota || 0)) throw httpError(409, "quota_above_plan", "工作区片段配额不能超过组织月度套餐上限", { planLimit: Number(billing?.monthly_clip_quota || 0) }); + if (current.metric === "storage" && limitValue > Number(billing?.storage_gb || 0)) throw httpError(409, "quota_above_plan", "工作区存储配额不能超过组织套餐上限", { planLimit: Number(billing?.storage_gb || 0) }); + const timestamp = new Date().toISOString(); + dbRun("UPDATE quota_allocations SET limit_value = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [limitValue, timestamp, quotaId, organizationId]); + addAudit({ context, action: "quota.allocation.updated", targetType: "quota_allocation", targetId: quotaId, metadata: { metric: current.metric, workspaceId: current.workspace_id, previousLimit: Number(current.limit_value), limitValue } }); + return organizationCommercial(context); +} + +export function updateCostCenter(context, organizationId, costCenterId, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的成本中心", { organizationId }); + requirePermission(context, "billing:manage"); + const current = dbGet("SELECT * FROM cost_centers WHERE id = ? AND organization_id = ?", [costCenterId, organizationId]); + if (!current) throw httpError(404, "cost_center_not_found", "成本中心不存在", { costCenterId }); + const name = String(body.name ?? current.name).trim(); + const description = String(body.description ?? current.description ?? "").trim(); + const budget = Number(body.monthlyBudget ?? current.monthly_budget); + const status = String(body.status ?? current.status).trim(); + if (!name) throw httpError(400, "cost_center_name_required", "成本中心名称不能为空"); + if (!Number.isFinite(budget) || budget < 0) throw httpError(400, "cost_center_budget_invalid", "成本中心月度预算不能小于 0"); + if (!["active", "archived"].includes(status)) throw httpError(400, "cost_center_status_invalid", "成本中心状态无效"); + const timestamp = new Date().toISOString(); + dbRun("UPDATE cost_centers SET name = ?, description = ?, monthly_budget = ?, status = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [name, description, budget, status, timestamp, costCenterId, organizationId]); + addAudit({ context, action: "billing.cost_center.updated", targetType: "cost_center", targetId: costCenterId, metadata: { previous: current, next: { name, description, monthlyBudget: budget, status } } }); + return organizationCommercial(context); +} + +function roundMoney(value) { + return Number((Number(value || 0)).toFixed(2)); +} + +function invoiceDate(value, field, endOfDay = false) { + const raw = String(value || "").trim(); + if (!raw) return ""; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) + ? `${raw}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z` + : raw; + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) throw httpError(400, "invoice_date_invalid", `${field} 不是有效日期`, { field, value }); + return date.toISOString(); +} + +function invoicePeriodForCycle(cycle, reference = new Date()) { + const date = new Date(reference); + date.setUTCDate(1); + date.setUTCHours(0, 0, 0, 0); + if (cycle === "annual") { + date.setUTCMonth(0, 1); + const end = new Date(date); + end.setUTCFullYear(end.getUTCFullYear() + 1, 0, 1); + end.setUTCMilliseconds(-1); + return { start: date.toISOString(), end: end.toISOString() }; + } + if (cycle === "quarterly") { + date.setUTCMonth(Math.floor(date.getUTCMonth() / 3) * 3, 1); + const end = new Date(date); + end.setUTCMonth(end.getUTCMonth() + 3, 1); + end.setUTCMilliseconds(-1); + return { start: date.toISOString(), end: end.toISOString() }; + } + const end = new Date(date); + end.setUTCMonth(end.getUTCMonth() + 1, 1); + end.setUTCMilliseconds(-1); + return { start: date.toISOString(), end: end.toISOString() }; +} + +function invoiceStatusLabel(status) { + return { + draft: "草稿", + issued: "已开票", + paid: "已支付", + overdue: "已逾期", + void: "已作废" + }[status] || status; +} + +function invoicePayload(row) { + if (!row) return null; + return { + id: row.id, + organizationId: row.organization_id, + billingAccountId: row.billing_account_id, + invoiceNumber: row.invoice_number, + status: row.status, + statusLabel: invoiceStatusLabel(row.status), + currency: row.currency, + billingCycle: row.billing_cycle, + periodStart: row.period_start, + periodEnd: row.period_end, + issuedAt: row.issued_at, + dueAt: row.due_at, + paidAt: row.paid_at, + voidedAt: row.voided_at, + subtotal: Number(row.subtotal || 0), + taxRate: Number(row.tax_rate || 0), + taxAmount: Number(row.tax_amount || 0), + totalAmount: Number(row.total_amount || 0), + snapshot: parseJson(row.snapshot_json, {}), + createdBy: row.created_by, + createdByName: row.created_by_name || row.created_by || "system", + createdAt: row.created_at, + updatedAt: row.updated_at, + lineCount: Number(row.line_count || 0) + }; +} + +function invoiceLines(invoiceId) { + return dbAll("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY sort_order, created_at, id", [invoiceId]).map((row) => ({ + id: row.id, + invoiceId: row.invoice_id, + lineType: row.line_type, + description: row.description, + quantity: Number(row.quantity || 0), + unitName: row.unit_name, + unitPrice: Number(row.unit_price || 0), + amount: Number(row.amount || 0), + metadata: parseJson(row.metadata_json, {}), + sortOrder: Number(row.sort_order || 0), + createdAt: row.created_at + })); +} + +function requireInvoiceRead(context, organizationId) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的账单", { organizationId }); + if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage")) throw httpError(403, "permission_denied", "当前角色没有查看组织账单的权限"); +} + +function invoiceListRows(organizationId, options = {}) { + const clauses = ["i.organization_id = ?"]; + const params = [organizationId]; + const status = String(options.status || "").trim(); + if (status) { + if (!["draft", "issued", "paid", "overdue", "void"].includes(status)) throw httpError(400, "invoice_status_invalid", "账单状态无效", { status }); + clauses.push("i.status = ?"); + params.push(status); + } + const query = String(options.query || "").trim().slice(0, 120); + if (query) { + clauses.push("(LOWER(i.invoice_number) LIKE ? OR LOWER(i.status) LIKE ?)"); + const like = `%${query.toLowerCase()}%`; + params.push(like, like); + } + const exportMode = Boolean(options.exportMode); + const rawPage = Number(options.page || 1); + const rawPageSize = Number(options.pageSize || (exportMode ? 10000 : 25)); + const page = Number.isFinite(rawPage) ? Math.min(100000, Math.max(1, Math.floor(rawPage))) : 1; + const pageSize = Number.isFinite(rawPageSize) ? Math.min(exportMode ? 10000 : 100, Math.max(1, Math.floor(rawPageSize))) : exportMode ? 10000 : 25; + const where = `WHERE ${clauses.join(" AND ")}`; + const total = Number(dbGet(`SELECT COUNT(*) AS count FROM organization_invoices i ${where}`, params)?.count || 0); + const rows = dbAll( + `SELECT i.*, u.display_name AS created_by_name, COUNT(il.id) AS line_count + FROM organization_invoices i + LEFT JOIN users u ON u.id = i.created_by + LEFT JOIN invoice_lines il ON il.invoice_id = i.id + ${where} + GROUP BY i.id + ORDER BY i.period_end DESC, i.created_at DESC + LIMIT ? OFFSET ?`, + [...params, pageSize, (page - 1) * pageSize] + ).map(invoicePayload); + const summaryRows = dbAll("SELECT status, COUNT(*) AS count, COALESCE(SUM(total_amount), 0) AS amount FROM organization_invoices WHERE organization_id = ? GROUP BY status", [organizationId]); + const summary = { count: 0, amount: 0, draft: 0, issued: 0, paid: 0, overdue: 0, void: 0 }; + for (const row of summaryRows) { + summary[row.status] = Number(row.count || 0); + summary.count += Number(row.count || 0); + if (row.status !== "void") summary.amount += Number(row.amount || 0); + } + return { invoices: rows, pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0 }, summary }; +} + +function invoiceDetailRow(organizationId, invoiceId) { + const row = dbGet( + `SELECT i.*, u.display_name AS created_by_name, COUNT(il.id) AS line_count + FROM organization_invoices i + LEFT JOIN users u ON u.id = i.created_by + LEFT JOIN invoice_lines il ON il.invoice_id = i.id + WHERE i.organization_id = ? AND i.id = ? + GROUP BY i.id`, + [organizationId, invoiceId] + ); + if (!row) throw httpError(404, "invoice_not_found", "账单不存在或不属于当前组织", { invoiceId }); + return { invoice: invoicePayload(row), lines: invoiceLines(invoiceId) }; +} + +export function organizationInvoices(context, organizationId, options = {}) { + requireInvoiceRead(context, organizationId); + return { organizationId, ...invoiceListRows(organizationId, options) }; +} + +export function organizationInvoice(context, organizationId, invoiceId) { + requireInvoiceRead(context, organizationId); + return invoiceDetailRow(organizationId, invoiceId); +} + +export function exportOrganizationInvoices(context, organizationId, options = {}) { + requireInvoiceRead(context, organizationId); + const result = invoiceListRows(organizationId, { ...options, page: 1, pageSize: 10000, exportMode: true }); + return { exportedAt: new Date().toISOString(), organizationId, ...result }; +} + +export function generateOrganizationInvoice(context, organizationId, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能为其他组织生成账单", { organizationId }); + requirePermission(context, "billing:manage"); + const billing = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]); + if (!billing) throw httpError(404, "billing_not_found", "组织套餐记录不存在", { organizationId }); + const cycle = billing.billing_cycle || "monthly"; + const defaults = invoicePeriodForCycle(cycle); + const hasStart = body.periodStart !== undefined && String(body.periodStart).trim() !== ""; + const hasEnd = body.periodEnd !== undefined && String(body.periodEnd).trim() !== ""; + if (hasStart !== hasEnd) throw httpError(400, "invoice_period_incomplete", "账期开始和结束日期必须同时提供"); + const periodStart = hasStart ? invoiceDate(body.periodStart, "账期开始", false) : defaults.start; + const periodEnd = hasEnd ? invoiceDate(body.periodEnd, "账期结束", true) : defaults.end; + if (new Date(periodStart).getTime() >= new Date(periodEnd).getTime()) throw httpError(400, "invoice_period_invalid", "账期结束必须晚于账期开始"); + const existing = dbGet("SELECT id FROM organization_invoices WHERE organization_id = ? AND period_start = ? AND period_end = ?", [organizationId, periodStart, periodEnd]); + if (existing) return { ...invoiceDetailRow(organizationId, existing.id), idempotent: true }; + + const dueDays = Math.min(365, Math.max(0, Math.floor(Number(body.dueDays ?? 30)))); + const taxRate = Number(body.taxRate ?? 0); + if (!Number.isFinite(taxRate) || taxRate < 0 || taxRate > 100) throw httpError(400, "invoice_tax_rate_invalid", "税率必须在 0 到 100 之间"); + const dueDate = new Date(new Date(periodEnd).getTime() + dueDays * 86400000).toISOString(); + const events = dbAll("SELECT * FROM usage_events WHERE organization_id = ? AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC, id ASC", [organizationId, periodStart, periodEnd]); + const workspaces = Object.fromEntries(dbAll("SELECT id, name FROM workspaces WHERE organization_id = ?", [organizationId]).map((row) => [row.id, row.name])); + const costCenters = new Map(); + let estimatedCost = 0; + let totalUnits = 0; + for (const event of events) { + const code = costCenterCodeForUsage(event); + const current = costCenters.get(code) || { code, workspaceIds: new Set(), units: 0, events: 0, amount: 0 }; + current.workspaceIds.add(event.workspace_id || "organization"); + current.units += Number(event.units || 0); + current.events += 1; + current.amount += Number(event.estimated_cost || 0); + costCenters.set(code, current); + estimatedCost += Number(event.estimated_cost || 0); + totalUnits += Number(event.units || 0); + } + const activeSeats = Number(dbGet("SELECT COUNT(*) AS count FROM organization_members WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); + const clipUnits = Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= ? AND created_at <= ?", [organizationId, periodStart, periodEnd])?.units || 0); + const storageUsed = Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0); + const lines = [ + { lineType: "base_fee", description: `${billing.plan_name} · 套餐固定费`, quantity: 1, unitName: "账期", unitPrice: Number(billing.base_fee || 0), metadata: { planName: billing.plan_name } } + ]; + if (Number(billing.seat_unit_price || 0) > 0) lines.push({ lineType: "seat", description: "组织活跃席位", quantity: activeSeats, unitName: "席位", unitPrice: Number(billing.seat_unit_price || 0), metadata: { activeSeats } }); + if (Number(billing.storage_unit_price || 0) > 0 && storageUsed > 0) lines.push({ lineType: "storage", description: "组织存储用量", quantity: storageUsed, unitName: "GB", unitPrice: Number(billing.storage_unit_price || 0), metadata: { storageUsed } }); + if (Number(billing.clip_unit_price || 0) > 0 && clipUnits > 0) lines.push({ lineType: "clip", description: "生成片段用量", quantity: clipUnits, unitName: "片段", unitPrice: Number(billing.clip_unit_price || 0), metadata: { clipUnits } }); + let sortOrder = lines.length; + for (const detail of [...costCenters.values()].sort((a, b) => b.amount - a.amount || a.code.localeCompare(b.code))) { + const workspaceNames = [...detail.workspaceIds].map((id) => workspaces[id] || "组织级"); + lines.push({ lineType: "usage", description: `计量用量 · ${detail.code}`, quantity: detail.units, unitName: "单位", unitPrice: detail.units ? roundMoney(detail.amount / detail.units) : 0, amountOverride: roundMoney(detail.amount), metadata: { costCenter: detail.code, events: detail.events, workspaces: workspaceNames } }); + } + lines.forEach((line) => { + line.amount = line.amountOverride === undefined ? roundMoney(line.quantity * line.unitPrice) : roundMoney(line.amountOverride); + }); + const subtotal = roundMoney(lines.reduce((sum, line) => sum + line.amount, 0)); + const taxAmount = roundMoney(subtotal * taxRate / 100); + const totalAmount = roundMoney(subtotal + taxAmount); + const timestamp = new Date().toISOString(); + const invoiceId = `invoice-${organizationId}-${periodStart.slice(0, 10).replaceAll("-", "")}-${periodEnd.slice(0, 10).replaceAll("-", "")}`; + const invoiceNumber = `INV-${organizationId.replace(/[^A-Za-z0-9]+/g, "-").toUpperCase()}-${periodStart.slice(0, 10).replaceAll("-", "")}-${periodEnd.slice(0, 10).replaceAll("-", "")}`; + const snapshot = { + billing: { planName: billing.plan_name, billingCycle: cycle, currency: billing.currency || "CNY", baseFee: Number(billing.base_fee || 0), seatUnitPrice: Number(billing.seat_unit_price || 0), storageUnitPrice: Number(billing.storage_unit_price || 0), clipUnitPrice: Number(billing.clip_unit_price || 0) }, + period: { start: periodStart, end: periodEnd }, + usage: { eventCount: events.length, totalUnits: roundMoney(totalUnits), estimatedCost: roundMoney(estimatedCost), clipUnits, storageUsed, activeSeats }, + taxRate + }; + withTransaction(() => { + dbRun("INSERT INTO organization_invoices(id, organization_id, billing_account_id, invoice_number, status, currency, billing_cycle, period_start, period_end, due_at, subtotal, tax_rate, tax_amount, total_amount, snapshot_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [invoiceId, organizationId, billing.id, invoiceNumber, billing.currency || "CNY", cycle, periodStart, periodEnd, dueDate, subtotal, taxRate, taxAmount, totalAmount, JSON.stringify(snapshot), context.user.id, timestamp, timestamp]); + for (const [index, line] of lines.entries()) { + dbRun("INSERT INTO invoice_lines(id, invoice_id, line_type, description, quantity, unit_name, unit_price, amount, metadata_json, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [`invoice-line-${invoiceId}-${index + 1}`, invoiceId, line.lineType, line.description, line.quantity, line.unitName, line.unitPrice, line.amount, JSON.stringify(line.metadata || {}), index, timestamp]); + } + dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'invoice.generated', '{}', ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, billing.id, JSON.stringify({ invoiceId, invoiceNumber, periodStart, periodEnd, totalAmount }), context.user.id, timestamp]); + }); + addAudit({ context, action: "billing.invoice.generated", targetType: "organization_invoice", targetId: invoiceId, metadata: { invoiceNumber, periodStart, periodEnd, totalAmount, idempotent: false } }); + return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: false }; +} + +export function updateOrganizationInvoiceStatus(context, organizationId, invoiceId, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的账单", { organizationId }); + requirePermission(context, "billing:manage"); + const current = dbGet("SELECT * FROM organization_invoices WHERE organization_id = ? AND id = ?", [organizationId, invoiceId]); + if (!current) throw httpError(404, "invoice_not_found", "账单不存在或不属于当前组织", { invoiceId }); + const nextStatus = String(body.status || "").trim(); + if (!["draft", "issued", "paid", "overdue", "void"].includes(nextStatus)) throw httpError(400, "invoice_status_invalid", "账单状态无效", { status: nextStatus }); + if (nextStatus === current.status) return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: true }; + const transitions = { draft: new Set(["issued", "void"]), issued: new Set(["paid", "overdue", "void"]), overdue: new Set(["paid", "void"]), paid: new Set(), void: new Set() }; + if (!transitions[current.status]?.has(nextStatus)) throw httpError(409, "invoice_transition_invalid", `账单不能从${invoiceStatusLabel(current.status)}变更为${invoiceStatusLabel(nextStatus)}`, { from: current.status, to: nextStatus }); + const timestamp = new Date().toISOString(); + const issuedAt = nextStatus === "issued" && !current.issued_at ? timestamp : current.issued_at; + const paidAt = nextStatus === "paid" ? (current.paid_at || timestamp) : current.paid_at; + const voidedAt = nextStatus === "void" ? (current.voided_at || timestamp) : current.voided_at; + dbRun("UPDATE organization_invoices SET status = ?, issued_at = ?, paid_at = ?, voided_at = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [nextStatus, issuedAt, paidAt, voidedAt, timestamp, invoiceId, organizationId]); + dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'invoice.status.updated', ?, ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, current.billing_account_id, JSON.stringify({ status: current.status }), JSON.stringify({ status: nextStatus }), context.user.id, timestamp]); + addAudit({ context, action: "billing.invoice.status.updated", targetType: "organization_invoice", targetId: invoiceId, metadata: { invoiceNumber: current.invoice_number, previousStatus: current.status, status: nextStatus } }); + return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: false }; +} + +export function exportOrganizationCommercial(context, organizationId) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能导出其他组织的商业运营数据", { organizationId }); + if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) throw httpError(403, "permission_denied", "当前角色没有导出组织商业运营数据的权限"); + return { exportedAt: new Date().toISOString(), organizationId, ...organizationCommercial(context) }; +} + +function usageOrganizationScope(context, options = {}) { + const organizationId = context.organization.id; + const orgWide = Boolean(context.systemAdmin || context.orgElevated || ["org_owner", "org_admin"].includes(context.organizationMembership?.role_key)); + const clauses = ["ue.organization_id = ?"]; + const params = [organizationId]; + const requestedWorkspaceId = String(options.workspaceId || "").trim(); + const requestedProjectId = String(options.projectId || "").trim(); + + if (requestedWorkspaceId) { + const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ? AND status = 'active'", [requestedWorkspaceId]); + if (!workspace || workspace.organization_id !== organizationId) throw httpError(404, "workspace_not_found", "用量筛选工作区不存在或不属于当前组织", { workspaceId: requestedWorkspaceId }); + if (!orgWide && requestedWorkspaceId !== context.workspace?.id) throw httpError(403, "usage_scope_forbidden", "当前角色只能查看当前工作区的用量", { workspaceId: requestedWorkspaceId }); + clauses.push("ue.workspace_id = ?"); + params.push(requestedWorkspaceId); + } else if (!orgWide) { + clauses.push("ue.workspace_id = ?"); + params.push(context.workspace.id); + } + + if (requestedProjectId) { + const project = dbGet( + `SELECT p.id, p.workspace_id, w.organization_id + FROM projects p JOIN workspaces w ON w.id = p.workspace_id + WHERE p.id = ?`, + [requestedProjectId] + ); + if (!project || project.organization_id !== organizationId) throw httpError(404, "project_not_found", "用量筛选项目不存在或不属于当前组织", { projectId: requestedProjectId }); + if (!orgWide && project.workspace_id !== context.workspace?.id) throw httpError(403, "usage_scope_forbidden", "当前角色不能查看其他工作区项目用量", { projectId: requestedProjectId }); + clauses.push("ue.project_id = ?"); + params.push(requestedProjectId); + } else if (!orgWide && context.project?.id) { + clauses.push("(ue.project_id IS NULL OR ue.project_id = ?)"); + params.push(context.project.id); + } + + return { clauses, params, orgWide }; +} + +function usageFilterQuery(options = {}) { + const clauses = []; + const params = []; + const query = String(options.query || "").trim().slice(0, 160); + if (query) { + const like = `%${query.toLowerCase()}%`; + clauses.push("(LOWER(ue.kind) LIKE ? OR LOWER(ue.unit_name) LIKE ? OR LOWER(COALESCE(ue.metadata_json, '')) LIKE ? OR LOWER(COALESCE(w.name, '')) LIKE ? OR LOWER(COALESCE(p.name, '')) LIKE ? OR LOWER(COALESCE(u.display_name, '')) LIKE ? OR LOWER(COALESCE(u.email, '')) LIKE ?)"); + params.push(like, like, like, like, like, like, like); + } + for (const [key, column] of [["kind", "ue.kind"], ["unitName", "ue.unit_name"], ["userId", "ue.user_id"]]) { + const value = String(options[key] || "").trim(); + if (value) { + clauses.push(`${column} = ?`); + params.push(value); + } + } + const costCenter = String(options.costCenter || "").trim().toLowerCase(); + if (costCenter) { + const metadataCostCenter = "LOWER(COALESCE(json_extract(ue.metadata_json, '$.costCenter'), ''))"; + const eventKind = "LOWER(ue.kind)"; + const defaultLocalGpu = `(${eventKind} LIKE '%image%' OR ${eventKind} LIKE '%video%' OR ${eventKind} LIKE '%i2v%' OR ${eventKind} LIKE '%tts%' OR ${eventKind} LIKE '%asr%' OR ${eventKind} LIKE '%generation%')`; + const defaultStorage = `(${eventKind} LIKE '%storage%' OR ${eventKind} LIKE '%asset%' OR ${eventKind} LIKE '%delivery%' OR ${eventKind} LIKE '%export%')`; + const defaultOperations = `NOT ${defaultLocalGpu} AND NOT ${defaultStorage}`; + if (costCenter === "local-gpu") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultLocalGpu}))`); + else if (costCenter === "storage") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultStorage}))`); + else if (costCenter === "operations") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultOperations}))`); + else clauses.push(`${metadataCostCenter} = ?`); + params.push(costCenter); + } + const from = auditDate(options.from, "开始时间"); + const to = auditDate(options.to, "结束时间", true); + if (from) { + clauses.push("ue.created_at >= ?"); + params.push(from); + } else { + clauses.push("ue.created_at >= datetime('now', 'start of month')"); + } + if (to) { + clauses.push("ue.created_at <= ?"); + params.push(to); + } + return { clauses, params, from, to }; +} + +function usagePageOptions(options = {}) { + const exportMode = Boolean(options.exportMode); + const rawPage = Number(options.page || 1); + const rawPageSize = Number(options.pageSize || (exportMode ? 10000 : 25)); + const page = Number.isFinite(rawPage) ? Math.min(100000, Math.max(1, Math.floor(rawPage))) : 1; + const pageSize = Number.isFinite(rawPageSize) ? Math.min(exportMode ? 10000 : 100, Math.max(1, Math.floor(rawPageSize))) : exportMode ? 10000 : 25; + return { page, pageSize, offset: (page - 1) * pageSize }; +} + +function usageEventPayload(row) { + const metadata = parseJson(row.metadata_json, {}); + return { + id: row.id, + createdAt: row.created_at, + organizationId: row.organization_id, + workspaceId: row.workspace_id, + workspaceName: row.workspace_name || "组织级", + projectId: row.project_id, + projectName: row.project_name || "组织级", + userId: row.user_id, + userName: row.user_name || row.user_id || "系统", + userEmail: row.user_email || "", + kind: row.kind, + units: Number(row.units || 0), + unitName: row.unit_name, + estimatedCost: Number(row.estimated_cost || 0), + costCenter: costCenterCodeForUsage(row), + metadata + }; +} + +function usageFilterFacets(context, organizationId, orgWide) { + const workspaces = orgWide + ? dbAll("SELECT id, name, slug FROM workspaces WHERE organization_id = ? AND status = 'active' ORDER BY name", [organizationId]) + : dbAll("SELECT id, name, slug FROM workspaces WHERE id = ? AND organization_id = ? AND status = 'active'", [context.workspace.id, organizationId]); + const workspaceIds = workspaces.map((workspace) => workspace.id); + const projects = workspaceIds.length + ? dbAll( + `SELECT p.id, p.name, p.workspace_id, w.name AS workspace_name + FROM projects p JOIN workspaces w ON w.id = p.workspace_id + WHERE p.workspace_id IN (${workspaceIds.map(() => "?").join(",")}) + ORDER BY w.name, p.name`, + workspaceIds + ) + : []; + const users = dbAll( + `SELECT DISTINCT u.id, u.display_name, u.email + FROM usage_events ue + JOIN users u ON u.id = ue.user_id + WHERE ue.organization_id = ?${orgWide ? "" : " AND ue.workspace_id = ?"} + ORDER BY u.display_name`, + orgWide ? [organizationId] : [organizationId, context.workspace.id] + ); + const kinds = dbAll( + `SELECT DISTINCT ue.kind + FROM usage_events ue + WHERE ue.organization_id = ?${orgWide ? "" : " AND ue.workspace_id = ?"} + ORDER BY ue.kind`, + orgWide ? [organizationId] : [organizationId, context.workspace.id] + ).map((row) => row.kind); + const costCenters = dbAll("SELECT id, code, name, currency FROM cost_centers WHERE organization_id = ? AND status = 'active' ORDER BY name", [organizationId]); + return { workspaces, projects, users, kinds, costCenters }; +} + +export function organizationUsage(context, organizationId, options = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的用量明细", { organizationId }); + if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) throw httpError(403, "permission_denied", "当前角色没有查看组织用量明细的权限"); + + const scope = usageOrganizationScope(context, options); + const filters = usageFilterQuery(options); + const where = [...scope.clauses, ...filters.clauses]; + const whereParams = [...scope.params, ...filters.params]; + const pageOptions = usagePageOptions(options); + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + const countRow = dbGet(`SELECT COUNT(*) AS event_count, COALESCE(SUM(ue.units), 0) AS total_units, COALESCE(SUM(ue.estimated_cost), 0) AS total_cost FROM usage_events ue LEFT JOIN workspaces w ON w.id = ue.workspace_id LEFT JOIN projects p ON p.id = ue.project_id LEFT JOIN users u ON u.id = ue.user_id ${whereSql}`, whereParams); + const byKind = dbAll( + `SELECT ue.kind, ue.unit_name, SUM(ue.units) AS units, SUM(ue.estimated_cost) AS estimated_cost, COUNT(*) AS events + FROM usage_events ue + LEFT JOIN workspaces w ON w.id = ue.workspace_id + LEFT JOIN projects p ON p.id = ue.project_id + LEFT JOIN users u ON u.id = ue.user_id + ${whereSql} + GROUP BY ue.kind, ue.unit_name + ORDER BY estimated_cost DESC, events DESC`, + whereParams + ).map((row) => ({ kind: row.kind, unitName: row.unit_name, units: Number(row.units || 0), estimatedCost: Number(row.estimated_cost || 0), events: Number(row.events || 0) })); + const rows = dbAll( + `SELECT ue.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS user_name, u.email AS user_email + FROM usage_events ue + LEFT JOIN workspaces w ON w.id = ue.workspace_id + LEFT JOIN projects p ON p.id = ue.project_id + LEFT JOIN users u ON u.id = ue.user_id + ${whereSql} + ORDER BY ue.created_at DESC, ue.id DESC + LIMIT ? OFFSET ?`, + [...whereParams, pageOptions.pageSize, pageOptions.offset] + ).map(usageEventPayload); + const total = Number(countRow?.event_count || 0); + return { + organizationId, + filters: { + query: String(options.query || "").trim(), + workspaceId: String(options.workspaceId || "").trim(), + projectId: String(options.projectId || "").trim(), + userId: String(options.userId || "").trim(), + kind: String(options.kind || "").trim(), + unitName: String(options.unitName || "").trim(), + costCenter: String(options.costCenter || "").trim(), + from: filters.from || "month-start", + to: filters.to || "" + }, + items: rows, + summary: { + eventCount: total, + totalUnits: Number(countRow?.total_units || 0), + totalCost: Number(countRow?.total_cost || 0), + byKind + }, + pagination: { + page: pageOptions.page, + pageSize: pageOptions.pageSize, + total, + totalPages: total ? Math.ceil(total / pageOptions.pageSize) : 0 + }, + facets: usageFilterFacets(context, organizationId, scope.orgWide) + }; +} + +export function exportOrganizationUsage(context, organizationId, options = {}) { + return { + exportedAt: new Date().toISOString(), + ...organizationUsage(context, organizationId, { ...options, page: 1, pageSize: 10000, exportMode: true }) + }; +} + +export function usageSummary(context) { + const usage = dbAll( + `SELECT kind, unit_name, SUM(units) AS units, SUM(estimated_cost) AS estimated_cost + FROM usage_events + WHERE organization_id = ? AND created_at >= datetime('now', 'start of month') + GROUP BY kind, unit_name + ORDER BY estimated_cost DESC`, + [context.organization.id] + ); + const quotas = dbAll( + `SELECT metric, limit_value, used_value, unit, period_start, period_end + FROM quota_allocations + WHERE organization_id = ? AND (workspace_id = ? OR workspace_id IS NULL) + ORDER BY metric`, + [context.organization.id, context.workspace.id] + ); + const totalCost = usage.reduce((sum, item) => sum + Number(item.estimated_cost || 0), 0); + return { usage, quotas, totalCost }; +} + +function quotaRow(context, metric) { + return dbGet( + `SELECT * FROM quota_allocations + WHERE organization_id = ? + AND metric = ? + AND (workspace_id = ? OR workspace_id IS NULL) + AND julianday(period_start) <= julianday('now') + AND julianday(period_end) >= julianday('now') + ORDER BY CASE WHEN workspace_id = ? THEN 0 ELSE 1 END + LIMIT 1`, + [context.organization.id, metric, context.workspace.id, context.workspace.id] + ); +} + +export function requireQuota(context, metric, units = 1) { + const row = quotaRow(context, metric); + if (!row) return null; + const requested = Number(units || 0); + const rowUsed = Number(row.used_value || 0); + const rowLimit = Number(row.limit_value || 0); + const billing = billingAccount(context.organization.id); + const planLimit = metric === "clip" ? Number(billing?.monthly_clip_quota || 0) : 0; + const planUsed = metric === "clip" + ? Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", [context.organization.id])?.units || 0) + : 0; + const effectiveLimit = planLimit ? Math.min(rowLimit, planLimit) : rowLimit; + const effectiveUsed = Math.max(rowUsed, planUsed); + if (effectiveUsed + requested > effectiveLimit) { + throw httpError(429, "quota_exceeded", `当前组织的${row.unit || metric}额度不足`, { + metric, + unit: row.unit, + used: effectiveUsed, + limit: effectiveLimit, + requested + }); + } + return { ...row, used_value: effectiveUsed, limit_value: effectiveLimit, remaining: effectiveLimit - effectiveUsed - requested }; +} + +function auditInteger(value, fallback, minimum, maximum) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.floor(parsed))); +} + +function auditDate(value, field, endOfDay = false) { + const raw = String(value || "").trim(); + if (!raw) return ""; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) + ? `${raw}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z` + : raw; + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) throw httpError(400, "audit_date_invalid", `${field} 不是有效日期`, { field, value }); + return date.toISOString(); +} + +function auditPayload(row) { + if (!row) return null; + return { + ...row, + organizationName: row.organization_name || "", + workspaceName: row.workspace_name || "", + projectName: row.project_name || "", + actorName: row.actor_name || row.actor_user_id || "system", + actorEmail: row.actor_email || "", + metadata: parseJson(row.metadata_json, {}) + }; +} + +function auditSecurityPayload(row) { + return { + ...row, + userName: row.user_name || row.user_id || "未知用户", + metadata: parseJson(row.metadata_json, {}) + }; +} + +function auditScope(context, options = {}) { + const requestedOrganizationId = String(options.organizationId || "").trim(); + const requestedWorkspaceId = String(options.workspaceId || "").trim(); + const requestedProjectId = String(options.projectId || "").trim(); + const clauses = []; + const params = []; + const orgWide = Boolean(context.orgElevated || ["org_owner", "org_admin"].includes(context.organizationMembership?.role_key)); + const scope = { + mode: context.systemAdmin ? "global" : orgWide ? "organization" : context.project ? "project" : "workspace", + organizationId: context.systemAdmin ? requestedOrganizationId || null : context.organization.id, + workspaceId: requestedWorkspaceId || (!context.systemAdmin && !orgWide ? context.workspace?.id || null : null), + projectId: requestedProjectId || (!context.systemAdmin && !orgWide ? context.project?.id || null : null) + }; + + if (context.systemAdmin) { + if (requestedOrganizationId) { + const organization = dbGet("SELECT id FROM organizations WHERE id = ?", [requestedOrganizationId]); + if (!organization) throw httpError(404, "organization_not_found", "审计筛选组织不存在", { organizationId: requestedOrganizationId }); + clauses.push("a.organization_id = ?"); + params.push(requestedOrganizationId); + } + if (requestedWorkspaceId) { + const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ?", [requestedWorkspaceId]); + if (!workspace) throw httpError(404, "workspace_not_found", "审计筛选工作区不存在", { workspaceId: requestedWorkspaceId }); + if (requestedOrganizationId && workspace.organization_id !== requestedOrganizationId) throw httpError(400, "audit_scope_invalid", "工作区不属于筛选组织"); + clauses.push("a.workspace_id = ?"); + params.push(requestedWorkspaceId); + scope.organizationId ||= workspace.organization_id; + } + if (requestedProjectId) { + const project = dbGet( + `SELECT p.id, p.workspace_id, w.organization_id + FROM projects p JOIN workspaces w ON w.id = p.workspace_id + WHERE p.id = ?`, + [requestedProjectId] + ); + if (!project) throw httpError(404, "project_not_found", "审计筛选项目不存在", { projectId: requestedProjectId }); + if (requestedWorkspaceId && project.workspace_id !== requestedWorkspaceId) throw httpError(400, "audit_scope_invalid", "项目不属于筛选工作区"); + if (requestedOrganizationId && project.organization_id !== requestedOrganizationId) throw httpError(400, "audit_scope_invalid", "项目不属于筛选组织"); + clauses.push("a.project_id = ?"); + params.push(requestedProjectId); + scope.organizationId ||= project.organization_id; + scope.workspaceId ||= project.workspace_id; + } + return { clauses, params, scope }; + } + + if (requestedOrganizationId && requestedOrganizationId !== context.organization.id) { + throw httpError(403, "audit_scope_forbidden", "不能查看其他组织的审计记录", { organizationId: requestedOrganizationId }); + } + clauses.push("a.organization_id = ?"); + params.push(context.organization.id); + + if (requestedWorkspaceId) { + const workspace = dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [requestedWorkspaceId, context.organization.id]); + if (!workspace) throw httpError(404, "workspace_not_found", "审计筛选工作区不存在或不属于当前组织", { workspaceId: requestedWorkspaceId }); + if (!orgWide && requestedWorkspaceId !== context.workspace.id) throw httpError(403, "audit_scope_forbidden", "当前角色只能查看当前工作区审计记录", { workspaceId: requestedWorkspaceId }); + clauses.push("a.workspace_id = ?"); + params.push(requestedWorkspaceId); + scope.workspaceId = requestedWorkspaceId; + } else if (!orgWide) { + clauses.push("(a.workspace_id IS NULL OR a.workspace_id = ?)"); + params.push(context.workspace.id); + } + + if (requestedProjectId) { + const project = dbGet( + `SELECT p.id, p.workspace_id + FROM projects p JOIN workspaces w ON w.id = p.workspace_id + WHERE p.id = ? AND w.organization_id = ?`, + [requestedProjectId, context.organization.id] + ); + if (!project) throw httpError(404, "project_not_found", "审计筛选项目不存在或不属于当前组织", { projectId: requestedProjectId }); + if (!orgWide && project.workspace_id !== context.workspace.id) throw httpError(403, "audit_scope_forbidden", "当前角色不能查看其他工作区项目审计记录", { projectId: requestedProjectId }); + clauses.push("a.project_id = ?"); + params.push(requestedProjectId); + scope.projectId = requestedProjectId; + } else if (!orgWide) { + clauses.push(context.project?.id ? "(a.project_id IS NULL OR a.project_id = ?)" : "a.project_id IS NULL"); + if (context.project?.id) params.push(context.project.id); + } + return { clauses, params, scope }; +} + +const AUDIT_SELECT = ` + SELECT a.*, o.name AS organization_name, w.name AS workspace_name, p.name AS project_name, + u.display_name AS actor_name, u.email AS actor_email + FROM audit_logs a + LEFT JOIN organizations o ON o.id = a.organization_id + LEFT JOIN workspaces w ON w.id = a.workspace_id + LEFT JOIN projects p ON p.id = a.project_id + LEFT JOIN users u ON u.id = a.actor_user_id`; + +function auditFilterQuery(options = {}) { + const clauses = []; + const params = []; + const query = String(options.query || "").trim().slice(0, 160); + if (query) { + const like = `%${query.toLowerCase()}%`; + clauses.push("(LOWER(a.action) LIKE ? OR LOWER(a.target_type) LIKE ? OR LOWER(a.target_id) LIKE ? OR LOWER(COALESCE(u.display_name, '')) LIKE ? OR LOWER(COALESCE(u.email, '')) LIKE ? OR LOWER(a.metadata_json) LIKE ?)"); + params.push(like, like, like, like, like, like); + } + for (const [key, column] of [["action", "a.action"], ["targetType", "a.target_type"], ["targetId", "a.target_id"], ["result", "a.result"], ["actorUserId", "a.actor_user_id"]]) { + const value = String(options[key] || "").trim(); + if (value) { + clauses.push(`${column} = ?`); + params.push(value); + } + } + const from = auditDate(options.from, "开始时间"); + const to = auditDate(options.to, "结束时间", true); + if (from) { + clauses.push("a.created_at >= ?"); + params.push(from); + } + if (to) { + clauses.push("a.created_at <= ?"); + params.push(to); + } + return { clauses, params }; +} + +function auditFacets(scopeClauses, scopeParams) { + const where = scopeClauses.length ? `WHERE ${scopeClauses.join(" AND ")}` : ""; + const base = (column) => dbAll(`SELECT ${column} AS value, COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where} GROUP BY ${column} ORDER BY count DESC, value LIMIT 100`, scopeParams); + return { + actions: base("a.action").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })), + targetTypes: base("a.target_type").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })), + results: base("a.result").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })), + actors: dbAll(`SELECT a.actor_user_id AS value, COALESCE(u.display_name, a.actor_user_id, 'system') AS label, COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where} GROUP BY a.actor_user_id, u.display_name ORDER BY count DESC, label LIMIT 100`, scopeParams) + .filter((item) => item.value) + .map((item) => ({ value: item.value, label: item.label, count: Number(item.count || 0) })) + }; +} + +export function listAuditEvents(context, options = {}) { + requirePermission(context, "audit:view"); + const { clauses: scopeClauses, params: scopeParams, scope } = auditScope(context, options); + const filters = auditFilterQuery(options); + const clauses = [...scopeClauses, ...filters.clauses]; + const params = [...scopeParams, ...filters.params]; + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const page = auditInteger(options.page, 1, 1, 100000); + const maxPageSize = options.forExport ? 5000 : 200; + const pageSize = auditInteger(options.pageSize ?? options.limit, options.forExport ? 500 : 25, 1, maxPageSize); + const total = Number(dbGet(`SELECT COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where}`, params)?.count || 0); + const auditLog = dbAll(`${AUDIT_SELECT} ${where} ORDER BY a.created_at DESC, a.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]).map(auditPayload); + return { + auditLog, + pagination: { + page, + pageSize, + total, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + hasMore: page * pageSize < total + }, + filters: { + query: String(options.query || "").trim(), + action: String(options.action || "").trim(), + targetType: String(options.targetType || "").trim(), + targetId: String(options.targetId || "").trim(), + result: String(options.result || "").trim(), + actorUserId: String(options.actorUserId || "").trim(), + from: String(options.from || "").trim(), + to: String(options.to || "").trim() + }, + facets: auditFacets(scopeClauses, scopeParams), + scope + }; +} + +export function getAuditEvent(context, auditId) { + requirePermission(context, "audit:view"); + const id = String(auditId || "").trim(); + if (!id) throw httpError(400, "audit_id_required", "审计事件 ID 不能为空"); + const { clauses, params, scope } = auditScope(context); + clauses.push("a.id = ?"); + params.push(id); + const row = dbGet(`${AUDIT_SELECT} WHERE ${clauses.join(" AND ")}`, params); + if (!row) throw httpError(404, "audit_not_found", "审计事件不存在或不在当前权限范围内", { auditId: id }); + const event = auditPayload(row); + const relatedAudit = dbAll( + `${AUDIT_SELECT} WHERE ${[...auditScope(context).clauses, "a.target_type = ?", "a.target_id = ?", "a.id <> ?"].join(" AND ")} ORDER BY a.created_at DESC, a.id DESC LIMIT 12`, + [...auditScope(context).params, row.target_type, row.target_id, id] + ).map(auditPayload); + const metadata = event.metadata || {}; + const userIds = [...new Set([row.actor_user_id, metadata.userId, metadata.user_id, metadata.actorUserId].map((value) => String(value || "").trim()).filter(Boolean))]; + const references = [...new Set([metadata.sessionId, metadata.deviceRecordId, metadata.deviceId].map((value) => String(value || "").trim()).filter(Boolean))]; + let relatedSecurityEvents = []; + if (userIds.length || references.length) { + const securityClauses = []; + const securityParams = []; + if (userIds.length) { + securityClauses.push(`e.user_id IN (${userIds.map(() => "?").join(",")})`); + securityParams.push(...userIds); + } + for (const reference of references) { + securityClauses.push("e.metadata_json LIKE ?"); + securityParams.push(`%${reference}%`); + } + relatedSecurityEvents = dbAll( + `SELECT e.*, u.display_name AS user_name + FROM auth_security_events e LEFT JOIN users u ON u.id = e.user_id + WHERE ${securityClauses.map((item) => `(${item})`).join(" OR ")} + ORDER BY e.created_at DESC LIMIT 20`, + securityParams + ).map(auditSecurityPayload); + } + return { event, relatedAudit, relatedSecurityEvents, scope }; +} + +export function exportAuditEvents(context, options = {}) { + const result = listAuditEvents(context, { ...options, page: 1, forExport: true, pageSize: options.pageSize || options.limit || 500 }); + return { + exportedAt: new Date().toISOString(), + scope: result.scope, + filters: result.filters, + total: result.pagination.total, + auditLog: result.auditLog + }; +} + +export function scopedAuditLog(context, limit = 40) { + return listAuditEvents(context, { page: 1, pageSize: limit }).auditLog; +} + +export function scopedModels(context) { + return dbAll( + `SELECT * FROM model_connectors + WHERE organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?) + ORDER BY created_at DESC`, + [context.organization.id, context.workspace.id] + ).map((model) => ({ + ...model, + capability: parseJson(model.capabilities_json, []), + protocol: parseJson(model.protocol_json, {}), + approvalRequired: Boolean(model.approval_required), + costMode: model.cost_mode + })); +} + +function rolePolicyRows(organizationId) { + const roles = dbAll("SELECT key, scope, name, description FROM roles ORDER BY scope, name"); + const permissions = dbAll("SELECT key, description FROM permissions ORDER BY key").map((permission) => ({ + ...permission, + systemOnly: SYSTEM_ONLY_PERMISSIONS.has(permission.key) + })); + const overrides = dbAll( + `SELECT organization_id, role_key, permission_key, effect, updated_by, created_at, updated_at + FROM organization_role_permissions + WHERE organization_id = ? + ORDER BY role_key, permission_key`, + [organizationId] + ); + return roles.map((role) => { + const basePermissions = dbAll("SELECT permission_key FROM role_permissions WHERE role_key = ? ORDER BY permission_key", [role.key]).map((row) => row.permission_key); + const roleOverrides = overrides.filter((override) => override.role_key === role.key); + const effective = new Set(basePermissions); + for (const override of roleOverrides) { + if (override.effect === "grant") effective.add(override.permission_key); + if (override.effect === "revoke") effective.delete(override.permission_key); + } + return { + ...role, + basePermissions, + permissions: [...effective].sort(), + overrides: roleOverrides.map((override) => ({ permissionKey: override.permission_key, effect: override.effect, updatedAt: override.updated_at })) + }; + }); +} + +export function organizationRolePolicies(organizationId) { + return { + roles: rolePolicyRows(organizationId), + permissions: dbAll("SELECT key, description FROM permissions ORDER BY key").map((permission) => ({ + ...permission, + systemOnly: SYSTEM_ONLY_PERMISSIONS.has(permission.key) + })) + }; +} + +export function updateOrganizationRolePolicy(context, organizationId, roleKey, body = {}) { + requirePermission(context, "organization:roles:manage"); + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的角色策略", { organizationId }); + const role = dbGet("SELECT key, scope, name FROM roles WHERE key = ?", [roleKey]); + if (!role) throw httpError(404, "role_not_found", "角色不存在", { roleKey }); + if (role.key === "org_owner") throw httpError(400, "owner_policy_locked", "组织所有者策略不可被组织级覆盖"); + const permissionKey = String(body.permissionKey || "").trim(); + const permission = dbGet("SELECT key FROM permissions WHERE key = ?", [permissionKey]); + if (!permission) throw httpError(400, "permission_not_found", "权限不存在", { permissionKey }); + if (SYSTEM_ONLY_PERMISSIONS.has(permissionKey)) throw httpError(400, "system_permission_locked", "系统级权限不能通过组织策略覆盖", { permissionKey }); + if (typeof body.enabled !== "boolean") throw httpError(400, "policy_enabled_invalid", "enabled 必须是布尔值"); + + const baseline = Boolean(dbGet("SELECT 1 FROM role_permissions WHERE role_key = ? AND permission_key = ?", [roleKey, permissionKey])); + const timestamp = new Date().toISOString(); + if (body.enabled === baseline) { + dbRun("DELETE FROM organization_role_permissions WHERE organization_id = ? AND role_key = ? AND permission_key = ?", [organizationId, roleKey, permissionKey]); + } else { + dbRun( + `INSERT INTO organization_role_permissions(organization_id, role_key, permission_key, effect, updated_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(organization_id, role_key, permission_key) DO UPDATE SET effect = excluded.effect, updated_by = excluded.updated_by, updated_at = excluded.updated_at`, + [organizationId, roleKey, permissionKey, body.enabled ? "grant" : "revoke", context.user.id, timestamp, timestamp] + ); + } + addAudit({ context, action: "organization.role_policy.updated", targetType: "organization_role_permission", targetId: `${organizationId}:${roleKey}:${permissionKey}`, metadata: { roleKey, permissionKey, enabled: body.enabled, baseline } }); + return organizationRolePolicies(organizationId); +} + +function ensureAssetContext(context, assetId) { + if (!context.project) throw httpError(400, "project_required", "资产操作必须绑定项目"); + const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, context.project.id]); + if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目", { assetId }); + return asset; +} + +function assetDetail(assetId, projectId) { + const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, projectId]); + if (!asset) return null; + const versions = dbAll("SELECT id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at FROM asset_versions WHERE asset_id = ? ORDER BY version_number DESC", [assetId]).map((version) => ({ + ...version, + metadata: parseJson(version.metadata_json, {}), + fileName: version.file_name || "", + mimeType: version.mime_type || "application/octet-stream", + fileSize: Number(version.file_size || 0), + contentSha256: version.content_sha256 || "" + })); + const bindings = dbAll( + `SELECT b.id, b.shot_id, b.usage_role, b.created_at, s.title AS shot_title + FROM asset_bindings b + JOIN shots s ON s.id = b.shot_id + WHERE b.asset_id = ? + ORDER BY b.created_at DESC`, + [assetId] + ); + const currentVersion = versions.find((version) => version.id === asset.current_version_id) || versions[0] || null; + return { + ...asset, + lockStatus: asset.lock_status, + currentVersionId: asset.current_version_id, + currentVersion, + versions, + bindings + }; +} + +export function scopedAssets(context) { + if (!context.project) return []; + return dbAll("SELECT id FROM assets WHERE project_id = ? ORDER BY updated_at DESC, created_at DESC", [context.project.id]) + .map((row) => assetDetail(row.id, context.project.id)) + .filter(Boolean); +} + +export function getAsset(context, assetId) { + return assetDetail(assetId, context.project?.id); +} + +export function createAsset(context, body) { + requirePermission(context, "asset:edit"); + if (!context.project) throw httpError(400, "project_required", "创建资产必须绑定项目"); + const name = String(body.name || "").trim(); + if (!name) throw httpError(400, "name_required", "资产名称不能为空"); + const id = String(body.id || `asset-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`); + const timestamp = new Date().toISOString(); + const versionId = `${id}-v1`; + const kind = String(body.kind || "reference").trim(); + const lockStatus = String(body.lockStatus || "draft").trim(); + const storagePath = String(body.storagePath || `assets/${context.project.id}/${id}/v1/metadata.json`).trim(); + const metadata = { + subtitle: String(body.subtitle || "本地项目资产"), + initial: String(body.initial || name.slice(0, 1)), + tags: Array.isArray(body.tags) ? body.tags : [], + usage: String(body.usage || "当前项目"), + detail: String(body.detail || "待补充资产描述"), + lock: String(body.lock || "待补充连续性备注"), + mimeType: String(body.mimeType || "application/octet-stream"), + size: Number(body.size || 0) + }; + const fileName = String(body.fileName || storagePath.split("/").pop() || "").trim(); + const mimeType = String(body.mimeType || "application/octet-stream"); + const fileSize = Number(body.fileSize ?? body.size ?? 0); + const contentSha256 = String(body.contentSha256 || "").trim(); + dbRun("INSERT INTO assets(id, project_id, kind, name, lock_status, current_version_id, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, context.project.id, kind, name, lockStatus, versionId, context.user.id, timestamp, timestamp]); + dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, id, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(metadata), context.user.id, timestamp]); + addAudit({ context, action: "asset.created", targetType: "asset", targetId: id, metadata: { kind, name, storagePath } }); + return assetDetail(id, context.project.id); +} + +export function createAssetVersion(context, assetId, body) { + requirePermission(context, "asset:edit"); + const asset = ensureAssetContext(context, assetId); + const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]); + const currentMetadata = parseJson(currentVersion?.metadata_json, {}); + const latest = dbGet("SELECT MAX(version_number) AS version_number FROM asset_versions WHERE asset_id = ?", [assetId]); + const versionNumber = Number(latest?.version_number || 0) + 1; + const versionId = `${assetId}-v${versionNumber}`; + const timestamp = new Date().toISOString(); + const storagePath = String(body.storagePath || currentVersion?.storage_path || `assets/${context.project.id}/${assetId}/v${versionNumber}/metadata.json`).trim(); + const metadata = { + ...currentMetadata, + ...(body.metadata && typeof body.metadata === "object" ? body.metadata : {}), + versionNote: String(body.versionNote || "版本更新"), + mimeType: String(body.mimeType || currentMetadata.mimeType || "application/octet-stream"), + size: Number(body.size ?? currentMetadata.size ?? 0) + }; + const fileName = String(body.fileName || currentVersion?.file_name || storagePath.split("/").pop() || "").trim(); + const mimeType = String(body.mimeType || currentVersion?.mime_type || currentMetadata.mimeType || "application/octet-stream"); + const fileSize = Number(body.fileSize ?? body.size ?? currentVersion?.file_size ?? currentMetadata.size ?? 0); + const contentSha256 = String(body.contentSha256 || currentVersion?.content_sha256 || "").trim(); + dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, assetId, versionNumber, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(metadata), context.user.id, timestamp]); + dbRun("UPDATE assets SET current_version_id = ?, updated_at = ? WHERE id = ?", [versionId, timestamp, assetId]); + addAudit({ context, action: "asset.version.created", targetType: "asset_version", targetId: versionId, metadata: { assetId, versionNumber, storagePath } }); + return assetDetail(asset.id, context.project.id); +} + +export function restoreAssetVersion(context, assetId, versionId) { + requirePermission(context, "asset:edit"); + const asset = ensureAssetContext(context, assetId); + const version = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [versionId, assetId]); + if (!version) throw httpError(404, "asset_version_not_found", "要恢复的资产版本不存在"); + if (asset.current_version_id === version.id) return assetDetail(assetId, context.project.id); + const timestamp = new Date().toISOString(); + dbRun("UPDATE assets SET current_version_id = ?, updated_at = ? WHERE id = ?", [version.id, timestamp, assetId]); + addAudit({ context, action: "asset.version.restored", targetType: "asset_version", targetId: version.id, metadata: { assetId, previousVersionId: asset.current_version_id, restoredVersionNumber: version.version_number } }); + return assetDetail(assetId, context.project.id); +} + +export function updateAssetLock(context, assetId, body) { + requirePermission(context, "asset:edit"); + const asset = ensureAssetContext(context, assetId); + const lockStatus = String(body.lockStatus || "draft").trim(); + if (!["draft", "review", "locked", "archived"].includes(lockStatus)) throw httpError(400, "lock_status_invalid", "资产锁定状态无效", { lockStatus }); + const timestamp = new Date().toISOString(); + dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [lockStatus, timestamp, assetId]); + addAudit({ context, action: "asset.lock.updated", targetType: "asset", targetId: assetId, metadata: { previous: asset.lock_status, lockStatus } }); + return assetDetail(assetId, context.project.id); +} + +export function updateAssetRights(context, assetId, body) { + requirePermission(context, "voice:approve"); + const asset = ensureAssetContext(context, assetId); + if (asset.kind !== "voice") throw httpError(400, "voice_asset_required", "只有声音资产需要走声音授权审批"); + const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]); + if (!currentVersion) throw httpError(404, "asset_version_not_found", "当前声音资产版本不存在"); + const rightsStatus = String(body.rightsStatus || "needs-evidence").trim(); + if (!["needs-evidence", "submitted", "approved", "rejected", "expired"].includes(rightsStatus)) { + throw httpError(400, "rights_status_invalid", "声音授权状态无效", { rightsStatus }); + } + const evidence = body.evidence && typeof body.evidence === "object" ? body.evidence : {}; + if (rightsStatus === "approved" && !String(evidence.reference || evidence.consentRef || "").trim()) { + throw httpError(400, "rights_evidence_required", "批准声音资产前必须填写授权证据引用"); + } + const metadata = parseJson(currentVersion.metadata_json, {}); + const timestamp = new Date().toISOString(); + const nextMetadata = { + ...metadata, + rightsEvidence: { + ...((metadata && metadata.rightsEvidence) || {}), + ...evidence, + reviewedBy: context.user.id, + reviewedAt: timestamp + } + }; + dbRun("UPDATE asset_versions SET rights_status = ?, metadata_json = ? WHERE id = ? AND asset_id = ?", [rightsStatus, JSON.stringify(nextMetadata), currentVersion.id, assetId]); + dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [rightsStatus === "approved" ? "locked" : rightsStatus === "rejected" ? "review" : asset.lock_status, timestamp, assetId]); + addAudit({ context, action: "voice.rights.updated", targetType: "asset_version", targetId: currentVersion.id, result: rightsStatus === "approved" ? "pass" : rightsStatus, metadata: { assetId, rightsStatus, evidence: nextMetadata.rightsEvidence } }); + return assetDetail(assetId, context.project.id); +} + +export function bindAssetToShot(context, assetId, body) { + requirePermission(context, "asset:edit"); + const asset = ensureAssetContext(context, assetId); + const shotId = String(body.shotId || "").trim(); + const shot = dbGet( + `SELECT s.id, s.title FROM shots s + JOIN episodes e ON e.id = s.episode_id + JOIN seasons se ON se.id = e.season_id + JOIN series sr ON sr.id = se.series_id + WHERE s.id = ? AND sr.project_id = ?`, + [shotId, context.project.id] + ); + if (!shot) throw httpError(400, "shot_invalid", "镜头不存在或不属于当前项目", { shotId }); + const usageRole = String(body.usageRole || asset.kind || "continuity").trim(); + const timestamp = new Date().toISOString(); + dbRun("INSERT INTO asset_bindings(id, asset_id, shot_id, usage_role, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(asset_id, shot_id, usage_role) DO UPDATE SET created_by = excluded.created_by", [`binding-${assetId}-${shotId}-${usageRole}`, assetId, shotId, usageRole, context.user.id, timestamp]); + addAudit({ context, action: "asset.bound", targetType: "asset_binding", targetId: `${assetId}:${shotId}`, metadata: { assetId, shotId, usageRole } }); + return assetDetail(asset.id, context.project.id); +} + +export function buildContextPayload(context) { + const canManageOrganization = hasPermission(context, "organization:manage") || hasPermission(context, "organization:members:invite"); + const canManageWorkspace = hasPermission(context, "workspace:manage") || hasPermission(context, "workspace:create") || hasPermission(context, "workspace:members:manage"); + const canManageProjectMembers = hasPermission(context, "project:members:manage"); + const canViewUsage = hasPermission(context, "usage:view") || hasPermission(context, "billing:manage"); + const canViewAudit = hasPermission(context, "audit:view"); + const canViewModels = hasPermission(context, "model:manage"); + const canUseGenerationAdapters = hasPermission(context, "job:create") || canViewModels; + const modelRows = scopedModels(context); + const adapterCatalog = canViewModels ? modelRows : modelRows.map((model) => ({ + id: model.id, + label: model.label, + kind: model.kind, + capability: model.capability, + status: model.status, + costMode: model.costMode, + approvalRequired: model.approvalRequired + })); + const members = canManageOrganization ? orgMembers(context.organization.id) : []; + const invitations = hasPermission(context, "organization:members:invite") ? pendingInvitations(context.organization.id) : []; + const billing = canViewUsage ? billingAccount(context.organization.id) : null; + const usage = canViewUsage ? usageSummary(context) : null; + const auditLog = canViewAudit ? scopedAuditLog(context) : []; + const workspaceMemberRows = canManageWorkspace ? workspaceMembers(context.workspace.id) : []; + const projectMemberRows = canManageProjectMembers && context.project ? projectMembers(context.project.id) : []; + const organizationRows = dbAll( + `SELECT o.id, o.name, o.slug, o.deployment_mode, o.status, om.role_key, r.name AS role_name, + (SELECT COUNT(*) FROM workspaces w WHERE w.organization_id = o.id) AS workspace_count + FROM organizations o + JOIN organization_members om ON om.organization_id = o.id AND om.user_id = ? AND om.status = 'active' + LEFT JOIN roles r ON r.key = om.role_key + WHERE o.status = 'active' + ORDER BY o.name`, + [context.user.id] + ); + const workspaces = context.workspaces.map((workspace) => ({ + ...workspace, + memberCount: Number(dbGet("SELECT COUNT(*) AS count FROM workspace_members WHERE workspace_id = ? AND status = 'active'", [workspace.id])?.count || 0), + projectCount: Number(dbGet("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ?", [workspace.id])?.count || 0) + })); + const projects = context.projects.map((project) => ({ + ...project, + owner: dbGet("SELECT display_name FROM users WHERE id = ?", [project.owner_user_id])?.display_name || project.owner_user_id, + memberCount: Number(dbGet("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND status = 'active'", [project.id])?.count || 0) + })); + const rolePolicyCatalog = canManageOrganization || context.systemAdmin ? organizationRolePolicies(context.organization.id) : { roles: [], permissions: [] }; + const roleRows = rolePolicyCatalog.roles; + const permissionRows = rolePolicyCatalog.permissions; + const rolePermissionRows = rolePolicyCatalog.roles; + return { + context: { + currentUser: context.user, + currentOrganization: context.organization, + currentWorkspace: context.workspace, + currentProject: context.project, + organizationRole: context.organizationMembership.role_key, + workspaceRole: context.workspaceMembership?.role_key || (context.orgElevated ? "org_admin" : null), + projectRole: context.projectMembership?.role_key || null, + roles: context.roles, + permissions: context.permissions, + systemAdmin: context.systemAdmin + }, + platform: { + organizations: organizationRows, + workspaces, + projects, + members, + workspaceMembers: workspaceMemberRows, + projectMembers: projectMemberRows, + invitations, + billing, + usage, + auditLog, + modelRegistry: canViewModels ? modelRows : [], + adapterCatalog: canUseGenerationAdapters ? adapterCatalog : [], + rolePolicies: rolePolicyCatalog, + organization: context.organization, + workspace: context.workspace + }, + roles: roleRows, + permissions: permissionRows, + rolePermissions: rolePermissionRows + }; +} + +export function addAudit({ context, action, targetType, targetId, result = "ok", metadata = {} }) { + dbRun( + "INSERT INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [`aud-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, context.organization.id, context.workspace?.id || null, context.project?.id || null, context.user.id, action, targetType, targetId, result, JSON.stringify(metadata), new Date().toISOString()] + ); +} + +export function addUsage({ context, kind, units = 1, unitName = "event", estimatedCost = 0, metadata = {} }) { + const timestamp = new Date().toISOString(); + dbRun( + "INSERT INTO usage_events(id, organization_id, workspace_id, project_id, user_id, kind, units, unit_name, estimated_cost, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [`usage-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, context.organization.id, context.workspace?.id || null, context.project?.id || null, context.user.id, kind, units, unitName, estimatedCost, JSON.stringify(metadata), timestamp] + ); + if (["job", "clip", "clips"].includes(String(unitName).toLowerCase())) { + const row = quotaRow(context, "clip"); + if (row) dbRun("UPDATE quota_allocations SET used_value = used_value + ?, updated_at = ? WHERE id = ?", [Number(units || 0), timestamp, row.id]); + } +} + +function requireSystemAdmin(context) { + if (!context?.systemAdmin) throw httpError(403, "system_admin_required", "只有系统管理员可以访问全局用户目录"); +} + +function systemUserOrganizations(userId) { + return dbAll( + `SELECT o.id, o.name, o.slug, om.role_key, r.name AS role_name, om.status, om.joined_at + FROM organization_members om + JOIN organizations o ON o.id = om.organization_id + LEFT JOIN roles r ON r.key = om.role_key + WHERE om.user_id = ? + ORDER BY CASE om.status WHEN 'active' THEN 0 ELSE 1 END, o.name`, + [userId] + ); +} + +function systemUserWorkspaces(userId) { + return dbAll( + `SELECT w.id, w.name, w.slug, w.organization_id, o.name AS organization_name, wm.role_key, r.name AS role_name, wm.status + FROM workspace_members wm + JOIN workspaces w ON w.id = wm.workspace_id + JOIN organizations o ON o.id = w.organization_id + LEFT JOIN roles r ON r.key = wm.role_key + WHERE wm.user_id = ? + ORDER BY CASE wm.status WHEN 'active' THEN 0 ELSE 1 END, o.name, w.name`, + [userId] + ); +} + +function systemUserProjects(userId) { + return dbAll( + `SELECT p.id, p.name, p.workspace_id, w.name AS workspace_name, o.name AS organization_name, pm.role_key, r.name AS role_name, pm.status + FROM project_members pm + JOIN projects p ON p.id = pm.project_id + JOIN workspaces w ON w.id = p.workspace_id + JOIN organizations o ON o.id = w.organization_id + LEFT JOIN roles r ON r.key = pm.role_key + WHERE pm.user_id = ? + ORDER BY CASE pm.status WHEN 'active' THEN 0 ELSE 1 END, o.name, w.name, p.name`, + [userId] + ); +} + +function systemUserRow(row) { + const organizations = systemUserOrganizations(row.id); + const workspaces = systemUserWorkspaces(row.id); + const projects = systemUserProjects(row.id); + return { + id: row.id, + displayName: row.display_name, + email: row.email, + avatarColor: row.avatar_color, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastLoginAt: row.last_login_at || null, + mfaEnabled: Boolean(row.mfa_enabled), + activeSessionCount: Number(row.active_session_count || 0), + lastSessionSeenAt: row.last_session_seen_at || null, + systemAdmin: row.system_admin_status === "active", + systemAdminStatus: row.system_admin_status || null, + systemRoleKey: row.system_role_key || null, + organizationCount: organizations.length, + workspaceCount: workspaces.length, + projectCount: projects.length, + organizations, + workspaces, + projects + }; +} + +function systemUserSelect() { + return `SELECT u.*, sa.role_key AS system_role_key, sa.status AS system_admin_status, + c.last_login_at, + COALESCE(m.enabled, 0) AS mfa_enabled, + (SELECT COUNT(*) FROM auth_sessions s WHERE s.user_id = u.id AND s.revoked_at IS NULL AND s.expires_at > datetime('now')) AS active_session_count, + (SELECT MAX(s.last_seen_at) FROM auth_sessions s WHERE s.user_id = u.id AND s.revoked_at IS NULL) AS last_session_seen_at + FROM users u + LEFT JOIN system_admins sa ON sa.user_id = u.id + LEFT JOIN user_credentials c ON c.user_id = u.id + LEFT JOIN user_mfa_methods m ON m.user_id = u.id`; +} + +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", `无效的${scope}角色`, { roleKey, scope }); + return role; +} + +function applySystemMemberships(userId, body = {}, actorContext) { + const timestamp = new Date().toISOString(); + const organizations = Array.isArray(body.organizationMemberships) + ? body.organizationMemberships + : body.organizationId + ? [{ organizationId: body.organizationId, roleKey: body.organizationRoleKey || "org_member", status: body.organizationStatus || "active" }] + : []; + const workspaces = Array.isArray(body.workspaceMemberships) + ? body.workspaceMemberships + : body.workspaceId + ? [{ workspaceId: body.workspaceId, roleKey: body.workspaceRoleKey || "writer", status: body.workspaceStatus || "active" }] + : []; + const projects = Array.isArray(body.projectMemberships) + ? body.projectMemberships + : body.projectId + ? [{ projectId: body.projectId, roleKey: body.projectRoleKey || "project_editor", status: body.projectStatus || "active" }] + : []; + + for (const assignment of organizations) { + const organizationId = String(assignment.organizationId || "").trim(); + const organization = dbGet("SELECT id FROM organizations WHERE id = ? AND status = 'active'", [organizationId]); + if (!organization) throw httpError(404, "organization_not_found", "目标组织不存在或已停用", { organizationId }); + roleForScope(String(assignment.roleKey || "org_member"), "organization"); + const existing = dbGet("SELECT status FROM organization_members WHERE organization_id = ? AND user_id = ?", [organizationId, userId]); + if (!existing || existing.status !== "active") assertOrganizationSeatAvailable(organizationId, { userId }); + const status = ["active", "suspended"].includes(assignment.status) ? assignment.status : "active"; + dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, joined_at = CASE WHEN excluded.status = 'active' THEN excluded.joined_at ELSE organization_members.joined_at END, updated_at = excluded.updated_at", [`om-${organizationId}-${userId}`, organizationId, userId, assignment.roleKey || "org_member", status, status === "active" ? timestamp : null, timestamp, timestamp]); + } + for (const assignment of workspaces) { + const workspaceId = String(assignment.workspaceId || "").trim(); + const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ? AND status = 'active'", [workspaceId]); + if (!workspace) throw httpError(404, "workspace_not_found", "目标工作区不存在或已停用", { workspaceId }); + roleForScope(String(assignment.roleKey || "writer"), "workspace"); + if (!dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [workspace.organization_id, userId])) { + throw httpError(409, "organization_membership_required", "加入工作区前必须先加入所属组织", { workspaceId, organizationId: workspace.organization_id }); + } + dbRun("INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, updated_at = excluded.updated_at", [`wm-${workspaceId}-${userId}`, workspaceId, userId, assignment.roleKey || "writer", ["active", "suspended"].includes(assignment.status) ? assignment.status : "active", timestamp, timestamp]); + } + for (const assignment of projects) { + const projectId = String(assignment.projectId || "").trim(); + const project = dbGet("SELECT id, workspace_id FROM projects WHERE id = ?", [projectId]); + if (!project) throw httpError(404, "project_not_found", "目标项目不存在", { projectId }); + roleForScope(String(assignment.roleKey || "project_editor"), "project"); + const workspace = dbGet("SELECT organization_id FROM workspaces WHERE id = ?", [project.workspace_id]); + if (!dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [workspace.organization_id, userId])) throw httpError(409, "organization_membership_required", "加入项目之前必须先加入所属组织", { projectId }); + if (!dbGet("SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ? AND status = 'active'", [project.workspace_id, userId])) throw httpError(409, "workspace_membership_required", "加入项目之前必须先加入所属工作区", { projectId, workspaceId: project.workspace_id }); + dbRun("INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, updated_at = excluded.updated_at", [`pm-${projectId}-${userId}`, projectId, userId, assignment.roleKey || "project_editor", ["active", "suspended"].includes(assignment.status) ? assignment.status : "active", timestamp, timestamp]); + } + if (organizations.length || workspaces.length || projects.length) addAudit({ context: actorContext, action: "system.user.memberships.updated", targetType: "user", targetId: userId, metadata: { organizations, workspaces, projects } }); + return { organizations, workspaces, projects }; +} + +function setSystemAdminStatus(context, userId, enabled) { + const current = dbGet("SELECT status FROM system_admins WHERE user_id = ?", [userId]); + if (!enabled) { + const activeCount = Number(dbGet("SELECT COUNT(*) AS count FROM system_admins WHERE status = 'active'")?.count || 0); + if (current?.status === "active" && activeCount <= 1) throw httpError(409, "last_system_admin", "不能移除最后一个系统管理员"); + if (current) dbRun("UPDATE system_admins SET status = 'suspended', updated_at = ? WHERE user_id = ?", [new Date().toISOString(), userId]); + } else { + const timestamp = new Date().toISOString(); + dbRun("INSERT INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES (?, 'system_admin', 'active', ?, ?) ON CONFLICT(user_id) DO UPDATE SET status = 'active', updated_at = excluded.updated_at", [userId, timestamp, timestamp]); + } +} + +export function systemUsers(context, options = {}) { + requireSystemAdmin(context); + const query = String(options.query || "").trim().toLowerCase(); + const status = String(options.status || "").trim(); + const limit = Math.min(250, Math.max(1, Number(options.limit || 100))); + const clauses = []; + const params = []; + if (query) { + clauses.push("(lower(u.display_name) LIKE ? OR lower(u.email) LIKE ? OR lower(u.id) LIKE ?)"); + params.push(`%${query}%`, `%${query}%`, `%${query}%`); + } + if (["active", "suspended", "invited"].includes(status)) { + clauses.push("u.status = ?"); + params.push(status); + } + const rows = dbAll(`${systemUserSelect()} ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY CASE u.status WHEN 'active' THEN 0 WHEN 'invited' THEN 1 ELSE 2 END, u.display_name LIMIT ?`, [...params, limit]).map(systemUserRow); + const allUsers = dbAll("SELECT status FROM users"); + const allAdmins = dbAll("SELECT status FROM system_admins"); + return { + users: rows, + filters: { query, status, limit }, + summary: { + total: allUsers.length, + active: allUsers.filter((user) => user.status === "active").length, + suspended: allUsers.filter((user) => user.status === "suspended").length, + invited: allUsers.filter((user) => user.status === "invited").length, + systemAdmins: allAdmins.filter((admin) => admin.status === "active").length, + activeSessions: Number(dbGet("SELECT COUNT(*) AS count FROM auth_sessions WHERE revoked_at IS NULL AND expires_at > datetime('now')")?.count || 0) + } + }; +} + +export function systemUserDetail(context, userId) { + requireSystemAdmin(context); + const row = dbGet(`${systemUserSelect()} WHERE u.id = ?`, [userId]); + if (!row) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); + const user = systemUserRow(row); + return { + user, + devices: listUserDevices(userId), + sessions: listUserSessions(userId, null), + securityEvents: listSecurityEvents(userId, { limit: 40 }), + recentAudit: dbAll( + `SELECT a.id, a.action, a.target_type, a.target_id, a.result, a.metadata_json, a.created_at, u.display_name AS actor_name + FROM audit_logs a + LEFT JOIN users u ON u.id = a.actor_user_id + WHERE a.target_type = 'user' AND a.target_id = ? + ORDER BY a.created_at DESC LIMIT 30`, + [userId] + ).map((audit) => ({ ...audit, metadata: parseJson(audit.metadata_json, {}) })) + }; +} + +export function updateSystemUser(context, userId, body = {}) { + requireSystemAdmin(context); + const current = dbGet(`${systemUserSelect()} WHERE u.id = ?`, [userId]); + if (!current) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); + const nextStatus = body.status === undefined ? current.status : String(body.status || "").trim(); + if (!['active', 'suspended'].includes(nextStatus)) throw httpError(400, "system_user_status_invalid", "全局用户状态只能是 active 或 suspended"); + const nextDisplayName = body.displayName === undefined ? current.display_name : String(body.displayName || "").trim(); + if (!nextDisplayName) throw httpError(400, "display_name_required", "显示名称不能为空"); + if (userId === context.user.id && nextStatus === "suspended") throw httpError(400, "cannot_suspend_self", "不能停用当前登录的系统管理员账号"); + const targetIsSystemAdmin = current.system_admin_status === "active"; + if (targetIsSystemAdmin && nextStatus === "suspended") { + const activeAdminCount = Number(dbGet("SELECT COUNT(*) AS count FROM system_admins WHERE status = 'active'")?.count || 0); + if (activeAdminCount <= 1) throw httpError(409, "last_system_admin", "不能停用最后一个系统管理员,请先指定其他系统管理员"); + } + if (nextStatus === "suspended") { + const ownerOrganizations = dbAll( + `SELECT o.id, o.name + FROM organizations o + JOIN organization_members om ON om.organization_id = o.id AND om.user_id = ? AND om.role_key = 'org_owner' AND om.status = 'active' + WHERE o.status = 'active' AND (SELECT COUNT(*) FROM organization_members other WHERE other.organization_id = o.id AND other.role_key = 'org_owner' AND other.status = 'active') <= 1`, + [userId] + ); + if (ownerOrganizations.length) throw httpError(409, "sole_organization_owner", "该用户是组织唯一所有者,请先转移组织所有权", { organizations: ownerOrganizations }); + } + const timestamp = new Date().toISOString(); + let revokedCount = 0; + withTransaction(() => { + dbRun("UPDATE users SET display_name = ?, status = ?, updated_at = ? WHERE id = ?", [nextDisplayName, nextStatus, timestamp, userId]); + if (nextStatus === "suspended" && current.status !== "suspended") revokedCount = revokeAllUserSessions(userId, { reason: "system_user_suspended", actorUserId: context.user.id }); + if (typeof body.systemAdmin === "boolean") setSystemAdminStatus(context, userId, body.systemAdmin); + }); + addAudit({ + context, + action: nextStatus === "suspended" ? "system.user.suspended" : current.status === "suspended" ? "system.user.reactivated" : "system.user.updated", + targetType: "user", + targetId: userId, + result: "ok", + metadata: { + previous: { displayName: current.display_name, status: current.status }, + next: { displayName: nextDisplayName, status: nextStatus, systemAdmin: typeof body.systemAdmin === "boolean" ? body.systemAdmin : current.system_admin_status === "active" }, + revokedSessionCount: revokedCount + } + }); + return { ...systemUserDetail(context, userId), revokedSessionCount: revokedCount }; +} + +export function createSystemUser(context, body = {}) { + requireSystemAdmin(context); + const email = String(body.email || "").trim().toLowerCase(); + const displayName = String(body.displayName || "").trim(); + if (!/^\S+@\S+\.\S+$/.test(email)) throw httpError(400, "email_invalid", "请输入有效邮箱"); + if (!displayName) throw httpError(400, "display_name_required", "显示名称不能为空"); + if (dbGet("SELECT id FROM users WHERE lower(email) = ?", [email])) throw httpError(409, "email_exists", "该邮箱已经存在"); + const password = String(body.password || `Temp-${randomBytes(12).toString("base64url")}`); + if (password.length < 12) throw httpError(400, "password_too_short", "初始密码至少需要 12 个字符"); + const userId = String(body.id || `u-${Date.now()}-${randomBytes(4).toString("hex")}`).replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80); + const timestamp = new Date().toISOString(); + const record = createPasswordRecord(password); + const status = body.status === "suspended" ? "suspended" : "active"; + withTransaction(() => { + dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [userId, displayName, email, body.avatarColor || "#477d69", status, timestamp, timestamp]); + dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [userId, record.salt, record.hash, timestamp, timestamp]); + if (body.systemAdmin) setSystemAdminStatus(context, userId, true); + applySystemMemberships(userId, body, context); + }); + addAudit({ context, action: "system.user.created", targetType: "user", targetId: userId, metadata: { email, displayName, status, systemAdmin: Boolean(body.systemAdmin), temporaryPasswordIssued: !body.password } }); + return { ...(systemUserDetail(context, userId)), temporaryPassword: body.password ? null : password }; +} + +export function resetSystemUserPassword(context, userId, body = {}, metadata = {}) { + requireSystemAdmin(context); + const user = dbGet("SELECT id, email FROM users WHERE id = ?", [userId]); + if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); + const temporaryPassword = String(body.password || `Temp-${randomBytes(12).toString("base64url")}`); + const result = resetPassword(userId, temporaryPassword, { ...metadata, actorUserId: context.user.id, reason: "system_admin_reset" }); + addAudit({ context, action: "system.user.password_reset", targetType: "user", targetId: userId, metadata: { email: user.email, revokedSessionCount: result.revokedSessionCount, temporaryPasswordIssued: !body.password } }); + return { ...systemUserDetail(context, userId), temporaryPassword: body.password ? null : temporaryPassword, revokedSessionCount: result.revokedSessionCount }; +} + +export function resetSystemUserMfa(context, userId, metadata = {}) { + requireSystemAdmin(context); + const user = dbGet("SELECT id, email FROM users WHERE id = ?", [userId]); + if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); + const result = resetMfa(userId, { ...metadata, actorUserId: context.user.id, reason: "system_admin_reset" }); + addAudit({ context, action: "system.user.mfa_reset", targetType: "user", targetId: userId, metadata: { email: user.email, revokedSessionCount: result.revokedSessionCount } }); + return { ...systemUserDetail(context, userId), revokedSessionCount: result.revokedSessionCount }; +} + +export function updateSystemUserMemberships(context, userId, body = {}) { + requireSystemAdmin(context); + const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]); + if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); + withTransaction(() => applySystemMemberships(userId, body, context)); + addAudit({ context, action: "system.user.memberships.updated", targetType: "user", targetId: userId, metadata: { requested: body } }); + return systemUserDetail(context, userId); +} + +export function parseModelRow(model) { + return { + ...model, + capability: parseJson(model.capabilities_json, []), + protocol: parseJson(model.protocol_json, {}), + approvalRequired: Boolean(model.approval_required), + costMode: model.cost_mode + }; +} + +export function systemSettings() { + return dbAll("SELECT key, category, value_json, value_type, description, is_sensitive, updated_by, created_at, updated_at FROM system_settings ORDER BY category, key").map((row) => ({ + ...row, + value: parseJson(row.value_json, null), + is_sensitive: Boolean(row.is_sensitive) + })); +} + +export function featureFlags() { + return dbAll("SELECT key, label, description, enabled, scope, updated_by, created_at, updated_at FROM feature_flags ORDER BY key").map((row) => ({ + ...row, + enabled: Boolean(row.enabled) + })); +} + +export function notificationChannels() { + return dbAll("SELECT id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at FROM notification_channels ORDER BY created_at ASC").map((row) => ({ + ...row, + enabled: Boolean(row.enabled), + events: parseJson(row.events_json, []) + })); +} + +export function apiClients() { + return dbAll("SELECT id, name, client_key_prefix, key_version, organization_id, workspace_id, status, scopes_json, last_used_at, created_by, created_at, updated_at FROM api_clients ORDER BY created_at DESC").map((row) => ({ + ...row, + client_key: row.client_key_prefix ? `${row.client_key_prefix}****` : "仅创建或轮换时显示一次", + client_key_preview: row.client_key_prefix ? `${row.client_key_prefix}****` : "仅创建或轮换时显示一次", + scopes: parseJson(row.scopes_json, []) + })); +} + +function identityTokenHash(token) { + return createHash("sha256").update(String(token || "")).digest("hex"); +} + +function safeUrl(value, label, { allowEmpty = true } = {}) { + const input = String(value || "").trim(); + if (!input && allowEmpty) return ""; + try { + const url = new URL(input); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error("protocol"); + return url.toString().replace(/\/$/, ""); + } catch { + throw httpError(400, "identity_url_invalid", `${label}必须是 HTTP(S) 地址`); + } +} + +function safeEntityId(value, label, { allowEmpty = true } = {}) { + const input = String(value || "").trim(); + if (!input && allowEmpty) return ""; + if (!input || /[\r\n\s]/.test(input) || !/^(https?:\/\/|urn:)/i.test(input)) { + throw httpError(400, "identity_entity_id_invalid", `${label}必须是 HTTP(S) 或 URN 标识`); + } + return input; +} + +function identityPolicyPayload(row) { + const current = row || dbGet("SELECT * FROM identity_policies WHERE id = 'default'"); + return { + id: current?.id || "default", + passwordLoginEnabled: Boolean(current?.password_login_enabled), + mfaRequiredForAdmins: Boolean(current?.mfa_required_for_admins), + mfaRequiredForAll: Boolean(current?.mfa_required_for_all), + ssoEnabled: Boolean(current?.sso_enabled), + localLoginFallback: Boolean(current?.local_login_fallback), + sessionTtlHours: Number(current?.session_ttl_hours || 12), + maxSessionsPerUser: Number(current?.max_sessions_per_user || 10), + updatedBy: current?.updated_by || null, + updatedAt: current?.updated_at || null + }; +} + +function identityProviderPayload(row) { + return { + id: row.id, + name: row.name, + kind: row.kind, + organizationId: row.organization_id || null, + workspaceId: row.workspace_id || null, + issuerUrl: row.issuer_url, + authorizationUrl: row.authorization_url, + tokenUrl: row.token_url, + userinfoUrl: row.userinfo_url, + jwksUrl: row.jwks_url || "", + entryPoint: row.entry_point || "", + idpCertRef: row.idp_cert_ref || "", + spIssuer: row.sp_issuer || "", + audience: row.audience || "", + samlNameIdFormat: row.saml_name_id_format || "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + wantAssertionsSigned: row.want_assertions_signed !== 0, + wantAuthnResponseSigned: row.want_authn_response_signed !== 0, + validateInResponseTo: row.validate_in_response_to || "ifPresent", + clientId: row.client_id, + clientSecretRef: row.client_secret_ref, + scopes: parseJson(row.scopes_json, ["openid", "profile", "email"]), + claimMapping: parseJson(row.claim_mapping_json, { email: "email", displayName: "name", externalId: "sub" }), + autoProvision: row.auto_provision !== 0, + defaultRoleKey: row.default_role_key || "org_member", + defaultWorkspaceRoleKey: row.default_workspace_role_key || "writer", + enabled: Boolean(row.enabled), + status: row.status, + lastProbeAt: row.last_probe_at, + errorMessage: row.error_message || "", + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function directorySyncPayload(row) { + return { + id: row.id, + name: row.name, + kind: row.kind, + organizationId: row.organization_id, + organizationName: row.organization_name || null, + providerId: row.provider_id, + providerName: row.provider_name || null, + endpoint: row.endpoint, + endpointPath: `/scim/v2.0/${encodeURIComponent(row.id)}`, + tokenHint: row.token_hint, + enabled: Boolean(row.enabled), + syncMode: row.sync_mode, + schedule: row.schedule, + lastSyncAt: row.last_sync_at, + lastStatus: row.last_status, + lastSyncedCount: Number(row.last_synced_count || 0), + errorMessage: row.error_message || "", + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +export function identityCenter() { + const providers = dbAll("SELECT * FROM identity_providers ORDER BY created_at ASC").map(identityProviderPayload); + const directorySyncs = dbAll( + `SELECT d.*, o.name AS organization_name, p.name AS provider_name + FROM directory_syncs d + LEFT JOIN organizations o ON o.id = d.organization_id + LEFT JOIN identity_providers p ON p.id = d.provider_id + ORDER BY d.created_at ASC` + ).map(directorySyncPayload); + return { + policy: identityPolicyPayload(), + providers, + directorySyncs, + organizationOptions: dbAll("SELECT id, name FROM organizations WHERE status = 'active' ORDER BY name"), + workspaceOptions: dbAll("SELECT id, organization_id AS organizationId, name FROM workspaces WHERE status = 'active' ORDER BY name"), + summary: { + enabledProviders: providers.filter((item) => item.enabled).length, + readyProviders: providers.filter((item) => item.status === "ready").length, + enabledDirectorySyncs: directorySyncs.filter((item) => item.enabled).length, + managedOrganizations: new Set(directorySyncs.map((item) => item.organizationId).filter(Boolean)).size + } + }; +} + +export function publicIdentityProviders() { + return dbAll("SELECT id, name, kind, organization_id, enabled, status FROM identity_providers WHERE enabled = 1 AND status IN ('configured', 'ready') ORDER BY name").map((row) => ({ + id: row.id, + name: row.name, + kind: row.kind, + organizationId: row.organization_id || null, + enabled: Boolean(row.enabled), + status: row.status + })); +} + +export function updateIdentityPolicy(context, body = {}) { + requirePermission(context, "system:settings:edit"); + const current = dbGet("SELECT * FROM identity_policies WHERE id = 'default'"); + if (!current) throw httpError(500, "identity_policy_missing", "身份策略尚未初始化"); + const next = { + passwordLoginEnabled: body.passwordLoginEnabled === undefined ? Boolean(current.password_login_enabled) : Boolean(body.passwordLoginEnabled), + mfaRequiredForAdmins: body.mfaRequiredForAdmins === undefined ? Boolean(current.mfa_required_for_admins) : Boolean(body.mfaRequiredForAdmins), + mfaRequiredForAll: body.mfaRequiredForAll === undefined ? Boolean(current.mfa_required_for_all) : Boolean(body.mfaRequiredForAll), + ssoEnabled: body.ssoEnabled === undefined ? Boolean(current.sso_enabled) : Boolean(body.ssoEnabled), + localLoginFallback: body.localLoginFallback === undefined ? Boolean(current.local_login_fallback) : Boolean(body.localLoginFallback), + sessionTtlHours: body.sessionTtlHours === undefined ? Number(current.session_ttl_hours) : Number(body.sessionTtlHours), + maxSessionsPerUser: body.maxSessionsPerUser === undefined ? Number(current.max_sessions_per_user) : Number(body.maxSessionsPerUser) + }; + if (!next.passwordLoginEnabled && !next.ssoEnabled) throw httpError(400, "identity_login_method_required", "至少保留一种登录方式"); + if (next.ssoEnabled && !dbGet("SELECT id FROM identity_providers WHERE enabled = 1 AND status IN ('configured', 'ready')")) throw httpError(400, "identity_sso_provider_required", "启用 SSO 前请先配置一个可用的身份提供商"); + if (!Number.isInteger(next.sessionTtlHours) || next.sessionTtlHours < 1 || next.sessionTtlHours > 168) throw httpError(400, "identity_session_ttl_invalid", "会话时长必须是 1 到 168 小时"); + if (!Number.isInteger(next.maxSessionsPerUser) || next.maxSessionsPerUser < 1 || next.maxSessionsPerUser > 50) throw httpError(400, "identity_session_limit_invalid", "单用户会话数必须是 1 到 50"); + const timestamp = new Date().toISOString(); + dbRun("UPDATE identity_policies SET password_login_enabled = ?, mfa_required_for_admins = ?, mfa_required_for_all = ?, sso_enabled = ?, local_login_fallback = ?, session_ttl_hours = ?, max_sessions_per_user = ?, updated_by = ?, updated_at = ? WHERE id = 'default'", [next.passwordLoginEnabled ? 1 : 0, next.mfaRequiredForAdmins ? 1 : 0, next.mfaRequiredForAll ? 1 : 0, next.ssoEnabled ? 1 : 0, next.localLoginFallback ? 1 : 0, next.sessionTtlHours, next.maxSessionsPerUser, context.user.id, timestamp]); + addAudit({ context, action: "system.identity.policy.updated", targetType: "identity_policy", targetId: "default", metadata: { previous: identityPolicyPayload(current), value: next } }); + return identityCenter(); +} + +export function saveIdentityProvider(context, body = {}, providerId = null) { + requirePermission(context, "system:settings:edit"); + const existing = providerId ? dbGet("SELECT * FROM identity_providers WHERE id = ?", [providerId]) : null; + if (providerId && !existing) throw httpError(404, "identity_provider_not_found", "身份提供商不存在"); + const name = String(body.name ?? existing?.name ?? "").trim(); + const kind = String(body.kind ?? existing?.kind ?? "oidc").trim().toLowerCase(); + if (!name) throw httpError(400, "identity_provider_name_required", "身份提供商名称不能为空"); + if (!['oidc', 'saml'].includes(kind)) throw httpError(400, "identity_provider_kind_invalid", "只支持 OIDC 或 SAML 配置"); + const issuerUrl = safeUrl(body.issuerUrl ?? existing?.issuer_url, "Issuer URL"); + const authorizationUrl = safeUrl(body.authorizationUrl ?? existing?.authorization_url, "Authorization URL"); + const tokenUrl = safeUrl(body.tokenUrl ?? existing?.token_url, "Token URL"); + const userinfoUrl = safeUrl(body.userinfoUrl ?? existing?.userinfo_url, "UserInfo URL"); + const jwksUrl = safeUrl(body.jwksUrl ?? existing?.jwks_url, "JWKS URL"); + const entryPoint = safeUrl(body.entryPoint ?? existing?.entry_point, "SAML Entry Point"); + const idpCertRef = String(body.idpCertRef ?? existing?.idp_cert_ref ?? "").trim(); + const spIssuer = safeEntityId(body.spIssuer ?? existing?.sp_issuer, "SAML SP Issuer"); + const audience = safeEntityId(body.audience ?? existing?.audience, "SAML Audience"); + const samlNameIdFormat = safeEntityId(body.samlNameIdFormat ?? existing?.saml_name_id_format ?? "", "SAML NameID Format"); + const wantAssertionsSigned = body.wantAssertionsSigned === undefined ? existing?.want_assertions_signed !== 0 : Boolean(body.wantAssertionsSigned); + const wantAuthnResponseSigned = body.wantAuthnResponseSigned === undefined ? existing?.want_authn_response_signed !== 0 : Boolean(body.wantAuthnResponseSigned); + const validateInResponseTo = String(body.validateInResponseTo ?? existing?.validate_in_response_to ?? "ifPresent").trim(); + if (!["never", "ifPresent", "always"].includes(validateInResponseTo)) throw httpError(400, "identity_provider_validate_in_response_to_invalid", "SAML InResponseTo 校验策略必须是 never、ifPresent 或 always"); + const clientId = String(body.clientId ?? existing?.client_id ?? "").trim(); + const clientSecretRef = String(body.clientSecretRef ?? existing?.client_secret_ref ?? "").trim(); + const organizationId = body.organizationId === undefined ? (existing?.organization_id || null) : (body.organizationId ? String(body.organizationId).trim() : null); + if (organizationId && !dbGet("SELECT id FROM organizations WHERE id = ? AND status = 'active'", [organizationId])) throw httpError(404, "identity_provider_organization_not_found", "身份提供商归属组织不存在或已停用"); + const workspaceId = body.workspaceId === undefined ? (existing?.workspace_id || null) : (body.workspaceId ? String(body.workspaceId).trim() : null); + if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND status = 'active' AND (? IS NULL OR organization_id = ?)", [workspaceId, organizationId, organizationId])) throw httpError(404, "identity_provider_workspace_not_found", "身份提供商默认工作区不存在、已停用或不属于绑定组织"); + const scopes = Array.isArray(body.scopes) ? body.scopes.map((item) => String(item).trim()).filter(Boolean) : parseJson(existing?.scopes_json || "[]", ["openid", "profile", "email"]); + const claimMapping = body.claimMapping && typeof body.claimMapping === "object" ? body.claimMapping : parseJson(existing?.claim_mapping_json || "{}", { email: "email", displayName: "name", externalId: "sub" }); + const autoProvision = body.autoProvision === undefined ? existing?.auto_provision !== 0 : Boolean(body.autoProvision); + const defaultRoleKey = String(body.defaultRoleKey ?? existing?.default_role_key ?? "org_member").trim() || "org_member"; + if (!dbGet("SELECT key FROM roles WHERE key = ? AND scope = 'organization'", [defaultRoleKey])) throw httpError(400, "identity_provider_role_invalid", "SSO 默认组织角色无效"); + const defaultWorkspaceRoleKey = String(body.defaultWorkspaceRoleKey ?? existing?.default_workspace_role_key ?? "writer").trim() || "writer"; + if (!dbGet("SELECT key FROM roles WHERE key = ? AND scope = 'workspace'", [defaultWorkspaceRoleKey])) throw httpError(400, "identity_provider_workspace_role_invalid", "SSO 默认工作区角色无效"); + const enabled = body.enabled === undefined ? Boolean(existing?.enabled) : Boolean(body.enabled); + if (enabled && kind === "oidc" && (!issuerUrl || !clientId || !clientSecretRef)) throw httpError(400, "identity_provider_incomplete", "启用 OIDC 提供商前需要 Issuer、Client ID 和密钥环境变量名"); + if (enabled && kind === "saml" && (!entryPoint || !idpCertRef || !spIssuer)) throw httpError(400, "identity_provider_incomplete", "启用 SAML 提供商前需要 IdP Entry Point、证书环境变量名和 SP Issuer"); + const id = existing?.id || providerId || `idp-${Date.now()}-${randomBytes(4).toString("hex")}`; + const timestamp = new Date().toISOString(); + const status = enabled ? (existing?.status === "ready" && existing?.kind === kind ? existing.status : "configured") : "disabled"; + if (existing) { + dbRun("UPDATE identity_providers SET name = ?, kind = ?, organization_id = ?, workspace_id = ?, issuer_url = ?, authorization_url = ?, token_url = ?, userinfo_url = ?, jwks_url = ?, entry_point = ?, idp_cert_ref = ?, sp_issuer = ?, audience = ?, saml_name_id_format = ?, want_assertions_signed = ?, want_authn_response_signed = ?, validate_in_response_to = ?, client_id = ?, client_secret_ref = ?, scopes_json = ?, claim_mapping_json = ?, auto_provision = ?, default_role_key = ?, default_workspace_role_key = ?, enabled = ?, status = ?, error_message = '', updated_at = ? WHERE id = ?", [name, kind, organizationId, workspaceId, issuerUrl, authorizationUrl, tokenUrl, userinfoUrl, jwksUrl, entryPoint, idpCertRef, spIssuer, audience, samlNameIdFormat, wantAssertionsSigned ? 1 : 0, wantAuthnResponseSigned ? 1 : 0, validateInResponseTo, clientId, clientSecretRef, JSON.stringify(scopes), JSON.stringify(claimMapping), autoProvision ? 1 : 0, defaultRoleKey, defaultWorkspaceRoleKey, enabled ? 1 : 0, status, timestamp, id]); + } else { + const identityProviderPlaceholders = Array(30).fill("?").join(", "); + dbRun(`INSERT INTO identity_providers(id, name, kind, organization_id, workspace_id, issuer_url, authorization_url, token_url, userinfo_url, jwks_url, entry_point, idp_cert_ref, sp_issuer, audience, saml_name_id_format, want_assertions_signed, want_authn_response_signed, validate_in_response_to, client_id, client_secret_ref, scopes_json, claim_mapping_json, auto_provision, default_role_key, default_workspace_role_key, enabled, status, created_by, created_at, updated_at) VALUES (${identityProviderPlaceholders})`, [id, name, kind, organizationId, workspaceId, issuerUrl, authorizationUrl, tokenUrl, userinfoUrl, jwksUrl, entryPoint, idpCertRef, spIssuer, audience, samlNameIdFormat, wantAssertionsSigned ? 1 : 0, wantAuthnResponseSigned ? 1 : 0, validateInResponseTo, clientId, clientSecretRef, JSON.stringify(scopes), JSON.stringify(claimMapping), autoProvision ? 1 : 0, defaultRoleKey, defaultWorkspaceRoleKey, enabled ? 1 : 0, status, context.user.id, timestamp, timestamp]); + } + addAudit({ context, action: existing ? "system.identity.provider.updated" : "system.identity.provider.created", targetType: "identity_provider", targetId: id, metadata: { name, kind, enabled } }); + return identityCenter(); +} + +export async function probeIdentityProvider(context, providerId) { + requirePermission(context, "system:settings:edit"); + const provider = dbGet("SELECT * FROM identity_providers WHERE id = ?", [providerId]); + if (!provider) throw httpError(404, "identity_provider_not_found", "身份提供商不存在"); + const timestamp = new Date().toISOString(); + if (provider.kind !== "oidc") { + const ref = String(provider.idp_cert_ref || "").trim(); + const missing = []; + if (!provider.entry_point) missing.push("Entry Point"); + if (!provider.sp_issuer) missing.push("SP Issuer"); + if (!ref || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) missing.push("证书环境变量名"); + if (ref && !process.env[ref]) missing.push(`环境变量 ${ref}`); + if (missing.length) { + const message = `SAML 配置不完整:缺少 ${missing.join("、")}`; + dbRun("UPDATE identity_providers SET status = 'error', error_message = ?, last_probe_at = ?, updated_at = ? WHERE id = ?", [message, timestamp, timestamp, providerId]); + addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, result: "error", metadata: { kind: provider.kind, status: "error", missing } }); + throw httpError(400, "identity_provider_probe_failed", message); + } + dbRun("UPDATE identity_providers SET status = 'ready', error_message = '', last_probe_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, providerId]); + addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, metadata: { kind: provider.kind, status: "ready" } }); + return { provider: identityCenter().providers.find((item) => item.id === providerId), discovery: { entryPoint: provider.entry_point, spIssuer: provider.sp_issuer, callbackPath: "/api/auth/sso/saml/acs" } }; + } + if (!provider.issuer_url) throw httpError(400, "identity_provider_issuer_required", "OIDC 提供商缺少 Issuer URL"); + const discoveryUrl = `${provider.issuer_url.replace(/\/$/, "")}/.well-known/openid-configuration`; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const response = await fetch(discoveryUrl, { headers: { accept: "application/json" }, signal: controller.signal }); + clearTimeout(timeout); + const discovery = await response.json().catch(() => ({})); + if (!response.ok || !discovery.authorization_endpoint || !discovery.token_endpoint) throw new Error(`OIDC discovery HTTP ${response.status}`); + dbRun("UPDATE identity_providers SET authorization_url = ?, token_url = ?, userinfo_url = ?, jwks_url = ?, status = 'ready', error_message = '', last_probe_at = ?, updated_at = ? WHERE id = ?", [discovery.authorization_endpoint, discovery.token_endpoint, discovery.userinfo_endpoint || provider.userinfo_url, discovery.jwks_uri || provider.jwks_url || '', timestamp, timestamp, providerId]); + addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, metadata: { kind: provider.kind, status: "ready", discoveryUrl } }); + return { provider: identityCenter().providers.find((item) => item.id === providerId), discovery: { issuer: discovery.issuer || provider.issuer_url, authorizationEndpoint: discovery.authorization_endpoint, tokenEndpoint: discovery.token_endpoint, userinfoEndpoint: discovery.userinfo_endpoint || "", jwksUri: discovery.jwks_uri || provider.jwks_url || "" } }; + } catch (error) { + dbRun("UPDATE identity_providers SET status = 'error', error_message = ?, last_probe_at = ?, updated_at = ? WHERE id = ?", [error.message, timestamp, timestamp, providerId]); + addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, result: "error", metadata: { error: error.message } }); + throw httpError(502, "identity_provider_probe_failed", `OIDC 发现文档检查失败:${error.message}`); + } +} + +export function createDirectorySync(context, body = {}) { + requirePermission(context, "system:settings:edit"); + const name = String(body.name || "企业目录").trim(); + if (!name) throw httpError(400, "directory_sync_name_required", "目录同步名称不能为空"); + const providerId = body.providerId ? String(body.providerId) : null; + if (providerId && !dbGet("SELECT id FROM identity_providers WHERE id = ?", [providerId])) throw httpError(404, "identity_provider_not_found", "关联的身份提供商不存在"); + const id = `dir-${Date.now()}-${randomBytes(4).toString("hex")}`; + const token = `scim_${randomBytes(24).toString("base64url")}`; + const timestamp = new Date().toISOString(); + const organizationId = context.organization.id; + dbRun("INSERT INTO directory_syncs(id, name, kind, organization_id, provider_id, endpoint, token_hint, enabled, sync_mode, schedule, created_by, created_at, updated_at) VALUES (?, ?, 'scim', ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)", [id, name, organizationId, providerId, String(body.endpoint || "").trim(), `${token.slice(0, 10)}****${token.slice(-4)}`, String(body.syncMode || "provision-and-deprovision"), String(body.schedule || "manual"), context.user.id, timestamp, timestamp]); + dbRun("INSERT INTO directory_sync_tokens(id, directory_sync_id, token_hash, token_hint, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)", [`dirtok-${Date.now()}-${randomBytes(4).toString("hex")}`, id, identityTokenHash(token), `${token.slice(0, 10)}****${token.slice(-4)}`, context.user.id, timestamp]); + addAudit({ context, action: "system.identity.directory.created", targetType: "directory_sync", targetId: id, metadata: { organizationId, providerId, syncMode: body.syncMode || "provision-and-deprovision" } }); + return { directorySync: identityCenter().directorySyncs.find((item) => item.id === id), token }; +} + +export function updateDirectorySync(context, directorySyncId, body = {}) { + requirePermission(context, "system:settings:edit"); + const current = dbGet("SELECT * FROM directory_syncs WHERE id = ?", [directorySyncId]); + if (!current) throw httpError(404, "directory_sync_not_found", "目录同步不存在"); + if (current.organization_id !== context.organization.id && !context.systemAdmin) throw httpError(403, "directory_sync_forbidden", "不能管理其他组织的目录同步"); + const enabled = body.enabled === undefined ? Boolean(current.enabled) : Boolean(body.enabled); + const name = String(body.name ?? current.name).trim(); + if (!name) throw httpError(400, "directory_sync_name_required", "目录同步名称不能为空"); + const timestamp = new Date().toISOString(); + dbRun("UPDATE directory_syncs SET name = ?, endpoint = ?, enabled = ?, sync_mode = ?, schedule = ?, updated_at = ? WHERE id = ?", [name, String(body.endpoint ?? current.endpoint).trim(), enabled ? 1 : 0, String(body.syncMode ?? current.sync_mode), String(body.schedule ?? current.schedule), timestamp, directorySyncId]); + addAudit({ context, action: "system.identity.directory.updated", targetType: "directory_sync", targetId: directorySyncId, metadata: { enabled, name } }); + return identityCenter(); +} + +export function rotateDirectorySyncToken(context, directorySyncId) { + requirePermission(context, "system:settings:edit"); + const current = dbGet("SELECT * FROM directory_syncs WHERE id = ?", [directorySyncId]); + if (!current) throw httpError(404, "directory_sync_not_found", "目录同步不存在"); + if (current.organization_id !== context.organization.id && !context.systemAdmin) throw httpError(403, "directory_sync_forbidden", "不能管理其他组织的目录同步"); + const token = `scim_${randomBytes(24).toString("base64url")}`; + const timestamp = new Date().toISOString(); + const hint = `${token.slice(0, 10)}****${token.slice(-4)}`; + dbRun("UPDATE directory_sync_tokens SET revoked_at = ? WHERE directory_sync_id = ? AND revoked_at IS NULL", [timestamp, directorySyncId]); + dbRun("INSERT INTO directory_sync_tokens(id, directory_sync_id, token_hash, token_hint, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)", [`dirtok-${Date.now()}-${randomBytes(4).toString("hex")}`, directorySyncId, identityTokenHash(token), hint, context.user.id, timestamp]); + dbRun("UPDATE directory_syncs SET token_hint = ?, updated_at = ? WHERE id = ?", [hint, timestamp, directorySyncId]); + addAudit({ context, action: "system.identity.directory.token_rotated", targetType: "directory_sync", targetId: directorySyncId, metadata: { tokenHint: hint } }); + return { directorySync: identityCenter().directorySyncs.find((item) => item.id === directorySyncId), token }; +} + +function directoryForScim(directorySyncId, token) { + const directory = dbGet("SELECT d.*, o.name AS organization_name FROM directory_syncs d LEFT JOIN organizations o ON o.id = d.organization_id WHERE d.id = ? AND d.enabled = 1", [directorySyncId]); + if (!directory) throw httpError(404, "scim_directory_not_found", "SCIM 目录不存在或未启用"); + const tokenRow = dbGet("SELECT id FROM directory_sync_tokens WHERE directory_sync_id = ? AND token_hash = ? AND revoked_at IS NULL", [directorySyncId, identityTokenHash(token)]); + if (!tokenRow) throw httpError(401, "scim_token_invalid", "SCIM 令牌无效"); + dbRun("UPDATE directory_sync_tokens SET last_used_at = ? WHERE id = ?", [new Date().toISOString(), tokenRow.id]); + return directory; +} + +function scimUserPayload(user) { + return { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + id: user.id, + userName: user.email, + displayName: user.display_name, + active: user.status === "active", + emails: [{ value: user.email, primary: true }], + meta: { resourceType: "User", created: user.created_at, lastModified: user.updated_at } + }; +} + +export function scimListUsers(directorySyncId, token, startIndex = 1, count = 100) { + const directory = directoryForScim(directorySyncId, token); + const rows = dbAll("SELECT u.* FROM users u JOIN organization_members om ON om.user_id = u.id WHERE om.organization_id = ? ORDER BY u.created_at LIMIT ? OFFSET ?", [directory.organization_id, Math.min(Math.max(Number(count) || 100, 1), 200), Math.max((Number(startIndex) || 1) - 1, 0)]); + const total = Number(dbGet("SELECT COUNT(*) AS count FROM users u JOIN organization_members om ON om.user_id = u.id WHERE om.organization_id = ?", [directory.organization_id])?.count || 0); + return { schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: total, startIndex: Number(startIndex) || 1, itemsPerPage: rows.length, Resources: rows.map(scimUserPayload) }; +} + +export function scimCreateUser(directorySyncId, token, body = {}) { + const directory = directoryForScim(directorySyncId, token); + const email = String(body.userName || body.emails?.[0]?.value || "").trim().toLowerCase(); + const displayName = String(body.displayName || body.name?.formatted || email.split("@")[0] || "企业用户").trim(); + if (!email || !email.includes("@")) throw httpError(400, "scim_email_invalid", "SCIM 用户必须提供有效邮箱"); + const timestamp = new Date().toISOString(); + const active = body.active !== false; + const existing = dbGet("SELECT * FROM users WHERE lower(email) = lower(?)", [email]); + const userId = existing?.id || `u-scim-${Date.now()}-${randomBytes(4).toString("hex")}`; + withTransaction(() => { + if (existing) dbRun("UPDATE users SET display_name = ?, status = ?, updated_at = ? WHERE id = ?", [displayName, active ? "active" : "suspended", timestamp, userId]); + else dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, '#477d69', ?, ?, ?)", [userId, displayName, email, active ? "active" : "suspended", timestamp, timestamp]); + dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, 'org_member', ?, ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at", [`om-${directory.organization_id}-${userId}`, directory.organization_id, userId, active ? "active" : "suspended", timestamp, timestamp, timestamp]); + }); + return { status: existing ? 200 : 201, user: scimUserPayload(dbGet("SELECT * FROM users WHERE id = ?", [userId])) }; +} + +export function scimPatchUser(directorySyncId, token, userId, body = {}) { + const directory = directoryForScim(directorySyncId, token); + const user = dbGet("SELECT u.* FROM users u JOIN organization_members om ON om.user_id = u.id WHERE u.id = ? AND om.organization_id = ?", [userId, directory.organization_id]); + if (!user) throw httpError(404, "scim_user_not_found", "SCIM 用户不存在"); + let displayName = user.display_name; + let email = user.email; + let active = user.status === "active"; + for (const operation of Array.isArray(body.Operations) ? body.Operations : []) { + const path = String(operation.path || "").toLowerCase(); + if (path === "active") active = Boolean(operation.value); + if (path === "displayname" || path === "name.formatted") displayName = String(operation.value || displayName); + if (path === "username" || path === "emails[type eq \"work\"].value") email = String(operation.value || email).toLowerCase(); + } + const timestamp = new Date().toISOString(); + dbRun("UPDATE users SET display_name = ?, email = ?, status = ?, updated_at = ? WHERE id = ?", [displayName, email, active ? "active" : "suspended", timestamp, userId]); + dbRun("UPDATE organization_members SET status = ?, updated_at = ? WHERE organization_id = ? AND user_id = ?", [active ? "active" : "suspended", timestamp, directory.organization_id, userId]); + return scimUserPayload(dbGet("SELECT * FROM users WHERE id = ?", [userId])); +} + +export function scimDeleteUser(directorySyncId, token, userId) { + const directory = directoryForScim(directorySyncId, token); + const user = dbGet("SELECT u.id FROM users u JOIN organization_members om ON om.user_id = u.id WHERE u.id = ? AND om.organization_id = ?", [userId, directory.organization_id]); + if (!user) throw httpError(404, "scim_user_not_found", "SCIM 用户不存在"); + const timestamp = new Date().toISOString(); + dbRun("UPDATE organization_members SET status = 'suspended', updated_at = ? WHERE organization_id = ? AND user_id = ?", [timestamp, directory.organization_id, userId]); + dbRun("UPDATE users SET status = 'suspended', updated_at = ? WHERE id = ?", [timestamp, userId]); + return { ok: true }; +} + +export function serviceHealth() { + return dbAll("SELECT id, service_key, label, kind, status, endpoint, latency_ms, queue_depth, version, last_heartbeat, metadata_json, updated_at FROM service_health ORDER BY kind, label").map((row) => ({ + ...row, + metadata: parseJson(row.metadata_json, {}) + })); +} + +export function updateServiceHealth(context, serviceKey, body) { + requirePermission(context, "runner:manage"); + const service = dbGet("SELECT * FROM service_health WHERE service_key = ?", [serviceKey]); + if (!service) throw httpError(404, "runner_not_found", "Runner 服务不存在", { serviceKey }); + if (service.kind !== "runner") throw httpError(400, "runner_operation_invalid", "只有 Runner 服务支持运维动作"); + const action = String(body.action || "").trim(); + const allowed = new Set(["pause", "resume", "drain", "restart", "heartbeat"]); + if (!allowed.has(action)) throw httpError(400, "runner_action_invalid", "Runner 运维动作无效", { action }); + const metadata = parseJson(service.metadata_json, {}); + const timestamp = new Date().toISOString(); + const previousStatus = service.status; + let status = service.status; + let lastHeartbeat = service.last_heartbeat; + if (action === "pause") status = "paused"; + if (action === "drain") status = "draining"; + if (action === "restart") { + status = "starting"; + lastHeartbeat = null; + } + if (action === "resume") status = metadata.resumeStatus || (service.version ? "ready" : "waiting-model"); + if (action === "heartbeat") { + status = metadata.resumeStatus || (service.version ? "ready" : "waiting-model"); + lastHeartbeat = timestamp; + } + const nextMetadata = { ...metadata, lastAction: action, lastActionBy: context.user.id, lastActionAt: timestamp, previousStatus, resumeStatus: status === "paused" || status === "draining" ? (service.version ? "ready" : "waiting-model") : metadata.resumeStatus }; + dbRun("UPDATE service_health SET status = ?, last_heartbeat = ?, metadata_json = ?, updated_at = ? WHERE service_key = ?", [status, lastHeartbeat, JSON.stringify(nextMetadata), timestamp, serviceKey]); + addAudit({ context, action: `runner.${action}`, targetType: "service_health", targetId: serviceKey, metadata: { previousStatus, status } }); + const updated = serviceHealth().find((item) => item.service_key === serviceKey); + return { runner: updated, runners: serviceHealth().filter((item) => item.kind === "runner") }; +} diff --git a/server/work-items.mjs b/server/work-items.mjs new file mode 100644 index 0000000..5a7b8e9 --- /dev/null +++ b/server/work-items.mjs @@ -0,0 +1,302 @@ +import { dbAll } from "./db.mjs"; +import { hasPermission } from "./tenant.mjs"; + +const REVIEW_LABELS = { + "single-frame": "一图一画面", + "continuity-lock": "连续性证据", + "voice-subtitle-asr": "声音 / 字幕 / ASR 对齐", + "clip-bridge": "片段衔接 / 实际末帧" +}; + +const PRIORITY_ORDER = { high: 0, medium: 1, low: 2 }; +const ACTIONABLE_JOB_STATUSES = new Set(["queued", "running", "blocked", "failed"]); +const ACTIONABLE_TASK_STATUSES = new Set(["open", "in_progress", "blocked"]); +const ACTIONABLE_ASSET_RIGHTS = new Set(["needs-evidence", "pending", "review", "rejected"]); +const ACTIONABLE_DELIVERY_STATUSES = new Set(["draft", "prepared", "pending", "review"]); + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function scope(context) { + return { + organizationId: context.organization?.id || "", + workspaceId: context.workspace?.id || "", + projectId: context.project?.id || "" + }; +} + +function priorityForStatus(status) { + if (["blocked", "failed", "changes_requested", "rejected"].includes(status)) return "high"; + if (["queued", "pending", "draft", "prepared", "review"].includes(status)) return "medium"; + return "low"; +} + +function reviewTitle(row) { + const episode = row.episode_number ? `E${String(row.episode_number).padStart(2, "0")}` : "当前集"; + const shot = row.shot_number ? `-S${String(row.shot_number).padStart(2, "0")}` : ""; + const label = REVIEW_LABELS[row.lane] || row.lane; + if (row.status === "changes_requested") return `${episode}${shot} ${label}需修改`; + if (row.status === "rejected") return `${episode}${shot} ${label}已驳回`; + return `${episode}${shot} ${label}待确认`; +} + +function jobTitle(row) { + const shot = row.shot_number ? ` · E${String(row.episode_number || 1).padStart(2, "0")}-S${String(row.shot_number).padStart(2, "0")}` : ""; + if (row.status === "blocked") return `${row.kind}${shot} 等待前置任务`; + if (row.status === "failed") return `${row.kind}${shot} 生成失败,等待重试`; + if (row.status === "running") return `${row.kind}${shot} 正在执行`; + return `${row.kind}${shot} 等待本地 Worker`; +} + +function assetTitle(row) { + if (row.kind === "voice") return `${row.name} · v${row.version_number || 1} 声音授权证据待补`; + return `${row.name} · v${row.version_number || 1} 版权证据待补`; +} + +function actionableReviews(context) { + if (!context.project) return []; + const canReview = hasPermission(context, "qa:review"); + const canFixProduction = ["script:edit", "asset:edit", "prompt:edit", "voice:edit"].some((permission) => hasPermission(context, permission)); + if (!canReview && !canFixProduction) return []; + const rows = dbAll( + `SELECT r.id, r.shot_id, r.lane, r.status, r.evidence_json, r.updated_at, + s.title AS shot_title, s.shot_number, + e.episode_number, e.title AS episode_title + FROM reviews r + LEFT JOIN shots s ON s.id = r.shot_id + LEFT JOIN episodes e ON e.id = s.episode_id + WHERE r.organization_id = ? AND r.workspace_id = ? AND r.project_id = ? + AND r.status IN ('pending', 'changes_requested', 'rejected') + ORDER BY r.updated_at DESC + LIMIT 100`, + [context.organization.id, context.workspace.id, context.project.id] + ); + return rows + .filter((row) => canReview || ["changes_requested", "rejected"].includes(row.status)) + .map((row) => { + const evidence = parseJson(row.evidence_json, {}); + const blockers = Array.isArray(evidence.blockers) ? evidence.blockers : []; + return { + id: `review-${row.id}`, + kind: "review", + title: reviewTitle(row), + detail: blockers[0] || REVIEW_LABELS[row.lane] || row.lane, + status: row.status, + priority: priorityForStatus(row.status), + targetTab: "qa", + targetId: row.id, + updatedAt: row.updated_at, + metadata: { + reviewId: row.id, + shotId: row.shot_id, + shotTitle: row.shot_title || "", + lane: row.lane, + episodeTitle: row.episode_title || "" + } + }; + }); +} + +function actionableJobs(context) { + if (!context.project || (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage"))) return []; + const rows = dbAll( + `SELECT j.id, j.kind, j.status, j.adapter_id, j.error_message, j.updated_at, + j.shot_id, s.title AS shot_title, s.shot_number, e.episode_number + FROM generation_jobs j + LEFT JOIN shots s ON s.id = j.shot_id + LEFT JOIN episodes e ON e.id = s.episode_id + WHERE j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ? + AND j.status IN ('queued', 'running', 'blocked', 'failed') + ORDER BY CASE j.status WHEN 'failed' THEN 0 WHEN 'blocked' THEN 1 WHEN 'queued' THEN 2 ELSE 3 END, j.updated_at DESC + LIMIT 100`, + [context.organization.id, context.workspace.id, context.project.id] + ); + return rows.filter((row) => ACTIONABLE_JOB_STATUSES.has(row.status)).map((row) => ({ + id: `job-${row.id}`, + kind: "job", + title: jobTitle(row), + detail: row.error_message || row.shot_title || `适配器:${row.adapter_id}`, + status: row.status, + priority: priorityForStatus(row.status), + targetTab: "jobs", + targetId: row.id, + updatedAt: row.updated_at, + metadata: { jobId: row.id, shotId: row.shot_id || null, adapterId: row.adapter_id } + })); +} + +function actionableAssets(context) { + if (!context.project) return []; + const canEditAssets = hasPermission(context, "asset:edit") || hasPermission(context, "compliance:manage"); + const canApproveVoice = hasPermission(context, "voice:approve"); + if (!canEditAssets && !canApproveVoice) return []; + const rows = dbAll( + `SELECT a.id, a.kind, a.name, a.lock_status, a.updated_at, + av.version_number, av.rights_status, av.metadata_json + FROM assets a + JOIN projects p ON p.id = a.project_id + JOIN workspaces w ON w.id = p.workspace_id + LEFT JOIN asset_versions av ON av.id = a.current_version_id + WHERE p.id = ? AND p.workspace_id = ? AND w.organization_id = ? + ORDER BY a.updated_at DESC + LIMIT 100`, + [context.project.id, context.workspace.id, context.organization.id] + ); + return rows + .filter((row) => ACTIONABLE_ASSET_RIGHTS.has(row.rights_status) && (row.kind === "voice" ? canApproveVoice : canEditAssets)) + .map((row) => { + const metadata = parseJson(row.metadata_json, {}); + return { + id: `asset-${row.id}`, + kind: "asset", + title: assetTitle(row), + detail: metadata.subtitle || (row.kind === "voice" ? "固定声线未完成授权证据确认" : "连续性资产未完成版权证据确认"), + status: row.rights_status, + priority: row.kind === "voice" ? "high" : "medium", + targetTab: "casting", + targetId: row.id, + updatedAt: row.updated_at, + metadata: { assetId: row.id, assetKind: row.kind, lockStatus: row.lock_status, versionNumber: row.version_number || 1 } + }; + }); +} + +function actionableDeliveries(context) { + if (!context.project || (!hasPermission(context, "delivery:view") && !hasPermission(context, "delivery:approve"))) return []; + const rows = dbAll( + `SELECT id, version, channel, status, active_batch_id, updated_at + FROM deliveries + WHERE organization_id = ? AND workspace_id = ? AND project_id = ? + AND status IN ('draft', 'prepared', 'pending', 'review') + ORDER BY updated_at DESC + LIMIT 50`, + [context.organization.id, context.workspace.id, context.project.id] + ); + return rows.filter((row) => ACTIONABLE_DELIVERY_STATUSES.has(row.status)).map((row) => ({ + id: `delivery-${row.id}`, + kind: "delivery", + title: `${row.version} 内部交付版本待审阅`, + detail: row.active_batch_id ? `${row.channel} · 已有活动批次` : `${row.channel} · 尚未激活交付批次`, + status: row.status, + priority: priorityForStatus(row.status), + targetTab: "export", + targetId: row.id, + updatedAt: row.updated_at, + metadata: { deliveryId: row.id, version: row.version, channel: row.channel } + })); +} + +function actionableInvitations(context) { + if (!context.user?.email || !context.organization?.id) return []; + const now = new Date().toISOString(); + const rows = dbAll( + `SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.role_key, i.expires_at, i.created_at, + o.name AS organization_name, w.name AS workspace_name, p.name AS project_name, + u.display_name AS inviter_name, r.name AS role_name + FROM invitations i + JOIN organizations o ON o.id = i.organization_id + LEFT JOIN workspaces w ON w.id = i.workspace_id + LEFT JOIN projects p ON p.id = i.project_id + LEFT JOIN users u ON u.id = i.invited_by + LEFT JOIN roles r ON r.key = i.role_key + WHERE i.organization_id = ? + AND lower(i.email) = lower(?) + AND i.status = 'pending' + AND i.expires_at > ? + AND (i.workspace_id IS NULL OR i.workspace_id = ?) + AND (i.project_id IS NULL OR i.project_id = ?) + ORDER BY i.created_at DESC + LIMIT 50`, + [context.organization.id, context.user.email, now, context.workspace?.id || "", context.project?.id || ""] + ); + return rows.map((row) => ({ + id: `invitation-${row.id}`, + kind: "invitation", + title: `接受加入${row.organization_name}的邀请`, + detail: `${row.workspace_name || "组织级"}${row.project_name ? ` · ${row.project_name}` : ""} · ${row.role_name || row.role_key}`, + status: "pending", + priority: "medium", + targetTab: "account", + targetId: row.id, + updatedAt: row.created_at, + metadata: { invitationId: row.id, organizationId: row.organization_id, workspaceId: row.workspace_id, projectId: row.project_id, inviterName: row.inviter_name || "" } + })); +} + +function actionableTasks(context) { + if (!context.project || !hasPermission(context, "task:view")) return []; + const canSeeAll = hasPermission(context, "task:manage"); + const clauses = [ + "t.organization_id = ?", + "t.workspace_id = ?", + "t.project_id = ?", + "t.status IN ('open', 'in_progress', 'blocked')" + ]; + const params = [context.organization.id, context.workspace.id, context.project.id]; + if (!canSeeAll) { + clauses.push("t.assignee_user_id = ?"); + params.push(context.user.id); + } + const rows = dbAll( + `SELECT t.id, t.title, t.kind, t.status, t.priority, t.due_at, t.updated_at, u.display_name AS assignee_name + FROM project_tasks t + LEFT JOIN users u ON u.id = t.assignee_user_id + WHERE ${clauses.join(" AND ")} + ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'open' THEN 1 ELSE 2 END, + CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, + COALESCE(t.due_at, '9999-12-31T23:59:59.999Z'), t.updated_at DESC + LIMIT 100`, + params + ); + return rows.filter((row) => ACTIONABLE_TASK_STATUSES.has(row.status)).map((row) => ({ + id: `task-${row.id}`, + kind: "task", + title: row.title, + detail: `${row.kind} · ${row.assignee_name || "未分派"}${row.due_at ? ` · 截止 ${row.due_at.slice(0, 10)}` : ""}`, + status: row.status, + priority: row.priority, + targetTab: "tasks", + targetId: row.id, + updatedAt: row.updated_at, + metadata: { taskId: row.id, assigneeUserId: row.assignee_user_id || null, dueAt: row.due_at || null } + })); +} + +export function listWorkItems(context, options = {}) { + const items = [ + ...actionableReviews(context), + ...actionableJobs(context), + ...actionableAssets(context), + ...actionableDeliveries(context), + ...actionableInvitations(context), + ...actionableTasks(context) + ].sort((left, right) => { + const priority = (PRIORITY_ORDER[left.priority] ?? 9) - (PRIORITY_ORDER[right.priority] ?? 9); + if (priority !== 0) return priority; + return String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")); + }); + const limit = Math.max(1, Math.min(200, Number(options.limit || 100))); + const visibleItems = items.slice(0, limit); + const byKind = {}; + const byStatus = {}; + for (const item of visibleItems) { + byKind[item.kind] = (byKind[item.kind] || 0) + 1; + byStatus[item.status] = (byStatus[item.status] || 0) + 1; + } + return { + items: visibleItems, + summary: { + total: visibleItems.length, + high: visibleItems.filter((item) => item.priority === "high").length, + byKind, + byStatus + }, + scope: scope(context), + generatedAt: new Date().toISOString() + }; +} diff --git a/server/worker.mjs b/server/worker.mjs new file mode 100644 index 0000000..218737b --- /dev/null +++ b/server/worker.mjs @@ -0,0 +1,282 @@ +import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; +import { executeGenerationJob } from "./execution.mjs"; + +const workerId = process.env.AI_DRAMA_WORKER_ID || `local-worker-${process.pid}`; +const pollMs = Math.max(250, Number(process.env.AI_DRAMA_WORKER_POLL_MS || 1200)); +const maxConcurrency = Math.max(1, Math.min(8, Number(process.env.AI_DRAMA_WORKER_CONCURRENCY || 2))); +const leaseMs = Math.max(30_000, Number(process.env.AI_DRAMA_WORKER_LEASE_MS || 300_000)); +const staleAfterMs = Math.max(15_000, Number(process.env.AI_DRAMA_WORKER_STALE_MS || Math.max(30_000, pollMs * 5))); +const enabled = process.env.AI_DRAMA_WORKER_ENABLED !== "0"; + +const state = { + workerId, + enabled, + pollMs, + maxConcurrency, + inFlight: new Set(), + startedAt: enabled ? new Date().toISOString() : null, + lastPollAt: null, + lastClaimAt: null, + lastCompletedAt: null, + lastFailedAt: null, + lastHeartbeatAt: null, + lastReclaimAt: null, + lastReclaimedCount: 0, + lastRetryAt: null, + lastRetryCount: 0, + lastError: "", + timer: null, + pumping: false +}; + +function now() { + return new Date().toISOString(); +} + +function workerContext(job) { + const organization = dbGet("SELECT * FROM organizations WHERE id = ?", [job.organization_id]); + const workspace = dbGet("SELECT * FROM workspaces WHERE id = ?", [job.workspace_id]); + const project = dbGet("SELECT * FROM projects WHERE id = ?", [job.project_id]); + const user = dbGet("SELECT * FROM users WHERE id = 'u-local-worker'") || dbGet("SELECT * FROM users WHERE id = ?", [job.created_by]); + return { + user, + organization, + workspace, + project, + permissions: ["job:create", "queue:manage", "model:manage", "usage:view", "audit:view"], + roles: [{ key: "local_worker", name: "本地 Worker", scope: "system" }], + systemAdmin: false, + orgElevated: true, + workspaces: [], + projects: [] + }; +} + +function localQueueDepth() { + return Number(dbGet( + `SELECT COUNT(*) AS count + FROM generation_jobs j + JOIN model_connectors m ON m.id = j.adapter_id + WHERE j.status = 'queued' AND m.cost_mode = 'local' AND m.status = 'ready'`, + [] + )?.count || 0); +} + +function queueAlert() { + const oldest = dbGet("SELECT MIN(created_at) AS oldest FROM generation_jobs WHERE status = 'queued'", []); + const queueDepth = localQueueDepth(); + const oldestAt = oldest?.oldest || null; + const oldestAgeMs = oldestAt ? Math.max(0, Date.now() - Date.parse(oldestAt)) : 0; + return { + level: queueDepth === 0 ? "none" : oldestAgeMs >= 10 * 60 * 1000 ? "critical" : oldestAgeMs >= 3 * 60 * 1000 ? "warning" : "normal", + queueDepth, + oldestAt, + oldestAgeMs + }; +} + +function updateWorkerHealth(status = enabled ? "ready" : "paused") { + const timestamp = now(); + state.lastHeartbeatAt = timestamp; + const alert = queueAlert(); + const metadata = { + workerId, + enabled, + concurrency: maxConcurrency, + inFlight: state.inFlight.size, + pollMs, + lastPollAt: state.lastPollAt, + lastClaimAt: state.lastClaimAt, + lastCompletedAt: state.lastCompletedAt, + lastFailedAt: state.lastFailedAt, + lastHeartbeatAt: state.lastHeartbeatAt, + staleAfterMs, + heartbeatAgeMs: 0, + lastReclaimAt: state.lastReclaimAt, + lastReclaimedCount: state.lastReclaimedCount, + lastRetryAt: state.lastRetryAt, + lastRetryCount: state.lastRetryCount, + queueAlert: alert, + lastError: state.lastError + }; + dbRun( + `UPDATE service_health + SET status = ?, queue_depth = ?, last_heartbeat = ?, version = ?, metadata_json = ?, updated_at = ? + WHERE service_key = 'local-worker'`, + [status, localQueueDepth(), timestamp, "node-24-local-worker", JSON.stringify(metadata), timestamp] + ); +} + +function reclaimStaleLeases() { + const timestamp = now(); + const staleBefore = new Date(Date.now() - leaseMs).toISOString(); + const staleJobs = dbAll("SELECT id, max_attempts FROM generation_jobs WHERE status = 'running' AND leased_at IS NOT NULL AND leased_at < ?", [staleBefore]); + if (!staleJobs.length) return 0; + withTransaction(() => { + for (const job of staleJobs) { + const attempt = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [job.id])?.attempt_number || 0); + const message = `Worker 租约在 ${leaseMs}ms 后失效,已回收第 ${attempt} 次执行`; + const exhausted = attempt >= Number(job.max_attempts || 3); + dbRun("UPDATE job_attempts SET status = 'failed', error_message = ?, finished_at = ? WHERE job_id = ? AND attempt_number = ? AND status = 'running'", [message, timestamp, job.id, attempt]); + dbRun("UPDATE generation_jobs SET status = ?, error_message = ?, next_run_at = ?, leased_by = NULL, leased_at = NULL, finished_at = CASE WHEN ? THEN ? ELSE finished_at END, updated_at = ? WHERE id = ?", [exhausted ? "failed" : "queued", message, exhausted ? null : timestamp, exhausted ? 1 : 0, exhausted ? timestamp : null, timestamp, job.id]); + } + }); + state.lastReclaimAt = timestamp; + state.lastReclaimedCount = staleJobs.length; + state.lastError = `${staleJobs.length} 个过期任务租约已回收`; + return staleJobs.length; +} + +function scheduleRetry(jobId, message) { + const job = dbGet("SELECT max_attempts FROM generation_jobs WHERE id = ?", [jobId]); + const attempt = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0); + const maxAttempts = Number(job?.max_attempts || 3); + if (!job || attempt >= maxAttempts) return false; + const delayMs = Math.min(120_000, 2 ** Math.max(0, attempt - 1) * 2_000); + const nextRunAt = new Date(Date.now() + delayMs).toISOString(); + dbRun("UPDATE generation_jobs SET status = 'queued', next_run_at = ?, error_message = ?, leased_by = NULL, leased_at = NULL, updated_at = ? WHERE id = ? AND status = 'failed'", [`${message};将在 ${Math.ceil(delayMs / 1000)} 秒后自动重试(${attempt}/${maxAttempts})`, nextRunAt, now(), jobId]); + state.lastRetryAt = now(); + state.lastRetryCount += 1; + return true; +} + +function claimNextJob() { + const timestamp = now(); + const staleBefore = new Date(Date.now() - leaseMs).toISOString(); + return withTransaction(() => { + const row = dbGet( + `SELECT j.* + FROM generation_jobs j + JOIN model_connectors m ON m.id = j.adapter_id + WHERE j.status = 'queued' + AND (j.next_run_at IS NULL OR j.next_run_at <= ?) + AND (j.leased_by IS NULL OR j.leased_at < ?) + AND m.cost_mode = 'local' + AND m.status = 'ready' + AND NOT EXISTS ( + SELECT 1 + FROM job_dependencies d + JOIN generation_jobs dependency ON dependency.id = d.depends_on_job_id + WHERE d.job_id = j.id AND dependency.status <> 'completed' + ) + ORDER BY j.priority DESC, j.created_at ASC + LIMIT 1`, + [timestamp, staleBefore] + ); + if (!row) return null; + const result = dbRun( + `UPDATE generation_jobs + SET leased_by = ?, leased_at = ?, updated_at = ? + WHERE id = ? AND status = 'queued' AND (leased_by IS NULL OR leased_at < ?)`, + [workerId, timestamp, timestamp, row.id, staleBefore] + ); + if (!Number(result?.changes || 0)) return null; + state.lastClaimAt = timestamp; + return { ...row, leased_by: workerId, leased_at: timestamp }; + }); +} + +function clearLease(jobId) { + dbRun( + "UPDATE generation_jobs SET leased_by = NULL, leased_at = NULL, updated_at = ? WHERE id = ? AND leased_by = ?", + [now(), jobId, workerId] + ); +} + +async function processClaimedJob(job) { + try { + const result = await executeGenerationJob(workerContext(job), job.id, { workerId, source: "local-worker" }); + state.lastCompletedAt = now(); + state.lastError = ""; + return { jobId: job.id, status: result.job?.status || "completed" }; + } catch (error) { + state.lastFailedAt = now(); + state.lastError = String(error.message || error).slice(0, 500); + const retryScheduled = scheduleRetry(job.id, state.lastError); + return { jobId: job.id, status: retryScheduled ? "queued" : "failed", retryScheduled, error: state.lastError }; + } finally { + clearLease(job.id); + } +} + +async function pump() { + if (!enabled || state.pumping) return; + state.pumping = true; + state.lastPollAt = now(); + try { + reclaimStaleLeases(); + while (state.inFlight.size < maxConcurrency) { + const job = claimNextJob(); + if (!job) break; + state.inFlight.add(job.id); + const task = processClaimedJob(job); + void task.finally(() => { + state.inFlight.delete(job.id); + updateWorkerHealth(); + }); + } + updateWorkerHealth(); + } catch (error) { + // SQLite can briefly reject BEGIN IMMEDIATE while another local process commits. + // Keep the worker alive and let the next poll retry instead of taking down the API. + state.lastError = String(error?.message || error).slice(0, 500); + try { + updateWorkerHealth("degraded"); + } catch { + // A second lock while reporting health is still transient; the next poll retries. + } + } finally { + state.pumping = false; + } +} + +export function workerStatus() { + const heartbeatAt = state.lastHeartbeatAt || state.lastPollAt || state.startedAt; + const heartbeatAgeMs = heartbeatAt ? Math.max(0, Date.now() - Date.parse(heartbeatAt)) : null; + const stale = enabled && (heartbeatAgeMs === null || heartbeatAgeMs > staleAfterMs); + return { + ...state, + inFlight: state.inFlight.size, + queueDepth: localQueueDepth(), + staleAfterMs, + lastHeartbeatAt: state.lastHeartbeatAt, + heartbeatAgeMs, + healthStatus: stale ? "stale" : enabled ? "ready" : "paused", + stale, + queueAlert: queueAlert(), + timer: undefined, + pumping: undefined + }; +} + +export async function runWorkerOnce() { + if (!enabled) return { ...workerStatus(), dispatched: 0, disabled: true }; + reclaimStaleLeases(); + const job = claimNextJob(); + if (!job) { + updateWorkerHealth(); + return { ...workerStatus(), dispatched: 0 }; + } + state.inFlight.add(job.id); + const result = await processClaimedJob(job); + state.inFlight.delete(job.id); + updateWorkerHealth(); + return { ...workerStatus(), dispatched: 1, result }; +} + +export function startLocalWorker() { + if (!enabled || state.timer) { + updateWorkerHealth(enabled ? "ready" : "paused"); + return workerStatus(); + } + updateWorkerHealth("ready"); + state.timer = setInterval(() => { void pump(); }, pollMs); + void pump(); + return workerStatus(); +} + +export function stopLocalWorker() { + if (state.timer) clearInterval(state.timer); + state.timer = null; + updateWorkerHealth("paused"); +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..710a4e8 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,1739 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + Archive, + AudioLines, + Bell, + Bot, + BookOpen, + Building2, + CheckCircle2, + Clapperboard, + Coins, + Copy, + Download, + FileJson2, + Film, + FolderKanban, + Gauge, + HardDrive, + Images, + KeyRound, + Layers3, + Link2, + ListChecks, + ListPlus, + LogOut, + Mail, + Menu, + Mic2, + Network, + Plus, + Play, + RadioTower, + RefreshCw, + Save, + Scale, + Search, + Settings2, + ShieldCheck, + Sparkles, + TimerReset, + UserPlus, + Users, + Volume2, + Wand2, + XCircle +} from "lucide-react"; +import { adapters, marketBenchmarks, workflowTemplates } from "./data/sampleProject"; +import { createProjectShell, emptyProject } from "./data/projectShell"; +import { ScenePreview } from "./components/ScenePreview"; +import { + AdminAuditPage, + AdminModelsPage, + AdminOverviewPage, + AdminQueuePage, + AdminUsagePage, + AccountSecurityPage, + CreatorHomePage, + IdentityCenterPage, + LoginPage, + NotificationBell, + NotificationCenterPage, + TaskCenterPage, + RegisterPage, + SystemSettingsPage, + SystemUsersPage +} from "./components/EnterprisePages"; +import { DeliveryPortalPage } from "./components/DeliveryPortalPage"; +import { + AssetLibraryPage, + BatchProductionPage, + ProjectAssistantPage, + VoiceStudioPage +} from "./components/CreatorSuitePages"; +import { + CastingBoardPage, + DeliveryAccessPage, + DeliveryOpsPage, + DirectorProductionPage, + GenerationQueuePage, + ProjectFactoryPage, + QaReviewPage, + ScriptProductionPage, + SeriesBiblePage +} from "./components/ProductionPages"; +import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api"; +import { buildAllExports, buildModelRequest, downloadJson } from "./lib/exporters"; +import { projectQa } from "./lib/qa"; +import { getPlatformSummary, platformData } from "./platform/platformData"; + +const tabs = [ + { id: "overview", label: "生产总控", icon: RadioTower }, + { id: "factory", label: "项目工厂", icon: Layers3 }, + { id: "script", label: "剧本拆解", icon: BookOpen }, + { id: "casting", label: "资产选角", icon: ShieldCheck }, + { id: "director", label: "导演工作台", icon: Clapperboard }, + { id: "jobs", label: "生成队列", icon: Wand2 }, + { id: "modelops", label: "模型中台", icon: Network }, + { id: "qa", label: "审片中心", icon: ListChecks }, + { id: "compliance", label: "成本合规", icon: Scale }, + { id: "export", label: "交付运营", icon: Download }, + { id: "admin", label: "平台管理", icon: KeyRound } +]; + +const navigationGroups = [ + { + label: "创作套件", + items: [ + { id: "creator-home", label: "我的工作台", icon: RadioTower }, + { id: "tasks", label: "协作任务", icon: ListChecks, requiredPermission: "task:view" }, + { id: "assistant", label: "项目 AI 助手", icon: Bot }, + { id: "asset-library", label: "资产库", icon: Images, requiredPermission: "asset:edit" }, + { id: "voice-studio", label: "声音与字幕", icon: AudioLines, requiredPermission: "voice:edit" }, + { id: "batch-production", label: "批量生产", icon: ListPlus, requiredPermission: "job:create" } + ] + }, + { + label: "账户", + items: [ + { id: "account", label: "账号与安全", icon: KeyRound }, + { id: "notifications", label: "通知中心", icon: Bell } + ] + }, + { + label: "生产流水线", + items: [ + { id: "overview", label: "生产总控", icon: RadioTower, requiredPermission: "usage:view" }, + { id: "factory", label: "项目工厂", icon: Layers3, requiredPermission: "project:create" }, + { id: "script", label: "剧本拆解", icon: BookOpen, requiredPermission: "script:edit" }, + { id: "casting", label: "资产与选角", icon: ShieldCheck, requiredPermission: "asset:edit" }, + { id: "director", label: "导演工作台", icon: Clapperboard, requiredPermission: "job:create" }, + { id: "jobs", label: "生成任务", icon: Wand2, requiredPermission: "job:create" }, + { id: "bible", label: "系列 Bible", icon: BookOpen, requiredPermission: "script:edit" }, + { id: "locks", label: "连续性锁", icon: ShieldCheck, requiredPermission: "asset:edit" }, + { id: "shots", label: "镜头清单", icon: Clapperboard, requiredPermission: "job:create" }, + { id: "modelops", label: "模型中台", icon: Network, requiredPermission: "model:manage" }, + { id: "qa", label: "审片中心", icon: ListChecks, requiredPermission: "qa:review" }, + { id: "compliance", label: "成本合规", icon: Scale, requiredPermission: "usage:view" }, + { id: "export", label: "交付运营", icon: Download, requiredPermission: "delivery:view" }, + { id: "delivery-portal", label: "客户交付门户", icon: Link2, requiredPermission: "delivery:view" } + ] + }, + { + label: "管理员后台", + items: [ + { id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" }, + { id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" }, + { id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" }, + { id: "admin-models", label: "模型与 Runner", icon: Network, requiredPermission: "model:manage" }, + { id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" }, + { id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" }, + { id: "admin-audit", label: "审计与合规", icon: ListChecks, requiredPermission: "audit:view" } + ] + }, + { + label: "系统设置", + items: [ + { id: "system-overview", label: "系统总览", icon: Settings2, requiredPermission: "system:settings:view" }, + { id: "system-identity", label: "企业身份", icon: KeyRound, requiredPermission: "system:settings:view" }, + { id: "system-users", label: "全局用户目录", icon: Users, requiredPermission: "system:settings:view" }, + { id: "system-generation", label: "生成策略", icon: ShieldCheck, requiredPermission: "system:settings:view" }, + { id: "system-storage", label: "存储与队列", icon: HardDrive, requiredPermission: "system:settings:view" }, + { id: "system-notifications", label: "通知与 API", icon: Mail, requiredPermission: "system:settings:view" }, + { id: "system-features", label: "功能开关", icon: Sparkles, requiredPermission: "feature_flag:manage" } + ] + } +]; + +const navigationTabIds = new Set(navigationGroups.flatMap((group) => group.items.map((item) => item.id))); + +function tabFromLocation() { + if (typeof window === "undefined") return "creator-home"; + const candidate = window.location.hash.replace(/^#/, ""); + return navigationTabIds.has(candidate) ? candidate : "creator-home"; +} + +function inviteTokenFromLocation() { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("invite") || ""; +} + +function portalTokenFromLocation() { + if (typeof window === "undefined") return ""; + const match = window.location.pathname.match(/^\/portal\/([^/]+)\/?$/); + return match ? decodeURIComponent(match[1]) : ""; +} + +function ssoTicketFromLocation() { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("sso_ticket") || ""; +} + +function ssoErrorFromLocation() { + if (typeof window === "undefined") return ""; + const params = new URLSearchParams(window.location.search); + const code = params.get("sso_error"); + if (!code) return ""; + return params.get("sso_error_description") || `企业 SSO 登录失败:${code}`; +} + +function normalizeAdapter(adapter) { + return { + ...adapter, + kind: adapter.kind || adapter.protocol || "http-json", + baseUrl: adapter.baseUrl || adapter.endpoint || "", + enabled: adapter.enabled ?? (adapter.status === "ready"), + status: adapter.status || (adapter.enabled ? "ready" : "planned"), + costMode: adapter.costMode || adapter.cost_mode || "local", + approvalRequired: adapter.approvalRequired ?? Boolean(adapter.approval_required), + capability: adapter.capability || adapter.capabilities || [] + }; +} + +function adapterCatalogForContext(platformContext) { + const registry = platformContext?.platform?.adapterCatalog || platformContext?.platform?.modelRegistry; + if (Array.isArray(registry) && registry.length) return registry.map(normalizeAdapter); + return adapters.map(normalizeAdapter); +} + +function preferredAdapterId(catalog, requestedId) { + if (requestedId && catalog.some((adapter) => adapter.id === requestedId)) return requestedId; + const imageAdapter = catalog.find((adapter) => (adapter.capability || []).some((item) => /image-to-video|i2v|video/i.test(item))); + return imageAdapter?.id || catalog.find((adapter) => adapter.status === "ready")?.id || catalog[0]?.id || "owned-model-platform"; +} + +function StatusPill({ tone = "neutral", children }) { + return {children}; +} + +function SectionTitle({ icon: Icon, title, action }) { + return ( +
+
+ +

{title}

+
+ {action} +
+ ); +} + +function Metric({ label, value, icon: Icon }) { + return ( +
+ + {label} + {value} +
+ ); +} + +function ProgressBar({ value, tone = "ok" }) { + return ( +
+ +
+ ); +} + +function Cockpit({ project, qaResults, setActiveTab, setActiveShotId, platformContext, canCreateJob, canApproveDelivery }) { + const platform = platformContext?.platform || platformData; + const platformSummary = platformContext?.summary || getPlatformSummary(); + const organization = platform.organization || platformData.workspace; + const plan = platform.billing || platform.plan; + const totalDuration = project.shots.reduce((sum, shot) => sum + shot.durationSec, 0); + const blockedItems = project.pipeline.filter((item) => item.status === "blocked").length; + + return ( +
+
+
+

LOCAL AI DRAMA OPS

+

{project.series.title} · {project.episode.title}

+

{organization.name || "私有化内容组织"}:把剧本、资产、模型、队列、审片、成本、合规和交付纳入同一个私有生产系统。

+
+ {canCreateJob && } + {canApproveDelivery && } +
+
+
+ 商用准备度 + {project.production.commercialReadiness}% + +

{plan ? `${plan.plan_name || plan.name} · ${plan.seat_limit || plan.seats} seats · ${plan.storage_gb || plan.storageGb}GB storage · 外部云连接需显式审批。` : "当前角色无计费数据查看权限。"}

+
+
+ +
+ + + + +
+ +
+
+
+

生产流水线

+ {blockedItems ? `${blockedItems} 个阻塞` : "运行中"} +
+
+ {project.pipeline.map((item) => { + const target = item.id === "script" ? "script" : item.id === "assets" ? "casting" : item.id === "qa" ? "qa" : item.id === "edit" ? "export" : "jobs"; + return ( + + );})} +
+
+ +
+
+

镜头生产状态

+ {project.shots.length} shots / {totalDuration}s +
+
+ {project.shots.map((shot) => { + const qa = qaResults.find((item) => item.shotId === shot.id); + return ( + + ); + })} +
+
+ +
+
+

生成队列

+ local-only +
+
+
+ 任务 + 镜头 + 适配器 + 成本策略 + 状态 +
+ {project.productionJobs.map((job) => ( + + ))} +
+
+ +
+
+

QA 证据

+ 需要真实产物回填 +
+
+ {project.qaEvidence.map((item) => ( +
+ {item.result === "pass" ? : } +
+ {item.label} +

{item.evidence}

+
+
+ ))} +
+
+ +
+
+

交付物

+ {project.deliverables.filter((item) => item.status === "ready").length}/{project.deliverables.length} +
+
+ {project.deliverables.map((item) => ( +
+ {item.name} + {item.path} + {item.status} +
+ ))} +
+
+ +
+
+

市面商业平台对标

+ 已转成本地版工作流 +
+
+ {(platform.researchMatrix || platformSummary.researchMatrix || []).map((item) => ( +
+ {item.category} + {item.commercialNeed} +

{item.localBuild}

+
+ ))} +
+
+
+
+ ); +} + +function ProjectFactory({ setActiveTab, platformContext }) { + const platform = platformContext?.platform || platformData; + const projects = platform.projects || platformData.projects; + return ( +
+ +
+
+

商业项目入口

+
+ {["原创 AI 漫剧", "小说解说漫", "动态漫拆层", "仿真人短剧审慎模式", "平台 Agent 流水线"].map((name) => ( + + ))} +
+
+
+

项目资产空间

+
+ {platformData.assetStorage.buckets.map((bucket) => ( +
+ + {bucket.label} + {bucket.fileCount} files + {bucket.policy} +
+ ))} +
+
+
+

项目列表

+
+
+ 项目类型阶段准备度风险更新 +
+ {projects.map((project) => ( + + ))} +
+
+
+
+ ); +} + +function ScriptStudio({ project, canEditAssets }) { + return ( +
+ +
+
+

长文本导入

+

{project.scriptStudio.sourceType} · {project.scriptStudio.importLimit}

+