# Spec: 巡樓後端（Phase C · live）

> Status: `approved`  
> Status note: 使用者 2026-07-10「spec 通過」  
> Source: `docs/product/haixun-backend/requirements.md`（**approved**）  
> Last updated: `2026-07-10`  
> 修訂：平台 key／BYOK 計費；會員 API 齊全、頁面可不開；**§9 嚴格驗收場景全量**

## 1. 摘要

本規格定義 **Harbor Desk（`apps/web`）接 live** 時後端必須具備的對外行為與跨切契約。  
骨架 **搬入並改造 `stand-alone-service`**，撰寫 **一律 go-zero**（`.api` → generate → handler／logic／module）。  
P0：數字 uid、權限、**Threads OAuth**、Job／通知、AI·Exa·dev 設定（**平台 key＋BYOK**）。  
P1：創作／**Outbox 真發送**、人設、靈感研究、用量、**海巡雙路徑**、**邀請關係**。  
**會員相關 API 必須完整**（含註冊／三方登入等）；**前端頁面可不提供**（現階段 UI 以 admin 代建＋email 登入為主）。  
Worker 為 **獨立 binary**；Mongo **index init** 與 **seeder** 分包裝。

## 2. 名詞與實體

| 名詞 | 定義 |
|------|------|
| **Member** | 租戶內登入主體；主鍵 **`uid` 為數字**；以 email（或三方綁定）登入；角色含至少 `member`／`admin` |
| **Permission** | 能力點或角色集合；中介層依 token 內身份擋 API |
| **Platform keys** | 寫在 **系統設定檔（etc）** 的 AI／Exa 等金鑰；全站預設用這組代打外部服務 |
| **BYOK** | Bring Your Own Key：會員在設定填了自己的 AI 或 Exa key 時，該 meter 改打會員 key |
| **Key mode** | 單次呼叫解析結果：`platform` 或 `byok`（寫入 UsageEvent） |
| **ThreadsAccount** | 經 **OAuth** 綁定的 Threads 帳號 |
| **ThreadPlay** | 互回方案；可指定 `schedule_start_at` |
| **OutboxBundle / OutboxStep** | 發送佇列；**worker 真打 Threads** |
| **Job** | 背景任務；guarded 更新 |
| **Notification** | 站內通知 |
| **Persona / Brand / Scout\*** | 人設、品牌、海巡 |
| **Invite** | 邀請碼、`parent_uid`（數字 uid） |
| **AiSettings / PlacementSettings** | 會員級覆寫：provider／model、是否已配 key、dev_mode |
| **Usage / Plan** | free｜starter｜pro；事件含是否 BYOK |
| **Gateway / Worker** | go-zero HTTP；獨立 worker |

## 3. 狀態機

### 3.1 Member

```text
created (admin 代建 | 自助註冊 API | 三方首次登入) --> email_unverified | active
email_unverified --> active           : 驗證成功（規則依註冊管道）
active --> suspended                  : admin 停權
suspended --> active                  : admin 復權
* --> password_reset_pending          : 申請重設（token 時限）
```

- **頁面**：現階段前端**不提供**公開註冊／三方登入 UI。  
- **API**：會員域 **完整提供**（含註冊、三方 OAuth 綁定／登入等），供日後開頁或外部客戶端；未開 UI ≠ 不做 API。  
- 停權會員：登入拒絕；既有 token 在 me／中介層視為無效。  

### 3.2 ThreadsAccount（OAuth）

```text
(none) --> authorizing                : 發起 OAuth
authorizing --> connected             : callback 成功換 token
authorizing --> failed                : 使用者拒絕／換票失敗
connected --> connected               : refresh session 成功
connected --> expired_or_error        : token 失效
expired_or_error --> connected        : 重新授權或 refresh 成功
* --> disconnected                    : 解除綁定（刪除或標記不可用）
```

- `is_usable`：僅 `connected` 且未過期可供 lead／cast／發送。  

### 3.3 Job

```text
pending --> queued --> running --> succeeded
                       running --> failed
                       running --> cancel_requested --> cancelled
queued|pending --> cancelled
```

- 更新必須 **guarded**（樂觀條件或等價）；Redis lock value = `workerID`（`AGENTS.md`）。  

### 3.4 OutboxBundle / OutboxStep

```text
Bundle: scheduling --> active --> completed
                      active --> partial_failed
                      * --> cancelled

Step:   draft|scheduled --> publishing --> published
                            publishing --> failed
        failed --> publishing   : retry（規則允許時）
        * --> cancelled|blocked
```

- **真發送：** `published` 僅在 Threads API（或等價）回成功後寫入。  
- **root（主貼）失敗：** 後續 reply **不得**標為 published；可標 blocked／維持 scheduled。  
- 步驟 `scheduled_at` 來自 play 的 `schedule_start_at` 與間隔；**未到時間 worker 不送**。  
- **單篇 compose** 亦必帶可選 `schedule_start_at`（預設 now）。  

### 3.5 Invite 綁定

```text
parent_uid = null  -->  parent_uid = inviter_uid   : 會員 apply 邀請碼（一次）
parent_uid != null -->  (會員不可改)
admin: parent_uid 任意合法調整（防環：不可掛到自己的子孫下）
```

### 3.6 海巡掃描路徑

```text
dev_mode_enabled == false  -->  scan_via_api
dev_mode_enabled == true   -->  scan_via_crawler  (Chrome extension session)
```

兩條路徑皆須實作；結果統一寫入 ScoutPost 語意。

## 4. 使用者流程（行為）

### 4.1 會員進站（P0）

**主路徑（現前端）：**

1. Admin 代建島民 → **數字 uid**、臨時密碼、角色、邀請碼、可選 parent；密碼僅回傳一次。  
2. 島民 email／密碼 login → JWT。  
3. 未驗證信箱：對齊前端閘（僅驗證／登出／有限 profile）。  

**完整 API（頁面可暫無）：**

1. 自助註冊、寄碼、驗證、三方 OAuth 註冊／登入／綁定解綁等 **後端齊全**。  
2. 行為與權限與 admin 代建一致（數字 uid、角色預設 member、產邀請碼）。  
3. 前端何時開頁不阻擋 API 驗收。  

### 4.2 Threads OAuth（P0）

1. 已登入會員請求「開始綁定」→ 系統回授權 URL（含 state）。  
2. 使用者在 Threads／Meta 完成授權 → callback 至後端。  
3. 後端換 token、寫入 ThreadsAccount（密文 token **不進** JWT、不進前端永久存儲）。  
4. 前端列表顯示 username、connection、session 過期語意。  
5. 刷新 session：成功延長／更新 `session_*` 欄位；失敗標記 error。  

### 4.3 互回／單篇 → Outbox 真發送（P1）

1. 會員存 ThreadPlay（含 steps、lead／cast、`schedule_start_at`、間隔）或 compose 單篇（含 `schedule_start_at`）。  
2. submit／publishSingle → 建立 OutboxBundle＋Steps（`scheduled_at` 算好）。  
3. **Worker** 到點取步驟 → publishing → 呼叫 Threads → published 或 failed。  
4. 會員看 Outbox 詳情；失敗可 retry（合規則）。  

### 4.4 海巡（P1）

1. 會員維護 Brand／Product；prepareBrief → 可審 scan_terms。  
2. runScan：讀會員 `dev_mode_enabled` 選 API 或爬蟲。  
3. 命中列表可 draft outreach／skip／mark／send（發送仍受 Outbox 或直接回覆契約約束，spec 實作時與前端 mock 對齊）。  

### 4.5 邀請（P1）

1. 每位會員有唯一 `invite_code`。  
2. 無邀請人：`applyInviteCode` 綁定；已綁定拒絕。  
3. 會員讀自己網絡（邀請人、直邀、延伸計數）。  
4. Admin：搜尋、焦點調 `parent_uid`、防環。  

### 4.6 設定 AI／Exa 與金鑰模式（P0）

1. **系統預設 key（平台模式）**  
   - 寫在 **唯一 etc 設定檔**（或 env 覆寫同鍵）：至少  
     - AI：各 provider 的平台 API key（或目前啟用 provider 的 key）  
     - 搜尋：Exa 平台 API key  
   - 會員未填自己的 key 時，所有 AI／搜尋呼叫走 **platform keys**。  

2. **會員 BYOK**  
   - 會員在設定填寫自己的 AI key 和／或 Exa key → 該類呼叫改走會員 key。  
   - 讀取 API 只回 `*_configured`（是否已填），**永不回明文**。  
   - 清除會員 key → 回到平台 key。  

3. **解析順序（每次外部呼叫）**  

```text
if 會員該 meter 已配置自己的 key:
    use BYOK key  →  key_mode = byok
else if etc 有平台 key:
    use platform key → key_mode = platform
else:
    fail 明確錯誤（未設定可用金鑰）
```

4. Provider／model：單一 provider（`xai`｜`opencode-go`）＋單一 model；換 provider 回模型清單並保留同名或預設第一個。  
5. `dev_mode_enabled` 只影響海巡掃描路徑，與 BYOK 正交。  

### 4.7 用量怎麼算（平台 vs BYOK）— 契約（分開計、分開顯示）

目標：

1. **平台代付**才扣方案 **credits**，並計入「平台用量」。  
2. **BYOK** 不扣方案 credits，但 **必須另計次數**，且 **報表／API／前端展示與平台用量完全分開**，禁止混成一條「總次數」讓人分不清誰付的。  

#### 4.7.1 記帳規則

| 情況 | 扣方案 credits | 記什麼 | event 標記 |
|------|----------------|--------|------------|
| AI · platform | **是** | 平台點數 + 平台次數 | `key_mode=platform`，`credits>0` |
| AI · byok | **否** | **僅 BYOK 次數**（credits 固定 0） | `key_mode=byok` |
| 搜尋 · platform | **是** | 同上 | platform |
| 搜尋 · byok | **否** | 僅 BYOK 次數 | byok |
| 混合（同流程 AI+搜尋） | 各 meter 獨立 | 兩筆 event 可不同 key_mode | 例：AI byok、搜尋 platform → 平台只扣搜尋點；BYOK 只加 AI 次數 |
| 雙端皆無 key | 失敗 | 不寫成功用量 | 錯誤可讀 |
| 外部呼叫失敗 | 不扣成功點 | 可選 failed 事件 | 不計入成功 BYOK／平台次數 |

**額度檢查（僅 platform）：** 呼叫前查剩餘 credits → 不足拒絕 → 成功後原子扣點並寫 platform event。  

**BYOK：** 不查方案 credits；可另設 **BYOK 專用 rate limit**（每分鐘／日，etc 可配）；計入 **byok_count** 系列欄位。  

#### 4.7.2 彙總欄位（必須分開，禁止混加）

`UsageMonthSummary`（或等價）至少拆成兩組，**不得**只回一個模糊的 `total_calls`：

| 欄位組 | 含義 | 計入條件 |
|--------|------|----------|
| **platform** | `credits_used`、`credits_remaining`、可選 `platform_call_count` by meter | `key_mode=platform` 成功事件 |
| **byok** | `byok_call_count` 總計 + **by meter**（如 `ai_copy` / `web_search` / `ai_research` / `ai_image`） | `key_mode=byok` 成功事件 |

- 平台「已用點數」**只**加總 platform。  
- BYOK「使用次數」**只**加總 byok；**不得**加進 platform 的 used 或讓 UI 誤顯示為同一進度條。  
- 明細 `listEvents` 每筆必有 `key_mode`；篩選參數建議支援 `key_mode=platform|byok|all`（預設 all 但列表仍標示清楚）。  

#### 4.7.3 顯示契約（前端／Admin）

| 畫面 | 必須 |
|------|------|
| 會員用量摘要 | **兩塊**：① 方案點數（平台）② BYOK 本月次數（可按 meter 拆） |
| 進度條／剩餘 | **只綁平台點數**；BYOK 用數字計數，不用同一條 bar 混算 |
| 事件明細 | 每列標示「平台／自己的 Key」 |
| Admin 全體用量 | 平台消耗與 BYOK 次數 **分欄或分圖**，禁止單一「總呼叫」混加 |

#### 4.7.4 為何

- 經濟清楚：誰付錢、扣誰的額度。  
- 產品清楚：會員知道自帶 key 用了幾次，不被算進方案進度。  
- 營運可稽核：日後若對 BYOK 加服務費，已有獨立計數。  

### 4.8 失敗通則

- 未實作功能：**非 2xx 成功空 data**；回明確錯誤碼與 message。  
- Auth 失敗：不強制清前端所有 local 狀態的「假成功」；前端依錯誤處理（對齊既有精神）。  

## 5. 介面契約

### 5.1 跨切

| 項目 | 契約 |
|------|------|
| 成功 envelope | `{ "code": 102000, "message": "...", "data": ..., "error": null }`（與 `AGENTS.md` 一致；若 stand-alone 既有成功碼不同，**搬改時統一為 102000 並在 migrate 文件註明**） |
| 失敗 | `code` ≠ 成功；`message` 可讀；`error` 可帶細節；**禁止** data 裝成功空列表假裝有實作 |
| 時間 | 全部 **UTC unix nanoseconds** `int64` |
| 列表 | 請求 `page`（從 1）、`pageSize`；回應 `pagination` + `list` |
| Auth | `Authorization: Bearer <access_token>`；refresh 另端點 |
| 權限 | Auth middleware 解析 uid；Admin middleware／permission 檢查 `admin` |
| 身分 | 對外 JSON 會員主鍵欄位名 **`uid` 型別數字**（JSON number）；禁止字串 slug 主鍵 |

### 5.2 能力分組（對齊 `apps/web` repos · 路徑前綴建議 `/api/v1`）

> 下列為 **行為邊界**；精確 path 可在 go-zero `.api` 微調，但 **能力不得缺**。未列 P2 可不做。

| 域 | 主要行為 | 權限 |
|----|----------|------|
| **Auth（API 齊全）** | login, logout, refresh, me, updateProfile, requestPasswordReset, resetPassword, sendEmailVerification, verifyEmail；**register**；**三方 OAuth 登入／綁定／解綁**（provider 清單可配置） | 公開／己；UI 可暫不暴露 register／三方 |
| **Admin Members** | listPage, get, create, setSuspended, setEmailVerified, setRoles, resetPassword | admin |
| **Threads Accounts** | oauthStart, oauthCallback, list, refreshSession, disconnect | member |
| **Jobs** | list, get, createDemo／依 template 建立 | member |
| **Notifications** | list, unreadCount, markRead, markAllRead | member |
| **Settings** | get/save Ai, get/save Placement, listModels(provider)；回傳是否 platform_key_available／member_key_configured（無明文） | member |
| **Personas** | CRUD, setActive, analyzeText, analyzeAccount | member |
| **Plays** | list/get/save/remove, resolveExternalLink, submit, **listByPost**, **listByExternalUrl** | member |
| **Outbox** | list/get, retryStep, remove；（發送由 worker，無「假成功」模擬當 live 完成條件） | member |
| **OwnPosts / Mentions / Compose** | ownPosts: list/sync/lastSyncedAt/generateReply/sendReply/**analyzePost**；mentions: list/generateReply/markReplied/skip；compose: **mimic**、**analyzeViral**、publishSingle(+schedule)。**不做** generateFromFormula | member |
| **Inspiration / Research / Media** | **僅現 UI：** trends(list/refresh)、elements CRUD、session(get/save/clear)、chat(chat\|generate)；research.search；generateImage；**attach 真上傳**（持久 URL）。**不做**舊靈感 list/viral/bookmark/spark/searchTrends | member |
| **Usage** | summary, events（含 key_mode）, plan, purchase(mock pay), admin tenant summary/analytics, setMemberPrefs | member／admin |
| **Scout** | brands/products CRUD, importProductFromUrl, prepareBrief, runScanFromBrief, posts, outreach*, homework*。**不做** ScoutTopic CRUD（現 UI 未用） | member |
| **Invite** | getMyNetwork, applyInviteCode, admin getTree/move/listParentCandidates | member／admin |

### 5.3 前端對照（live 必須可驅動）

| 前端區域 | 後端域 |
|----------|--------|
| Login / Verify / Reset / Profile | Auth |
| AdminUsers | Admin Members + Usage prefs |
| Crew 帳號 | Threads Accounts OAuth |
| Crew 人設 | Personas |
| Studio 方案／Wizard／單篇＋排程 | Plays, Compose, Outbox |
| Outbox 頁 | Outbox + Worker |
| Jobs / 鈴鐺 | Jobs, Notifications |
| Settings | Settings + 擴充 ZIP（靜態或 downloads API） |
| Scout / Brands | Scout |
| Invite | Invite |
| Usage / Plans | Usage |

### 5.4 Job template（P0–P1 最低）

| Template | 觸發 | 成功 |
|----------|------|------|
| `demo` 或健康檢查類 | 手動／設定 | 進度跑完 succeeded + 通知 |
| `persona_analyze_*` | 分析人設 | persona 更新 |
| `threads_sync_posts` | 同步已發 | own posts 更新 |
| `outbox_publish_step` | worker 輪詢 | 步驟 published／failed |
| `scout_scan` | 海巡掃描 | ScoutPost 寫入 |
| `inspire`／`research` 可選 | 長任務 | 結果落庫或回寫 session |

## 6. 與現況對照

| 能力 | Retain | Replace | Remove | 備註 |
|------|--------|---------|--------|------|
| `stand-alone-service` go-zero 骨架 | ✅ 結構、middleware、module 模式 | 業務域改巡樓 | 電商 cart/order/product… | **直接搬** |
| stand-alone **多份 etc** | — | **收斂為一份** `etc/gateway.yaml`（+ env） | gateway-dev/prod/nas 碎檔常態 | 決策 #9 |
| stand-alone 數字／可產 uid 方向 | ✅ **數字 uid** | 對齊巡樓 Member | 前端 mock 字串 uid 不當契約 | 決策 #10 |
| stand-alone permission／admin middleware | ✅ 概念 | 收斂 member｜admin | 電商權限雜訊 | 決策 #11 |
| `apps/web` mock 行為 | ✅ 產品語意 | live HTTP 實作 | localStorage 當唯一真相 | 行為來源 |
| `apps/web` mock `user_*` 字串 | 僅 mock | live 用數字 uid | 當 API 契約 | |
| `old/backend` 海巡／OAuth／發文 | 演算法參考 | 新 module 重寫 | 整包搬 old | |
| Outbox 前端 simulate 按鈕 | mock 可留 | live 以 worker 真發為準 | 以 simulate 當 live 完成 | 決策 #3 |
| 舊靈感 list/viral/spark… | — | — | **Remove**；只做現 UI chat 流 | 決策 2026-07-10 |
| generateFromFormula / ScoutTopic | — | — | **Remove**（現 UI 未用） | 決策 2026-07-10 |
| 附圖 attachLocal | mock data URL | **真上傳** + 可取 URL | 假上傳當完成 | 決策 #19 |
| Insights 真管線 | 頁面 local 可留 | — | Phase C 後端真管線 | 決策 #20 P2 |
| Member uid | JSON number | FE toString 顯示 | 字串 slug 主鍵 | 決策 #10+#21 |
| 公開註冊 **頁面** | — | — | 前端暫不開 | 決策 #1 UI |
| 公開註冊／三方 **API** | ✅ 完整實作 | — | 不做半套 | API 齊、頁可後開 |
| AI／搜尋 key | etc 平台 key | 會員 BYOK 覆寫 | 僅會員 key、無平台 fallback 當唯一模式 | §4.6–4.7 |
| Mongo index 混在啟動 | — | **獨立 init 包** | 啟動時隱式 bulk | 決策 #12 |
| Seeder 混業務 | — | **獨立 seeder 包** | 與 init 合併 | 決策 #13 |

## 7. 非功能

- **安全／權限：** JWT 鑑權；admin 路由雙重檢查；provider API key 加密或至少與 JWT 分離存儲；OAuth token 僅服務端。  
- **錯誤碼：** 業務錯誤用穩定數字／scope（可沿 stand-alone FullCode 精神）；成功 **102000**。  
- **可觀測：** Job `progress_percent`／`progress_summary`；Outbox step `error` 字串；worker 日誌不含 key。  
- **效能邊界（初值）：** 列表預設 pageSize≤50；Outbox worker 併發可配置；海巡單次命中上限可配置（spec 實作預設 50–100）。  
- **設定：** **單一 etc 檔**；secret 優先環境變數覆寫。  

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

| 實體 | 關鍵欄位 | 生命週期 |
|------|----------|----------|
| Member | uid(number), email, password_hash, roles[], status, email_verified, invite_code, parent_uid?, profile… | admin 建→用→可停權 |
| ThreadsAccount | id, owner_uid, platform ids, tokens(server), connection, session_expires_at | OAuth 建→刷新→解除 |
| ThreadPlay | id, owner_uid, steps[], schedule_start_at, status | 草稿→提交 |
| OutboxBundle/Step | id, play_id, steps status, scheduled_at, error | 至 completed／取消 |
| Job | id, template_type, status, progress_*, error | 終態保留可查 |
| Notification | id, uid, kind, ref_*, read_at | 可累積 |
| Persona / Brand / Product / Scout* | owner_uid + 領域欄位 | CRUD |
| Invite 邊 | parent_uid on Member | 補填或 admin 調 |
| Platform secrets | etc／env：AI keys、Exa key | 部署設定 |
| AiSettings / Placement | per uid：provider, model, member keys configured, dev_mode | 覆寫更新 |
| UsageEvent | uid, meter, credits, **key_mode**, created_at | platform 與 byok **分列** |
| UsageMonthSummary | platform 點數欄 + **byok 次數欄（可 by meter）** | **禁止**混成唯一總次數 |
| Plan prefs | uid, plan_id, unlimited? | 只約束 platform 扣點 |
| Indexes | 各集合查詢鍵 | **init 指令**維護 |
| Seed | admin 帳、demo 選用 | **seeder 指令** |

## 9. 驗收場景（Given / When / Then）— 嚴格 PM 全量

**通過標準：** 下列每一條皆可自動化或手測重現；任一失敗＝該域未過關。  
**共通斷言（凡成功回應皆適用，下稱「成功 envelope」）：**  
`code=102000`；`message` 非空；時間欄位為 **int64 unix ns UTC**；列表含 `pagination`（`page,pageSize,total,totalPages`）+ `list`；**絕不**在未實作時回 200/102000 且 `data` 空成功。

**共通斷言（失敗）：** `code≠102000`；`message` 人類可讀；不得洩漏其他使用者密碼／明文 API key／OAuth token。

---

### 9.0 基礎設施／工程約束

| ID | Given | When | Then |
|----|-------|------|------|
| A-01 | 倉庫交付 | 檢視後端專案 | 源自 stand-alone-service 改造；go-zero `.api`+handler/logic/module 結構存在 |
| A-02 | 交付物 | 列 `etc/` | **僅一份**主設定檔（如 `gateway.yaml`）；無多份 dev/prod/nas 常態碎檔 |
| A-03 | 交付物 | 建置 | 存在 **gateway** 與 **獨立 worker** 兩個可執行目標 |
| A-04 | 乾淨 Mongo | 只跑 **init** | 必要 index 存在；**無**強制插入業務種子會員 |
| A-05 | 已 init 空業務 | 只跑 **seeder** | 種子 admin（或文件約定帳）出現；可重複執行不產生重複關鍵唯一鍵（idempotent） |
| A-06 | gateway 啟動 | 不跑 seeder | 服務可起、健康檢查 OK；不依賴 seeder 才會 bind port |
| A-07 | 任意 API | 成功 | 成功 envelope；失敗非空成功 |
| A-08 | 文件／契約 | 查會員主鍵 | **uid 為數字**（JSON number），非 `user_xxx` 字串主鍵 |

---

### 9.1 Auth · 登入／會話／權限

| ID | Given | When | Then |
|----|-------|------|------|
| AU-01 | 已 seeder 的有效帳密 | login 正確 | 102000；回 access+refresh；body 含數字 uid、roles |
| AU-02 | 有效帳 | login 錯誤密碼 | 失敗；不洩漏「帳號是否存在」以外必要資訊（至少不回 token） |
| AU-03 | 不存在 email | login | 失敗（與 AU-02 同形可接受） |
| AU-04 | 有效 access | me | 回當前會員；uid 數字；與 login 一致 |
| AU-05 | 無 Authorization | me | 401／未授權類失敗 |
| AU-06 | 過期 access | me | 失敗；**不**回成功空 member |
| AU-07 | 有效 refresh | refresh | 新 access（及可選新 refresh）；舊 access 策略文件化（作廢或並存短時） |
| AU-08 | 無效 refresh | refresh | 失敗 |
| AU-09 | 已登入 | logout | 成功；之後用原 access me 失敗（或等同登出語意） |
| AU-10 | suspended 會員 | login | 失敗，message 表明停權 |
| AU-11 | 曾登入後被停權 | me 或受保護 API | 失敗／拒絕 |
| AU-12 | 非 admin | 任一 admin-only API | 權限失敗 |
| AU-13 | admin | 同一 admin API | 可成功（其餘條件滿足時） |
| AU-14 | access 屬於 uid=A | 讀改 uid=B 的私有資源 | 失敗（除非 admin 且 API 允許） |

---

### 9.2 Auth · 註冊 API（頁面可不存在）

| ID | Given | When | Then |
|----|-------|------|------|
| AR-01 | 註冊 API 已部署 | 文件／OpenAPI／.api | **端點存在**，可被整合測試呼叫 |
| AR-02 | 合法 email+密碼 | POST 註冊 | 102000；得數字 uid；預設角色含 member；有 invite_code |
| AR-03 | email 已存在 | 再註冊 | 失敗 |
| AR-04 | 密碼過短／非法 | 註冊 | 失敗，可讀原因 |
| AR-05 | 註冊成功且需驗證 | 未驗證 login 進主功能 | 對齊閘：主功能拒或僅允許驗證流 |
| AR-06 | 註冊後 | 用回傳或流程寄碼→verify | email_verified=true |
| AR-07 | 錯誤驗證碼 | verify | 失敗；verified 仍 false |
| AR-08 | 過期驗證碼 | verify | 失敗 |
| AR-09 | 前端無註冊頁 | 只打 API | **仍可完整走完** AR-02～AR-06（API 驗收不依賴 UI） |

---

### 9.3 Auth · 重設密碼／改密／改資料

| ID | Given | When | Then |
|----|-------|------|------|
| AP-01 | 已知 email | requestPasswordReset | 一律成功形（或不洩漏是否存在）；有 token 機制 |
| AP-02 | 有效 reset token | resetPassword 新密碼 | 成功；舊密碼 login 失敗；新密碼 login 成功 |
| AP-03 | 過期／用過 token | resetPassword | 失敗 |
| AP-04 | 已登入 | updateProfile 改 display_name | 成功；me 反映 |
| AP-05 | 已登入 | 改 email | 成功後 email_verified=false（或文件約定）；需重驗證 |
| AP-06 | 改密 | 錯 current_password | 失敗 |
| AP-07 | 改密 | 正確 current + 合法 new | 成功；新密碼可登 |
| AP-08 | 頭像 | 合法圖上傳／data | 成功；非法格式／過大失敗 |

---

### 9.4 Auth · 三方登入 API（頁面可不開）

| ID | Given | When | Then |
|----|-------|------|------|
| AO-01 | 配置至少一 provider | 開始三方登入 | 回授權 URL 或等價 |
| AO-02 | 首次三方成功 callback | 完成 | 新建 Member 數字 uid；可發 JWT |
| AO-03 | 同三方帳再次登入 | callback | **同一 uid**，不重複建檔 |
| AO-04 | 已 email 登入會員 | 綁定三方 | 綁定成功；之後可用三方登 |
| AO-05 | 解綁 | 呼叫解綁 | 成功後不可再用該三方登（除非再綁） |
| AO-06 | 三方失敗／拒權 | callback | 失敗可讀；不建半套會員 |

---

### 9.5 Admin 島民管理

| ID | Given | When | Then |
|----|-------|------|------|
| AD-01 | admin | createMember 合法 | 數字 uid；臨時密碼**僅此次**回應；DB 有密碼雜湊 |
| AD-02 | admin | create 重複 email | 失敗 |
| AD-03 | admin | listPage page=1 | pagination 正確；list 元素含數字 uid |
| AD-04 | admin | query 名稱／email／uid | 只回匹配列 |
| AD-05 | admin | setSuspended true | 該帳 login 失敗 |
| AD-06 | admin | setSuspended false | 可再 login |
| AD-07 | admin | 停權自己 | 失敗 |
| AD-08 | 系統僅一 active admin | 停權或降級該 admin | 失敗（至少保留一位） |
| AD-09 | admin | setRoles 加 admin | 成功 |
| AD-10 | admin | setEmailVerified | 狀態與 verified_at 正確 |
| AD-11 | admin | resetPassword 無自訂 | 回新臨時密碼；舊密失效 |
| AD-12 | admin | resetPassword 指定密 | 該密可登 |
| AD-13 | member | createMember | 權限失敗 |
| AD-14 | admin | getUser 存在／不存在 | 有則完整；無則 404 形失敗 |

---

### 9.6 Threads OAuth 帳號

| ID | Given | When | Then |
|----|-------|------|------|
| TH-01 | 已登入 | oauthStart | 回授權 URL；state 可驗證 |
| TH-02 | 合法 callback | 換票成功 | 帳號列表多一筆；connection=connected；is_usable=true；token **不在** JSON 明文 |
| TH-03 | 使用者拒絕授權 | callback | 失敗／無 usable 帳；不寫假 connected |
| TH-04 | 換票失敗 | callback | failed 語意；可讀錯誤 |
| TH-05 | 已綁帳號 | list | 屬當前 uid；含 session 過期欄位語意 |
| TH-06 | connected 帳 | refreshSession 成功 | 更新 session_expires／refreshed；仍 usable |
| TH-07 | refresh 失敗 | refreshSession | 標記 error／不可用；不假裝成功 |
| TH-08 | 已綁 | disconnect | 列表不再 usable；不可再當 lead 發送 |
| TH-09 | 未 OAuth 只「登記」 | （若有旁路） | **P0 不得**靠旁路算驗收通過；驗收以 TH-02 為準 |
| TH-10 | 他會員帳號 id | 我方 refresh/disconnect | 失敗 |

---

### 9.7 Job 與通知

| ID | Given | When | Then |
|----|-------|------|------|
| JB-01 | 已登入 | 建立 demo Job | status 從 pending/queued 可查 |
| JB-02 | Job running | worker 推進 | progress_percent 單調不亂跳；summary 更新 |
| JB-03 | Job 成功 | 終態 | status=succeeded；completed_at 有值 |
| JB-04 | Job 失敗 | 終態 | status=failed；error 非空 |
| JB-05 | Job 成功／失敗 | 通知 | 對應 uid 有 Notification；unread+1 |
| JB-06 | 有未讀 | unreadCount | 數字正確 |
| JB-07 | 未讀通知 | markRead | read_at 有值；unread-1 |
| JB-08 | 多則未讀 | markAllRead | unread=0 |
| JB-09 | 他 uid 的 job | 我 get | 失敗 |
| JB-10 | 非法狀態跳轉 | 強改（若有內部 API） | guarded 拒絕 |

---

### 9.8 設定 · AI／Exa／dev · 平台 key／BYOK

| ID | Given | When | Then |
|----|-------|------|------|
| ST-01 | 已登入 | getAi | 有 provider/model；`api_key_configured` 布林；**無**明文 key |
| ST-02 | 已登入 | saveAi 換 provider | 成功；research_* 與 provider/model 一致 |
| ST-03 | provider=xai | listModels | 回該 provider 清單非空 |
| ST-04 | 換 provider | listModels＋預設 model | 同名保留否則第一個 |
| ST-05 | etc 有平台 AI key、會員未填 | 任 AI 呼叫成功 | key_mode=platform；**扣 credits**；平台次數+1；BYOK 次數不變 |
| ST-06 | 會員 save AI key | getAi | configured=true；仍無明文 |
| ST-07 | 會員已填 AI key | AI 呼叫成功 | key_mode=byok；**credits=0**；方案剩餘不變；**BYOK 次數+1**；平台 used 不變 |
| ST-08 | 會員清除 AI key | 再 AI | 回 platform（若 etc 有） |
| ST-09 | etc 無 AI 且會員無 | AI 呼叫 | 失敗可讀；不假成功 |
| ST-10 | etc 有 Exa、會員未填 | 搜尋成功 | platform 扣點；平台搜尋次數+1 |
| ST-11 | 會員填 Exa key | 搜尋成功 | byok；不扣點；**BYOK 搜尋次數+1** |
| ST-12 | AI byok + 搜尋 platform 同流程 | 各記 event | 平台只扣搜尋；BYOK 只加 AI 次；摘要兩組分開 |
| ST-13 | platform 額度不足 | AI 呼叫 | **拒絕**、不打外部（預設） |
| ST-14 | getPlacement | — | Exa 語意；dev_mode 布林；無明文 key |
| ST-15 | savePlacement dev_mode true/false | get | 值持久 |
| ST-16 | 擴充 ZIP | GET 下載 | 可下載非空 zip（靜態或 API） |

---

### 9.9 用量與方案

| ID | Given | When | Then |
|----|-------|------|------|
| USG-01 | 新會員 | getSummary | plan 預設合理；**platform 剩餘**與 **byok 次數=0** 分欄 |
| USG-02 | 僅 platform 消耗後 | getSummary | platform used↑ remaining↓；**byok 次數不變** |
| USG-03 | 僅 BYOK 消耗後 | getSummary | platform remaining **不變**；**byok_call_count↑**（可按 meter） |
| USG-04 | 混合：AI byok + 搜尋 platform | getSummary | 平台只扣搜尋點；BYOK 只加 AI 次數；**兩組數字各自正確** |
| USG-05 | listEvents | — | 每筆含 meter、credits、**key_mode**、時間 ns |
| USG-06 | listEvents 篩 key_mode=byok | — | **僅** byok 列；無 platform 列 |
| USG-07 | listEvents 篩 platform | — | 僅 platform 列 |
| USG-08 | 回傳 summary JSON | 檢查 | **禁止**單一 `total_calls` 把 platform+byok 加總當唯一欄位（若有 grand total 必須另標且文件化；預設不要） |
| USG-09 | 前端用量頁契約 | 對照 API | 點數條只吃 platform；BYOK 另區顯示次數 |
| USG-10 | admin tenant analytics | — | 平台消耗與 BYOK 次數 **分欄／分序列**，不可混成一條 |
| USG-11 | member | purchasePlan（假付款） | plan 變更；purchase 紀錄 |
| USG-12 | admin | setMemberPrefs plan | 該 uid 平台方案變 |
| USG-13 | admin | unlimited | platform 不因額度拒；byok 計數仍獨立累加 |
| USG-14 | member | set 他人 prefs | 失敗 |

---

### 9.10 人設 Persona

| ID | Given | When | Then |
|----|-------|------|------|
| PE-01 | 已登入 | create/save | 得 id；list 可見 |
| PE-02 | 多則 | setActiveId | getActive 為該 id |
| PE-03 | 刪除 active | remove | active 重設規則明確（空或另一則） |
| PE-04 | analyzeFromText | 成功 | status/ready 欄位更新；可落庫 |
| PE-05 | analyzeFromAccount | 成功或 Job | 結果可查；失敗 Job failed+通知 |
| PE-06 | 他 uid 人設 | 我改刪 | 失敗 |

---

### 9.11 互回方案 Play 與單篇 Compose

| ID | Given | When | Then |
|----|-------|------|------|
| PL-01 | 合法 lead+steps | save | 持久；get 一致 |
| PL-02 | reply 帳號不在 speakers | submit | 失敗 |
| PL-03 | 合法 play + schedule_start_at 未來 | submit | Outbox 建立；step.scheduled_at 對齊 |
| PL-04 | resolveExternalLink 合法 URL | — | 回 target 預覽欄位 |
| PL-05 | 非法 URL | resolve | 失敗 |
| PL-06 | compose 單篇 + schedule 未來 | publishSingle | Outbox 一 step；scheduled_at=指定時間 |
| PL-07 | compose 不傳 schedule | publishSingle | scheduled_at≈now（允許小誤差） |
| PL-08 | 無可用帳號 | publish/submit | 失敗 |
| PL-09 | 刪 play | remove | list 不再見；已提交 outbox 策略明確（保留或級聯文件化） |
| PL-10 | 已有綁 ownPost 的 play | listByPost | 只回該 post 的方案 |
| PL-11 | 已 resolve 外部 URL | listByExternalUrl | 只回該 URL 正規化後的方案 |
| CP-01 | 已登入＋人設 | compose.mimic | 回仿寫正文；platform/byok 計費適用 |
| CP-02 | 已登入 | compose.analyzeViral | 回結構分析欄位；計費適用 |

---

### 9.12 Outbox 真發送（Worker）

| ID | Given | When | Then |
|----|-------|------|------|
| OB-01 | step 未到 scheduled_at | worker tick | **不**呼叫 Threads；狀態仍 scheduled |
| OB-02 | step 已到點且 token 有效 | worker | publishing→**published**；Threads 側可見（或 sandbox 可證） |
| OB-03 | Threads API 失敗 | worker | step=failed；error 非空；**非** published |
| OB-04 | root failed | 查 bundle | 後續 reply **皆非** published（blocked/scheduled 等） |
| OB-05 | root 成功 reply 到點 | worker | reply published |
| OB-06 | failed step 可重試 | retryStep | 再進 publishing；成功則 published |
| OB-07 | 不可重試狀態 | retryStep | 失敗 |
| OB-08 | 全部成功 | bundle | status=completed |
| OB-09 | 部分失敗 | bundle | partial_failed |
| OB-10 | remove bundle | — | list 不可見；worker 不再送 |
| OB-11 | 他 uid outbox | 我 get/retry | 失敗 |
| OB-12 | live 環境 | 僅靠前端 simulate | **不算** OB-02 驗收通過 |

---

### 9.13 已發貼文／提及

| ID | Given | When | Then |
|----|-------|------|------|
| OP-01 | usable 帳 | sync | 列表更新；lastSyncedAt 更新 |
| OP-02 | sync 失敗 | — | 錯誤可讀；不清空當成功 |
| OP-03 | 有貼文 | generateReply | 回文字；platform 或 byok 計費規則適用 |
| OP-04 | sendReply | 成功 | Threads 可見；本地狀態已回 |
| OP-05 | sendReply 失敗 | — | 本地不標已送成功 |
| OP-06 | mention | markReplied / skip | 狀態正確 |
| OP-07 | 他帳 accountId | sync | 失敗 |
| OP-08 | 有貼文 | analyzePost | 分析欄位更新；platform/byok 計費適用 |
| OP-09 | 有 mention | list（可篩 account） | 契約完整；時間 ns |
| OP-10 | 有 mention | generateReply | 回文字草稿；計費適用 |
| OP-11 | — | generateFromFormula | **不提供**（Remove；呼叫應 404／未實作失敗，不得假成功） |

---

### 9.14 靈感／研究／媒體

> **範圍：** 僅現 UI（InspirePanel + Today 趨勢）。舊 list／viral／bookmark／spark／searchTrends **不做、不驗收**。

| ID | Given | When | Then |
|----|-------|------|------|
| IN-01 | listTrends | — | 有 list（可空但契約完整） |
| IN-02 | refreshTrends | — | 更新或明確錯誤 |
| IN-03 | save/remove element | — | CRUD 正確 |
| IN-04 | chat mode=chat | — | session 寫入 messages |
| IN-05 | chat mode=generate | — | draft.body 乾淨正文可取 |
| IN-06 | research.search | platform/byok | 計費規則同 §9.8 |
| IN-07 | generateImage | — | 回 url；計費規則適用 |
| IN-08 | attach/upload（**真上傳**） | 合法檔 multipart（或等價） | 回持久 **url**（+id）；非法／過大失敗；**禁止**僅回本機 data URL 當 live 完成 |
| IN-09 | 未實作上傳 | 呼叫 | **非** 102000+空成功 |

---

### 9.15 海巡 Scout

| ID | Given | When | Then |
|----|-------|------|------|
| SC-01 | 已登入 | createBrand/saveProduct | 持久 list 可見 |
| SC-02 | 刪 brand | — | 所屬 product 策略明確（級聯或拒刪） |
| SC-03 | prepareBrief 有 intent | — | 回 brief含 scan_terms 可改 |
| SC-04 | intent 空 | prepareBrief | 失敗 |
| SC-05 | dev_mode=false | runScanFromBrief | **走 API 路徑**（log/欄位 `scan_path=api` 可證） |
| SC-06 | dev_mode=true 且 session 可用 | runScan | **走 crawler**（`scan_path=crawler`） |
| SC-07 | dev_mode=true 但無 session | runScan | 失敗可讀或明確降級策略（文件＋實作一致） |
| SC-08 | 掃描成功 | listPosts | 有命中；時間 ns |
| SC-09 | draftOutreach | — | 草稿文字寫入 |
| SC-10 | skip / markPublished | — | 狀態對 |
| SC-11 | sendOutreach 真送 | — | 對齊發送契約；失敗不假 published |
| SC-12 | removePost / removeTheme | — | 列表消失 |
| SC-13 | homework save/get/list/remove | — | CRUD 正確 |
| SC-14 | importProductFromUrl 合法 | — | 回填草稿欄位；非法 URL 失敗 |
| SC-15 | provider purpose + 已填產品痛點、解法能力、同類排除詞 | prepareBrief / runScan | 只以痛點＋解法能力產詞；產品名與同類排除詞不可作為 query |
| SC-16 | provider 掃描命中 | 貼文同時有痛點、解法能力與案例／專業／推薦證據 | 留存為解法提供者；同類產品銷售與證據不足者不留存 |
| SC-17 | activity 掃描命中 | draft / promote | 短回草稿不含產品置入；不可升級為商機 |

---

### 9.16 邀請 Invite

| ID | Given | When | Then |
|----|-------|------|------|
| IV-01 | 新會員 | getMyNetwork | 有 invite_code；parent 空或 seeder 值 |
| IV-02 | 無 parent | apply 合法碼 | parent=邀請人 uid；upline 可見 |
| IV-03 | 已 parent | apply 任意碼 | 失敗 alreadyBound |
| IV-04 | apply 自己的碼 | — | 失敗 |
| IV-05 | apply 不存在碼 | — | 失敗 |
| IV-06 | apply 空碼 | — | 失敗 |
| IV-07 | 有直邀 | getMyNetwork | downlines 正確；計數一致 |
| IV-08 | admin | getTree | 森林結構完整 |
| IV-09 | admin | move 到合法父 | parent 更新；子孫跟著 |
| IV-10 | admin | move 到自己子孫下 | 失敗防環 |
| IV-11 | admin | move 到自己 | 失敗 |
| IV-12 | member | moveMember | 權限失敗 |
| IV-13 | admin | listParentCandidates(排除子樹) | 不含該子樹節點 |

---

### 9.17 契約與安全紅線

| ID | Given | When | Then |
|----|-------|------|------|
| X-01 | 任一未實作路徑 | 呼叫 | **非** 102000+空 data |
| X-02 | 成功回應 | 檢查 | 無 password、無 raw api key、無 oauth access_token 欄位 |
| X-03 | worker/gateway log | 打 key 的請求 | log **無**明文 key |
| X-04 | 列表 page=0 或負 | — | 正規化或 400，行為一致 |
| X-05 | pageSize 過大 | — | cap 到上限（如 50） |
| X-06 | 時間欄位 | 抽樣 | 皆 ns 量級 int64（非秒、非 ms 混用） |

---

### 9.18 驗收執行約定（嚴格 PM）

1. **P0 過關：** §9.0 + §9.1 + §9.3 + §9.5 + §9.6 + §9.7 + §9.8（ST-01～16 中與 P0 相關）+ §9.17 全過；§9.2／§9.4 API 存在性可測。  
2. **P1 過關：** P0 全過 + §9.9～§9.16 全過（含 OB 真發送、SC 雙路徑、IV）。  
3. 每條 **ID 必須在測試清單有對應 case 名稱**（自動化或手測腳本表）；禁止「大概可以」。  
4. 外部依賴（Threads／AI）可用 **契約測試 + 受控沙箱**；但 OB-02／TH-02 不得用「只改 DB 狀態」冒充。  

## 10. 開放規格問題

1. 成功碼若 stand-alone 現況≠102000，搬遷時統一策略（建議：**統一 102000**）。  
2. OAuth callback 網域與 state 儲存（Redis／DB）細節。  
3. 爬蟲 session 與 gateway 的傳遞安全（僅 dev_mode）。  
4. 目錄落點：**`apps/backend`**（gateway + `cmd/worker` 等同 monorepo 根；已定）。  
5. BYOK 是否要加「平台服務費」（每呼叫固定點）— **預設否**；僅記 event，日後可加。  
6. 三方登入支援哪些 provider 第一版（Google／Apple／…）— **plan 列最低集合**，API 介面先通用。  

## 11. 批准

- [x] Spec 已批准（2026-07-10 / 使用者「spec 通過」）：
