# 换装跳舞直播间 — 技术设计文档(Model / View / Control / Process) 依据:产品需求 `docs/todo/dressup-dance-room.md`(下称"需求文档",本文各处 引用其章节号);系统架构 `docs/structure/architecture.md`(下称"架构文档")。 本文档把已确认的产品决策落到:数据库表结构(M)、前后端界面清单(V)、 按钮/交互动作(C)、各类用户的操作流程(P),供开发排期和实现参考。 约定:字段名延续现有 `library.json`/`live-state.json` 的英文小驼峰风格; 表结构在第一期仍以 JSON 文件承载,第三期(用户账号)后迁移 SQLite(见 架构文档 1.4 节)——本文按"关系表"描述,两种落地方式都能直接映射。 --- ## M — Model:数据结构设计 ### M.1 `users` | 字段 | 类型 | 说明 | |---|---|---| | `id` | string (uuid) | 主键 | | `email` | string | 邮箱,唯一(7.1 节,第一期登录方式) | | `passwordHash` | string | bcrypt/argon2,邮箱登录用 | | `emailVerified` | boolean | 邮箱验证是否已完成(7.1 节"账号+邮箱验证")。未验证账号可登录、可投票,但**不能发起任何付费操作**(充值/传衣服/传人),避免用一次性邮箱刷积分退款链路 | | `role` | enum | `viewer` \| `admin`,默认 `viewer`。`/api/admin/*` 全部要求 `role: admin`(见 M.10 会话校验)——这条同时偿还架构文档 1.2 节记的"后台零鉴权"技术债:MVCP 新增了 `POST /api/admin/credit/adjust` 这类直接改余额的接口,不能继续只靠网络边界防护 | | `oauthProviders` | array | 预留 `["google","apple","github"]`,第一期为空数组 | | `nickname` | string | 展示昵称,迁移自现有 cookie `viewer_nickname` | | `legacyUid` | string \| null | 绑定的旧 `viewer_uid`(cookie),用于历史投票/点播记录迁移,见 P.4 | | `ageConfirmed` | boolean | "我已满18岁" 勾选记录(5.1 节) | | `ageConfirmedAt` | number (ts) | | | `creditBalance` | number | 当前积分余额(冗余字段,权威值仍是 `credit_ledger` 流水汇总,见 M.2) | | `createdAt` | number (ts) | | ### M.2 `credit_ledger`(积分流水,7.2 节) | 字段 | 类型 | 说明 | |---|---|---| | `id` | string | 主键 | | `userId` | string | 外键 → `users.id` | | `delta` | number | 正数=充值/退款,负数=消费 | | `reason` | enum | `topup` \| `spend_garment` \| `spend_character` \| `refund_stage1_fail` \| `refund_stage2_fail` \| `refund_moderation_reject` \| `admin_adjust` | | `relatedAssetId` | string \| null | 关联的 `garments.id` 或 `characters.id`(消费/退款时) | | `balanceAfter` | number | 变动后余额,写入时计算好,避免每次汇总流水 | | `paymentRef` | string \| null | 充值时的外部支付流水号(Stripe/支付宝等,7.3 节) | | `createdAt` | number (ts) | | ### M.3 `characters`(人物库) | 字段 | 类型 | 说明 | |---|---|---| | `id` | string | 主键 | | `name` | string | | | `imageUrl` | string | 人物参考图 | | `tags` | array | | | `source` | enum | `admin_seed` \| `user_paid`(6.1 节) | | `ownerUserId` | string \| null | `user_paid` 来源必填 | | `appearanceDescriptor` | string | **管理员手写的人物外观特征**(发色/发长/发型、五官、体态 2-3 条,如 `"shoulder-length wavy black hair, delicate features, a slender build"`)。填入 M.15 模板的 `{{appearanceDescriptor}}`(一步法确认后不再与 `garments.appearanceDescriptor` 拼接——两者分别对应 ``/`` 两张独立参考图,见 M.15),跟 `dance_refs.motionDescriptor` 完全同一套路:录入时写一次、长期复用。`admin_seed` 来源必填;`user_paid`(模式三)来源无人手写,走视觉模型自动打标(第四期,需求文档 8.8) | | `isDefault` | boolean | 是否为直播默认人物;第一期**全库最多一条为 true**(6.1 节已确认,不做多人物轮换) | | `moderationStatus` | enum | `auto_passed` \| `blacklisted` \| `manually_removed`(5 节:自动黑名单过滤,无强制人工预审) | | `totalSegments` | number \| null | 付费购买的 10 秒片段总配额,`admin_seed`/默认人物为 null(不限量,只受 4.6 节去重缓存影响的是复用次数,不是配额) | | `remainingSegments` | number \| null | 剩余可播片段数,每播放一段 -1(7.2 节;架构文档 2.7 节) | | `status` | enum | `active` \| `expired`(配额耗尽后置 `expired`,记录不删除,7.4 节/架构文档 2.7 节) | | `createdAt` | number (ts) | | ### M.4 `garments`(衣服库) | 字段 | 类型 | 说明 | |---|---|---| | `id` | string | 主键 | | `name` | string | | | `category` | string | 分类标签(古风/泳装/校服…) | | `thumbnailUrl` | string | | | `imageUrl` | string | 原图 | | `appearanceDescriptor` | string | **管理员手写的服装特征**(颜色/款式/材质,如 `"a fitted red qipao with gold trim"`),填入 M.15 模板的 `{{garmentDescriptor}}`(对应独立的 ``,不再与人物特征拼接)。`admin_seed` 必填;模式二用户上传的走视觉模型打标(第四期) | | `source` | enum | `admin_seed` \| `user_paid`(6.2 节) | | `ownerUserId` | string \| null | | | `moderationStatus` | enum | `pending` \| `approved` \| `rejected`(模式二走轻量图片审核,审核通过默认沉淀进公共库,3 节模式二第 3 点) | | `isActive` | boolean | 上下架开关 | | `createdAt` | number (ts) | | ### M.5 `dance_refs`(参考跳舞视频/音乐库) | 字段 | 类型 | 说明 | |---|---|---| | `id` | string | 主键 | | `name` | string | | | `videoUrl` | string | | | `styleTags` | array | | | `durationSec` | number | | | `motionDescriptor` | string | **管理员手写的动作描述**(如"拉丁风格摆胯+举臂旋转"),拼进 Stage2 prompt 模板的 `detailed_description`(已确认,需求文档 4.2/6.3 节),上传时必填 | | `copyrightSource` | enum | `test_placeholder` \| `self_made` \| `licensed`(第一期全部 `test_placeholder`,6.3/8 节) | | `isActive` | boolean | | | `createdAt` | number (ts) | | ### M.6 ~~`composed_images`(Stage1 换装图产物)~~ —— 已作废 **2026-09-02 S1 spike 实测确认路径 B(一步法)成立**(需求文档 8.1、 `spike/s1-s2-s4-2026-09-02/RESULTS.md`):人物图+衣服图直接进 `ref2va` 的 `reference_image_urls`,无需先合成换装图这一步。**Stage1、 这张表、`appearanceDescriptor` 的"合成图取值优先级"设计、图片模型选型、 按 `(characterId, garmentId)` 去重换装图缓存——全部不需要了**。 去重缓存的落地位置改为直接在 M.7 `generated_videos` 上按 `(characterId, garmentId)` 查是否已有 `status: done` 的记录(4.6 节 仍然成立,只是缓存对象从"换装图"变成"成品视频",一步到位)。 ### M.7 `generated_videos`(Stage2 产物,`library.json` 风格 clip 的超集) 在现有 `library.json` clip 字段基础上(见 `docs/done` 第 3 节)新增: | 新增字段 | 类型 | 说明 | |---|---|---| | `characterId` | string | | | `garmentId` | string | | | `refDanceVideoId` | string | 外键 → M.5 | | `channel` | enum | `live` \| `dance`(区分是 `live.html` 还是 `dance.html` 的产物,两条独立时刻表共用同一张表时靠这个字段区分,见架构文档 2.1 节) | | `backend` | enum | `h3_console` \| `fal`(4.3 节双算力策略,记录这条具体是哪条算力路径生成的,便于追溯成本) | | `priority` | enum | `normal` \| `paid_boost`(`paid_boost` = 付费新人物内容,排播时走 2.6 节"插队"通道,不参与投票) | | `requestedByUserId` | string \| null | 付费请求必填,免费匿名请求为 null(用 `legacyUid` 兜底) | | `width`/`height` | number | `dance` 频道默认 480×832;`live` 频道走独立的 `H3_GEN_PARAMS`,目前也是 480×832(`docs/done` 2.2 节),两者不共享同一份配置 | ### M.8 直播时刻表状态(非持久化数据库表,`live-state.json` 风格的内存+落盘结构,`dance.html` 独立一份) ```jsonc { "channel": "dance", "schedule": [{ "videoId": "...", "startAt": 0, "endAt": 0, "requestedBy": null, "priority": "normal" }], "priorityInbox": [ /* paid_boost 产物就绪后先进这里,ensureSchedule 优先消费,见架构文档 2.6 节 */ ], "freeQueue": [ /* 免费模式一提交,走投票排序,结构同 v1 promptQueue */ ] } ``` ⚠️ **与 v1 的一处有意差异**:现有 `live-state.json` 的 `schedule` 条目内联 存的是**整个 clip 对象**(`{ clip, startAt, endAt, requestedBy }`),而这里 存的是 `videoId` 引用。dance 频道资产多、外键关系复杂(人物/衣服/ 参考视频),内联整份快照会让"后台改了一条素材、时刻表里还是旧副本"变成常 态。**实现时不要照抄 v1 的内联写法**,按 id 引用 + 读取时 join。 ### M.9 `moderation_log` | 字段 | 类型 | 说明 | |---|---|---| | `id` | string | 主键 | | `assetType` | enum | `garment_upload` \| `character_upload` \| `generated_video`(C.2 的"人工下架内容"针对的是已生成视频,需要能单独落账) | | `assetId` | string | | | `verdict` | enum | `auto_pass` \| `auto_reject_blacklist` \| `admin_takedown`(5 节:自动过 + 事后人工兜底) | | `reason` | string \| null | | | `reviewedBy` | string | `system` 或管理员 `userId` | | `createdAt` | number (ts) | | ### M.10 `sessions`(登录会话 + 邮箱验证令牌) 需求 7.1 的登录方式是"账号+邮箱验证",M.1 只存了 `passwordHash`,验证和 登录态需要单独的短期令牌表(也是唯一有 TTL 的表,落地时按过期时间清理): | 字段 | 类型 | 说明 | |---|---|---| | `id` | string | 主键 | | `userId` | string | 外键 → `users.id` | | `kind` | enum | `session`(登录态)\| `email_verify`(注册后发出的验证链接)\| `password_reset` | | `token` | string | 随机串(`crypto.randomUUID`/32 字节随机);`session` 类型写进 HttpOnly cookie,**不复用现有的明文 `viewer_uid` cookie** | | `expiresAt` | number (ts) | `session` 建议 30 天,`email_verify`/`password_reset` 建议 24 小时 | | `createdAt` | number (ts) | | 鉴权规则(一处集中实现,别散在各路由里): - `/api/dance/*` 的免费接口:无需 session,沿用 cookie `viewer_uid`。 - 任何扣积分/写用户资产的接口:要求有效 `session` + `users.emailVerified = true`。 - `/api/admin/*` 全部接口:要求有效 `session` + `users.role = 'admin'`。 ### M.11 `blacklist`(黑名单) V.3 的 `POST /api/admin/blacklist`、C.2 的"拉黑用户/资产"、P.6 的人工兜底 都在写这张表,原稿漏了它: | 字段 | 类型 | 说明 | |---|---|---| | `id` | string | 主键 | | `targetType` | enum | `user` \| `character` \| `garment` \| `generated_video` \| `ip` | | `targetId` | string | 对应实体 id;`ip` 类型存 IP 字符串 | | `reason` | string | 管理员填写 | | `createdBy` | string | 管理员 `userId` | | `createdAt` | number (ts) | | 拦截点:提交类接口(`/api/dance/pick`、两个 upload)入口先查这张表,命中 直接拒绝,**不进流水线、不扣积分**;已在库的资产被拉黑时,同时从排播候选 和衣柜列表里过滤掉(不删除记录,跟 5 节"记录保留"的口径一致)。 ⚠️ 这张表和需求文档 5 节说的**人脸黑名单是两回事**:这里是运营手工拉黑的 本站实体,人脸黑名单是模型侧的敏感人物识别(选型见需求文档 8.4)。 ### M.12 `pricing_config`(计费配置) 需求 7.2 的原话是"先把余额+流水+**配置化扣费额**这套机制打好,数值上线前 再定"——数值待定不等于载体待定,这张表就是那个载体(第一期可以是一个 `pricing.json`,字段不变): | 字段 | 类型 | 说明 | |---|---|---| | `key` | string | 主键,如 `spend_garment` / `spend_character_per_segment` / `topup_tier_1` | | `kind` | enum | `spend`(扣费项)\| `topup_tier`(充值档位) | | `credits` | number | 扣费项=消耗积分数;充值档位=到账积分数 | | `priceCents` | number \| null | 仅 `topup_tier` 用,法币价格(分) | | `segmentsPerUnit` | number \| null | 仅 `spend_character_per_segment` 用:每份积分换算成几个 10 秒片段,模式三的 `remainingSegments` 由此算出(需求 3.3 第 3 点) | | `isActive` | boolean | 下架旧档位不删记录(历史流水要能解释当时的价格) | | `updatedAt` | number (ts) | | **扣费金额一律从这张表读,不写死在代码里**;`credit_ledger` 每条流水落库时 把当时的 `credits` 一起记下来,改价不影响历史账目的可解释性。 ### M.13 上传图片/视频的存储与访问(已按 S4 spike 结论简化) 原稿里 `garments.imageUrl`/`characters.imageUrl` 都只是 string,没说文件 存哪、怎么对外服务、上限多少。v1 只有视频、且走 `GET /lib/video/:id` 反代 H3 Console(本地不落盘),**用户上传图片是一条全新的链路**,先定死这几点: - **落地位置**:第一期落本地盘 `uploads/{yyyymm}/{uuid}.{ext}`,`server.js` 新增静态路由 `GET /uploads/*` 只读服务(与 `/lib/video/*` 并列),供 `admin.html`/`dance.html` 自己展示缩略图用。`imageUrl` 存相对路径 `/uploads/...`,不存绝对域名,方便以后整体迁到对象存储时只改一处前缀。 - **入站限制**(trust boundary,必须在服务端做,不能只靠前端校验): 单文件 ≤ 8 MB;仅接受 `image/jpeg`/`image/png`/`image/webp`,且按**实际 文件头**判断而不是扩展名或客户端 `Content-Type`;服务端重新编码一遍再 落盘(顺带剥掉 EXIF——用户上传的人物照大概率带 GPS 定位)。 - **文件名**:一律服务端生成 uuid,不使用用户提供的文件名(路径穿越)。 - **喂给 H3 Console 的那一份,走 `/v1/assets` 转发,不是自建公网签名** ——**2026-09-02 S4 spike 已推翻原方案**(原方案是"给 `uploads/` 挂 公网可读的临时签名地址")。H3 Console 自带 `POST /v1/assets`(multipart, 字段名 `file`),是控制台自己的文件选择器在用的同一个端点,返回的 URL H3 后端保证能拉到(`H3-CONSOLE.md` 已补充此端点文档)。落地方式:图片/ 视频先落本地 `uploads/`(给我们自己的页面用),**需要提交生成任务时再 转发一次到 `/v1/assets`**,拿到的 URL 直接填 `reference_image_urls`/ `reference_video_urls`,`characters`/`garments`/`dance_refs` 表可以选择 多存一个 `h3AssetUrl` 字段缓存这个转发结果,避免每次提交都重新转发同一 份素材。**这条只解决 H3 Console 这一侧**;第四期若接 fal.ai 实时路径, fal 是完全不同的服务,仍要单独解决入站可达性(需求文档 8.10,推迟到 第四期,不影响第一~三期)。 - **清理**:审核驳回/生成失败的上传文件,随退款流程一起删除物理文件, 不留孤儿。 ### M.14 ER 关系图 ```mermaid erDiagram users ||--o{ sessions : "登录态/验证令牌" users ||--o{ credit_ledger : "流水" users ||--o{ blacklist : "被拉黑 / 创建拉黑" pricing_config ||--o{ credit_ledger : "扣费/充值取价" users ||--o{ characters : "user_paid 拥有者" users ||--o{ garments : "user_paid 拥有者" users ||--o{ generated_videos : "requestedBy" characters ||--o{ generated_videos : "一步法直接输入(无 Stage1)" garments ||--o{ generated_videos : "一步法直接输入(无 Stage1)" dance_refs ||--o{ generated_videos : "Stage2 输入" characters ||--o{ moderation_log : "审核记录" garments ||--o{ moderation_log : "审核记录" ``` ### M.15 Stage2 默认 Prompt 模板(已确认,需求文档 4.2 节;**一步法版,2026-09-02 实测通过**) 依据仓库里的 `docs/h3skill.md`(内部 H3 提示词写作方法论)"全能参考 模式六段式"结构写成。**`{{...}}` 是占位符**,由 M.3/M.4/M.5 的 `appearanceDescriptor`/`motionDescriptor` 字段在拼装时填入: - `{{appearanceDescriptor}}` 来源:**优先取 `characters` + `garments` 两个手写字段拼接**,视觉模型自动打标只是模式二/三的兜底(第四期) - `` = 人物参考图(`characters.imageUrl`),供人脸/发型/体型 的视觉身份 - `` = 衣服参考图(`garments.imageUrl`),`retention_analysis` 标 `attribute_transfer`(H9 白名单取值,语义就是属性转移)——只转移 服装的外观属性,不引入这张图的其他内容 - `