Files
wangdefa 23b2820101
CI / test (push) Successful in 22s
Release / release (push) Successful in 44s
发布 v0.8.3
2026-07-30 12:44:14 +08:00

5.2 KiB

数据库规范

GORM + SQLite(可选 MySQL/PostgreSQL)实战约定;模型在 internal/model/,连接与 AutoMigrate 在 internal/database/

租户级数据删除

  • 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 %w 返回,不得忽略。
  • JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 AutoMigrate 或 ORM association 推断级联范围。
  • 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
  • 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
  • 批量删除/清理遇到无法解析的 JSON payload 时记 log.Printf 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go logSkippedTask)。

大集合谓词用子查询,禁止展开 IN 列表

  • 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 WHERE x IN (SELECT …) 子查询,不要先 Pluck ID 再 IN ? 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
  • GORM 写法:把 tx.Model(&T{}).Select("id").Where(...) 作为参数传入 Where("x IN (?)", sub)(见 tenantdelete.go deleteAlertHits / alertHitRuleIDs)。
  • 例外:同表自删(删除条件子查询引用被删表)MySQL 报 1093,改按主键排序的固定批次循环删(aigateway.go cleanupTable,每批 10000,单批失败记日志退出)。
  • 若原本的 Pluck 兼有 FOR UPDATE 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(lockTenantEventRows)。
  • 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。

Common Mistake: 用 Save 持久化在途任务的陈旧快照

Symptom:一条记录已被另一事务删除,在途任务随后执行 Save 却把该行重新插入,或覆盖并发更新后的 payload。

Cause:GORM SaveUPDATE 零命中时会回退到 CREATE/upsert,不适合持久化长时运行开始时读取的快照。

Fix:用 WHERE id = ? AND updated_at = ? 的条件 Updates,并严格要求 RowsAffected == 1;零命中表示记录已删除或版本已变,不得补做 Create

Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零

Symptom:UPDATE 把可空列(如 *time.Time)写成 NULL 后,用同一个结构体变量再次 First() 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。

Cause:gorm Scan 遇到 NULL 列时跳过赋值(保持字段现状),不会主动置 nil。复用变量(循环内重读、更新后回读同一 &ch)时旧值残留。

Fix:更新后回读一律用新变量(var fresh model.X; db.First(&fresh, id))。

Prevention:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 First 同一行也各用独立变量。另:map Updates 写 NULL 用 gorm.Expr("NULL") 最稳(无类型 nil 在部分路径下不生效)。

Common Mistake: 需要"条件更新多列含 NULL"时的写法

// 正确:零写库条件 + 显式 NULL
db.Model(&model.AiChannel{}).
    Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
    Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})

serializer:json 字段走 map Updates 时手动 marshal

  • 切片/结构体字段用 gorm:"serializer:json;type:text" 声明(如 AiKey.Models []string),Create/First/struct 路径自动序列化;
  • Updates(map[string]any{...}) 路径不要依赖 GORM 对 map 值应用 serializer——把值 json.Marshal 成 string 放进 map(见 aigateway.go UpdateKey),行为版本无关且可测;
  • 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;nil 切片 marshal 为 null,读回 nil,天然表达「空 = 不限」语义。

Common Mistake: 进程内缓存键漏掉查询维度

Symptom:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。

Cause:internal/oci/cached.gockey 只拼了租户 OCID+资源名+region,而底层查询按 cred.EffectiveCompartment() 过滤——影响结果的维度没有全部进键,不同参数命中同一条缓存。

Fix:键值加入 cred.CompartmentID(空 = 租户根,天然区分)。

Prevention:缓存键必须覆盖影响回源结果的全部输入维度(租户、区间、区域、过滤参数);给 Credentials/查询结构体新增会改变结果的字段时,同步检查 ckey 调用点;隔离行为写进 cached_test.go 的 isolation 用例。

GORM 列名与 map 更新

  • GORM 默认命名把连续大写按公共缩写拆分:UserOCIDuser_oc_idTenancyOCIDtenancy_oc_id(不是 user_ocid)。用 Updates(map[string]any{...}) 写裸列名前先确认真实列名(schema.Parse 或查建表 SQL),SQLite 下写错列名报 "no such column" 才暴露,MySQL/PG 同错。零值字段(如清空 passphrase_enc)必须走 map 更新,struct 更新会忽略零值。(2026-07-23 activate-api-key 切换签名用户)