| 項目 | 內容 |
|---|---|
| 提出 | 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) |
本專案有兩套 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 天(月分區) |
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。「誰 / 時間」全自動,呼叫端不必傳。
定義在 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 有變才記)
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)。
| 決策 | 選擇 | 理由 |
|---|---|---|
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 用獨立旗標避免誤報未變更的項目 |
api_mw.py 既有兩個 bug 修補(teardown 漏 update / before_request 無保護)為同 session 另一獨立修補,不屬本 FRtest/test_audit_log.py(helper,3 測試)+ test/test_audit_event_instrumentation.py(埋點,11 測試)。
埋點測試以 .__wrapped__ 繞過 @transaction、純 MagicMock 注入依賴、caplog 斷言 稽核 log 確實 emit(event_code + 關鍵 id)。涵蓋「狀態未變不記」的負向案例。
見同資料夾 audit-log-query-runbook.md。