batch2_mapping.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package fixture
  2. import (
  3. "database/sql"
  4. "fmt"
  5. "regexp"
  6. "time"
  7. )
  8. // 本文件是**第二批 4 张表的纯映射规则**(不连库、不取当前时间,输入即输出,全部可单测)。
  9. //
  10. // 沿用的既有口径(上一批实测拍板,不重复讨论):
  11. // · 金额 DECIMAL(12,2):逐行 ROUND(x,2);legacy float 先 CAST 成十进制串再整数分换算
  12. // · 时间 datetime(0) → DATETIME(3):秒级原样,毫秒补 .000,**不使用当前时间**
  13. // · request_id / balance_after / create_user_id=0 一律 NULL(不落 0、不落空串)
  14. // hex32 校验旧 UUID 主键形态(32 位小写 hex)。
  15. var hex32 = regexp.MustCompile(`^[0-9a-f]{32}$`)
  16. // NormalizeBizNo 校验并原样返回 biz_no。
  17. //
  18. // 三条硬约束(任一条不满足就**报错中止**,绝不截断、绝不改成新 UUID):
  19. // 1. 非空
  20. // 2. 长度 == 32(target 是 VARCHAR(32))
  21. // 3. 全为小写 hex
  22. //
  23. // 依据:biz_no 是双跑与回滚的**唯一对齐锚点**,一旦被截断/改写,割接期就再也找不回旧行。
  24. func NormalizeBizNo(legacyID string) (string, error) {
  25. if legacyID == "" {
  26. return "", fmt.Errorf("biz_no: legacy 主键为空")
  27. }
  28. if len(legacyID) != 32 {
  29. return "", fmt.Errorf("biz_no %q 长度 %d ≠ 32(target 是 VARCHAR(32)),拒绝截断", legacyID, len(legacyID))
  30. }
  31. if !hex32.MatchString(legacyID) {
  32. return "", fmt.Errorf("biz_no %q 不是 32 位小写 hex,形态与旧 UUID 主键约定不符,拒绝猜测", legacyID)
  33. }
  34. return legacyID, nil
  35. }
  36. // resolveUserRef 把 legacy int 用户引用解析成 ULID;legacy 0 / NULL / 无映射 → NULL。
  37. //
  38. // 这是本批所有「引用人」列的统一口径:root_userid / parent_userid / userid / studentid /
  39. // request_userid / response_userid / create_userid 全部走它。
  40. func resolveUserRef(v sql.NullInt64, resolve CreatorResolver, col string, warns *[]string) sql.NullString {
  41. if !v.Valid || v.Int64 <= 0 {
  42. return sql.NullString{}
  43. }
  44. uid, ok := resolve(v.Int64)
  45. if !ok {
  46. *warns = append(*warns, fmt.Sprintf("%s:legacy %d 无 t_legacy_mapping 行 → NULL", col, v.Int64))
  47. return sql.NullString{}
  48. }
  49. return sql.NullString{String: uid, Valid: true}
  50. }
  51. // requireUserRef 用于**目标态 NOT NULL** 的引用人列(user_id / student_id / request_user_id):
  52. // 解析不到就报错,不允许静默落 NULL 或空串。
  53. func requireUserRef(v int64, resolve CreatorResolver, col string) (string, error) {
  54. if v <= 0 {
  55. return "", fmt.Errorf("%s:legacy 值为 %d,目标态 NOT NULL 且语义上必须指向一个真实用户", col, v)
  56. }
  57. uid, ok := resolve(v)
  58. if !ok {
  59. return "", fmt.Errorf("%s:legacy %d 无 t_legacy_mapping 行,目标态 NOT NULL —— 需先补映射行", col, v)
  60. }
  61. return uid, nil
  62. }
  63. // LegacyMissingTime 是「legacy 侧没有这个字段」时的确定性哨兵值。
  64. //
  65. // 用途:目标态 created_at / updated_at 是 NOT NULL 且**无默认值**(GORM AutoMigrate 建的),
  66. // 而 legacy `t_user_divide_into` 根本没有时间列。此时只能用哨兵,**绝不能用 NOW()**——
  67. // 一旦用当前时间,同一份数据每次迁移结果都不同,双跑/回滚就失去可比性。
  68. const LegacyMissingTime = "1970-01-01 00:00:00"
  69. // epochSentinel 返回 LegacyMissingTime 对应的 time.Time(本地时区,秒级)。
  70. func epochSentinel() time.Time {
  71. return time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local)
  72. }
  73. // mapTime3 把 legacy datetime(0) 规整成「秒级、毫秒 0」的时间,落 DATETIME(3) 即 .000。
  74. func mapTime3(t time.Time) time.Time {
  75. return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, t.Location())
  76. }
  77. // truncateTo 按字符截断到 max(target VARCHAR 列),返回是否发生截断。
  78. func truncateTo(v sql.NullString, max int, col string, warns *[]string) sql.NullString {
  79. if !v.Valid {
  80. return sql.NullString{}
  81. }
  82. rs := []rune(v.String)
  83. if len(rs) <= max {
  84. return sql.NullString{String: v.String, Valid: true}
  85. }
  86. *warns = append(*warns, fmt.Sprintf("%s 超过 %d 字符,已按字符截断(目标列长度限制)", col, max))
  87. return sql.NullString{String: string(rs[:max]), Valid: true}
  88. }
  89. // MapDivideInto 映射分账树节点。
  90. //
  91. // 空值语义:
  92. // - `parent_userid = 0` → **NULL**(根节点无上级);legacy 里根节点的 root_userid = 自己,原样保留
  93. // - 金额 NULL → 0.00 且告警(目标 NOT NULL)
  94. func MapDivideInto(l LegacyDivideInto, resolve CreatorResolver) (TargetDivideInto, []string, error) {
  95. var warns []string
  96. uid, err := requireUserRef(l.UserID, resolve, "t_user_divide_into.userid")
  97. if err != nil {
  98. return TargetDivideInto{}, warns, err
  99. }
  100. out := TargetDivideInto{
  101. UserID: uid,
  102. Type: int8(l.Type),
  103. RootUserID: resolveUserRef(l.RootUserID, resolve, "t_user_divide_into.root_userid", &warns),
  104. ParentUserID: resolveUserRef(l.ParentUserID, resolve,
  105. "t_user_divide_into.parent_userid", &warns),
  106. LeftVal: l.LeftVal,
  107. RightVal: l.RightVal,
  108. Layer: l.Layer,
  109. // legacy 无时间列 → epoch 哨兵(确定性,不取当前时间)
  110. CreatedAt: epochSentinel(),
  111. UpdatedAt: epochSentinel(),
  112. }
  113. type col struct {
  114. name string
  115. in sql.NullString
  116. out *int64
  117. }
  118. for _, c := range []col{
  119. {"quota", l.Quota, &out.QuotaCents},
  120. {"total_income", l.TotalIncome, &out.TotalIncomeCents},
  121. {"total_income_payed", l.TotalIncomePayed, &out.TotalIncomePayedCents},
  122. } {
  123. s, present := moneyOrZero(c.in)
  124. if !present {
  125. warns = append(warns, fmt.Sprintf("t_user_divide_into(%d).%s 在 legacy 为 NULL → 落 0.00", l.UserID, c.name))
  126. }
  127. cents, err := DecimalToCents(s)
  128. if err != nil {
  129. return TargetDivideInto{}, warns, fmt.Errorf("t_user_divide_into(%d).%s:%w", l.UserID, c.name, err)
  130. }
  131. *c.out = cents
  132. }
  133. return out, warns, nil
  134. }
  135. // MapDivideIntoLog 映射分账明细(D1:id 原样保留)。
  136. func MapDivideIntoLog(l LegacyDivideIntoLog, resolve CreatorResolver) (TargetDivideIntoLog, []string, error) {
  137. var warns []string
  138. uid, err := requireUserRef(l.UserID, resolve, "t_user_divide_into_log.userid")
  139. if err != nil {
  140. return TargetDivideIntoLog{}, warns, err
  141. }
  142. sid, err := requireUserRef(l.StudentID, resolve, "t_user_divide_into_log.studentid")
  143. if err != nil {
  144. return TargetDivideIntoLog{}, warns, err
  145. }
  146. out := TargetDivideIntoLog{
  147. ID: l.ID, // D1
  148. Type: int8(l.Type),
  149. UserID: uid,
  150. StudentID: sid,
  151. OrderNo: truncateTo(l.OrderNo, 50, fmt.Sprintf("t_user_divide_into_log(%d).orderid", l.ID), &warns),
  152. Remark: l.Remark, // text → LONGTEXT,不截断
  153. }
  154. s, present := moneyOrZero(l.Money)
  155. if !present {
  156. warns = append(warns, fmt.Sprintf("t_user_divide_into_log(%d).money 在 legacy 为 NULL → 落 0.00", l.ID))
  157. }
  158. cents, err := DecimalToCents(s)
  159. if err != nil {
  160. return TargetDivideIntoLog{}, warns, fmt.Errorf("t_user_divide_into_log(%d).money:%w", l.ID, err)
  161. }
  162. out.MoneyCents = cents
  163. if !l.CreateTime.Valid {
  164. return TargetDivideIntoLog{}, warns, fmt.Errorf(
  165. "t_user_divide_into_log(%d).create_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", l.ID)
  166. }
  167. out.CreateTime = mapTime3(l.CreateTime.Time)
  168. return out, warns, nil
  169. }
  170. // MapBalanceRequest 映射充值/退费工单(**biz_no 口径**:旧 UUID 主键原样进 biz_no)。
  171. //
  172. // 空值语义:
  173. // - `response_userid = 0` → **NULL**(未审批/系统)
  174. // - `response_time` / `transfer_accounts_time` / `orderid` NULL → NULL
  175. // - `response_msg` / `remark` 的**空串保留空串**(legacy 语义就是「填了但为空」,
  176. // 不是「没填」;这两个列在 target 侧也没有唯一键依赖,不存在上一批
  177. // `uk_request_type` 那种「空串撞键」的风险)
  178. // - `to_institution_id` / `to_campus_id`:legacy **无对应列** → NULL(见报告「外键未做清单」)
  179. func MapBalanceRequest(l LegacyBalanceRequest, resolve CreatorResolver) (TargetBalanceRequest, []string, error) {
  180. var warns []string
  181. bizNo, err := NormalizeBizNo(l.BizNo)
  182. if err != nil {
  183. return TargetBalanceRequest{}, warns, fmt.Errorf("t_user_balance_request.%w", err)
  184. }
  185. uid, err := requireUserRef(l.UserID, resolve, "t_user_balance_request.userid")
  186. if err != nil {
  187. return TargetBalanceRequest{}, warns, err
  188. }
  189. reqUID, err := requireUserRef(l.RequestUserID, resolve, "t_user_balance_request.request_userid")
  190. if err != nil {
  191. return TargetBalanceRequest{}, warns, err
  192. }
  193. if !l.RequestTime.Valid {
  194. return TargetBalanceRequest{}, warns, fmt.Errorf(
  195. "t_user_balance_request(%s).request_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", bizNo)
  196. }
  197. out := TargetBalanceRequest{
  198. BizNo: bizNo,
  199. Type: l.Type,
  200. UserID: uid,
  201. InstitutionID: l.SchoolID, // schoolid → institution_id(org 域映射未做,直迁)
  202. CampusID: l.CampusID, // campusid → campus_id(同上)
  203. TransferAccountsType: l.TransferAccountsType,
  204. OrderNo: truncateTo(l.OrderNo, 50, "t_user_balance_request("+bizNo+").orderid", &warns),
  205. RequestUserID: reqUID,
  206. RequestTime: mapTime3(l.RequestTime.Time),
  207. ResponseUserID: resolveUserRef(l.ResponseUserID, resolve, "t_user_balance_request.response_userid", &warns),
  208. ResponseMsg: truncateTo(l.ResponseMsg, 1000, "t_user_balance_request("+bizNo+").response_msg", &warns),
  209. Approved: l.Aproved,
  210. Item: truncateTo(l.Item, 1000, "t_user_balance_request("+bizNo+").item", &warns),
  211. Imgs: l.Imgs, // text → LONGTEXT
  212. Remark: truncateTo(l.Remark, 1000, "t_user_balance_request("+bizNo+").remark", &warns),
  213. }
  214. if l.TransferAccountsTime.Valid {
  215. out.TransferAccountsTime = sql.NullTime{Time: mapTime3(l.TransferAccountsTime.Time), Valid: true}
  216. }
  217. if l.ResponseTime.Valid {
  218. out.ResponseTime = sql.NullTime{Time: mapTime3(l.ResponseTime.Time), Valid: true}
  219. }
  220. // created_at / updated_at:目标态 NOT NULL 且无默认值,由 legacy 真实时间派生(不取当前时间)
  221. out.CreatedAt = mapTime3(l.RequestTime.Time)
  222. out.UpdatedAt = out.CreatedAt
  223. if l.ResponseTime.Valid {
  224. out.UpdatedAt = mapTime3(l.ResponseTime.Time)
  225. }
  226. s, present := moneyOrZero(l.Money)
  227. if !present {
  228. warns = append(warns, fmt.Sprintf("t_user_balance_request(%s).money 在 legacy 为 NULL → 落 0.00", bizNo))
  229. }
  230. cents, err := DecimalToCents(s)
  231. if err != nil {
  232. return TargetBalanceRequest{}, warns, fmt.Errorf("t_user_balance_request(%s).money:%w", bizNo, err)
  233. }
  234. out.MoneyCents = cents
  235. return out, warns, nil
  236. }
  237. // MapExpenditureLog 映射收支记录(**biz_no 口径**)。
  238. //
  239. // `expenditure_id`:legacy `expenditureid` 指向 `t_expenditure.id`,字典表走 D1(id 原样),
  240. // 因此这里是**恒等映射**;校验在 verify 阶段用 JOIN 字典表做,不在这里猜。
  241. func MapExpenditureLog(l LegacyExpenditureLog, resolve CreatorResolver) (TargetExpenditureLog, []string, error) {
  242. var warns []string
  243. bizNo, err := NormalizeBizNo(l.BizNo)
  244. if err != nil {
  245. return TargetExpenditureLog{}, warns, fmt.Errorf("t_expenditure_log.%w", err)
  246. }
  247. out := TargetExpenditureLog{
  248. BizNo: bizNo,
  249. InstitutionID: l.SchoolID, // schoolid → institution_id(org 域映射未做,直迁)
  250. CampusID: l.CampusID,
  251. ExpenditureID: l.ExpenditureID,
  252. CreateUserID: resolveUserRef(l.CreateUserID, resolve, "t_expenditure_log("+bizNo+").create_userid", &warns),
  253. Year: l.Year,
  254. Month: l.Month,
  255. Remark: l.Remark, // text → LONGTEXT
  256. Imgs: l.Imgs,
  257. }
  258. s, present := moneyOrZero(l.Cost)
  259. if !present {
  260. warns = append(warns, fmt.Sprintf("t_expenditure_log(%s).cost 在 legacy 为 NULL → 落 0.00", bizNo))
  261. }
  262. cents, err := DecimalToCents(s)
  263. if err != nil {
  264. return TargetExpenditureLog{}, warns, fmt.Errorf("t_expenditure_log(%s).cost:%w", bizNo, err)
  265. }
  266. out.CostCents = cents
  267. if !l.CreateTime.Valid {
  268. return TargetExpenditureLog{}, warns, fmt.Errorf(
  269. "t_expenditure_log(%s).create_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", bizNo)
  270. }
  271. out.CreateTime = mapTime3(l.CreateTime.Time)
  272. return out, warns, nil
  273. }
  274. // MapExpenditure 映射收支类型字典(D1:id 原样保留,使 expenditure_id 在双跑里可对齐)。
  275. func MapExpenditure(l LegacyExpenditure) (TargetExpenditure, []string, error) {
  276. var warns []string
  277. out := TargetExpenditure{
  278. ID: l.ID, // D1
  279. Type: l.Type,
  280. ParentID: l.ParentID,
  281. Name: l.Name,
  282. Remark: l.Remark,
  283. InstitutionID: 0, // 旧库无作用域列 → 0(平台通用字典)
  284. }
  285. if len([]rune(l.Name)) > 500 {
  286. return TargetExpenditure{}, warns, fmt.Errorf(
  287. "t_expenditure(%d).name 超 500 字符(目标 varchar(500)),拒绝截断字典名称", l.ID)
  288. }
  289. return out, warns, nil
  290. }