IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    资损防控:搞定跨境交易系统中金额处理规范

    隐墨星辰发表于 2025-01-05 14:12:11
    love 0

    在跨境交易日益频繁的当下,资损防控成为支付系统设计的关键环节。金额处理的规范性直接关系到资金安全与企业信誉。本文将深入探讨交易系统中金额计算、存储、传输的最佳实践,详细剖析常见资损场景及解决方案。

    这篇文章主要讲清楚:交易系统(电商、支付等)金额处理常见资损场景,如何构建一个适合跨境支付业务(中国业务更不在话下)的Money类,应用Money的最佳实践,包括计算、存储、传输,目的是在金额处理上减少资损风险。

    一、背景

    前几天有读者私聊我问了几个问题:

    “做国际支付,不同的币种的最小单位不同,比如人民币是分,日元是元,那数据库里面应该应该保存整数还是小数?”

    “从哪里获取到这个币种的最小单位是多少?”

    “我赞成你说的不应该直接对金额进行加减乘除操作,但我还是不知道怎么做,怎样才能落地呢?”

    以前在公司时,也有兄弟部门因为金额处理不当,导致了好几万的资损故障,然后过来问我金额处理的最佳实践,当时也给他们做过一次相关分享。

    二、金额处理场景

    从上图可以看到,对于交易系统而言,一共有下面几种场景需要做金额处理:

    1. 接收外部请求。比如商户下单100元,或用户转账1000元。
    2. 内部应用处理。比如计算手续费等。
    3. 内部应用保存到数据库,从数据库读取。
    4. 内部应用之间传输。
    5. 发送给外部系统或银行渠道。比如向银行请求扣款100元。

    三、金额计算常见误区及严重后果

    对于研发经验不足的团队而言,经常会犯以下几种错误:

    • 没有定义统一的Money类,各系统间使用BigDecimal、double、long等数据类型进行金额处理及存储。
    • 定义了统一的Money类,但是写代码时不严格遵守,仍然有些代码使用BigDecimal、double、long等数据类型进行金额处理。
    • 手动对金额进行加、减、乘、除运算,单位(元与分)换算。

    带来的后果,通常就是资金损失,再细化一下,最常见的情况有下面3种:

    1)手动做单位换算导致金额被放大或缩小100倍。

    • 比如大家规定传的是元,但是其中有位同学忘记了,以为传的是分,外部渠道要求传元,就手动乘以100。或者反过来。
    • 还有一种情况,部分币种比如日元最小单元就是元,假如系统约定传的是分,外部渠道要求传元,就可能在网关处理时手动乘以100。

    2)1分钱归属问题。比如结算给商家,或计算手续费时,碰到除不尽时,使用四舍五入,还是向零舍入,还是银行家舍入?这取决于财务策略。

    3)精度丢失。在大金额时,double有可能会有精度丢失问题。

    四、金额处理原则

    直接上答案:

    • 制定适用于公司业务的Money类来统一处理金额。
    • 在入口网关接收到请求后,就转换为Money类。
    • 所有内部应用的金额处理,强制全部使用Money类运算、传输,禁止自己手动加减乘除、单位换算(比如元到分)。
    • 数据库使用DECIMAL类型保存,保存单位为元。
    • 在出口网关外发时,再根据外部接口文档要求,转换成使用指定的单位。有些是元,有些是分(最小货币单位)

    五、制定Money类

    JAVA有制定金额处理规范JSR 354(Java Specification Request 354),对应的实现包是Java Money API(javax.money),它提供了一套用于处理货币和货币计算的API。不过我们通常选择实现自己的Money类,主要是方便,可以自由定制,比如小数舍入问题。

    一个Money类通常包括以下几个主要方面:

    • 通过参数生成一个Money类。
    • 加减乘除处理。
    • 比较处理。
    • 获取金额(元)和获取最小单位金额(元或分)。

    下面的代码由ChatGPT o1模型生成。

    提示词为:

    编写一个Money类,支持跨境支付场景下的多币种诉求。要求:

    1)实现Comparable和Serializable。

    2)成员变量币种使用BigDecimal amount,Currency currency。

    3)静态方法传入币种和数字返回一个Money类实例。

    4)支持:加、减、乘、除、比较大小操作,加减和比较需要判断币种相同。

    5)默认使用四余五入,但是支持RoundingMode能力。

    6)提供getAmount和getAmountMinorUnit方法,前者返回单位元,后者返回币种的最小单位,通过Currency.getDefaultFractionDigits()和amount计算出来。

    生成的 Money 类实现了跨境支付场景下多币种的高精度金额处理,满足了以下关键需求:

    • 实现了 Comparable 和 Serializable 接口,支持排序和序列化。
    • 使用 BigDecimal 和 Currency 来表示金额和币种,确保高精度和标准化。
    • 提供了丰富的操作方法,包括加、减、乘、除,并且支持自定义和四舍五入模式。
    • 提供了 getAmount 和 getAmountMinorUnit 方法,分别返回单位元和最小单位的金额。
    • 确保类的不可变性和线程安全性。
    • 根据币种自动计算出最小单位,比如人民币就是分,而日元就是元。
    • 只有相同币种才能做加、减。
    • 乘除支持舍入,默认使用四舍五入,但是支持其它舍入方式。

    通过这种设计,可以在跨境支付、金融应用等需要高精度、多币种支持的场景中安全、有效地使用 Money 类进行金额处理。

    后面会做拆解说明。

    完整代码如下:

    import java.io.Serializable;
    import java.math.BigDecimal;
    import java.math.RoundingMode;
    import java.util.Currency;
    import java.util.Objects;
    
    
    /**
     * Money类用于表示不同币种的金额,支持高精度计算和多币种操作。
     * 该类是不可变的(immutable),并且实现了Comparable和Serializable接口。
     */
    public final class Money implements Comparable, Serializable {
        private static final long serialVersionUID = 1L;
    
    
        private final BigDecimal amount;
        private final Currency currency;
    
    
        /**
         * 私有构造函数,确保通过工厂方法创建实例。
         *
         * @param amount   金额,单位为元
         * @param currency 币种,非空
         */
        private Money(BigDecimal amount, Currency currency) {
            if (amount == null) {
                throw new IllegalArgumentException("Amount cannot be null.");
            }
            if (currency == null) {
                throw new IllegalArgumentException("Currency cannot be null.");
            }
            this.amount = amount;
            this.currency = currency;
        }
    
    
        /**
         * 静态工厂方法,通过传入Currency和BigDecimal金额创建Money实例。
         * 默认使用RoundingMode.HALF_UP进行四舍五入。
         *
         * @param currency 币种
         * @param amount   金额,单位为元
         * @return 新的Money实例
         */
        public static Money of(Currency currency, BigDecimal amount) {
            return of(currency, amount, RoundingMode.HALF_UP);
        }
    
    
        /**
         * 静态工厂方法,通过传入Currency和BigDecimal金额创建Money实例。
         * 允许指定RoundingMode进行四舍五入。
         *
         * @param currency     币种
         * @param amount       金额,单位为元
         * @param roundingMode 四舍五入模式
         * @return 新的Money实例
         */
        public static Money of(Currency currency, BigDecimal amount, RoundingMode roundingMode) {
            Objects.requireNonNull(currency, "Currency cannot be null.");
            Objects.requireNonNull(amount, "Amount cannot be null.");
            Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
    
    
            BigDecimal scaledAmount = amount.setScale(
                    currency.getDefaultFractionDigits(),
                    roundingMode
            );
    
    
            return new Money(scaledAmount, currency);
        }
    
    
        /**
         * 加法操作,返回新的Money实例。
         * 仅允许相同币种的加法操作。
         *
         * @param other 加数
         * @return 相加后的Money实例
         * @throws IllegalArgumentException 如果币种不一致
         */
        public Money add(Money other) {
            validateSameCurrency(other);
            BigDecimal resultAmount = this.amount.add(other.amount);
            return new Money(resultAmount, this.currency);
        }
    
    
        /**
         * 减法操作,返回新的Money实例。
         * 仅允许相同币种的减法操作。
         *
         * @param other 减数
         * @return 相减后的Money实例
         * @throws IllegalArgumentException 如果币种不一致
         */
        public Money subtract(Money other) {
            validateSameCurrency(other);
            BigDecimal resultAmount = this.amount.subtract(other.amount);
            return new Money(resultAmount, this.currency);
        }
    
    
        /**
         * 乘法操作,使用默认舍入模式(RoundingMode.HALF_UP),返回新的Money实例。
         *
         * @param multiplier 乘数
         * @return 乘法后的Money实例
         * @throws ArithmeticException 如果需要进行舍入但无法进行
         * @throws IllegalArgumentException 如果multiplier为null
         */
        public Money multiply(BigDecimal multiplier) {
            return multiply(multiplier, RoundingMode.HALF_UP);
        }
    
    
        /**
         * 乘法操作,返回新的Money实例。
         *
         * @param multiplier    乘数
         * @param roundingMode  四舍五入模式
         * @return 乘法后的Money实例
         * @throws ArithmeticException 如果需要进行舍入但没有指定舍入模式
         * @throws IllegalArgumentException 如果multiplier或roundingMode为null
         */
        public Money multiply(BigDecimal multiplier, RoundingMode roundingMode) {
            Objects.requireNonNull(multiplier, "Multiplier cannot be null.");
            Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
    
    
            BigDecimal resultAmount = this.amount.multiply(multiplier)
                    .setScale(currency.getDefaultFractionDigits(), roundingMode);
            return new Money(resultAmount, this.currency);
        }
    
    
        /**
         * 除法操作,返回新的Money实例。
         *
         * @param divisor       除数
         * @param scale         保留的小数位数
         * @param roundingMode  四舍五入模式
         * @return 除法后的Money实例
         * @throws ArithmeticException 如果除数为零或无法精确表示
         * @throws IllegalArgumentException 如果divisor或roundingMode为null
         */
        public Money divide(BigDecimal divisor, int scale, RoundingMode roundingMode) {
            Objects.requireNonNull(divisor, "Divisor cannot be null.");
            Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
            if (divisor.compareTo(BigDecimal.ZERO) == 0) {
                throw new ArithmeticException("Division by zero.");
            }
    
    
            BigDecimal resultAmount = this.amount.divide(divisor, scale, roundingMode)
                    .setScale(currency.getDefaultFractionDigits(), roundingMode);
            return new Money(resultAmount, this.currency);
        }
    
    
        /**
         * 比较大小,仅允许相同币种的比较。
         *
         * @param other 要比较的Money对象
         * @return 负数、零或正数,分别表示小于、等于或大于
         * @throws IllegalArgumentException 如果币种不一致
         */
        @Override
        public int compareTo(Money other) {
            validateSameCurrency(other);
            return this.amount.compareTo(other.amount);
        }
    
    
        /**
         * 获取金额,单位为元。
         *
         * @return 金额
         */
        public BigDecimal getAmount() {
            return amount;
        }
    
    
        /**
         * 获取最小单位金额,通过Currency.getDefaultFractionDigits()和amount计算。
         * 例如,人民币1元 = 100分,日元1元 = 1元。
         *
         * @return 最小单位金额
         */
        public BigDecimal getAmountMinorUnit() {
            int fractionDigits = currency.getDefaultFractionDigits();
            return amount.movePointRight(fractionDigits);
        }
    
    
        /**
         * 获取币种。
         *
         * @return 币种
         */
        public Currency getCurrency() {
            return currency;
        }
    
    
        /**
         * 校验两个Money对象的币种是否相同。
         *
         * @param other 另一个Money对象
         * @throws IllegalArgumentException 如果币种不一致
         */
        private void validateSameCurrency(Money other) {
            if (!this.currency.equals(other.currency)) {
                throw new IllegalArgumentException("Currencies do not match.");
            }
        }
    
    
        /**
         * 重写equals方法,基于金额和币种判断相等。
         *
         * @param o 其他对象
         * @return 是否相等
         */
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
    
    
            Money money = (Money) o;
            return amount.equals(money.amount) &&
                    currency.equals(money.currency);
        }
    
    
        /**
         * 重写hashCode方法,基于金额和币种生成哈希码。
         *
         * @return 哈希码
         */
        @Override
        public int hashCode() {
            return Objects.hash(amount, currency);
        }
    
    
        /**
         * 重写toString方法,格式化输出币种和金额。
         *
         * @return 格式化后的字符串
         */
        @Override
        public String toString() {
            return String.format("%s %s", currency.getCurrencyCode(), amount);
        }
    }
    

    下面做拆解说明。

    5.1. 核心属性

    public final class Money implements Comparable, Serializable {
        private static final long serialVersionUID = 1L;
    
        private final BigDecimal amount;
        private final Currency currency;
    }
        

    5.2. 通过金额数值和币种构建一个Money类

    /**
         * 私有构造函数,确保通过工厂方法创建实例。
         *
         * @param amount   金额,单位为元
         * @param currency 币种,非空
         */
        private Money(BigDecimal amount, Currency currency) {
            if (amount == null) {
                throw new IllegalArgumentException("Amount cannot be null.");
            }
            if (currency == null) {
                throw new IllegalArgumentException("Currency cannot be null.");
            }
            this.amount = amount;
            this.currency = currency;
        }
    
    
        /**
         * 静态工厂方法,通过传入Currency和BigDecimal金额创建Money实例。
         * 默认使用RoundingMode.HALF_UP进行四舍五入。
         *
         * @param currency 币种
         * @param amount   金额,单位为元
         * @return 新的Money实例
         */
        public static Money of(Currency currency, BigDecimal amount) {
            return of(currency, amount, RoundingMode.HALF_UP);
        }
    
    
        /**
         * 静态工厂方法,通过传入Currency和BigDecimal金额创建Money实例。
         * 允许指定RoundingMode进行四舍五入。
         *
         * @param currency     币种
         * @param amount       金额,单位为元
         * @param roundingMode 四舍五入模式
         * @return 新的Money实例
         */
        public static Money of(Currency currency, BigDecimal amount, RoundingMode roundingMode) {
            Objects.requireNonNull(currency, "Currency cannot be null.");
            Objects.requireNonNull(amount, "Amount cannot be null.");
            Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
    
    
            BigDecimal scaledAmount = amount.setScale(
                    currency.getDefaultFractionDigits(),
                    roundingMode
            );
    
    
            return new Money(scaledAmount, currency);
        }
    

    5.3. 加减乘除

    1)注意除法有除不尽舍入的问题,需要根据业务来指定舍入的模式,建议默认提供四舍五入,但是保留指定模式的能力。具体可以参考:java.math.RoundingMode。

    • UP:远零方向舍入。示例:1.6返回2,-1.6返回-2。
    • DOWN:向零方向舍入。示例:1.6返回1,-1.6返回-1。
    • CEILING:向上舍入。示例:1.6返回2,-1.6返回-1。
    • FLOOR:向下舍入。示例:1.6返回1,-1.6返回-2。
    • HALF_UP:四舍五入。示例:1.5返回2,-1.5返回-2。
    • HALF_DOWN:五舍六入。示例:1.5返回1,-1.5返回-1,1.6返回2,-1.6返回-2。
    • HALF_EVEN:银行家算法,尾数小于0.5舍,尾数大于0.5入,尾数等于0.5往最终结果是偶数的方向进。示例:1.51返回2,-1.49返回-1,2.5返回2,3.5返回4(1.5,2.5,3.5,4.5,5.5等这些最终只出现2,4,4,4,6等偶数)。

    2)加和减,需要先判断币种,只有币种相同才能做加减。

        /**
         * 加法操作,返回新的Money实例。
         * 仅允许相同币种的加法操作。
         *
         * @param other 加数
         * @return 相加后的Money实例
         * @throws IllegalArgumentException 如果币种不一致
         */
        public Money add(Money other) {
            validateSameCurrency(other);
            BigDecimal resultAmount = this.amount.add(other.amount);
            return new Money(resultAmount, this.currency);
        }
    
    
        /**
         * 减法操作,返回新的Money实例。
         * 仅允许相同币种的减法操作。
         *
         * @param other 减数
         * @return 相减后的Money实例
         * @throws IllegalArgumentException 如果币种不一致
         */
        public Money subtract(Money other) {
            validateSameCurrency(other);
            BigDecimal resultAmount = this.amount.subtract(other.amount);
            return new Money(resultAmount, this.currency);
        }
    
    
        /**
         * 乘法操作,使用默认舍入模式(RoundingMode.HALF_UP),返回新的Money实例。
         *
         * @param multiplier 乘数
         * @return 乘法后的Money实例
         * @throws ArithmeticException 如果需要进行舍入但无法进行
         * @throws IllegalArgumentException 如果multiplier为null
         */
        public Money multiply(BigDecimal multiplier) {
            return multiply(multiplier, RoundingMode.HALF_UP);
        }
    
    
        /**
         * 乘法操作,返回新的Money实例。
         *
         * @param multiplier    乘数
         * @param roundingMode  四舍五入模式
         * @return 乘法后的Money实例
         * @throws ArithmeticException 如果需要进行舍入但没有指定舍入模式
         * @throws IllegalArgumentException 如果multiplier或roundingMode为null
         */
        public Money multiply(BigDecimal multiplier, RoundingMode roundingMode) {
            Objects.requireNonNull(multiplier, "Multiplier cannot be null.");
            Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
    
    
            BigDecimal resultAmount = this.amount.multiply(multiplier)
                    .setScale(currency.getDefaultFractionDigits(), roundingMode);
            return new Money(resultAmount, this.currency);
        }
    
    
        /**
         * 除法操作,返回新的Money实例。
         *
         * @param divisor       除数
         * @param scale         保留的小数位数
         * @param roundingMode  四舍五入模式
         * @return 除法后的Money实例
         * @throws ArithmeticException 如果除数为零或无法精确表示
         * @throws IllegalArgumentException 如果divisor或roundingMode为null
         */
        public Money divide(BigDecimal divisor, int scale, RoundingMode roundingMode) {
            Objects.requireNonNull(divisor, "Divisor cannot be null.");
            Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
            if (divisor.compareTo(BigDecimal.ZERO) == 0) {
                throw new ArithmeticException("Division by zero.");
            }
    
    
            BigDecimal resultAmount = this.amount.divide(divisor, scale, roundingMode)
                    .setScale(currency.getDefaultFractionDigits(), roundingMode);
            return new Money(resultAmount, this.currency);
        }
    

    5.4. 比较大小

        /**
         * 比较大小,仅允许相同币种的比较。
         *
         * @param other 要比较的Money对象
         * @return 负数、零或正数,分别表示小于、等于或大于
         * @throws IllegalArgumentException 如果币种不一致
         */
        @Override
        public int compareTo(Money other) {
            validateSameCurrency(other);
            return this.amount.compareTo(other.amount);
        }

    5.5. 返回元和分单位的数字

    所有内部应用全部使用getAmount(),不允许使用getAmountMinorUnit()。保证内部应用大家的语义保持一致。

    只有请求外部渠道时,如果渠道要求使用币种最小单位,才使用getAmountMinorUnit()。

        /**
         * 获取金额,单位为元。
         *
         * @return 金额
         */
        public BigDecimal getAmount() {
            return amount;
        }
    
    
        /**
         * 获取最小单位金额,通过Currency.getDefaultFractionDigits()和amount计算。
         * 例如,人民币1元 = 100分,日元1元 = 1元。
         *
         * @return 最小单位金额
         */
        public BigDecimal getAmountMinorUnit() {
            int fractionDigits = currency.getDefaultFractionDigits();
            return amount.movePointRight(fractionDigits);
        }
    

    六、Money类实际应用最佳实践

    从接收外部请求开始,到内部计算、存储,最后外发到渠道,完整实践说明。

    6.1. 接收入口请求

    在入口网关处,先转换成Money类,再往后请求。

    // 使用外部请求的参数构建Money类
    Money payAmount = Money.of(BigDecimal.valueOf(outRequest.getPayAmount()), outRequest.getCurrency()); 
    
    // 构建内部请求
    PayRequest request = new PayRequest();
    request.setPayAmount(payAmount);
    ... ...
    
    // 发给内部应用
    payService.pay(request);
    

    6.2. 内部应用运算

    内部所有应用,全部使用Money类流转和计算。

    Money payAmount = request.getPayAmount();
    Money fee = payAmount.multiply(BigDecimal.valueOf(0.03));
    
    // 其它处理
    

    6.3. 内部数据库存储

    Money payAmount = request.getPayAmount();
    BigDecimal amount = payAmount.getAmount();
    String currency = payAmount.getCurrency().getCurrencyCode();
    
    // 构建DO
    Order order = new Order();
    order.setAmount(amount);
    order.setCurrency(currency);
    ...
    
    // 保存入库
    saveToDB(order);
    

    6.4. 外发处理

    渠道要求是元,使用:

    payAmount.getAmount();

    如果要求是分或最小币种单位,使用:

    payAmount.getAmountMinorUnit();

    7. 结束语

    金额如果处理得不好,带来的直接后果就是资金损失,哪怕不是今天,早晚也得出事。

    如果你是研发同学,发现内部还没有使用Money类处理金额,建议早点对内部系统做改造。如果你是产品经理,建议转给内部研发工程师,避免踩资损的坑。

    本文由人人都是产品经理作者【隐墨星辰】,微信公众号:【隐墨星辰】,原创/授权 发布于人人都是产品经理,未经许可,禁止转载。

    题图来自Unsplash,基于 CC0 协议。



沪ICP备19023445号-2号
友情链接