iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 25

Day 25:稽核軌跡與異動的前後值

  • 分享至 

  • xImage
  •  

Day 25:稽核軌跡與異動的前後值

有人來問「這一筆的金額上個月是誰改的」,多半已經是幾個月以後的事。問的人手上沒有當時的畫面、沒有當時的程式版本,只有當時留下來的那幾列記錄。誰登入、誰把哪一個欄位從什麼改成什麼、誰看了哪一筆,稽核這一層的每一個決定都得先假設這個場景,包括那些看起來只是省空間的決定。

本篇說明:

  1. 為什麼名字也要抄一份,不是存個代號再 join 回去
  2. 一個欄位就把登入的結果編完,而失敗原因為什麼要跟給使用者看的那一句分開
  3. 檢視為什麼只記讀單筆,也是三張裡唯一預設關著的
  4. 異動一次存檔記一列,而它為什麼排在 transaction 外面、寫失敗了怎麼辦
  5. 改了什麼記成一段 DiffGram,而擷取它的位置只有一個

一、三張表及共通欄

把每一次呼叫都記下來是走不通的,讀取遠多於寫入,量體會直接爆炸。稽核要回答的問題收成三個,一個問題一張表:

要回答的問題 落在哪裡
誰登入了,結果是什麼 st_log_login
誰看了哪一筆 st_log_access
誰對哪一筆做了什麼,哪些欄位從什麼改成什麼 st_log_change

三張表住在 log 這個資料庫分類,整體預設是關的,三張各有一個子開關。而不管是哪一張,每一列都先鋪同一組共通欄。

要記下「誰做的」,直覺的作法是存一個使用者的列識別,顯示名字的時候再 join 回去。

這裡走不通,而且不是效能問題。log 是一個獨立的資料庫分類(Day 11 談過記錄可以自己占一個資料庫),它與 commoncompany 實體分離,可以被搬到另一台機器、甚至依年份分成好幾個庫。跨庫的 join 不是比較慢,是不存在。一個必須 join 才有意義的欄位,在這裡就是一個沒有意義的欄位。

所以每一組身分都存兩欄,識別碼一欄、顯示名稱一欄(節選自 AuditEntry.cs):

columns.Add(new AuditColumn("user_id", UserId));
columns.Add(new AuditColumn("user_name", UserName));
columns.Add(new AuditColumn("company_id", CompanyId));
columns.Add(new AuditColumn("company_name", CompanyName));
// access_token / trace_id / client_ip / source ...
columns.Add(new AuditColumn("api_key_id", ApiKeyId));
columns.Add(new AuditColumn("api_key_name", ApiKeyName));

這份資料不正規化,而且刻意不正規化。使用者改了名字、公司改了抬頭,舊列留著的是當時那個名字,跟現在的 st_user 對不起來。這正是稽核要的:一份紀錄要說的是那一刻的狀況,不是今天的狀況。同一個取捨在別的地方會是缺陷,在這裡是規格。

金鑰那一組裡,識別碼不是祕密,所以它進得了稽核列;祕密則是框架發放之後自己手上都沒有明文,所以它進不去。這一組欄位最有用的地方在登入那一張表:一串登入失敗集中在同一個應用,跟同樣一串分散在好幾個應用,讀起來是兩件事。


二、登入記錄

三張裡最單純的一張,主開關打開就跟著開。它比共通欄多的是一個事件值,加一句失敗原因。

事件值自己就帶著結果,不另外開一個成功與否的欄。登入成功、登入失敗、帳號被鎖、登出,再加上信任的近端呼叫替某個使用者開出來的 session,五個值各是一種結果。少了那一欄,「事件值跟結果欄對不起來」這種情況就不會發生。

失敗原因那一欄跟登入畫面上顯示的那一句管的不是同一件事。畫面上的訊息一律簡要,不會告訴使用者是「帳號不存在」還是「密碼錯了」,這是通例:只要分得出來,登入這一支就成了試帳號存不存在的工具。稽核這一欄沒有這個顧慮,它的讀者是事後查帳的人,記得越具體越有用。


三、檢視記錄

調出一筆資料來看,這件事本身也要留下記錄。誰在什麼時候調出了哪一位客戶的完整資料,在合規的問法裡跟誰改了它一樣重要。Day 11 那張分類表把 log 寫成「操作、登入與異動的記錄」,「操作」指的就是這一軸。

它比共通欄多的是哪一支表單,加那一筆的鍵。調出單筆明細本來就是把整筆載進來,所以「誰」加「哪一筆」就是一列記錄的單位。

三張裡只有它預設是關的,因為它的量體跟著查詢次數走。部署層那個開關可以全開,逐張表單也可以單獨開,客戶主檔值得記,代碼檔不值得。

寫下這一列的是讀單筆那一支,清單查詢與開窗選取不留記錄。


四、異動記錄

表單存檔一次,稽核就記一列。那一列寫的是誰、什麼時候、哪一支表單的哪一筆,做的是新增修改還是刪除,另外帶一個敏感旗標。

同一列還裝著異動明細:改了哪些欄位、每一欄從什麼變成什麼,全部收在同一個欄位裡,下一節談那一欄怎麼來的。

新增、修改還是刪除,看主檔那一列的狀態。只改了明細而主檔一個字沒動,仍然算修改,因為那一整筆記錄確實變了。

主開關關著就整套不記。打開之後,異動這一軸跟登入一樣預設是開的,而逐張表單的規則可以改掉這個預設:異動與檢視各有一個三態值,跟著部署層的預設走、強制記、強制不記。

寫入排在 transaction 外面

稽核那一列寫在 transaction 外面,存檔成功了就是成功了,它沒寫進去也不會把存檔拉下來:框架記一筆 error,業務照常結束。

Day 14 把這個決定跟它的對照擺在一起講過:主檔與明細必須同 transaction,變更稽核接受非原子。兩個決定相反,因為壞掉的樣子不一樣:明細漏掉是留下一張不齊的單而且不報錯,稽核漏掉是少一列記錄、資料本身是對的。

不擋業務不等於可以隨便掉,每一種寫不進去的情況都有一條路:

情況 行為
一般 進一條有上限的記憶體佇列,背景批次寫進 log 資料庫
佇列滿了 當場改成同步寫,不丟
log 資料庫寫不進去 設了退路檔案就落地成檔,沒設就只剩那一筆 error

一條「不能掉」的佇列,滿載時的正確行為不是等,是換一條路。

還有一件事只有這一層會遇到:稽核寫入本身就是一次資料庫存取,而慢的資料庫存取框架會另外記一筆,那一筆又是一次稽核寫入。解法是打 log 資料庫的時候不做這個偵測,循環就斷在那裡。

橫切所有層的機制,最容易漏掉的不是誰沒被蓋到,是它蓋到了自己。


五、異動明細

「記下改了什麼」一般的作法是逐欄比對,一個欄位一列。它的欄位級查詢力最強,某一欄在過去一年被誰改過幾次,一句 SQL 就答得出來;代價是列數等於異動次數乘上欄數,比對邏輯還要自己寫一套,而那份查詢力在多數「這張單改了什麼」的問法上用不到。

框架改用 DataSet 本來就有的東西:GetChanges() 拿到變更集,WriteXml 以 DiffGram 模式寫成一段同時帶新值與舊值的 XML,前面再附上當時的欄位結構,包在同一個外層元素裡。一次存檔寫一列,主檔與所有明細的新舊值都在那一欄裡面,零自訂比對程式。

把一張訂單的狀態與運費改掉,那一欄存下來的就是這個形狀:

<AuditChanges>
  <xs:schema id="form" ...>
    ...  sys_id / status / freight 各自的型別  ...
  </xs:schema>
  <diffgr:diffgram ...>
    <form>
      <Order diffgr:id="Order1" diffgr:hasChanges="modified">
        <status>Confirmed</status>
        <freight>41.90</freight>
      </Order>
    </form>
    <diffgr:before>
      <Order diffgr:id="Order1">
        <status>Draft</status>
        <freight>32.38</freight>
      </Order>
    </diffgr:before>
  </diffgr:diffgram>
</AuditChanges>

擷取與寫入則分別落在存檔的兩邊:存檔前取得變更集,存檔之後才寫進軌跡。這個位置被 DoBeforeSaveDoSave 兩側同時釘住,Day 14 講過為什麼;這裡看的是它在程式碼裡長什麼樣(節選自 FormBusinessObject.Write.cs):

bool auditChange = ChangeAuditEnabled();
using var changes = auditChange ? args.DataSet.GetChanges() : null;
var (rowKey, changeKind) = auditChange
    ? ExtractMasterChange(args.DataSet, masterTableName) : (null, ChangeKind.Update);

DoSave(context);

if (auditChange && changes != null)
    WriteChangeAudit(changeKind, rowKey, AuditDiffGram.Serialize(changes), masterTableName, ...);

那個布林值也是成本控制:關掉的時候一次 GetChanges 都不做。

位置排錯不會報錯,只會少東西。移到 DoSave 後面,資料庫寫成功之後列狀態與原值一起被清掉,GetChanges() 回來的是 null,那一次存檔在軌跡上完全不存在;移到 DoBeforeSave 前面,記下的是計算欄與規則還沒填之前的值。刪除那一格拿不到變更集,作法是先載一份刪除前的快照、把每一列標成刪除,再走同一支 GetChanges,拿到的就是那一筆被刪掉之前的完整內容。

讀回來那一側就靠那份結構:先把它讀出來,再把 DiffGram 讀進去,得到一個真正的 DataSet,改了哪些欄位由比對原值與現值得出。結構是那一列自己帶的,不是今天的定義,所以三年後那張表多兩欄、少一欄、改了型別,三年前那一列軌跡讀回來還是當時的樣子。早期寫下的那些沒有這一段結構,框架靠最外層那個元素分辨新舊、各走各的解法,一列舊資料都不必搬。


回到 Northwind

案例把稽核打開,登入與異動跟著開、檢視留在關,背景寫入改成同步直寫,這樣才示範得出來。規則那張表放了三列:訂單與客戶把檢視強制打開,分類把異動強制關掉。

改一筆客戶的聯絡人與城市,st_log_change 就多一列:

user_id      = demo          company_id   = NORTHWIND
user_name    = Demo User     company_name = Northwind Traders
source       = Customer.Save prog_id      = Customer
change_kind  = 2             is_sensitive = 1
row_key      = ALFKI 那一列的 sys_rowid
api_key_id   = (空)          api_key_name = (空)

裝改動內容的那一欄裡,contact_namecity 各有改前與改後兩個值。is_sensitive1 來自客戶那一列規則。

強制關掉與強制打開各驗過一次:改一筆分類,資料進去了而軌跡一列都沒有多;讀單筆那一軸相反,部署層是關的,而訂單與客戶各讀一次就各留下一列。同一份部署、同一段程式碼,記與不記的差別全在那三列上。

金鑰那兩欄是空的,而空得有道理:案例一把金鑰都還沒發,驗證器根本沒去查金鑰,識別碼無從記起。一個沒有生效的機制,在稽核上的痕跡不是零列,是每一列都少兩欄。

軌跡留下來了,而案例自己讀不到它。稽核查詢那幾支宣告的是 Encrypted,拿 Day 23 那種明文請求打過去,回的是 This API requires encoded or encrypted transmission.;改走會做金鑰交換的路徑再打一次,過了那一關,回來的是 Not authorized to read the audit log.

原因是案例一筆權限模型都沒有。稽核讀取這道判定不掛在 FormSchema 上,它直接拿保留的 ProgId 去問授權服務,所以昨天那個「表單沒宣告就整套跳過」的開口在這裡不存在。寫入掛在一個布林開關上,讀取掛在一套權限模型上,而只有寫入那一側打開的部署,恰好最需要有人提醒它把讀取那一側接上去。


小結

稽核軌跡在被寫下的當天完全沒有讀者,而且只會一直長下去,長到得搬去另一台機器、依年份切成好幾個庫。這正是身分那幾欄要連名字一起抄的理由:到那個時候,join 不是慢不慢的問題,是根本沒有那條路。

所以這一篇的每一個決定,驗收日期都不是今天。記哪些事,決定的是那一天問得出什麼;每一列自己說得完整,決定的是那一天讀不讀得懂;那一欄自己帶著當時的結構,決定的是資料表改版之後還讀不讀得回;transaction 外面那一筆寫入,決定的是它到底有沒有被寫下來。

這一章的四篇換過四種角度看同一次呼叫:它是一段要被序列化的 bytes、一段要被保護的 bytes、一個要被判定權限的請求、一件要被記下來的事。而四篇的底線是同一條:呼叫端送上來的每一樣東西都是主張,不是事實。

凡是能決定結果的東西,都不能由呼叫端說了算;凡是要被記下來的東西,記的就得是呼叫端真的送了什麼。兩句看起來相反,實際上是同一條分界線的兩側。

明天換一個層面,談這些被存下來、被記下來的值本身:一個數字在系統裡到底代表什麼。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 24:權限的三軸:動作、列與欄
下一篇
Day 26:數值語意、多幣別與計量單位
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言