国际版扫码点餐系统设计:多语言与多渠道支付网关的架构实践
扫码点餐在国内已经是餐厅的标配但一旦加上“国际版”这三个字整个系统复杂度的跨度会直接拉满。我自己在做某跨平台扫码点餐项目时最深的体感是语言切换和支付渠道接入不是“加几个翻译文件”和“调几个支付接口”那么简单它们会渗透到菜单数据模型、订单状态机、金额计算、回调和退款流程的每一个角落。这篇内容我把整个项目从设计到落地的关键决策、踩坑经验都整理出来希望对正在做或准备做同类系统的朋友有点实际帮助。1. 先从业务说起国际版扫码点餐到底难在哪1.1 同样是扫码点餐难点差在哪国内版扫码点餐的核心链路是“扫码 → 看菜单 → 下单 → 在线支付 → 后厨出餐”这套链路在技术上非常成熟。但国际版面对的顾客群体是流动的、跨境的、语言和支付习惯完全不可预测的。比如同一张桌上上午可能是本地顾客扫码习惯用本地钱包下午来了一群国外游客界面语言要切到英文甚至日文、韩文支付方式变成了外卡或国际钱包。如果系统在语言和支付上提前没有做“通道级”的设计就会出现“菜单只有一种语言、支付接口只接了一家、金额只有一种币种”的僵硬状态。更麻烦的是语言、币种、支付渠道这三件事不是独立的。顾客切换语言后菜单内容、口味选项、过敏原提示、价格展示都要跟着变价格展示的币种和最终支付币种、结算汇率又跟支付渠道强关联。所以国际版扫码点餐的核心难点不是某个功能有多难写而是“所有功能之间怎么建立一致性的联动规则”。1.2 关键模块与技术选型在我设计的方案里系统划分为几大模块扫码会话模块负责二维码生成、桌台绑定、会话续期。菜单模块负责菜品、分类、规格的多语言内容与库存。订单模块负责购物车、下单、订单状态流转。支付模块负责多渠道接入、支付回调、退款对账。国际化模块负责文案、日期、金额格式的统一处理。技术栈选用的是 Java 17 Spring Boot 3数据存储用 MySQL Redis异步消息用消息队列来处理支付回调。单体应用起步模块之间用清晰的 service 层边界隔离。对于大部分餐饮商家场景一上来拆微服务属于过度设计单体把边界划清楚后续再按支付、订单模块拆分也来得及。2. 多语言方案从界面文案到菜单数据全链路2.1 界面文案与业务数据必须分开处理很多第一次做国际化的同学会把所有翻译都塞进一个 properties 文件里结果菜单名、菜品描述这种高频变的业务数据也写死在代码里运营改一次翻译就要发一次版本这种做法在真实项目中完全行不通。我的做法是把国际化拆成两层界面文案层按钮、提示语、错误信息这类静态内容用标准的 i18n 资源文件管理。比如“加入购物车”在不同语言下是不同的资源 key这部分交给代码加载走 ResourceBundle 或者 Spring 的 MessageSource。业务数据层菜单名、分类名、规格名、描述信息这类数据必须存在数据库里而且不能简单用 name_en、name_ja 这样加列的方式。语言种类一多加列的做法就崩了。界面文案层适合用 MessageSource 管理因为这类文案跟业务数据没有绑定关系加载快改版也集中。业务数据层则必须设计成一张翻译子表让每一条菜单记录可以无限扩展语言版本。2.2 菜单多语言的数据表设计业务数据的国际化最稳的方案是一张主表搭配一张翻译表。主表存菜品的基础属性编号、价格、分类、库存、状态翻译表存每个语言版本下的名称和描述。菜品主表设计大致如下CREATE TABLE menu_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_code VARCHAR(64) NOT NULL, category_id BIGINT NOT NULL, base_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE menu_item_translation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL, locale VARCHAR(16) NOT NULL, name VARCHAR(128) NOT NULL, description VARCHAR(512), alias_name VARCHAR(128), UNIQUE KEY uk_item_locale (item_id, locale) );这个设计的好处是语言可以随时扩展加一种新语言就批量插入对应 locale 的记录不用动表结构。查询时通过 locale 参数 JOIN 翻译表匹配不到再回退默认语言。这里有一个关键决策翻译表里要冗余一份alias_name搜索别名。因为日语、韩语、阿拉伯语在搜索场景下经常要用到不同的检索字段独立存更灵活。2.3 语言切换与会话绑定顾客扫码后第一步就是确定语言。我用的方案是三层优先级二维码携带的 lang 参数餐厅可以在菜单上给不同语言的二维码也可以让顾客手动切换。HTTP 请求的 Accept-Language 请求头。系统默认语言。确定之后语言标识会写入扫码会话存到 Redis 里后续购物车、下单、支付页面全部从这个会话里读取语言上下文。这样保证用户在点餐中途切换语言已经加入购物车的菜品描述、规格、价格也能同步切换。语言切换对购物车数据有直接影响比如“少辣”“不要香菜”这种备注切换语言后原来的内容还在但购物车展示字段会跟着语言走这部分要在前端做按字段加载不要把购物车的展示文案提前拼接死。2.4 金额格式、日期与汇率联动真正容易翻车的是国际化里的格式处理我处理的是日期时间和金额这两块。Java 里直接用 NumberFormat 和 DateTimeFormatter 就可以解决大部分展示问题。比如美元习惯显示$12.50日元习惯显示¥1,200欧元区有些地方习惯12,50 €。这些格式问题如果每个前端页面自己拼字符串一定会出现不一致。DecimalFormat df (DecimalFormat) NumberFormat.getCurrencyInstance(locale); df.setCurrency(Currency.getInstance(currencyCode)); String priceText df.format(amount);金额存数据库时有一个不能妥协的原则一律用 decimal(10,2) 存计算时用 BigDecimal禁止用 double 或 float。这是支付场景的底线问题浮点误差在金额计算里是不可接受的。汇率联动方面订单生成时要锁定当时汇率并把汇率和币种快照写进订单表。这样后续退款、订单查询、对账都按这个快照金额处理不会因为汇率波动产生差异。3. 支付网关无缝集成的架构3.1 为什么必须做渠道抽象国际版扫码点餐的支付场景从来不是“只接一家支付渠道”那么简单。本地顾客可能用本地扫码支付跨境游客可能用外卡结算还有部分地方喜欢用电子钱包和预付卡。每一家渠道的接口风格、签名方式、回调字段、退款规则都不一样。如果代码里到处散落着某渠道的 HTTP 调用和签名逻辑后续每接入一个新渠道都要改动大量业务代码这完全背离了“无缝集成”的目标。正确的思路是做一个支付渠道抽象层业务侧只依赖抽象接口具体实现按渠道隔离成独立组件。这样项目在接第一个渠道和接第五个渠道时的成本基本一致核心业务代码零改动。3.2 统一支付接口定义我定义了一个统一的支付网关接口所有渠道适配器都要实现它public interface PaymentGateway { /** * 渠道标识例如 LOCAL_SCAN、CARD_ACQUIRING、E_WALLET */ PaymentChannelEnum channel(); /** * 创建预支付订单返回支付所需参数 */ PaymentPrepayResult createPayment(PaymentPrepayRequest request); /** * 解析并验证渠道回调内容 */ PaymentCallbackResult parseCallback(PaymentCallbackRequest request); /** * 发起退款 */ RefundResult refund(RefundRequest request); /** * 查询渠道侧订单状态 */ PaymentQueryResult queryOrder(PaymentQueryRequest request); }这个接口在整个项目里是整个支付模块的“宪法”任何渠道接入都必须实现这五个方法。业务代码只面向这个接口编程订单确认、支付结果同步、退款申请都走统一入口。3.3 Strategy Factory 的渠道路由渠道路由我用的是策略模式加工厂模式的组合这也是 Java 里最顺手的一次实践。具体写法是利用 Spring 的依赖注入特点把所有 PaymentGateway 实现类收集到一个 List 里再在初始化阶段按 channel 注册到 Map。Component public class PaymentGatewayRouter { private final MapPaymentChannelEnum, PaymentGateway gatewayMap new HashMap(); public PaymentGatewayRouter(ListPaymentGateway gateways) { for (PaymentGateway gateway : gateways) { gatewayMap.put(gateway.channel(), gateway); } } public PaymentGateway route(PaymentChannelEnum channel) { PaymentGateway gateway gatewayMap.get(channel); if (gateway null) { throw new UnsupportedOperationException(Unsupported channel: channel); } return gateway; } }新增一个支付渠道时只需要新增一个实现类加上 Component 注解路由表自动注册完成业务代码不用改任何一行。这个模式我强烈推荐它不只是省事更重要的是降低了“新渠道接入”时引入回归 bug 的概率。这里要注意渠道枚举本身要设计成可扩展的不要用 if-else 去判断渠道类型。渠道枚举只做标识具体行为全部收敛在适配器内部。3.4 回调验签与边界处理支付回调是支付模块里最容易出问题的环节国际版场景下问题会被放大。因为跨境网络不稳定、渠道回调可能延迟、重复、乱序还有伪造回调的安全风险。我在每个渠道适配器内部做验签验签算法和密钥完全封装在适配器里业务层不感知。回调处理本身遵循一套固定流程渠道回调进来先按渠道适配器解析报文。校验签名验证订单金额、币种、订单号。对订单号加分布式锁防止并发重复回调同时修改订单状态。落库支付流水更新订单支付状态。推送消息到后端通知后厨制作。第 3 步特别重要。真实生产的回调重复率比想象中高很多尤其渠道侧做了失败重发机制后同一笔支付回调可能会来三四次。没有幂等控制的回调流程会把订单状态冲坏。签名校验时我额外做了一层“金额一致性校验”也就是汇率快照。如果回调里支付的金额和订单创建时锁定的金额不一致绝对不能直接改状态要进入人工核对清单。这个边界一开始很容易忽略等出了问题再补就很被动。3.5 退款与对账统一入口退款是支付集成里容易“做完基础版就抛到脑后”的环节但国际版场景里恰恰是退款最容易出问题。首先是部分退款。顾客点了三菜一汤结果后厨发现有个菜售罄需要退掉单个菜品。这时候退款金额不是订单全额而是单个菜品金额同时还要考虑整单是否还有优惠分摊。我的处理方式是在订单里保存菜品行的金额明细和优惠分摊比例退款时按菜品行反向计算。其次是对账。每家渠道的结算周期不同有的 T1有的按日汇总有的按周。统一对账入口的做法是每天凌晨从各渠道拉取前一天的交易流水和本地支付流水表做比对差异项自动生成异常工单。退款接口同样走统一的 RefundRequest适配器内部转换成各渠道的退款报文并把退款状态同步到本地。4. 核心流程实现点餐到支付的完整链路4.1 扫码会话与桌台绑定扫码点餐的第一步是顾客扫桌上的二维码。二维码里我存的是桌台编码和门店编码而不是订单号这一点很关键。订单是在顾客浏览菜单、加购之后才生成的二维码只解决“这个人坐在哪一桌”的问题。扫码之后服务端生成一个会话标识并写入 Redis结构大致是String sessionKey scan:session: sessionId; ScanSession session new ScanSession(); session.setStoreNo(storeNo); session.setTableNo(tableNo); session.setLocale(locale); redisTemplate.opsForValue().set(sessionKey, session, Duration.ofMinutes(30));会话的有效期建议设置成 30 分钟并且顾客每次加购、下单时都刷新过期时间。这样顾客聊了很久没下单不会突然被踢出页面但又不会出现“二维码永久有效导致异物霸占餐桌”的问题。桌台绑定会话的设计有额外好处如果一个顾客扫码后把链接分享给朋友朋友打开链接访问的是同一个会话各自选菜合并到一个购物车体验上更像一桌人一起点餐。4.2 下单与预支付生成顾客提交订单后服务端做这几件事校验购物车菜品状态与库存。计算订单金额应用优惠规则。创建订单主记录和订单明细。生成支付单进入待支付状态。根据顾客选定支付渠道调用对应适配器获取预支付参数。订单号我用的是“业务日期 门店编号 随机序号”组合比如 20250516 00012 6 位随机数这样既保证可读性也能在分布式环境下避免重复。创建支付单时订单状态要有一个明确的“待支付”阶段。我在这个阶段就调预支付接口而不是等顾客点“去支付”再调这样页面上可以直接展示二维码或跳转支付页缩短用户等待时间。预支付生成结果要缓存到 Redis避免顾客重复点击“去支付”产生多个渠道预支付单。渠道侧一般会限制一个订单号只能预支付一次重复创建直接报错。4.3 订单状态机设计订单状态机是整个系统的核心骨架我把它拆成订单主状态和支付子状态两部分避免所有状态混在一起。订单主状态CREATED已下单待支付。PAID已支付等待后厨确认。PREPARING制作中。COMPLETED已完成。CANCELLED已取消。REFUNDED已退款。支付子状态PENDING待支付。SUCCESS支付成功。REFUNDING退款中。REFUNDED已退款。订单状态机的流转规则用枚举加状态表去校验禁止业务代码里随意修改状态。比如已取消的订单不能变成已支付已退款的订单不能再发起部分退款这些约束要集中在一个校验器里。这个状态下退款和取消是两个容易冲突的边界。顾客支付成功后如果后厨还没开始做顾客要求取消这时候订单会走到“取消中”状态如果渠道退款回调先于本地取消确认到达状态就要合并处理不能出现“系统里已退款、订单显示制作中”这种灾难情况。4.4 支付完成后通知与补单机制支付成功后的动作可以拆成两条路径同步路径渠道回调更新支付流水更新订单状态。异步路径向业务方推送出餐消息向顾客推送支付成功通知。我建议同步路径只做核心状态更新所有衍生动作放异步消息。原因很简单支付回调里做太多事会把回调接口拖慢渠道侧可能因此超时重发造成重复处理。这里补单机制必须设计好。真实场景里渠道回调偶尔会延迟甚至丢失不能完全依赖回调驱动状态更新。我的方案是引入一个定时任务每隔一分钟扫描处于“待支付”且超过预支付创建时间 5 分钟的支付单主动调用渠道查询接口拉取支付结果。这个补单机制在跨境支付场景里尤为重要因为跨境网关回调延迟明显比本地支付高不做主动查询用户明明付了钱系统却迟迟不确认客诉会直接打爆门店电话。5. 实战中踩过的坑与排查思路5.1 菜单翻译遗漏与缓存分层界面文案翻译遗漏很好发现页面上直接显示资源 key 或者英文原文。但菜单数据的翻译遗漏很隐蔽因为菜单数据量很大运营批量录入时经常漏掉某个冷门语言版本。我的排查思路是建立翻译完整性校验定时任务SELECT i.id, i.sku_code FROM menu_item i LEFT JOIN menu_item_translation t ON t.item_id i.id AND t.locale ? WHERE t.id IS NULL;把支持的语言列表作为参数循环跑一遍查出来缺翻译的菜品自动生成待翻译任务。缓存策略上菜单列表缓存 key 必须包含 locale防止出现“切语言之后还是旧语言菜单”的缓存串数据问题。我遇到过最典型的一次菜品名称缓存没区分语言顾客把语言切成英文热门菜品还是显示中文名。排查了半天发现是缓存 key 只按门店加分类维度做的缓存压根没把 locale 拼进去。5.2 汇率波动与退款金额不一致汇率问题是我觉得整个项目里最容易踩的深坑。订单创建时锁定汇率后支付金额按锁定汇率计算这个规则一定要贯穿退款流程。举个具体场景顾客用美金支付了一笔 100 美元订单当时结算汇率是 7.2 人民币汇率稳定在某个水平。两小时后顾客申请退款如果退款接口此时用“最新汇率”计算退款金额折算回顾客原支付币种就可能出现明显差额顾客会觉得“为什么退款少了”而且对账也会出现差异。正确处理是订单表里存兑换汇率快照和结算币种退款时用快照换算回原币种保证退款金额和支付本金严格一致。另外要注意部分渠道退款有手续费这个费用是体现成渠道侧扣减还是额外补收要在设计支付账单时提前列清楚不能退款后才发现账单对不上。5.3 回调重复、丢失与乱序支付回调的乱序问题比大多数人想得更严重。我遇到过的最极端情况是取消订单操作先执行然后旧的支付成功回调才到系统把已取消订单又改成了已支付。解决思路分三层状态机校验层状态变更前检查当前状态是否允许流转不允许就直接拒绝并记录告警。分布式锁层对订单号加锁保证同一时间只有一个线程在处理状态变更。幂等表层支付流水表对渠道回调单号建唯一索引重复回调直接命中冲突拒绝。这三层缺一不可光靠某一层都不够因为状态机校验无法处理两个请求恰好同时进来的并发场景分布式锁无法处理回调丢失后的补单幂等表只能防重不能防乱序。5.4 二维码过期与跨设备续点二维码过期时间设多长是个平衡问题。设太短顾客看菜单时间长一点就失效体验很差设太长空桌被人恶意占着又会影响翻台。我最后采用的是“30 分钟无操作过期”并且购物车有数据时自动续期。同时把会话和桌台绑定关系放在 Redis顾客跨设备扫码时用微信或其他方式打开的链接能识别同一个会话不会出现“手机加了菜、平板看不到”的问题。压测时发现另一个细节顾客扫码后前端会不断轮询购物车数据高峰期会产生大量无效请求。我后来在会话里加了个 revision 版本号购物车每次变更就递增一次前端只在版本号变化时才拉全量数据请求量直接降了一个数量级。5.5 高并发时段下单与库存超卖餐厅高峰期的并发量虽然比不上电商大促但短时集中下单还是可能打爆单点数据库。尤其热门菜品库存只有几十份的时候抢购场景和秒杀很像。库存扣减我用的是 Redis 预扣减加数据库兜底校验的方案Long count redisTemplate.opsForValue().decrement(stockKey, quantity); if (count null || count 0) { // 回滚扣减提示库存不足 redisTemplate.opsForValue().increment(stockKey, quantity); throw new StockNotEnoughException(); }订单创建成功后再异步同步库存到数据库。这个方案能扛住瞬时集中流量但要接受极端情况下 Redis 和数据库库存短暂不一致的可能。我的补偿策略是每笔订单支付成功后再次核验数据库库存如果支付后才发现超卖就自动触发退款关单同时把结果推送门店确认。这个策略在粥粉面饭这类快餐场景里被验证是可靠的因为顾客点餐就是冲着一两道热门菜去的超卖识别得越早处理成本越低。6. 一些真实体会和扩展方向做完这个项目后我最大的体会是“国际版”三个字真正增加的成本并不在代码量上而在一致性规则的梳理上。语言、币种、支付、退款这些模块单独做都不复杂难的是它们彼此咬合的那部分菜单和语言咬合订单和汇率咬合支付和状态机咬合退款和对账咬合。只要咬合规则一开始没定义清楚后面每个渠道的接入都会把这几个咬合点再翻出来蹂躏一遍。所以建议大家在做之前先把“顾客从扫码到退款的完整旅程”画成一份状态流转文档标注清楚每一步哪些数据需要快照、哪些数据允许变化、哪些操作要加锁。文档不一定要多长但必须把语言、币种、订单状态三个维度的变化规则写清楚。如果后续业务扩展我会优先考虑两个方向一个是在菜单多语言基础上加上过敏原和营养信息的翻译体系这在国际游客密集的场景里属于刚需另一个是围绕支付渠道做更细粒度的风控策略比如大额订单强制走信用卡渠道、异常地域请求默认拦截。前者关系用户体验后者关系资金安全两个方向都值得单独开一轮迭代去做。