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 頁面設計):
- 拿回名字、做全新題材 → 直接呼叫
POST /themes(不需要先查詢墓碑狀態, 後端自動判定並覆蓋)。 - 讓 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