config.go 12 KB

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