config.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. // Package fixture 实现 AC-15 阶段 2 的**前置件**:把 legacy 资金域 4 张表的一条链路
  2. // 搬到目标库 yuansiwei_2026,并逐列核对。
  3. //
  4. // 定位与边界(务必先读):
  5. // - 本包只做**资金域 4 表**:t_user_expense / t_user_expense_school / t_user_expense_log,
  6. // 以及解析用户所需的 t_user + t_legacy_mapping。其余 7 张资金/活动表与 8 个写 SP 的
  7. // 双跑比对**不在本包范围**(下一批)。
  8. // - 本包**不改** internal/academic/{service,repository,handler}:映射规则全部在此独立实现,
  9. // 避免为了「能跑通」去污染生产路径。
  10. // - 本包唯一的写目标是 yuansiwei_2026(fixture 数据,用完 DELETE)与 legacy 侧只读连接。
  11. // finance_bk **只 SELECT**。
  12. //
  13. // 三条落地口径(来自已拍板结论,不再讨论):
  14. // 1. 用户 id 走 t_legacy_mapping:legacy int user_id → canonical_user_id(VARCHAR(26) ULID)。
  15. // 2. 主键:t_user_expense 的键是 user_id(无自增 id);t_user_expense_log 用 D1
  16. // ——**显式插入 legacy id**,双跑按 id 直接对齐。不试图从 legacy id 反算 target id。
  17. // 3. 金额 DECIMAL(12,2):**逐行 ROUND(x,2)**,不是 ROUND(SUM,2);且不用 float 运算,
  18. // 用「十进制字符串 → 整数分」精确换算(见 decimal.go)。
  19. package fixture
  20. import (
  21. "database/sql"
  22. "encoding/json"
  23. "fmt"
  24. "math"
  25. "os"
  26. "path/filepath"
  27. "regexp"
  28. "sort"
  29. "strconv"
  30. "strings"
  31. "time"
  32. "gorm.io/driver/mysql"
  33. "gorm.io/gorm"
  34. "gorm.io/gorm/logger"
  35. )
  36. // 允许连接的 host(allow-list,非 deny-list):只允许本机。
  37. var allowedHosts = map[string]bool{
  38. "127.0.0.1": true,
  39. "localhost": true,
  40. "::1": true,
  41. }
  42. // 允许连接的库。finance_bk 只读;yuansiwei_2026 是本包唯一写目标。
  43. var (
  44. allowedDatabases = map[string]bool{
  45. "finance_bk": true,
  46. "legacy_oracle": true,
  47. "yuansiwei_2026": true,
  48. // org 域(机构/校区)迁移源,**只读**。见 readOnlyDatabases。
  49. "yuansiwei_i": true,
  50. "yuansiwei": true,
  51. }
  52. // writableDatabases 明确「哪些库允许写」:目标库只白名单 yuansiwei_2026。
  53. writableDatabases = map[string]bool{
  54. "yuansiwei_2026": true,
  55. }
  56. // readOnlyDatabases 明确「哪些库绝不允许写」——写语句一律在 guard 层拒绝。
  57. readOnlyDatabases = map[string]bool{
  58. "finance_bk": true,
  59. "legacy_oracle": true,
  60. "yuansiwei_i": true,
  61. "yuansiwei": true,
  62. // e2e_test 有独立的建表逻辑,本包不碰。
  63. "e2e_test": true,
  64. }
  65. )
  66. // DefaultLegacyUserID 是 fixture 选中的 legacy 用户(选择依据见 README/报告):
  67. // t_user_expense 1 行 + t_user_expense_school 1 行 + t_user_expense_log 6 条,
  68. // 且 balance=0.788 / spend=4999.212 带 3 位小数——覆盖「逐行 ROUND 到分」的取值边界。
  69. const DefaultLegacyUserID int64 = 53691
  70. // Config 是 fixture 的两条连接参数。
  71. //
  72. // 口令**只经环境变量或已有配置文件读取,不打印、不落盘、不进报告**。
  73. type Config struct {
  74. Host string
  75. Port int
  76. TargetDB string
  77. TargetUser string
  78. TargetPass string
  79. LegacyDB string
  80. LegacyUser string
  81. LegacyPass string
  82. // OrgSourceDB 是 org 域(t_school / t_school_campus)的迁移源库,只读。
  83. OrgSourceDB string
  84. OrgSourceUser string
  85. OrgSourcePass string
  86. }
  87. // dsnFile 是仓库里已存在的 dev 配置(口令已在磁盘上,本包只读取、不新增副本)。
  88. type dsnFile struct {
  89. DB struct {
  90. DSN string `json:"dsn"`
  91. } `json:"db"`
  92. }
  93. var dsnPattern = regexp.MustCompile(`^([^:]+):([^@]+)@tcp\(([^:]+):(\d+)\)/([^?]+)`)
  94. // distMasked 返回不含口令的 DSN 形态,用于日志与报告。
  95. func (c Config) TargetMasked() string {
  96. return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.TargetUser, c.Host, c.Port, c.TargetDB)
  97. }
  98. // OrgMasked 返回 org 源库不含口令的 DSN 形态。
  99. func (c Config) OrgMasked() string {
  100. return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.OrgSourceUser, c.Host, c.Port, c.OrgSourceDB)
  101. }
  102. // LegacyMasked 返回 legacy 侧不含口令的 DSN 形态。
  103. func (c Config) LegacyMasked() string {
  104. return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.LegacyUser, c.Host, c.Port, c.LegacyDB)
  105. }
  106. // LoadConfig 读取连接参数。
  107. //
  108. // 优先级:环境变量(AC15_*)> 仓库内 config/dev.json 的 db.dsn。
  109. // 之所以保留文件回退:dev 环境里 AC15_DB_PASS 未必已导出,而 dev.json 是仓库既有的
  110. // 配置载体(不新增口令副本、不打印口令)。生产/CI 走环境变量即可。
  111. func LoadConfig(repoRoot string) (Config, error) {
  112. cfg := Config{
  113. Host: envOr("AC15_DB_HOST", "127.0.0.1"),
  114. Port: envIntOr("AC15_DB_PORT", 3306),
  115. TargetDB: envOr("AC15_TARGET_DB", "yuansiwei_2026"),
  116. TargetUser: envOr("AC15_DB_USER", "dev"),
  117. TargetPass: os.Getenv("AC15_DB_PASS"),
  118. LegacyDB: envOr("AC15_LEGACY_DB", "finance_bk"),
  119. LegacyUser: envOr("AC15_LEGACY_USER", ""),
  120. LegacyPass: os.Getenv("AC15_LEGACY_PASS"),
  121. OrgSourceDB: envOr("AC15_ORG_DB", "yuansiwei_i"),
  122. OrgSourceUser: os.Getenv("AC15_ORG_USER"),
  123. OrgSourcePass: os.Getenv("AC15_ORG_PASS"),
  124. }
  125. // 目标库口令缺失时回退到仓库既有 dev.json(只读其 db.dsn,不写回)。
  126. if cfg.TargetPass == "" || cfg.TargetUser == "dev" {
  127. path := filepath.Join(repoRoot, "config", "dev.json")
  128. if raw, err := os.ReadFile(path); err == nil {
  129. var f dsnFile
  130. if err := json.Unmarshal(raw, &f); err == nil {
  131. if m := dsnPattern.FindStringSubmatch(f.DB.DSN); m != nil {
  132. if cfg.TargetPass == "" {
  133. cfg.TargetPass = m[2]
  134. }
  135. if os.Getenv("AC15_DB_USER") == "" {
  136. cfg.TargetUser = m[1]
  137. }
  138. }
  139. }
  140. }
  141. }
  142. // legacy 侧默认复用同一账号(本机 dev 账号对 finance_bk 有只读权限);
  143. // 也可用 AC15_LEGACY_USER / AC15_LEGACY_PASS 单独指定。
  144. if cfg.LegacyUser == "" {
  145. cfg.LegacyUser = cfg.TargetUser
  146. }
  147. if cfg.LegacyPass == "" {
  148. cfg.LegacyPass = cfg.TargetPass
  149. }
  150. if cfg.OrgSourceUser == "" {
  151. cfg.OrgSourceUser = cfg.TargetUser
  152. }
  153. if cfg.OrgSourcePass == "" {
  154. cfg.OrgSourcePass = cfg.TargetPass
  155. }
  156. if cfg.TargetPass == "" {
  157. return Config{}, fmt.Errorf("目标库口令不可得:请设置 AC15_DB_PASS 或在 %s/config/dev.json 提供 db.dsn", repoRoot)
  158. }
  159. return cfg, nil
  160. }
  161. func envOr(key, def string) string {
  162. if v := os.Getenv(key); v != "" {
  163. return v
  164. }
  165. return def
  166. }
  167. func envIntOr(key string, def int) int {
  168. if v := os.Getenv(key); v != "" {
  169. if n, err := strconv.Atoi(v); err == nil && n > 0 {
  170. return n
  171. }
  172. }
  173. return def
  174. }
  175. // Guard 校验 host 与库名白名单,并对**写目标**做更严的判定。
  176. //
  177. // 返回 error 而非 panic:本包由 CLI 串成多步,单步失败要能定位到是「守卫」还是「数据」,
  178. // 因此这里不做 fail-fast panic(与 dualrun 包阶段 1 的 panic 语义不同,后者是单次运行)。
  179. func (c Config) Guard() error {
  180. if !allowedHosts[c.Host] {
  181. return fmt.Errorf("数据源守卫失败:host %q 不在允许清单内(只允许本机)", maskHost(c.Host))
  182. }
  183. if !allowedDatabases[c.TargetDB] {
  184. return fmt.Errorf("数据源守卫失败:target 库 %q 不在允许清单内", c.TargetDB)
  185. }
  186. if !writableDatabases[c.TargetDB] {
  187. return fmt.Errorf("数据源守卫失败:%q 不是本包允许写的库(只允许 yuansiwei_2026)", c.TargetDB)
  188. }
  189. if !allowedDatabases[c.LegacyDB] {
  190. return fmt.Errorf("数据源守卫失败:legacy 库 %q 不在允许清单内", c.LegacyDB)
  191. }
  192. if c.OrgSourceDB != "" && !allowedDatabases[c.OrgSourceDB] {
  193. return fmt.Errorf("数据源守卫失败:org 源库 %q 不在允许清单内", c.OrgSourceDB)
  194. }
  195. if readOnlyDatabases[c.LegacyDB] == false {
  196. // legacy_oracle 虽在 allowedDatabases 内,但本包对 legacy 侧一律只读;
  197. // 这里只做提示性约束:真正保证由 extract.go 只发 SELECT 实现。
  198. _ = c.LegacyDB
  199. }
  200. return nil
  201. }
  202. // maskHost 把 host 后两段遮掉,保证日志/报告不出现完整地址。
  203. func maskHost(host string) string {
  204. parts := strings.Split(host, ".")
  205. if len(parts) == 4 {
  206. return parts[0] + "." + parts[1] + ".***.**"
  207. }
  208. return "<masked>"
  209. }
  210. func (c Config) targetDSN() string {
  211. return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
  212. c.TargetUser, c.TargetPass, c.Host, c.Port, c.TargetDB)
  213. }
  214. func (c Config) orgSourceDSN() string {
  215. return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
  216. c.OrgSourceUser, c.OrgSourcePass, c.Host, c.Port, c.OrgSourceDB)
  217. }
  218. // OpenOrgSource 打开 org 域迁移源(`yuansiwei_i`)的**只读**连接。
  219. func (c Config) OpenOrgSource() (*sql.DB, error) {
  220. db, err := openGorm(c.orgSourceDSN())
  221. if err != nil {
  222. return nil, fmt.Errorf("连接 org 源库失败:%w", err)
  223. }
  224. if err := assertDatabase(db, c.OrgSourceDB); err != nil {
  225. _ = db.Close()
  226. return nil, err
  227. }
  228. if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil {
  229. _ = db.Close()
  230. return nil, fmt.Errorf("org 源库会话只读设置失败:%w", err)
  231. }
  232. return db, nil
  233. }
  234. func (c Config) legacyDSN() string {
  235. return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
  236. c.LegacyUser, c.LegacyPass, c.Host, c.Port, c.LegacyDB)
  237. }
  238. // dsnFor 组出「同一账号、指定库」的 DSN(只读用途;写权限由 assertDatabase + 会话只读兜底)。
  239. func (c Config) dsnFor(dbName string) string {
  240. return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
  241. c.TargetUser, c.TargetPass, c.Host, c.Port, dbName)
  242. }
  243. // OpenReadOnlyDB 打开 `allowedDatabases` 中**任一**库的只读连接。
  244. //
  245. // 用途:核对旧库的定义(`SHOW CREATE PROCEDURE` / `information_schema` / 跨库视图),
  246. // 也就是"读定义而不是照着名字猜行为"这件事的落点。**不参与迁移**,也不产出任何写操作:
  247. // 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,服务端会拒绝本会话的任何写语句。
  248. //
  249. // 入参:dbName 库名(必须在 allowedDatabases 内,否则直接拒绝)。
  250. // 出参:*sql.DB(只读);error——库名不在白名单 / 连接失败 / 库名与 DATABASE() 不符。
  251. func (c Config) OpenReadOnlyDB(dbName string) (*sql.DB, error) {
  252. if !allowedDatabases[dbName] {
  253. return nil, fmt.Errorf("数据源守卫失败:库 %q 不在允许清单内(只允许 %v)", dbName, sortedKeys(allowedDatabases))
  254. }
  255. if !allowedHosts[c.Host] {
  256. return nil, fmt.Errorf("数据源守卫失败:host %q 不在允许清单内(只允许本机)", maskHost(c.Host))
  257. }
  258. db, err := openGorm(c.dsnFor(dbName))
  259. if err != nil {
  260. return nil, fmt.Errorf("连接库 %q 失败:%w", dbName, err)
  261. }
  262. if err := assertDatabase(db, dbName); err != nil {
  263. _ = db.Close()
  264. return nil, err
  265. }
  266. if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil {
  267. _ = db.Close()
  268. return nil, fmt.Errorf("库 %q 会话只读设置失败:%w", dbName, err)
  269. }
  270. return db, nil
  271. }
  272. // sortedKeys 返回白名单的稳定顺序(错误信息里可复现,不随 map 遍历顺序抖动)。
  273. func sortedKeys(m map[string]bool) []string {
  274. out := make([]string, 0, len(m))
  275. for k := range m {
  276. out = append(out, k)
  277. }
  278. sort.Strings(out)
  279. return out
  280. }
  281. // openGorm 用 gorm 的 mysql driver 打开连接(复用仓库既有依赖,不新增 driver)。
  282. func openGorm(dsn string) (*sql.DB, error) {
  283. gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
  284. Logger: logger.Default.LogMode(logger.Silent),
  285. DisableAutomaticPing: false,
  286. })
  287. if err != nil {
  288. return nil, err
  289. }
  290. sqlDB, err := gdb.DB()
  291. if err != nil {
  292. return nil, err
  293. }
  294. sqlDB.SetMaxOpenConns(2)
  295. sqlDB.SetMaxIdleConns(2)
  296. sqlDB.SetConnMaxLifetime(5 * time.Minute)
  297. return sqlDB, nil
  298. }
  299. // OpenTarget 打开目标库连接,并二次核对 SELECT DATABASE()。
  300. func (c Config) OpenTarget() (*sql.DB, error) {
  301. db, err := openGorm(c.targetDSN())
  302. if err != nil {
  303. return nil, fmt.Errorf("连接目标库失败:%w", err)
  304. }
  305. if err := assertDatabase(db, c.TargetDB); err != nil {
  306. _ = db.Close()
  307. return nil, err
  308. }
  309. return db, nil
  310. }
  311. // OpenLegacy 打开 legacy **只读**连接。
  312. //
  313. // 只读不是口头纪律:连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,
  314. // 服务端会把本会话的写语句直接拒绝(MySQL 5.6+ 支持 session 级 read only)。
  315. func (c Config) OpenLegacy() (*sql.DB, error) {
  316. db, err := openGorm(c.legacyDSN())
  317. if err != nil {
  318. return nil, fmt.Errorf("连接 legacy 库失败:%w", err)
  319. }
  320. if err := assertDatabase(db, c.LegacyDB); err != nil {
  321. _ = db.Close()
  322. return nil, err
  323. }
  324. if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil {
  325. _ = db.Close()
  326. return nil, fmt.Errorf("legacy 会话只读设置失败(拒绝在可写会话上跑比对):%w", err)
  327. }
  328. return db, nil
  329. }
  330. // assertDatabase 让服务器自己报出当前库再比对(只写 DSN 库名不够)。
  331. func assertDatabase(db *sql.DB, want string) error {
  332. var got sql.NullString
  333. if err := db.QueryRow("SELECT DATABASE()").Scan(&got); err != nil {
  334. return err
  335. }
  336. if !got.Valid || got.String != want {
  337. return fmt.Errorf("当前库 %q 与期望 %q 不一致", got.String, want)
  338. }
  339. return nil
  340. }
  341. // moneyToleranceCents 是金额比对的硬容差:0 分——逐分必须相等。
  342. const moneyToleranceCents int64 = 0
  343. // absInt64 取绝对值(仅用于报告展示差值)。
  344. func absInt64(v int64) int64 {
  345. if v < 0 {
  346. return -v
  347. }
  348. return v
  349. }
  350. // float64Of 把十进制字符串解析成 float64——**仅用于「float 陷阱」演示**,
  351. // 生产映射路径禁止用 float 做金额运算(见 decimal.go 的整数分换算)。
  352. func float64Of(s string) float64 {
  353. v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
  354. if err != nil {
  355. return math.NaN()
  356. }
  357. return v
  358. }