| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- // 十进制「定点格式化」纯函数(**不碰数据库、不碰时钟**)——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:])
- }
|