library-picturebook-activity/docs/design/public/ugc-work-status-redesign.md
En 8995e2f2e2 feat: 公众端多项功能增强——短信登录、作品状态优化、创作流程组件 keep-alive
后端:
- 新增手机号验证码登录接口及 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>
2026-04-10 11:19:42 +08:00

418 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=4CATALOGED到本地 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` | → -2taken_down |
| 超管恢复 | `POST /content-review/works/{id}/restore` | → 3published |
| 超管撤销审核 | `POST /content-review/works/{id}/revoke` | → 1pending_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 三态命名风格一致
- 本地化为"未发布"也是中文用户最直观的理解