Repo:BE(含 FE 的 error code i18n) | 前置:CM-1077.1 使用判定服務 ✅ 已完成 | 母卡:CM-1077
前置卡狀態:CM-1082(
.1)已落地 —— BE commit17e977eb,8 檔 +540 行,11 條測試通過,Notion 狀態修正待驗證。本卡可直接接上其介面(見下方「前置卡.1提供的介面」一節),不必再等。
檢測基準(detection profile)目前只能新增、不能改也不能刪。
is_active=false),資料還在,列表看不到但 DB 裡一直堆著。但也不能無條件開放修改——基準版本的 uid 是派工契約(下發格式 profile:<版本 uid>),sha256 是 agent 端對帳的信任根。已經被掃描用過的版本如果內容被換掉,之後回頭看那次掃描報告,沒有人能確定當時到底掃了什麼。
本卡要做的就是:在「客觀上從未被使用過」的前提下,開放就地修正與硬刪除;一旦用過就凍結。
| 動作 | 端點 | 前置條件 |
|---|---|---|
| 版本改來源 | PATCH /api/1.0/detection-tool-profile-versions/<uid>/source |
未被使用過;版號不動;改完重抽控制項 |
| 版本刪除 | DELETE /api/1.0/detection-tool-profile-versions/<uid> |
未被使用過 |
| 主檔刪除 | DELETE /api/1.0/detection-tool-profiles/<uid> |
底下所有版本都未被使用過 |
| 停用(沿用既有,不動) | 既有 PUT /api/1.0/detection-tool-profiles/<uid>/deactivate |
— |
「未被使用過」的判定一律呼叫前置卡 CM-1077.1 提供的使用判定服務,本卡不重寫判定邏輯、不自己去 grep job_execution_detection_tools.tool_params。判定服務是唯讀的,回答「這個版本/這支主檔被誰用過」。
FE 呈現(按鈕、確認對話、狀態顯示)屬 CM-1077.3,本卡只負責後端行為正確且可輪詢。
.1 提供的介面(已落地,直接用)CM-1082(.1)已完成(commit 17e977eb),以下是實際落地的介面,本卡直接呼叫即可。
檔案:app/detection_tools/service/detection_profile_service.py
| 方法 | 用途 | 軟刪穿透 |
|---|---|---|
get_version_usage(version_uid: str) -> dict |
版本層判定 | 做(軟刪專案的引用不計入 in_use) |
get_profile_usage(uid: str) -> dict |
主檔層判定,語意「底下任一版本被用過即算」 | 不做(判準更嚴) |
兩支都已掛 @transaction。版本查不到時 get_version_usage raise NotFound(DETECTION_PROFILE_NOT_FOUND)。
{"in_use": bool, "projects": [{"uid": str, "name": str}], "version_refs": [...]}
🔴 projects 只列活著的專案(兩層都是)——主檔層雖然把軟刪引用計入 in_use,訊息裡仍不列它。所以會出現 in_use=true 但 projects 為空的合法狀態,本卡的 409 訊息組裝必須能處理這種情形,不能假設 projects 非空。
.3 會用,本卡不必重複建)GET /api/1.0/detection-tool-profiles/<uid>/referencing-usage 主檔層
GET /api/1.0/detection-tool-profile-versions/<uid>/referencing-usage 版本層
infra/detection_tools/repository/detection_profile_usage_query.py → DetectionProfileUsageQuery.find_refs()(三來源 UNION ALL)di_containers/detection_tools/detection_tools_containers.py wiring 完成list_all_versions(profile_id)(不分頁,判定專用)——不要改用會截斷的 list_versions()🔴 本卡一律呼叫上述兩支 app service 方法,不要自己再查引用、不要繞過去直接用 DetectionProfileUsageQuery(那是 infra 層,app service 才是判定語意的所在)。
_build_usage() 的測試用 fallback_build_usage() 裡有一條 if self._usage_query is None: return {"in_use": False, ...} 的 fallback,是給不組全套依賴的單元測試用的。
寫本卡的測試時,若 mock 掉 usage_query 或漏組依賴,判定會一律回「未使用」→ 守門測試會假性通過。 本卡的 409 測試必須確認 usage_query 有實際注入(斷言 in_use=true 那條路徑真的走到),否則等於沒測到守門。
判準是客觀的「有沒有被使用過」,不是問使用者意圖。
| 狀態 | 可做的事 |
|---|---|
| 從未被使用過 | 就地修正來源(版號不動、重抽控制項)/刪除 |
| 已被使用過 | 一律凍結,只能建立新版本 |
🔴 已被使用的版本不提供「確定要更新嗎」這種硬改選項。
決策者原話:「萬一有被掃過,下次掃同版本結果不一樣,也很奇怪」。uid 是凍結契約、sha256 是對帳基準,同一個版本 uid 在不同時間指向不同內容,之後回頭看某次掃描報告,沒有人能確定當時到底掃了什麼。在 GRC 系統裡這是稽核可信度問題,不是 UX 取捨,所以不能交給使用者當次決定。
被排除的方案(不要回頭選):跳確認框問「確定要更新嗎?會影響既有資料」讓使用者自行決定——已被決策者明確否決。
刪掉 current_version → 擋下,回 DETECTION_PROFILE_CURRENT_VERSION_UNDELETABLE,要求使用者先把 current 指標改指其他版本,或整支主檔刪掉。 理由:current 指標懸空會讓派工拿不到東西。 (DB 層 detection_profiles.current_version_id 的 FK 是 ON DELETE SET NULL——不要靠它,那是防資料損毀的最後一道,不是業務規則;業務層要主動擋。)
主檔刪除是硬刪(含底下所有版本與控制項)。 config.detection_profile_versions.profile_id → detection_profiles(id) 已是 ON DELETE CASCADE,detection_profile_controls.version_id → detection_profile_versions(id) 也是 ON DELETE CASCADE(見 scripts/sql/2026-08-03-fr060-1-detection-profile-split.sql),所以刪主檔一路帶走,不需要新的 migration。 理由:「從未被使用過」已保證沒有稽核軌跡牽連——這正是判準要那麼嚴的原因。
file 型改來源不清掉舊的 upload_files(那筆檔案記錄可能被別處引用),只換 file_id。舊檔留著即可,不做 GC。
現有 deactivate_profile() 是軟刪(is_active=false),語意是「不再出現在下拉,但歷史仍可回溯」。加上硬刪之後兩者並存:
| 動作 | 條件 | 語意 |
|---|---|---|
| 停用 | 隨時可用 | 不再出現在下拉,歷史仍可回溯 |
| 刪除 | 從未被使用過 | 完全消失,當作沒存在過 |
⚠️ 主檔唯一索引是 partial WHERE is_active:
CREATE UNIQUE INDEX uq_dp_tool_tenant_name ... WHERE is_active(停用=讓出名字,CM-1047 才修好的機制。)硬刪不影響這個機制,但不要動到那個索引。
檔案:common/code/detection_tools_error_code.py
現有尾號(協調者於開卡時查過):409 系列最大 DETECTION_TOOLS_409008(DETECTION_TAXONOMY_IN_USE)、400 系列最大 DETECTION_TOOLS_400016(DETECTION_PROFILE_NAME_REQUIRED)。接續編號:
| 常數 | 代碼 | 用途 |
|---|---|---|
DETECTION_PROFILE_VERSION_IN_USE |
DETECTION_TOOLS_409009 |
版本已被使用過,不可改來源/刪除 |
DETECTION_PROFILE_IN_USE |
DETECTION_TOOLS_409010 |
主檔底下有版本被使用過,不可刪除 |
DETECTION_PROFILE_CURRENT_VERSION_UNDELETABLE |
DETECTION_TOOLS_409011 |
不可單獨刪除當前版本 |
⚠️ 開工時請重新確認尾號沒被別的 session 佔用(同期有其他卡在動同一個檔)。若已被佔用就往後遞補,並在完成回報中註明實際採用的號碼。 (截至 CM-1082 完成(commit 17e977eb)時尾號未變 —— .1 沒有新增任何 error code,上表三個號碼仍可照用。)
🔴 BE 新增 error code 必須同步 FE 的 i18n 對照檔,否則前端顯示原始碼字串。三個語系檔都要補:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/config/locales/i18n/zh-tw/error-code.json
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/config/locales/i18n/zh-cn/error-code.json
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/config/locales/i18n/en/error-code.json
(DETECTION_TOOLS_409008 / 400016 在這三個檔裡的既有寫法可直接照抄格式。zh-cn 若整批缺項不要單補,只加本卡三條。)這件事屬本卡(BE 側 error code 誰定義誰補 i18n),不要留給 .3。
就地修正 url 型來源之後,控制項必須重抽,否則明細頁還是舊的內容。
既有端點:
POST /api/1.0/detection-tool-profile-versions/<uid>/extraction 手動重抽
GET /api/1.0/detection-tool-profile-versions/<uid>/extraction 狀態輪詢
抽取狀態機在 config.detection_profile_versions.extraction_status:pending / running / succeeded / failed。
⚠️ 母卡原本寫「已抽取(succeeded)狀態沒有重抽入口,本卡要順便補」——這條已被 CM-1075 部分處理:ControlExtractionSummary.vue 現在在「來源已過期」時會顯示「重新解析」按鈕。但一般 succeeded 態(未過期)仍沒有入口。
本卡要確認的是:改完來源後 extraction_status 如何流轉(應回落 pending 並排程重抽),以及 GET .../extraction 輪詢得到正確狀態。FE 呈現屬 .3,本卡只要保證後端狀態正確且可輪詢。
⚠️ 排程時機注意既有慣例:DetectionProfileExtractionService.schedule() 刻意不加 @transaction(它不碰 DB,只捕 context 後開執行緒),必須在 app service 的 @transaction commit 之後才呼叫,否則背景執行緒讀不到還沒 commit 的那一列。route 層已有 _schedule_extraction() / _schedule_extraction_if_unextracted() 兩支 helper,照既有 pattern 用。
app/detection_tools/service/detection_profile_service.py
create_new_version / update_profile / deactivate_profile / _guard_system_writable
_require_profile / _resolve_source / _store_file / _backfill_sha256
app/detection_tools/service/detection_profile_extraction_service.py
schedule / retry / get_status / list_controls / _check_source_freshness
api/detection_tools/routes/detection_profile_route.py
現有 Resource(DetailRoute 的 PUT =編輯基本資料不升版,可作為新端點的形狀參考)
api/detection_tools/__init__.py 路由註冊(⚠️ 註冊順序有意義,見檔內註解)
infra/detection_tools/model/detection_profile.py current_version_id(FK ON DELETE SET NULL)
infra/detection_tools/model/detection_profile_version.py
infra/detection_tools/model/detection_profile_control.py
common/code/detection_tools_error_code.py error code
scripts/sql/2026-08-03-fr060-1-detection-profile-split.sql CASCADE / partial unique index 定義
app/detection_tools/service/detection_orchestration_service.py
_expand_profile_ref(`profile:<uid>` 展開,理解「被使用」語意用)
⚠️ 註冊路由順序:api/detection_tools/__init__.py 內 /list 與 /menu 必須在 /<string:uid> 之前註冊,否則會被當成 uid 吃掉。新增 DELETE 到既有 DetectionProfileDetailRoute 即可(同一個 Resource 加 method),不必新開路徑。版本相關端點掛在 /detection-tool-profile-versions/<uid> 這一組。
@transaction;只有 infra 層能碰 session/ORM。被其他 @transaction method 呼叫的 helper 不重複加,但 docstring 要標「caller 必須在 @transaction scope 內」。scope='SYSTEM')寫入需平台管理員——沿用既有 _guard_system_writable() pattern,不要另立新 helper。刪除是寫入動作,同樣要過。.1 提供的服務。scripts/sql/ 下加日期註解 + 檔頭 -- Date:,收尾必 INSERT public.schema_migrations,用 psql --single-transaction -v ON_ERROR_STOP=1 以 cmmgr 帳號套。git checkout <branch> / git switch <branch> 一律不執行;發現 branch 不對就停下問。git add <檔名>、禁用 -am;不 push(push 永遠等使用者明示)。BE 與 FE 分開 commit。main_app.py,port 8000;restart 必 kill -9)。BE 出錯先看 log/app.log,不要先問使用者。