CM-1077.2 修正來源 + 刪除(BE 寫入端)

Repo:BE(含 FE 的 error code i18n) | 前置:CM-1077.1 使用判定服務 ✅ 已完成 | 母卡:CM-1077

前置卡狀態:CM-1082(.1)已落地 —— BE commit 17e977eb,8 檔 +540 行,11 條測試通過,Notion 狀態 修正待驗證。本卡可直接接上其介面(見下方「前置卡 .1 提供的介面」一節),不必再等。


§1

問題

檢測基準(detection profile)目前只能新增、不能改也不能刪

  • 上傳時打錯 URL、指到錯的檔案、選錯工具 → 只能再開一版,錯的那版永遠留在版本歷史裡。
  • 整支基準建錯(例如測試時亂建的、名字打錯的)→ 只能「停用」(is_active=false),資料還在,列表看不到但 DB 裡一直堆著。
  • DEV 上就有實例:「SSRF」這支基準的 v1/v2 抽取都 failed、從來沒被任何派工引用過,卻刪不掉。

但也不能無條件開放修改——基準版本的 uid 是派工契約(下發格式 profile:<版本 uid>),sha256 是 agent 端對帳的信任根。已經被掃描用過的版本如果內容被換掉,之後回頭看那次掃描報告,沒有人能確定當時到底掃了什麼。

本卡要做的就是:在「客觀上從未被使用過」的前提下,開放就地修正與硬刪除;一旦用過就凍結。


§2

本卡範圍(四個動作)

動作 端點 前置條件
版本改來源 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,本卡只負責後端行為正確且可輪詢。


§3

前置卡 .1 提供的介面(已落地,直接用)

CM-1082(.1)已完成(commit 17e977eb),以下是實際落地的介面,本卡直接呼叫即可。

App service 兩支

檔案: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=trueprojects 為空的合法狀態,本卡的 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 查詢:infra/detection_tools/repository/detection_profile_usage_query.pyDetectionProfileUsageQuery.find_refs()(三來源 UNION ALL)
  • DI:已在 di_containers/detection_tools/detection_tools_containers.py wiring 完成
  • domain 側新增 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 那條路徑真的走到),否則等於沒測到守門。


§4

🔴 決策者已裁定的規則

判準是客觀的「有沒有被使用過」,不是問使用者意圖。

狀態 可做的事
從未被使用過 就地修正來源(版號不動、重抽控制項)/刪除
已被使用過 一律凍結,只能建立新版本

🔴 已被使用的版本不提供「確定要更新嗎」這種硬改選項。

決策者原話:「萬一有被掃過,下次掃同版本結果不一樣,也很奇怪」。uid 是凍結契約、sha256 是對帳基準,同一個版本 uid 在不同時間指向不同內容,之後回頭看某次掃描報告,沒有人能確定當時到底掃了什麼。在 GRC 系統裡這是稽核可信度問題,不是 UX 取捨,所以不能交給使用者當次決定。

被排除的方案(不要回頭選):跳確認框問「確定要更新嗎?會影響既有資料」讓使用者自行決定——已被決策者明確否決。


§5

已拍板的邊界

  1. 刪掉 current_version → 擋下,回 DETECTION_PROFILE_CURRENT_VERSION_UNDELETABLE,要求使用者先把 current 指標改指其他版本,或整支主檔刪掉。 理由:current 指標懸空會讓派工拿不到東西。 (DB 層 detection_profiles.current_version_id 的 FK 是 ON DELETE SET NULL——不要靠它,那是防資料損毀的最後一道,不是業務規則;業務層要主動擋。)

  2. 主檔刪除是硬刪(含底下所有版本與控制項)。 config.detection_profile_versions.profile_iddetection_profiles(id) 已是 ON DELETE CASCADEdetection_profile_controls.version_iddetection_profile_versions(id) 也是 ON DELETE CASCADE(見 scripts/sql/2026-08-03-fr060-1-detection-profile-split.sql),所以刪主檔一路帶走,不需要新的 migration。 理由:「從未被使用過」已保證沒有稽核軌跡牽連——這正是判準要那麼嚴的原因。

  3. file 型改來源不清掉舊的 upload_files(那筆檔案記錄可能被別處引用),只換 file_id。舊檔留著即可,不做 GC。


§6

刪除與現有「停用」怎麼共存

現有 deactivate_profile() 是軟刪(is_active=false),語意是「不再出現在下拉,但歷史仍可回溯」。加上硬刪之後兩者並存:

動作 條件 語意
停用 隨時可用 不再出現在下拉,歷史仍可回溯
刪除 從未被使用過 完全消失,當作沒存在過

⚠️ 主檔唯一索引是 partial WHERE is_active

CREATE UNIQUE INDEX uq_dp_tool_tenant_name ... WHERE is_active

(停用=讓出名字,CM-1047 才修好的機制。)硬刪不影響這個機制,但不要動到那個索引


§7

新增 error code

檔案:common/code/detection_tools_error_code.py

現有尾號(協調者於開卡時查過):409 系列最大 DETECTION_TOOLS_409008DETECTION_TAXONOMY_IN_USE)、400 系列最大 DETECTION_TOOLS_400016DETECTION_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


§8

改完要能重抽

就地修正 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_statuspending / 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 用。


§9

相關程式碼座標

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> 這一組。


§10

作業紀律

  • DDD 分層:route 不做 DB 查詢、不 import ORM model,只負責解 request → 呼叫 app service → 序列化 response;app service 每個 public method 掛 @transaction;只有 infra 層能碰 session/ORM。被其他 @transaction method 呼叫的 helper 不重複加,但 docstring 要標「caller 必須在 @transaction scope 內」。
  • 權限檢查:寫入 API 必須有權限檢查。公版(scope='SYSTEM')寫入需平台管理員——沿用既有 _guard_system_writable() pattern,不要另立新 helper。刪除是寫入動作,同樣要過。
  • 不重複造輪子:寫任何新 method 之前先 grep 既有能力(按行為 grep,不是按你想取的名字)。使用判定一律走 .1 提供的服務。
  • 🔴 只動 DEV。STG/POC 一律等決策者當次明示(環境異動鐵律)。
  • 若需要 SQL migration(本卡預期不需要,CASCADE 已就位):scripts/sql/ 下加日期註解 + 檔頭 -- Date:,收尾必 INSERT public.schema_migrations,用 psql --single-transaction -v ON_ERROR_STOP=1cmmgr 帳號套。
  • 不切 branch:任何 git checkout <branch> / git switch <branch> 一律不執行;發現 branch 不對就停下問。
  • commit:顯式 git add <檔名>禁用 -am不 push(push 永遠等使用者明示)。BE 與 FE 分開 commit。
  • 收尾類動作等使用者明確下令(spec/SUMMARY/Notion 母卡回寫)。本卡子卡狀態可在交接時回寫「修正待驗證」。
  • 改 service code 後要重啟 BEmain_app.py,port 8000;restart 必 kill -9)。BE 出錯先看 log/app.log,不要先問使用者。
  • 卡住兩次就停下回報,不要自己硬闖。

§11

完成定義