對應 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 命名與私鑰座標,不應該用同一份腳本無腦套過去)。
執行中任何一步結果與本文件描述不符:停下回報,不要自行補救或跳步。
為 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]。
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;"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_center 端 plans 表缺 enterprise seed)。
以 root 帳號(DEV:admin / 密碼查 .env 或密碼管理器)登入平台管理頁,逐項確認:
任何一項不符:停下回報。
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=true、status=valid、 plan=enterprise、deployment_mode=saas、expires_at 約落在執行日起算一年後。
腳本本身冪等,直接重跑 --apply 即可,不需要先清資料:
has_license=true 被跳過,不會重複發照或觸發 D6 換發。[fail])下次重跑會重新嘗試(因為還沒有現行照)。license_center 端 license_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;customer_code/order_no 命名慣例與 license_center 私鑰座標(不能沿用 DEV 的 INTERNAL-<uid前綴> 佔位命名)。has_license,子租戶若自己沒有 tenant_licenses 現行照列會被視為需要, 這與 D8「效力涵蓋子孫」的執法讀取邏輯是兩件事——執法讀的是該租戶自己的現行照, 不會往上層查找;因此嚴格說每個租戶各自需要一張現行照,這正是本腳本「逐租戶掃描」 而非只掃頂層的原因,行為已在 DEV 驗證:131/152/153 三個不同層級的租戶皆各自拿到照)。