后端: - 新增手机号验证码登录接口及 PublicSmsLoginDto - LeaiSync 作品同步状态阈值从 CATALOGED 调整为 DUBBED - UgcWork 实体字段微调、数据库迁移脚本修正 前端: - Login 页面支持用户名/手机号双模式登录 - public.ts 新增 loginBySms、sendSmsCode API - AI 创作流程全部视图添加 keep-alive 组件名导出 - CreatingView 生成逻辑优化 - WelcomeView 欢迎页增强 - BookReaderView、作品库等页面细节修复 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 KiB
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 进度状态,没有独立的发布状态字段
// 当前注释
// -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 进度状态和本地发布状态分到两个独立字段,彻底解决语义复用。
-- 现有: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:
-- 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.javapublic 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类型加unpublishedfrontend/src/api/public.tsworks/Index.vue作品库列表- tabs 数组加
{ key: 'unpublished', label: '未发布' } - statusTextMap 加
unpublished: '未发布' - 卡片状态标签样式加
&.unpublished(紫色淡) - 按 status 显示空状态文案
- tabs 数组加
works/Detail.vue作品详情- 整体配色清理紫粉化(emoji + 橙色清理)
- 核心:根据 status 显示不同操作按钮:
unpublished→ 「公开发布」(主操作,紫粉渐变) + 「编辑信息」 + 「补充配音」 + 「删除」pending_review→ 「撤回审核」 + 「删除」published→ 「下架」 + 「删除」rejected→ 「修改后重交」 + 「删除」
create/views/EditInfoView.vue三按钮语义调整- 「保存」 → unpublished,跳作品库
?tab=unpublished - 「去配音」 → DubbingView,配音完成后还是 unpublished
- 「立即发布」 → 一次性走完 unpublished + 公开发布两步,跳作品库
?tab=pending_review
- 「保存」 → unpublished,跳作品库
- 删除
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.sqlmigration UgcWork实体加leaiStatus+ 改status类型LeaiSyncService加 leai → 本地 status 映射逻辑PublicUserWorkServicepublish/unpublish/withdraw 接口实现PublicContentReviewService审核动作 status 语义对齐
7.3 第三阶段:联调 + 超管端调整
- 前端去 dev mock,接入真实接口
- 超管端 WorkReview / WorkManagement 状态映射调整
- 端到端测试:完整跑通从创作 → 编目 → 未发布 → 公开发布 → 审核 → 已发布 → 下架 → 重新发布的全流程
8. 相关文档
- 用户端 UGC 社区升级 — 上层产品定位
- UGC 开发计划 — 整体开发节奏
- 超管端内容管理 — 超管审核流程
- 超管端作品数据优化 — 超管端作品列表
附录 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 三态命名风格一致
- 本地化为"未发布"也是中文用户最直观的理解