FR-062.7 T-7.2 既有租戶發照 Migration — 執行 SOP

對應 Notion:T-7.2=CM-1136|子需求 FR-062.7=CM-1134|母案 CM-1112 撰寫日期:2026-08-09(DEV 演練,本文件同時作為未來 STG/POC 上版時的操作依據)


🔴 執行前必讀:環境範圍

本文件目前僅適用 DEV。STG/POC 需決策者「當次」明確放行後才可執行——這是 D14/ CM-1136 卡片的明文約束(見 design.md §5 T-7.2 一列、common/authz/license.py 檔頭注解)。 腳本本身也內建護欄:DB_NAME 不在允許清單(目前只有 guidant_ai_dev)會直接拒絕執行, 不提供 --db-name 逃生門(與 FR-059 seed script 的顯式確認模式不同,這是刻意設計—— T-7.2 這一支涉及對外發照,比對照庫 seed 風險更高,STG/POC 版本需要另外規劃 customer_code 命名與私鑰座標,不應該用同一份腳本無腦套過去)。

執行中任何一步結果與本文件描述不符:停下回報,不要自行補救或跳步

0. 這支腳本做什麼

為 DEV 環境「已存在但尚未持有現行照」的租戶(root tenant 除外)各發一張內部長效照

欄位 理由
plan enterprise(全模組) 內部測試用途,不限制功能
type formal D14 最終修正:直接發正式規格照,無過渡照、無豁免分支
deployment_mode saas DEV/STG 走 SaaS 驗證路徑,不綁機器指紋
效期 365 天 卡片要求「約一年」
expiry_policy notify/grace/readonly/lockout 全部關閉 design.md §4.5「全部關閉=形同永久照,內部長效照即此組態」;DEV/STG 是內部環境,不需要真的走到期管線

冪等:跑之前先查每個租戶是否已有 is_current=true 的現行照,有就跳過([skip])。 重複執行不會重複發照。

root tenant 排除:root(tenants.id=1)永遠不持照,這是設計如此(見 common/authz/license.py 檔頭「易踩雷點一」),腳本會自動跳過並印 [skip]

1. 前置確認

cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
grep DB_NAME .env   # 必須是 guidant_ai_dev,不是就先停下

確認 ~/Projects/Billows/Audit-Manager/license_center/.env 存在且含 LICENSE_CENTER_KEY_PASSPHRASE(腳本靠此自動解鎖私鑰,不會互動詢問)。

先盤點現況(不要假設哪些租戶已有照——T-7.1/T-7.3 驗收期間插過測試短效照,狀態會變):

export PGPASSWORD='<查 .env DB_SECRET>'
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -c "SELECT id, tenant_id, license_id, license_type, status, is_current, expires_at, plan
      FROM config.tenant_licenses ORDER BY tenant_id, id;"
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -c "SELECT id, name, parent_id FROM tenants ORDER BY id;"

2. 執行

cd ~/Projects/Billows/Audit-Manager/compliance-manager-be

# 先 dry-run(只印會對哪些租戶發照,不寫入)
poetry run python scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py

# 確認 dry-run 清單符合預期後才 --apply
poetry run python scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py --apply

輸出開頭會印 DB : guidant_ai_dev @ 192.168.50.188——核對這一行是目標環境。 逐租戶結果標記:[skip](root 或已有現行照)/[ok] <租戶名> → license_id=...[fail] <租戶名> <原因>。結尾印 總計:created=N skipped=N failed=N

有任何 [fail]:停下回報,不要重跑(重跑前先確認失敗原因,可能是私鑰 passphrase、DB 連線、或 license_centerplans 表缺 enterprise seed)。

3. 部署後驗收 checklist

以 root 帳號(DEV:admin / 密碼查 .env 或密碼管理器)登入平台管理頁,逐項確認:

任何一項不符:停下回報。

4. 驗證 SQL(供人工複查)

SELECT tl.tenant_id, t.name, tl.license_id, tl.license_type, tl.status, tl.plan,
       tl.deployment_mode, tl.expires_at, jsonb_object_keys(tl.modules) AS module_count
FROM config.tenant_licenses tl
JOIN tenants t ON t.id = tl.tenant_id
WHERE tl.is_current = true
ORDER BY tl.tenant_id;

預期:除 root(id=1)外,每個租戶恰有一列 is_current=truestatus=validplan=enterprisedeployment_mode=saasexpires_at 約落在執行日起算一年後。

5. 失敗重跑方式

腳本本身冪等,直接重跑 --apply 即可,不需要先清資料:

  • 已成功入庫的租戶會因 has_license=true 被跳過,不會重複發照或觸發 D6 換發。
  • 失敗的租戶([fail])下次重跑會重新嘗試(因為還沒有現行照)。
  • 若某租戶簽發成功但入庫失敗(license_centerlicense_issuance 已留一筆紀錄, 但 tenant_licenses 沒有),重跑會為它再簽一張新的license_center 端允許 同租戶多筆歷史簽發紀錄,不影響——tenant_licenses 只認最後一次成功入庫的為現行)。 這不是問題,license_issuance 本就是稽核留存表,不要求一對一。

若要清掉某次測試發的照重來(僅限 DEV 除錯用,正式 migration 不應該需要):

-- 僅 DEV,僅除錯用:作廢某租戶當前照,讓下次腳本重新視為「無照」重發
UPDATE config.tenant_licenses SET is_current = false WHERE tenant_id = <id> AND is_current = true;

6. 已知限制 / 未涵蓋範圍(刻意,非遺漏)

  • 本 SOP 不含 STG/POC 執行步驟——那是 FR-062 上版流程的下一段,需要決策者當次放行 後另開派工,並且需要另外規劃該環境的 customer_codeorder_no 命名慣例與 license_center 私鑰座標(不能沿用 DEV 的 INTERNAL-<uid前綴> 佔位命名)。
  • 本腳本不處理「已有現行照但即將到期」的續發——那是換照/到期管線(T-3.1/T-7.1) 的職責,本腳本只補「完全沒有現行照」的空缺。
  • 子租戶不單獨發照——照綁頂層客戶租戶,效力涵蓋子孫租戶(design.md D8)。若某 頂層租戶下有子租戶但頂層已有照,子租戶不會被腳本誤判為「需要發照」(腳本邏輯是 逐租戶查 has_license,子租戶若自己沒有 tenant_licenses 現行照列會被視為需要, 這與 D8「效力涵蓋子孫」的執法讀取邏輯是兩件事——執法讀的是該租戶自己的現行照, 不會往上層查找;因此嚴格說每個租戶各自需要一張現行照,這正是本腳本「逐租戶掃描」 而非只掃頂層的原因,行為已在 DEV 驗證:131/152/153 三個不同層級的租戶皆各自拿到照)。