AI 生成后台改数据后,操作日志别只记按钮:Go + MySQL 怎么验
AI 生成的后台页面能新增、编辑、删除,并不代表上线后能追责。真正出问题时,最麻烦的不是接口返回 200,而是你只知道"有人改过",不知道谁改了哪一行、从什么值改到什么值、当时命中了哪个权限码、请求来自哪个租户或角色。
很多后台系统把操作日志做成按钮旁边的一句 记录日志,或者只在前端路由里记"用户点击了编辑"。这种日志看起来有内容,排障时基本用不上。前端按钮可以绕过,批量接口不会走同一个点击事件,AI 生成代码还常把新增、编辑、删除都塞进同一个通用 handler。最后数据库里只有一行"编辑客户",没有目标 ID,没有前后差异,也没有接口权限。
这篇只讨论一个更窄的问题:Go 后台里,AI 生成 CRUD 之后,操作日志应该怎么验才算能用。重点不是做一个漂亮的日志列表,而是把权限、数据变更和数据库写入放在同一条链路里检查。
先看一个容易漏的表结构
假设有一张客户表,AI 生成 CRUD 时通常会给出类似结构:
sql
CREATE TABLE crm_customer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
name VARCHAR(80) NOT NULL,
phone VARCHAR(32) DEFAULT '',
level VARCHAR(20) DEFAULT 'normal',
owner_user_id BIGINT NOT NULL,
remark VARCHAR(255) DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
KEY idx_tenant_owner (tenant_id, owner_user_id),
KEY idx_tenant_deleted (tenant_id, deleted_at)
);
页面上点"编辑",保存成功,只能证明更新语句执行了。操作日志至少还要回答这些问题:
| 问题 | 不能只靠什么判断 | 必须落到哪里 |
|---|---|---|
| 谁改的 | 前端用户名 | 后端登录态里的 user_id |
| 改了哪条 | 页面当前行 | 请求参数和数据库主键 |
| 改了什么 | "编辑客户"文案 | 变更前后的字段差异 |
| 有没有权限 | 菜单是否可见 | 接口权限码和后端中间件 |
| 属于哪个租户 | 前端筛选条件 | SQL 的 tenant_id 条件 |
这几个字段缺一个,排障都会很难受。尤其是 tenant_id 和 permission_code,它们不该靠前端传进来。
操作日志表不要只存一个 message
一个可查的操作日志表,建议先把检索维度拆出来。JSON 可以放差异,但关键字段不要全塞进 JSON。
sql
CREATE TABLE admin_operation_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
actor_id BIGINT NOT NULL,
actor_name VARCHAR(64) NOT NULL,
permission_code VARCHAR(100) NOT NULL,
module VARCHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL,
target_table VARCHAR(64) NOT NULL,
target_id BIGINT NOT NULL,
before_json JSON NULL,
after_json JSON NULL,
diff_json JSON NULL,
request_id VARCHAR(64) NOT NULL,
ip VARCHAR(64) DEFAULT '',
user_agent VARCHAR(255) DEFAULT '',
result VARCHAR(20) NOT NULL,
error_msg VARCHAR(255) DEFAULT '',
created_at DATETIME NOT NULL,
KEY idx_tenant_target (tenant_id, target_table, target_id),
KEY idx_actor_time (actor_id, created_at),
KEY idx_perm_time (permission_code, created_at),
KEY idx_request_id (request_id)
);
这里有两个细节。
第一,before_json 和 after_json 最好来自数据库读回,不要直接拿前端提交的 body 当 before。前端只知道它看到的字段,后端才知道真实保存了什么。
第二,permission_code 不是装饰字段。它要和后端权限中间件命中的权限码一致。否则日志里写的是 customer:edit,实际接口只校验了登录态,还是没法证明权限链路没漏。
Go 接口里先读旧值,再写新值
下面是一个简化版写法,省掉框架细节,只保留顺序。真正项目里可以放到 service 或 middleware,但不要把"读旧值"和"写日志"完全丢给前端。
go
type CustomerPatch struct {
Name string `json:"name"`
Phone string `json:"phone"`
Level string `json:"level"`
Remark string `json:"remark"`
}
type AuditLog struct {
TenantID int64
ActorID int64
ActorName string
PermissionCode string
Module string
Action string
TargetTable string
TargetID int64
BeforeJSON string
AfterJSON string
DiffJSON string
RequestID string
Result string
ErrorMsg string
}
func UpdateCustomer(ctx context.Context, id int64, req CustomerPatch) error {
tenantID := TenantIDFromCtx(ctx)
actor := ActorFromCtx(ctx)
perm := "crm_customer:update"
oldRow, err := repo.GetCustomerForUpdate(ctx, tenantID, id)
if err != nil {
writeAudit(ctx, AuditLog{
TenantID: tenantID, ActorID: actor.ID, ActorName: actor.Name,
PermissionCode: perm, Module: "crm_customer", Action: "update",
TargetTable: "crm_customer", TargetID: id,
RequestID: RequestIDFromCtx(ctx), Result: "failed", ErrorMsg: err.Error(),
})
return err
}
next := oldRow
next.Name = req.Name
next.Phone = req.Phone
next.Level = req.Level
next.Remark = req.Remark
diff := BuildDiff(oldRow, next, []string{"name", "phone", "level", "remark"})
if len(diff) == 0 {
return nil
}
if err := repo.UpdateCustomer(ctx, tenantID, id, next); err != nil {
writeAudit(ctx, AuditLog{
TenantID: tenantID, ActorID: actor.ID, ActorName: actor.Name,
PermissionCode: perm, Module: "crm_customer", Action: "update",
TargetTable: "crm_customer", TargetID: id,
BeforeJSON: ToJSON(oldRow), DiffJSON: ToJSON(diff),
RequestID: RequestIDFromCtx(ctx), Result: "failed", ErrorMsg: err.Error(),
})
return err
}
writeAudit(ctx, AuditLog{
TenantID: tenantID, ActorID: actor.ID, ActorName: actor.Name,
PermissionCode: perm, Module: "crm_customer", Action: "update",
TargetTable: "crm_customer", TargetID: id,
BeforeJSON: ToJSON(oldRow), AfterJSON: ToJSON(next), DiffJSON: ToJSON(diff),
RequestID: RequestIDFromCtx(ctx), Result: "success",
})
return nil
}
这段代码的重点不是结构体写法,而是顺序:权限上下文先确定,旧值从数据库拿,更新和日志共用同一个 tenant_id、target_id、request_id。如果 AI 只生成了 db.Update(req),没有旧值、没有 diff、没有失败日志,就要补验收项。
权限中间件和操作日志要对得上
后台最常见的假安全是:按钮隐藏了,接口也有鉴权,但日志里看不出当时鉴权的是哪一个权限码。排查时只能翻代码。
验收时可以用三条 curl 覆盖:未登录、无权限、有权限。
bash
# 1. 未登录,应该 401,不能写 success 日志
curl -i -X PATCH 'https://api.example.com/admin/crm/customers/1001' \
-H 'Content-Type: application/json' \
-d '{"level":"vip"}'
# 2. 登录但没有 crm_customer:update,应该 403,日志可记录 failed + permission_code
curl -i -X PATCH 'https://api.example.com/admin/crm/customers/1001' \
-H 'Authorization: Bearer staff_without_perm' \
-H 'Content-Type: application/json' \
-d '{"level":"vip"}'
# 3. 有权限,应该 200,并能查到 diff_json
curl -i -X PATCH 'https://api.example.com/admin/crm/customers/1001' \
-H 'Authorization: Bearer manager_with_perm' \
-H 'Content-Type: application/json' \
-d '{"level":"vip","remark":"signed"}'
对应的 SQL 也要能查出来:
sql
SELECT actor_id, permission_code, target_table, target_id, result, diff_json, request_id
FROM admin_operation_log
WHERE tenant_id = 10
AND target_table = 'crm_customer'
AND target_id = 1001
ORDER BY id DESC
LIMIT 5;
期望结果不是"有一行日志"这么简单,而是能看到类似内容:
json
{
"actor_id": 23,
"permission_code": "crm_customer:update",
"target_table": "crm_customer",
"target_id": 1001,
"result": "success",
"diff_json": {
"level": {"before": "normal", "after": "vip"},
"remark": {"before": "", "after": "signed"}
},
"request_id": "req_20260727_113001"
}
如果这里只剩 message=编辑客户成功,后续遇到脏数据就很难追。
批量更新要按目标行写日志
AI 生成批量接口时,容易把日志写成一条:批量更新成功,共 30 条。这条日志对统计有用,对追责没用。批量接口至少要有批次日志和行级日志两层。
sql
CREATE TABLE admin_operation_batch (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
actor_id BIGINT NOT NULL,
action VARCHAR(32) NOT NULL,
target_table VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
total_count INT NOT NULL,
success_count INT NOT NULL,
failed_count INT NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_request_id (request_id)
);
批次表回答"这次操作整体发生了什么",行级日志回答"每一行发生了什么"。客户等级、负责人、状态这类字段,一旦批量改错,不能只靠回忆 Excel 原文件来恢复。
批量接口的验收 SQL 可以这样写:
sql
SELECT COUNT(*) AS row_logs
FROM admin_operation_log
WHERE request_id = 'req_bulk_20260727_01'
AND target_table = 'crm_customer'
AND action = 'batch_update';
SELECT success_count, failed_count
FROM admin_operation_batch
WHERE request_id = 'req_bulk_20260727_01';
如果批次里成功 18 条、失败 2 条,行级日志就应该能对应到 20 个目标 ID。只记录一个总数,不够。
数据库约束失败也要进日志
操作日志不是只记录成功。很多线上问题恰恰发生在失败路径:唯一索引冲突、租户条件不匹配、状态不允许修改、字段超长、外键不存在。失败不写日志,用户只会看到"保存失败",后台没有证据。
例如手机号唯一约束冲突:
sql
ALTER TABLE crm_customer
ADD UNIQUE KEY uk_tenant_phone_active (tenant_id, phone, deleted_at);
更新时撞到唯一索引,日志里至少保留目标 ID、权限码、请求字段和错误码:
json
{
"tenant_id": 10,
"actor_id": 23,
"permission_code": "crm_customer:update",
"target_table": "crm_customer",
"target_id": 1001,
"result": "failed",
"error_msg": "Duplicate entry '10-13800000000' for key 'uk_tenant_phone_active'",
"request_id": "req_20260727_113201"
}
这里不要把完整手机号、身份证、token 这类敏感字段原样打满。日志要能排障,也要能脱敏。手机号可以存 138****0000,token 不该进日志。
和代码生成器的关系:不要只生成接口,也要生成验收点
代码生成器很容易生成列表、新增、编辑、删除这些基础文件,难点是把权限码和日志字段一起纳入模板边界。我看 Go 后台项目时,会重点看生成器有没有把"接口、权限、菜单、日志"放到同一张验收表里。
XYGo Admin 里可以直接看两个位置:server/internal/middleware/operation_log.go 负责操作日志中间件,server/internal/middleware/admin_permission.go 负责后台接口权限校验。它们能作为这篇判断的源码样本:日志不是单独的装饰功能,应该贴着权限和请求上下文走。源码入口放这里就够了:GitHub 仓库。
如果你维护的是自己的生成器,也可以把下面这张表放进模板验收:
| 模板项 | 验收方式 |
|---|---|
| update 接口 | 读旧值、写新值、生成 diff |
| delete 接口 | 记录目标 ID、租户、权限码、结果 |
| batch 接口 | 批次日志 + 行级日志 |
| permission 中间件 | 日志里的 permission_code 与后端校验一致 |
| request_id | 接口响应、应用日志、操作日志能串起来 |
| 脱敏 | 手机号、token、身份证不进明文日志 |
上线前按这几步查
我的检查顺序一般是这样:
bash
# 1. 找出最近 10 条客户修改日志
SELECT id, actor_id, permission_code, target_id, result, created_at
FROM admin_operation_log
WHERE target_table = 'crm_customer'
ORDER BY id DESC
LIMIT 10;
# 2. 查某个 request_id 是否能串到应用日志
SELECT request_id, diff_json, error_msg
FROM admin_operation_log
WHERE request_id = 'req_20260727_113001';
# 3. 查有没有成功修改但 diff 为空的日志
SELECT id, target_id, action, result
FROM admin_operation_log
WHERE result = 'success'
AND action IN ('update', 'batch_update')
AND (diff_json IS NULL OR JSON_LENGTH(diff_json) = 0)
ORDER BY id DESC
LIMIT 20;
# 4. 查有没有缺权限码的日志
SELECT id, module, action, target_id
FROM admin_operation_log
WHERE permission_code = '' OR permission_code IS NULL
ORDER BY id DESC
LIMIT 20;
前两条用于确认链路能追,后两条用于找生成代码留下的洞。尤其是第三条,很多"保存成功但没实际变化"的请求会制造噪音。如果没有字段变化,可以不写成功日志,或者写成 noop,别混进真正的更新记录里。
结论
AI 生成后台时,操作日志不能只当页面功能。它更像后台系统的黑匣子:平时没人看,出问题时必须能还原现场。
最小可用标准就四个:后端权限码能对上,目标行能定位,字段差异能读懂,失败路径也有记录。只要这四个没过,页面再顺也别急着上线。后面真遇到"谁把客户等级改了""为什么普通账号能改负责人""批量导入覆盖了哪几行",日志能不能回答,差别很大。