# 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 点
                                  │   保存 / 去配音 / 直接发布
                                  │ 编目完成(leai=CATALOGED)后同步为未发布
                                  ▼
                          ┌───────────────┐
                          │  UNPUBLISHED  │  成品私有：已编目，配音可选
                          │    未发布      │◀──────────────┐
                          └───────┬───────┘               │
                                  │                       │
                                  │ 用户在详情页点         │
                                  │   "公开发布"          │
                                  ▼                       │
                          ┌───────────────┐               │
                          │PENDING_REVIEW │  排队待审核    │
                          │    审核中      │               │
                          └───┬───────┬───┘               │
                              │       │                   │
                  超管通过    │       │ 超管拒绝          │
                              ▼       ▼                   │
                  ┌───────────────┐ ┌───────────────┐    │
                  │   PUBLISHED   │ │   REJECTED    │    │
                  │    已发布      │ │    被拒绝      │    │
                  │  发现页可见    │ │               │    │
                  └───────┬───────┘ └───────┬───────┘    │
                          │                 │             │
                          │ 用户/超管下架    │ 用户改完重交  │
                          └─────────────────┴─────────────┘
                                            │
                                  rejected → pending_review
                                  published → unpublished
```

### 2.2 状态流转表

| 起始状态 | 触发 | 目标状态 | 触发方 | 接口 |
|---|---|---|---|---|
| (无) | 创建作品 | DRAFT | 系统 | leai 创作流程内部 |
| DRAFT | 编目完成（leai status → **CATALOGED**，配音为可选） | UNPUBLISHED | 系统（Webhook 或 `PUT /leai-proxy/work/{id}` 后 `LeaiSyncService` 同步） | 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` |

**实现说明（与代码对齐）**：`LeaiSyncService` 在乐读派进度前进至 ≥ `CATALOGED` 时，若本地 `status` 仍为 `draft`，则写入 `unpublished`。`PUT /leai-proxy/work/{id}` 代理在乐读派返回成功后主动调用 `syncWork`，减少仅依赖 Webhook 的延迟，便于「直接发布」单次提交审核。

### 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** | 本次新增状态 | 乐读派进度 ≥ CATALOGED(4) 且本地同步后，用户尚未提交审核 |

## 附录 B：状态命名为什么是 `unpublished` 而不是 `private`

- `private` 在很多平台是"永久不公开"的语义（比如 GitHub private repo）
- `unpublished` 强调"还没发布"的暂时性，跟"已发布 published"形成对立
- 跟 Medium 的 Draft / Unlisted / Published 三态命名风格一致
- 本地化为"未发布"也是中文用户最直观的理解
