跳轉到

Theme Store(題材正典)Integration Guide — Frontend

Backend branch: feat/theme-store → cortex/dev API base: SIT(admin Bearer token 必要,所有端點皆為 admin only) 生效時間:需先對 SIT DB 套用 migration 151_theme_store.sql,cortex-api reload 後 /api/admin/theme-store/* 八支端點才會出現在 OpenAPI;migration 套用前呼叫這八支端點會 500(表不存在)。

Consumer:cortex-management 題材管理 /strategy/themes(headerBar 新增 「匯出 / 匯入」Drawer,既有產生草稿/重建題材工作流不動)+ /strategy/themes/manual(全新「手動題材」CRUD 頁)。


1. Breaking Changes(無)

既有公開端點 GET /api/themes、GET /api/themes/{theme_id}/stocks 的 response 形狀完全不變(欄位仍是 theme_id/theme_name/member_count/ is_eligible,以及 stocks 端點既有的 stocks[] 結構)。資料來源已切到新表 public.theme + taiwan.theme_member,但這對前端消費端透明——詳細行為 差異(is_blocked 排除、下市過濾維持不變)見 docs/api-reference.md「Phase 1 更新(資料來源切換)」註記。

前置條件(非 breaking,但會擋住功能):題材正典表上線當下是空的。 migration 套用後、尚未執行過 POST /import-from-coverage 前, GET /api/themes 會回傳 []、GET /api/admin/theme-store/themes 會回傳 {"themes": []}。這不是 bug,見 §10「上線第一步」。

2. 新端點總覽(8 支,全部 /api/admin/theme-store prefix,admin only)

Method Path 用途
GET /themes 列出題材(source/is_edited/member_count 等)
POST /themes 建立手動題材
PATCH /themes/{theme_name} 改描述(不改名)
DELETE /themes/{theme_name} 刪除(墓碑化,is_blocked=true)
DELETE /blocked/{theme_name} 取消墓碑,允許下次 wrapper 重新接入
GET /themes/{theme_name}/members 取成員(管理視圖,無下市過濾)
PUT /themes/{theme_name}/members 整批取代成員
POST /import-from-coverage wrapper:從 coverage 題材頁接入

Auth:Bearer token + admin(members.is_admin = true)。完整規格(含每支的 request/response 範例)見 docs/api-reference.md「Theme Store(題材正典 CRUD)」一節。

3. JSON 型別

ticker(API 欄位,全文一律用這個名字)= DB 的 stock_code(裸 4-6 位數字 代號,不帶 .TW/.TWO)。

ThemeMemberIn(寫入用):

欄位 型別 必填 說明
ticker string 是 4-6 位數字裸代號,格式錯誤回 422
company_name string 是 至少 1 字元,max_length=200,超過回 422
role string 否 upstream / midstream / downstream / related

同一次 payload 內 ticker 重複 → 422。POST /themes 的 theme_name 同樣 max_length=200,超過回 422。

theme_name 還必須是單一 URL path segment:它同時是自然鍵與 /themes/{theme_name}(以及公開 GET /api/themes/{theme_id}/stocks)的 path 參數,這些路由只吃一段 path、server 又在 routing 前就解碼,所以 AI/server 這種名稱一旦建立就再也無法被讀取/編輯/刪除。名稱含 /、\、控制字元 (ord(c) < 32),或正好是 . / .. → 422,不寫入任何資料。 字元規則已發佈在 OpenAPI schema 上(CreateThemeRequest.theme_name.pattern = ^[^/\\\x00-\x1f]+$),UI 可以直接拿這個 pattern 在送出前擋掉;違反者的 detail[].type 是 string_pattern_mismatch、detail[].ctx.pattern 就是該 pattern。保留名稱 . / .. 無法寫進 pattern,改由伺服器端驗證器擋下, detail[].msg 帶有 題材名稱不可包含路徑分隔字元或控制字元,前端仍需自行 檢查這兩個名稱。

ThemeMemberOut(讀取用):{ ticker, company_name, listing_type, role, source } —— listing_type 為 tw/two,恆為 NOT NULL(不會是 null:寫入時 解析不出上市/上櫃類別會被拒絕寫入或跳過該成員,見 §5);source 為 manual/coverage。

ThemeSummary:{ theme_name, description, source, is_edited, member_count, created_at, updated_at }

ThemeDetail = ThemeSummary + members: ThemeMemberOut[]

POST /themes 回 ThemeDetail;PATCH /themes/{name} 回 ThemeDetail; GET /themes 回 { themes: ThemeSummary[] };GET/PUT .../members 回 { theme_name, members: ThemeMemberOut[] }。

4. 五情境真實 response

以下 curl 打 $API_BASE(部署且已套用 migration 151 的 cortex-api), $ADMIN_TOKEN 為 admin 身份的 Bearer token。JSON block 內的 真實 response,本文件不得憑空杜撰 JSON 內容。

4-1 建立成功(201)

curl -i -X POST "$API_BASE/api/admin/theme-store/themes" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"theme_name":"手動題材A","description":"測試用途","members":[{"ticker":"3037","company_name":"欣興","role":"upstream"}]}'
HTTP 201
{
  "theme_name": "機器人關節",
  "description": "人形機器人關節模組供應鏈",
  "source": "manual",
  "is_edited": false,
  "member_count": 1,
  "created_at": "2026-09-02T10:20:52.411356Z",
  "updated_at": "2026-09-02T10:20:52.411358Z",
  "members": [
    {
      "ticker": "2330",
      "company_name": "台積電",
      "listing_type": "tw",
      "role": null,
      "source": "manual"
    }
  ]
}

4-2 建立同名題材(409)

緊接 4-1 之後,對同一個 theme_name 再 POST 一次:

curl -i -X POST "$API_BASE/api/admin/theme-store/themes" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"theme_name":"手動題材A","members":[{"ticker":"3037","company_name":"欣興"}]}'
HTTP 409
{
  "error": {
    "code": "Theme already exists: 機器人關節",
    "message": "Theme already exists: 機器人關節",
    "details": {}
  }
}

4-3 對墓碑列取成員(404)

先刪除 4-1 建立的題材(墓碑化),再對同一個 theme_name 取成員:

curl -i -X DELETE "$API_BASE/api/admin/theme-store/themes/%E6%89%8B%E5%8B%95%E9%A1%8C%E6%9D%90A" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

curl -i "$API_BASE/api/admin/theme-store/themes/%E6%89%8B%E5%8B%95%E9%A1%8C%E6%9D%90A/members" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
HTTP 404
{
  "error": {
    "code": "Theme not found: 機器人關節",
    "message": "Theme not found: 機器人關節",
    "details": {}
  }
}

4-4 匯入 dry-run(200)

curl -i -X POST "$API_BASE/api/admin/theme-store/import-from-coverage" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"dry_run":true}'
HTTP 200
{
  "imported": [
    "CoWoS"
  ],
  "skipped": [],
  "available": 1,
  "warnings": [
    "listing_type unresolved for 9999 — member skipped from CoWoS"
  ],
  "dry_run": true
}

4-5 匯入含 warning(200)

擷取方式:以與單元測試相同的真實 FastAPI router + SQLite harness 執行,題材頁 fixture 為 themes/CoWoS.md(成員 3037 欣興=股票主檔有、9999=主檔與 durable evidence 皆無, 故被跳過並產生 warning);4-1~4-3 為同一次流程中依序建立→重複建立→刪除後取成員。 warning 文字改為 "listing_type unresolved for <code> — member skipped from <theme>" (帶題材名稱)後,verifier 已用上述 harness 重新擷取本節與 §4-4 的 JSON 回應。

跟 4-4 同一支端點,dry_run:false 正式執行,抓一次 warnings 非空(含 listing_type unresolved for <code> — member skipped from <theme>)的真實回應。若當下 my-tw-coverage 語料沒有任何會觸發 unresolved listing_type 的成員代號, verifier 需改用一個刻意帶入無法解析代號的測試題材頁 fixture,並在填入 response 前於本節下方註記所用 fixture 名稱:

curl -i -X POST "$API_BASE/api/admin/theme-store/import-from-coverage" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"dry_run":false}'
HTTP 200
{
  "imported": [
    "CoWoS"
  ],
  "skipped": [],
  "available": 1,
  "warnings": [
    "listing_type unresolved for 9999 — member skipped from CoWoS"
  ],
  "dry_run": false
}

5. 狀態碼(含 is_blocked 規則與兩條還原路徑)

Endpoint Success Errors
GET /themes 200 —
POST /themes 201 409 同名且未刪除;正常由 FOR UPDATE 序列化,此 409 是 unique constraint race 的 defence-in-depth 安全網,實務上不應觸發(已轉譯為 409,不會外洩成 500);422 驗證失敗(含 theme_name/company_name 超過 200 字、theme_name 不是單一 path segment、ticker 無法判定上市/上櫃)
PATCH /themes/{name} 200 404(不存在或已刪除)
DELETE /themes/{name} 204 404(不存在或已是墓碑)
DELETE /blocked/{name} 204 404 非墓碑;400 墓碑但 source="manual"
GET /themes/{name}/members 200 404(不存在或已刪除)
PUT /themes/{name}/members 200 404(不存在或已刪除);409 僅為 unique-key race 的 defence-in-depth 安全網,實務上不應觸發(不是版本衝突偵測,見下方 last-write-wins);422 驗證失敗(含 company_name 超過 200 字、ticker 無法判定上市/上櫃)
POST /import-from-coverage 200(含 dry_run=true) 409 兩個並發匯入請求競爭建立同一個新題材(unique-key race 的 defence-in-depth 安全網,實務上不應觸發);503 coverage 題材頁目錄不可用,或任何一個題材頁檔案讀不到/不是合法 UTF-8(detail 固定為 "coverage 題材頁目前無法讀取,請確認 my-tw-coverage 資料目錄已掛載",不含 server 端路徑);422 驗證失敗

手動路徑(POST /themes、PUT .../members)listing_type 無法解析時的 422:detail 固定格式為 "無法判定上市/上櫃(不在股票主檔): <codes>", 整個請求不寫入任何資料——這與 POST /import-from-coverage 不同,wrapper 遇到同樣情況是跳過該成員(見 §4-5),不是回錯誤。

匯入的 503 與「沒有合法成員」是兩件事:讀不到(或解不開 UTF-8)的題材頁會 讓整批匯入回 503,不會表現成「有題材、零個成員」的 200——後者是真的沒有 成員的題材頁才有的結果。UI 對 503 應提示「coverage 題材頁暫時不可讀,稍後重 試」,不要顯示成「這批題材都是空的」。

完全相同的重跑是 no-op:對已存在、未編輯的 coverage 題材,若題材頁解析出 的成員與已存的完全相同,後端不寫入、也不更新 updated_at,該題材仍列在 imported。因此使用者重按一次匯入不會讓列表的「最後更新時間」全部跳動,UI 不需要為此特別去抖。

is_blocked=true(墓碑列)可見性規則——這是本組 API 裡對前端行為影響 最大的一條規則,UI 務必照這張表處理,不要各自猜:

對象 行為
GET /themes(清單)、公開 GET /api/themes/.../stocks 視同不存在,一律過濾
GET /themes/{name}/members 視同不存在 → 404
PUT .../members、PATCH /themes/{name} 視同不存在 → 404
POST /themes(建立) 不回 409——視為「用這個名字重建一個全新手動題材」
DELETE /themes/{name} 目標已是墓碑 → 視同不存在 → 404(連點兩次刪除得到一致結果)
DELETE /blocked/{name} 唯一看得到墓碑的端點;source="manual" 的墓碑回 400

成員覆寫是 last-write-wins,後端不偵測 lost update。 PUT .../members 不比對版本: 兩個分頁從同一份成員清單出發時,後送出的那個會等前一個提交完再整批覆蓋它,兩邊都收 200, 先存的人不會被告知自己的修改被蓋掉。UI 必須在進入編輯前(以及存檔成功後)重新載入成員, 不要指望 409 擋住這種情況——那個 409 只涵蓋 unique-key race。若日後要真正擋,需另行導入 樂觀鎖(If-Match / 版本欄位),本期未實作。

兩條還原路徑(UI 需要能分辨使用者想要哪一種,見 §8 頁面設計):

  1. 拿回名字、做全新題材 → 直接呼叫 POST /themes(不需要先查詢墓碑狀態, 後端自動判定並覆蓋)。
  2. 讓 coverage 版本回來 → 先 DELETE /blocked/{theme_name} (僅對 source="coverage" 的已刪除題材顯示這個動作),再呼叫一次 POST /import-from-coverage 才會真正補回成員——單靠 DELETE /blocked 本身題材是「存在但成員為空」的狀態。

6. Consumer 完整 type diff

src/types/themes.ts 目前沒有任何 Theme Store(/api/admin/theme-store/*) 對應型別(既有型別都是給 GET /api/themes、GET /api/theme-recaps/* 等既有 端點用),以下是全新新增,+ 為要加入的內容:

+export type ThemeStoreMemberRole =
+  | 'upstream'
+  | 'midstream'
+  | 'downstream'
+  | 'related';
+
+export interface ThemeStoreMemberIn {
+  ticker: string;
+  company_name: string;
+  role?: ThemeStoreMemberRole;
+}
+
+export interface ThemeStoreMemberOut {
+  ticker: string;
+  company_name: string | null;
+  listing_type: 'tw' | 'two';
+  role: ThemeStoreMemberRole | null;
+  source: 'manual' | 'coverage';
+}
+
+export interface ThemeStoreSummary {
+  theme_name: string;
+  description: string | null;
+  source: 'manual' | 'coverage';
+  is_edited: boolean;
+  member_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ThemeStoreDetail extends ThemeStoreSummary {
+  members: ThemeStoreMemberOut[];
+}
+
+export interface ThemeStoreListResponse {
+  themes: ThemeStoreSummary[];
+}
+
+export interface ThemeStoreMembersResponse {
+  theme_name: string;
+  members: ThemeStoreMemberOut[];
+}
+
+export interface ImportFromCoverageRequest {
+  themes?: string[];
+  dry_run?: boolean;
+}
+
+export type ThemeStoreSkipReason =
+  | '已刪除,不再接入'
+  | '手動維護中'
+  | '找不到題材頁'
+  | '題材頁沒有合法成員'
+  | '題材名稱不是合法的路徑片段';
+
+export interface ImportFromCoverageResponse {
+  imported: string[];
+  skipped: { theme: string; reason: ThemeStoreSkipReason }[];
+  available: number;
+  warnings: string[];
+  dry_run: boolean;
+}

7. Consumer 端遷移 checklist

  • [ ] 在 src/types/themes.ts 加入 §6 diff 內全部新型別(ThemeStoreMemberIn、 ThemeStoreMemberOut、ThemeStoreSummary、ThemeStoreDetail、 ThemeStoreListResponse、ThemeStoreMembersResponse、 ImportFromCoverageRequest、ImportFromCoverageResponse、 ThemeStoreSkipReason)
  • [ ] 為 §2 列出的 8 支 /api/admin/theme-store/* 端點各自新增對應的 service method(含 admin Bearer token 帶入)
  • [ ] 在手動題材 CRUD 頁與匯出/匯入 Drawer 內,把 422 / 409 / 404 / 503 / 400 分別對應到明確的錯誤提示文案(不要全部落到同一個 catch-all)
  • [ ] 五個 skip 理由(已刪除,不再接入 / 手動維護中 / 找不到題材頁 / 題材頁沒有合法成員 / 題材名稱不是合法的路徑片段)在匯入結果 UI 上 要能個別呈現,不要合併成單一「失敗」訊息
  • [ ] 匯入 dry-run 判定一律探測 GET /themes/{name}/members:404 ⇒ 建立、 200 ⇒ 覆蓋成員
  • [ ] 成員編輯前(與存檔成功後)重新載入成員:後端是 last-write-wins,不會用 409 擋住互相覆蓋
  • [ ] 匯入正式執行一律先呼叫 PUT /themes/{name}/members;僅 HTTP 404 時才 fallback 呼叫 POST /themes
  • [ ] 題材正典表上線初期是空的:GET /api/themes 回 [] 時,UI 顯示「尚未 匯入」提示,不要誤判為錯誤(見 §10)
  • [ ] 「允許重新接入」(DELETE /blocked/{name})只對 source="coverage" 的墓碑題材顯示;source="manual" 的墓碑題材不顯示此動作(呼叫該端點 會回 400)
  • [ ] 在題材相關頁面標註 GET /api/stock-lists?searchType=theme 的 Phase 1 缺口:手動題材在該端點查無資料、已墓碑題材在該端點仍會回傳過期成員 (見 §9)

8. 建議前端串接流程

手動題材頁 /strategy/themes/manual:清單走 GET /themes;新增走 POST /themes;編輯描述走 PATCH /themes/{name};編輯成員走 PUT /themes/{name}/members(整批取代,UI 需送出「完整」成員清單,不是 diff);刪除走 DELETE /themes/{name};「允許重新接入」走 DELETE /blocked/{name},且只對 source="coverage" 的已刪除題材顯示。

/strategy/themes 匯出:GET /themes 取得題材清單(下拉選單來源)→ 針對要匯出的題材逐一呼叫 GET /themes/{name}/members 組出匯出 JSON。清單 與匯出走同一張表、同一組端點,不會再有兩邊數字對不上的問題。

/strategy/themes 匯入 dry-run 判定:對每個檔案內的題材,探測 GET /themes/{name}/members——404 ⇒ 判定為「建立」、200 ⇒ 判定為 「覆蓋成員」(並可用回傳的 members.length 算出「目前 N → 匯入後 M」)。

匯入正式執行:逐題材序列執行,一律先呼叫 PUT /themes/{name}/members; 僅 HTTP 404 時才 fallback 呼叫 POST /themes 建立;其他任何錯誤該題材 視為失敗、不 fallback、不中斷其餘題材的匯入。判定(dry-run)與執行用同一支 探測端點(GET .../members),避免兩段式邏輯各自解讀不一致。

POST /import-from-coverage 觸發:一律先以 dry_run=true 呼叫一次, 把回傳的 imported/skipped/warnings 攤給使用者確認,使用者按下確認鈕 才用 dry_run=false 正式送出。兩次呼叫執行完全相同的判斷邏輯(含 listing_type 解析),唯一差異是 dry_run=true 完全不發出任何 INSERT/UPDATE/DELETE、也不取 row lock;dry_run=false 是單一 transaction,所有題材處理完才一次性 commit, 被處理題材的 FOR UPDATE 鎖列持有到 commit 為止。

9. Phase 1 限制

  • 題材 recap/籌碼/資金流/supply-chain 頁面仍讀舊表 taiwan.coverage_theme_membership:透過本組 API 建立或編輯的題材,在這些 頁面還看不到,UI 需明確提示這個落差(Phase 2 才遷移這些讀取點)。
  • GET /api/stock-lists?searchType=theme(StockList 元件消費的端點) 同樣仍讀舊表:一個只存在於題材正典表的手動題材,在這支端點會查無資料; 一個已被 DELETE /themes/{name} 墓碑化的題材,在這支端點仍會繼續 回傳它被刪除前的成員,直到 Phase 2 遷移為止。
  • 正典表不會自動清理:coverage 端的題材頁若被刪除,正典表內對應的題材 不會被連動移除或清空,會保留最後一次接入的成員原封不動;要移除只能明確 呼叫 DELETE /themes/{name}。
  • 不對稱行為:GET /api/themes(清單)僅列出至少有 1 個 active 成員的 題材;GET /api/themes/{id}/stocks 對一個成員數為 0 的題材則回傳 found: true, member_count: 0(例如 DELETE /blocked/{name} 後、尚未 重新 POST /import-from-coverage 之前)。兩支端點對「空題材」的可見性 因此不一致,前端不能假設兩者同步。
  • 不支援題材改名:theme_name 是自然鍵,目前沒有 rename 端點。
  • POST /import-from-coverage 只能手動觸發,沒有排程;每次都要人工按。

10. 上線第一步

題材正典表上線當下是空的(migration 只建表,不搬資料)。第一件事是呼叫 POST /import-from-coverage(建議先 dry_run=true 預覽,確認 imported 清單符合預期後再 dry_run=false 正式送出)把 coverage 端要用的題材接進來; 在此之前 GET /api/themes 會是空清單,手動題材頁與匯出/匯入 Drawer 的題材 下拉也都會是空的。

11. 參考

  • 完整 API 規格與範例:docs/api-reference.md「Theme Store(題材正典 CRUD)」一節
  • DB schema:docs/DATABASE_SCHEMA.md「Theme store(題材正典)」一節
  • Migration:investment-agent/backend/database/migrations/151_theme_store.sql