Files
xinghuoapi/openspec/changes/migrate-orm-gorm-to-ent/proposal.md
yangjianbo 3d617de577 refactor(数据库): 迁移持久层到 Ent 并清理 GORM
将仓储层/基础设施改为 Ent + 原生 SQL 执行路径,并移除 AutoMigrate 与 GORM 依赖。
重构内容包括:
- 仓储层改用 Ent/SQL(含 usage_log/account 等复杂查询),统一错误映射
- 基础设施与 setup 初始化切换为 Ent + SQL migrations
- 集成测试与 fixtures 迁移到 Ent 事务模型
- 清理遗留 GORM 模型/依赖,补充迁移与文档说明
- 增加根目录 Makefile 便于前后端编译

测试:
- go test -tags unit ./...
- go test -tags integration ./...
2025-12-29 10:03:27 +08:00

17 KiB
Raw Blame History

Proposal: 将 GORM 迁移至 Ent保留软删除语义

Change ID

migrate-orm-gorm-to-ent

背景

当前后端(backend/)使用 GORM 作为 ORM仓储层backend/internal/repository/*.go)大量依赖字符串 SQL、Preloadgorm.Exprclause 等机制。

为支持后续从 GORM 迁移到 Ent本变更首先把“schema 管理”从 GORM AutoMigrate 切换为 版本化 SQL migrationsbackend/migrations/*.sql+ schema_migrations 记录表,避免 ORM 层隐式改表导致的不可审计/不可回滚问题,并确保空库可通过 migrations 重建得到“当前代码可运行”的 schema。

项目已明确:

  • 生产环境依赖软删除语义deleted_at 过滤必须默认生效)。
  • 更看重 类型安全 / 可维护性(希望减少字符串拼接与运行期错误)。

因此,本变更将数据库访问从 GORM 迁移到 Ententgo.io/ent),并用 Ent 的 Interceptor + Hook + Mixin 实现与现有行为一致的软删除默认过滤能力(参考 Ent 官方拦截器文档中的 soft delete 模式)。

说明:

  • Ent 的拦截器/软删除方案需要在代码生成阶段启用相关 feature例如 intercept),并按 Ent 的要求在入口处引入 ent/runtime 以注册 schema hooks/interceptors避免循环依赖
  • 本仓库的 Go module 位于 backend/go.mod,因此 Ent 生成代码建议放在 backend/ent/(例如 backend/ent/schema/),而不是仓库根目录。

落地提示:

  • 入口处的实际 import 路径应以模块路径为准。以当前仓库为例,若 ent 生成目录为 backend/ent/,则 runtime import 形如:github.com/Wei-Shaw/sub2api/ent/runtime

目标

  1. 用 Ent 替代 GORM提升查询/更新的类型安全与可维护性。
  2. 保持现有软删除语义:默认查询不返回软删除记录;支持显式 bypass例如后台审计/修复任务)。
  3. 将“启动时 AutoMigrate”替换为“可审计、可控的迁移流程”第一阶段采用 backend/migrations/*.sql 在部署阶段执行)。
  4. 保持 internal/service 与 handler 等上层不感知 ORM继续以 repository interface 为边界)。

非目标

  • 不重写业务逻辑与对外 API 行为(除必要的错误类型映射外)。
  • 不强行把现有复杂统计 SQLusage_log_repo.go 的趋势/CTE/聚合)全部改成 Ent Builder这类保持 Raw/SQL Builder 更可控。

关键决策(本提案给出推荐方案)

1) users.allowed_groups:从 Postgres array 改为关系表(推荐)

现状:users.allowed_groups BIGINT[],并使用 ANY() / array_remove()(见 user_repo.go / group_repo.go)。

决策:新增中间表 user_allowed_groups(user_id, group_id, created_at),并建立唯一约束 (user_id, group_id)

理由:

  • Ent 对 array 需要自定义类型 + 仍大量依赖 raw SQL可维护性一般。
  • 关系表建模更“Ent-friendly”查询/权限/过滤更清晰,后续扩展(例如允许来源、备注、有效期)更容易。

约束与说明:

  • 不建议对该 join 表做软删除:解绑/移除应为硬删除(否则“重新绑定”与唯一约束会引入额外复杂度)。如需审计,建议写审计日志/事件表。
  • 外键建议 ON DELETE CASCADE(删除 user/group 时自动清理绑定关系,语义更接近当前级联清理逻辑)。

兼容策略:

  • Phase 1新增表并 从旧 array 回填;仓储读取改从新表,写入可短期双写(可选)。
  • Phase 2灰度确认后移除 allowed_groups 列与相关 SQL。

2) account_groups:保持复合主键,使用 Ent Edge Schema推荐

现状:account_groups(account_id, group_id) 复合主键,并附带 priority/created_at 等额外字段(见 account_repo.go)。

决策:不修改数据库表结构,在 Ent 中将其建模为 Edge Schema带额外字段的 M2M join entity并将其标识符配置为复合主键account_id + group_id)。

理由:

  • 该表是典型“多对多 + 额外字段”场景Ent 原生支持 Edge Schema允许对 join 表做 CRUD、加 hooks/策略,并保持类型安全。
  • 避免线上 DDL更换主键带来的锁表风险与回滚复杂度。
  • 当前表已具备唯一性(复合主键),与 Edge Schema 的复合标识符完全匹配。

设计概览

A. Ent 客户端与 DI

  • ProvideDB/InitDB 从返回 *gorm.DB 改为返回 *ent.Client(必要时同时暴露 *sql.DB 供 raw 统计使用)。
  • cmd/server/wire.go 的 cleanup 从 db.DB().Close() 改为 client.Close()

A.1 迁移边界与命名映射(必须明确)

为保证线上数据与查询语义不变Ent schema 需要显式对齐现有表/字段:

  • 表名:使用 usersapi_keysgroupsaccountsaccount_groupsproxiesredeem_codessettingsuser_subscriptionsusage_logs 等现有名称(不要让 Ent 默认命名生成新表)。
  • ID 类型:现有主键是 BIGSERIAL,建议 Ent 中统一用 int64(避免 Go 的 int 在 32-bit 环境或跨系统时产生隐性问题)。
  • 时间字段created_at/updated_at/deleted_at 均为 TIMESTAMPTZschema 中应显式声明 DB 类型,避免生成 timestamp without time zone 导致行为变化。

A.2 代码生成与 feature flags必须写死

建议在 backend/ent/generate.go 固化生成命令(示例):

//go:build ignore
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature intercept --feature sql/upsert ./schema

说明:

  • intercept:用于软删除的通用拦截器工具(以及未来可复用的全局 query policy
  • sql/upsert:用于替代 GORM 的 ON CONFLICT(例如 settings 的 upsert如果短期不迁移 upsert可暂不启用。

生成命令与 feature flags 必须进入 CI 校验避免“本地生成了、CI/生产没生成”的隐性差异)。

B. 软删除实现(必须)

对所有需要软删除的实体:

  • 在 Ent schema 中通过 Mixin 添加 deleted_at(或 delete_time)字段。
  • 通过 Query Interceptor 在查询阶段默认追加 deleted_at IS NULL 过滤(含 traversals
  • 通过 Mutation Hook 处理两类行为:
    • 拦截 delete 操作,将 delete 变为 update设置 deleted_at = now()
    • 拦截 update 操作,默认追加 deleted_at IS NULL 过滤,避免软删除记录被意外更新(与当前 GORM 行为对齐)。
  • 提供 SkipSoftDelete(ctx):在需要包含软删数据的查询或需要 hard delete 的管理任务中显式使用。

SkipSoftDelete 推荐实现

type softDeleteKey struct{}

func SkipSoftDelete(ctx context.Context) context.Context {
    return context.WithValue(ctx, softDeleteKey{}, true)
}

func shouldSkipSoftDelete(ctx context.Context) bool {
    v, _ := ctx.Value(softDeleteKey{}).(bool)
    return v
}

注意Ent 的“默认不更新软删记录”通常应通过 mutation hook 实现(而不是 query interceptor否则容易出现“UpdateOneByID 仍可更新已软删记录”的行为差异。

行为兼容性约定(建议写入测试)

  • Delete(id) 对“已软删”的记录应尽量保持 幂等(返回成功或 rows=0但不应抛 NotFound 破坏现有行为)。
  • 默认查询(列表/详情/关联加载)均不应返回软删记录。
  • 仅在明确管理/审计场景允许 hard delete并且必须显式传递 SkipSoftDelete(ctx) 或使用专用方法)。

B.1 Raw SQL 与事务一致性(必须遵守)

本项目存在不少事务型写操作(如 group_repo.DeleteCascade),并且部分逻辑使用 raw SQL或未来保留 raw

规则:

  • 事务内的 raw 写操作必须绑定到同一个事务:优先使用 Ent 的 tx.ExecContext(ctx, ...) 执行 raw DML确保与 Ent mutation 同一事务提交/回滚。
  • 避免在事务中直接使用独立注入的 *sql.DB 执行写操作(会绕开事务,破坏原子性)。

C. 仓储层迁移策略

优先改动“CRUD/关联加载明显”的仓储,复杂统计保持 raw

  1. user_repo.go / api_key_repo.go / group_repo.go / proxy_repo.go / redeem_code_repo.go / setting_repo.go
  2. account_repo.goJSONB merge、复杂筛选与 join 排序,部分保留 raw
  3. user_subscription_repo.go(原子增量、批量更新)
  4. usage_log_repo.go(建议保留 Raw SQL底层连接迁移到 database/sql 或 Ent driver

D. 错误映射

repository/translatePersistenceError 从 GORM error 改为:

  • ent.IsNotFound(err) → 映射为 service.ErrXxxNotFound
  • ent.IsConstraintError(err) / 驱动层 unique violation → 映射为 service.ErrXxxExists

同时清理所有 GORM 错误泄漏点:

  • backend/internal/server/middleware/api_key_auth_google.go - 已修复:改为判断 service.ErrApiKeyNotFound(并已有单元测试覆盖)
  • backend/internal/repository/account_repo.go:50 - 需迁移:直接判断 gorm.ErrRecordNotFound
  • backend/internal/repository/redeem_code_repo.go:125 - 需迁移:使用 gorm.ErrRecordNotFound
  • backend/internal/repository/error_translate.go:16 - 核心翻译函数,需改为 Ent 错误

E. JSONB 字段处理策略

accounts 表的 credentialsextra 字段使用 JSONB 类型,当前使用 PostgreSQL || 操作符进行合并更新。

Ent 处理方案:

  • 定义自定义 JSONMap 类型用于 schema
  • 对于简单的 JSONB 读写,使用 Ent 的 field.JSON() 类型
  • 对于 JSONB 合并操作(COALESCE(credentials,'{}') || ?),使用 raw SQL
    • 事务外:使用 client.ExecContext(ctx, ...)(确保复用同一连接池与可观测性能力)。
    • 事务内:使用 tx.ExecContext(ctx, ...)(确保原子性,不得绕开事务)。
  • 或者在应用层先读取、合并、再写入(需要事务保证原子性)

F. DECIMAL/NUMERIC 字段(必须显式确认)

当前 schema 中存在多处 DECIMAL/NUMERIC(例如 users.balancegroups.rate_multiplier、订阅/统计中的 cost 字段等。GORM 当前用 float64 读写这些列。

第一阶段结论(兼容优先):

  • 继续使用 float64,并在 Ent schema 中把字段的数据库类型显式设为 Postgres numeric(… , …)(避免生成 double precision),同时接受现有的精度风险(与当前行为一致)。
  • 精度优先(后续可选):改用 decimal.Decimal(或其他 decimal 类型)作为 Go 类型,以避免金额/费率累积误差;但会波及 internal/service 的字段类型与 JSON 序列化,属于更大范围重构。

数据库迁移(建议)

本仓库已存在 backend/migrations/*.sql,且当前数据库演进也更契合“版本化 SQL 迁移”模式,而不是在应用启动时自动改动 schema。

决策(第一阶段):继续使用 backend/migrations/*.sql 作为唯一的版本化迁移来源Ent 仅负责运行期访问,不在启动阶段自动改动 schema。

可选(后续阶段):若团队希望更强的 schema diff/漂移检测能力,可再引入 Atlas并与现有 SQL 迁移策略对齐后逐步迁移(但不作为第一阶段前置)。

重要现状说明(必须先处理):

  • 历史上存在“启动期 AutoMigrate + 迁移脚本覆盖不全”的混用风险:新环境仅跑 SQL migrations 可能出现缺表/缺列。
  • 另一个高风险点是 SQL migrations 中的默认管理员/默认分组种子(如果存在固定密码/固定账号,属于明显的生产安全隐患),应当从 migrations 中移除,改为在安装流程中显式创建。

当前处理策略(本变更已落地的基线):

  • 通过 backend/internal/infrastructure/migrations_runner.go 引入内置 migrations runnerschema_migrations + pg_advisory_lock),用于按文件名顺序执行 backend/migrations/*.sql 并记录校验和。
  • 补齐 migrations 覆盖面(新增 schema parity / legacy 数据修复迁移),确保空库执行 migrations 后即可跑通当前集成测试。
  • 移除 migrations 内的默认管理员/默认分组种子,避免固定凭据风险;管理员账号由 internal/setup 显式创建。

第一阶段至少包含:

  • 新增 user_allowed_groups 表,并从 users.allowed_groups 回填数据。
  • (如需要)为所有软删表统一索引:(deleted_at)(deleted_at, id),确保默认过滤不拖慢查询。

迁移 SQL 草案PostgreSQL

以下 SQL 旨在让执行方案更“可落地”,实际落地时请按 backend/migrations/*.sql 拆分为可回滚步骤,并评估锁表窗口。

(1) 新增 join 表:user_allowed_groups

CREATE TABLE IF NOT EXISTS user_allowed_groups (
  user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  group_id   BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (user_id, group_id)
);

CREATE INDEX IF NOT EXISTS idx_user_allowed_groups_group_id
  ON user_allowed_groups(group_id);

(2) 从 users.allowed_groups 回填

INSERT INTO user_allowed_groups (user_id, group_id)
SELECT u.id, x.group_id
FROM users u
CROSS JOIN LATERAL unnest(u.allowed_groups) AS x(group_id)
WHERE u.allowed_groups IS NOT NULL
ON CONFLICT DO NOTHING;

(3) 回填校验(建议在灰度/发布前跑一次)

-- 旧列展开后的行数(去重后) vs 新表行数
WITH old_pairs AS (
  SELECT DISTINCT u.id AS user_id, x.group_id
  FROM users u
  CROSS JOIN LATERAL unnest(u.allowed_groups) AS x(group_id)
  WHERE u.allowed_groups IS NOT NULL
)
SELECT
  (SELECT COUNT(*) FROM old_pairs)               AS old_pair_count,
  (SELECT COUNT(*) FROM user_allowed_groups)     AS new_pair_count;

Phase 2 删除 users.allowed_groups 列应在“代码已完全切换到新表 + 已灰度验证”之后执行,并作为单独迁移文件。

Phase 2 清理计划(仅在灰度完成后执行)

前置条件(必须同时满足):

  • 应用侧 读路径 已完全从 user_allowed_groups 获取 allowed-groups不再读取 users.allowed_groups)。
  • 应用侧 写路径 已稳定双写/已切到只写 user_allowed_groups(并确认线上没有写回旧列的旧版本)。
  • 运行期指标确认allowed-groups 相关功能无报错、无权限回归(建议至少一个发布周期)。

执行步骤(建议):

  1. 先发布“只读新表 + 仍保留旧列”的版本(兼容期),并监控一段时间。
  2. 发布“停止写旧列(只写 join 表)”的版本,并监控一段时间。
  3. 执行独立迁移DDL
    • ALTER TABLE users DROP COLUMN allowed_groups;
    • (可选)删除任何旧列相关的索引/约束(如果存在)。
  4. 发布“移除旧列代码路径”的版本(清理遗留 SQL例如 ANY(allowed_groups)/array_remove)。

回滚策略:

  • 如果在步骤 1/2 发现功能回归可直接回滚应用版本DB 仍向后兼容)。
  • 一旦执行步骤 3DROP COLUMN回滚将需要手动加回列并从 join 表回填(不推荐在线上紧急回滚时做)。

部署策略:

  • 先跑 DB migration兼容旧代码再灰度切换 Ent 仓储。
  • 保留回滚路径feature flag 或快速回切到旧版本镜像DB 迁移需保持向后兼容)。

影响范围

  • 文件(预计修改):backend/internal/infrastructure/*, backend/cmd/server/*, backend/internal/repository/*, backend/internal/setup/*, backend/internal/server/middleware/*
  • 依赖:新增 entgo.io/ent、(可选)ariga.io/atlas/ent/migrate

风险与缓解

风险 说明 缓解
软删除语义不一致 Ent 默认不会自动过滤软删 强制使用 mixin+interceptor+hook并加集成测试覆盖"软删不可见/可 bypass"
Schema 迁移风险 allowed_groups 需要数据变更array→join 表) 迁移分两阶段migration 保持向后兼容;灰度发布
迁移脚本缺失/漂移 过去依赖 AutoMigrate 演进 schemaSQL migrations 可能不完整 在切换前补齐 migrations新增“迁移脚本可重建全量 schema”的 CI/集成测试校验
统计 SQL 行为变化 迁移连接方式后可能出现 SQL 细节差异 usage_log_repo 保持原 SQL优先做黑盒回归
性能退化 默认过滤 soft delete 增加条件 deleted_at 加索引;对热点查询做 explain/压测
集成测试中断 测试 harness 依赖 *gorm.DB 事务回滚 优先迁移测试基础设施,改用 *ent.Tx*sql.Tx
JSONB 合并操作 Ent 不直接支持 PostgreSQL || 操作符 使用 client.ExecContext/tx.ExecContext 执行 raw SQL事务内必须用 tx或应用层合并
行级锁 clause.Locking{Strength: "UPDATE"} 需替换 使用 Ent 的 ForUpdate() 方法
Upsert 语义 clause.OnConflict 的等价实现 使用 OnConflict().UpdateNewValues()DoNothing()

成功标准(验收)

  1. 现有单元/集成测试通过repository integration tests带 Docker通过。
  2. 软删除默认过滤行为与线上一致:任意 Delete 后常规查询不可见;显式 SkipSoftDelete 可见。
  3. allowed_groups 相关功能回归通过:查询/绑定/解绑/分组删除联动保持一致。
  4. 关键读写路径API key 鉴权、账户调度、订阅扣费/限额)无行为变化,错误类型与 HTTP 状态码保持兼容。