feat: bootstrap commercial AI drama platform
This commit is contained in:
+42
@@ -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
|
||||
@@ -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=<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/<project-id>
|
||||
```
|
||||
|
||||
每个项目单独拥有一个子目录,子目录对应 series bible、角色、场景、道具、分镜、配音、QA、剪辑工程和最终视频交付;服务端不会把一个项目的导出写进另一个项目目录。
|
||||
@@ -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 节点。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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、对象存储和异地备份策略;部署上线前应验证快照可读性,并配置独立备份保留和恢复演练。
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
# API 与本地执行器参考
|
||||
|
||||
## 认证
|
||||
|
||||
除健康检查和登录接口外,业务接口要求:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <session-token>
|
||||
```
|
||||
|
||||
服务端会按以下顺序解析访问边界:
|
||||
|
||||
```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-<host>
|
||||
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 文件,应顺序执行。
|
||||
@@ -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、九宫格、边界表作为生成输入。
|
||||
- 不让同一张生成图出现多个地点、多个时间点、多个机位或多个连续动作。
|
||||
@@ -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、口型模型和剪辑工具统一纳管。默认不调用付费云端节点,所有外部适配器必须显式启用。
|
||||
@@ -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、声纹、首尾帧差异。
|
||||
@@ -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 接入,不需要重做组织模型。
|
||||
@@ -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 时间戳生成字幕,再进入剪辑合成。
|
||||
@@ -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 服务保持运行。
|
||||
|
||||
@@ -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 视口,确认无横向溢出和控制台错误。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 项目工程目录模板
|
||||
|
||||
每个正式剧集项目可复制本目录结构:
|
||||
|
||||
```text
|
||||
bible/ series bible、世界观、季纲、集纲
|
||||
characters/ 角色锁、服装状态、声音锁、参考图
|
||||
locations/ 场景锁、固定机位、天气与灯光状态
|
||||
props/ 道具锁、关键物状态
|
||||
shots/ 分镜 JSON、prompt pack、首尾帧、视频片段
|
||||
voices/ 台词表、TTS wav、角色参考音频
|
||||
qa/ 单画面、连续性、声音字幕、片段衔接质检结果
|
||||
edit/ 剪辑清单、字幕、音轨、转场说明
|
||||
final-video/ 最终成片与发布版本
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "thunder-mouth",
|
||||
"title": "雷雨口",
|
||||
"format": "竖屏 AI 漫剧",
|
||||
"logline": "暴雨夜,冷静的地铁口志愿者和急着回家的女学生,用三段避险选择逃过一次雷暴事故。",
|
||||
"originalityNote": "原创灾害安全短剧;只参考短剧节奏和本地生成工艺,不复制现成 IP、角色或名场面。",
|
||||
"visualStyle": "原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,傍晚雷雨,稳定侧向中远景机位。",
|
||||
"showEngine": "每集一个日常危险现场,角色用具体行动拆解风险,结尾留下下一处隐患。",
|
||||
"continuityRule": "角色、服装、道具、场景、天气、机位、声音全部入 ledger;下一段视频优先使用上一段实际末帧。"
|
||||
}
|
||||
@@ -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 集"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 声线替换随机原生音轨;环境声可独立保留。"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "metro-canopy-rain",
|
||||
"name": "地铁口玻璃连廊",
|
||||
"lock": "同一个地铁站与商场玻璃入口,混凝土雨棚,暖色顶灯,右侧玻璃门,左侧雨街,外面有树、金属指示牌、积水、路锥和警戒线。",
|
||||
"weather": "傍晚雷暴,强降雨,地面反光,远处有闪电。",
|
||||
"cameraLock": "稳定中远景侧向机位,角色比例不跳变,不突然进室内,不切正脸大特写。"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"id": "blue-umbrella",
|
||||
"name": "折叠蓝伞",
|
||||
"lock": "陈宇右手低握,未打开,不作为避雷动作道具。"
|
||||
},
|
||||
{
|
||||
"id": "yellow-poncho",
|
||||
"name": "黄色雨披",
|
||||
"lock": "唐夏折在双臂上,颜色稳定,不突然穿上。"
|
||||
},
|
||||
{
|
||||
"id": "warning-cones",
|
||||
"name": "路锥和警戒线",
|
||||
"lock": "远处积水旁,任何角色都不跨越。"
|
||||
},
|
||||
{
|
||||
"id": "phone",
|
||||
"name": "手机",
|
||||
"lock": "第三段陈宇低位拿出,不遮挡脸。"
|
||||
}
|
||||
]
|
||||
@@ -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": "使用上一段实际末帧作为下一段首帧。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "规避嘴形"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "规避嘴形"
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI 短剧本地生产台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1937
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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("; "));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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" })
|
||||
});
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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}****` : "仅创建或轮换时显示一次";
|
||||
}
|
||||
+760
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+595
@@ -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()
|
||||
};
|
||||
@@ -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`
|
||||
};
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
@@ -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, {})
|
||||
}));
|
||||
}
|
||||
+397
@@ -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 };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
+205
@@ -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);
|
||||
}
|
||||
+1197
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
+2738
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
+1739
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,701 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AudioLines,
|
||||
Bot,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CheckSquare2,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Download,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileText,
|
||||
Film,
|
||||
HardDrive,
|
||||
FolderOpen,
|
||||
Images,
|
||||
Layers3,
|
||||
LockKeyhole,
|
||||
MessageSquareText,
|
||||
Mic2,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Square,
|
||||
Tags,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import {
|
||||
bindAssetToShot,
|
||||
createAsset,
|
||||
createAssetVersion,
|
||||
fetchAsset,
|
||||
fetchAssetContent,
|
||||
fetchAssets,
|
||||
fetchJobs,
|
||||
queryAssistant,
|
||||
restoreAssetVersion,
|
||||
updateAssetLock,
|
||||
updateAssetRights,
|
||||
uploadAsset,
|
||||
uploadAssetVersion,
|
||||
verifyAsset
|
||||
} from "../lib/api";
|
||||
import { buildVoiceTable, downloadJson } from "../lib/exporters";
|
||||
|
||||
function SuiteHeader({ kicker, title, description, actions }) {
|
||||
return (
|
||||
<div className="suite-header">
|
||||
<div>
|
||||
<span className="card-kicker">{kicker}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="suite-header-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteMetric({ icon: Icon, label, value, detail, tone = "neutral" }) {
|
||||
return (
|
||||
<div className={`suite-metric ${tone}`}>
|
||||
<div className="suite-metric-icon"><Icon size={17} /></div>
|
||||
<div><span>{label}</span><strong>{value}</strong><small>{detail}</small></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteStatus({ status }) {
|
||||
const config = {
|
||||
locked: ["已锁定", "ok"],
|
||||
ready: ["可用", "ok"],
|
||||
draft: ["草稿", "neutral"],
|
||||
pending: ["待处理", "warn"],
|
||||
running: ["执行中", "warn"],
|
||||
blocked: ["阻塞", "warn"],
|
||||
review: ["待复核", "warn"],
|
||||
archived: ["已归档", "neutral"],
|
||||
"needs-evidence": ["待版权证据", "warn"],
|
||||
completed: ["已完成", "ok"],
|
||||
approved: ["已确认", "ok"],
|
||||
"needs-user-approved-reference": ["待参考音频", "warn"]
|
||||
}[status] || [status || "未设置", "neutral"];
|
||||
return <span className={`suite-status ${config[1]}`}><span />{config[0]}</span>;
|
||||
}
|
||||
|
||||
function AssetVisual({ asset }) {
|
||||
const rawKind = asset.rawKind || asset.kind;
|
||||
const Icon = rawKind === "character" || asset.kind === "角色" ? Images : rawKind === "location" || asset.kind === "场景" ? Film : rawKind === "prop" || asset.kind === "道具" ? Tags : rawKind === "voice" || asset.kind === "声音" ? FileAudio : FileImage;
|
||||
const visualKind = rawKind === "character" || asset.kind === "角色" ? "character" : rawKind === "location" || asset.kind === "场景" ? "location" : rawKind === "prop" || asset.kind === "道具" ? "prop" : rawKind === "voice" || asset.kind === "声音" ? "voice" : "system";
|
||||
return <div className={`asset-visual asset-${visualKind}`}><Icon size={22} /><strong>{asset.initial}</strong></div>;
|
||||
}
|
||||
|
||||
const assetKindLabels = {
|
||||
character: "角色",
|
||||
location: "场景",
|
||||
prop: "道具",
|
||||
style: "风格",
|
||||
lora: "LoRA",
|
||||
voice: "声音",
|
||||
subtitle: "字幕",
|
||||
reference: "参考"
|
||||
};
|
||||
|
||||
const assetKindOptions = [
|
||||
["character", "角色"],
|
||||
["location", "场景"],
|
||||
["prop", "道具"],
|
||||
["voice", "声音"],
|
||||
["style", "风格"],
|
||||
["lora", "LoRA"],
|
||||
["reference", "参考"]
|
||||
];
|
||||
|
||||
const MAX_ASSET_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
const ASSET_UPLOAD_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif", "svg", "wav", "mp3", "m4a", "mp4", "webm", "json", "txt", "md", "csv", "safetensors", "ckpt", "pt", "bin"]);
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function validateAssetFile(file) {
|
||||
if (!file) throw new Error("没有选择文件");
|
||||
if (!file.size) throw new Error("不能上传空文件");
|
||||
if (file.size > MAX_ASSET_UPLOAD_BYTES) throw new Error(`单次本地资产上传不能超过 12MB,当前文件为 ${formatBytes(file.size)}`);
|
||||
const extension = file.name.toLowerCase().split(".").pop();
|
||||
const supported = file.type.startsWith("image/") || file.type.startsWith("audio/") || file.type.startsWith("video/") || file.type.startsWith("text/") || file.type === "application/json" || ASSET_UPLOAD_EXTENSIONS.has(extension);
|
||||
if (!supported) throw new Error(`暂不支持 ${extension ? `.${extension}` : "该类型"} 文件,请导入图片、音频、视频、文本、JSON 或本地模型文件`);
|
||||
return file;
|
||||
}
|
||||
|
||||
function isTextAsset(fileName = "", contentType = "") {
|
||||
return contentType.startsWith("text/") || contentType.includes("json") || /\.(json|txt|md|csv)$/i.test(fileName);
|
||||
}
|
||||
|
||||
function normalizeAsset(row) {
|
||||
const metadata = row.currentVersion?.metadata || {};
|
||||
const rawKind = row.kind || "reference";
|
||||
const currentVersion = row.currentVersion || {};
|
||||
return {
|
||||
...row,
|
||||
rawKind,
|
||||
kind: assetKindLabels[rawKind] || rawKind,
|
||||
status: row.lockStatus || row.lock_status || "draft",
|
||||
currentVersionId: row.currentVersionId || row.current_version_id || row.currentVersion?.id || "",
|
||||
version: row.currentVersion?.version_number ? `v${row.currentVersion.version_number}` : "v1",
|
||||
initial: metadata.initial || row.name?.slice(0, 1) || "资",
|
||||
subtitle: metadata.subtitle || "本地项目资产",
|
||||
usage: metadata.usage || "当前项目",
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
detail: metadata.detail || "待补充资产描述",
|
||||
lock: metadata.lock || "待补充连续性备注",
|
||||
rightsStatus: row.currentVersion?.rights_status || "needs-evidence",
|
||||
versions: row.versions || [],
|
||||
bindings: row.bindings || [],
|
||||
fileName: currentVersion.fileName || currentVersion.file_name || "",
|
||||
mimeType: currentVersion.mimeType || currentVersion.mime_type || metadata.mimeType || "application/octet-stream",
|
||||
fileSize: Number(currentVersion.fileSize ?? currentVersion.file_size ?? metadata.size ?? 0),
|
||||
contentSha256: currentVersion.contentSha256 || currentVersion.content_sha256 || ""
|
||||
};
|
||||
}
|
||||
|
||||
function readFileBase64(file) {
|
||||
return file.arrayBuffer().then((buffer) => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
});
|
||||
}
|
||||
|
||||
function inferAssetKind(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith(".safetensors") || name.endsWith(".ckpt") || name.endsWith(".pt")) return "lora";
|
||||
if (file.type.startsWith("audio/")) return "voice";
|
||||
if (file.type === "application/json" || name.endsWith(".json")) return "style";
|
||||
return "reference";
|
||||
}
|
||||
|
||||
export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const [filter, setFilter] = useState("全部");
|
||||
const [query, setQuery] = useState("");
|
||||
const [assets, setAssets] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState("");
|
||||
const [previewText, setPreviewText] = useState("");
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showBinding, setShowBinding] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [uploadForm, setUploadForm] = useState({ name: "", kind: "reference", subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [pendingFile, setPendingFile] = useState(null);
|
||||
const [bindingForm, setBindingForm] = useState({ shotId: project.shots[0]?.id || "", usageRole: "continuity" });
|
||||
const inputRef = useRef(null);
|
||||
const versionInputRef = useRef(null);
|
||||
const filtered = assets.filter((asset) => (filter === "全部" || asset.kind === filter) && `${asset.name} ${asset.subtitle} ${asset.tags.join(" ")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const selected = assets.find((asset) => asset.id === selectedId) || filtered[0] || assets[0];
|
||||
const lockedCount = assets.filter((asset) => asset.status === "locked").length;
|
||||
|
||||
async function loadAssets() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssets(contextOverrides);
|
||||
const next = (payload.assets || []).map(normalizeAsset);
|
||||
setAssets(next);
|
||||
setSelectedId((current) => next.some((item) => item.id === current) ? current : next[0]?.id || "");
|
||||
setLoadError("");
|
||||
} catch (error) {
|
||||
setLoadError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAssets(); }, [project.series?.id, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl = "";
|
||||
async function loadPreview() {
|
||||
setPreviewUrl("");
|
||||
setPreviewType("");
|
||||
setPreviewText("");
|
||||
setPreviewError("");
|
||||
setPreviewLoading(false);
|
||||
if (!selected?.id || !selected.currentVersion?.storage_path || selected.currentVersion.storage_path.endsWith("metadata.json")) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssetContent(selected.id, contextOverrides);
|
||||
if (cancelled) return;
|
||||
setPreviewType(payload.contentType);
|
||||
if (isTextAsset(selected.fileName, payload.contentType)) {
|
||||
const text = await payload.blob.text();
|
||||
if (!cancelled) setPreviewText(text.slice(0, 24000));
|
||||
} else {
|
||||
objectUrl = URL.createObjectURL(payload.blob);
|
||||
if (!cancelled) setPreviewUrl(objectUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) setPreviewError(error.message);
|
||||
} finally {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
loadPreview();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [selected?.id, selected?.currentVersion?.storage_path, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
function replaceAsset(nextAsset) {
|
||||
const normalized = normalizeAsset(nextAsset);
|
||||
setAssets((current) => current.some((item) => item.id === normalized.id) ? current.map((item) => item.id === normalized.id ? normalized : item) : [normalized, ...current]);
|
||||
setSelectedId(normalized.id);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function prepareUpload(file) {
|
||||
try {
|
||||
const checked = validateAssetFile(file);
|
||||
const name = checked.name.replace(/\.[^.]+$/, "") || checked.name;
|
||||
setPendingFile(checked);
|
||||
setUploadForm({ name, kind: inferAssetKind(checked), subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setShowUpload(true);
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
event.target.value = "";
|
||||
prepareUpload(file);
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
prepareUpload(event.dataTransfer.files?.[0]);
|
||||
}
|
||||
|
||||
async function submitUpload(event) {
|
||||
event.preventDefault();
|
||||
if (!pendingFile) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const asset = await uploadAsset({
|
||||
data: await readFileBase64(pendingFile),
|
||||
fileName: pendingFile.name,
|
||||
name: uploadForm.name.trim() || pendingFile.name,
|
||||
kind: uploadForm.kind,
|
||||
subtitle: uploadForm.subtitle,
|
||||
usage: uploadForm.usage,
|
||||
detail: uploadForm.detail,
|
||||
lock: uploadForm.lock,
|
||||
mimeType: pendingFile.type || "application/octet-stream",
|
||||
rightsStatus: uploadForm.rightsStatus,
|
||||
tags: uploadForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
|
||||
}, contextOverrides);
|
||||
replaceAsset(asset.asset);
|
||||
setShowUpload(false);
|
||||
setPendingFile(null);
|
||||
setNotice(`本地资产“${pendingFile.name}”已写入资产库,SHA-256、文件大小和版本台账已登记。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate(event) {
|
||||
event.preventDefault();
|
||||
if (!assetForm.name.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await createAsset({ ...assetForm, name: assetForm.name.trim(), tags: assetForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean) }, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowCreate(false);
|
||||
setAssetForm({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setNotice(`资产“${payload.asset.name}”已创建为 v1 草稿。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLock() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const nextStatus = selected.status === "locked" ? "review" : "locked";
|
||||
const payload = await updateAssetLock(selected.id, nextStatus, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setNotice(`${selected.name} 已${nextStatus === "locked" ? "锁定" : "转为待复核"},后续镜头会读取最新状态。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function bindSelected(event) {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await bindAssetToShot(selected.id, bindingForm, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowBinding(false);
|
||||
setNotice(`${selected.name} 已绑定到 ${bindingForm.shotId},生成任务会继承该资产版本。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createVersion() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await createAssetVersion(selected.id, { versionNote: "从资产检查器创建的新版本", rightsStatus: selected.rightsStatus }, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setNotice(`${selected.name} 已创建 ${normalizeAsset(payload.asset).version} 版本。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVersionUpload(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !selected) return;
|
||||
event.target.value = "";
|
||||
try {
|
||||
validateAssetFile(file);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await uploadAssetVersion(selected.id, {
|
||||
data: await readFileBase64(file),
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
rightsStatus: selected.rightsStatus,
|
||||
versionNote: "从资产库上传新文件版本"
|
||||
}, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setNotice(`${selected.name} 已上传文件版本 v${normalizeAsset(payload.asset).version},SHA-256 已登记。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySelected() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await verifyAsset(selected.id, contextOverrides);
|
||||
setNotice(payload.verification.verified ? `${selected.name} 当前版本完整性校验通过。` : `${selected.name} 当前版本完整性校验失败,请检查文件是否被替换。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await fetchAssetContent(selected.id, contextOverrides);
|
||||
const url = URL.createObjectURL(payload.blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = selected.fileName || `${selected.name}-v${selected.currentVersion?.version_number || 1}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setNotice(`${selected.name} 当前版本已准备本地下载。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreVersion(version) {
|
||||
if (!selected || selected.currentVersionId === version.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await restoreAssetVersion(selected.id, version.id, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowVersions(false);
|
||||
setNotice(`${selected.name} 已恢复到 v${version.version_number},后续镜头将继承该版本。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader
|
||||
kicker="CREATOR SUITE / ASSET LIBRARY"
|
||||
title="资产库"
|
||||
description={`${project.series.title} · 角色、场景、道具、风格和本地 LoRA 统一管理,所有生产镜头从锁定资产继承。`}
|
||||
actions={<><input ref={inputRef} className="visually-hidden" type="file" accept="image/*,audio/*,video/*,text/*,application/json,.safetensors,.ckpt,.pt,.bin" onChange={handleUpload} /><input ref={versionInputRef} className="visually-hidden" type="file" accept="image/*,audio/*,video/*,text/*,application/json,.safetensors,.ckpt,.pt,.bin" onChange={handleVersionUpload} /><button className="subtle" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={15} />导入本地资产</button><button className="primary" onClick={() => setShowCreate(true)} disabled={busy}><Plus size={15} />新建资产</button></>}
|
||||
/>
|
||||
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
|
||||
{loadError && <div className="suite-notice warning"><CircleAlert size={15} />资产库读取失败:{loadError}<button onClick={loadAssets} disabled={loading}><RefreshCw size={14} />重试</button></div>}
|
||||
<div className="suite-metric-grid">
|
||||
<SuiteMetric icon={Images} label="资产总数" value={assets.length} detail="当前项目可复用" tone="ok" />
|
||||
<SuiteMetric icon={LockKeyhole} label="已锁定" value={`${lockedCount}/${assets.length}`} detail="连续性继承源" tone="ok" />
|
||||
<SuiteMetric icon={RefreshCw} label="版本" value={assets.reduce((sum, item) => sum + item.versions.length, 0)} detail="已登记版本" />
|
||||
<SuiteMetric icon={ShieldCheck} label="版权证据" value={`${assets.filter((item) => item.rightsStatus === "approved").length}/${assets.length}`} detail="未确认资产不会进入正式批次" tone={assets.some((item) => item.rightsStatus !== "approved") ? "warn" : "ok"} />
|
||||
</div>
|
||||
<div className="asset-library-layout">
|
||||
<aside className="suite-filter-rail">
|
||||
<span className="suite-filter-title">资产类型</span>
|
||||
{["全部", ...assetKindOptions.map(([, label]) => label)].map((item) => <button key={item} className={filter === item ? "selected" : ""} onClick={() => setFilter(item)}>{item}<span>{item === "全部" ? assets.length : assets.filter((asset) => asset.kind === item).length}</span></button>)}
|
||||
<div className="suite-rail-divider" />
|
||||
<span className="suite-filter-title">锁定状态</span>
|
||||
<div className="suite-rail-note"><LockKeyhole size={14} /><span>角色、服装、道具、天气和镜头继承当前 ledger。</span></div>
|
||||
</aside>
|
||||
<section className="suite-library-main">
|
||||
<div className="suite-toolbar"><div className="suite-search"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索资产、标签或角色" /></div><span>{loading ? "读取中…" : `${filtered.length} 个资产`}</span></div>
|
||||
<div className="asset-intake-strip" onDragOver={(event) => event.preventDefault()} onDrop={handleDrop}>
|
||||
<div className="asset-intake-copy"><HardDrive size={17} /><div><strong>本地资产入口</strong><span>单文件不超过 12MB,导入后自动登记 SHA-256、版本和本地存储路径。</span></div></div>
|
||||
<button className="icon-text-button" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={14} />选择文件</button>
|
||||
</div>
|
||||
<div className="asset-card-grid">
|
||||
{filtered.map((asset) => <button key={asset.id} className={`asset-card ${selected?.id === asset.id ? "selected" : ""}`} onClick={() => setSelectedId(asset.id)}><AssetVisual asset={asset} /><div className="asset-card-body"><div className="asset-card-title"><strong>{asset.name}</strong><SuiteStatus status={asset.status} /></div><span>{asset.kind} · {asset.subtitle}</span><div className="asset-tag-row">{asset.tags.slice(0, 2).map((tag) => <em key={tag}>{tag}</em>)}</div><small>{asset.version} · {asset.usage}</small></div></button>)}
|
||||
</div>
|
||||
{!filtered.length && <div className="suite-empty"><Search size={18} />{loading ? "正在读取资产库…" : "没有匹配资产"}</div>}
|
||||
</section>
|
||||
{selected && <aside className="asset-inspector"><div className="inspector-heading"><div><span className="card-kicker">ASSET INSPECTOR</span><h3>{selected.name}</h3></div><SuiteStatus status={selected.status} /></div>{previewLoading ? <div className="asset-preview-placeholder"><RefreshCw size={17} />读取本地预览…</div> : previewText ? <pre className="asset-text-preview">{previewText}</pre> : previewUrl && previewType.startsWith("image/") ? <img className="asset-media-preview" src={previewUrl} alt={`${selected.name} 预览`} /> : previewUrl && previewType.startsWith("audio/") ? <audio className="asset-media-preview" controls src={previewUrl} /> : previewUrl && previewType.startsWith("video/") ? <video className="asset-media-preview" controls src={previewUrl} /> : <AssetVisual asset={selected} />}{previewError && <p className="asset-preview-error">文件预览不可用:{previewError}</p>}{selected.fileName && <div className="asset-file-meta"><FileText size={14} /><div><strong>{selected.fileName}</strong><span>{formatBytes(selected.fileSize)} · {selected.mimeType}</span></div></div>}{selected.contentSha256 && <div className="asset-integrity-line"><span>SHA-256</span><code title={selected.contentSha256}>{selected.contentSha256.slice(0, 16)}…</code><button className="icon-text-button" onClick={verifySelected} disabled={busy}><Check size={13} />校验</button></div>}<dl className="suite-definition-list"><dt>类型</dt><dd>{selected.kind}</dd><dt>版本</dt><dd>{selected.version}</dd><dt>文件大小</dt><dd>{selected.fileSize ? formatBytes(selected.fileSize) : "无文件"}</dd><dt>存储路径</dt><dd className="mono">{selected.currentVersion?.storage_path || "仅有元数据"}</dd><dt>使用范围</dt><dd>{selected.usage}</dd><dt>版权/授权</dt><dd>{selected.rightsStatus === "approved" ? "已确认" : "待补证据"}</dd><dt>绑定镜头</dt><dd>{selected.bindings.length ? selected.bindings.map((item) => item.shot_id).join("、") : "尚未绑定"}</dd></dl><div className="inspector-copy"><span>视觉锁</span><p>{selected.detail}</p></div><div className="inspector-copy"><span>连续性备注</span><p>{selected.lock}</p></div><div className="inspector-actions"><button className="subtle" onClick={() => setShowVersions(true)}><RefreshCw size={15} />版本记录</button><button className="subtle" onClick={downloadSelected} disabled={busy || !selected.fileSize}><Download size={15} />下载当前版</button><button className="primary" onClick={() => setShowBinding(true)}><LockKeyhole size={15} />绑定到镜头</button><button className="subtle" onClick={toggleLock} disabled={busy}><ShieldCheck size={15} />{selected.status === "locked" ? "转待复核" : "锁定版本"}</button></div></aside>}
|
||||
</div>
|
||||
{showUpload && pendingFile && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && (setShowUpload(false), setPendingFile(null))}><form className="suite-modal" onSubmit={submitUpload}><div className="suite-modal-head"><div><span className="card-kicker">LOCAL ASSET INTAKE</span><h3>导入资产</h3></div><button type="button" className="icon-only-button" onClick={() => { setShowUpload(false); setPendingFile(null); }} aria-label="关闭"><XCircle size={17} /></button></div><div className="upload-file-summary"><FileText size={19} /><div><strong>{pendingFile.name}</strong><span>{formatBytes(pendingFile.size)} · {pendingFile.type || "application/octet-stream"}</span></div><SuiteStatus status="needs-evidence" /></div><div className="suite-form-grid"><label>资产名称<input value={uploadForm.name} onChange={(event) => setUploadForm((current) => ({ ...current, name: event.target.value }))} required /></label><label>资产类型<select value={uploadForm.kind} onChange={(event) => setUploadForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={uploadForm.subtitle} onChange={(event) => setUploadForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={uploadForm.usage} onChange={(event) => setUploadForm((current) => ({ ...current, usage: event.target.value }))} /></label><label>版权状态<select value={uploadForm.rightsStatus} onChange={(event) => setUploadForm((current) => ({ ...current, rightsStatus: event.target.value }))}><option value="needs-evidence">待版权证据</option><option value="submitted">已提交证据</option></select></label><label>标签(逗号分隔)<input value={uploadForm.tags} onChange={(event) => setUploadForm((current) => ({ ...current, tags: event.target.value }))} /></label><label className="full-span">视觉锁<textarea value={uploadForm.detail} onChange={(event) => setUploadForm((current) => ({ ...current, detail: event.target.value }))} placeholder="角色外观、场景构图、服装或风格锚点" /></label><label className="full-span">连续性备注<textarea value={uploadForm.lock} onChange={(event) => setUploadForm((current) => ({ ...current, lock: event.target.value }))} placeholder="后续镜头必须继承的锁定信息" /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => { setShowUpload(false); setPendingFile(null); }}>取消</button><button type="submit" className="primary" disabled={busy}><Upload size={15} />{busy ? "写入中…" : "写入资产库"}</button></div></form></div>}
|
||||
{showCreate && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowCreate(false)}><form className="suite-modal" onSubmit={submitCreate}><div className="suite-modal-head"><div><span className="card-kicker">NEW ASSET</span><h3>新建资产</h3></div><button type="button" className="icon-only-button" onClick={() => setShowCreate(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="suite-form-grid"><label>资产名称<input value={assetForm.name} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} autoFocus required /></label><label>资产类型<select value={assetForm.kind} onChange={(event) => setAssetForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={assetForm.subtitle} onChange={(event) => setAssetForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={assetForm.usage} onChange={(event) => setAssetForm((current) => ({ ...current, usage: event.target.value }))} /></label><label className="full-span">标签(逗号分隔)<input value={assetForm.tags} onChange={(event) => setAssetForm((current) => ({ ...current, tags: event.target.value }))} placeholder="角色锁, 原创, 待审核" /></label><label className="full-span">视觉锁<textarea value={assetForm.detail} onChange={(event) => setAssetForm((current) => ({ ...current, detail: event.target.value }))} /></label><label className="full-span">连续性备注<textarea value={assetForm.lock} onChange={(event) => setAssetForm((current) => ({ ...current, lock: event.target.value }))} /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowCreate(false)}>取消</button><button type="submit" className="primary" disabled={busy}><Plus size={15} />创建资产</button></div></form></div>}
|
||||
{showVersions && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowVersions(false)}><section className="suite-modal"><div className="suite-modal-head"><div><span className="card-kicker">VERSION HISTORY</span><h3>{selected.name}</h3></div><button className="icon-only-button" onClick={() => setShowVersions(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="version-list">{selected.versions.map((version) => <div key={version.id}><div><strong>v{version.version_number}{selected.currentVersionId === version.id ? " · 当前" : ""}</strong><span>{version.metadata?.versionNote || "版本记录"}</span></div><SuiteStatus status={version.rights_status === "approved" ? "approved" : "needs-evidence"} /><small>{version.storage_path}{version.content_sha256 ? ` · SHA ${version.content_sha256.slice(0, 12)}…` : ""}</small>{selected.currentVersionId !== version.id && <button className="icon-text-button" onClick={() => restoreVersion(version)} disabled={busy}><RefreshCw size={13} />恢复此版本</button>}</div>)}</div><div className="suite-modal-actions"><button className="subtle" onClick={() => setShowVersions(false)}>关闭</button><button className="subtle" onClick={() => versionInputRef.current?.click()} disabled={busy}><Upload size={15} />上传新版本</button><button className="primary" onClick={createVersion} disabled={busy}><Plus size={15} />创建空白版本</button></div></section></div>}
|
||||
{showBinding && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowBinding(false)}><form className="suite-modal compact" onSubmit={bindSelected}><div className="suite-modal-head"><div><span className="card-kicker">SHOT BINDING</span><h3>绑定到镜头</h3></div><button type="button" className="icon-only-button" onClick={() => setShowBinding(false)} aria-label="关闭"><XCircle size={17} /></button></div><p className="suite-modal-copy">绑定后,生成任务会把“{selected.name}”的当前版本作为连续性输入。</p><div className="suite-form-grid"><label>镜头<select value={bindingForm.shotId} onChange={(event) => setBindingForm((current) => ({ ...current, shotId: event.target.value }))}>{project.shots.map((shot) => <option key={shot.id} value={shot.id}>{shot.id} · {shot.title}</option>)}</select></label><label>使用角色<select value={bindingForm.usageRole} onChange={(event) => setBindingForm((current) => ({ ...current, usageRole: event.target.value }))}><option value="character">角色</option><option value="location">场景</option><option value="prop">道具</option><option value="style">风格</option><option value="continuity">连续性</option></select></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowBinding(false)}>取消</button><button type="submit" className="primary" disabled={busy}><LockKeyhole size={15} />确认绑定</button></div></form></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoiceStudioPage({ project, onQueueJob, contextOverrides, canApproveVoice = false }) {
|
||||
const lines = useMemo(() => project.shots.flatMap((shot) => shot.voiceLines.map((line) => ({ ...line, shotId: shot.id, shotTitle: shot.title }))), [project]);
|
||||
const jobs = project.productionJobs || [];
|
||||
const [view, setView] = useState("profiles");
|
||||
const [selectedLineId, setSelectedLineId] = useState(lines[0]?.id || "");
|
||||
const [auditionedLocal, setAuditionedLocal] = useState([]);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [voiceAssets, setVoiceAssets] = useState([]);
|
||||
const [voiceAssetsLoading, setVoiceAssetsLoading] = useState(true);
|
||||
const [rightsNotes, setRightsNotes] = useState({});
|
||||
const selectedLine = lines.find((line) => line.id === selectedLineId) || lines[0];
|
||||
const lockedVoices = voiceAssetsLoading ? project.characters.filter((character) => character.voiceLock?.status === "approved").length : voiceAssets.filter((asset) => asset.currentVersion?.rights_status === "approved").length;
|
||||
const auditioned = useMemo(() => new Set([
|
||||
...auditionedLocal,
|
||||
...jobs.filter((job) => job.kind.includes("TTS") && job.output).flatMap((job) => lines.filter((line) => job.output.includes(line.id)).map((line) => line.id))
|
||||
]), [auditionedLocal, jobs, lines]);
|
||||
const aligned = useMemo(() => new Set(jobs.filter((job) => job.kind.includes("ASR") && job.output).flatMap((job) => lines.filter((line) => job.output.includes(line.id) || job.output.endsWith("/alignment.json") && job.shotId === line.shotId).map((line) => line.id))), [jobs, lines]);
|
||||
const alignmentPercent = lines.length ? Math.round((aligned.size / lines.length) * 100) : 0;
|
||||
|
||||
async function loadVoiceAssets() {
|
||||
setVoiceAssetsLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssets(contextOverrides);
|
||||
const next = (payload.assets || []).filter((asset) => asset.kind === "voice");
|
||||
setVoiceAssets(next);
|
||||
setRightsNotes((current) => Object.fromEntries(next.map((asset) => [asset.id, current[asset.id] || asset.currentVersion?.metadata?.rightsEvidence?.reference || asset.currentVersion?.metadata?.consentRef || ""])));
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setVoiceAssetsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadVoiceAssets(); }, [contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
async function updateVoiceRights(asset, rightsStatus) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const reference = String(rightsNotes[asset.id] || "").trim();
|
||||
const result = await updateAssetRights(asset.id, { rightsStatus, evidence: { reference, source: "voice-studio-review" } }, contextOverrides);
|
||||
setVoiceAssets((current) => current.map((item) => item.id === asset.id ? result.asset : item));
|
||||
setNotice(`${asset.name} 已更新为“${rightsStatus === "approved" ? "已确认" : rightsStatus === "submitted" ? "已提交证据" : rightsStatus === "rejected" ? "已驳回" : rightsStatus}”。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function audition() {
|
||||
if (!selectedLine) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onQueueJob?.("单句 TTS 试听", selectedLine.shotId, { adapter: "local-tts", output: `voices/auditions/${selectedLine.id}.wav` });
|
||||
setAuditionedLocal((current) => [...new Set([...current, selectedLine.id])]);
|
||||
setNotice(`已将“${selectedLine.id}”作为单句试听写入本地队列。正式批量配音仍需用户确认参考音频。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAlignment() {
|
||||
if (!selectedLine) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onQueueJob?.("ASR 台词校验", selectedLine.shotId, { adapter: "owned-model-platform", output: `qa/${selectedLine.shotId}/${selectedLine.id}-alignment.json` });
|
||||
setNotice(`已将“${selectedLine.id}”的 ASR/字幕对齐任务写入队列。完成后会回填词级时间轴。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader kicker="CREATOR SUITE / VOICE & SUBTITLE" title="声音与字幕" description="固定角色声线、单句试听、字幕台词和 ASR 对齐集中管理;未确认的参考音频不会进入批量生产。" actions={<button className="subtle" onClick={() => setNotice("当前声音策略:IndexTTS-2.5 / paraformer-zh-long;H3 原生音轨和旧 probe 不可作为正式参考音频。")}><ShieldCheck size={15} />查看声音策略</button>} />
|
||||
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
|
||||
<div className="suite-metric-grid"><SuiteMetric icon={Mic2} label="角色声线" value={`${project.characters.length}`} detail={`${lockedVoices} 个已确认`} tone={lockedVoices ? "ok" : "warn"} /><SuiteMetric icon={MessageSquareText} label="台词" value={`${lines.length} 句`} detail="中文对白" /><SuiteMetric icon={AudioLines} label="试听记录" value={`${auditioned.size}`} detail="单句,不批量" tone="ok" /><SuiteMetric icon={CheckCircle2} label="ASR 对齐" value={`${alignmentPercent}%`} detail={`${aligned.size}/${lines.length} 句已有任务`} tone={alignmentPercent === 100 ? "ok" : "warn"} /></div>
|
||||
<div className="voice-layout">
|
||||
<section className="studio-card voice-main-card"><div className="suite-tabs">{[["profiles", "角色声线", Mic2], ["lines", "台词表", MessageSquareText], ["alignment", "ASR 对齐", AudioLines]].map(([id, label, Icon]) => <button key={id} className={view === id ? "selected" : ""} onClick={() => setView(id)}><Icon size={15} />{label}</button>)}</div>
|
||||
{view === "profiles" && <div className="voice-profile-grid">{project.characters.map((character) => <article key={character.id} className="voice-profile"><div className="voice-avatar">{character.name.slice(0, 1)}</div><div className="voice-profile-main"><div className="voice-profile-title"><strong>{character.name}</strong><SuiteStatus status={character.voiceLock?.status} /></div><span>{character.voiceLock?.voiceId}</span><p>{character.voiceLock?.tone}</p><div className="voice-meta"><span>{character.voiceLock?.ttsModel}</span><span>{character.voiceLock?.asrModel}</span></div></div><button className="icon-text-button" onClick={() => setNotice(`${character.name}:${character.voiceLock?.referencePolicy}`)}><ChevronRight size={15} />详情</button></article>)}</div>}
|
||||
{view === "lines" && <div className="voice-lines-table"><div className="voice-lines-head"><span>台词</span><span>角色</span><span>镜头</span><span>目标时长</span><span>口型策略</span><span>状态</span></div>{lines.map((line) => <button key={line.id} className={selectedLine?.id === line.id ? "selected" : ""} onClick={() => setSelectedLineId(line.id)}><strong>{line.text}</strong><span>{project.characters.find((character) => character.id === line.characterId)?.name}</span><span>{line.shotId}</span><span>{line.targetDurationSec}s</span><span>{line.mouthPlan}</span><SuiteStatus status={auditioned.has(line.id) ? "ready" : "pending"} /></button>)}</div>}
|
||||
{view === "alignment" && <div className="alignment-list">{lines.map((line) => <div key={line.id}><div><strong>{line.id} · {line.text}</strong><span>{line.shotTitle} · {line.audioFile}</span></div><div className="alignment-track"><span style={{ width: aligned.has(line.id) ? "100%" : auditioned.has(line.id) ? "38%" : "0%" }} /></div><SuiteStatus status={aligned.has(line.id) ? "ready" : auditioned.has(line.id) ? "running" : "pending"} /></div>)}</div>}
|
||||
</section>
|
||||
<aside className="studio-card voice-inspector"><div className="card-heading-row"><div><span className="card-kicker">LINE INSPECTOR</span><h3>单句试听</h3></div><FileAudio size={18} /></div>{selectedLine ? <><div className="selected-line"><span>{selectedLine.shotId} · {selectedLine.id}</span><strong>{selectedLine.text}</strong><small>{selectedLine.emotion} · 目标 {selectedLine.targetDurationSec}s</small></div><div className="voice-lock-box"><ShieldCheck size={16} /><div><strong>正式声音门</strong><p>必须使用用户确认的自然参考音频。正脸长对白不作为默认镜头策略。</p></div></div><div className="voice-action-stack"><button className="primary full-width" onClick={audition} disabled={busy}><Play size={15} />生成这一句试听</button><button className="subtle full-width" onClick={runAlignment} disabled={busy}><AudioLines size={15} />运行 ASR 对齐</button><button className="subtle full-width" onClick={() => { downloadJson(`${project.episode.id}-voice-lines.json`, buildVoiceTable(project)); setNotice("字幕字段 JSON 已准备下载,包含角色、台词、镜头、目标时长和 ASR 字段。"); }}><FolderOpen size={15} />下载字幕字段 JSON</button></div></> : <div className="suite-empty">暂无台词</div>}</aside>
|
||||
</div>
|
||||
<section className="studio-card voice-approval-card"><div className="card-heading-row"><div><span className="card-kicker">VOICE RIGHTS / APPROVAL</span><h3>参考音频授权台账</h3><p className="muted-copy">声音资产必须有可追溯的授权证据;未批准的参考音频只能做单句试听,不能进入批量 TTS。</p></div><ShieldCheck size={18} /></div>{voiceAssetsLoading ? <div className="suite-empty">正在读取声音资产…</div> : voiceAssets.length ? <div className="voice-rights-list">{voiceAssets.map((asset) => { const rightsStatus = asset.currentVersion?.rights_status || "needs-evidence"; const evidence = asset.currentVersion?.metadata?.rightsEvidence?.reference || asset.currentVersion?.metadata?.consentRef || ""; return <div className="voice-rights-row" key={asset.id}><div className="voice-rights-title"><div className="voice-avatar"><FileAudio size={16} /></div><div><strong>{asset.name}</strong><span>{asset.currentVersion?.version_number ? `v${asset.currentVersion.version_number}` : "v1"} · {asset.currentVersion?.storage_path}</span></div></div><SuiteStatus status={rightsStatus} /><input aria-label={`${asset.name} 授权证据引用`} value={rightsNotes[asset.id] ?? evidence} onChange={(event) => setRightsNotes((current) => ({ ...current, [asset.id]: event.target.value }))} placeholder="授权合同、同意书或本地证据路径" /><div className="voice-rights-actions"><button className="subtle" onClick={() => updateVoiceRights(asset, "submitted")} disabled={busy}>提交证据</button>{canApproveVoice && <button className="primary" onClick={() => updateVoiceRights(asset, "approved")} disabled={busy || !String(rightsNotes[asset.id] || evidence).trim()}>批准使用</button>}<button className="danger-button" onClick={() => updateVoiceRights(asset, "rejected")} disabled={busy}>驳回</button></div></div>; })}</div> : <div className="suite-empty">当前项目还没有声音资产,请先导入一条本地参考音频。</div>}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function jobStatus(project, shotId, kind) {
|
||||
const job = (project.productionJobs || []).find((item) => item.shotId === shotId && item.kind.includes(kind));
|
||||
return job?.status || "pending";
|
||||
}
|
||||
|
||||
export function BatchProductionPage({ project, onQueueJob, contextOverrides }) {
|
||||
const [selectedShots, setSelectedShots] = useState(project.shots.map((shot) => shot.id));
|
||||
const [taskKind, setTaskKind] = useState("单画面关键帧");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [executionLogs, setExecutionLogs] = useState([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const allSelected = selectedShots.length === project.shots.length;
|
||||
function toggleShot(id) { setSelectedShots((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]); }
|
||||
async function runBatch() {
|
||||
if (!selectedShots.length) { setNotice("请先选择至少一个镜头。"); return; }
|
||||
setNotice(`正在将 ${selectedShots.length} 个镜头拆成独立任务…`);
|
||||
try {
|
||||
for (const shotId of selectedShots) await onQueueJob?.(taskKind, shotId);
|
||||
setNotice(`已写入 ${selectedShots.length} 个独立任务;每个镜头仍保持一次一个完整画面。`);
|
||||
} catch (error) { setNotice(error.message); }
|
||||
}
|
||||
async function loadExecutionLogs() {
|
||||
setShowLogs(true);
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const result = await fetchJobs({ ...contextOverrides, projectId: contextOverrides?.projectId });
|
||||
setExecutionLogs(result.jobs || []);
|
||||
} catch (error) {
|
||||
setNotice(`执行日志读取失败:${error.message}`);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader kicker="CREATOR SUITE / BATCH PRODUCTION" title="批量生产" description="把选中的镜头拆成可追踪的独立 job,逐镜头继承角色锁、场景锁、实际末帧和 local-only 成本策略。" actions={<button className="primary" onClick={runBatch}><WandSparkles size={15} />运行选中任务</button>} />
|
||||
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
|
||||
<div className="batch-control-bar"><div className="batch-kind"><span>任务类型</span><select value={taskKind} onChange={(event) => setTaskKind(event.target.value)}><option>单画面关键帧</option><option>首尾帧图生视频</option><option>批量跑单画面 QA</option><option>剪辑合成清单</option></select></div><div className="batch-scope"><span>当前范围</span><strong>{selectedShots.length}/{project.shots.length} 个镜头</strong><button className="context-link" onClick={() => setSelectedShots(allSelected ? [] : project.shots.map((shot) => shot.id))}>{allSelected ? <Square size={15} /> : <CheckSquare2 size={15} />}{allSelected ? "清空" : "全选"}</button></div><div className="batch-policy"><ShieldCheck size={15} /><span>local-only · 禁止拼图/多格/多时间点</span></div></div>
|
||||
<div className="batch-layout"><section className="studio-card wide-card"><div className="section-bar"><div><h3>镜头批次</h3><span>每行对应一个可重试、可审计的生成任务</span></div><span className="suite-status ok"><span />{project.shots.length} shots</span></div><div className="batch-table"><div className="batch-table-head"><span>选择</span><span>镜头</span><span>连续性源</span><span>关键帧</span><span>视频</span><span>声音</span><span>QA</span></div>{project.shots.map((shot) => <button key={shot.id} className={selectedShots.includes(shot.id) ? "selected" : ""} onClick={() => toggleShot(shot.id)}><span>{selectedShots.includes(shot.id) ? <CheckSquare2 size={17} /> : <Square size={17} />}</span><div><strong>{shot.id} · {shot.title}</strong><small>{shot.durationSec}s · {shot.camera}</small></div><span className="mono">{shot.firstFrame === "AUTO_PREVIOUS_ACTUAL_LAST_FRAME" ? "actual-last-frame" : "episode-start"}</span><SuiteStatus status={jobStatus(project, shot.id, "关键帧")} /><SuiteStatus status={jobStatus(project, shot.id, "图生视频")} /><SuiteStatus status={jobStatus(project, shot.id, "TTS")} /><SuiteStatus status={jobStatus(project, shot.id, "QA")} /></button>)}</div></section><aside className="studio-card"><div className="card-heading-row"><div><span className="card-kicker">BATCH CONTRACT</span><h3>执行约束</h3></div><CircleAlert size={18} /></div><ul className="batch-contract-list"><li><Check size={14} />每个任务只绑定一个镜头</li><li><Check size={14} />图片输出数量固定为 1</li><li><Check size={14} />shot-02 / shot-03 继承上一段实际末帧</li><li><Check size={14} />云端连接器默认不进入批次</li><li><Check size={14} />失败后保留 job、attempt 和证据</li></ul><button className="subtle full-width" onClick={loadExecutionLogs}><FolderOpen size={15} />查看执行日志</button></aside></div>
|
||||
{showLogs && <section className="studio-card batch-log-card"><div className="card-heading-row"><div><span className="card-kicker">EXECUTION LOG</span><h3>批次执行日志</h3></div><div className="row-actions"><button className="subtle" onClick={loadExecutionLogs} disabled={logsLoading}><RefreshCw size={14} />刷新</button><button className="icon-only-button" onClick={() => setShowLogs(false)} title="关闭执行日志" aria-label="关闭执行日志"><XCircle size={15} /></button></div></div>{logsLoading ? <div className="suite-empty">读取任务日志…</div> : <div className="batch-log-table"><div><span>任务</span><span>镜头</span><span>状态</span><span>尝试</span><span>错误</span></div>{executionLogs.map((job) => <div key={job.id}><strong>{job.kind}</strong><span>{job.shotId || job.shot_id || "全局"}</span><SuiteStatus status={job.status} /><span>{job.attempts || job.attemptLog?.length || 0}</span><span>{job.errorMessage || "-"}</span></div>)}{!executionLogs.length && <div className="suite-empty">当前范围没有任务日志。</div>}</div>}</section>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectAssistantPage({ project, setActiveTab, allowedTabs = [] }) {
|
||||
const [messages, setMessages] = useState([{ role: "assistant", text: `我已载入《${project.series.title}》当前项目数据。可以检查连续性、拆解镜头、生成单画面 prompt 或整理交付清单。` }]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const actions = [
|
||||
{ id: "continuity", label: "检查连续性", icon: ShieldCheck, target: "qa" },
|
||||
{ id: "shot", label: "拆成镜头", icon: ClapperboardIcon, target: "director" },
|
||||
{ id: "prompt", label: "整理 prompt pack", icon: WandSparkles, target: "director" },
|
||||
{ id: "delivery", label: "检查交付", icon: FolderOpen, target: "export" }
|
||||
];
|
||||
const visibleActions = actions.filter((action) => !allowedTabs.length || allowedTabs.includes(action.target));
|
||||
function ClapperboardIcon(props) { return <Film {...props} />; }
|
||||
|
||||
async function ask(question, action) {
|
||||
setBusy(true);
|
||||
setMessages((current) => [...current, { role: "user", text: question }]);
|
||||
try {
|
||||
const result = await queryAssistant({ question, actionId: action?.id || "freeform" });
|
||||
setMessages((current) => [...current, { role: "assistant", text: result.answer || "项目助手没有返回可执行结果。" }]);
|
||||
const target = result.suggestedTabs?.find((tab) => !allowedTabs.length || allowedTabs.includes(tab)) || action?.target;
|
||||
if (target && (!allowedTabs.length || allowedTabs.includes(target))) setActiveTab(target);
|
||||
} catch (error) {
|
||||
setMessages((current) => [...current, { role: "assistant", text: `项目助手请求失败:${error.message}` }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function runAction(action) {
|
||||
ask(action.label, action);
|
||||
}
|
||||
|
||||
function submit(event) {
|
||||
event.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
setInput("");
|
||||
ask(text);
|
||||
}
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader kicker="CREATOR SUITE / PROJECT ASSISTANT" title="项目 AI 助手" description="助手只读取当前组织、工作区和项目上下文,输出可落到剧本、资产、分镜、QA 或交付模块的生产动作。" actions={<span className="assistant-local-badge"><Bot size={15} />本地规则 + 项目数据</span>} />
|
||||
<div className="assistant-layout"><section className="studio-card assistant-chat"><div className="assistant-chat-head"><div><span className="card-kicker">PROJECT CONTEXT</span><h3>{project.series.title} · {project.episode.title}</h3></div><span className="suite-status ok"><span />已连接</span></div><div className="assistant-message-list">{messages.map((message, index) => <div key={`${message.role}-${index}`} className={`assistant-message ${message.role}`}><span className="assistant-message-avatar">{message.role === "assistant" ? <Bot size={15} /> : "我"}</span><p>{message.text}</p></div>)}{busy && <div className="assistant-message assistant"><span className="assistant-message-avatar"><Bot size={15} /></span><p className="typing-dots">正在读取项目锁…</p></div>}</div><form className="assistant-input" onSubmit={submit}><input value={input} onChange={(event) => setInput(event.target.value)} placeholder="输入要检查的镜头、角色或交付问题" /><button className="primary" type="submit" disabled={busy}><MessageSquareText size={15} />发送</button></form></section><aside className="assistant-action-panel"><div className="card-heading-row"><div><span className="card-kicker">PRODUCTION ACTIONS</span><h3>快捷动作</h3></div><Sparkles size={18} /></div>{visibleActions.map((action) => { const Icon = action.icon; return <button key={action.id} className="assistant-action" disabled={busy} onClick={() => runAction(action)}><span className="assistant-action-icon"><Icon size={16} /></span><span><strong>{action.label}</strong><small>调用项目助手并进入对应生产模块</small></span><ChevronRight size={15} /></button>; })}<div className="assistant-context-card"><strong>当前硬约束</strong><span>一图一画面</span><span>实际末帧连续</span><span>固定声线</span><span>原创 / 本地优先</span></div></aside></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AlertTriangle, CheckCircle2, Download, Film, LockKeyhole, MessageSquare, RefreshCw, Send } from "lucide-react";
|
||||
import { fetchPublicDeliveryPortal, publicDeliveryFileUrl, submitPublicDeliveryFeedback } from "../lib/api";
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function DeliveryPortalPage({ token }) {
|
||||
const [payload, setPayload] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [feedbackName, setFeedbackName] = useState("");
|
||||
const [feedbackEmail, setFeedbackEmail] = useState("");
|
||||
const [feedbackMessage, setFeedbackMessage] = useState("");
|
||||
const [feedbackBusy, setFeedbackBusy] = useState(false);
|
||||
const [feedbackNotice, setFeedbackNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchPublicDeliveryPortal(token);
|
||||
setPayload(result);
|
||||
setFeedbackName(result.review?.reviewerName || result.portal?.recipientName || "");
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
setPayload(null);
|
||||
setError(nextError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback(decision) {
|
||||
if (decision === "changes_requested" && !feedbackMessage.trim()) {
|
||||
setFeedbackNotice("提出修改意见前,请填写具体反馈。");
|
||||
return;
|
||||
}
|
||||
setFeedbackBusy(true);
|
||||
setFeedbackNotice("");
|
||||
try {
|
||||
const result = await submitPublicDeliveryFeedback(token, {
|
||||
decision,
|
||||
reviewerName: feedbackName,
|
||||
reviewerEmail: feedbackEmail,
|
||||
message: feedbackMessage
|
||||
});
|
||||
setPayload(result);
|
||||
setFeedbackMessage("");
|
||||
setFeedbackNotice(decision === "approved" ? "已记录验收结果,感谢确认。" : "修改意见已提交给项目方。");
|
||||
} catch (nextError) {
|
||||
setFeedbackNotice(nextError.message);
|
||||
} finally {
|
||||
setFeedbackBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [token]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="delivery-portal-shell"><div className="delivery-portal-loading"><RefreshCw size={18} />正在读取交付资料…</div></div>;
|
||||
}
|
||||
|
||||
if (error || !payload) {
|
||||
const expired = error?.status === 410;
|
||||
return <div className="delivery-portal-shell"><main className="delivery-portal-error"><div className="delivery-portal-brand"><span><Film size={19} /></span><strong>AI 短剧交付门户</strong></div><div className="delivery-portal-error-icon"><AlertTriangle size={28} /></div><h1>{expired ? "访问链接已过期" : "暂时无法打开交付资料"}</h1><p>{expired ? "请联系项目方重新生成一个有效的交付访问链接。" : "链接可能已撤销、输入有误,或对应版本已经下线。"}</p><button className="primary" type="button" onClick={load}><RefreshCw size={15} />重新检查</button></main></div>;
|
||||
}
|
||||
|
||||
const portal = payload.portal || {};
|
||||
const delivery = payload.delivery || {};
|
||||
const files = payload.files || [];
|
||||
const review = payload.review || { status: "pending", count: 0 };
|
||||
const reviewStatusLabel = review.status === "approved" ? "已验收" : review.status === "changes_requested" ? "需修改" : "待客户确认";
|
||||
return <div className="delivery-portal-shell">
|
||||
<header className="delivery-portal-topbar">
|
||||
<div className="delivery-portal-brand"><span><Film size={19} /></span><strong>AI 短剧交付门户</strong></div>
|
||||
<div className="delivery-portal-secure"><LockKeyhole size={14} />本地发行 · 受控访问</div>
|
||||
</header>
|
||||
<main className="delivery-portal-main">
|
||||
<section className="delivery-portal-intro">
|
||||
<span className="card-kicker">DELIVERY PORTAL</span>
|
||||
<h1>{delivery.projectName || "项目交付资料"}</h1>
|
||||
<p>项目方已发布一份可下载的交付版本,请使用下方文件入口获取本次交付资料。</p>
|
||||
{portal.recipientName && <span className="delivery-portal-recipient">收件人:{portal.recipientName}</span>}
|
||||
</section>
|
||||
<section className="delivery-portal-summary">
|
||||
<div><span>交付版本</span><strong>{delivery.version || "未命名版本"}</strong></div>
|
||||
<div><span>发布时间</span><strong>{formatDate(delivery.publishedAt)}</strong></div>
|
||||
<div><span>链接有效期</span><strong>{formatDate(portal.expiresAt)}</strong></div>
|
||||
<div><span>剩余下载</span><strong>{portal.downloadsRemaining ?? 0} / {portal.maxDownloads ?? 0}</strong></div>
|
||||
</section>
|
||||
<section className="delivery-portal-files">
|
||||
<div className="delivery-portal-section-heading"><div><span className="card-kicker">FILES</span><h2>交付文件</h2></div><span className="delivery-portal-count">{files.length} 个文件</span></div>
|
||||
<div className="delivery-portal-file-list">
|
||||
{files.map((file) => <article className="delivery-portal-file" key={file.kind}><div className="delivery-portal-file-icon"><Download size={17} /></div><div><strong>{file.label}</strong><span>{file.fileName}</span><small>下载后会记录一次交付访问</small></div><a className="primary delivery-portal-download" href={publicDeliveryFileUrl(token, file.kind)}><Download size={14} />下载</a></article>)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="delivery-portal-review">
|
||||
<div className="delivery-portal-section-heading"><div><span className="card-kicker">CLIENT REVIEW</span><h2>客户验收</h2></div><span className={`delivery-review-status ${review.status}`}><MessageSquare size={13} />{reviewStatusLabel}</span></div>
|
||||
{review.message && <div className="delivery-portal-review-current"><strong>{review.reviewerName || "客户联系人"}</strong><span>{formatDate(review.submittedAt)}</span><p>{review.message}</p></div>}
|
||||
<div className="delivery-portal-review-form">
|
||||
<div className="delivery-portal-review-fields"><label>联系人<input value={feedbackName} onChange={(event) => setFeedbackName(event.target.value)} placeholder="姓名 / 部门" /></label><label>邮箱<input type="email" value={feedbackEmail} onChange={(event) => setFeedbackEmail(event.target.value)} placeholder="用于项目方识别" /></label></div>
|
||||
<label>反馈说明<textarea rows="4" value={feedbackMessage} onChange={(event) => setFeedbackMessage(event.target.value)} placeholder="如需修改,请写明具体镜头、时间点或交付问题;确认验收时可留空。" /></label>
|
||||
{feedbackNotice && <div className="delivery-portal-review-notice"><CheckCircle2 size={14} />{feedbackNotice}</div>}
|
||||
<div className="delivery-portal-review-actions"><button className="subtle" type="button" onClick={() => submitFeedback("changes_requested")} disabled={feedbackBusy}><Send size={14} />提交修改意见</button><button className="primary" type="button" onClick={() => submitFeedback("approved")} disabled={feedbackBusy}><CheckCircle2 size={14} />确认验收</button></div>
|
||||
</div>
|
||||
{review.count > 0 && <small className="delivery-portal-review-history">本链接已提交 {review.count} 次反馈,项目方可查看完整记录。</small>}
|
||||
</section>
|
||||
<footer className="delivery-portal-footer"><CheckCircle2 size={15} />发布版本:{delivery.version || "-"} · 已通过项目方交付审批</footer>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
export function ScenePreview({ shot, active }) {
|
||||
return (
|
||||
<div className={`scene-preview ${active ? "active" : ""}`}>
|
||||
<div className="rain"></div>
|
||||
<div className="canopy"></div>
|
||||
<div className="glass"></div>
|
||||
<div className="street-tree"></div>
|
||||
<div className="cones">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="puddle"></div>
|
||||
<div className="character chen">
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="character tang">
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="scene-caption">
|
||||
<strong>{shot.id}</strong>
|
||||
<span>{shot.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+919
@@ -0,0 +1,919 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "http://127.0.0.1:8787";
|
||||
const STORAGE_KEY = "ai-drama-platform-context";
|
||||
const SESSION_KEY = "ai-drama-platform-session";
|
||||
const DEVICE_KEY = "ai-drama-platform-device-id";
|
||||
|
||||
function readStoredContext() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredSession() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeviceId() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto?.getRandomValues?.(bytes);
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("") || `device-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function getClientDeviceId() {
|
||||
let deviceId = localStorage.getItem(DEVICE_KEY);
|
||||
if (!deviceId) {
|
||||
deviceId = createDeviceId();
|
||||
localStorage.setItem(DEVICE_KEY, deviceId);
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
export function getAuthSession() {
|
||||
return readStoredSession();
|
||||
}
|
||||
|
||||
export function saveAuthSession(session) {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
export function saveClientContext(next) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export function getClientContext() {
|
||||
return readStoredContext();
|
||||
}
|
||||
|
||||
function contextHeaders(overrides = {}) {
|
||||
const stored = readStoredContext();
|
||||
const session = readStoredSession();
|
||||
const context = { ...stored, ...overrides };
|
||||
const headers = { "content-type": "application/json", "x-device-id": getClientDeviceId() };
|
||||
if (session?.token) headers.authorization = `Bearer ${session.token}`;
|
||||
if (context.userId) headers["x-user-id"] = context.userId;
|
||||
if (context.organizationId) headers["x-organization-id"] = context.organizationId;
|
||||
if (context.workspaceId) headers["x-workspace-id"] = context.workspaceId;
|
||||
if (context.projectId) headers["x-project-id"] = context.projectId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}, contextOverrides = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...contextHeaders(contextOverrides),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function apiBlob(path, options = {}, contextOverrides = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...contextHeaders(contextOverrides),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return { blob: await response.blob(), contentType: response.headers.get("content-type") || "application/octet-stream" };
|
||||
}
|
||||
|
||||
export async function fetchPlatformContext(overrides = {}) {
|
||||
const payload = await apiFetch("/api/context", {}, overrides);
|
||||
const context = payload.context;
|
||||
if (context) {
|
||||
saveClientContext({
|
||||
userId: context.currentUser.id,
|
||||
organizationId: context.currentOrganization.id,
|
||||
workspaceId: context.currentWorkspace.id,
|
||||
projectId: context.currentProject?.id || ""
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchWorkItems(overrides = {}) {
|
||||
return apiFetch("/api/work-items", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchTasks({ status = "all", assignedTo = "", limit = 100 } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ status, limit: String(limit) });
|
||||
if (assignedTo) params.set("assignedTo", assignedTo);
|
||||
return apiFetch(`/api/tasks?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createTask(body, overrides = {}) {
|
||||
return apiFetch("/api/tasks", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateTask(taskId, body, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchTaskDetail(taskId, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/detail`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchTaskComments(taskId, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/comments`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createTaskComment(taskId, body, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/comments`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function addTaskLink(taskId, body, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/links`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function removeTaskLink(taskId, linkId, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/links/${encodeURIComponent(linkId)}`, { method: "DELETE" }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProjectActivity({ limit = 80 } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
return apiFetch(`/api/project-activity?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchNotifications({ limit = 60, unreadOnly = false } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (unreadOnly) params.set("unreadOnly", "1");
|
||||
return apiFetch(`/api/notifications?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function markNotificationRead(notificationId, read = true, overrides = {}) {
|
||||
return apiFetch(`/api/notifications/${encodeURIComponent(notificationId)}`, { method: "PATCH", body: JSON.stringify({ read }) }, overrides);
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(overrides = {}) {
|
||||
return apiFetch("/api/notifications/read-all", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchNotificationPreferences(overrides = {}) {
|
||||
return apiFetch("/api/notification-preferences", {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateNotificationPreference(category, enabled, overrides = {}) {
|
||||
return apiFetch(`/api/notification-preferences/${encodeURIComponent(category)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }, overrides);
|
||||
}
|
||||
|
||||
export async function login(body) {
|
||||
const payload = await apiFetch("/api/auth/login", { method: "POST", body: JSON.stringify(body) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function loginWithMfa(body) {
|
||||
const payload = await apiFetch("/api/auth/login/mfa", { method: "POST", body: JSON.stringify(body) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchSsoProviders() {
|
||||
return apiFetch("/api/auth/sso/providers", { headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
export function ssoStartUrl(providerId, options = {}) {
|
||||
const params = new URLSearchParams({ providerId: String(providerId || "") });
|
||||
const returnTo = options.returnTo || `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
params.set("returnTo", returnTo || "/");
|
||||
for (const key of ["organizationId", "workspaceId", "projectId"]) if (options[key]) params.set(key, options[key]);
|
||||
return `${API_BASE}/api/auth/sso/start?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function redeemSsoTicket(ticket) {
|
||||
const payload = await apiFetch("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function startMfaEnrollment(enrollmentToken) {
|
||||
return apiFetch("/api/auth/mfa/enroll/setup", { method: "POST", body: JSON.stringify({ enrollmentToken }) });
|
||||
}
|
||||
|
||||
export async function completeMfaEnrollment(body) {
|
||||
const payload = await apiFetch("/api/auth/mfa/enroll/enable", { method: "POST", body: JSON.stringify(body) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchMfaStatus(overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa", {}, overrides);
|
||||
}
|
||||
|
||||
export async function startMfaSetup(overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/setup", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function enableMfa(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/enable", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function cancelMfaSetup(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/setup/cancel", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function disableMfa(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/disable", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function previewInvitation(token) {
|
||||
return apiFetch(`/api/invitations/preview?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
export async function registerInvitedUser(body) {
|
||||
const payload = await apiFetch("/api/auth/register", { method: "POST", body: JSON.stringify(body) });
|
||||
saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchAuthSession() {
|
||||
const payload = await apiFetch("/api/auth/session");
|
||||
const current = readStoredSession();
|
||||
if (payload.session && current?.token) saveAuthSession({ ...current, ...payload.session });
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/password", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAuthSessions() {
|
||||
return apiFetch("/api/auth/sessions");
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId) {
|
||||
return apiFetch(`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function revokeOtherAuthSessions() {
|
||||
return apiFetch("/api/auth/sessions/revoke-others", { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function fetchAuthDevices() {
|
||||
return apiFetch("/api/auth/devices");
|
||||
}
|
||||
|
||||
export async function trustAuthDevice(deviceId) {
|
||||
return apiFetch(`/api/auth/devices/${encodeURIComponent(deviceId)}/trust`, { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function untrustAuthDevice(deviceId) {
|
||||
return apiFetch(`/api/auth/devices/${encodeURIComponent(deviceId)}/untrust`, { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function fetchSecurityEvents({ limit = 80, eventType = "" } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (eventType) params.set("eventType", eventType);
|
||||
return apiFetch(`/api/auth/security-events?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemSecurityEvents(userId = "", overrides = {}, { limit = 120, eventType = "" } = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (userId) params.set("userId", userId);
|
||||
if (eventType) params.set("eventType", eventType);
|
||||
return apiFetch(`/api/system/security-events?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function acceptInvitation(invitationId, overrides = {}) {
|
||||
return apiFetch(`/api/invitations/${encodeURIComponent(invitationId)}/accept`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchInvitations(overrides = {}) {
|
||||
return apiFetch("/api/invitations", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProject(overrides = {}) {
|
||||
return apiFetch("/api/project", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createJob(body, overrides = {}) {
|
||||
return apiFetch("/api/jobs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssets(overrides = {}) {
|
||||
return apiFetch("/api/assets", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAsset(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssetContent(assetId, overrides = {}) {
|
||||
return apiBlob(`/api/assets/${encodeURIComponent(assetId)}/content`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createAsset(body, overrides = {}) {
|
||||
return apiFetch("/api/assets", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function uploadAsset(body, overrides = {}) {
|
||||
return apiFetch("/api/assets/upload", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function uploadAssetVersion(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions/upload`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function verifyAsset(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/verify`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function createAssetVersion(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function restoreAssetVersion(assetId, versionId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions/${encodeURIComponent(versionId)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateAssetLock(assetId, lockStatus, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/lock`, { method: "POST", body: JSON.stringify({ lockStatus }) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateAssetRights(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/rights`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function bindAssetToShot(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/bindings`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function queryAssistant(body, overrides = {}) {
|
||||
return apiFetch("/api/assistant/query", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createWorkspace(body, overrides = {}) {
|
||||
return apiFetch("/api/workspaces", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createOrganization(body, overrides = {}) {
|
||||
return apiFetch("/api/organizations", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createProject(body, overrides = {}) {
|
||||
return apiFetch("/api/projects", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function inviteMember(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invitations`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resendOrganizationInvitation(organizationId, invitationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invitations/${encodeURIComponent(invitationId)}/resend`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function revokeOrganizationInvitation(organizationId, invitationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invitations/${encodeURIComponent(invitationId)}/revoke`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationMember(organizationId, userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateWorkspaceMember(workspaceId, userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProjectMember(projectId, userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function addProjectMember(projectId, body, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/members`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemConfig(overrides = {}) {
|
||||
return apiFetch("/api/system/config", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchIdentityCenter(overrides = {}) {
|
||||
return apiFetch("/api/system/identity", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemUsers(overrides = {}, filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.query) params.set("query", filters.query);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.limit) params.set("limit", String(filters.limit));
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(`/api/system/users${suffix}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemUser(userId, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateSystemUser(userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createSystemUser(body, overrides = {}) {
|
||||
return apiFetch("/api/system/users", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resetSystemUserPassword(userId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/reset-password`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resetSystemUserMfa(userId, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/reset-mfa`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateSystemUserMemberships(userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/memberships`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function revokeSystemUserSessions(userId, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/revoke-sessions`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationCommercial(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial`, {}, overrides);
|
||||
}
|
||||
|
||||
function usageQuery(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of ["query", "workspaceId", "projectId", "userId", "kind", "unitName", "costCenter", "status", "from", "to", "page", "pageSize", "format"]) {
|
||||
if (filters[key] !== undefined && filters[key] !== null && String(filters[key]) !== "") params.set(key, String(filters[key]));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function fetchOrganizationUsage(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/usage${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationUsage(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/usage/export${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationUsageCsv(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery({ ...filters, format: "csv" });
|
||||
return apiBlob(`/api/organizations/${encodeURIComponent(organizationId)}/usage/export?${query}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationBilling(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/billing`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationInvoices(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationInvoice(organizationId, invoiceId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/${encodeURIComponent(invoiceId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function generateOrganizationInvoice(organizationId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/generate`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationInvoiceStatus(organizationId, invoiceId, status, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/${encodeURIComponent(invoiceId)}/status`, { method: "POST", body: JSON.stringify({ status }) }, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationInvoices(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/export${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationInvoicesCsv(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery({ ...filters, format: "csv" });
|
||||
return apiBlob(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/export?${query}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateQuotaAllocation(organizationId, quotaId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/quotas/${encodeURIComponent(quotaId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationCommercial(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial/export`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateCostCenter(organizationId, costCenterId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/cost-centers/${encodeURIComponent(costCenterId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateIdentityPolicy(body, overrides = {}) {
|
||||
return apiFetch("/api/system/identity/policy", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createIdentityProvider(body, overrides = {}) {
|
||||
return apiFetch("/api/system/identity/providers", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateIdentityProvider(providerId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/providers/${encodeURIComponent(providerId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function probeIdentityProvider(providerId, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/providers/${encodeURIComponent(providerId)}/probe`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function createDirectorySync(body, overrides = {}) {
|
||||
return apiFetch("/api/system/identity/directory-syncs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateDirectorySync(directorySyncId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/directory-syncs/${encodeURIComponent(directorySyncId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function rotateDirectorySyncToken(directorySyncId, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/directory-syncs/${encodeURIComponent(directorySyncId)}/rotate-token`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function saveSystemConfig(settings, overrides = {}) {
|
||||
return apiFetch("/api/system/config", { method: "POST", body: JSON.stringify({ settings }) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateFeatureFlag(key, enabled, overrides = {}) {
|
||||
return apiFetch("/api/system/feature-flags", { method: "POST", body: JSON.stringify({ key, enabled }) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateNotificationChannel(channel, overrides = {}) {
|
||||
return apiFetch("/api/system/notifications", { method: "POST", body: JSON.stringify(channel) }, overrides);
|
||||
}
|
||||
|
||||
export async function testNotification(body = {}, overrides = {}) {
|
||||
return apiFetch("/api/system/notifications/test", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchNotificationDeliveries(overrides = {}) {
|
||||
return apiFetch("/api/system/notifications/deliveries", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createApiClient(body, overrides = {}) {
|
||||
return apiFetch("/api/system/api-clients", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateApiClient(clientId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/api-clients/${encodeURIComponent(clientId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function rotateApiClientKey(clientId, overrides = {}) {
|
||||
return apiFetch(`/api/system/api-clients/${encodeURIComponent(clientId)}/rotate`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganization(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationRolePolicies(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/role-policies`, {}, { ...overrides, organizationId });
|
||||
}
|
||||
|
||||
export async function updateOrganizationRolePolicy(organizationId, roleKey, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/role-policies/${encodeURIComponent(roleKey)}`, { method: "PATCH", body: JSON.stringify(body) }, { ...overrides, organizationId });
|
||||
}
|
||||
|
||||
export async function updateWorkspace(workspaceId, body, overrides = {}) {
|
||||
return apiFetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProject(projectId, body, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function changeProjectLifecycle(projectId, action, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/lifecycle`, { method: "POST", body: JSON.stringify({ action }) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemHealth(overrides = {}) {
|
||||
return apiFetch("/api/system/health", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemReadiness(overrides = {}) {
|
||||
return apiFetch("/api/system/readiness", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemBackups(overrides = {}) {
|
||||
return apiFetch("/api/system/backups", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createSystemBackup(overrides = {}) {
|
||||
return apiFetch("/api/system/backups", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function operateRunner(serviceKey, action, overrides = {}) {
|
||||
return apiFetch(`/api/system/health/${encodeURIComponent(serviceKey)}/action`, { method: "POST", body: JSON.stringify({ action }) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAdminQueue(overrides = {}) {
|
||||
return apiFetch("/api/admin/queue", {}, overrides);
|
||||
}
|
||||
|
||||
export async function batchQueueAction(body, overrides = {}) {
|
||||
return apiFetch("/api/admin/queue/batch", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchWorkerStatus(overrides = {}) {
|
||||
return apiFetch("/api/system/worker", {}, overrides);
|
||||
}
|
||||
|
||||
export async function dispatchWorker(overrides = {}) {
|
||||
return apiFetch("/api/system/worker/dispatch", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionGraph(overrides = {}) {
|
||||
const query = overrides.episodeId ? `?episodeId=${encodeURIComponent(overrides.episodeId)}` : "";
|
||||
return apiFetch(`/api/production/graph${query}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionCatalog(overrides = {}) {
|
||||
return apiFetch("/api/production/catalog", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createProductionSeason(body, overrides = {}) {
|
||||
return apiFetch("/api/production/seasons", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createProductionEpisode(body, overrides = {}) {
|
||||
return apiFetch("/api/production/episodes", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionEpisode(episodeId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/episodes/${encodeURIComponent(episodeId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function importProductionScript(body, overrides = {}) {
|
||||
return apiFetch("/api/production/script/import", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function materializeProductionScript(body, overrides = {}) {
|
||||
return apiFetch("/api/production/script/materialize", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionBible(body, overrides = {}) {
|
||||
return apiFetch("/api/production/bible", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createProductionShot(body, overrides = {}) {
|
||||
return apiFetch("/api/production/shots", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionShot(shotId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionShotVersions(shotId, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}/versions`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function restoreProductionShotVersion(shotId, versionId, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}/versions/${encodeURIComponent(versionId)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function saveProductionPrompt(shotId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}/prompt-versions`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionReviews(overrides = {}) {
|
||||
return apiFetch("/api/production/reviews", {}, overrides);
|
||||
}
|
||||
|
||||
export async function runAutomatedProductionQa(overrides = {}) {
|
||||
return apiFetch("/api/production/qa/run", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function runMediaProductionQa(overrides = {}) {
|
||||
return apiFetch("/api/production/qa/media/run", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchMediaArtifacts(params = {}, overrides = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.shotId) query.set("shotId", params.shotId);
|
||||
if (params.jobId) query.set("jobId", params.jobId);
|
||||
if (params.limit) query.set("limit", String(params.limit));
|
||||
return apiFetch(`/api/production/media-artifacts${query.toString() ? `?${query.toString()}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function decideProductionReview(reviewId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/reviews/${encodeURIComponent(reviewId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function addProductionReviewComment(reviewId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/reviews/${encodeURIComponent(reviewId)}/comments`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveries(overrides = {}) {
|
||||
return apiFetch("/api/production/deliveries", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDelivery(body, overrides = {}) {
|
||||
return apiFetch("/api/production/deliveries", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryChannels(overrides = {}) {
|
||||
return apiFetch("/api/production/delivery-channels", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryChannel(body, overrides = {}) {
|
||||
return apiFetch("/api/production/delivery-channels", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateDeliveryChannel(channelId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/delivery-channels/${encodeURIComponent(channelId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryBatches(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryBatch(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function activateDeliveryBatch(batchId, overrides = {}) {
|
||||
return apiFetch(`/api/production/delivery-batches/${encodeURIComponent(batchId)}/activate`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function rollbackDeliveryBatch(batchId, overrides = {}) {
|
||||
return apiFetch(`/api/production/delivery-batches/${encodeURIComponent(batchId)}/rollback`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function approveDelivery(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryReleases(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryRelease(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function decideDeliveryRelease(releaseId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function publishDeliveryRelease(releaseId, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryAccessLinks(releaseId, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/access-links`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryAccessFeedback(releaseId, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/access-feedback`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryAccessLink(releaseId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/access-links`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function revokeDeliveryAccessLink(linkId, overrides = {}) {
|
||||
return apiFetch(`/api/production/access-links/${encodeURIComponent(linkId)}/revoke`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchPublicDeliveryPortal(token) {
|
||||
const response = await fetch(`${API_BASE}/api/public/delivery/${encodeURIComponent(token)}`, { headers: { accept: "application/json" } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function publicDeliveryFileUrl(token, kind) {
|
||||
return `${API_BASE}/api/public/delivery/${encodeURIComponent(token)}/file?kind=${encodeURIComponent(kind)}`;
|
||||
}
|
||||
|
||||
export async function submitPublicDeliveryFeedback(token, body = {}) {
|
||||
const response = await fetch(`${API_BASE}/api/public/delivery/${encodeURIComponent(token)}/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function writeExports(overrides = {}) {
|
||||
return apiFetch("/api/exports/write", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function retryJob(jobId, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function cancelJob(jobId, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateJobPriority(jobId, priority, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/priority`, { method: "POST", body: JSON.stringify({ priority }) }, overrides);
|
||||
}
|
||||
|
||||
function auditQuery(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters || {})) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function fetchAuditEvents(filters = {}, overrides = {}) {
|
||||
const query = auditQuery(filters);
|
||||
return apiFetch(`/api/audit${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAuditEvent(auditId, overrides = {}) {
|
||||
return apiFetch(`/api/audit/${encodeURIComponent(auditId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportAuditEvents(filters = {}, overrides = {}) {
|
||||
const query = auditQuery(filters);
|
||||
return apiFetch(`/api/audit/export${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAuditExport(overrides = {}) {
|
||||
return exportAuditEvents({}, overrides);
|
||||
}
|
||||
|
||||
export async function registerModel(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/models/register", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateModel(modelId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/models/${encodeURIComponent(modelId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function probeModel(modelId, overrides = {}) {
|
||||
return apiFetch(`/api/platform/models/${encodeURIComponent(modelId)}/probe`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchJobs(overrides = {}) {
|
||||
return apiFetch("/api/jobs", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchStorageUsage(overrides = {}) {
|
||||
return apiFetch("/api/usage/storage", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchCompositions(overrides = {}) {
|
||||
return apiFetch("/api/production/compositions", {}, overrides);
|
||||
}
|
||||
|
||||
export async function composeProduction(body, overrides = {}) {
|
||||
return apiFetch("/api/production/compose", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchJob(jobId, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function runJob(jobId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
@@ -0,0 +1,153 @@
|
||||
import { projectQa } from "./qa.js";
|
||||
|
||||
const 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.";
|
||||
|
||||
export function buildShotList(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.shot-list.v1",
|
||||
seriesId: project.series.id,
|
||||
episodeId: project.episode.id,
|
||||
aspectRatio: project.production.aspectRatio,
|
||||
shots: project.shots.map((shot) => ({
|
||||
id: shot.id,
|
||||
title: shot.title,
|
||||
durationSec: shot.durationSec,
|
||||
camera: shot.camera,
|
||||
action: shot.action,
|
||||
characters: shot.characterIds,
|
||||
location: shot.locationId,
|
||||
props: shot.propIds,
|
||||
firstFrame: shot.firstFrame,
|
||||
lastFrame: shot.lastFrame,
|
||||
transitionFromPrevious: shot.transitionFromPrevious,
|
||||
voiceLines: shot.voiceLines
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptPack(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.prompt-pack.v1",
|
||||
adapter: project.production.defaultAdapter,
|
||||
global: {
|
||||
style: project.series.visualStyle,
|
||||
singleFrameRule,
|
||||
negativePrompt: project.production.globalNegativePrompt
|
||||
},
|
||||
characterLocks: project.characters,
|
||||
locationLocks: project.locations,
|
||||
propLocks: project.props,
|
||||
prompts: project.shots.map((shot) => ({
|
||||
id: shot.id,
|
||||
imagePrompt: `${singleFrameRule} ${project.series.visualStyle} ${shot.prompt}`,
|
||||
negativePrompt: `${project.production.globalNegativePrompt}, ${shot.negativePrompt}`,
|
||||
imageToVideoPrompt: shot.videoPrompt,
|
||||
seed: shot.seed,
|
||||
durationSec: shot.durationSec
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVoiceTable(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.voice-lines.v1",
|
||||
episodeId: project.episode.id,
|
||||
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: project.characters.map((character) => ({
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
voiceLock: character.voiceLock
|
||||
})),
|
||||
lines: project.shots.flatMap((shot) =>
|
||||
shot.voiceLines.map((line) => ({
|
||||
shotId: shot.id,
|
||||
lineId: line.id,
|
||||
characterId: line.characterId,
|
||||
text: line.text,
|
||||
emotion: line.emotion,
|
||||
targetDurationSec: line.targetDurationSec,
|
||||
audioFile: line.audioFile,
|
||||
mouthPlan: line.mouthPlan
|
||||
}))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEditList(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.edit-list.v1",
|
||||
episodeId: project.episode.id,
|
||||
frameRate: project.production.fps,
|
||||
clips: project.shots.map((shot, index) => ({
|
||||
order: index + 1,
|
||||
shotId: shot.id,
|
||||
sourceVideo: `shots/${shot.id}/${shot.id}.mp4`,
|
||||
durationSec: shot.durationSec,
|
||||
audioTracks: shot.voiceLines.map((line) => line.audioFile),
|
||||
subtitleSource: `voices/${shot.id}.srt`,
|
||||
transition: shot.transitionFromPrevious === "actual-last-frame" ? "match-last-frame" : "insert-bridge-shot",
|
||||
qaRequiredBeforeEdit: ["single-frame", "continuity-lock", "voice-subtitle-asr", "clip-bridge"]
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildModelRequest(project, shot, adapter) {
|
||||
return {
|
||||
adapterId: adapter.id,
|
||||
endpoint: adapter.routes?.imageToVideo || adapter.baseUrl,
|
||||
payload: {
|
||||
series_bible: project.series,
|
||||
episode: project.episode,
|
||||
shot,
|
||||
locks: {
|
||||
characters: project.characters.filter((item) => shot.characterIds.includes(item.id)),
|
||||
location: project.locations.find((item) => item.id === shot.locationId),
|
||||
props: project.props.filter((item) => shot.propIds.includes(item.id))
|
||||
},
|
||||
output_contract: {
|
||||
image_batch_size: 1,
|
||||
image_rule: singleFrameRule,
|
||||
video_segment_sec: shot.durationSec,
|
||||
require_actual_last_frame_for_next_clip: true,
|
||||
reject_cloud_paid_nodes_by_default: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAllExports(project) {
|
||||
return {
|
||||
shotList: buildShotList(project),
|
||||
promptPack: buildPromptPack(project),
|
||||
voiceTable: buildVoiceTable(project),
|
||||
editList: buildEditList(project),
|
||||
qaResults: projectQa(project)
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadJson(filename, value) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
const blockedSingleFrameTerms = [
|
||||
"storyboard",
|
||||
"comic page",
|
||||
"comic strip",
|
||||
"contact sheet",
|
||||
"collage",
|
||||
"montage",
|
||||
"multi-panel",
|
||||
"split screen",
|
||||
"diptych",
|
||||
"triptych",
|
||||
"filmstrip",
|
||||
"picture-in-picture",
|
||||
"inset frame",
|
||||
"multiple scenes",
|
||||
"multiple locations",
|
||||
"multiple time points",
|
||||
"before-and-after",
|
||||
"sequential action",
|
||||
"alternate angles",
|
||||
"grid layout",
|
||||
"poster layout",
|
||||
"分镜拼图",
|
||||
"四宫格",
|
||||
"九宫格",
|
||||
"多画面",
|
||||
"拼贴图",
|
||||
"漫画页",
|
||||
"左右两个画面",
|
||||
"上下两个画面"
|
||||
];
|
||||
|
||||
export function runShotQa(shot, project) {
|
||||
// A negative prompt should contain the exact layouts we want to reject, so it is not a violation by itself.
|
||||
const promptText = [shot.prompt, shot.action, shot.camera].join(" ").toLowerCase();
|
||||
const missingLocks = [];
|
||||
if (!shot.characterIds?.length) missingLocks.push("角色锁");
|
||||
if (!shot.locationId) missingLocks.push("场景锁");
|
||||
if (!shot.propIds?.length) missingLocks.push("道具锁");
|
||||
if (!shot.firstFrame || !shot.lastFrame) missingLocks.push("首尾帧");
|
||||
|
||||
const blockedPromptTerms = blockedSingleFrameTerms.filter((term) =>
|
||||
promptText.includes(term.toLowerCase())
|
||||
);
|
||||
|
||||
const visibleSpeech = shot.voiceLines.some((line) => line.mouthPlan === "严格口型");
|
||||
const hasVoiceLock = shot.voiceLines.every((line) => {
|
||||
const character = project.characters.find((item) => item.id === line.characterId);
|
||||
return character?.voiceLock?.voiceId;
|
||||
});
|
||||
|
||||
const score =
|
||||
100 -
|
||||
missingLocks.length * 12 -
|
||||
blockedPromptTerms.length * 10 -
|
||||
(visibleSpeech && !hasVoiceLock ? 18 : 0) -
|
||||
(shot.transitionFromPrevious === "hard-cut-risk" ? 10 : 0);
|
||||
|
||||
return {
|
||||
shotId: shot.id,
|
||||
score: Math.max(0, score),
|
||||
gates: [
|
||||
{
|
||||
id: "single-frame",
|
||||
label: "一图一画面",
|
||||
status: blockedPromptTerms.length === 0 ? "pass" : "fail",
|
||||
detail:
|
||||
blockedPromptTerms.length === 0
|
||||
? "提示词包含单画面约束,未发现分屏/拼图/漫画页风险词。"
|
||||
: `发现禁用词:${blockedPromptTerms.join("、")}`
|
||||
},
|
||||
{
|
||||
id: "continuity-lock",
|
||||
label: "角色/场景/道具连续性",
|
||||
status: missingLocks.length === 0 ? "pass" : "warn",
|
||||
detail:
|
||||
missingLocks.length === 0
|
||||
? "角色、场景、道具和首尾帧都已绑定。"
|
||||
: `缺少:${missingLocks.join("、")}`
|
||||
},
|
||||
{
|
||||
id: "voice-subtitle-asr",
|
||||
label: "声音/字幕/ASR 对齐",
|
||||
status: hasVoiceLock ? "pass" : "warn",
|
||||
detail: hasVoiceLock
|
||||
? "对白角色均有固定声线 ID,后续可用 ASR 校对字幕时间。"
|
||||
: "有台词角色尚未配置固定声线,禁止把 H3 随机原生声音当最终声线。"
|
||||
},
|
||||
{
|
||||
id: "clip-bridge",
|
||||
label: "片段衔接",
|
||||
status: shot.transitionFromPrevious === "actual-last-frame" ? "pass" : "warn",
|
||||
detail:
|
||||
shot.transitionFromPrevious === "actual-last-frame"
|
||||
? "使用上一段实际末帧作为下一段首帧。"
|
||||
: "场景跨度较大或未指定实际末帧衔接,需要中间过渡镜头。"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function projectQa(project) {
|
||||
return project.shots.map((shot) => runShotQa(shot, project));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,264 @@
|
||||
export const platformResearchMatrix = [
|
||||
{
|
||||
category: "创作入口",
|
||||
commercialNeed: "从长文本、小说、短剧剧本、图片参考、角色设定进入生产,而不是只手填 prompt。",
|
||||
localBuild: "项目工厂、剧本拆解、章节/场次/镜头自动切分、风格预设、分镜生成。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "资产一致性",
|
||||
commercialNeed: "角色、场景、道具、服装、声线可复用,跨集跨镜头保持一致。",
|
||||
localBuild: "角色定妆、三视图、衣橱、location lock、prop lock、voice lock、continuity ledger。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "模型与工作流中台",
|
||||
commercialNeed: "接多模型、多能力、多队列,支持图像、视频、配音、口型、字幕、合成。",
|
||||
localBuild: "自有模型平台为主适配器,OpenAI-compatible 和 ComfyUI optional runner。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "生成编排",
|
||||
commercialNeed: "批量生成、重试、取消、优先级、依赖关系、队列状态、失败原因。",
|
||||
localBuild: "本地 job contract、runner health、队列状态、批次与依赖声明。",
|
||||
status: "next"
|
||||
},
|
||||
{
|
||||
category: "审片质检",
|
||||
commercialNeed: "内容审核、画面完整性、角色一致性、声音字幕对齐、镜头衔接、人工审核。",
|
||||
localBuild: "QA gate、review lanes、证据记录、阻塞项、人工通过/驳回状态。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "成本与额度",
|
||||
commercialNeed: "商业 SaaS 必须有套餐、额度、成本中心、调用计量、预算预警。",
|
||||
localBuild: "local-only 策略、quota、成本中心、每类任务单位成本占位、预算状态。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "权限与团队",
|
||||
commercialNeed: "多团队、多角色、多项目隔离,支持编剧/美术/配音/审片/管理员。",
|
||||
localBuild: "workspace、members、roles、permission matrix、audit log。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "合规版权",
|
||||
commercialNeed: "原创/IP 风险、肖像权、声音权、训练/参考来源、可商用授权记录。",
|
||||
localBuild: "rights ledger、policy gate、禁止真人仿冒、参考素材来源记录。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "交付运营",
|
||||
commercialNeed: "版本、成片、字幕、封面、发布渠道、素材归档、复用为模板。",
|
||||
localBuild: "delivery manifest、edit list、版本状态、导出目录、模板复用。",
|
||||
status: "mvp"
|
||||
}
|
||||
];
|
||||
|
||||
export const platformData = {
|
||||
// Static fallback only. The running API hydrates the same shape from SQLite.
|
||||
organizations: [
|
||||
{ id: "org-studio-lab", name: "星河短剧实验室", slug: "xinghe-studio", role: "组织所有者", workspaceCount: 3 },
|
||||
{ id: "org-northstar", name: "北辰内容厂牌", slug: "northstar-content", role: "组织所有者", workspaceCount: 1 }
|
||||
],
|
||||
users: [
|
||||
{ id: "u-owner", name: "林制片", email: "producer@local.test", status: "active" },
|
||||
{ id: "u-writer", name: "白编剧", email: "writer@local.test", status: "active" },
|
||||
{ id: "u-art", name: "沈美术", email: "art@local.test", status: "active" },
|
||||
{ id: "u-voice", name: "许配音", email: "voice@local.test", status: "active" },
|
||||
{ id: "u-review", name: "顾审片", email: "review@local.test", status: "active" }
|
||||
],
|
||||
organizationMemberships: [
|
||||
{ organizationId: "org-studio-lab", userId: "u-owner", role: "org_owner", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-writer", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-art", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-voice", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-review", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-northstar", userId: "u-owner", role: "org_owner", status: "active" }
|
||||
],
|
||||
workspaces: [
|
||||
{ id: "ws-local-aidrama", organizationId: "org-studio-lab", name: "短剧生产中心", slug: "drama-production" },
|
||||
{ id: "ws-pilot", organizationId: "org-studio-lab", name: "素材实验室", slug: "asset-lab" },
|
||||
{ id: "ws-northstar-main", organizationId: "org-northstar", name: "北辰试制部", slug: "pilot-room" }
|
||||
],
|
||||
workspaceMemberships: [
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-owner", role: "producer" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-writer", role: "writer" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-art", role: "art_director" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-voice", role: "voice_editor" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-review", role: "reviewer" },
|
||||
{ workspaceId: "ws-pilot", userId: "u-owner", role: "producer" },
|
||||
{ workspaceId: "ws-northstar-main", userId: "u-owner", role: "producer" }
|
||||
],
|
||||
projectMemberships: [
|
||||
{ projectId: "thunder-mouth", userId: "u-owner", role: "project_editor" },
|
||||
{ projectId: "template-original-manhua", userId: "u-owner", role: "project_editor" }
|
||||
],
|
||||
workspace: {
|
||||
id: "ws-local-aidrama",
|
||||
name: "本地 AI 短剧工作室",
|
||||
deployment: "single-machine-private",
|
||||
basePath: "/Users/xz/Documents/daima/ai短剧/ai-drama-platform",
|
||||
dataPolicy: "local-first-no-paid-cloud-by-default",
|
||||
productTier: "Private Studio MVP",
|
||||
commercialTarget: "私有部署 AI 漫剧/短剧生产平台"
|
||||
},
|
||||
plan: {
|
||||
name: "Studio Local",
|
||||
seats: 8,
|
||||
storageGb: 512,
|
||||
monthlyClipQuota: 1200,
|
||||
localRunnerOnly: true,
|
||||
cloudConnectorsRequireApproval: true
|
||||
},
|
||||
members: [
|
||||
{ id: "u-owner", name: "Owner", role: "平台管理员", status: "active" },
|
||||
{ id: "u-writer", name: "编剧位", role: "编剧", status: "invited" },
|
||||
{ id: "u-art", name: "美术位", role: "资产美术", status: "invited" },
|
||||
{ id: "u-voice", name: "声音位", role: "配音/字幕", status: "invited" },
|
||||
{ id: "u-review", name: "审片位", role: "审片", status: "invited" }
|
||||
],
|
||||
permissionMatrix: [
|
||||
{ role: "平台管理员", permissions: ["workspace:*", "model:*", "billing:*", "compliance:*", "project:*"] },
|
||||
{ role: "制片", permissions: ["project:create", "job:prioritize", "delivery:approve", "cost:view"] },
|
||||
{ role: "编剧", permissions: ["script:edit", "shot:create", "ledger:update"] },
|
||||
{ role: "资产美术", permissions: ["asset:edit", "prompt:edit", "reference:upload"] },
|
||||
{ role: "配音/字幕", permissions: ["voice:edit", "subtitle:edit", "asr:run"] },
|
||||
{ role: "审片", permissions: ["qa:review", "clip:approve", "clip:reject"] }
|
||||
],
|
||||
modelRegistry: [
|
||||
{
|
||||
id: "owned-i2v",
|
||||
label: "自有图生视频平台",
|
||||
capability: ["image-to-video", "first-last-frame", "vertical-video"],
|
||||
endpoint: "http://127.0.0.1:7860/api/generate/i2v",
|
||||
status: "not-connected",
|
||||
costMode: "local",
|
||||
approvalRequired: false
|
||||
},
|
||||
{
|
||||
id: "owned-image",
|
||||
label: "自有图片/改图平台",
|
||||
capability: ["text-to-image", "image-edit", "single-frame"],
|
||||
endpoint: "http://127.0.0.1:7860/api/generate/image",
|
||||
status: "not-connected",
|
||||
costMode: "local",
|
||||
approvalRequired: false
|
||||
},
|
||||
{
|
||||
id: "local-tts",
|
||||
label: "本地固定声线 TTS",
|
||||
capability: ["tts", "voice-lock", "subtitle-timing"],
|
||||
endpoint: "http://127.0.0.1:7861/api/tts",
|
||||
status: "planned",
|
||||
costMode: "local",
|
||||
approvalRequired: false
|
||||
},
|
||||
{
|
||||
id: "newapi-audio-production",
|
||||
label: "NewAPI 音频中转",
|
||||
capability: ["tts", "voice-lock", "emotion-control", "asr", "subtitle-timing"],
|
||||
endpoint: "https://newapi.ysblack.com/v1",
|
||||
status: "ready",
|
||||
costMode: "mixed",
|
||||
approvalRequired: true
|
||||
},
|
||||
{
|
||||
id: "comfyui-optional",
|
||||
label: "ComfyUI 工作流桥接",
|
||||
capability: ["workflow", "qwen-image", "qwen-edit", "h3-bridge"],
|
||||
endpoint: "http://127.0.0.1:8188",
|
||||
status: "optional",
|
||||
costMode: "mixed",
|
||||
approvalRequired: true
|
||||
}
|
||||
],
|
||||
runnerHealth: [
|
||||
{ id: "script-runner", name: "剧本拆解 Runner", status: "ready", queueDepth: 0, lastHeartbeat: "local" },
|
||||
{ id: "image-runner", name: "单画面 Runner", status: "waiting-model", queueDepth: 3, lastHeartbeat: "not-connected" },
|
||||
{ id: "video-runner", name: "视频片段 Runner", status: "waiting-model", queueDepth: 2, lastHeartbeat: "not-connected" },
|
||||
{ id: "voice-runner", name: "固定配音 Runner", status: "planned", queueDepth: 6, lastHeartbeat: "not-connected" },
|
||||
{ id: "qa-runner", name: "审片质检 Runner", status: "ready", queueDepth: 4, lastHeartbeat: "local" },
|
||||
{ id: "export-runner", name: "合成导出 Runner", status: "ready", queueDepth: 1, lastHeartbeat: "local" }
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
id: "thunder-mouth",
|
||||
workspaceId: "ws-local-aidrama",
|
||||
title: "雷雨口",
|
||||
type: "AI 漫剧",
|
||||
episodes: 1,
|
||||
stage: "production",
|
||||
owner: "u-owner",
|
||||
readiness: 72,
|
||||
risk: "medium",
|
||||
updatedAt: "2026-08-19"
|
||||
},
|
||||
{
|
||||
id: "template-original-manhua",
|
||||
workspaceId: "ws-pilot",
|
||||
title: "原创国漫短剧模板",
|
||||
type: "模板",
|
||||
episodes: 0,
|
||||
stage: "template",
|
||||
owner: "u-owner",
|
||||
readiness: 48,
|
||||
risk: "low",
|
||||
updatedAt: "2026-08-19"
|
||||
}
|
||||
],
|
||||
assetStorage: {
|
||||
root: "/Users/xz/Documents/daima/ai短剧/ai-drama-platform/exports/project-template",
|
||||
buckets: [
|
||||
{ id: "references", label: "参考与定妆", fileCount: 0, policy: "must-have-rights" },
|
||||
{ id: "keyframes", label: "单画面关键帧", fileCount: 3, policy: "single-frame-only" },
|
||||
{ id: "clips", label: "视频片段", fileCount: 0, policy: "qa-before-edit" },
|
||||
{ id: "voices", label: "固定配音", fileCount: 6, policy: "voice-rights-required" },
|
||||
{ id: "deliveries", label: "交付版本", fileCount: 5, policy: "approval-required" }
|
||||
]
|
||||
},
|
||||
reviewLanes: [
|
||||
{ id: "lane-frame", label: "画面完整性", owner: "审片", pending: 3, pass: 2, reject: 0 },
|
||||
{ id: "lane-continuity", label: "角色/场景连续性", owner: "美术", pending: 3, pass: 2, reject: 0 },
|
||||
{ id: "lane-voice", label: "声音字幕", owner: "配音/字幕", pending: 6, pass: 0, reject: 0 },
|
||||
{ id: "lane-rights", label: "版权合规", owner: "平台管理员", pending: 1, pass: 3, reject: 0 }
|
||||
],
|
||||
costCenters: [
|
||||
{ id: "cc-local-gpu", label: "本地 GPU/电力", monthBudget: 500, used: 86, unit: "CNY" },
|
||||
{ id: "cc-storage", label: "本地存储", monthBudget: 200, used: 28, unit: "CNY" },
|
||||
{ id: "cc-cloud", label: "外部云接口", monthBudget: 0, used: 0, unit: "CNY" }
|
||||
],
|
||||
compliancePolicies: [
|
||||
{ id: "single-frame", label: "一图一画面", severity: "blocker", status: "enforced" },
|
||||
{ id: "original-ip", label: "原创/IP 风险", severity: "blocker", status: "enforced" },
|
||||
{ id: "voice-right", label: "声音授权", severity: "blocker", status: "needs-evidence" },
|
||||
{ id: "face-right", label: "真人脸权", severity: "blocker", status: "disabled-by-default" },
|
||||
{ id: "cloud-spend", label: "付费云端节点", severity: "approval", status: "disabled-by-default" }
|
||||
],
|
||||
auditLog: [
|
||||
{ id: "aud-001", actor: "u-owner", action: "project.created", target: "thunder-mouth", result: "ok", time: "2026-08-19 15:00" },
|
||||
{ id: "aud-002", actor: "qa-runner", action: "policy.single-frame.checked", target: "shot-01", result: "pass", time: "2026-08-19 15:10" },
|
||||
{ id: "aud-003", actor: "export-runner", action: "exports.write", target: "project-template", result: "ok", time: "2026-08-19 15:18" },
|
||||
{ id: "aud-004", actor: "platform", action: "cloud.connector.blocked", target: "paid-node", result: "requires-approval", time: "2026-08-19 15:22" }
|
||||
]
|
||||
};
|
||||
|
||||
export function getPlatformSummary() {
|
||||
const queueDepth = platformData.runnerHealth.reduce((sum, runner) => sum + runner.queueDepth, 0);
|
||||
const usedBudget = platformData.costCenters.reduce((sum, item) => sum + item.used, 0);
|
||||
const totalBudget = platformData.costCenters.reduce((sum, item) => sum + item.monthBudget, 0);
|
||||
const blockers = platformData.compliancePolicies.filter((item) => item.status !== "enforced").length;
|
||||
return {
|
||||
workspace: platformData.workspace,
|
||||
activeProjects: platformData.projects.filter((item) => item.stage === "production").length,
|
||||
modelCount: platformData.modelRegistry.length,
|
||||
runnerCount: platformData.runnerHealth.length,
|
||||
queueDepth,
|
||||
budget: {
|
||||
used: usedBudget,
|
||||
total: totalBudget
|
||||
},
|
||||
complianceBlockers: blockers,
|
||||
researchMatrix: platformResearchMatrix
|
||||
};
|
||||
}
|
||||
+6048
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
});
|
||||
Reference in New Issue
Block a user