# Spec: 巡樓 Console 重構（Harbor Desk）

> Status: **`approved`**  
> Source: `docs/product/haixun-console/requirements.md`（approved 2026-07-09）  
> Last updated: `2026-07-09`  
> 批准：使用者要求開始拆 plan/tasks（2026-07-09）  
> 變更：reply 步驟發言池含 lead（主帳可回別人的回覆）  
> 範圍：Phase A 地基契約 + Phase B 前端/Mock 行為；Phase C 接真映射；Phase D 僅邊界預留

---

## 1. 摘要

重構後的巡樓 Console 分四階段交付。本規格鎖定：

1. **平台地基**：登入會話、角色權限、Threads 帳號基礎、Job、站內通知。  
2. **新前端 Harbor Desk**：5 入口 IA、Studio 多帳互回 wizard、Outbox、通知；**預設 mock 資料**。  
3. **同一契約**在 `mock | live` 切換；B 未過關不開大型業務後端。

落點（2026-07-09 已封存舊碼）：

| 項目 | 決策 |
|------|------|
| 舊 monorepo | **`old/` 整包封存，只讀** |
| 新前端 | repo 根 `apps/web`（從零） |
| 新後端 | `services/api`（從零；A 可先 mock-only 前端） |
| 視覺 | Harbor Desk 為唯一預設主題 |
| 海巡 Scout | B 階段占位空頁 |
| Email 驗證閘 | A/B 關閉 |
| Mock 持久化 | `localStorage` |

---

## 2. 名詞與實體

| 名詞 | 定義 |
|------|------|
| Member | 登入會員（email/password + JWT） |
| Role | 簡化：`member` \| `admin`（可多值） |
| ThreadsAccount | 已綁定的 Threads 經營帳號（OAuth token 由後端保管） |
| Lead | 本串**主帳號**：固定發 `root` 樓主貼；並屬於發言池，可在後續 `reply` 再出場 |
| Cast | 本串**額外可出場帳號**（通常不含 lead）；與 lead 組成發言池 |
| Speakers（發言池） | `{ lead_account_id } ∪ cast_account_ids`；**每一則 reply 步驟的 `account_id` 必須 ∈ Speakers**，**允許 = lead** |
| ThreadPlay | **發文計畫 / 串場劇本**（一則主貼 + 有序回覆步驟） |
| PlayStep | 劇本一步：`kind=root\|reply`、綁定 `account_id`、文案、可選排程偏移；`reply` 語意為接上一步（單線） |
| OutboxItem | 出站佇列項目：對應某 PlayStep 的一次發送意圖與狀態 |
| Job | 背景任務 run（進度、狀態、結果摘要） |
| Notification | 站內通知（多由 Job 終態產生） |
| DataSource | `mock` \| `live`：前端資料來源模式 |

---

## 3. 狀態機

### 3.1 Job

```text
pending --> queued --> running --> succeeded
                       running --> failed
                       running --> cancel_requested --> cancelled
queued|pending --> cancelled   (若支援提前取消)
* --> expired                  (lock 逾時，後端語意；mock 可簡化省略)
```

### 3.2 ThreadPlay

```text
draft --> ready          (劇本與帳號齊全、通過預覽)
ready --> scheduling     (寫入 Outbox)
scheduling --> active    (至少一步 in flight 或 scheduled)
active --> completed     (全部步驟 succeeded 或 skipped)
active --> partial_failed (有 failed，可重試)
draft|ready --> archived
```

### 3.3 PlayStep / OutboxItem

```text
draft --> scheduled --> publishing --> published
                         publishing --> failed
scheduled --> cancelled
failed --> scheduled     (retry)
```

**規則：**

- `root` 步驟未 `published` 前，後續 `reply` 不得進入 `publishing`。  
- `root` 若 `failed`，後續步驟保持 `scheduled` 或改 `blocked`（顯示「等待主貼」）。  
- `root.account_id` **必須** = `lead_account_id`。  
- 任一 `reply.account_id` **必須** ∈ Speakers（**含 lead**）；校驗失敗不可 submit。  
- P0 單線：第 n 步 reply 視為回覆第 n−1 步已發出的那則（不另選 parent；D 再談樹狀）。

### 3.4 ThreadsAccount.connection

```text
unknown --> connected
unknown --> error
connected --> error
error --> connected      (重新授權/診斷成功)
```

---

## 4. 使用者流程

### 4.1 登入（A/B）

1. 使用者開 `/login`，輸入 email、password（可選 tenant 若多租戶 UI 需要；mock 固定 `default`）。  
2. 成功 → 存 access/refresh（live）或 session flag（mock）→ 進 `/app/today`。  
3. 失敗 → 表單錯誤，不進主殼。  
4. 重新整理：有有效會話則進主殼，否則回登入。  
5. 登出 → 清會話 → 登入頁。

### 4.2 帳號 Crew（B mock / C live）

1. 列表顯示已綁帳號：顯示名、頭像色、connection、是否預設 lead 候選。  
2. 「連接 Threads」：live 走 OAuth；mock 新增一個假帳號。  
3. 標記不健康帳號不可被選為 Lead/Cast（disabled + 原因）。

### 4.3 創作 Studio — 互回 wizard（B 核心）

| 步驟 | 畫面行為 |
|------|----------|
| 1 主題 | 標題/一句主題；可「示範填入」 |
| 2 船隊 | 必選 1 Lead；多選 Cast（≥0，UI 列表可預設排除 lead，避免重複勾）；不可選 error 帳號 |
| 3 劇本 | 第 0 步固定 `root` + `account_id=lead`（不可改帳）；其後每步 `reply`：**下拉發言池 = lead + 全部 cast**（**含 lead**，以便主帳回別人的回覆）+ textarea；可增刪排序（root 固定頂） |
| 4 預覽 | 對話泡泡：帳號名 + 文案；root 標記；可見 lead 出現在非第 0 步 |
| 5 船期 | 主貼時間（datetime）；回覆間隔（分鐘，預設 5）；可預覽各步預計時間 |
| 6 提交 | 建/更新 ThreadPlay=`scheduling`，生成 OutboxItem；導向 Outbox 或 Today |

任一步「上一步」保留表單狀態。草稿自動存（mock：`localStorage` key 見 §8）。

### 4.4 Outbox

1. 列表：Play 標題、狀態、步驟進度（2/5）。  
2. 點進詳情：逐步狀態時間線。  
3. Mock「模擬發送」：依序 root→replies 推進狀態（可加速）。  
4. 失敗步驟顯示錯誤文案 +「重試」（mock 直接 succeeded）。

### 4.5 Today

1. 卡片：待處理失敗、今日排程、進行中 Job、快捷「開新串場」。  
2. 點通知/失敗 → 對應 Outbox 或 Job 詳情。

### 4.6 通知

1. 頂欄鈴鐺：未讀數。  
2. 下拉最近 N 筆；點擊標記已讀並 navigate。  
3. Job `succeeded|failed` 產生通知（mock 在模擬結束時寫入）。

### 4.7 Scout（B）

占位頁：標題「探查」、短說明「海巡獲客將在下一階段開放」、無假成功操作。

### 4.8 設定

- 顯示目前 `DataSource` 切換（dev 可見；prod 預設 live 且可藏）。  
- AI key：B 僅 UI 存 local（不送出）；C 再定義。

---

## 5. 介面契約

### 5.0 共用規則

| 規則 | 規格 |
|------|------|
| Envelope | `{ code, message, data?, error? }`；成功 `code=102000` |
| 列表 | query `page`/`pageSize`；`data: { pagination, list }` |
| 時間 | API/DB：**unix nanoseconds int64 UTC**；UI 顯示本地時區 |
| Auth | `Authorization: Bearer <access>`；401 → refresh 一次 → 仍失敗登出 |
| 禁止 | 未實作端點回 200+空成功；應 501 或明確業務錯誤 |

前端 repository 介面（概念）：

```text
AuthRepo, MemberRepo, PermissionRepo,
ThreadsAccountRepo, ThreadPlayRepo, OutboxRepo,
JobRepo, NotificationRepo
```

`createRepos(dataSource)` → mock 或 live 實作同一 TypeScript interface。

### 5.1 HTTP / API（Phase A 最小 + C 對齊）

> 路徑為**目標契約**；可與舊 `/api/v1` 對齊以利搬碼。Mock 不經 HTTP 亦可，但 shape 相同。

#### Auth / Member / Permission

| Method | Path | 用途 | 要點 |
|--------|------|------|------|
| POST | `/api/v1/auth/login` | 登入 | body: email, password, tenant_id? → tokens |
| POST | `/api/v1/auth/refresh` | 換票 | refresh_token → tokens |
| POST | `/api/v1/auth/logout` | 登出 | auth |
| GET | `/api/v1/members/me` | 我 | tenant_id, uid, email, roles[] |
| GET | `/api/v1/permissions/me` | 我的權限 | keys[] 或 * |

Admin 建號可延後；mock 內建一組使用者即可。

#### Threads accounts

| Method | Path | 用途 |
|--------|------|------|
| GET | `/api/v1/threads-accounts` | 列表（pagination） |
| POST | `/api/v1/threads-accounts/oauth/start` | 取得授權 URL（live） |
| GET | `/api/v1/threads-accounts/:id` | 詳情 / 健康 |
| POST | `/api/v1/threads-accounts/:id/diagnostics` | 連線診斷（可選 A） |

#### Jobs

| Method | Path | 用途 |
|--------|------|------|
| GET | `/api/v1/jobs` | 列表 |
| GET | `/api/v1/jobs/:id` | 詳情 |
| POST | `/api/v1/jobs/:id/cancel` | 取消 |
| POST | `/api/v1/jobs` | 建立（A 可僅 demo 或內部） |

Job 資源欄位（最低）：`id, template_type, status, progress_summary, progress_percent, error?, created_at, updated_at, completed_at?`

#### Notifications

| Method | Path | 用途 |
|--------|------|------|
| GET | `/api/v1/notifications` | 列表 |
| POST | `/api/v1/notifications/:id/read` | 已讀 |
| POST | `/api/v1/notifications/read-all` | 全已讀 |

欄位：`id, title, body, kind, ref_type, ref_id, read_at?, created_at`

#### ThreadPlay / Outbox（B mock 完整；C 再 live）

| Method | Path | 用途 |
|--------|------|------|
| GET/POST | `/api/v1/thread-plays` | 列表 / 建立 |
| GET/PATCH | `/api/v1/thread-plays/:id` | 讀 / 更新劇本 |
| POST | `/api/v1/thread-plays/:id/submit` | 提交進 Outbox |
| GET | `/api/v1/outbox` | 佇列列表 |
| GET | `/api/v1/outbox/:id` | 詳情（含 steps） |
| POST | `/api/v1/outbox/:id/steps/:stepId/retry` | 重試一步 |

**ThreadPlay 主體（邏輯 JSON）：**

```json
{
  "id": "…",
  "title": "週末咖啡話題",
  "topic": "…",
  "status": "draft",
  "lead_account_id": "…",
  "cast_account_ids": ["…"],
  "steps": [
    {
      "id": "s0",
      "sort_order": 0,
      "kind": "root",
      "account_id": "…",
      "text": "主貼文案",
      "delay_from_previous_sec": 0
    },
    {
      "id": "s1",
      "sort_order": 1,
      "kind": "reply",
      "account_id": "…",
      "text": "回覆文案",
      "delay_from_previous_sec": 300
    }
  ],
  "schedule_start_at": 0,
  "created_at": 0,
  "updated_at": 0
}
```

### 5.2 前端路由（`apps/web`）

使用 **path router**（非 hash），basename 可 `/`。

| Path | 名稱 | 需登入 | Phase |
|------|------|--------|-------|
| `/login` | 登入 | 否 | B |
| `/app` | 佈局殼 | 是 | B |
| `/app/today` | 今日 | 是 | B |
| `/app/crew` | 帳號 | 是 | B |
| `/app/studio` | 創作列表 | 是 | B |
| `/app/studio/new` | 新 wizard | 是 | B |
| `/app/studio/:playId` | 編輯 wizard | 是 | B |
| `/app/outbox` | 發送佇列 | 是 | B |
| `/app/outbox/:id` | 佇列詳情 | 是 | B |
| `/app/jobs` | 任務（可二級） | 是 | B |
| `/app/jobs/:id` | 任務詳情 | 是 | B |
| `/app/scout` | 探查占位 | 是 | B |
| `/app/settings` | 設定 | 是 | B |

**導覽（一級，≤5）：** Today · Crew · Studio · Scout · Outbox  
Jobs 從 Today/通知進入；Settings 在使用者選單。

### 5.3 Job types（A 最小）

| template_type | 用途 | Phase |
|---------------|------|-------|
| `demo_long_task` | 驗證 job+通知 | A/B mock 可模擬 |
| （業務 job） | 發佈串場、8D… | C/D |

B mock 可不跑真 worker：Timer 推進 job 狀態並寫 notification。

---

## 6. 與現況對照

| 能力 | 決策 | 備註 |
|------|------|------|
| 整個舊 monorepo | **Archive** | 實體路徑 `old/`；主線不引用為 package |
| 舊 Auth/JWT/envelope | **參考後重寫** | 契約精神可相容 |
| 舊 Job 核心 | **參考後重寫** | 可抄模式，不搬半成品 |
| copy_mission 管線 | **Discard** | 不復活 |
| 舊 web Console | **Archive** | 新 `apps/web` |
| 海巡 Patrol 全套 | **Defer D** | Scout 占位 |
| 田園掌機 UI | **Discard 預設** | Harbor Desk |
| 時間/envelope/job 規則 | **Keep as policy** | 見根目錄 `AGENTS.md` |

---

## 7. 非功能

### 安全 / 權限

- 未登入不可進 `/app/**`。  
- `admin` 專屬操作（若有）前後端雙檢。  
- Live：token 不進 URL；refresh 單飛。  
- Mock：不模擬權限繞過成功寫入 live。

### 錯誤

- UI 可讀中文 message。  
- 區分 401（登出）vs 5xx（保留會話，顯示重試）。  

### 可觀測

- Job 進度摘要必顯示。  
- Outbox 步驟級狀態必顯示。  

### 效能（B）

- 首屏可互動 < 3s（本機 dev 網路）。  
- 列表 mock ≤ 50 筆即可。  

### 無障礙（基線）

- 表單 label 關聯；主按鈕對比足夠；焦點可見。

---

## 8. 資料保存（邏輯）

### 8.1 Mock localStorage keys

| Key | 內容 |
|-----|------|
| `harbor.datasource` | `mock` \| `live` |
| `harbor.session` | mock 登入態 |
| `harbor.accounts` | ThreadsAccount[] |
| `harbor.plays` | ThreadPlay[] |
| `harbor.outbox` | Outbox 聚合 |
| `harbor.jobs` | Job[] |
| `harbor.notifications` | Notification[] |
| `harbor.play_draft` | wizard 未提交草稿 |

### 8.2 實體欄位（最低）

| 實體 | 關鍵欄位 |
|------|----------|
| Member | uid, tenant_id, email, roles |
| ThreadsAccount | id, username, display_name, connection, is_usable |
| ThreadPlay | 見 §5.1 JSON |
| OutboxBundle | id, play_id, status, steps[] |
| OutboxStep | step_id, account_id, status, error?, published_at?, scheduled_at? |
| Job | 見 §5.1 |
| Notification | 見 §5.1 |

---

## 9. UI 規格（Harbor Desk）

### 9.1 Layout

```text
┌─────────────────────────────────────────────┐
│ Logo   [今日]…          🔔  主題  使用者 ▾   │  topbar
├──────────┬──────────────────────────────────┤
│ Today    │  Page title                      │
│ Crew     │  Content                         │
│ Studio   │                                  │
│ Scout    │                                  │
│ Outbox   │                                  │
└──────────┴──────────────────────────────────┘
```

- 桌面：左導覽 ~220px。  
- 手機：底欄 5 格（Today/Crew/Studio/Scout/Outbox）；設定進使用者選單。  
- 內容最大寬 ~1080px，表單閱讀寬 ~640–720px（wizard）。

### 9.2 視覺 token（建議名，實作可微調色值）

| Token | 用途 | 建議 |
|-------|------|------|
| `--hb-bg` | 頁背景 | 冷灰白 |
| `--hb-surface` | 卡片 | 白 |
| `--hb-ink` | 主文 | 近黑 slate |
| `--hb-muted` | 次文 | 可讀灰（勿過淡） |
| `--hb-line` | 邊框 | 淺灰 |
| `--hb-brand` | 主色/CTA | 霧海青 |
| `--hb-danger` | 錯誤 | 紅 |
| `--hb-success` | 成功 | 綠 |
| `--radius` | 卡片 | 12–16px |
| `--font-sans` | 混排 | Inter, Taipei Sans TC, system |

禁止：滿版動態雲、木質頂欄、emoji 主 icon、彩虹漸層按鈕。

### 9.3 元件基線

Button（primary/ghost/danger）、Input、Textarea、Select、Card、Badge（狀態）、EmptyState、Stepper（wizard）、Toast、BellMenu。

---

## 10. 驗收場景（Given / When / Then）

1. **Given** 未登入 **When** 開 `/app/today` **Then** 導向 `/login`。  
2. **Given** mock 帳密正確 **When** 登入 **Then** 進 Today 且看到至少一張待辦/空狀態。  
3. **Given** 兩個 usable 帳號 A、B **When** Studio：lead=A cast=B，劇本為 root(A) → reply(B) → reply(A) **Then** 可預覽三句；submit 後 Outbox 三步順序正確且第二則回覆發言者為 lead。  
3b. **Given** 劇本某 reply 選了不在 Speakers 的帳號 **When** 提交 **Then** 阻擋並提示。  
4. **Given** 已提交串 **When** 模擬發送 **Then** root→reply 依序 published；通知出現成功。  
5. **Given** root 模擬失敗 **When** 查看 **Then** reply 不標 published；可重試 root。  
6. **Given** Job 模擬完成 **When** 開鈴鐺 **Then** 未讀 +1，點進 job 詳情。  
7. **Given** Scout **When** 進入 **Then** 僅占位，無「成功建立海巡」類假操作。  
8. **Given** live 模式且後端 5xx **When** 拉 me **Then** 不強制清 token（與需求 US-09 對齊，C 必須）。  

---

## 11. Phase 邊界（實作不得越級）

| Phase | 做 | 不做 |
|-------|----|------|
| A | 契約+可選最小 live auth/job；或純 mock 平台殼 | 海巡、完整互回後端 |
| B | `apps/web` 全 mock 體驗 | 大寫業務 API |
| C | 逐 repo 接 live | 改 IA 大翻（除非修 bug） |
| D | 海巡等 | — |

---

## 12. 開放規格問題

1. **Tenant UI：** login 是否顯示 tenant 欄？**預設：隱藏，mock/live 固定 default tenant。**  
2. **Lead 能否出現在 reply？** → **已決策：必須支援**（主帳回別人的回覆）。Cast 多選 UI 仍可不重複勾 lead；發言下拉一定含 lead。  
3. **A 是否必須先有可跑的 Go gateway，還是 B 可完全純前端？** **預設：B 可純前端 mock；A 的 live 可與 B 平行，但不阻塞 B 過關。**  
4. **Outbox 與舊 publish_queue 是否同 collection？** **預設：C 再定映射；規格層當新模型。**  

---

## 13. 批准

- [x] Spec 已批准：2026-07-09（使用者：好請幫我開始拆）  
- 下一步：依 [plan.md](./plan.md) 與 [tasks/](./tasks/) 實作
