每次改订单,我都存了快照

项目用事件溯源 + 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 轮询事件表。拿到新事件后,处理流程固定四步:

  1. 从 orders 表读出当前行,调 snapshot_order_model 序列化成 JSON(before)
  2. 把事件里的变更应用到 SeaORM ActiveModel
  3. 更新 orders 表,再序列化一次(after)
  4. 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 体验。

相关推荐
传说之后1 小时前
Go Context 完全指南:树状级联、超时控制、值传递与最佳实践
后端·go
一个骇客1 小时前
还在写 Python 脚本?试试用 Unix 命令分析莎士比亚
后端
亦暖筑序1 小时前
Vibe Coding 用久了,代码手感真的会退化——以及我怎么试图解决这个问题
程序员·开源·github
太阳之子1 小时前
开源推荐:一个专为 AI Agent 设计的求职自动化工具
开源
XovH1 小时前
Django 实战:从零开发一个完整的博客系统(附带文章、分类、标签)
后端
XovH1 小时前
Django 表单(Forms)与数据验证:处理用户提交与防止常见攻击
后端
fliter1 小时前
从 C 的混乱到 Rust 的优雅:字符串处理为什么这么难
后端
jieyucx1 小时前
Go 语言进阶:结构体指针、new 关键字与匿名结构体/成员详解
开发语言·后端·golang·结构体
IT大家说1 小时前
那些没人主动教你的代码小技巧,写完代码干净又优雅
后端