欢迎访问悦橙教程(wld5.com),关注java教程。悦橙教程  java问答|  每日更新
页面导航 : > > 文章正文

来了,资金类交易业务(如电商交易、支付结算)中,经常提到的Money类!,了解了Money类,

来源: javaer 分享于  点击 32096 次 点评:111

来了,资金类交易业务(如电商交易、支付结算)中,经常提到的Money类!,了解了Money类,


资金类交易业务中 经常提到的Money类,大家了解一下。 了解了Money类,就会对资金类业务如电商交易、支付更了解。

资金类业务中,金额如果处理得不好,带来的直接后果就是资金损失(资损风险)。

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

不统一,存在各系统使用BigDecimal、double、long等数据类型来定义金额。

手动对金额进行加、减、乘、除运算,单位(元与分)换算。

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

1)手动做单位换算导致金额被放大或缩小100倍。比如大家规定传的是元,但是其中有位同学忘记了,以为传的是分,外部渠道要求传元,就手动乘以100。或者反过来。

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

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

这时候,解决方案就是定义统一的 Money 类。并且,要明确统一使用 Money 类来处理金额数据。

1. 定义统一的Money 类

制定适用于公司业务的Money类来统一处理金额。

我司是灵活用工类企业服务平台,money不涉及外币,因此我们的 Money 类不用涉及 java.util.Currency,只关注对人民币币种金额的处理。

talk is cheap , show you the code directly.

点击查看代码

import cn.hutool.core.lang.Assert;

import java.beans.Transient;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.RoundingMode;

/**
 * 资金类交易业务(如电商交易、支付)中,经常提到的Money类。
 * <p>该类是不可变的(immutable)(见{@link #add},会返回新的Money),并且实现了Comparable和Serializable接口。
 * <p><a href="https://mp.weixin.qq.com/s/34JEFRrV_b6J_8Z_O3MbLg">原始文章</a>
 *
 * @author zhangguozhan
 * @date 2025-01-07 14:40
 * @see FeeRate
 */
public class Money implements Serializable, Comparable<Money> {
    private static final long serialVersionUID = 1L;

    //    private BigDecimal yuan;
    private long fen;


    /**
     * 私有构造函数,确保通过工厂方法{@link #ofYuan,#ofFen}来创建实例。
     *
     * @param fen
     */
    private Money(long fen) {
        this.fen = fen;
    }

    public static Money ofYuan(BigDecimal yuan) {
        Assert.notNull(yuan, "yuan is null");
        if (yuan.scale() > 2) {
            throw new IllegalArgumentException("元 目前只支持两位小数位数");
        }
        long fen = yuan.movePointRight(2).longValue();
        Assert.isTrue(fen >= 0, "元必须大于等于0");
        return new Money(fen);
    }

    public static Money ofFen(Long fen) {
        Assert.notNull(fen, "fen is null");
        Assert.isTrue(fen >= 0, "分必须大于等于0");
        return new Money(fen);
    }

    public long getFen() {
        return fen;
    }

    public BigDecimal getYuan() {
        return convertFen2Yuan();
    }

    @Transient
    public String getYuanString() {
        return String.valueOf(convertFen2Yuan());
    }

    /**
     * 加法操作,返回新的Money实例。(注意Money类是不可变类,此操作不会修改当前Money对象的属性值)
     *
     * @param other
     * @return
     */
    public Money add(Money other) {
        Assert.notNull(other, "other is null");
        Assert.notNull(other.fen, "other.yuan is null");
        long sum = this.fen + other.fen;
        return ofFen(sum);
    }


    /**
     * 计算手续费,保留2位小数(四舍五入)
     *
     * @param feeRate
     * @return
     */
    public Money calculateFee(FeeRate feeRate) {
        return calculateFee(feeRate, RoundingMode.HALF_UP);//HALF_UP 表示 四舍五入
    }

    /**
     * 计算手续费,保留2位小数(全舍)
     *
     * @param feeRate
     * @return
     */
    public Money calculateFeeByTruncation(FeeRate feeRate) {
        return calculateFee(feeRate, RoundingMode.DOWN);//DOWN 表示 直接舍去
    }

    /**
     * 计算手续费,保留2位小数-protected(按指定的RoundingMode来计算)
     * (本方法仅在本包内测试使用,对外暴露最好包装一下roundingMode,从而规避因个别开发者对RoundingMode了解不足带来的资金bug)
     *
     * @param feeRate
     * @param roundingMode
     * @return
     */
    protected Money calculateFee(FeeRate feeRate, RoundingMode roundingMode) {
        Assert.notNull(feeRate, "feeRatePercentage is null");
        return feeRate.calculateFee(this, roundingMode);
    }


    @Override
    public int compareTo(Money obj) {
        return Long.compare(fen, obj.fen);
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this) return true;
        if (obj instanceof Money) return compareTo((Money) obj) == 0;
        return false;
    }

    @Override
    public String toString() {
        return String.format("%s元(%s分)", convertFen2Yuan(), fen);
    }


    private BigDecimal convertFen2Yuan() {
        return BigDecimal.valueOf(fen).movePointLeft(2);
    }
}



测试:

    public static void main(String[] args) {
        Money money = Money.of(BigDecimal.valueOf(100));
        Money money2 = Money.of(BigDecimal.valueOf(200));
        System.out.println(money.compareTo(money2));
        System.out.println(money.equals(money2));
        System.out.println(money.add(money2));
    }

输出:

-1
false
300元(30000分)

2. 如何统一使用 Money 类

【要点】

  • 在入口网关接收到请求后,就转换为Money类。

  • 所有内部应用的金额处理,强制全部使用Money类运算、传输,禁止自己手动加减乘除、单位换算(比如元到分)。

  • 数据库最好使用 bigint 类型来保存,单位为分(最小货币单位)。便于数字计算。

  • 在出口网关外发时,再根据外部接口文档要求,转换成使用指定的单位(有些是元,有些是分)。

下面依次列举每个要点的详细实现方案。

2.1 入口网关使用Money

以SpringBoot项目为例,金额通常存在于某个VO结构中。SpringMVC默认使用Jackson作为序列化工具。

2.1.1 针对接口响应中使用Money来返回金额数据,我们可以扩展Jackson的JsonSerializer

先来看下面的VO,其中,@JsonSerialize(using = ToYuanSerializer.class)会自动将Money转换成以“元”为单位的金额。

@Data
public class HiringTaskApplyVO {

    @JsonSerialize(using = ToYuanSerializer.class)
    private Money settlementAmount;

    // other fields
}

ToYuanSerializer继承自fastxml中的抽象泛型类com.fasterxml.jackson.databind.JsonSerializer<T>。我们来看一下它的实现。

import com.emax.trans.Money;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JacksonStdImpl;
import com.fasterxml.jackson.databind.jsontype.TypeSerializer;

/**
 * 将Money类型的金额字段转为元单位的String类型
 */
@JacksonStdImpl
public class ToYuanSerializer extends JsonSerializer<Money> {

    @Override
    public void serialize(Money value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        writeValue(gen, value);
    }

    @Override
    public void serializeWithType(Money value, JsonGenerator gen, SerializerProvider serializers, TypeSerializer typeSer) throws IOException {
        writeValue(gen, value);
    }
    
    private void writeValue(JsonGenerator gen, Money value) throws IOException {
        gen.writeString(value.getYuanString());
    }
}

2.1.2 针对接口请求中使用Money来返回金额数据,我们可以扩展Jackson的 JsonDeserializer

@Data
public class HiringTaskApplyQuery {

    @JsonDeserialize(using = ToMoneyDeserializer.class)
    private Money settlementAmount;

    // other fields
}

其中的ToMoneyDeserializer继承自fastxml的抽象泛型类com.fasterxml.jackson.databind.JsonDeserializer<T>

import com.emax.trans.Money;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.annotation.JacksonStdImpl;

/**
 * 将单位元类型字段转为{@link Money}类型
 */
@JacksonStdImpl
public class ToMoneyDeserializer extends JsonDeserializer<Money> {

    @Override
    public Money deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
        if (StringUtils.isBlank(jsonParser.getText())) {
            return Money.ofFen(0L);
        }
        BigDecimal yuan = new BigDecimal(jsonParser.getText());
        if (yuan.scale() > 2) {
            //保留两位小数,四舍五入保留两位小数
            return Money.ofYuan(yuan.setScale(2, RoundingMode.HALF_UP));
        } else {
            return Money.ofYuan(yuan);
        }
    }

}

2.2 内部对象属性/变量,使用Money

RPC交互的DTO、程序内部的VO,都是Java语言内部调用,只需要用Money即可。Money实现了Java的Serializable接口,支持Java序列化。

使用方式参见下面segment:

Money amount = orderDTO.getAmount();
Money fee = amount.calculateFee(feeRate);
entity.setFee(fee);
...

2.3 持久层使用Money与数据库交互

这里,我们以 MybatisPlus 为例。
通过扩展 mybatis 的 TypeHandler 来实现。

2.3.1 如果数据库里的金额字段是以“元”为单位的浮点数据。那么,下面的 MoneyYuanTypeHandler 可以派上用场。

package org.apache.ibatis.type;

import com.emax.trans.Money;
import org.springframework.stereotype.Component;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 实现Money与数据库的“元”金额字段的互相映射
 */
@Component
@MappedTypes(value = Money.class)
public class MoneyYuanTypeHandler extends BaseTypeHandler<Money> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Money parameter, JdbcType jdbcType) throws SQLException {
        ps.setBigDecimal(i, parameter.getYuan());
    }


    @Override
    public Money getNullableResult(ResultSet rs, String columnName) throws SQLException {
        return Money.ofYuan(rs.getBigDecimal(columnName));
    }

    @Override
    public Money getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        return Money.ofYuan(rs.getBigDecimal(columnIndex));
    }

    @Override
    public Money getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        return Money.ofYuan(cs.getBigDecimal(columnIndex));
    }
}

按下面这样通过@TableField指定 typeHandler 来使用。

@Data
@TableName(value = "hiring_task_apply", autoResultMap = true)
public class HiringTaskApply {

    @TableField(typeHandler = MoneyYuanTypeHandler.class)
    private Money settlementAmount;

    // other fields
}

2.3.2 如果数据库里的金额字段是以“分”为单位的整形数据。那么,我们使用 MoneyFenTypeHandler

package org.apache.ibatis.type;

import com.emax.trans.Money;
import org.springframework.stereotype.Component;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 实现Money与数据库的“分”金额字段的互相映射
 */
@Component
@MappedTypes(value = Money.class)
public class MoneyFenTypeHandler extends BaseTypeHandler<Money> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Money parameter, JdbcType jdbcType) throws SQLException {
        ps.setLong(i, parameter.getFen());
    }

    @Override
    public Money getNullableResult(ResultSet rs, String columnName) throws SQLException {
        return Money.ofFen(rs.getLong(columnName));
    }

    @Override
    public Money getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        return Money.ofFen(rs.getLong(columnIndex));
    }

    @Override
    public Money getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        return Money.ofFen(cs.getLong(columnIndex));
    }
}

2.3.3 注意:指定field的typeHandler时,要同时设定TableName#autoResultMap=true,否则可能不生效。

下面mybatis-3.5.16.jar源码显示TableName#autoResultMap、TableField#typeHandler这两个注解属性的javadoc。

点击查看代码
// ↓both annotation are in package com.baomidou.mybatisplus.annotation.

/**
 * 表字段标识
 *
 * @author hubin sjy tantan
 * @since 2016-09-09
 */
public @interface TableField {
    /**
     * 类型处理器 (该默认值不代表会按照该值生效),
     * 只生效于 mp 自动注入的 method,
     * 建议配合 {@link TableName#autoResultMap()} 一起使用
     *
     * @since 3.1.2
     */
    Class<? extends TypeHandler> typeHandler() default UnknownTypeHandler.class;
    ... 
}

/**
 * 数据库表相关
 *
 * @author hubin, hanchunlin
 * @since 2016-01-23
 */
public @interface TableName {
    /**
     * 是否自动构建 resultMap 并使用,
     * 只生效于 mp 自动注入的 method,
     * 如果设置 resultMap 则不会进行 resultMap 的自动构建并注入,
     * 只适合个别字段 设置了 typeHandler 或 jdbcType 的情况
     *
     * @since 3.1.2
     */
    boolean autoResultMap() default false;
    ...
}

2.4 出口网关外发中的Money

先以聚合系统为例,通道层的交易api,金额使用Money类型。具体到特定的通道实现类内部,通过调用Money的相关getter方法,如 getFen()、getYuan()、getYuanString(),转换成道侧要求的单位(有些是元,有些是分)即可。

同样,对于非聚合系统来说,当我们的程序需要调用外部系统接口时,也将我们的Money金额进行相关转换即可。



3. 总结-使用 Money,规避资金问题

就像表示日期/时间数据,我们使用Date,而非String。使用字符串有哪些弊端?

  • 首先,不可规避的数据类型转换---String 与 Date 之间的转换。
  • 其次,数据转换时,可能出现格式转换失败的异常。
  • 再次,代码耦合问题。尤其是聚合系统中,使用String格式的字符串,开发者极易在service层生成通道层需要的格式。从而造成代码职责不清晰。
  • 第四,程序不易读。如果一个方法让传递String格式的日期/时间,我们在调用时就需要依赖了解这个方法的具体实现,或其doc文档,来传递正确的数据。
    我们通过使用标准的Date类型来传递日期/时间数据,就不会存在上面的弊端。

同样,使用 Money,与使用Date,异曲同工!

金额如果处理得不好,带来的直接后果就是资金损失,哪怕不是今天,早晚也得出事。
如果你是研发同学,发现内部还没有使用Money类处理金额,建议早点对内部系统做改造。如果你是产品经理,建议转给内部研发工程师,避免踩资损的坑。


当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/18657723


相关栏目:

用户点评