Skip to content

♻️ database: 将 PostgreSQL 重构为 MySQL - #12

Merged
CodFrm merged 3 commits into
mainfrom
refactor/mysql
Aug 13, 2026
Merged

♻️ database: 将 PostgreSQL 重构为 MySQL#12
CodFrm merged 3 commits into
mainfrom
refactor/mysql

Conversation

@CodFrm

@CodFrm CodFrm commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

概要

  • 将运行时数据库驱动、仓储 SQL 语义和未发布迁移基线从 PostgreSQL 重构为 MySQL
  • 将 Docker Compose、示例配置、E2E 工具链切换到 MySQL 9.7.2
  • 清理 PostgreSQL 依赖、配置、部署资源与文档引用

关键实现

  • 使用 MySQL named lock 串行化多副本迁移
  • 使用 ON DUPLICATE KEY UPDATELAST_INSERT_ID 和条件赋值保持设备 upsert、同步版本分配及对象更新语义
  • 使用生成列和唯一索引表达原 PostgreSQL partial unique index 约束
  • E2E runner 安全解析 MySQL DSN 地址,不在诊断中泄露用户名、密码或数据库名

验证

  • make lint
  • make test(Go race 全量测试;前端 36 个文件、384 项测试)
  • make build
  • pnpm -C e2e runner-test(rebase 后 7 项通过)
  • MySQL 9.7.1 实库执行全部 7 个迁移并核对表、迁移账本、生成列和唯一索引
  • 文件配置及 etcd 配置两条启动路径健康检查均通过,数据库与 Redis 探活成功
  • 双实例共享数据库启动验证通过

配置说明

本地开发配置 configs/config.yaml 被 Git 忽略,不包含在本 PR。mysql-dev 环境配置已写入开发 etcd 的 /config/mysql-dev/agentre-server/*

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

本 PR 将 agentre-server 的运行时数据库从 PostgreSQL 全面切换为 MySQL:包括迁移锁实现、迁移 DDL、仓储层 upsert/原子语义、测试用 sqlmock 方言、以及本地/部署/E2E 工具链与文档引用的同步更新。

Changes:

  • 将迁移串行化从 PostgreSQL advisory lock 重构为 MySQL named lock(GET_LOCK / RELEASE_LOCK)。
  • 将核心表迁移 DDL 从 PG 类型/partial index 语义改写为 MySQL(JSON、生成列、UNIQUE KEY、AUTO_INCREMENT 等)。
  • 将测试与 E2E/部署配置从 PG 生态切到 MySQL(sqlmock 方言、DSN 解析、compose、示例配置、文档)。

Reviewed changes

Copilot reviewed 58 out of 59 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
README.md 更新开发指引中的数据库说明为 MySQL
migrations/migrations.go 迁移 runner 改为 MySQL named lock 串行化
migrations/migrations_test.go 删除(原迁移锁 runner 的 sqlmock 测试)
migrations/202608100001_followed_sessions.go 新表迁移 DDL 改为 MySQL 版本(AUTO_INCREMENT/UNIQUE KEY)
migrations/202608090001_workspace_sync.go sync 相关表迁移 DDL 改为 MySQL(JSON/生成列/索引重写)
migrations/202605200005_device_flow_codes.go device flow 表迁移改为 MySQL(生成列替代 partial unique)
migrations/202605200004_device_tokens.go token 表迁移改为 MySQL(类型/索引改写)
migrations/202605200003_devices.go devices 表迁移改为 MySQL(类型/索引改写)
migrations/202605200002_user_identities.go user_identities 表迁移改为 MySQL(JSON/索引改写)
migrations/202605200001_users.go users 表迁移改为 MySQL(生成列表达“仅活跃唯一”)
internal/testutils/database.go sqlmock 工厂切到 MySQL 方言并引入自定义 matcher
internal/service/user_svc/user_test.go 测试改用 MySQL sqlmock 工厂
internal/service/sync_svc/sync.go 注释从 jsonb 更新为 JSON
internal/service/sync_svc/sync_test.go 测试改用 MySQL sqlmock 工厂
internal/service/device_svc/register_web_test.go 注释/说明从 Postgres 改为 MySQL(并保持 UTF-8 讨论)
internal/service/device_svc/device.go Upsert 行为说明从 RETURNING 改为 transaction read-back
internal/service/device_svc/device_test.go 测试改用 MySQL sqlmock 工厂
internal/service/device_svc/client_info.go 注释更新:ip 列从 inet 改为 varchar(45)
internal/repository/user_repo/user_test.go repository sqlmock 期望适配 MySQL(Exec/Result 等)
internal/repository/user_identity_repo/user_identity.go Create 前补默认 JSON {} 以满足 MySQL JSON NOT NULL
internal/repository/user_identity_repo/user_identity_test.go sqlmock 期望适配 MySQL(Exec/Result/RawProfile)
internal/repository/sync_repo/sync_test.go sync repo 测试适配 MySQL(LAST_INSERT_ID、ON DUPLICATE KEY)
internal/repository/sync_repo/state.go NextVersion 改为 MySQL 事务 + LAST_INSERT_ID 语义
internal/repository/sync_repo/object.go Save upsert 改为 MySQL 条件赋值(IF/VALUES)
internal/repository/sync_repo/avatar.go JSON 取值由 PG 操作符改为 MySQL JSON_EXTRACT/UNQUOTE
internal/repository/follow_repo/follow.go Follow upsert 语义调整为 MySQL 方言(OnConflict/DoNothing)
internal/repository/follow_repo/follow_test.go follow repo 测试适配 MySQL(Exec/Result)
internal/repository/device_token_repo/device_token_test.go token repo 测试适配 MySQL
internal/repository/device_repo/device.go device upsert 改为 MySQL:写入后同事务读回最终行
internal/repository/device_repo/device_test.go device upsert 测试适配 MySQL(追加读回断言)
internal/repository/device_flow_repo/device_flow_test.go device flow repo 测试适配 MySQL
internal/model/entity/user_identity_entity/user_identity.go raw_profile 从 jsonb 改为 json
internal/model/entity/sync_entity/sync.go payload 从 jsonb 改为 json,并更新注释约束
internal/model/entity/device_token_entity/device_token.go IP 列类型从 inet 改为 varchar(45)
internal/controller/device_ctr/register_web_test.go controller 测试改用 MySQL sqlmock 工厂
go.mod 依赖从 gorm postgres driver 切到 mysql driver
go.sum 清理 postgres/pgx 相关依赖校验并引入/保留 mysql driver 校验
e2e/webe2e/main.go web e2e harness 从 postgres.Open 改为 mysql.Open,并改写种子写入逻辑
e2e/web/runner-config.spec.ts runner 配置测试适配 MySQL DSN,并新增地址解析用例
e2e/run-e2e-web.mjs runner 启动诊断从解析 PG URL 改为安全解析 MySQL tcp 地址
e2e/README.md e2e 文档更新:依赖从 PostgreSQL 改为 MySQL
e2e/playwright.runner.config.ts 新增 runner-config spec 的独立 Playwright 配置
e2e/package.json 新增 runner-test 脚本
docs/verification.md 验证文档更新:MySQL + Redis
docs/testing.md 测试策略文档更新:MySQL sqlmock + 迁移验证命令等
docs/specs/2026-08-08-drop-device-capabilities.md 文档中的 jsonb 描述更新为 JSON
docs/specs/2026-08-07-multi-instance-safety.md 多副本安全文档更新:迁移锁实现改为 MySQL named lock
docs/specs/2026-08-07-gitea-k3s-deploy.md 部署 spec 文档更新:MySQL + Redis
docs/references/verification-report-template.md 验证报告模板更新:MySQL + Redis
docs/develop.md 开发文档更新:MySQL + Redis
docs/architecture.md 架构文档更新:共享状态/Upsert/迁移锁等改为 MySQL 描述
deploy/README.md 部署文档更新:compose 使用 MySQL + Redis
deploy/docker-compose.yml compose 服务从 postgres 改为 mysql(含健康检查与数据卷)
deploy/config.docker.yaml compose 用配置从 postgres DSN 改为 MySQL DSN
configs/config.example.yaml 示例配置从 postgres 改为 mysql(driver + DSN)
cmd/synce2e/seed.go synce2e harness 从 postgres.Open 改为 mysql.Open,并改写种子写入逻辑
cmd/synce2e/main.go synce2e 工具说明更新:写入 MySQL
cmd/server/main.go 移除 postgres driver blank import(但未见 mysql 对应 import)
AGENTS.md 仓库概述更新:MySQL 9.7 + Redis 7
Suppressed comments (2)

migrations/migrations.go:50

  • 这里已经改成 MySQL named lock,但注释仍在用“advisory lock”措辞(包括“advisory lock 不绑定数据”),会误导读者和后续维护。建议把注释统一改成 named lock。
    migrations/migrations.go:31
  • 迁移串行化逻辑(withMigrationLock/acquireMigrationLock)是这次改动的关键行为,但 migrations/ 下已经没有任何 *_test.go 覆盖它(同时本文件注释还提到“为了让测试能换成极小值”)。建议恢复/新增 sqlmock 用例覆盖:GET_LOCK 重试直到成功、超时返回、以及最终 RELEASE_LOCK 的调用序列。

This issue also appears on line 46 of the same file.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cmd/server/main.go
Comment on lines 7 to 11
"github.com/cago-frame/cago"
"github.com/cago-frame/cago/configs"
"github.com/cago-frame/cago/database/db"
_ "github.com/cago-frame/cago/database/db/postgres"
"github.com/cago-frame/cago/pkg/component"
"github.com/cago-frame/cago/pkg/opentelemetry/metric"
Comment thread docs/testing.md
Comment on lines 86 to 90
**What is untested is the DDL, not the runner.** `migrations/migrations_test.go` does use
sqlmock, on the advisory-lock wrapper `withMigrationLock` that serialises concurrently
starting replicas — it asserts the `pg_try_advisory_lock` retry, that the migration func
only runs once the lock is held, and that `pg_advisory_unlock` follows. That is a
starting replicas — it asserts the `GET_LOCK` retry, that the migration func
only runs once the lock is held, and that `RELEASE_LOCK` follows. That is a
statement-sequence assertion, so it stays hermetic; it says nothing about whether any
Comment on lines 34 to 36
// Follow 是一条语句的条件插入:ON DUPLICATE KEY ... DO NOTHING 由数据库原子裁决,
// 并发重复关注两边都成功、只落一行。先查再插会在两个副本同时首次关注时双双
// 走到 INSERT,竞败方撞唯一索引拿到一个约束错误。
Comment on lines 15 to 18
// Follow 必须是一条语句:INSERT ... ON DUPLICATE KEY (user_id, device_fingerprint,
// session_id) DO NOTHING。重复关注(同账号、同一目标会话)命中唯一索引时是
// no-op,由数据库原子裁决:不新增行、也不重置首次关注时间——R12「关注幂等」在
// 数据层的落点。这里用 0 行受影响的结果模拟「已关注」的那次重复请求。
Comment on lines 10 to 14
// 这是 agentre-server 本轮唯一的服务端新增表,也是硬不变量(server 不持有任何
// 会话内容)的唯一例外,且它存的是「指向」——目标设备指纹 + 会话标识 + 关注时间,
// 不含标题、消息或转录。表按 (user_id, device_fingerprint, session_id) 唯一,
// 关注/取消因此幂等:重复关注命中唯一索引时 ON CONFLICT DO NOTHING,不新增行、
// 关注/取消因此幂等:重复关注命中唯一索引时 ON DUPLICATE KEY DO NOTHING,不新增行、
// 不重置首次关注时间;取消就是一条 DELETE,删不到也是成功。
Comment thread docs/architecture.md Outdated
Comment on lines 196 to 199
A write with no conditional `UPDATE` to hang the decision on needs the database to arbitrate
some other way. `device_repo.Upsert` is a single `INSERT … ON CONFLICT ("user_id",
some other way. `device_repo.Upsert` is a single `INSERT … ON DUPLICATE KEY ("user_id",
"fingerprint") DO UPDATE`, not a find-then-create, so two exchanges for the same device
converge on one row instead of racing to a `uk_devices_user_fingerprint` duplicate-key 500.
CodFrm added 2 commits August 13, 2026 11:16
上一版是把 PG 的 DDL 逐句转写过来,留下四个实库可复现的缺陷。这一轮按 MySQL 自己的
写法重做基线(迁移未发布,直接改基线而非追加补丁,已与用户确认)。

- 自然键回到真列:live_location_key 那个 CONCAT(...CHAR(0)...) 生成列在
  utf8mb4_0900_ai_ci 下分隔符权重为空、会被忽略,('proj','Xdev') 与 ('projX','dev')
  拼成同一个键而误判重复。改成 (user_id, project_sync_id, agentred_fingerprint,
  live_location_flag) 四列唯一键,用「存活时为 1、否则 NULL」的标志列表达部分唯一索引
- 标识与凭据列一律 utf8mb4_bin:默认排序规则大小写不敏感,sync_id 'abc'/'ABC' 会互相
  顶掉、且 WHERE sync_id=? 会取回另一行。表级 ENGINE/CHARSET/COLLATE 全部写死,
  不再随 character_set_server 变化
- sync_avatars.content 由 text 改 mediumtext:text 上限 65535 字节,而
  sync_svc.MaxAvatarBytes 是 4 MiB,超过 64KB 的头像直接 ER_DATA_TOO_LONG
- users / device_flow_codes 的部分唯一索引改用标志列,键首列放业务列,使同一索引既做
  约束又能服务 FindByEmail 与 user_code 轮询——两处原本都是全表扫,后者还是每 5 秒一次
  的 device flow 热路径且含两条 UPDATE
- device_code 按生成器实际产出收窄到 varchar(64)(randomBase32(32) 恒为 52 位),
  它是主键,InnoDB 会把它塞进每条二级索引

sync_repo.Save 不再用 ON DUPLICATE KEY UPDATE:MySQL 命中的是任意唯一键,
sync_objects 上有两个,自然键冲突时它会静默改写另一个 sync_id 的那一行,本次上行的
对象从未落库而调用方拿到成功。改成「带版本条件的 UPDATE + 裸 INSERT」,用新增的
internal/pkg/dberr.IsDuplicateKey 按索引名区分 1062:撞身份键是版本竞败(吞掉),
撞自然键是 R4b 兜底(上抛)。

其余:
- 恢复被删的 migrations/migrations_test.go 并补 GET_LOCK 返回 NULL 的用例(原先
  NULL 会变成一条与等锁无关的驱动扫描错误);go test ./migrations/ 不再是 no test files
- 去掉 testutils 里把实际 SQL 改写成 PG 方言的匹配层,全部期望改写为真实 MySQL 语句
- raw_profile 的默认值交回 schema(DEFAULT ('{}')),删掉与 user_svc 重复的仓储层兜底
- make test-e2e 显式跑 runner-test,否则 web/ 被冒烟轨道排除、它一次都不会跑
- 修掉机械替换留下的半成品注释与文档(ON DUPLICATE KEY (cols) DO NOTHING 之类不存在的
  语法、「GET_LOCK 轮询而非阻塞的 GET_LOCK」这种同义对比、残留的 PG/Redis 字样)
上一版对「什么算相等」做了两个错误决定,都由排序规则的选择静默生效。

1) email 不该区分大小写。同一个人用 A@b.C 和 a@b.c 注册会落成两个账号。改成
   utf8mb4_0900_as_ci:只折叠大小写。没有直接用表默认的 ai_ci,因为 ai =
   accent-insensitive 连重音一起折叠,会把 e@x.c 与 é@x.c 当成同一个邮箱。
   users.email 与 user_identities.email 必须同规则,否则两列比较会报 illegal mix。

2) user_code 同理,它是印给人看、由人敲进浏览器的。usercode.Normalize 已经会转大写,
   排序规则是第二层保障:将来多一条忘了 Normalize 的路径,症状会是「查不到这个验证码」
   这种很难联想到大小写的报错。

3) 顺带查出来的同类问题:utf8mb4_bin 是 PAD SPACE,会忽略尾随空格——'x' 与 'x '
   在它下面相等,于是尾随空格不同的两个 sync_id 会撞唯一键、WHERE sync_id='x' 还会
   取回另一行。这跟上一轮修掉的 CHAR(0) 是同一类缺陷,而 PG 的 text 是 NO PAD。
   全部标识列换成 utf8mb4_0900_bin(既逐字节、又 NO PAD)。

4) ip / user_agent 只写不读,从不出现在任何 WHERE 里,去掉它们的显式排序规则。只在
   真正参与比较的列上写,读的人才知道哪些列的判等语义是被刻意选过的。

新增 migrations/collation_test.go 把策略钉住:DDL 里出现任何 PAD SPACE 排序规则即失败,
人手输入的标识符必须 _ci、不透明标识必须 _0900_bin、每张表必须钉 ENGINE 与字符集。
这类缺陷在单测和肉眼 review 里都看不出来,只能靠断言挡。

排序规则策略连同两个坑(_0900_ 之外都是 PAD SPACE;ai_ci 不只是忽略大小写)写进
docs/architecture.md,并列出必须共享排序规则的两组跨表比较列。

实库验证:email 大小写互撞而重音不撞、小写验证码能对上、'x' 与 'x ' 互不冲突、
三组跨表 join 不报 illegal mix;上一轮四个修复与三条索引路径全部回归通过。
@CodFrm
CodFrm merged commit 18ce3d7 into main Aug 13, 2026
4 checks passed
@CodFrm
CodFrm deleted the refactor/mysql branch August 13, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants