惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
MyScale Blog
MyScale Blog
WordPress大学
WordPress大学
N
News and Events Feed by Topic
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
MongoDB | Blog
MongoDB | Blog
V
Vulnerabilities – Threatpost
月光博客
月光博客
罗磊的独立博客
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Apple Machine Learning Research
Apple Machine Learning Research
有赞技术团队
有赞技术团队
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
F
Full Disclosure
Simon Willison's Weblog
Simon Willison's Weblog
D
DataBreaches.Net
T
Threatpost
Hacker News: Ask HN
Hacker News: Ask HN
阮一峰的网络日志
阮一峰的网络日志
TaoSecurity Blog
TaoSecurity Blog
Microsoft Azure Blog
Microsoft Azure Blog
Scott Helme
Scott Helme
S
Securelist
W
WeLiveSecurity
K
Kaspersky official blog
The GitHub Blog
The GitHub Blog
Attack and Defense Labs
Attack and Defense Labs
博客园 - 三生石上(FineUI控件)
The Hacker News
The Hacker News
Google Online Security Blog
Google Online Security Blog
Stack Overflow Blog
Stack Overflow Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
Security Latest
Security Latest
M
MIT News - Artificial intelligence
人人都是产品经理
人人都是产品经理
The Last Watchdog
The Last Watchdog
C
Check Point Blog
T
Troy Hunt's Blog
P
Proofpoint News Feed
J
Java Code Geeks
G
Google Developers Blog
Schneier on Security
Schneier on Security
Cyberwarzone
Cyberwarzone
S
Security @ Cisco Blogs
宝玉的分享
宝玉的分享
Recent Commits to openclaw:main
Recent Commits to openclaw:main
A
About on SuperTechFans
T
The Blog of Author Tim Ferriss
L
LINUX DO - 最新话题
Jina AI
Jina AI

人人都是产品经理

为什么你的产品找不到差异化?90%的失败都卡在第一步上(下) – 人人都是产品经理, 3年从30万到1300万用户、获2200万美元融资,这个AI教育产品用“抽卡”破解了获客难题 – 人人都是产品经理, 园区招商系统怎么做才能真正帮到去化?我加了这一个功能,推广链接转发400次阅读过万 – 人人都是产品经理, AI大事件:OpenAI发完网络安全模型又搞药物研发,小鹏汽车要抓”DeepSeek时刻” – 人人都是产品经理, 电商不是卖货,是一场更残酷的产品经理实战 – 人人都是产品经理, 没想到,活动营销又回来了! – 人人都是产品经理, 为何All-in海外KOC:一场关于AI时代窗口期的豪赌 – 人人都是产品经理, 重新理解企业的内部协作 – 人人都是产品经理, 苹果的 AI 战略到底是什么? – 人人都是产品经理, 医疗智能体·第2讲——合规护城河:等保、PIPL与HIPAA的架构实战 – 人人都是产品经理, 向量知识库五步法:从“答非所问”到“精准回复” – 人人都是产品经理, 鸿蒙PC三方库构建总指挥HPKBUILD(sha)库为例 – 人人都是产品经理, 何时该用LLM?AI产品经理的LLM设计指南 – 人人都是产品经理, 医疗信息领域的需求方、决策方、准入方以及关注点(二) – 人人都是产品经理, 即梦涨价:一场被误读的「傲慢」 – 人人都是产品经理, 面试AI PM必答题:Hermes和OpenClaw的区别,如何讲清楚业务价值 – 人人都是产品经理, AI的下一张船票:世界模型——AI产品经理必须理解的技术拐点 – 人人都是产品经理, 小红书做GEO,怎么让AI信你?记住这 3 个重要信息 – 人人都是产品经理, 5 家印度 AI 初创公司,看看印度 AI 再做什么 – 人人都是产品经理, AI项目跨团队协作:产品技术业务如何不打架 – 人人都是产品经理, Agentic Workflow(智能体工作流):让AI从”答案生成器”变成”数字员工” – 人人都是产品经理, lycium_plusplus 项目全景解读:OpenHarmony 三方库构建的“大管家” – 人人都是产品经理, 从爆单救火到前置履约:两套预采策略,把生鲜大促履约效率拉满 – 人人都是产品经理, 什么时候该补货?我用一轮数据做了一个决定 – 人人都是产品经理, 从“机械兜底”到“动态分流”:AI客服重复进线治理的4大底层逻辑 – 人人都是产品经理, 抖音拼效率,红书拼洞察 – 人人都是产品经理, 全民狂欢与退潮——为什么龙虾这波热潮冷却得如此之快? – 人人都是产品经理, Stripe押注!MPP重塑全球支付 – 人人都是产品经理, 小红书GEO:AI引用你的内容,不是因为你对,而是因为你看起来可信 – 人人都是产品经理, 前百度副总裁押注办公Agent,日韩付费爆发,Manus迎来强劲对手 – 人人都是产品经理, 企事业单位数字化的业务供需本质 – 人人都是产品经理, 医疗智能体·第1讲——医疗信息化重构:从“辅助软件”到“自主智能体”的范式转移 – 人人都是产品经理, 粉丝量就是空气!!! – 人人都是产品经理, 用户说“薯片碎了”,机器回“要买吗?”:意图识别的翻车与破局 – 人人都是产品经理, RAG召回准确率从75到90 我做对了这三件事 – 人人都是产品经理, AI大事件:Anthropic改收费、OpenAI发安全版、手术机器人纳入医保、阿里发布”秒悟” – 人人都是产品经理, Chrome 推出 Skills 新功能,Agent 重塑上网方式 – 人人都是产品经理, GitHub前创始人拿了a16z的1700万美元,做Agent时代的Git – 人人都是产品经理 拷贝或克隆其他 Flutter OH 项目到本地后无法运行 – 人人都是产品经理, 优惠券设计:优惠券创建 – 人人都是产品经理, 不用死磕文档!AI 助手 1 小时搞定飞书 CLI 安装 + 配置 + 知识库 – 人人都是产品经理, 用小龙虾做竞品分析报告:从2天到20分钟,我是怎么做到的 – 人人都是产品经理 用小龙虾做市场分析报告:搞懂这3个公式,市场规模不再靠猜 – 人人都是产品经理, 你早就在做 Harness 工程,只是不知道它叫这个名字 – 人人都是产品经理, Think Long就够?你可能想多了! – 人人都是产品经理, 货代SRM实战:供应商准入怎么做,才能让资源池不是通讯录而是可交付网络? – 人人都是产品经理, 如何做好用户调研?详解基本技巧 – 人人都是产品经理, 木鸟、途家、美团对打,平台春天行动开“卷” – 人人都是产品经理, 入职才发现公司不靠谱?小红书从业者求职避坑指南 – 人人都是产品经理, 美国 AI 三巨头联手封堵,中国 AI 突围之路在何方 – 人人都是产品经理, 小红书,放在需求对面的镜子 – 人人都是产品经理, AI 会带来大规模失业吗? – 人人都是产品经理, 从出单到补货前,我第一次犹豫:该不该放大? – 人人都是产品经理, Flutter 三方库鸿蒙化适配:5 种高效检查方式,快速判断是否需要适配 – 人人都是产品经理, 从做产品进阶拿结果:医美机构产品经理转岗科室运营经理 – 人人都是产品经理, 阿里HappyHorse,一场关于“Token经济”的阳谋 – 人人都是产品经理, To B AI:客户留存落地的观察与思考 – 人人都是产品经理, AI产品的“生命线”——数据采集、标注、清洗的产品化设计 – 人人都是产品经理, 谈谈AI Agent(二):当“孩子”能自己“体验世界”时,你该学什么? – 人人都是产品经理, UI/UX设计师的3层能力进阶,前两层让你活下来,第三层…才是真正的分水岭 – 人人都是产品经理, 2分钟 → 30秒,效率提升75%:B端产品经理如何用「规则枷锁」驯服AI幻觉? – 人人都是产品经理, 还没来得及学OpenClaw,来了个更猛的:Hermes Agent – 人人都是产品经理, AI日报:宇树机器人跑出10m/s刷新世界纪录 – 人人都是产品经理, 一文说透基金互金如何用情绪价值引导用户决策做转化 – 人人都是产品经理, 当浏览器开始替你”看”网页:AI 浏览器正在亲手拆掉它脚下的那张网 – 人人都是产品经理, 0代码,一天时间我Vibe Coding了个网站 – 人人都是产品经理, Hermes 和 OpenClaw 之争,Agent 的能力应该“装上去”还是“长出来”? – 人人都是产品经理 视频生成的“桌子”,字节Seedance 2掀完,阿里快乐马掀 – 人人都是产品经理, 从听不懂到完全信任:我的 Codex 深度产品体验 – 人人都是产品经理, 当虚拟偶像有了北京户口,与真人偶像还有什么区别? – 人人都是产品经理, 会说,远远比会做更重要 —— 对 SBTI 爆火现象的五层观察 – 人人都是产品经理, AI产品经理必看:当“搭环境”比“选模型”更重要,你的认知还在2024年吗? – 人人都是产品经理, 2026年AI产品商业化核心逻辑:从功能demo到规模化营收的3个必破卡点 – 人人都是产品经理, 京东围绕供应链,卷起裤腿下场的那些事儿 – 人人都是产品经理, SBTI一夜刷屏:它赢在了“太会说人话” – 人人都是产品经理, 折扣零售的真相:不是便宜,而是价值感! – 人人都是产品经理, 和甲方吵了一架,最后加钱做了——我学到的ToB产品经理生存法则 – 人人都是产品经理, 和几位小红书操盘手聊了8小时,干货全在这 – 人人都是产品经理, 智谱GLM-5.1登场,开源模型首超Opus4.6!!! – 人人都是产品经理 Anthropic收入凭什么反超OpenAI,终于有人把这事说清楚了 – 人人都是产品经理, 史上最有故事感的技术报告——Claude最强模型Mythos 7个极其精彩的细节 – 人人都是产品经理, 模型不是壁垒,Harness 也不是 – 人人都是产品经理, 抖音本地生活业务思考21 – 人人都是产品经理, Superpowers:145k Star的AI编码框架,到底是什么来头? Superpowers:145k Star的AI编码框架,到底是什么来头? – 人人都是产品经理, OpenAI 的路走错了,Anthropic Harness 解法启示:模型需要实践专科生 – 人人都是产品经理, 画原型图的前一步:设计站点地图 – 人人都是产品经理, 给 DeepSeek 的最后一封催更信 – 人人都是产品经理, 手把手教你用 Claude Code 搭建 AI 营销团队:5 个 Agent、12 项技能,独立完成研究、写作、设计全流程 – 人人都是产品经理, 你以为大模型在学语言?不,它在重新发明语言学 – 人人都是产品经理 所谓Skill,不过是AI时代的工业垃圾 – 人人都是产品经理, 聊一聊内容传播的几个方法 – 人人都是产品经理, 当平台开始吃掉生态:从 OpenClaw 被封杀,读懂 Anthropic 的这盘棋 – 人人都是产品经理, 你装了 10 个 AI 插件,Obsidian 还是一个文件夹 – 人人都是产品经理 关于AI智能体架构演进的系统性思考:从单体试水到多体协同的重构 – 人人都是产品经理, 当“人”变成Skill,我们又该何去何从? – 人人都是产品经理 Mythos 事件:前沿 AI 治理的意外实验 – 人人都是产品经理, 货代CRM:信用与风险管理怎么做,才能把坏账风险拦在放货之前? – 人人都是产品经理, 从HR收集自拍照到员工自助录入——我见证了园区人脸识别从”不可用”到”真好用”的全过程 – 人人都是产品经理 千问闯关AI混沌期:阿里画靶,吴嘉张弓,马云射箭? – 人人都是产品经理,
资损防控:搞定跨境交易系统中金额处理规范
隐墨星辰 · 2025-01-05 · via 人人都是产品经理

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

这篇文章主要讲清楚:交易系统(电商、支付等)金额处理常见资损场景,如何构建一个适合跨境支付业务(中国业务更不在话下)的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 协议。