项目用事件溯源 + CQRS,订单、排班、服务需求各有一个投影监听器。上一篇写了投影挂了怎么重试,这篇聊聊投影在正常工作的时候,顺手干的一件大事------审计日志。
提前声明:这是我自己项目的实践,不代表最佳实践,仅供参考。
手工记日志,很快就放弃了
一开始的想法很朴素:每次改订单,顺手写一条日志。
rust
// 伪代码:千万别这么干
fn update_order(order_id: &str, new_amount: i64) {
let old = get_order(order_id);
db.execute("UPDATE orders SET amount = ? WHERE id = ?", new_amount, order_id);
db.execute(
"INSERT INTO logs (order_id, action, before, after) VALUES (?, ?, ?, ?)",
order_id, "update_amount", old.amount, new_amount,
);
}
看着还行对吧?但订单有六个操作------创建、编辑、状态变更、取消、派工、结算。每个操作散在不同的 service 里,六个地方都要记。
最要命的是会漏。哪天加了一个新操作忘了写日志,那一次变更就丢进黑洞了。日志是副作用,不影响主流程,code review 也很难发现。
搞了两天我就把手工日志全删了。项目用了事件溯源,我想:能不能让基础设施自动记?
事件溯源天然适合做审计
事件溯源不直接改数据库状态,而是记录"发生了什么"(Domain Event),状态是事件算出来的。
项目里订单有六个事件类型:
rust
// backend/src/domain/crm/order/mod.rs
pub enum OrderEventEnvelope {
OrderCreated { /* 创建时的所有字段 */ },
OrderDetailsUpdated { /* 变更的字段 */ },
OrderStatusChanged { /* 新状态 */ },
OrderCancelled { /* 取消原因 */ },
OrderAssignmentUpdated { /* 派工变更 */ },
OrderSettlementUpdated { /* 结算变更 */ },
}
每个业务操作都会产生一个事件。事件本身就是审计线索------谁在什么时候做了什么。我只需要在投影层消费事件的时候,顺手把变更前后的状态拍个快照存下来。
投影层怎么自动记的
核心是 OrderProjection,一个 EventListener,每 250ms 轮询事件表。拿到新事件后,处理流程固定四步:
- 从 orders 表读出当前行,调
snapshot_order_model序列化成 JSON(before) - 把事件里的变更应用到 SeaORM ActiveModel
- 更新 orders 表,再序列化一次(after)
- before/after 双 JSON 写进
order_change_logs表
看下 OrderDetailsUpdated 的处理代码:
rust
// backend/src/infrastructure/projections/crm/order_projection.rs
OrderEventEnvelope::OrderDetailsUpdated {
merchant_id, order_uuid, operator_uuid,
customer_uuid, amount_cents, notes, updated_at,
} => {
// 查出当前订单
let Some(model) = orders::Entity::find()
.filter(orders::Column::Uuid.eq(order_uuid))
.one(txn).await?
else { return Ok(()); };
// 拍变更前快照
let before = snapshot_order_model(&model);
// 应用变更
let mut active = model.into_active_model();
active.customer_uuid = Set(customer_uuid);
active.amount_cents = Set(amount_cents);
active.notes = Set(notes);
active.updated_at = Set(updated_at);
active.event_id = Set(event_id);
// 更新读模型
let updated = active.update(txn).await?;
// 拍变更后快照,双快照写入日志
insert_change_log(
txn, merchant_uuid, updated.uuid,
"details_updated",
operator_uuid,
Some(before),
Some(snapshot_order_model(&updated)),
updated_at,
).await?;
}
六个事件类型的处理结构一样,区别只是各自应用的字段不同。OrderCreated 特殊些------没有 before,只存 after,订单刚出生嘛。
snapshot_order_model 把整行订单序列化成 JSON,17 个字段全量拍:
rust
// backend/src/infrastructure/projections/crm/order_projection.rs
fn snapshot_order_model(model: &orders::Model) -> Value {
serde_json::json!({
"uuid": model.uuid.to_string(),
"customer_uuid": model.customer_uuid.map(|v| v.to_string()),
"status": model.status,
"amount_cents": model.amount_cents,
"paid_amount_cents": model.paid_amount_cents,
"payment_method": model.payment_method,
"notes": model.notes,
"dispatch_note": model.dispatch_note,
"settlement_status": model.settlement_status,
"settlement_note": model.settlement_note,
"scheduled_start_at": model.scheduled_start_at.map(|v| v.to_rfc3339()),
"scheduled_end_at": model.scheduled_end_at.map(|v| v.to_rfc3339()),
// ... 还有 completed_at, paid_at, inserted_at, updated_at, event_id 等
})
}
为什么全量拍而不是只记变更字段?排查问题的时候,你永远不知道哪个字段会是关键线索。全量快照多占一点存储,但查起来零死角。
insert_change_log 就一条 INSERT:
rust
async fn insert_change_log<C>(txn: &C, merchant_uuid: Uuid, order_uuid: Uuid,
action: &str, operator_uuid: Option<Uuid>,
before_data: Option<Value>, after_data: Option<Value>,
created_at: DateTime<Utc>,
) -> Result<(), String>
where C: sea_orm::ConnectionTrait,
{
let active = order_change_logs::ActiveModel {
uuid: Set(Uuid::new_v4()),
merchant_id: Set(Some(merchant_uuid)),
order_uuid: Set(order_uuid),
action: Set(action.to_string()),
operator_uuid: Set(operator_uuid),
before_data: Set(before_data.map(Json::from)),
after_data: Set(after_data.map(Json::from)),
created_at: Set(created_at),
};
active.insert(txn).await?;
Ok(())
}
整条链路下来,业务代码只管发事件。投影层自动消费、更新读模型、拍快照------审计是副产品,不用额外操心。
前端展示:字段级 diff
存了得能看。订单详情页有个变更记录区域,前端拉到数据后做字段级 diff------只展示变化的字段,没变的不显示。
rust
// app/src/pages/orders.rs
fn order_log_diff_items(item: &OrderChangeLogDto) -> Vec<(String, String, String)> {
let before = item.before_data.as_ref().and_then(|v| v.as_object());
let after = item.after_data.as_ref().and_then(|v| v.as_object());
let keys = [
"customer_uuid", "status", "amount_cents", "payment_method",
"notes", "dispatch_note", "settlement_note",
"scheduled_start_at", "scheduled_end_at", /* ... */
];
let mut result = vec![];
for key in keys {
let before_val = before.and_then(|m| m.get(key));
let after_val = after.and_then(|m| m.get(key));
if before_val == after_val { continue; }
result.push((
field_label(key), // 字段中文名
format_value(before_val), // 变更前
format_value(after_val), // 变更后
));
}
result
}
渲染出来三列:字段名 | 变更前 | 变更后 。操作类型也做了中文映射,"created" → "创建订单"、"status_changed" → "状态变更"。创建操作的 before 是空的,只展示 after。
实际体验
开发期间我自己测试的时候,改了几次订单状态做功能验证。回头翻变更记录,每一步清清楚楚------什么时候创建、改了金额、派了工、点了完成。比翻数据库或者 grep 日志直观太多。
真跑起来以后,如果有人问"这个订单金额怎么变了",不用翻数据库,打开页面切到变更记录,谁在什么时间改了哪个字段、从什么值改到什么值,一行一行看过去就行。
总结
审计日志这件事,我的体会就一条:手工记日志是反人性的,让基础设施替你记。
事件溯源天然适配这个场景------每笔操作都有事件,投影层消费事件时顺手拍快照,业务代码完全不用管。
当然有取舍:全量 JSON 比增量记录占存储;投影是异步的,极端高频变更可能有延迟。但对于这个项目的业务量来说,完全够用。
你们订单的变更记录是怎么做的?手工记、AOP 切面、还是也用的事件溯源?评论区聊聊。
上一篇写了投影重试的三层容错方案,下一篇打算写写 Rust 全栈 SSR 的 Leptos 体验。