云端 Mac 上验收 iOS 金额计算:Decimal 精度与舍入契约

云端 Mac 上验收 iOS 金额计算:Decimal 精度与舍入契约

订单明细逐项相加与服务端总额相差一分,退款金额又在负数中点出现偏差:这类故障不能靠截图验收。把金额解析和舍入规则从界面中抽成纯函数,在云端 Mac 上运行固定样本,先证明双方计算契约一致,再接入订单页面。本文只处理数值规则,不连接支付网关,也不使用真实交易数据。

先写清金额计算契约

测试之前确定四件事:输入允许几位小数、业务结算保留几位、在哪一步舍入、恰好落在中点时向哪边取值。下面采用最多六位输入小数、两位结算小数,以及中点远离零的规则。这只是示例契约,不适用于所有业务。

折扣率、单价和结算金额不是同一种量。单价乘数量后可以保留中间精度,但最终金额必须经过约定的结算函数。不要让每个调用方自行格式化并转回数值。

Decimal 解决十进制表示问题,不替你决定业务规则。逐项舍入与合计后舍入都可能合理,但不能在同一订单中混用。

先确认服务端返回的是十进制字符串还是整数最小单位。如果实际返回浮点数,需要先调整接口契约;客户端无法从已经丢失的精度中恢复原值。

建立可独立运行的规则包

在空目录创建 Swift 库包,将规则代码放进生成的源文件,将测试写进对应测试目录。命令在云端 Mac 的项目目录执行,不需要启动模拟器。

mkdir MoneyRules
cd MoneyRules
swift package init --type library --name MoneyRules
swift --version

用下面实现替换 Sources/MoneyRules/MoneyRules.swift。固定解析区域,限制输入长度,并拒绝千位分隔符、指数形式、空白和超出约定的小数位。这里处理接口数据,不负责用户本地化输入。

import Foundation

public enum MoneyRules {
    public static func parse(_ text: String) -> Decimal? {
        guard text.utf8.count <= 20,
              text.range(
                  of: #"^-?[0-9]+(?:\.[0-9]{1,6})?\z"#,
                  options: .regularExpression
              ) != nil else {
            return nil
        }
        return Decimal(
            string: text,
            locale: Locale(identifier: "en_US_POSIX")
        )
    }

    public static func settle(_ value: Decimal) -> Decimal {
        var input = value
        var output = Decimal()
        NSDecimalRound(&output, &input, 2, .plain)
        return output
    }
}

不要写 Decimal(Double(text)!)。即使最后得到 Decimal,中间的二进制浮点转换也已经改变了数值来源。长度上限同样需要对应真实业务范围;不能为了通过测试任意截断金额。

用中点与计算顺序抓住差异

将下列内容写入 Tests/MoneyRulesTests/MoneyRulesTests.swift,替换默认测试。精确比较 Decimal,不加“允许误差”的容忍区间,否则一分钱的差异可能被测试主动隐藏。

import XCTest
@testable import MoneyRules

final class MoneyRulesTests: XCTestCase {
    private func number(_ text: String) throws -> Decimal {
        try XCTUnwrap(MoneyRules.parse(text))
    }

    func testMidpoints() throws {
        XCTAssertEqual(
            MoneyRules.settle(try number("1.005")),
            try number("1.01")
        )
        XCTAssertEqual(
            MoneyRules.settle(try number("-1.005")),
            try number("-1.01")
        )
        XCTAssertEqual(
            MoneyRules.settle(try number("1.004")),
            try number("1.00")
        )
    }

    func testRoundingStage() throws {
        let item = try number("0.335")
        let perLine = MoneyRules.settle(item) * Decimal(3)
        let total = MoneyRules.settle(item * Decimal(3))
        XCTAssertEqual(perLine, try number("1.02"))
        XCTAssertEqual(total, try number("1.01"))
    }

    func testRejectedInputs() {
        for text in ["", "1,25", "1e2", "NaN", " 1.00",
                     "1.0000001", "1.00\n"] {
            XCTAssertNil(MoneyRules.parse(text))
        }
    }
}

执行 swift test,确认三个测试全部通过。计算顺序测试不是在推荐某一种结算方式,而是在展示两者确实不同。业务选定其中一种后,还应增加订单级测试,阻止调用链换用另一种。

继续补充零值、负零、退款、最大允许金额,以及低于、等于、高于中点的三组样本。若改用中点取偶数的规则,应重新建立对应期望值,而不是只修改实现。

把传输与显示边界分开验收

接口传输不能依赖界面格式化器。展示可以添加分组符号或按地区调整小数分隔符,传输仍使用约定的十进制字符串。下面检查结算结果转成字符串后能否精确读回;它不保证字符串固定有两位小数。

import Foundation

var settled = MoneyRules.settle(MoneyRules.parse("12.345")!)
let wire = NSDecimalString(
    &settled,
    Locale(identifier: "en_US_POSIX")
)
precondition(MoneyRules.parse(wire) == settled)

把这项检查放进测试,而不是让生产程序遇到坏数据时直接终止。如果协议要求 "12.30" 这样的固定格式,应另外实现并测试编码器,不能仅凭数值相等就验收字符串。

跨端样本保存输入字符串、数量、舍入阶段和期望字符串,不保存真实订单号。服务端和客户端分别读取同一组样本,才能确认双方采用了相同规则。

接入流水线并保留可复查证据

先运行独立包测试,再执行应用测试。独立包失败就停止后续打包,避免把数值规则错误拖到界面验收阶段。记录提交标识、Swift 版本和失败样本,不用耗时数字代替正确性结论。

检查项保持短而明确:

纯 Foundation 测试通过,只证明计算层符合当前契约。最后仍需在 iOS 测试目标中验证页面输入和展示,并将发现的分位差异补成固定样本。每次修复都留下能重跑的测试,比记住一次人工核对结果更可靠。

常见问题

使用 Decimal 后,金额计算就不会出错了吗?

不会。先经 Double 转换、过早舍入或两端采用不同的舍入规则,仍会造成差异。应从受限的十进制字符串创建 Decimal,并明确在哪一步舍入。

金额计算测试必须启动 iOS 模拟器吗?

纯 Foundation 计算规则可以放进 Swift 包,在云端 Mac 上执行 swift test。界面输入、本地化显示及 iOS 专属代码仍需单独运行应用测试。

把构建队列交给独享云端 Mac

选择物理节点、机型与计费周期,让 Xcode 构建和自动化任务在自己的 macOS 环境中运行。实际可用状态以控制台实时返回为准。

继续核对你的构建环境

对照机型规格与操作说明,确定下一步配置。