# UGC 作品状态机重设计 — 产品设计方案 > 所属端:用户端(公众端)+ 超管端 + 后端 > 状态:方案已确认,前端 UI 优先实施 > 创建日期:2026-04-09 > 最后更新:2026-04-09 --- ## 1. 背景 ### 1.1 当前问题 用户作品(`UgcWork`)的状态当前**只有 4 个用户可见状态**: ``` draft / pending_review / published / rejected ``` 这种状态划分把两个**语义完全不同**的场景混在了 `draft` 里: | 场景 | 当前归属 | 实际语义 | |---|---|---| | AI 还在生成 / 用户没保存编目信息 | draft | 半成品,没做完 | | 用户做完了完整作品但不想公开 | draft | 成品但私有 | 混在一起的后果: - 用户在作品库 "草稿" tab 看到混杂——既有"待继续"的半成品,也有"已完成等待发布"的私有作品 - 没有"已完成但私有"的归属,用户想暂存自己的成品作品没地方放 - "公开发布"是隐藏在创作流程末端的一次性决策,用户错过了就只能看着自己的作品停在 draft ### 1.2 业界对比 主流内容平台都做了「**还没做完**」和「**做完了但不想公开**」的区分: | 平台 | 半成品 | 成品但私有 | 公开 | |---|---|---|---| | 抖音 | 草稿箱 | 仅自己可见 | 公开发布 | | 小红书 | 草稿 | 私密笔记 | 公开发布 | | B 站 | 草稿箱 | 仅自己可见 | 已发布(公开) | | Medium | Draft | Unlisted | Published | 我们的方案对齐这个共识。 ### 1.3 目标 引入新的中间状态 **`unpublished`(未发布)**,明确语义边界: - **草稿(draft)** = 作品技术上还没完成(生成失败 / 还在生成 / 没完成配音) - **未发布(unpublished)** = 作品技术上完整(配音已完成),但用户没主动公开 - **审核中(pending_review)** = 用户主动提交审核 - **已发布(published)** = 审核通过,发现页可见 - **被拒绝(rejected)** = 审核未通过,可改后重新提交 并支持: - 作品在状态间双向流转(已发布可以下架回未发布、被拒可以改后重交) - 「公开发布」从一次性决策变成可后悔、可推迟的常驻按钮 --- ## 2. 状态机设计 ### 2.1 完整状态流转图 ``` ┌──────────────────────────┐ │ 创作流程开始 │ │ /p/create/upload │ └────────────┬─────────────┘ │ ▼ ┌───────────────┐ │ DRAFT │ 半成品:还在生成或未完成配音 │ 草稿 │ └───────┬───────┘ │ │ 用户在 EditInfoView 点 │ 保存 / 去配音 / 立即发布 │ 任意按钮 = 配音完成 ▼ ┌───────────────┐ │ UNPUBLISHED │ 成品私有:已配音,可发布 │ 未发布 │◀──────────────┐ └───────┬───────┘ │ │ │ │ 用户在详情页点 │ │ "公开发布" │ ▼ │ ┌───────────────┐ │ │PENDING_REVIEW │ 排队待审核 │ │ 审核中 │ │ └───┬───────┬───┘ │ │ │ │ 超管通过 │ │ 超管拒绝 │ ▼ ▼ │ ┌───────────────┐ ┌───────────────┐ │ │ PUBLISHED │ │ REJECTED │ │ │ 已发布 │ │ 被拒绝 │ │ │ 发现页可见 │ │ │ │ └───────┬───────┘ └───────┬───────┘ │ │ │ │ │ 用户/超管下架 │ 用户改完重交 │ └─────────────────┴─────────────┘ │ rejected → pending_review published → unpublished ``` ### 2.2 状态流转表 | 起始状态 | 触发 | 目标状态 | 触发方 | 接口 | |---|---|---|---|---| | (无) | 创建作品 | DRAFT | 系统 | leai 创作流程内部 | | DRAFT | 配音完成(leai status → DUBBED) | UNPUBLISHED | 系统(webhook 同步) | LeaiSyncService | | UNPUBLISHED | 用户点「公开发布」 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` | | UNPUBLISHED | 用户补充配音/编辑 | UNPUBLISHED | 用户 | 更新内容接口 | | PENDING_REVIEW | 审核通过 | PUBLISHED | 超管 | `POST /content-review/works/{id}/approve` | | PENDING_REVIEW | 审核拒绝 | REJECTED | 超管 | `POST /content-review/works/{id}/reject` | | PENDING_REVIEW | 用户撤回 | UNPUBLISHED | 用户 | `POST /public/works/{id}/withdraw`(新增) | | PUBLISHED | 用户下架 | UNPUBLISHED | 用户 | `POST /public/works/{id}/unpublish`(新增) | | PUBLISHED | 超管强制下架 | UNPUBLISHED 或 TAKEN_DOWN | 超管 | `POST /content-review/works/{id}/takedown` | | REJECTED | 改完重交 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` | ### 2.3 状态可见性矩阵 | 状态 | 用户作品库可见 | 用户详情页可见 | 发现页可见 | 超管端可见 | |---|---|---|---|---| | DRAFT | ✓(草稿 tab) | ✓(继续创作) | ✗ | ✗ | | UNPUBLISHED | ✓(未发布 tab) | ✓(带"公开发布"按钮) | ✗ | ✗ | | PENDING_REVIEW | ✓(审核中 tab) | ✓(带"撤回"按钮) | ✗ | ✓(待审核队列) | | PUBLISHED | ✓(已发布 tab) | ✓(带"下架"按钮) | ✓ | ✓(已发布管理) | | REJECTED | ✓(被拒绝 tab) | ✓(带"修改后重交"按钮) | ✗ | ✓(历史) | --- ## 3. 当前现状调研 ### 3.1 后端现状 #### `UgcWork.status` 字段 - 类型:`Integer`(非 String) - 文件:`backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java:45` - 语义:**当前直接复用了 leai 进度状态**,没有独立的发布状态字段 ```java // 当前注释 // -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, // 3=COMPLETED, 4=CATALOGED, 5=DUBBED ``` #### Migration 历史映射 `backend-java/src/main/resources/db/migration/V5__leai_integration.sql` 把旧 varchar status 转换成 INT 时的映射: ``` draft → 0 pending_review → 1 (与 leai PENDING 复用!) processing → 2 (与 leai PROCESSING 复用) published → 3 (与 leai COMPLETED 复用!) rejected → -1 (与 leai FAILED 复用!) taken_down → -2 ``` **关键问题**:本地的 `published` 和 leai 的 `COMPLETED` 共享 `status=3`,本地的 `pending_review` 和 leai 的 `PENDING` 共享 `status=1`,本地的 `rejected` 和 leai 的 `FAILED` 共享 `status=-1`。这种语义复用是历史包袱,也是这次重设计的根本原因。 #### Webhook 同步逻辑 - 文件:`backend-java/.../leai/service/LeaiSyncService.java` - 当前规则:`remoteStatus > localStatus` 时全量更新 - **缺失**:leai status=4(CATALOGED)到本地 unpublished 的映射尚未实现 #### 现有审核 / 管理接口 | 接口 | 路径 | 当前行为 | |---|---|---| | 公众端发布 | `POST /public/works/{id}/publish` | draft/rejected → 1(被复用为 pending_review) | | 超管审核通过 | `POST /content-review/works/{id}/approve` | → 3(被复用为 published) | | 超管审核拒绝 | `POST /content-review/works/{id}/reject` | → -1(被复用为 rejected) | | 超管下架 | `POST /content-review/works/{id}/takedown` | → -2(taken_down) | | 超管恢复 | `POST /content-review/works/{id}/restore` | → 3(published) | | 超管撤销审核 | `POST /content-review/works/{id}/revoke` | → 1(pending_review) | ### 3.2 前端现状 #### 类型定义 - 文件:`frontend/src/api/public.ts:380-405` - `UserWork.status: string`,枚举:`draft / pending_review / published / rejected / taken_down` - **跟后端 Integer 不一致**——必有中间转换层(待验证),暂未确认转换发生在 controller 还是 mapper #### 公众端页面 | 文件 | 当前作用 | 改造需求 | |---|---|---| | `views/public/works/Index.vue` | 作品列表(5 tab) | 加 unpublished tab、加状态颜色映射 | | `views/public/works/Detail.vue` | 作品详情 | 加「公开发布」「下架」「撤回」按钮,根据 status 切换显示 | | `views/public/works/Publish.vue` | 单独的发布页(draft → pending_review) | 删除(功能并入 EditInfoView 和 Detail.vue) | | `views/public/create/views/EditInfoView.vue` | 创作流程内编辑信息 | 三按钮语义调整:保存 / 去配音 / 立即发布 | #### 超管端页面 | 文件 | 当前作用 | 改造需求 | |---|---|---| | `views/content/WorkReview.vue` | 审核 pending_review 作品 | 状态映射加 unpublished(虽然超管看不到) | | `views/content/WorkManagement.vue` | 管理 published / taken_down 作品 | 下架目标改为 unpublished(替代 taken_down) | --- ## 4. 后端 schema 改动 ### 4.1 推荐方案:拆字段(leai 进度 vs 本地发布状态解耦) **核心思想**:把 leai 进度状态和本地发布状态分到两个独立字段,彻底解决语义复用。 ```sql -- 现有:status INT(混淆 leai 进度 + 本地发布状态) -- 改为: ALTER TABLE t_ugc_work ADD COLUMN leai_status INT NOT NULL DEFAULT 0 COMMENT 'leai 创作进度: -1=FAILED, 0=INIT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED'; -- status 字段重定义为本地发布状态(VARCHAR 跟前端对齐) ALTER TABLE t_ugc_work MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT '本地发布状态: draft/unpublished/pending_review/published/rejected'; ``` ### 4.2 数据迁移 新建 `V9__split_work_status.sql`: ```sql -- 1. 加 leai_status 字段 ALTER TABLE t_ugc_work ADD COLUMN leai_status INT NOT NULL DEFAULT 0; -- 2. 把现有 status (INT) 的值复制到 leai_status UPDATE t_ugc_work SET leai_status = status; -- 3. 改 status 字段类型为 VARCHAR ALTER TABLE t_ugc_work MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft'; -- 4. 按 leai_status 回填本地 status UPDATE t_ugc_work SET status = CASE WHEN leai_status = -2 THEN 'unpublished' -- 原 taken_down 视为未发布 WHEN leai_status = -1 THEN 'draft' -- 原 rejected 视为草稿(无法判断是创作失败还是审核拒绝) WHEN leai_status = 0 THEN 'draft' WHEN leai_status = 1 THEN 'unpublished' -- 原 pending_review 兼容数据视为未发布(保守,让用户重新主动发布) WHEN leai_status = 2 THEN 'draft' WHEN leai_status = 3 THEN 'unpublished' -- 原 published 兼容数据视为未发布(同上,避免老数据自动公开) WHEN leai_status = 4 THEN 'unpublished' WHEN leai_status = 5 THEN 'unpublished' ELSE 'draft' END; -- 5. 加索引(按状态筛选会很常用) CREATE INDEX idx_ugc_work_status ON t_ugc_work(status); CREATE INDEX idx_ugc_work_leai_status ON t_ugc_work(leai_status); ``` **保守策略**:现有 `published` / `pending_review` 数据回填为 `unpublished` 而不是直接保留 `published`。原因是状态复用导致无法区分"用户真的发布过的"和"刚创作完成的",让用户重新主动发布最安全。 ### 4.3 不推荐的备选方案:保留单字段 不改 schema,沿用 `status INT`,在 0-5 之外加 `10=unpublished, 11=pending_review_local, 12=published_local`。 **缺点**: - 语义混乱继续存在 - 同事维护时容易把 leai 数字和本地数字搞混 - migration 简单但留长期技术债 --- ## 5. 改动清单 ### 5.1 后端(P0) - [ ] **新建 `WorkPublishStatus` 枚举类** `backend-java/.../ugc/enums/WorkPublishStatus.java` ```java public enum WorkPublishStatus { DRAFT, UNPUBLISHED, PENDING_REVIEW, PUBLISHED, REJECTED } ``` - [ ] **`UgcWork` 实体改造** - 加 `leaiStatus: Integer`(保持 leai 进度) - 改 `status: String`(本地发布状态,对应新枚举) - [ ] **新建 migration `V9__split_work_status.sql`**(见 4.2) - [ ] **`LeaiSyncService.updateStatusForward`**:当 leai status 推进到 DUBBED 时,自动把本地 status 从 draft 升到 unpublished(如果当前不是更高级状态) - [ ] **`PublicUserWorkService.publish()`**:检查作品 `status == 'unpublished'`,改为 `unpublished → pending_review`;同时支持 `rejected → pending_review` - [ ] **新增 `POST /public/works/{id}/unpublish`**:用户主动下架,`published → unpublished` - [ ] **新增 `POST /public/works/{id}/withdraw`**:用户撤回审核,`pending_review → unpublished` - [ ] **`PublicContentReviewService` 审核动作语义对齐**: - approve: pending_review → published - reject: pending_review → rejected - takedown: published → unpublished(参见 6.C 关于 taken_down 的决策) ### 5.2 公众端前端(P0) - [ ] **`UserWork.status` 类型加 `unpublished`** `frontend/src/api/public.ts` - [ ] **`works/Index.vue` 作品库列表** - tabs 数组加 `{ key: 'unpublished', label: '未发布' }` - statusTextMap 加 `unpublished: '未发布'` - 卡片状态标签样式加 `&.unpublished`(紫色淡) - 按 status 显示空状态文案 - [ ] **`works/Detail.vue` 作品详情** - 整体配色清理紫粉化(emoji + 橙色清理) - **核心**:根据 status 显示不同操作按钮: - `unpublished` → 「公开发布」(主操作,紫粉渐变) + 「编辑信息」 + 「补充配音」 + 「删除」 - `pending_review` → 「撤回审核」 + 「删除」 - `published` → 「下架」 + 「删除」 - `rejected` → 「修改后重交」 + 「删除」 - [ ] **`create/views/EditInfoView.vue` 三按钮语义调整** - 「保存」 → unpublished,跳作品库 `?tab=unpublished` - 「去配音」 → DubbingView,配音完成后还是 unpublished - 「立即发布」 → 一次性走完 unpublished + 公开发布两步,跳作品库 `?tab=pending_review` - [ ] **删除 `views/public/works/Publish.vue`**(功能并入 EditInfoView 和 Detail.vue) - [ ] **作品库 `query.tab` 已支持 `unpublished` 参数**(已在前次 commit 完成) ### 5.3 超管端前端(P1) - [ ] **`views/content/WorkReview.vue`**:状态映射加 `unpublished` 显示(虽然超管的待审核 tab 看不到 unpublished,但作品历史详情可能展示) - [ ] **`views/content/WorkManagement.vue`**:下架动作目标改为 unpublished(如果保留 taken_down 概念则不改) --- ## 6. 待拍板细节 ### 6.A schema 改动幅度 → ✅ 已确认 采用**方案 1(拆字段)**:`leai_status INT` + `status VARCHAR`,语义彻底解耦。 ### 6.B Publish.vue 处理 → ✅ 已确认删除 独立的发布页在新逻辑下没有存在意义,删除。功能拆到: - 编辑信息 → EditInfoView(创作流程内)+ Detail.vue「编辑信息」按钮(流程外回头改) - 公开发布 → Detail.vue「公开发布」按钮 ### 6.C 超管下架的目标状态 → ⏳ 待确认 两种方案: **方案 1(合并)**:超管下架 = 用户下架 = `published → unpublished`,作品回到用户私有空间,用户可以再次发布 **方案 2(区分)**:超管下架 = `published → taken_down`(独立状态),用户下架 = `published → unpublished`。`taken_down` 的作品**不允许用户再次发布**,相当于"被禁言" **讨论建议**:除非有"超管强制永久下架"的硬性需求,方案 1 更简洁。如果担心被超管下架的违规作品被用户再次提交,可以在审核环节让超管选择「拒绝并禁止重交」。 ### 6.D 已确认的产品决策 | 问题 | 决策 | |---|---| | 已发布作品下架 → 未发布 | ✅ 需要 | | 被拒绝作品改完重交 | ✅ 可以 | | 草稿能否直接发布 | ❌ 不能(必须先编目完整成为 unpublished) | | 超管端不显示 unpublished | ✅ 用户私有,超管不可见 | --- ## 7. 执行顺序 ### 7.1 第一阶段:前端 UI 层(不改后端) **目标**:让产品/设计/PM 能看到完整的新交互体验,前端用 dev mock 模拟 unpublished 状态。 - [ ] 作品库 `Index.vue` 加 unpublished tab + 颜色 + 文案 - [ ] 作品库 `Detail.vue` 重做:清 emoji + 紫粉配色 + 根据 status 切换按钮 - [ ] EditInfoView 三按钮语义调整 + dev mock 跳转 - [ ] 删除 `Publish.vue` - [ ] 前端 `UserWork.status` 类型扩展 **交付物**:完整的可点击 UI 流程(dev 模式无后端可走通) ### 7.2 第二阶段:后端 schema + service **前置依赖**:第一阶段 UI 已确认,产品验收通过 - [ ] 新建 `WorkPublishStatus` 枚举 - [ ] 新建 `V9__split_work_status.sql` migration - [ ] `UgcWork` 实体加 `leaiStatus` + 改 `status` 类型 - [ ] `LeaiSyncService` 加 leai → 本地 status 映射逻辑 - [ ] `PublicUserWorkService` publish/unpublish/withdraw 接口实现 - [ ] `PublicContentReviewService` 审核动作 status 语义对齐 ### 7.3 第三阶段:联调 + 超管端调整 - [ ] 前端去 dev mock,接入真实接口 - [ ] 超管端 WorkReview / WorkManagement 状态映射调整 - [ ] 端到端测试:完整跑通从创作 → 编目 → 未发布 → 公开发布 → 审核 → 已发布 → 下架 → 重新发布的全流程 --- ## 8. 相关文档 - [用户端 UGC 社区升级](./ugc-platform-upgrade.md) — 上层产品定位 - [UGC 开发计划](./ugc-development-plan.md) — 整体开发节奏 - [超管端内容管理](../super-admin/content-management.md) — 超管审核流程 - [超管端作品数据优化](../super-admin/works-data-optimization.md) — 超管端作品列表 --- ## 附录 A:术语对照 | 术语 | 含义 | 备注 | |---|---|---| | **leai** | 乐读派外部创作服务 | 提供 AI 绘本生成能力 | | **leai status** | 乐读派的创作进度状态 | 数字 -1 ~ 5 | | **本地发布状态** | 我们自己定义的作品发布状态 | 字符串,5 个值 | | **CATALOGED** | leai 状态 4,编目完成 | 用户在 EditInfoView 保存信息后到达 | | **DUBBED** | leai 状态 5,配音完成 | 配音是可选步骤 | | **未发布 unpublished** | 本次新增状态 | 已编目完成但未公开 | ## 附录 B:状态命名为什么是 `unpublished` 而不是 `private` - `private` 在很多平台是"永久不公开"的语义(比如 GitHub private repo) - `unpublished` 强调"还没发布"的暂时性,跟"已发布 published"形成对立 - 跟 Medium 的 Draft / Unlisted / Published 三态命名风格一致 - 本地化为"未发布"也是中文用户最直观的理解