# 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` 提供的介面」一節），不必再等。

---

## 問題

檢測基準（detection profile）目前**只能新增、不能改也不能刪**。

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

但也不能無條件開放修改——**基準版本的 `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 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=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 查詢：`infra/detection_tools/repository/detection_profile_usage_query.py` → `DetectionProfileUsageQuery.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` 那條路徑真的走到），否則等於沒測到守門。

---

## 🔴 決策者已裁定的規則

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

| 狀態 | 可做的事 |
|---|---|
| **從未被使用過** | 就地修正來源（版號不動、重抽控制項）／刪除 |
| **已被使用過** | **一律凍結**，只能建立新版本 |

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

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

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

---

## 已拍板的邊界

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_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**。
   理由：「從未被使用過」已保證沒有稽核軌跡牽連——這正是判準要那麼嚴的原因。

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

---

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

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

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

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

```sql
CREATE UNIQUE INDEX uq_dp_tool_tenant_name ... WHERE is_active
```

（停用＝讓出名字，CM-1047 才修好的機制。）硬刪不影響這個機制，但**不要動到那個索引**。

---

## 新增 error code

檔案：`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>` 這一組。

---

## 作業紀律

- **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=1` 以 `cmmgr` 帳號套。
- **不切 branch**：任何 `git checkout <branch>` / `git switch <branch>` 一律不執行；發現 branch 不對就停下問。
- **commit**：顯式 `git add <檔名>`、**禁用 `-am`**；**不 push**（push 永遠等使用者明示）。BE 與 FE 分開 commit。
- **收尾類動作等使用者明確下令**（spec／SUMMARY／Notion 母卡回寫）。本卡子卡狀態可在交接時回寫「修正待驗證」。
- **改 service code 後要重啟 BE**（`main_app.py`，port 8000；restart 必 `kill -9`）。BE 出錯先看 `log/app.log`，不要先問使用者。
- **卡住兩次就停下回報**，不要自己硬闖。

---

## 完成定義

- [ ] `PATCH /api/1.0/detection-tool-profile-versions/<uid>/source` 可用：未使用過的版本能改來源（file 型換 `file_id`、url 型換 `url`），**`version` 欄位不變**
- [ ] 改來源後 `extraction_status` 回落 `pending` 並自動排程重抽；`GET .../extraction` 輪詢得到 `running` → `succeeded`（或 `failed`）
- [ ] `DELETE /api/1.0/detection-tool-profile-versions/<uid>` 可用：未使用過且非 current 的版本能刪，連帶控制項一併消失
- [ ] 刪 current_version 被擋，回 409 `DETECTION_TOOLS_409011`
- [ ] `DELETE /api/1.0/detection-tool-profiles/<uid>` 可用：底下所有版本都未使用過時能刪，版本與控制項由 CASCADE 帶走
- [ ] 已被使用過的版本／主檔，三個寫入端點都回 409（`409009` / `409010`），**且沒有任何「強制執行」參數或旗標可以繞過**
- [ ] 三支端點都過權限檢查：公版由非平台管理員操作被擋
- [ ] 三個 error code 已加進 `common/code/detection_tools_error_code.py`，且 FE 三個語系的 `error-code.json` 都已同步（前端顯示中文而非原始碼字串）
- [ ] 既有 `deactivate_profile()` 行為零變化；partial unique index `uq_dp_tool_tenant_name` 未被更動
- [ ] 🔴 **具體驗收**：DEV 上的「SSRF」基準（v1／v2 皆 `failed`、引用 0 次）可以被完整刪乾淨——刪完 `config.detection_profiles` / `detection_profile_versions` / `detection_profile_controls` 三張表都查不到相關列，且列表頁不再出現
- [ ] BE 端 pytest 通過（既有測試零回歸）
- [ ] 409 守門測試**不是假性通過**：測試中 `usage_query` 有實際注入（未被 mock 成 `None`），`in_use=true` 那條路徑確實走到（避開 `_build_usage()` 的 `usage_query is None` fallback）
- [ ] BE／FE 各自 commit（顯式 `git add`，未 push）
