跳轉到

License 授權管理(License Manage)

功能群:系統管理|三層 RBAC 權限模型與角色權限基調先讀 功能群總覽

事實基準:2026-08-10 從 FE / BE code 掃出(config.tenant_licenses / config.tenant_license_events 兩表為 FR-062 新建,主專案自有完整 DDD 四層;schema 直接查 DEV DB 實表,db_schema.json dump 早於本功能尚未涵蓋)

變更紀錄

日期 FR 內容
2026-08-10 FR-062 初版:FR-062 License 控管機制上線(含 62.8/62.9/62.10 三輪追加)

1. 功能描述

License 授權管理頁是平台方(產品供應商)對客戶租戶發照與控管的後台:一列一個頂層客戶租戶,顯示該租戶現行授權照的方案、狀態、照型態、部署模式與到期日,並提供四組操作——向 License Center 請照(新簽/展延)、手動指派/換發授權照、檢視授權歷程時間線、立即停權/解除停權。

授權照(license)本身不是產品內產生的:它由獨立系統 License Center(LC)以私鑰簽發,LC 有自己的 repo 與自己的 DB,與本產品不共用任何資料庫、不互相直寫,兩者唯一的交互是「簽好的照」與一組帶 API token 的內部簽發 API。產品端 BE 是唯一信任邊界:不論照從 LC 直接回來、還是管理員手上傳,一律走同一套完整驗章+落地漏斗,不因來源可信就跳過驗證。

照的落地採 D6 replace 制:換發不覆寫也不刪除舊列,而是「舊現行照 is_current 轉 FALSE、新照插一列為 TRUE」,歷史全留。照綁頂層客戶租戶、效力涵蓋整棵子樹,故本頁清單刻意只列頂層租戶(parent_id 為 root 者),子孫租戶不出現在可指派清單。

主要使用者:平台管理員(root tenant)。本頁全部端點皆掛 @require_platform_admin_route,非 root 租戶呼叫一律 403;選單可見性另由 license.readis_platform=true)能力點以 ALL 規則控管(見 §3)。

角色速覽

角色 一句話
平台管理員(root tenant) 本頁唯一操作者——發照、換照、停權、查歷程;root 自己永不持照、也不受 license 執法管控
一般租戶使用者 完全看不到本頁(選單被 license.read ALL 規則濾掉、端點層 403);他們看的是我的授權狀態

1.1 功能總覽(本頁全部功能)

# 功能 說明 位置 詳述
1 頂層租戶授權總覽 一列一頂層客戶租戶(parent_id = root),六欄:租戶名稱/方案/狀態/照型態/部署模式/到期日;無搜尋、無分頁、無排序後端(僅租戶名稱欄可前端排序) 主畫面 DataTable §6.2
2 狀態 Tag 與停權標記並列 狀態 Tag(正常/即將到期/寬限期/唯讀/已鎖定/尚未授權)旁,被人為停權者另掛紅色「已停權」Tag,hover 顯示停權時間與原因 狀態欄 §12 坑 1
3 向 LC 請照(新簽) Dialog 選方案(來自 LC 的 plans 清單)+照型態(正式版/試用版)+天數(選填)+客戶代碼/訂單編號/授權對象(皆必填);送出後 BE 代打 LC 簽發、回照驗章落地 逐列「向 LC 請照」→ Dialog(新簽分頁) UC-LM-01
4 向 LC 請照(展延) 同一 Dialog 的「展延」分頁,只填天數;取現行照 license_id 向 LC 換一張展延後的新照;無現行照時該分頁不可選(hover 提示原因) 同上(展延分頁) UC-LM-01
5 指派/換發授權照 Dialog 上傳 .license(armor 文字檔)或舊版 .json(裸信封);是 LC 不可達時的手動 fallback 路徑 逐列「指派/換發」→ Dialog UC-LM-02
6 授權歷程時間線 唯讀 Dialog,該租戶全部 license 生命週期事件(新到舊):時間/事件型態/授權編號/操作者/說明 逐列「授權歷程」→ Dialog §6.4
7 立即停權 Dialog 可填選填的停權原因;在現行照蓋 suspended_at不換照、不改 status,即時生效為該租戶全域唯讀 逐列「立即停權」→ Dialog(僅有照且未停權時顯示) UC-LM-03
8 解除停權 二次確認後清空 suspended_at / suspend_reason不手動撥 status,由下一輪排程依 expires_at 自動回歸正確階段 逐列「解除停權」(僅已停權時顯示,與停權互斥) UC-LM-03

UC(§2)展開 3+4(向 LC 請照)、5(指派/換發,含冪等與跨租戶重複兩道檢查)、7+8(停權成對操作)三條核心操作路徑;總覽與授權歷程屬唯讀查詢,已於總覽表與 §6 描述完整。

2. Use Case

角色速覽

角色 一句話
平台管理員(root tenant) 本頁唯一操作者——發照、換照、停權、查歷程;root 自己永不持照、也不受 license 執法管控
一般租戶使用者 完全看不到本頁(選單被 license.read ALL 規則濾掉、端點層 403);他們看的是我的授權狀態

流程圖視覺慣例:菱形 = 判斷、橘底 = 例外/擋下、紅框 = 錯誤(標 error code)、綠框 = 成功終點、虛線 = 可選路徑。

UC-LM-01 向 License Center 請照(新簽/展延)

項目 內容
角色 平台管理員(root tenant)
前置條件 已登入;LICENSE_ACTIVATION_SERVER_URLLICENSE_CENTER_API_TOKEN 已設定且 LC 可達;展延另需該租戶已有現行照
產出 / 後置條件 config.tenant_licenses 新增一列現行照(舊列轉歷史);config.tenant_license_events 記一筆 lc_issue / lc_extend(+被取代的舊照一筆 replace);即改即生效,客戶零動作

UC-LM-01 流程:選方案/天數 → 平台管理員守門 → BE 帶 API token 打 LC 內部簽發 API → 回照仍走完整驗章+跨租戶重複檢查 → D6 replace 落地並記事件

UC-LM-02 指派/換發授權照(上傳 license 檔)

項目 內容
角色 平台管理員(root tenant)
前置條件 已登入;手上有 LC 簽發的 .license 或舊版 .json 照檔;目標租戶 uid 存在
產出 / 後置條件 驗章通過 → D6 replace 落地為該租戶現行照,事件記 assign;重傳同一張現行照 → 冪等跳過(靜默成功);照已被別的租戶落地過 → 409 拒絕並記 rejected_duplicate

UC-LM-02 流程:FE 判讀 armor/裸 JSON → 平台管理員與租戶存在性守門 → 驗章(失敗留證)→ 同租戶冪等檢查 → 跨租戶重複拒絕 → D6 replace 落地

UC-LM-03 立即停權/解除停權

項目 內容
角色 平台管理員(root tenant);SaaS 版獨有動作(Host 版照在客戶手上,只能等到期)
前置條件 已登入;該租戶有現行照(無照時 404 LICENSE_404001
產出 / 後置條件 停權:現行照 suspended_at = now()、suspend_reason 寫入,事件記 suspend,該租戶即時全域唯讀;解除:兩欄清空,事件記 unsuspend,狀態由下一輪排程重算

UC-LM-03 流程:依 suspended_at 有無切換成對按鈕 → 平台管理員與現行照守門 → 寫/清 suspended_at 獨立欄位(不動 status)→ 執法端取聯集即時唯讀;客戶端最多延遲一個 TTL 才感知

3. 權限矩陣

操作 FE 判定 BE 強制 BE 檢查位置
選單 / 頁面可見性 RBAC 選單(ui_routes.name='license-manage',掛於 group-license 群組,sort=15 license.read 能力點(is_platform=truerequirement=ALL)——非 root 角色不持有此能力點,選單查詢的 ALL 規則直接把節點排除 scripts/sql/2026-08-10-fr062-8-license-menu-restructure.sql;選單過濾見 common/authz/menu_license_filter.py
讀總覽 / 讀單筆 / 讀歷程 無額外前端角色判斷 @jwt_required() + @require_platform_admin_route api/license/routes/license_admin_route.pyLicenseTenantsRoute / LicenseTenantDetailRoute / LicenseTenantEventsRoute
指派/換發(assign) 無額外前端角色判斷;僅檔案格式前端預檢 同上 api/license/routes/license_admin_route.py::LicenseTenantAssignRoute
立即停權 / 解除停權 依該列 suspended_at 有無決定顯示哪一顆(互斥成對);has_license 為 false 時兩顆皆不顯示 同上;service 層另檢查目標租戶存在與有現行照 同檔 LicenseTenantLockRoute / LicenseTenantUnlockRouteTenantLicenseAdminService.force_lock_tenant / unlock_tenant
向 LC 請照 / 展延 / 查方案 展延分頁在 has_license 為 false 時 disabled(含 tooltip 說明);必填欄位未填時儲存鈕 disabled 同上 同檔 LicenseTenantLcIssueRoute / LicenseTenantLcExtendRoute / LicenseLcPlansRoute
唯讀 gate 對本頁的影響 不適用 本頁全部端點豁免唯讀 gate/license/ 前綴整段列入 _EXEMPT_PREFIXES)——攔了就是永久死鎖(唯讀→不能上傳新照→永遠唯讀) common/middleware/license_readonly_mw.py:55-73

本頁是本功能群少數FE 判定與 BE 強制無落差的頁面:所有寫入端點都在 route 層掛 @require_platform_admin_route,FE 那些條件顯示(停權/解除停權互斥、展延分頁 disabled)純粹是體驗優化,繞過 FE 直打 API 一樣被 BE 擋。

4. 狀態機與前置條件

本頁操作的對象是授權照,其狀態機定義於 FR-062 設計(docs/features/FR-062-2608-license-management/design.md §4.5),由每日排程(02:00 UTC,core/scheduler.py)依 expires_at 與照內 expiry_policy 推進:

狀態 語意 進入條件(出廠預設)
valid 正常服務 尚未進入通知窗
notice 全功能+到期提醒(橫幅/email 依照內旗標) 距到期 ≤ notify.days_before(預設 30 天)
grace 全功能+醒目警告 到期日已過且 grace 啟用(預設 14 天)
readonly 可登入、可看、可下載匯出,擋全部寫入 寬限用盡;出廠預設為終態
locked 鎖操作(lockout 段,出廠預設停用故不會發生) readonly.days 用盡且 lockout 啟用

停權(suspended_at)是與上述狀態機正交的第二軸——它由平台管理員人為觸發、由本頁操作,排程完全不碰該欄位,而執法端對兩軸取聯集判定唯讀。

本頁各操作的前置條件:

條件 效果 出處
呼叫者非 root tenant 全部端點 403(GRC_NOT_PLATFORM_ADMIN common/authz/decorators.py:29-41require_platform_admin_route
目標租戶 uid 不存在 404 LICENSE_404002 app/license/service/tenant_license_admin_service.py:284-288_get_tenant_or_raise
停權/解除停權/展延時該租戶無現行照 404 LICENSE_404001 同檔 :120-122:143-145:198-200
上傳/回照驗章失敗 400 LICENSE_400002;時鐘回撥另走 LICENSE_400006;內容非合法信封 LICENSE_400001。失敗一律 log 留證(事件時間+內容 SHA-256 指紋+失敗原因) app/license/service/license_verification_service.py:55-91
上傳的照與該租戶現行照同一 license_id 冪等跳過落地,直接回現行照(靜默成功,不報錯也不插重複列) 同檔 :104-111
上傳的照 license_id 已被其他租戶落地過(含歷史列) 409 LICENSE_409002,並以獨立 sessionrejected_duplicate 事件(外層交易會 rollback,同 session 寫的稽核紀錄會消失) 同檔 :123-142
照內 expires_at 已在通知窗內或已過期 落地當下即由狀態機算出 notice/grace/readonly/locked不寫死 valid、不必等隔日排程 同檔 :165-167;狀態機 domain/license/service/license_expiry_state_machine.py
照已被人為停權 排程 tick 對該列整列跳過到期推算(人為決策不被時間邏輯洗掉) domain/license/service/tenant_license_domain_service.py:94-100

5. UI 設計

📸 實際畫面截圖待補(一律亮色模式拍攝),預計補以下四張到 assets/img/: ① 整頁(總覽 DataTable,含狀態 Tag 與「已停權」標記並列)license-manage-overview.png ② 向 LC 請照 Dialog(新簽分頁,六欄位)license-manage-lc-issue.png ③ 授權歷程 Dialog(時間線 DataTable)license-manage-timeline.png ④ 立即停權 Dialog(警示訊息+停權原因輸入)license-manage-lock.png

版面骨架(單欄:Header + 一張 DataTable + 四個逐列動作各自開 Dialog;無左側樹、無 Tab、無搜尋列、無分頁):

License 授權管理頁版面:Header、總覽 DataTable(可跳 §6)、向 LC 請照 Dialog(可跳 UC-LM-01)、指派換發 Dialog(可跳 UC-LM-02)、授權歷程 Dialog(可跳 §6.4)、停權解除停權(可跳 UC-LM-03)

狀態呈現

  • DataTable loading 遮罩(loading);各 Dialog 各自獨立的送出 loading(submitting / locking / lcSubmitting),互不阻塞。
  • 狀態欄兩個 Tag 並列而非互相取代:左邊是到期管線的 status Tag(valid 綠/notice+grace 黃/readonly+locked 紅/無照灰),右邊是紅色「已停權」Tag(僅 suspended_at 有值時)。並列的理由是——停權期間到期日照樣往前走,管理員需要同時看到兩者才判斷得出「解除停權後會落在哪一段」。
  • 停權/解除停權互斥成對:依 suspended_at 有無切換顯示哪一顆,不同時擺兩顆;has_license 為 false 的租戶兩顆皆不顯示。
  • 停權標記 tooltip 顯示停權時間+原因(原因選填,未填則只顯示時間)。
  • LC 方案清單(lcPlans)只在第一次開請照 Dialog 時取一次,之後重開沿用同一份(方案清單透傳不落地、不隨租戶變動)。
  • 無 socket / 無輪詢;任何操作成功後皆重打 fetchList()

6. API 規格

Envelope:成功 {"status": true, "data": …}common/util/response_util.py::return_response);失敗經 jedi_common.handler.handler.register_error_handlers 轉為 {"error_code": "…", "msg": "…"} + HTTP 4xx/5xx。

6.1 總清單(本頁涉及的全部 endpoint)

分類 Method + Path 說明 完整規格
總覽 GET /license/tenants 頂層客戶租戶授權狀態總覽(本頁主要資料來源) §6.2
詳情 GET /license/tenants/{uid} 單一租戶現行照詳情(FE 目前未呼叫LicenseService.getTenantDetail 定義了但無呼叫端;供除錯/未來詳情頁用) §6.2
歷程 GET /license/tenants/{uid}/events license 生命週期事件時間線(新到舊) §6.4
寫入 POST /license/tenants/{uid}/assign 指派/換發(上傳 license 檔) §6.3
寫入 POST /license/tenants/{uid}/lock 立即停權(寫 suspended_at §6.3
寫入 POST /license/tenants/{uid}/unlock 解除停權(清 suspended_at §6.3
LC 代理 GET /license/lc/plans 向 LC 查方案清單(透傳不落地) §6.5
LC 代理 POST /license/tenants/{uid}/lc-issue 向 LC 請一張新照 §6.5
LC 代理 POST /license/tenants/{uid}/lc-extend 向 LC 展延現行照 §6.5

另有 POST /license/upload(root 寫入自己租戶的照)——FE 已刻意不再暴露此入口(root 永不發照給自己,該按鈕語意重複且易誤用),端點本體保留供其他呼叫端/未來自動化使用,本頁不列為功能。

以下 §6.2~6.5 為核心 endpoint 完整規格(欄位逐條對照 api/license/serializers/license.py 與 service 組裝碼,出處標在各段末)。

6.2 總覽 / 詳情類

[GET] /license/tenants

無 query 參數。Response data[](一項一頂層客戶租戶,TenantLicenseSummaryResponse):

欄位 型別 說明
tenant_uid / tenant_name string / string 租戶識別碼與名稱(由 TenantService.get_tenants() enrich)
has_license boolean 該租戶是否有現行照(決定四顆按鈕的顯示組合)
license_uid string|null 現行照的 uid
plan string|null 方案代碼(照內 plan,原樣顯示不做 i18n 對照)
status string|null 到期管線階段(valid/notice/grace/readonly/locked);無照為 null,FE 顯示「尚未授權」
expires_at datetime|null 到期日
license_type string|null 照型態(formal/trial/extension
deployment_mode string|null 部署模式(saas/host
suspended_at datetime|null 人為停權時間戳(NULL=未停權)——與 status 正交,總覽必須另外標
suspend_reason string|null 停權原因(選填)

清單範圍限制:只回 parent_id == SYSTEM_ROOT_TENANT_ID(=1)的頂層客戶租戶,子孫租戶與 root 自己都不在清單內。理由是照綁頂層、效力涵蓋全樹,對子租戶「指派」一張照沒有語意;把它們留在可指派清單等於留著逐租戶發照的入口。出處:app/license/service/tenant_license_admin_service.py:39-73;serializer api/license/serializers/license.py:79-93

[GET] /license/tenants/{uid}

Response data:該租戶現行照完整內容(TenantLicenseResponse,欄位表見 我的授權狀態 §6);無現行照時回 data: null(非 404)。租戶 uid 不存在才 404 LICENSE_404002。出處:app/license/service/tenant_license_admin_service.py:75-80

⚠️ TenantLicenseResponse 絕不回傳 license_raw(照原文含簽章,非顯示用途)——需要重驗證一律走 BE 內部驗章流程,不經 API 回吐原文。

6.3 寫入類

[POST] /license/tenants/{uid}/assign

Request body:

{
  "license": "-----BEGIN GUIDANT LICENSE-----\n…(armor 文字)…\n-----END GUIDANT LICENSE-----"
}

或舊版裸信封物件:

{
  "license": {"format_version": 2, "kid": "…", "payload": "…", "signature": "…"}
}
欄位 型別 必填 說明
license string|object armor 文字(.license 檔內容原樣)或裸 JSON 信封物件;BE 偵測 BEGIN 行即 dearmor 還原後走同一條驗章路徑

Response data:落地後的現行照(TenantLicenseResponse)。錯誤:403(非 root)/404 LICENSE_404002(租戶不存在)/400 LICENSE_400001LICENSE_400002LICENSE_400006(格式/驗章/時鐘)/409 LICENSE_409002(跨租戶重複)。出處:api/license/routes/license_admin_route.py:67-84app/license/service/tenant_license_admin_service.py:93-104LicenseVerificationService.upload_license(event_type="assign")

[POST] /license/tenants/{uid}/lock

欄位 型別 必填 說明
reason string|null 停權原因;會顯示在該租戶自己的授權狀態頁與到期橫幅上(客戶唯一看得到的說明)

行為:在現行照寫 suspended_at = now()suspend_reason不換照、不改 status。Response data:更新後的照。錯誤:403/404 LICENSE_404002(租戶)/404 LICENSE_404001(無現行照)。出處:api/license/routes/license_admin_route.py:87-106app/license/service/tenant_license_admin_service.py:106-131

[POST] /license/tenants/{uid}/unlock

無 body。行為:清空 suspended_at / suspend_reason不手動撥 status——該照下一輪排程 tick 依 expires_at 自動回歸正確階段(停權期間到期日照樣往前走,若期間已自然到期,解除後正確結果就是 readonly 而非停權前的階段)。Response 與錯誤同 lock。出處:同檔 :109-125:133-152

6.4 授權歷程

[GET] /license/tenants/{uid}/events

無 query。Response data[](一項一事件,新到舊,TenantLicenseEventResponse):

欄位 型別 說明
id integer 事件序號(PK)
tenant_id integer 租戶內部 id
license_id string|null 照內編號(非本表 PK、非 tenant_licenses.uid——時間線以照內編號串接)
event_type string 事件型態(下表 11 種)
actor string|null 操作者 login_name;排程觸發者為 system
detail object|null jsonb 原樣回吐,內容依 event_type 而異——FE 依型態挑欄位組句,不把整包 JSON 攤在畫面上
created_at datetime 事件時間

事件型態(DB CHECK 約束 ck_tenant_license_events_type 定義的 11 種,逐一實查 DEV DB):

event_type 顯示文字 何時產生
assign 後台指派 本頁「指派/換發」上傳成功
upload 自行上傳 直接呼叫 POST /license/upload
online_activate 線上開通 Host 版輸入開通序號成功
offline_activate 離線開通 Host 版上傳綁機器的照成功
replace 被新照取代 任一落地路徑取代了舊現行照(記在舊照的 license_id 上,detail.replaced_by_license_id 指向接替者)
status_transition 狀態轉換 每日排程推進到期階段(detailfrom_status / to_status
rejected_duplicate 跨租戶重複被拒 別的租戶的照被拿來落地(走獨立 session 寫入,見 §12 坑 3)
suspend 本頁「立即停權」
unsuspend 本頁「解除停權」
lc_issue 向 LC 請照 本頁「向 LC 請照(新簽)」
lc_extend 向 LC 展延 本頁「向 LC 請照(展延)」

suspend / unsuspend 兩型態FE i18n 尚無對應譯文license-manage.json 只有 event_assign ~ event_lc_extend 九項),FE 的 eventLabel() fallback 會直接顯示英文 key。見 §12 坑 6。

出處:api/license/serializers/license.py:96-105infra/license/license_event_repo.py、DB CHECK 約束實查 DEV config.tenant_license_events

6.5 LC 代理類(FR-062.10)

三支端點都是產品 BE 帶 X-API-Token header 打 License Center 的內部簽發 API(base URL 走 LICENSE_ACTIVATION_SERVER_URL,token 走 LICENSE_CENTER_API_TOKEN,兩者皆環境變數、值不入版控),逾時 10 秒。

[GET] /license/lc/plans

無參數。Response data[]:LC 回的 active plans 原樣透傳、不落地;FE Dropdown 綁 plan_name(顯示)/plan_code(值)。出處:app/license/service/tenant_license_admin_service.py:214-217

[POST] /license/tenants/{uid}/lc-issue

{
  "plan_code": "professional",
  "license_type": "formal",
  "days": 365,
  "customer_code": "HY-001",
  "order_no": "SO-2026-0001",
  "issued_to": "宏遠科技股份有限公司"
}
欄位 型別 必填 說明
plan_code string 是(FE 驗證) 方案代碼,取自 GET /license/lc/plans
customer_code / order_no / issued_to string 是(FE 驗證) 客戶代碼/訂單編號/授權對象,寫入照內作為帳本依據
days integer 有效天數;留空由 LC 用方案預設
license_type string formal(預設)/trial;FE 下拉只提供這兩項
deployment_mode string 未帶時 BE 自動補本站台的 DEPLOYMENT_MODE——產品自己最知道自己是哪一版,不讓 LC 用預設值(saas)猜(POC 首發即因 dialog 無此欄位、BE 也沒補而簽出記載錯誤的照);FE 目前不提供此欄位

必填檢查目前只在 FE(儲存鈕 disabled),BE 未做欄位級驗證——缺欄位會由 LC 回 400,轉成 LICENSE_400010。Response data:驗章落地後的新現行照。

[POST] /license/tenants/{uid}/lc-extend

欄位 型別 必填 說明
days integer 展延天數(FE 限制 ≥ 1);BE 取現行照 license_id 一併送 LC

錯誤碼對照(三支共用 _call_lc / _call_lc_get 的狀態碼映射):

情境 error_code 訊息
連線失敗(httpx.HTTPError LICENSE_400008 無法連線至 License Center,請稍後再試
LC 回 401(token 無效/停用)或 503(LC 私鑰未解鎖) LICENSE_400009 License Center 服務異常,請聯繫系統管理員
LC 回 400(缺必填/plan_code 不存在/days < 1)或其他非預期回應 LICENSE_400010 請照失敗,請確認方案代碼與必填欄位是否正確
LC 回 404(展延時找不到該 license_id) LICENSE_404004 License Center 找不到要展延的授權照

回照一律走完整驗章_verify_signed_license + _store_verified_license),不因來源是 LC 就跳過。出處:app/license/service/tenant_license_admin_service.py:154-282common/code/license_error_code.py:50-60

7. 前端檔案地圖(compliance-manager-fe/)

檔案 角色
src/views/license/LicenseManage.vue 唯一檢視檔(672 行;總覽 DataTable + 四個 Dialog 全在此,無子元件)
src/config/router/index.js:620-631 route license-manage,path /license/manage(與 license-statuslicense-activation 同掛 /license 區塊)
src/config/api/api.js:404-415 LICENSE_TENANTS / LICENSE_TENANT_DETAIL / _ASSIGN / _LOCK / _UNLOCK / _EVENTS / _LC_ISSUE / _LC_EXTEND / LICENSE_LC_PLANS 常數
src/service/LicenseService.js 本頁專屬 service(繼承 BaseService),13 支方法涵蓋本頁全部端點+開通頁用的三支
src/config/locales/i18n/{zh-tw,en}/license-manage.json 頁面 i18n,namespace lang.license_manage.*(同檔另含 lang.license_status.* / lang.license_activation.* / lang.license_module_names.*
src/config/locales/i18n/{zh-tw,en}/error-code.json 19 個 LICENSE_* 錯誤碼譯文
src/config/locales/i18n/{zh-tw,en}/menu.json 選單顯示名稱「License 授權管理」與群組名「授權管理」(group-license

8. 後端檔案地圖(本頁核心鏈路)

鏈路 Route App Service 底層
總覽 api/license/routes/license_admin_route.py::LicenseTenantsRoute app/license/service/tenant_license_admin_service.py::list_tenant_license_status jedi-auth TenantService.get_tenants()TenantLicenseDomainService.get_all_current()TenantLicenseRepoImpl(繼承 BaseRepositoryImpl
詳情 LicenseTenantDetailRoute get_tenant_license_detail TenantLicenseDomainService.get_current_by_tenant
歷程 LicenseTenantEventsRoute get_tenant_license_events infra/license/license_event_repo.py::LicenseEventRepo.list_by_tenant(raw SQL)
指派/換發 LicenseTenantAssignRoute assign_license委派 LicenseVerificationService.upload_license(event_type="assign") common/license/engine.py 驗章 → TenantLicenseDomainService.replace_currentTenantLicenseRepoImpl.mark_not_current + add
停權/解除 LicenseTenantLockRoute / LicenseTenantUnlockRoute force_lock_tenant / unlock_tenant TenantLicenseDomainService.set_suspensionTenantLicenseRepoImpl.set_suspension繞開 BaseRepositoryImpl.update(),見 §12 坑 4)
LC 請照/展延/方案 LicenseTenantLcIssueRoute / LcExtendRoute / LicenseLcPlansRoute request_license_from_lc / extend_license_from_lc / list_lc_plans httpx 打 LC 內部 API → 回照走同一個 upload_license 落地漏斗

DDD 提醒:本模組是主專案自有完整四層的範例(不同於 device / tenant 那種「薄殼+jedi 套件扛 domain」)——api/license/ + app/license/ + domain/license/ + infra/license/ 齊備,只在總覽處借用 jedi-auth 的 TenantService 取租戶清單。幾個值得照抄的做法:

  • 四條落地路徑(root 指派/自行上傳/離線開通/線上開通/LC 請照)共用同一個 _store_verified_license,冪等檢查與跨租戶重複檢查寫一處全收,不各自複製。
  • @transaction 為 reentrant:assign_license 開 scope,內部 upload_license@transaction reuse 同一 session,租戶查詢與驗章落地同一交易內完成。
  • 稽核事件寫入分兩種模式record() 用呼叫端 session + SAVEPOINT 隔離(事件寫失敗不毒死落地);record_out_of_band() 開獨立 session 自行 commit(給 rejected_duplicate 用——拒絕靠 raise 達成,外層交易必然 rollback)。
  • 繞 RLS 讀跨租戶事實(租戶樹 path、跨租戶 license 持有者)一律走 infra/license/license_tenant_resolver.py 的獨立 session + SET LOCAL app.is_super_admin 樣板,該樣板的 canonical 是 infra/system_config/system_config_root_reader.py禁止另寫第二份

9. DB

9.1 資料表總清單(本頁讀寫的全部表)

讀/寫 說明 欄位詳述
config.tenant_licenses 授權照落地表(D6 replace 制,一租戶同時只有一列 is_current=TRUE §9.3
config.tenant_license_events license 生命週期事件(append-only 稽核,11 種型態) §9.3
public.license_clock_watermark (落地時推進) 時鐘回撥防護浮水印(單列 id=1,存最大已見 issued_at
public.tenants 租戶清單/名稱(總覽 enrich);path 供頂層持照租戶解析(繞 RLS) GAI-SD-03

本頁不寫入 tenants 或任何 RBAC 表。config.tenant_licensesconfig.tenant_license_events 皆有 RLS policy(app.is_super_admintenant_id 落在 app.allowed_tenant_paths 內)——root 呼叫時 allowed_tenant_paths 為單層路徑,RLS 天然可見全部子租戶,故總覽不需額外的 super-admin bypass。

9.2 ER 圖(本頁讀寫範圍)

License 授權管理 ER:tenant_licenses 為主寫入表(D6 replace 制),tenant_license_events 以照內 license_id 串時間線;tenant_id 皆無 FK 而以 RLS 隔離;落地時推進時鐘浮水印

9.3 核心表欄位(取自 DEV DB 實查,含 DB comment)

config.tenant_licenses — 授權照落地表

DB comment:「FR-062.2 產品端 license 落地表」。

欄位 型別 說明
id bigint 內部 PK(config.tenant_licenses_id_seq
uid varchar(36) 對外識別碼(UUID),NOT NULL,tenant_licenses_uid_key UNIQUE
tenant_id bigint 持照租戶 id,NOT NULL;無 FK,隔離靠 RLS policy tenant_licenses_tenant_isolation
license_raw text NOT NULL;照原文 JSON(含簽章),逐次驗章依據;絕不經 API 回吐
license_id varchar(64)|null 照內編號——跨租戶唯一性檢查與事件時間線都以此串接
customer_code / order_no / issued_to varchar(64)/(64)/(255)|null 客戶代碼/訂單編號/授權對象(帳本欄位)
license_type varchar(20)|null DB comment:formal/trial/extension
issuer varchar(255)|null 簽發者(LC 端寫入 token_name,看得出由哪個環境請發)
issued_at / starts_at / expires_at timestamptz|null 簽發/生效/到期時間
expiry_policy jsonb|null 到期管線四段組態(notify / grace / readonly / lockout,各段 enabled/天數/show_bannersend_email
modules jsonb|null 已授權模組集(key=capability 的 resource_type,值 truthy 才算有;{"x": false} 與 key 不存在語意等價)
plan varchar(50)|null 方案代碼
limits jsonb|null 額度(目前只用 max_sub_tenants,未帶時出廠預設 5、null 表不限)
deployment_mode varchar(10)|null DB comment:saas/host
machine_fingerprint varchar(255)|null 機器指紋(Host 版開通時綁定;SaaS 照留空)
kid varchar(32)|null 簽章金鑰 id(支援計畫性輪替,產品端內建公鑰列表)
status varchar(20) NOT NULL,預設 'valid';CHECK ck_tenant_licenses_statusvalid/notice/grace/readonly/locked
is_current boolean NOT NULL 預設 TRUE;DB comment:「D6 replace 制:同租戶同一時刻僅一列 TRUE」
suspended_at timestamptz|null DB comment:「人為停權時間戳(NULL=未停權);排程不寫本欄,有值時整列跳過到期推進」
suspend_reason text|null DB comment:「停權原因(root 後台填,可空)」
org_unit_id integer|null FK → org_units.id(tenant-scoped mixin 帶入,本功能未使用)
created_user / updated_user / created_at / updated_at varchar(255)|timestamptz 審計欄(created_user 為 login_name,本頁 API 未 enrich nickname,見 §12 坑 7)

索引idx_tenant_licenses_tenant_currentbtree(tenant_id, is_current),現行照查詢主索引)、idx_tenant_licenses_suspended(partial index btree(tenant_id) WHERE suspended_at IS NOT NULL,只索引被停權的少數列)。

config.tenant_license_events — 生命週期事件(append-only)

欄位 型別 說明
id bigint PK
tenant_id bigint NOT NULL;同樣無 FK,RLS tenant_license_events_tenant_isolation 隔離
license_id varchar(64)|null 照內編號(非本表也非 tenant_licenses 的主鍵)
event_type varchar(32) NOT NULL;CHECK ck_tenant_license_events_type 限 §6.4 那 11 種
actor varchar(255)|null 操作者 login_name(排程為 system
detail jsonb|null 依 event_type 而異的補充資訊
created_at timestamptz NOT NULL 預設 now()
org_unit_id integer|null FK → org_units.id(未使用)

索引idx_tenant_license_events_tenant_createdbtree(tenant_id, created_at DESC),時間線查詢用)。

10. 頁面邏輯與資料對應

載入時序onMountedfetchList()GET /license/tenants → 填入 rows。方案清單不在載入時取,改在第一次開請照 Dialog 時 loadLcPlans() 取一次並以 lcPlansLoaded 標記,之後重開沿用。

關鍵欄位對應(API ↔ 畫面):

畫面元素 FE state API 欄位
租戶名稱欄(含 tooltip 截斷) rows[].tenant_name data[].tenant_name
方案欄 rows[].plan(無值顯示 - data[].plan
狀態 Tag statusLabel(data.status) + statusSeverity() data[].status(null → 顯示「尚未授權」灰 Tag)
「已停權」Tag 與 tooltip data.suspended_at 有值時渲染,tooltip 組 suspended_at + suspend_reason data[].suspended_at / suspend_reason
照型態 / 部署模式欄 typeLabel() / deploymentLabel()(i18n 三層 fallback:找不到譯文即顯示原值) data[].license_type / deployment_mode
到期日欄 new Date(...).toLocaleDateString() data[].expires_at
停權/解除停權按鈕顯示 data.has_license && !data.suspended_atdata.has_license && data.suspended_at 同上兩欄
展延分頁是否可選 lcTarget?.has_license data[].has_license
授權歷程列「說明」欄 eventDetailText(row)——依 event_type 挑欄位組句(狀態轉換組「舊→新」、取代組「已由 X 取代」、拒絕組「已歸屬租戶 N」、其餘組「方案 / 到期日」) data[].detail(jsonb 原樣)

儲存流程

  • 指派/換發onFileSelectFileReader 讀成文字 → 含 -----BEGIN GUIDANT LICENSE----- 則原樣以字串保留、否則 JSON.parse 成物件(兩者皆失敗則顯示格式錯誤)→ submitAssign 包成 {license: <字串或物件>} POST → 成功關 Dialog + fetchList()
  • 請照submitLclcMode 分流 requestLcIssue / requestLcExtend,儲存鈕的 disabled 由 lcIssueValid()(四個必填皆非空)/lcExtendValid()(days > 0)決定。
  • 停權:走 Dialog(要收選填原因);解除停權confirm.require 二次確認(無輸入)。

錯誤對應:BE error envelope → showError(e) toast,文案取自 FE error-code.json 對照的譯文(非 BE msg 原文)。本頁常見碼:LICENSE_400002(驗章失敗)、LICENSE_400006(時鐘回撥)、LICENSE_409002(跨租戶重複)、LICENSE_400008~400010(LC 相關)、LICENSE_404004(LC 找不到要展延的照)、GRC_NOT_PLATFORM_ADMIN(非 root)。

11. 背景行為與外部依賴

類型 內容
通知 本頁操作不寄信。到期通知信由每日排程的狀態轉換觸發(LicenseExpiryNotificationService,notice/grace/readonly 三段各依照內 send_email 旗標),落地與人為停權皆不視為狀態轉換、不寄信
排程 core/scheduler.py 每日 02:00 UTC 跑 license_expiry_state_machine:逐張現行照依 expiry_policy 重算 status,值有變才以 conditional UPDATE(WHERE status = from_status)落地——CAS 保證多 pod 同一次轉換恰好落地一次,也就是通知信不會重複寄。suspended_at 有值的照整列跳過
Socket 無 socket / 無輪詢;操作後靠 fetchList() 主動重抓
外部系統 License Center(獨立 repo ~/Projects/Billows/Audit-Manager/license_center/、獨立 DB)——本頁三支 LC 代理端點透過 httpx 打其 /api/internal/{issue,extend,plans},帶 X-API-Token header,逾時 10 秒;LC 不可達時「指派/換發」手動上傳即為天然 fallback
jedi-* 套件 jedi-authTenantService 取租戶清單)、jedi-commonBaseRepositoryImpl / @transaction / 錯誤 handler / RLS session)
系統參數 DEPLOYMENT_MODEsaas/host,請照未帶時由 BE 補進 payload)、LICENSE_ACTIVATION_SERVER_URL(LC base URL)、LICENSE_CENTER_API_TOKEN值只寫 .env,不入版控)、LICENSE_ENFORCEMENT_ENABLED(執法總開關)、LICENSE_READONLY_GATE_ENABLED(唯讀 gate 子開關)
跨頁影響 本頁的發照/停權會直接改變該租戶樹全站的行為:選單過濾(common/authz/menu_license_filter.py)、模組守門(@require_license)、唯讀 gate、任務型態選擇器、角色權限矩陣的反灰項——見我的授權狀態 §11

12. 邊界情況與已知坑

  1. 停權與到期是兩個正交軸,任何顯示或判斷只看其中一個都會漏判(CM-1173):停權寫的是獨立欄位 suspended_at不動 status——被停權的照 status 可能還是 valid。原本停權借用 status = "readonly" 表達,結果只要照還沒到期,隔天 02:00 UTC 的排程 tick 就依 expires_at 重算把停權撤銷掉(DEV 實例 tenant 158,停權活不過 24 小時)。改成獨立軸後排程對已停權的照整列跳過。維護時記住:執法端(viewer_is_readonly())、總覽 Tag、狀態頁、橫幅、前端反灰,五處全部都要對兩軸取聯集。
  2. 總覽只列頂層客戶租戶,子孫租戶不出現也不該出現(CM-1156):照綁頂層、效力涵蓋整棵子樹,對子租戶單獨「指派」一張照沒有語意,而且正是要消滅的逐租戶發照狀態。若客戶反映「某個子租戶在清單裡找不到」,那是設計如此——去看它的頂層租戶那一列。
  3. rejected_duplicate 事件必須走獨立 session,否則會跟著 rollback 消失(CM-1157):跨租戶重複是靠 raise ConflictError 達成的,外層 @transaction 會整個 rollback——寫在同一 session 裡的稽核紀錄會一起消失,而「有人試圖把別的租戶的照落到這裡」正是最需要留痕的一種。故該事件走 record_out_of_band()(獨立 session、自行 commit、繞 RLS)。其餘落地類事件走 record()(SAVEPOINT 隔離,寫失敗不擋落地)。
  4. set_suspension 刻意繞開 BaseRepositoryImpl.update():後者只寫值非 None 的欄位,而解除停權要寫的正是 NULL——走 update() 會靜默無作用。故 repo 層直接下 UPDATE 兩欄+審計欄位。日後若有其他「要寫 NULL」的需求,同一個坑會再踩一次。
  5. 同租戶重傳現行照是冪等成功、重傳歷史照是正常換照,兩者都不該被跨租戶檢查擋(CM-1155/CM-1157 的交互):冪等檢查只比對現行照、不掃歷史列——同一張照曾是歷史又被重新上傳,屬「換回舊照」的正當語意(例如換照後發現新照有問題要退回),要照常 replace 落地。跨租戶檢查則含歷史列(舊照仍屬原租戶的授權歷史),且位置在冪等檢查之後。三段順序不可調換。
  6. 授權歷程的 suspend / unsuspend 兩個事件型態缺 FE 譯文license-manage.json 目前只有 event_assign ~ event_lc_extend 九項,eventLabel() 的三層 fallback 會讓時間線該列直接顯示英文 key(suspend / unsuspend)。屬顯示層缺口,不影響資料正確性。
  7. 審計欄位未 enrich nicknamecreated_user / updated_user 存的是 login_name,本頁 API 未依專案規範額外提供 created_user_name / updated_user_name(nickname)。目前總覽與歷程都不顯示這兩欄(歷程顯示的是 actor,也是 login_name),屬「欄位存在但未使用」;日後若要在畫面顯示建立者中文名需一併補 enrich。
  8. LC 請照的必填檢查只在 FEplan_code / customer_code / order_no / issued_to 的必填由 FE 儲存鈕 disabled 表達,BE lc-issue 端點只是 payload.get(...) 取值後直送 LC,缺欄位由 LC 回 400 轉成 LICENSE_400010。繞過 FE 直打 API 不會得到欄位級的錯誤訊息。
  9. deployment_mode 在請照表單上沒有欄位:BE 未帶時自動補本站台的 DEPLOYMENT_MODE(見 §6.5)。這是修正 POC 首發簽出「SaaS 雲端版」記載錯誤照的做法——但也意味著無法從 SaaS 站台為 Host 客戶請照(會被補成 saas)。跨形態發照目前只能走 LC 後台自行簽發+本頁手動上傳。
  10. GET /license/tenants/{uid} 端點 FE 完全沒有呼叫端LicenseService.getTenantDetail() 有定義,但全 FE repo grep 不到呼叫。屬預留給未來詳情頁/除錯用;維護時不要以為它是總覽的資料來源(總覽走 GET /license/tenants)。
  11. 本頁全部端點豁免唯讀 gate/license/ 前綴整段列入 _EXEMPT_PREFIXES。這是刻意的防死鎖設計——若 root 自己的租戶樹進了唯讀狀態、又擋掉發照端點,就再也沒有辦法上傳新照解除,形成永久死鎖。豁免範圍是「整段前綴」而非逐支列舉,日後在 /license/ 底下新增任何端點都會自動豁免,新增有副作用的端點時要意識到這點
  12. 執法總開關關閉時本頁仍可正常操作LICENSE_ENFORCEMENT_ENABLED=false 只讓模組守門與唯讀 gate 放行,發照/停權等管理動作本身不受影響(它們是業務功能不是執法)。但停權在總開關關閉時不會產生任何實際效果viewer_is_readonly() 直接回 False),畫面上仍顯示「已停權」——排查「停權沒生效」時先查這個開關。

13. 開發與驗證

  • 跑起來:BE python main_app.py(port 8000,log 在 log/app.log);FE 在 compliance-manager-fe/ 起 Vite dev server。BE 改 service code 後必須重啟(無 hot reload)。LC 代理三支端點需另起 License Center(~/Projects/Billows/Audit-Manager/license_center/,DEV 預設 http://127.0.0.1:5062)並在 .env 設好 LICENSE_CENTER_API_TOKEN(token 至 LC 後台「API Token 管理」頁建立,值不入版控
  • 測試帳號:dev 環境 root tenant(平台管理員)帳號(密碼見 .env / 部署文件)——非 root 帳號登入時本頁選單與端點皆不可達,無法驗證
  • 導航路徑:登入 → 側邊選單最下方「授權管理」群組 → 「License 授權管理」(license-manage,path /license/manage,掛於 group-license,群組 sort=90
  • 前置資料:至少一個頂層客戶租戶(parent_id = 1);驗停權/展延/授權歷程需該租戶已有現行照(可先用「向 LC 請照」或「指派/換發」發一張)
  • 測試政策:碎片 case 預設不寫新 unit test(見 CLAUDE.md 測試政策);本模組既有測試以 license 搜尋 test/
  • E2Ecompliance-manager-test/ repo(Cucumber + Playwright);本頁相關 feature 檔以 license / 授權管理 搜尋
  • 相關文件:全案設計 docs/features/FR-062-2608-license-management/design.md(§4.4 資料模型/§4.5 狀態機/§4.6 執法設計/§4.7 開通與 LC 請照/§4.10 SaaS 後台操作/§6 模組分層);租戶側頁面見我的授權狀態;權限 SOP 見 _overview §2