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_CHANGEDto_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 格式:

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