扫码点餐这个赛道,国内早就卷成了红海,Java后端做一套本地版本并不难,难的是把它改造成一个真正意义上的国际版。我这次接手的系统,就是把一套原本只面向国内单一市场的扫码点餐系统,重构成支持多语言、多币种、多支付渠道的跨境版本,整个过程踩坑不少,但做完以后对整个支付体系的认知也彻底不一样了。这篇文章就把整个项目拆开讲清楚,从架构设计、多语言方案、支付抽象,到回调幂等、对账补偿、沙箱验证,全部梳理成可以直接参考的实操记录。
适合正在做出海餐饮SaaS、跨国门店点餐系统、或者想了解支付集成细节的Java后端开发者阅读。不管你只是写Demo还是真的要上线,这套设计思路都能用得上。
1. 项目整体设计与思路拆解
1.1 为什么"国际版"不是简单加几个翻译文件
一开始产品提需求时说"国际版嘛,就是把页面上中文改成英文,再加一个PayPal不就行了"。真做起来才发现,这句话里藏着至少三个层次的改动:语言只是表象,币种和支付渠道才是核心,再往下是订单生命周期和结算逻辑的连锁反应。
单说"点餐"这个动作,国内用户扫码之后看到的是菜品列表、价格、购物车、结算。但国际版场景里,同一个门店可能有本地顾客、游客、外籍常客,他们看到的语言不一样,显示的价格币种不一样,最后付款时能用的支付方式也不一样。有人想用本地银行转账,有人习惯刷信用卡,还有人只有某个电子钱包。更复杂的是,订单价格是按A币种菜单位价计算的,但用户可能想用B币种的钱包支付,这中间就涉及到汇率换算、四舍五入、以及支付渠道是否支持跨境结算等一系列问题。
所以,国际版的本质是把"点餐-支付"这条主链路里的每一个环节,从单一国家假设里拆出来,变成可配置、可插拔的模块。这也是我在项目里做的第一个决定:先不写业务功能,先把"国家/区域"作为顶层维度设计进来。
1.2 整体架构:配置驱动而非代码直写
我的方案是把系统按"区域、语言、币种、支付渠道"四个维度做配置化。每个维度都是独立的,区域决定可用币种和合规要求,语言决定文案和数字格式,币种决定计价和结算方式,支付渠道决定用户最终付款的通道。
架构上分了四层:
- 接入层:扫码入口,桌码/台码里携带门店ID、桌号、会话token。这一层跟国际版关系不大,但token设计上要注意支持跨语言环境下的重新拉取。
- 业务层:菜单浏览、购物车、下单、订单状态机。所有业务数据在主语言(比如英语)下存储,同时保留多语言副本,而不是直接把中文硬编码到业务表里。
- 支付层:统一支付抽象接口,对抗不同支付渠道的差异。这是整个项目改动最大的地方。
- 数据层:订单表、支付流水表、汇率表、语言资源表、幂等记录表。这些表都要加上区域和语言维度。
为什么用配置驱动?因为不同市场的上线节奏不一样。某区域上线时只需要新增一个区域配置、两个语言资源文件、一个支付渠道适配器,不需要改核心代码。这套思路在项目中期被验证得非常香——后来新增一个本地钱包渠道时,我一个人一天就完成了从适配器到联调的全流程。
1.3 核心链路:下单、支付、回调、对账
扫码点餐的核心链路是这样的:
- 顾客扫桌上的二维码,拿到店铺ID、桌号、会话token。
- 前端拿着token去拉取菜单和店铺配置,按当前语言展示。
- 顾客加购下单,点击结算,后端生成待支付订单。
- 订单状态由用户选择的支付渠道决定跳转方式:有的拉起收银台App,有的跳转到H5支付页,有的展示付款码。
- 支付渠道异步回调,后端验签、修改订单状态、通知前端刷新页面。
- 定时任务扫描超时未支付的订单做关闭处理,扫描支付成功但回调未到的订单做主动查单补偿。
这条链路本身不复杂,复杂的是第4步和第5步,在多语言、多币种环境下所有细节都会被放大。比如回调里返回的金额、币种、支付时间,都要经过一套统一的转换逻辑才能落到订单上,不能想当然地直接用渠道返回的原始字段去更新订单,不然汇率波动、小数位数差异就能把对账搞崩。
2. 多语言与多币种方案设计
2.1 国际化不只是语言资源文件:语言回退与动态切换
很多人以为i18n就是放几个messages.properties,对不起,那只是最基本的。扫码点餐场景中语言问题发生在三个层面:
- 后端文案:错误信息、状态提示、邮件通知。这些用标准的
ResourceBundle机制就能解决。 - 菜品和规格数据:菜品名称、描述、规格选项(比如"大份/中份/小份"),这些存在数据库里,不是静态资源文件。处理方案是给菜品表加一个
多语言文案子表,每个菜品在每种语言下都有一条文案记录,没有记录时回退到主语言。 - 数字和日期格式:1,234.56和1.234,56在不同语言环境代表完全不同的金额。这个问题坑了我很久,后面单独说。
语言回退是三件事:用户首选语言 -> 浏览器/App语言 -> 默认英语。回退不能满足时,用主语言兜底并加一个标志位,让前端知道当前显示的是回退文案,可以提示用户"该内容暂时只支持英语"。
动态切换方面,我用的方案是前端把当前语言编码放到请求头里,后端用拦截器解析后放入ThreadLocal,然后业务代码里所有文案取值都走一个LocalizationUtil。这个工具类读数据库多语言表,优先取请求语言,没有就取回退语言。实测下来性能影响可以忽略,但要注意缓存问题——语言表有更新时,必须主动刷新缓存。
2.2 金额与精度:钱永远不要用double
这是老生常谈,但国际版场景下犯错的代价更大了。国内版本可能所有价格都在同一个币种下,用分还是用元反正一致就行。国际版一旦出现汇率转换,任何浮点数误差都会被放大,而且支付渠道只认原始币种的小数位。
我定的规则:
- 数据库所有金额字段用
DECIMAL(18,2)统一存储,存储的是订单计价币种的主单位金额。 - Java代码里所有金额计算一律使用
BigDecimal,禁止double和float。 - 与支付渠道交互时,按渠道要求的单位传递:有的渠道要分,有的渠道要厘,有的要元。适配器内部负责转换,业务层不感知。
- 每个币种定义一个
货币元数据:小数位数、默认舍入模式、最小支付单位。例如日元没有小数,港币两位小数,有些中东货币三位小数。
BigDecimal的构造陷阱也踩过:new BigDecimal(0.1)会得到一堆精度垃圾,一定要用new BigDecimal("0.1")或BigDecimal.valueOf(0.1)。这两种我都遇到过,后来索性在工具类里统一封装金额创建入口,禁止业务代码直接new BigDecimal。
2.3 时区与日期格式:在"谁的时间"里结账
多语言系统里,时间问题比金额问题更隐蔽。一个订单可能有多个"时间"概念:门店当地时间、顾客所在时区时间、支付渠道回调时间、系统服务器时间。
我不建议把时间都转成UTC然后存字符串,那样查询报表时很难受。我的方案:
- 数据库一律存储UTC时间(用
TIMESTAMP WITH TIME ZONE或统一约定存UTC),时间运算在服务端完成。 - 订单表额外存一个
门店时区字段,注意这不是一次性的,因为顾客可能跨时区下单。 - 展示给顾客的时间,由前端使用其本地时区渲染;但订单的"结账时间""出餐时间"等业务时间,统一用门店时区定义。
- 支付渠道回调返回的时间,先解析成UTC,再转成订单所属时区的本地时间,避免直接用字符串拼接。
这个方案在测试时看起来繁琐,真正上线后才发现它的价值,因为跨境顾客完全不会困惑,门店看报表时也是回到自己熟悉的时间语义。
2.4 汇率处理:计价、结算与展示的三角关系
国际版扫码点餐里汇率是最容易出逻辑混乱的地方。一个订单流程中,汇率可能出现在三个位置:
- 菜单展示汇率:用户看到的菜单位价,可能是本店币种标价,也可能是用户自己选定的币种折算价。这个汇率仅用于展示,不影响订单计价。
- 订单计价汇率:顾客下单时,订单金额锁定为某个币种。比如菜单位价是美元,顾客选择用欧元支付,订单金额就是按当时汇率换算的欧元金额。这个汇率要固化在订单表里,不能实时浮动。
- 对账结算汇率:餐企最终跟门店结算时使用的汇率,跟顾客看到的无关,走财务模块。
我封装了一个ExchangeRateService,职责是:提供实时汇率查询、在订单生成时固化汇率快照、在展示时只读换算。注意汇率快照本身要存储换算时间、来源、买入价卖出价,方便后续审计。
我踩的一个坑是:以为可以从支付渠道返回的"结算金额"倒推汇率,结果发现渠道的汇率包含手续费,跟订单固化汇率差了一截,导致对账时产生了金额差异。最后解决方案是:订单表永久保存订单币种金额、支付币种金额、汇率、手续费明细,对账时逐一比对,而不是用总额除以汇率去猜测。
3. 支付网关的适配与集成
3.1 支付渠道差异分析:四种支付形态
国际版支付渠道的形态差异比国内大得多。粗略可分成四类:
- 扫码支付形态:顾客扫描商家提供的二维码,或者商家扫描顾客的付款码。这是国内主流,在海外某些区域也存在,但覆盖率没那么高。
- 银行卡收单形态:顾客在支付页填写卡号、有效期、CVV,或者跳转到银行3D验证页面确认。这是海外最通用的形态,涉及卡组织网络。
- 钱包/转账形态:顾客跳转到某个本地钱包App完成支付,或通过银行转账、先买后付等方式。这类渠道区域性极强。
- 统一收银台形态:顾客在一个聚合网页上选择支付方式,聚合服务商帮你完成后续逻辑。这种对商家开发量最小,但定制能力受限。
这些渠道的差异不仅体现在交互,还体现在技术对接上:签名方式、回调方式、退款方式、订阅机制各不相同。我花了很大篇幅做了一张对比表,驱动力只有一个——统一抽象接口时要覆盖这些差异,不能让某个渠道的特殊逻辑污染业务层。
3.2 支付适配层:把差异锁在接口背后
我的核心抽象就三层:
public interface PaymentAdapter { PayResult createPayment(PayRequest request); PayResult queryPayment(String outTradeNo); void handleCallback(CallbackRequest request); RefundResult refund(RefundRequest request); String getChannelCode(); boolean supportsCurrency(String currency); }PayRequest里包含订单号、金额、币种、顾客语言、区域、同步跳转地址和异步回调地址。PayResult统一返回支付跳转参数、支付状态、渠道流水号。
实现类根据渠道差异化填充细节,比如:
- 某国内扫码渠道的签名方式A,需要证书加密,回调报文格式是XML。
- 某海外卡组织的收单服务签名方式B,需要消息签名,回调报文是JSON。
- 某个本地钱包渠道签名方式C,回调里会有多层嵌套和自定义加密字段。
这些差异全部封装在各自的适配器里,业务层只面对接口。业务层不用关心当前用户在用什么渠道,它只需要根据渠道code从注册表里取适配器,然后调用统一方法。
还需要一个工厂或者注册管理器,把所有适配器按渠道code注册进一个Map,启动时校验是否有渠道缺失。我还做了一件事:createPayment返回值里同时携带"前端需要展示的支付方式名称",这个名称本身走多语言资源,确保顾客看到的支付方式文案也是本地化的。
3.3 核心实现:下单、回调验签、状态流转
以一个典型流程为例,顾客在澳大利亚门店扫码,菜单以英语展示,选择用银行卡支付。
下单时,后端创建订单,初始化状态为PENDING_PAYMENT,然后调用对应的银行卡收单适配器。适配器内部第一步调用支付网关的创建订单接口,拿到一串跳转URL;第二步把URL封装成PayResult返回;第三步前端拿到URL后跳转到网关页面,顾客完成付款。
这里有一个细节:跳转URL上经常会带着语言参数,比如?lang=en-AU或?locale=de。支付网关能根据这个参数决定用户看到的支付页语言。所以适配器设计时,这个参数必须从请求里透传过去。我给适配器加了一个LocalizedParameter字段,由业务层填充,适配器拼接到请求里。
回调处理是整个支付集成最核心的部分。各渠道回调报文差异巨大,但流程一致:
- 验签。用渠道公钥验签,失败直接拒绝并记录日志。
- 解析出订单号、渠道流水号、支付金额、支付币种。
- 查询本地订单,校验状态。如果订单已是
PAID,直接返回成功,避免重复处理。 - 幂等锁。用订单号加锁,防止并发回调导致重复修改。
- 更新订单状态为
PAID,写入支付流水,记录渠道回调原始报文。 - 触发后续业务动作(比如通知门店、更新桌台状态)。
- 向渠道返回确认成功。
每个步骤都可能出错。验签失败时,我会先检查公钥是否正确、回调时间戳偏差、字符集问题,而不是直接报警。幂等锁我用的方案是Redis分布式锁加数据库唯一索引双重保障,因为极端情况下回调并发导致连接数打满时,Redis锁虽然能挡住大部分重复请求,但万一锁超时释放了,数据库的唯一索引还能兜底。
3.4 幂等、对账与掉单补偿
支付集成的噩梦不是"支付失败",而是"支付成功但系统没感知"。我遇到最多的是这两种情况:
- 回调没有到达(网络抖动、渠道侧bug、防火墙拦了)。
- 回调到达了但验签时由于时间偏差失败。
所以必须有主动查单机制作为兜底。我的方案是定时任务,每2分钟扫描状态仍是PENDING_PAYMENT且创建时间超过10分钟的订单,对每个渠道调用queryPayment方法,查询订单真实状态。查到已支付就补单,查到已关闭就关单,查询本身也有超时重试和重试上限,防止把任务线程打爆。
幂等设计里有一个关键点:查询补单的写操作和回调的写操作要复用同一个paymentSuccess方法,这个方法的内部逻辑必须是"无论谁先执行,结果一致"。我通过唯一索引order_id + channel_txn_no保证支付流水只有一条记录,配合Redis锁确保只修一次订单状态。
对账则是一条更长的链路,每日跑批:本地订单表金额 = 渠道侧当日账单金额。误差来源通常有手续费、退款、折扣、汇率差。这个项目里我把对账差异拆成四类:已支付未入账、入账未支付、金额不一致、退款记录缺失。每一类都会生成对账异常单,由财务或研发介入处理。
4. 多语言支付无缝集成的实操细节
4.1 点餐场景中的多语言边界:菜品、规格、备注
支付集成之前,多语言的坑先出现在菜单数据上。菜品名称、描述、过敏原信息、规格标签、忌口备注,全都要多语言。我建了一张dish_localization子表,用dish_id + locale做主键,文案列存文本,主语言永远是产品的核心语言,比如英语。国际化子表的增删改走统一接口,不允许开发直接改数据库。
这里有个体验细节:顾客提交的备注(比如"不要香菜")是自由文本,不会也不需要做多语言翻译,但门店看到时必须能看懂。我的做法是提供一套"常用备注标签"体系,顾客点选标签,标签本身按多语言展示,门店端则显示该标签对应的门店语言文案。自由文本备注走OCR翻译服务,但这属于后期迭代,超出本次支付集成的范围。
支付环节里也到处是多语言:比如支付方式列表的文案,要展示"Credit Card"还是"Kreditkarte"取决于用户语言。我用一个PaymentMethodLocalization映射表,key是渠道code,value是各个语言下的展示名称。适配器返回渠道code,前端根据用户语言取文案,这样业务层跟语言彻底解耦。
4.2 扫码链路与支付参数的完整传递
二维码的内容设计也会影响多语言体验。一个桌码内容里至少要包含:店铺ID、桌号、渠道来源、会话token、语言偏好。语言偏好从二维码里读出来还是从请求头里读出来,我最终选择的是两种结合——二维码里写一个默认语言,前端检测到浏览器语言时再覆盖。
在支付环节,PayRequest里需要传递一个cancelUrl参数,顾客取消支付后跳转回来的页面。这个页面本身要做多语言化处理,不然用户从英文支付页取消后,跳回一个中文页面的菜单,体验很割裂。我踩过这个坑,所以后来设计支付结果页时,特意要求前端在URL的query里带上语言参数。
支付参数传递上另一个坑是callback地址不能经过任何重定向或者前置网关处理,因为支付渠道要求回调地址是公网可直达的HTTPS地址,任何中间层都可能改变报文体里面的某个参数,导致验签失败。我们在生产环境还被CDN缓存坑过一次,后来直接把核心回调接口排除在CDN缓存规则之外。
4.3 货币符号与数字格式的三种坑
国际版UI上最容易被忽略的是货币展示格式。同一个美元金额,不同语言环境下习惯不一样:
- 英语:$1,234.56
- 德语区:1.234,56 $
- 日语:¥1,235(没有小数位)
如果只是简单地在金额前面硬拼货币符号,一定有一批用户的看到的是错误格式。我的做法是把金额格式化交给前端,后端只返回"金额数字字符串+币种代码",由前端的国际化库根据当前语言环境生成格式化字符串。后端务必不要拼好符号再返回,否则汇率转换和格式化逻辑会纠缠在一起。
但有一种情况例外:PDF小票、邮件通知、或者发给门店的系统推送。这种没有前端国际化的场景,后端必须自己格式化。我封装了一个MoneyFormatter,输入金额数字字符串和币种代码,输出标准格式。测试用例覆盖了几十个币种,包括阿联酋迪拉姆的三位小数、智利比索的整数币、以及某些主要货币符号和代码在不同语言下的展示差异。
另外还有一个隐形坑:某些币种代码跟货币符号有歧义。比如CAD(加元)和AUD(澳元),符号都是$,前端如果只按符号展示,顾客根本分不清到底扣的是哪国钱。所以支付页里我要求一律展示ISO货币代码,符号只是辅助。
4.4 多语言多币种的沙箱验证方案
沙箱环境是整个项目里的隐形功臣。没有沙箱,我不敢想象怎么验证多语言、多币种、多渠道的排列组合。
我的沙箱验证步骤:
- 通过管理后台配置三个区域:澳大利亚门店、新加坡门店、日本门店。
- 每个区域配置不同币种和默认语言,分别对应英语、英语/中文双语、日语。
- 每个支付渠道在沙箱模式下配置一组测试密钥,注意沙箱密钥和正式密钥严禁混用且环境隔离,我见过有人把沙箱密钥带到生产环境引发大面积验签失败的事故。
- 写一套e2e冒烟用例,覆盖"用日语下的订单能不能用新加坡钱包支付""用美元计价的门店能不能收欧元"这类交叉场景。
- 用一个模拟回调工具,手动修改回调报文中的金额字段,验证验签失败时订单状态不变化。
沙箱环境里最值钱的一点是:它能验证出回调报文在真实的网络链路上是否丢字段、是否乱码、是否有签名时间偏差。比如某海外渠道沙箱的服务器时间是UTC,本地时间跟它差了8小时,我一开始没做时间窗口容忍,导致所有回调验签都失败。这个教训帮我写出了一套统一的回调验签工具,内部自动处理了时间窗口和时钟偏移。
5. 常见问题与排查技巧实录
5.1 金额对不上:浮点精度、舍入模式和币种小数位
先说结论:所有的金额计算,一律用BigDecimal,起步就要规划好舍入模式。我遇到的实际案例是订单总额为12.345欧元(这是三位小数的币种),渠道要求以"分"为单位存储,也就是1234.5分,但整数分是1235还是1234取决于舍入模式。我们用的模式是RoundingMode.HALF_UP,但某渠道默认用HALF_EVEN,两边算出来不一样,对账差了一分钱。
解决方式是在订单表里增加一列amount_in_minor_unit,直接存渠道要求的最小单位金额,这样业务层不管舍入,只是传递。但每次生成订单时,都要明确使用统一配置的舍入模式,这个配置存在币种元数据表里。
另一个容易踩的坑是BigDecimal除以BigDecimal时如果不指定精度,会抛ArithmeticException。所以我统一用divide(BigDecimal divisor, int scale, RoundingMode roundingMode),禁止使用无参除法。
5.2 回调验签失败的三个源头
回调验签失败最常见的原因,按频率排序是:
- 字符集不一致。渠道用ISO-8859-1签名,我们用UTF-8验签,只要报文里含非英文字符,签名必挂。
- 时间戳偏差。渠道要求签名里的时间戳跟当前时间差不能超过5分钟,但服务端时钟没有做NTP同步,导致超时。
- 拼接顺序错。有些渠道签名串是"金额+订单号+渠道流水",有些是"订单号+金额+渠道流水",顺序反了验签就失败。
排查这三个问题的办法是:把回调原始报文完整落库存档,单独做一个验签调试页面,可以粘贴报文、换字符集、换拼接顺序反复测试。这个调试页面在联调阶段救我无数次,后来直接做成了生产环境的只读排查工具。
5.3 超时未支付、取消、重复支付的状态处理
状态机要严谨。我用的订单状态是:
PENDING_PAYMENT: 已下单未支付PAID: 支付成功CLOSED: 超时关闭或用户取消REFUNDING: 退款中REFUNDED: 已退款
关键规则:
PENDING_PAYMENT在超时后不能直接变成CLOSED,因为可能存在"渠道已扣款但回调延迟"的极端情况。我做的处理是超时任务里先调渠道查单,查不到支付成功才关单。- 用户扫码重复支付同一订单时,第二次支付请求直接返回"订单已支付",不允许重复扣款。这个校验必须在创建支付请求前执行,而不是在回调阶段才判断。
- 订单关闭后又收到回调,这种场景要特殊标记。我建了一张
late_callback_log表,记录迟到的回调,人工介入处理后续退款。
5.4 多语言订单备注与支付小票的乱码与空白
如果订单备注是自由文本,多语言环境下API传输很容易出现乱码。原因通常是编码不一致——某个渠道回传备注字段时用了ISO-8859-1,而我们数据库用的是UTF-8。解决方式是在系统入口统一加一个过滤器,强制所有请求响应字符集为UTF-8。注意还要检查数据库连接串里的characterEncoding参数,Java侧连接MySQL时如果漏了,一样会乱码。
另外,支付成功后的电子小票上,门店名称、菜品名称需要按门店语言展示,但支付渠道的收据上是顾客语言。这个小细节在设计支付页时就要确认,不能让渠道的默认文案直接展示,因为它是基于渠道后台配置的,通常没有做多语言。
6. 从国际版支付集成中收获的经验
这个项目的后半段,几乎每一天都在验证一句话:支付集成的复杂度不在代码量,而在边界情况。多语言、多币种放进来后,边界情况成倍增加,而处理边界情况的核心不是"更多if-else",而是"配置驱动+统一抽象"。
我个人最受用的一个设计决定是:不要让业务代码知道任何支付渠道的具体规则。所有渠道差异被锁死在适配器内部,业务层只面对订单和统一接口。这样做的回报是巨大的——后来新增一个"先买后付"渠道时,我只花了一个下午就完成了适配器、沙箱联调、回归测试三件事。
还有一点想分享:多语言支付集成的测试,一定要用真实的多币种沙箱账号去测试,模拟回调时故意改变金额字段,验证验签和幂等是否可靠。这些测试用例要沉淀下来,放在CI/CD里每次发布回归都跑一遍,不然后面改任何一行支付代码,都可能悄悄引入金额错位的问题。
最后一个小技巧:无论渠道文档声称支持多少种语言,支付页上一定要有一个手动切换语言的入口,不能只依赖浏览器检测。我遇到过不少用户,浏览器默认语言是英语,但实际习惯用德语看支付页。进去点别的入口之前,用户就要能手动调整语言,这一点做得好不好,直接影响支付转化率。