// 十进制「定点格式化」纯函数(**不碰数据库、不碰时钟**)——AC-13d 追加。 // // --------------------------------------------------------------------------- // 为什么必须有这一层(不是"多此一举的漂亮化") // --------------------------------------------------------------------------- // // 库里三类"小数字符串"列(`decimal(5,1)` 比例 / `decimal(10,3)` 教龄 / `decimal(12,2)` 金额) // 都以**字符串**进出 Go,于是同一个值在不同环境里长成不同样子: // // MySQL 8 读 decimal(5,1) 的 85 → "85.0" // SQLite(单测) 读 numeric 亲和列 → "85" ← 尾部 0 被吃掉 // 前端刚才写入 "85.00"(多打一位) → "85.00" // // 三种形态**数值相等、字节不等**。后果不是"不好看",而是三条实打实的故障: // // 1. **前端 diff 误判**:编辑弹窗把接口回的值原样回填,第二次提交时"85" ≠ "85.0", // 被认为用户改过 ⇒ 生成一条脏变更(配置类页面最常见)。 // 2. **单测在两个方言之间摇摆**:断言 `"85.0"` 在 SQLite 上必红、断言 `"85"` 在 MySQL 上必红 ⇒ // 要么写 `mustDecimal` 把断言弱化成"数值对就行"(那就丢了"量纲/位数"这条防线), // 要么测试骨架随数据库变。**格式化到列声明的位数,两端都稳。** // 3. **金额与比例的"尺寸"混淆无人察觉**:`percentage` 如果被按金额格式化(1 位 vs 2 位), // `"85.0" → "85.00"` 恰好数值不变 ⇒ 量纲被污染的 bug 静默通过。**位数本身就是量纲指纹。** // // 因此:**出口统一按"列声明的 scale"定点化**,入口只做区间校验。本文件是出口的唯一实现点。 // // --------------------------------------------------------------------------- // 实现约束 // --------------------------------------------------------------------------- // // - **零 float64**:百分比/教龄虽不是资金,但"用浮点格式化再拼字符串"在 0.1 这种十进制 // 不可表示值上会产生 `84.99999999999999` 之类的残渣;本文件全程字符串 + int64。 // - **纯函数**:无 IO、无全局状态、无时区依赖 ⇒ 可以脱离数据库做单测(`decimal_format_test.go`)。 // - **不静默吞错**:非法文本返回错误,由调用方映射(Handler 侧统一 5002「数据不一致」, // 因为走到格式化时数据已经出库,格式坏 = 库里写坏了,不是用户参数问题,**不能回 2002**)。 package service import "strings" // 各类列的 scale(小数位数)——**与 DDL 声明一致,不要在这里"顺手统一"**。 const ( // ScalePercentage `t_course_payment_detail.percentage` 是 `decimal(5,1)` ⇒ 1 位。 ScalePercentage = 1 // ScaleYears `t_course_payment_detail.teacher_exp_min/max` 是 `decimal(10,3)` ⇒ 3 位。 ScaleYears = 3 // ScaleYuan 金额列统一 `decimal(12,2)` ⇒ 2 位。 ScaleYuan = 2 ) // FormatPercentage 把百分数字符串定点化到 1 位小数("85" → "85.0","85.00" → "85.0")。 // // 入参:text 库里的百分数("85" / "85.0" / "85.00")。 // 出参:(定点化字符串, error);空串/非数字/超过 int64 量级 → ErrInvalidFormat。 // // ⚠ **只格式化,不改量纲**:`"85"` 是 85%,不是 0.85。需要比率时由 `applyPercentage` 除 1000, // 不要在这里动手脚(否则"格式化了"会被误认为"归一化了")。 func FormatPercentage(text string) (string, error) { return FormatFixed(text, ScalePercentage) } // FormatYears 把教龄字符串定点化到 3 位小数("1" → "1.000","5" → "5.000")。 // // 入参:text 库里的教龄区间端点("0" / "1.000" / "5")。 // 出参:(定点化字符串, error);非法 → ErrInvalidFormat。 // // 为什么教龄也要固定位数:它是**区间比较的展示值**(前端拿它判断"我这一档是不是到了"), // `0` 与 `0.000` 在 JS 里一个是 number 一个是 string,跨端比较会出现 `"0" > "0.000"` 之类的字符串比较坑。 func FormatYears(text string) (string, error) { return FormatFixed(text, ScaleYears) } // FormatYuan 把金额字符串定点化到 2 位小数("100" → "100.00","12.3" → "12.30")。 // // 入参:text 库里的金额("0" / "100" / "300.00";空串按 0 处理,与 `parseAmount` 一致)。 // 出参:(定点化字符串, error);非法 → ErrDBError(**不是 2002**:出库数据坏 = 库写坏了)。 // // 内部走 `parseAmount(...).String()`:解析与输出都在定点整数域(`money` 包已禁 float64), // 这里不重复实现一遍舍入,**只有一处舍入规则**(第 3 位 ≥5 进位)。 func FormatYuan(text string) (string, error) { amt, err := parseAmount(text) if err != nil { return "", err } return amt.String(), nil } // FormatFixed 把十进制字符串四舍五入到固定小数位数(本包定点化的唯一实现)。 // // 入参: // - text 十进制字符串,允许前后空格与正负号("85" / "-0.01" / "1.23456"); // - scale 目标小数位(0~3,见 Scale* 常量)。 // // 出参:(定点化字符串, error);以下情形返回 ErrInvalidFormat: // 空串、只有符号、整数位非数字(含 ".5")、小数位非数字(含 "12.")、整数位超过 12 位(量级异常)。 // // 舍入:第 scale+1 位 ≥5 时进位(远离零方向),与 `money.NewFromYuanString` 同规则。 // 负零归一:`"-0.0"` → `"0.0"`(不产生 `-0.0`,否则前端 `Number("-0.0")` 得到 -0,diff 又会误判)。 func FormatFixed(text string, scale int) (string, error) { s := strings.TrimSpace(text) if s == "" { return "", errString("decimal: 空字符串无法定点化") } neg := false switch s[0] { case '-': neg = true s = s[1:] case '+': s = s[1:] } if s == "" { return "", errString("decimal: 只有符号") } intPart, fracPart, hasDot := strings.Cut(s, ".") if intPart == "" || !allDigits(intPart) { return "", errString("decimal: 整数位非法 " + text) } if hasDot && (fracPart == "" || !allDigits(fracPart)) { return "", errString("decimal: 小数位非法 " + text) // "12." 与 "12.a" 都拒(库里的值不该长这样) } // 量级护栏:整数位超过 12 位即超出本项目任何 decimal 列(decimal(12,2) 上限 10 位整数), // 继续算 int64 会静默溢出 ⇒ 明确拒绝(宁可 5002,也不要吐出错误金额)。 if len(intPart) > 12 { return "", errString("decimal: 整数位过长(超过 12 位)" + text) } // 保留 scale 位,看下一位决定进位 kept := fracPart roundUp := false if len(kept) > scale { roundUp = kept[scale] >= '5' kept = kept[:scale] } for len(kept) < scale { kept += "0" } var units int64 // 放大 10^scale 后的整数 for _, ch := range intPart { units = units*10 + int64(ch-'0') } for i := 0; i < scale; i++ { units *= 10 } var frac int64 for i := 0; i < len(kept); i++ { frac = frac*10 + int64(kept[i]-'0') } if roundUp { frac++ // 99…9 + 1 由 int64 相加自然向整数位进位 } units += frac return renderFixed(units, scale, neg), nil } // renderFixed 把"放大 10^scale 后的整数"渲染成定点字符串(FormatFixed 的输出侧)。 // // 入参:units 放大值(非负);scale 小数位;neg 是否为负。 // 出参:定点字符串(`units=850, scale=1` → "85.0";`units=10000, scale=2` → "100.00")。 func renderFixed(units int64, scale int, neg bool) string { if scale <= 0 { text := formatInt64(units) if neg && units != 0 { return "-" + text } return text } div := int64(1) for i := 0; i < scale; i++ { div *= 10 } whole := units / div frac := units % div fracText := formatInt64(frac) for len(fracText) < scale { fracText = "0" + fracText } text := formatInt64(whole) + "." + fracText if neg && units != 0 { return "-" + text } return text } // formatInt64 把非负 int64 转十进制(避免为本文件拉 strconv 进热路径的语感,也便于统一)。 func formatInt64(v int64) string { if v == 0 { return "0" } var buf [20]byte i := len(buf) for v > 0 { i-- buf[i] = byte('0' + v%10) v /= 10 } return string(buf[i:]) }