# FR-033 — 專案進度稽核事件埋點

| 項目 | 內容 |
|------|------|
| 提出 | 2026-06-04 |
| 類型 | 稽核能力補強（instrumentation）|
| 動機 | 原廠稽核需「誰、做了什麼、何時」的事件時間線；既有只有流程推進/退回有語意 log，任務完成/指派/退回、問卷狀態變更都沒記 |
| 範圍 | BE only（`app/` service 層埋點 + `common/` helper）；不動 FE、不動 schema |
| 相關 | FR-013（批次完成任務）、FR-021（問卷）；既有樣板 `app/flow_engine/service/stage_advance_service.py`（STAGE_ADVANCE）|

---

## 1. 背景：兩套 log 的分工

本專案有兩套 log，這次補的是**語意事件層**：

| | `system_logs`（語意事件層）| `api_logs`（HTTP 層）|
|---|---|---|
| 記什麼 | 業務事件（誰推進流程、誰完成任務）| 每個 HTTP request（method/path/body）|
| 怎麼記 | **選擇性** — code 主動寫 `extra={'event_code': ...}` | **全自動** — middleware 攔所有 request（`common/middleware/app_mw.py`）|
| 誰 | `user_uid` + `user_name`（DBLogHandler 自動從 user context 補）| 同左 |
| 時間 | `act_time` | `act_time` + `duration` |
| 做什麼 | `event_code` + `message`（語意明確）| `url` + `method` + `request` body（要解讀）|
| 查詢 API | ❌ 無，走 SQL（見 runbook）| ✅ 有（`POST /api/1.0/api-logs`）|
| 保留 | 180 天（月分區）| 90 天（月分區）|

### 1.1 寫入機制（jedi-common）

```
logger.info("...", extra={'event_code': N})
   → CustomFormatter.format()    # 自動補 user_uid / user_name（從 get_user_context()）
   → DBLogHandler.emit()         # 組 CreateSystemLogDTO
   → public.system_logs          # 一筆 row
```

`db` handler 掛在頂層 logger（`api` / `app` / `infra` / `domain` / `common` / `middleware`）。
service 用 `logging.getLogger(__name__)`（如 `app.grc.service.*`）是 `app` 的子 logger，
record 會 propagate 上去觸發 DBLogHandler。**「誰 / 時間」全自動，呼叫端不必傳。**

---

## 2. 補了哪些事件（新增 EventCode）

定義在 `common/enum/event_code.py`：

| EventCode | 值 | 埋點位置 | 對應行為 |
|---|---|---|---|
| `JOB_COMPLETED` | 6060 | `JobBatchCompleteService.batch_complete`（每筆成功）| 任務完成 |
| `TASK_ASSIGNED` | 6061 | `TaskAssigneeService.add_task_assignee` + `batch_add_task_assignees` | 指派 / 批次指派 |
| `TASK_REASSIGNED` | 6062 | `TaskAssigneeService.update_task_assignee` | 改派 |
| `TASK_UNASSIGNED` | 6063 | `TaskAssigneeService.delete_task_assignee` | 退回指派 |
| `TASK_SURVEY_STATUS_CHANGED` | 6070 | 問卷 3 個狀態轉換點 + `batch_set_task_survey_status` | 問卷送審 / 通過 / 退回 |

> **任務「退回」分兩種，皆有覆蓋**：流程層退回 = 既有 `STAGE_ADVANCE_REJECTED`(6052)；問卷退回 = `TASK_SURVEY_STATUS_CHANGED` 的 `to_status` 區分。
>
> **問卷狀態轉換的 4 個埋點**：
> - `app/task_survey/service/question_answer_service.py:update_task_survey_answer`（填寫送審路徑）
> - `app/task_survey/service/question_answer_service.py:checkpoint_task_survey_answer`（暫存/提交 checkpoint）
> - `app/task_survey/service/task_survey_service.py:batch_set_task_survey_status`（批次設狀態）
> - `app/task_survey/service/task_survey_service.py:update_task_survey`（REST PUT，status 有變才記）

---

## 3. 統一格式 — `common/util/audit_log.py`

為了可查性，所有埋點走同一個 helper，產出一致的 `[AUDIT:<event_name>] key=value` 格式：

```python
def audit(logger, event_name: str, event_code: int, **fields) -> None:
    parts = " ".join(f"{k}={v}" for k, v in fields.items() if v is not None)
    message = f"[AUDIT:{event_name}] {parts}".rstrip()
    logger.info(message, extra={'event_code': event_code})
```

產出的 `system_logs.message` 範例：

```
[AUDIT:JOB_COMPLETED] job_uid=ab12.. wf_uid=cd34.. job_type=general
[AUDIT:TASK_ASSIGNED] project_id=88 control_id=12 task_uid=ef.. assignee=u-99 is_approver=False
[AUDIT:TASK_ASSIGNED] project_id=88 count=15 user_count=3 control_count=5    ← 批次摘要一行
[AUDIT:TASK_REASSIGNED] control_id=12 task_uid=ef.. assignee=u-99 is_approver=True
[AUDIT:TASK_UNASSIGNED] control_id=12 task_uid=ef.. assignee=u-99
[AUDIT:TASK_SURVEY_STATUS_CHANGED] task_survey_uid=gh.. from_status=1 to_status=2
```

`from_status` / `to_status` 為 task_survey 狀態碼（0=UNFILLED, 1=EDITING, 2=UNDER_REVIEW, 3=ADD_NOTES, 9=COMPLETED）。

---

## 4. 設計決策

| 決策 | 選擇 | 理由 |
|------|------|------|
| Socket.IO `on_update` 協同編輯是否埋點 | **不埋** | 每次打字 patch 一題，頻率極高會灌爆 180 天分區；有稽核意義的是「狀態轉換」，走 REST/checkpoint 已涵蓋 |
| message 粒度 | 結構化前綴 + 完整 context（含 id）| 才能按 project_id / task_uid 精準定位；沿用既有 STAGE_ADVANCE pattern |
| 是否做 system_logs 查詢 API | **本期不做** | 先補埋點 + SQL runbook；查詢 API 留後續 feature |
| `delete_task_assignee` 是否加 `curr_user` 參數 | **不加** | 「誰」由 DBLogHandler 自動補，不需改 signature |
| 批次指派記法 | **一行摘要**（count/user_count/control_count）| 避免單次批次產生數十筆 log；個別指派可由 task_assignees 表查 |
| 問卷 `batch_set_task_survey_status` 偵測 | per-item 獨立 `changed` 變數 | 既有 `survey_status_change` 一旦 True 不重置（既有小瑕疵），audit 用獨立旗標避免誤報未變更的項目 |

### 4.1 不在本期 scope

- system_logs 查詢 API（給 UI 看時間線）
- Socket.IO 逐字 patch 埋點
- 補既有缺的歷史表（問卷狀態歷史表等結構性改動）
- `api_mw.py` 既有兩個 bug 修補（teardown 漏 update / before_request 無保護）為同 session 另一獨立修補，不屬本 FR

---

## 5. 測試

`test/test_audit_log.py`（helper，3 測試）+ `test/test_audit_event_instrumentation.py`（埋點，11 測試）。

埋點測試以 `.__wrapped__` 繞過 `@transaction`、純 MagicMock 注入依賴、`caplog` 斷言
稽核 log 確實 emit（event_code + 關鍵 id）。涵蓋「狀態未變不記」的負向案例。

---

## 6. 如何查

見同資料夾 [`audit-log-query-runbook.md`](./audit-log-query-runbook.md)。
