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.jsondump 早於本功能尚未涵蓋)
變更紀錄
| 日期 | 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.read(is_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_URL 與 LICENSE_CENTER_API_TOKEN 已設定且 LC 可達;展延另需該租戶已有現行照 |
| 產出 / 後置條件 | config.tenant_licenses 新增一列現行照(舊列轉歷史);config.tenant_license_events 記一筆 lc_issue / lc_extend(+被取代的舊照一筆 replace);即改即生效,客戶零動作 |
UC-LM-02 指派/換發授權照(上傳 license 檔)¶
| 項目 | 內容 |
|---|---|
| 角色 | 平台管理員(root tenant) |
| 前置條件 | 已登入;手上有 LC 簽發的 .license 或舊版 .json 照檔;目標租戶 uid 存在 |
| 產出 / 後置條件 | 驗章通過 → D6 replace 落地為該租戶現行照,事件記 assign;重傳同一張現行照 → 冪等跳過(靜默成功);照已被別的租戶落地過 → 409 拒絕並記 rejected_duplicate |
UC-LM-03 立即停權/解除停權¶
| 項目 | 內容 |
|---|---|
| 角色 | 平台管理員(root tenant);SaaS 版獨有動作(Host 版照在客戶手上,只能等到期) |
| 前置條件 | 已登入;該租戶有現行照(無照時 404 LICENSE_404001) |
| 產出 / 後置條件 | 停權:現行照 suspended_at = now()、suspend_reason 寫入,事件記 suspend,該租戶即時全域唯讀;解除:兩欄清空,事件記 unsuspend,狀態由下一輪排程重算 |
3. 權限矩陣¶
| 操作 | FE 判定 | BE 強制 | BE 檢查位置 |
|---|---|---|---|
| 選單 / 頁面可見性 | RBAC 選單(ui_routes.name='license-manage',掛於 group-license 群組,sort=15) |
license.read 能力點(is_platform=true,requirement=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.py(LicenseTenantsRoute / LicenseTenantDetailRoute / LicenseTenantEventsRoute) |
| 指派/換發(assign) | 無額外前端角色判斷;僅檔案格式前端預檢 | 同上 | api/license/routes/license_admin_route.py::LicenseTenantAssignRoute |
| 立即停權 / 解除停權 | 依該列 suspended_at 有無決定顯示哪一顆(互斥成對);has_license 為 false 時兩顆皆不顯示 |
同上;service 層另檢查目標租戶存在與有現行照 | 同檔 LicenseTenantLockRoute / LicenseTenantUnlockRoute → TenantLicenseAdminService.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-41(require_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,並以獨立 session 記 rejected_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、無搜尋列、無分頁):
狀態呈現:
- DataTable
loading遮罩(loading);各 Dialog 各自獨立的送出 loading(submitting/locking/lcSubmitting),互不阻塞。 - 狀態欄兩個 Tag 並列而非互相取代:左邊是到期管線的
statusTag(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 | string|object | 是 | armor 文字(.license 檔內容原樣)或裸 JSON 信封物件;BE 偵測 BEGIN 行即 dearmor 還原後走同一條驗章路徑 |
Response data:落地後的現行照(TenantLicenseResponse)。錯誤:403(非 root)/404 LICENSE_404002(租戶不存在)/400 LICENSE_400001|LICENSE_400002|LICENSE_400006(格式/驗章/時鐘)/409 LICENSE_409002(跨租戶重複)。出處:api/license/routes/license_admin_route.py:67-84、app/license/service/tenant_license_admin_service.py:93-104 → LicenseVerificationService.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-106、app/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 |
狀態轉換 | 每日排程推進到期階段(detail 帶 from_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-105、infra/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-282、common/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-status、license-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_current → TenantLicenseRepoImpl.mark_not_current + add |
| 停權/解除 | LicenseTenantLockRoute / LicenseTenantUnlockRoute |
force_lock_tenant / unlock_tenant |
TenantLicenseDomainService.set_suspension → TenantLicenseRepoImpl.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的@transactionreuse 同一 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_licenses與config.tenant_license_events皆有 RLS policy(app.is_super_admin或tenant_id落在app.allowed_tenant_paths內)——root 呼叫時allowed_tenant_paths為單層路徑,RLS 天然可見全部子租戶,故總覽不需額外的 super-admin bypass。
9.2 ER 圖(本頁讀寫範圍)¶
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_banner/send_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_status 限 valid/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_current(btree(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_created(btree(tenant_id, created_at DESC),時間線查詢用)。
10. 頁面邏輯與資料對應¶
載入時序:onMounted → fetchList() → 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_at / data.has_license && data.suspended_at |
同上兩欄 |
| 展延分頁是否可選 | lcTarget?.has_license |
data[].has_license |
| 授權歷程列「說明」欄 | eventDetailText(row)——依 event_type 挑欄位組句(狀態轉換組「舊→新」、取代組「已由 X 取代」、拒絕組「已歸屬租戶 N」、其餘組「方案 / 到期日」) |
data[].detail(jsonb 原樣) |
儲存流程:
- 指派/換發:
onFileSelect用FileReader讀成文字 → 含-----BEGIN GUIDANT LICENSE-----則原樣以字串保留、否則JSON.parse成物件(兩者皆失敗則顯示格式錯誤)→submitAssign包成{license: <字串或物件>}POST → 成功關 Dialog +fetchList()。 - 請照:
submitLc依lcMode分流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-auth(TenantService 取租戶清單)、jedi-common(BaseRepositoryImpl / @transaction / 錯誤 handler / RLS session) |
| 系統參數 | DEPLOYMENT_MODE(saas/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. 邊界情況與已知坑¶
- 停權與到期是兩個正交軸,任何顯示或判斷只看其中一個都會漏判(CM-1173):停權寫的是獨立欄位
suspended_at,不動status——被停權的照status可能還是valid。原本停權借用status = "readonly"表達,結果只要照還沒到期,隔天 02:00 UTC 的排程 tick 就依expires_at重算把停權撤銷掉(DEV 實例 tenant 158,停權活不過 24 小時)。改成獨立軸後排程對已停權的照整列跳過。維護時記住:執法端(viewer_is_readonly())、總覽 Tag、狀態頁、橫幅、前端反灰,五處全部都要對兩軸取聯集。 - 總覽只列頂層客戶租戶,子孫租戶不出現也不該出現(CM-1156):照綁頂層、效力涵蓋整棵子樹,對子租戶單獨「指派」一張照沒有語意,而且正是要消滅的逐租戶發照狀態。若客戶反映「某個子租戶在清單裡找不到」,那是設計如此——去看它的頂層租戶那一列。
rejected_duplicate事件必須走獨立 session,否則會跟著 rollback 消失(CM-1157):跨租戶重複是靠raise ConflictError達成的,外層@transaction會整個 rollback——寫在同一 session 裡的稽核紀錄會一起消失,而「有人試圖把別的租戶的照落到這裡」正是最需要留痕的一種。故該事件走record_out_of_band()(獨立 session、自行 commit、繞 RLS)。其餘落地類事件走record()(SAVEPOINT 隔離,寫失敗不擋落地)。set_suspension刻意繞開BaseRepositoryImpl.update():後者只寫值非None的欄位,而解除停權要寫的正是 NULL——走update()會靜默無作用。故 repo 層直接下 UPDATE 兩欄+審計欄位。日後若有其他「要寫 NULL」的需求,同一個坑會再踩一次。- 同租戶重傳現行照是冪等成功、重傳歷史照是正常換照,兩者都不該被跨租戶檢查擋(CM-1155/CM-1157 的交互):冪等檢查只比對現行照、不掃歷史列——同一張照曾是歷史又被重新上傳,屬「換回舊照」的正當語意(例如換照後發現新照有問題要退回),要照常 replace 落地。跨租戶檢查則含歷史列(舊照仍屬原租戶的授權歷史),且位置在冪等檢查之後。三段順序不可調換。
- 授權歷程的
suspend/unsuspend兩個事件型態缺 FE 譯文:license-manage.json目前只有event_assign~event_lc_extend九項,eventLabel()的三層 fallback 會讓時間線該列直接顯示英文 key(suspend/unsuspend)。屬顯示層缺口,不影響資料正確性。 - 審計欄位未 enrich nickname:
created_user/updated_user存的是 login_name,本頁 API 未依專案規範額外提供created_user_name/updated_user_name(nickname)。目前總覽與歷程都不顯示這兩欄(歷程顯示的是actor,也是 login_name),屬「欄位存在但未使用」;日後若要在畫面顯示建立者中文名需一併補 enrich。 - LC 請照的必填檢查只在 FE:
plan_code/customer_code/order_no/issued_to的必填由 FE 儲存鈕 disabled 表達,BElc-issue端點只是payload.get(...)取值後直送 LC,缺欄位由 LC 回 400 轉成LICENSE_400010。繞過 FE 直打 API 不會得到欄位級的錯誤訊息。 deployment_mode在請照表單上沒有欄位:BE 未帶時自動補本站台的DEPLOYMENT_MODE(見 §6.5)。這是修正 POC 首發簽出「SaaS 雲端版」記載錯誤照的做法——但也意味著無法從 SaaS 站台為 Host 客戶請照(會被補成saas)。跨形態發照目前只能走 LC 後台自行簽發+本頁手動上傳。GET /license/tenants/{uid}端點 FE 完全沒有呼叫端:LicenseService.getTenantDetail()有定義,但全 FE repo grep 不到呼叫。屬預留給未來詳情頁/除錯用;維護時不要以為它是總覽的資料來源(總覽走GET /license/tenants)。- 本頁全部端點豁免唯讀 gate:
/license/前綴整段列入_EXEMPT_PREFIXES。這是刻意的防死鎖設計——若 root 自己的租戶樹進了唯讀狀態、又擋掉發照端點,就再也沒有辦法上傳新照解除,形成永久死鎖。豁免範圍是「整段前綴」而非逐支列舉,日後在/license/底下新增任何端點都會自動豁免,新增有副作用的端點時要意識到這點。 - 執法總開關關閉時本頁仍可正常操作:
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/ - E2E:
compliance-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