跳轉到

我的授權狀態(License Status)

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

事實基準:2026-08-10 從 FE / BE code 掃出(config.tenant_licenses 為 FR-062 新建表,schema 直接查 DEV DB 實表;本頁為唯讀詳情頁,唯一 API 為 GET /license/status

變更紀錄

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

1. 功能描述

我的授權狀態頁是租戶側的唯讀授權詳情頁:告訴使用者「貴租戶買了哪些模組、授權何時到期、現在處在哪個階段」。本頁沒有任何寫入動作——唯一的按鈕是導向另一條 route 的「上傳新授權照」(Host 版限定,見 §1.1 #7)。

三件事讓這頁比「顯示一張表」複雜:

  • 照綁頂層客戶租戶、效力涵蓋整棵子樹:子租戶登入看到的是上層那張照,並額外顯示一則「授權繼承自上層租戶」說明——與執法軸讀到的是同一張照,否則會出現「狀態頁說沒授權、功能卻能用」的錯亂。
  • root tenant 是設計上永不持照的特權租戶:它顯示「本租戶為系統管理租戶,不受 License 授權管控」,而不是一般租戶的「尚未授權」空狀態——沒有照是「設計如此」不是「還沒買」。
  • 人為停權與到期階段是兩個正交軸:被停權的照 status 可能還是「正常」,故「現處階段」在停權時顯示「已停權」,並多出停權時間與原因兩格。

本頁本身只是靜態詳情頁,但它背後那張照驅動了全站行為:到期橫幅、寫入按鈕反灰、選單過濾、任務型態選擇器、角色權限矩陣的反灰項(見 §11)。

主要使用者:任何已登入使用者(一般租戶使用者)。BE 端點只掛 @jwt_required(),無角色守門也無 capability 綁定——這是刻意的:使用者必須能查自己的授權狀態,包含在無照或唯讀狀態下。

角色速覽

角色 一句話
一般租戶使用者 本頁主要讀者——看自己(或上層)那張照的模組集、到期日、現處階段
子租戶使用者 看到的是上層持照租戶的照,並見「授權繼承自上層租戶」說明;續約要找上層管理員
平台管理員(root tenant) 本頁對他顯示豁免說明;他實際使用的是 License 授權管理

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

# 功能 說明 位置 詳述
1 現行照資訊顯示 六格:方案/授權對象/授權編號/到期日/照型態/部署形態 主畫面資訊格 §6.2
2 現處階段 Tag 到期管線階段(正常/即將到期/寬限期/唯讀/已鎖定);被停權時改顯示「已停權」 資訊格首格 §4
3 停權說明與明細 被停權時:頂部紅色說明訊息 + 資訊格多出「停權時間」「停權原因」兩格 條件顯示 UC-LS-01
4 已授權模組清單 照內 modules 值為 true 者,排序後以 Tag 逐項列出(顯示中文模組名,無對照譯文時顯示原 key) 資訊格底部 §6.2
5 root 租戶豁免說明 root tenant 顯示「本租戶為系統管理租戶,不受 License 授權管控,全功能可用」,不顯示照內容 互斥狀態訊息 UC-LS-01
6 尚未授權空狀態 非 root 且無現行照時顯示「貴租戶尚未取得授權,請聯繫系統管理員」(HTTP 200,非拋錯) 互斥狀態訊息 UC-LS-01
7 授權繼承說明 照屬上層租戶時顯示「授權繼承自上層租戶『X』」(查不到名稱時走無名稱版文案);與照內容並存,不取代 條件顯示 §12 坑 2
8 上傳新授權照入口 導向 /license/activation 開通頁;僅 Host 版顯示deployment_mode === 'host'),且非 root、非繼承時才出現 頁首按鈕 §12 坑 3
9 全域到期/停權橫幅(非本頁元素) 掛在 AppLayout 頂端,任何登入頁面皆可見;停權文案優先於任何到期文案 AppLayout §11
10 唯讀 gate 連動(非本頁元素) 唯讀/停權時全站寫入按鈕反灰+原因提示;BE 端 before_request 全域攔截 全站 §11

UC(§2)展開 1(檢視授權狀態的完整分支判定)與 2(唯讀/停權連動全站行為)兩條——本頁功能極少且無寫入,其餘項目已於總覽表與 §6 描述完整。

2. Use Case

角色速覽

角色 一句話
一般租戶使用者 本頁主要讀者——看自己(或上層)那張照的模組集、到期日、現處階段
子租戶使用者 看到的是上層持照租戶的照,並見「授權繼承自上層租戶」說明;續約要找上層管理員
平台管理員(root tenant) 本頁對他顯示豁免說明;他實際使用的是 License 授權管理

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

UC-LS-01 檢視自己租戶的授權狀態

項目 內容
角色 任何已登入使用者
前置條件 已登入(僅此而已——無照、唯讀、被停權狀態下都必須能開本頁)
產出 / 後置條件 唯讀顯示;不寫入任何資料、不觸發任何背景行為

UC-LS-01 流程:GET /license/status → 解析頂層持照租戶(讀 tenants.path 第二段)→ root 豁免/無照空狀態/有照三分支 → 停權與繼承兩則說明可並存 → 顯示照內容與模組 Tag

UC-LS-02 唯讀/停權狀態下的全站連動

項目 內容
角色 任何已登入使用者(唯讀狀態影響整個租戶樹)
前置條件 該租戶現行照 status ∈ {readonly, locked}suspended_at 有值;且執法總開關與唯讀 gate 子開關皆啟用
產出 / 後置條件 前端寫入按鈕反灰+原因提示、頂端顯示橫幅;BE 全域攔截寫入回 403 LICENSE_403002/license/ 前綴與登入相關端點豁免(防死鎖)

UC-LS-02 流程:登入抓狀態 → 每次切頁 TTL 節流重抓 → 前端反灰(不隱藏)+橫幅 → 使用者仍點下去時由 BE before_request 唯讀 gate 攔 403;/license/ 前綴等豁免路徑放行以供自救

3. 權限矩陣

操作 FE 判定 BE 強制 BE 檢查位置
選單 / 頁面可見性 RBAC 選單(ui_routes.name='license-status',掛於 group-license 群組,sort=16 刻意不綁任何 capability——全租戶可見的自己狀態頁,維持 fail-open 可見 scripts/sql/2026-08-10-fr062-8-license-menu-restructure.sql(該 migration 明載此決定);DEV 實查 route_capabilitieslicense-status 無列
讀自己的授權狀態 無額外前端判斷 @jwt_required()——無角色、無 capability、無 license 軸守門 api/license/routes/license_route.py::LicenseMyStatusRoute.get
讀機器碼/部署形態(本頁間接用) @jwt_required()無照狀態也要能查(否則抄不到機器碼、開通不了) 同檔 LicenseActivationMachineCodeRoute.get
「上傳新授權照」按鈕顯示 !is_root_tenant && !is_inherited && deploymentMode === 'host' 不適用(僅導頁);開通端點本身另有守門,見開通頁 LicenseStatus.vue:97-100
唯讀 gate 對本頁的影響 不適用(本頁無寫入) 本頁與開通頁所在的 /license/ 前綴整段豁免唯讀 gate common/middleware/license_readonly_mw.py:55-73

「僅 JWT」在此是正確設計而非缺口:把自己的授權狀態鎖在權限後面,會讓最需要看到它的情境(無照、已到期、被停權)反而看不到。本頁完全唯讀、且只回自己租戶樹那張照(RLS + 頂層解析雙重限定),不存在越權讀取他人授權的路徑。

4. 狀態機與前置條件

本頁顯示的「現處階段」即照的到期管線狀態,由每日排程(02:00 UTC)推進,定義見 License 授權管理 §4。本頁的顯示對照

status 畫面文字 Tag 色 使用者感受
valid 正常 全功能
notice 即將到期 全功能+頂端提醒橫幅
grace 寬限期 全功能+醒目警告橫幅
readonly 唯讀 可登入/可看/可下載匯出,全部寫入被擋(出廠預設為終態)
locked 已鎖定 同上(出廠預設 lockout 停用,實務不會出現)
suspended_at 有值) 已停權 覆蓋上列任一階段的顯示——使用者實際受到的限制來自停權,不是 status

本頁的顯示分支前置條件:

條件 效果 出處
is_root_tenant 為 true 顯示豁免說明,不顯示照內容(root 永不持照) api/license/routes/license_route.py:58viewer_is_platform_admin());LicenseStatus.vue:144-146
非 root 且解析後的持照租戶無現行照 顯示「尚未授權」警告;HTTP 仍為 200、data 只有旗標欄位 app/license/service/license_status_service.py:19-35LicenseStatus.vue:148-150
is_inherited 為 true(持照租戶不是自己) 加一則「授權繼承自上層租戶」說明;刻意放在互斥鏈之外,與照內容並存 LicenseStatus.vue:152-162
拿不到持照租戶名稱(RLS 擋祖先查詢) 走無名稱版文案「本租戶的授權繼承自上層租戶…」 LicenseServiceLicenseStatus.vue:103-109;名稱查詢走 resolver 的繞 RLS 查詢
suspended_at 有值 頂部紅色停權說明 +「現處階段」顯示「已停權」+ 多出停權時間/原因兩格;同樣與照內容並存 LicenseStatus.vue:164-200
deployment_mode(站台,非照上的欄位)≠ host 不顯示「上傳新授權照」按鈕(SaaS 客戶沒有自行送照的管道,顯示等於指向死路) licenseStore.showActivationEntryLicenseStatus.vue:97-100

5. UI 設計

📸 實際畫面截圖待補(一律亮色模式拍攝),預計補以下四張到 assets/img/: ① 正常狀態整頁(資訊格六格+模組 Tag 清單)license-status-valid.png ② 尚未授權空狀態 license-status-no-license.png ③ 被停權狀態(紅色說明+停權時間/原因兩格)license-status-suspended.png ④ 到期橫幅(AppLayout 頂端,notice 黃/readonly 紅)license-status-banner.png

版面骨架(單欄唯讀詳情頁:Header + 互斥狀態訊息區 + 授權資訊格;無表單、無 Tab、無分頁):

我的授權狀態頁版面:全域到期橫幅(AppLayout,非本頁)、Header 與續約按鈕、互斥狀態訊息區(可跳 UC-LS-01)、授權資訊格與模組 Tag(可跳 §6)、導向開通頁

狀態呈現

  • 載入中顯示置中的 ProgressSpinner(整頁替換,不用骨架屏)。
  • 三則互斥訊息(root 豁免/尚未授權/正常顯示照內容)走 v-if / v-else-if 鏈;兩則補充說明(繼承、停權)刻意放在鏈之外——接進鏈裡會把照內容整段吃掉,變成「有授權卻看不到授權內容」。
  • 已授權模組以 Tag severity="info" 逐項列出,label 走 lang.license_module_names.* 三層 fallback(找不到譯文即顯示原 resource_type key);空清單顯示 -
  • 本頁不吃全域 store 快取onMounted 自行重打 GET /license/status,故資料一定是當下的(橫幅那份才是 TTL 快取)。
  • 無 socket / 無輪詢。

6. API 規格

Envelope:成功 {"status": true, "data": …};失敗經 jedi_common.handler.handler.register_error_handlers 轉為 {"error_code": "…", "msg": "…"}

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

分類 Method + Path 說明 完整規格
狀態 GET /license/status 查自己(實為頂層持照租戶)的現行照狀態——本頁唯一直接呼叫的 API §6.2
站台資訊 GET /license/activation/machine-code 本機機器碼與站台部署形態;本頁透過 licenseStore.fetchDeploymentMode() 間接呼叫,只取 deployment_mode 決定續約按鈕是否顯示 §6.3

開通相關的寫入端點(POST /license/activation/upload / online)屬開通頁/license/activation,另一條 route),本 spec 不展開。

6.2 狀態查詢

[GET] /license/status

無 query 參數。Response dataTenantLicenseResponse + 三個附加旗標;無現行照時仍回 200data 只有旗標欄位、照欄位缺席):

欄位 型別 說明
uid string 照的對外識別碼(FE 以「有無 uid」判斷是否有照)
tenant_id integer 持照租戶內部 id
license_id string|null 照內編號(畫面「授權編號」)
customer_code / order_no string|null 客戶代碼/訂單編號(畫面未顯示)
issued_to string|null 授權對象
license_type string|null 照型態(formal/trial/extension → 正式版/試用版/展延)
issuer string|null 簽發者(畫面未顯示)
issued_at / starts_at / expires_at datetime|null 簽發/生效/到期時間(畫面只顯示到期日)
expiry_policy object|null 到期管線四段組態;橫幅的 show_banner 逐段開關讀這裡
modules object|null 模組集(key=capability 的 resource_type,值 truthy 才算有);畫面只列 true 的項
plan string|null 方案代碼
limits object|null 額度(目前只有 max_sub_tenants;畫面未顯示)
deployment_mode string|null 照上寫的部署形態(≠ 站台的 DEPLOYMENT_MODE,見 §12 坑 3)
machine_fingerprint string|null 機器指紋(Host 版綁機器;畫面未顯示)
kid string|null 簽章金鑰 id(畫面未顯示)
status string 到期管線階段
is_current boolean 恆為 true(只回現行照)
suspended_at datetime|null 人為停權時間戳——與 status 正交,FE 橫幅/狀態卡片必須分辨「被停權」與「自然到期唯讀」(兩者出路不同:前者聯繫系統管理員,後者續約換照)
suspend_reason string|null 停權原因(由平台管理員在後台填寫,是客戶唯一看得到的說明)
created_user / created_at / updated_at string|datetime 審計欄(畫面未顯示)
is_root_tenant boolean 附加旗標:呼叫者是否為 root tenant(豁免說明用);有照/無照兩案都會附
is_inherited boolean 附加旗標:本照是否繼承自上層租戶
license_tenant_name string|null 持照租戶名(僅 is_inherited 時有值;RLS 擋祖先查詢時可能為 null,FE 有無名稱兩版文案)
licensed_job_types string[] 附加旗標:當前租戶可用的任務型態(general/survey/detection_tool)——供 FE 任務類型選擇器隱藏未授權項;由 BE licensed_job_types() 算,與 API 守門同一支函式,FE 不自己拿 modules 兜第二套對應表

license_raw 絕不回傳(照原文含簽章,非顯示用途;需要重驗證走 BE 內部驗章流程)。

讀的是頂層持照租戶的照,不是當前租戶自己的LicenseTenantResolver.resolve()tenants.path 取第二段解析出頂層客戶租戶(照綁頂層、效力涵蓋全樹),與執法軸讀到的是完全同一張照——否則會出現「狀態頁說沒授權、功能卻能用」的錯亂。

出處:api/license/serializers/license.py:4-76TenantLicenseResponsedump_my_status)、app/license/service/license_status_service.pyapi/license/routes/license_route.py:42-62

6.3 站台部署形態

[GET] /license/activation/machine-code

無 query。Response data

欄位 型別 說明
deployment_mode string 站台的 DEPLOYMENT_MODE 設定值(saas/host)——這是「這台伺服器是落地版還是雲端版」,與照上的 deployment_mode 不同
machine_fingerprint string|null 本機機器碼;SaaS 版恆為 null(不綁機器)

本頁只取 deployment_mode(決定續約按鈕是否顯示),機器碼是開通頁在用。此端點豁免於唯讀 gate 與 license 軸執法/license/ 前綴),任何登入使用者皆可查——無照狀態也必須看得到,否則抄不到機器碼、開不了通。出處:api/license/routes/license_route.py:65-82

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

檔案 角色
src/views/license/LicenseStatus.vue 本頁唯一檢視檔(248 行;純顯示,無子元件、無表單)
src/config/router/index.js:632-639 route license-status,path /license/status
src/stores/licenseStore.js 全站授權狀態快取(Pinia):橫幅顯示判定 showBanner、停權判定 isSuspended、任務型態過濾 jobTypeAllowed、開通入口判定 showActivationEntry;TTL 5 分鐘+路由切換觸發重抓
src/composables/useLicenseReadonly.js 唯讀狀態的寫入 UI 控制(isReadonly / readonlyReason / writeDisabled());全 FE 共 76 個檔案引用
src/components/license/LicenseExpiryBanner.vue 全域到期/停權橫幅(掛在 src/layout/AppLayout.vue:111
src/views/license/LicenseActivation.vue 開通頁(/license/activation,本頁「上傳新授權照」的導向目標,本 spec 不展開)
src/views/auth/Login.vue:160-172 登入成功後偵測無照(非 root 且無 uid)→ 導向開通頁
src/service/LicenseService.js getMyStatus() / getActivationMachineCode() 兩支供本頁使用
src/config/locales/i18n/{zh-tw,en}/license-manage.json 本頁 i18n 在 lang.license_status.*(與授權管理頁同檔);模組中文名在 lang.license_module_names.*(27 項)

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

鏈路 Route App Service 底層
查自己的授權狀態 api/license/routes/license_route.py::LicenseMyStatusRoute(GET) app/license/service/license_status_service.py::LicenseStatusService.get_my_license_status@transaction infra/license/license_tenant_resolver.py::LicenseTenantResolver.resolve(繞 RLS 讀 tenants.path)→ TenantLicenseDomainService.get_current_by_tenantTenantLicenseRepoImpl(繼承 BaseRepositoryImpl
附加旗標組裝 同上(route 層呼 viewer_is_platform_admin()licensed_job_types() common/authz/platform.pycommon/enum/grc_job_type_enum.py:25-40
站台部署形態 LicenseActivationMachineCodeRoute(GET) 無(route 直接讀 current_app.config + 呼 compute_machine_fingerprint() common/license/machine_fingerprint.py
唯讀 gate(影響全站,非本頁端點) before_request hook common/middleware/license_readonly_mw.pycommon/authz/license.py::viewer_is_readonly

DDD 提醒LicenseMyStatusRoute.get 在 route 層呼叫了 viewer_is_platform_admin()licensed_job_types() 兩支純 context 判定函式——這兩支不碰 DB、不開 session(前者讀 allowed_tenant_paths,後者讀 per-request 的 license 快照),屬「主體域守門」允許在 route 層表達的範疇(CLAUDE.md「授權守門一律用 common/authz/」雙軌放置規則),不違反「route 層不做 DB 查詢」。

9. DB

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

讀/寫 說明 欄位詳述
config.tenant_licenses 現行照(is_current=TRUE)——本頁唯一資料來源 License 授權管理 §9.3
public.tenants path 解析頂層持照租戶+取持照租戶名稱(皆繞 RLS,見下) GAI-SD-03

本頁零寫入——不新增、不更新、不刪除任何列,也不寫稽核事件。

9.2 ER 圖(本頁讀寫範圍)

本頁讀取範圍是 License 授權管理 §9.2 ER 圖 的唯讀子集(tenant_licensestenants),不另繪一張。

9.3 核心表欄位

欄位表見 License 授權管理 §9.3config.tenant_licenses)。本頁特別相關的兩點:

  • RLS 與繞 RLS 的分工config.tenant_licenses 的 policy tenant_licenses_tenant_isolation 允許 app.is_super_admintenant_id 落在 app.allowed_tenant_paths 內。子租戶讀上層的照時,該 policy 本身擋不住(tenant_id 不在自己的 path 內),故解析與名稱查詢都走 LicenseTenantResolver 的獨立 session + SET LOCAL app.is_super_admin 樣板。
  • 為什麼讀 path 不走 parent_idtenants 的 RLS SELECT policy 是前綴比對——租戶看得到自己與子孫、看不到祖先/1/102/ 不以 /1/102/152/ 起頭),沿 parent_id 往上走第一步就查無。而 path 本身就是完整祖先鏈且自己那列一定看得到,一次查詢即可解析。

10. 頁面邏輯與資料對應

載入時序onMounted 平行做兩件事——① fetchStatus()GET /license/status 填入 license本頁自己重打,不吃 store 快取);② licenseStore.fetchDeploymentMode() 取站台部署形態(已抓過就不重打,進行中共用同一個 promise)。

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

畫面元素 FE state API 欄位
是否顯示豁免說明 license.is_root_tenant data.is_root_tenant
是否顯示「尚未授權」 !license?.uid(且非 root) data.uid 缺席
現處階段 Tag isSuspended ? '已停權' : t('lang.license_manage.status_' + status) data.suspended_at / data.status
停權時間 / 停權原因兩格 isSuspended 時渲染 data.suspended_at / data.suspend_reason
方案/授權對象/授權編號/到期日 直接綁定 data.plan / issued_to / license_id / expires_at
照型態/部署形態 typeLabel() / deploymentLabel()(i18n 三層 fallback) data.license_type / data.deployment_mode
已授權模組 Tag 清單 moduleList computed——取 modules 中值為 true 的 key、排序、對照 lang.license_module_names.* data.modules
繼承說明文案 inheritedNotice computed(有名稱/無名稱兩版) data.is_inherited / data.license_tenant_name
「上傳新授權照」按鈕 showRenewAction computed data.is_root_tenantdata.is_inheritedlicenseStore.deploymentMode

橫幅與唯讀反灰的資料來源不同:本頁讀的是自己那趟 API 回應;橫幅與全站反灰讀的是 licenseStore(快取),兩者可能短暫不同步(見 §12 坑 4)。

錯誤對應fetchStatus 失敗只 showError(e) toast,不擋頁面渲染。licenseStore.fetchStatus 的失敗處理更保守——狀態歸 null、licensedJobTypes 維持 null(=不過濾)、suspendedAt 歸 null(fail-open:一次查詢失敗不該讓整站誤判成被停權)。

11. 背景行為與外部依賴

類型 內容
通知 本頁不觸發通知。到期通知信由每日排程的狀態轉換觸發(LicenseExpiryNotificationService,notice/grace/readonly 三段各依照內 send_email 旗標寄給該租戶管理員),寄信冪等性來自 domain 層 transition_status 的 CAS
排程 core/scheduler.py 每日 02:00 UTC license_expiry_state_machine —— 本頁顯示的「現處階段」就是這支排程推進的結果;被人為停權的照整列跳過
Socket 無 socket / 無輪詢
狀態快取 licenseStore 5 分鐘 TTL + 路由切換 afterEach 觸發重抓(refreshIfStale())。兩者合用的理由:只做 TTL 沒有東西會去踩它(橫幅掛在 AppLayout 只 mount 一次,SPA 內切頁不重新掛載);只看切頁則每切一頁打一發 API。故平台管理員停權後,客戶端最多延遲一個 TTL + 一次切頁才反映(登出/未登入態的路由跳轉刻意跳過重抓,避免拿註銷過的 token 打 API)
全站連動(本頁那張照驅動的) 選單過濾common/authz/menu_license_filter.py 砍掉能力點全落在未授權模組的葉節點(capabilities 為空的節點 fail-open 保留);② 模組守門@require_license(resource_type) 軸⑥,未授權回 403 LICENSE_403001;③ 唯讀 gatebefore_request 全域攔寫入回 403 LICENSE_403002;④ 任務型態選擇器:吃 licensed_job_types;⑤ 角色權限矩陣:未授權模組的能力點反灰+原因(GET /ui-routesis_licensed),寫入端另擋 403 LICENSE_403004;⑥ 子租戶額度:開子租戶時檢查 limits.max_sub_tenants(未帶時預設 5),超過回 403 LICENSE_403003
jedi-* 套件 jedi-commonBaseRepositoryImpl / @transaction / RLS session / 錯誤 handler);本頁不依賴 jedi-auth
系統參數 DEPLOYMENT_MODE(決定續約按鈕與機器碼)、LICENSE_ENFORCEMENT_ENABLED(執法總開關,關閉時模組守門與唯讀 gate 全放行)、LICENSE_READONLY_GATE_ENABLED(唯讀 gate 子開關,只關「過期後全域擋寫入」保留模組守門;總開關關閉時本開關無意義)

12. 邊界情況與已知坑

  1. 停權與到期是兩個正交軸,五處判斷都必須取聯集(CM-1173/CM-1174):被停權的照 status 可能還是 valid——只看 status 會整個漏判。本頁、橫幅(LicenseExpiryBanner.vue)、前端反灰(useLicenseReadonly.js)、BE 執法(viewer_is_readonly())、後台總覽 Tag 五處各自都要判兩軸。文案也要分開:停權的出路是「聯繫系統管理員」,到期的出路是「續約換照」,套錯會講出與事實相反的話(曾出現「卡片顯示唯讀、橫幅卻說即將於 X 日到期請續約」)。
  2. 繼承說明與停權說明刻意放在 v-else-if 鏈之外:底下的模組集/到期日就是那張照的內容,要與說明並存;接進互斥鏈裡會把照內容整段吃掉,變成「有授權卻看不到授權內容」。改動這段模板時容易手滑合併回去。
  3. 照上的 deployment_mode 與站台的 DEPLOYMENT_MODE 是兩回事,別拿錯(CM-1172):續約按鈕的判斷依據必須是站台的部署形態(走 GET /license/activation/machine-code),不是照上的 deployment_mode——後者在無照時根本不存在,拿來判斷會在最需要判斷的無照情境失效。SaaS 版一律不顯示續約入口:送照的唯一管道是平台後台指派(客戶零動作),開通頁對 SaaS 客戶是死路(點進去只有「請聯繫系統管理員」提示)。
  4. 本頁與橫幅的資料來源不同,可能短暫不一致:本頁 onMounted 自行重打 API(一定是當下值),橫幅與全站反灰讀 licenseStore 的 5 分鐘 TTL 快取。剛被停權時可能出現「本頁已顯示已停權、橫幅還沒出現」的短暫落差,切一次頁或等 TTL 過即一致。這是刻意取捨(停權不是秒級生效的需求),不是 bug。
  5. 未授權模組是隱藏、唯讀是反灰——兩種相反的處理,別統一:未授權是常態(沒買就是沒有這個功能),隱藏才符合認知;唯讀是異常態(買了但過期),把按鈕藏掉會讓使用者以為功能不存在/系統壞了,反灰+原因提示才傳達「功能還在,只是現在不能用」。唯一的例外是角色權限矩陣——那裡管理員是在配置權限而非使用功能,未授權項採反灰+原因(業界 show-and-disable 模式)。此例外已在程式碼註解寫明,避免日後有人「照原則修正」改回隱藏。
  6. 前端反灰不是安全機制useLicenseReadonly 只是體驗優化(讓使用者在點下去之前就知道)。真正的擋在 BE before_request 唯讀 gate,繞過前端直打 API 一樣 403。
  7. 唯讀 gate 不能只看 HTTP method:本專案有大量「POST 形狀的讀」(分頁列表查詢一律 POST 帶 filters/pager,約 70 支)。gate 採兩段判定——PUT/PATCH/DELETE 一律攔;POST 預設視為寫,只有登記在讀取白名單的才放行。白名單逐支列舉而非用路徑形狀推斷(/detection-tool-profiles/remote-agents/flow-engine/flow-templates 等都是複數名詞卻做寫入)。新增讀取型 POST 端點時要記得補進白名單,否則唯讀租戶會看不到該清單。
  8. 無照與唯讀是兩種不同的「不能用」:無照=所有模組皆未授權(fail-closed),由模組守門擋,碼 LICENSE_403001(出路:買);唯讀=有照但到期,由唯讀 gate 擋,碼 LICENSE_403002(出路:換照)。viewer_is_readonly() 對無照明確回 False,兩者不混。
  9. root tenant 沒有照不是異常is_root_tenant 旗標存在的理由就是讓 FE 分辨「設計上永不發照」與「還沒買」。任何 license 執法路徑第一步也都先過 viewer_is_platform_admin() 豁免——沒有這個分支的話,平台管理員會被自己的執法鎖在門外。
  10. super_admin 不是 license 的 break-glass:軸④capability 有帳號層 super admin 旁路,軸⑥license 沒有——子租戶的 super admin 若能旁路,等於任何客戶自己把帳號設成 super admin 就繞過商務授權。license 只認 root tenant。
  11. 模組名 i18n 有缺口就顯示原 keylang.license_module_names.* 目前 27 項,若照內出現未收錄的 resource_type(例如新增模組粒度後未同步 i18n),畫面會直接顯示英文 key。屬顯示層缺口,不影響授權判定。

13. 開發與驗證

  • 跑起來:BE python main_app.py(port 8000,log 在 log/app.log);FE 在 compliance-manager-fe/ 起 Vite dev server。BE 改 service code 後必須重啟(無 hot reload)
  • 測試帳號:dev 環境一般租戶帳號(密碼見 .env / 部署文件);root 帳號登入只會看到豁免說明,驗不到主要顯示邏輯
  • 導航路徑:登入 → 側邊選單最下方「授權管理」群組 → 「我的授權狀態」(license-status,path /license/status,掛於 group-license,群組 sort=90
  • 前置資料:該租戶樹的頂層租戶需有一張現行照(由平台管理員在 License 授權管理 發)。驗各分支的做法:尚未授權=找沒發過照的頂層租戶;繼承=用子租戶帳號登入;停權=在後台按「立即停權」後回本頁重整;到期各階段=發一張短效照或直接改 DB expires_at只動 DEV
  • 驗唯讀連動:停權或讓照進 readonly 後,隨便開一個有寫入按鈕的頁面確認反灰+tooltip,再用瀏覽器開發工具直打一支寫入 API 確認回 403 LICENSE_403002;同時確認 /license/ 前綴仍可用(防死鎖)
  • 排查「執法沒生效」:先查 LICENSE_ENFORCEMENT_ENABLEDLICENSE_READONLY_GATE_ENABLED 兩個環境變數,總開關關閉時模組守門與唯讀 gate 全部放行、畫面仍會顯示「已停權」
  • 測試政策:碎片 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.5 狀態機/§4.6 執法設計/§4.7 開通流程/§6 模組分層與 Plan);發照端見 License 授權管理;權限 SOP 見 _overview §2