东南亚连锁多业态团队首期试点海外版外卖系统时,最先撞墙的不是菜单翻译,而是「同一单谁改了什么、几端能不能对上」。

曼谷试点常见场景:总部运营在后台改配送费,商家端仍显示旧价;骑手 App 已点送达,用户端还在「备餐中」。根因往往不是网络慢,而是订单状态机没有统一写入口、权限没有按组织切数据范围。下文从模块划分、状态流转、RBAC 与部署片段说明怎么拆。示例为教学示意,以光合同城当期海外交付为准。
痛点:多端各写各的,权限边界模糊
低成本试点时,团队常把用户端、商家端、骑手端当成三个独立小项目。
每个端各自维护一张 order_status 字段,后台再拉第四套枚举。
改价、改地址、改配送时段,没有统一的「谁能改、改到哪一步」规则。
东南亚多城连锁还会叠加:总部看全量、分站只看本城、门店店长只能改自己店订单。
若权限只做到菜单可见性,做不到数据行级范围,就会出现「看得到别人的单、改得了不该改的状态」。
架构上应把订单写路径 与权限 Claims 做成可验收模块,而不是上线后再补 if-else。
模块目录树:订单中枢与权限域分离
text
overseas-wm-sea/
├── apps/
│ ├── diner-app/ # 用户端:只读 + 有限取消
│ ├── merchant-app/ # 商家端:接单、出餐、拒单
│ ├── rider-app/ # 骑手端:取餐、在途、送达
│ └── admin-console/ # 运营后台:改价、退款、人工介入
├── order-hub/
│ ├── state-machine/ # 唯一合法状态迁移
│ ├── command-api/ # 写入口:所有改单走这里
│ ├── read-model/ # 各端查询视图
│ └── event-outbox/ # 状态变更广播
├── authz/
│ ├── rbac-core/ # 角色、权限点
│ ├── data-scope/ # city / merchant / store 切片
│ └── audit-trail/ # 谁改了什么
├── takeout-catalog/ # 菜品、价格快照(不直接改订单)
├── payment-bridge/ # 支付回调,只推进资金相关迁移
├── mid-shared/
│ ├── user-master/
│ └── org-tree/ # 总部---分站---门店组织
└── ops/
├── state-transition.yaml
└── role-grant.yaml
验收要点:merchant-app 禁止直连数据库 UPDATE 订单表;所有写操作经 command-api,携带操作者 Claims。
read-model 可以按端做字段裁剪,但底层 order_id 与状态版本号必须一致。
技术栈列表(示意)
- 运行时:Java 17、Spring Boot 3;
command-api与read-model分进程 - 状态机:枚举 + 迁移表 YAML 配置,启动时校验无孤儿状态
- 权限:JWT Claims 携带
roles、city_codes、merchant_ids;网关层做 scope 过滤 - 消息:Outbox 表 + 消息队列,保证状态变更至少一次投递到各端缓存
- 数据:MySQL 8,
order_hub独立 schema;乐观锁字段state_version - 审计:
audit-trail只追加,禁止 UPDATE 历史行 - 部署:Docker Compose 或 K8s;配置外置 volume,便于多城复制
订单状态机:唯一写入口
海外版外卖订单建议按履约链路划分状态,而不是按端命名:
CREATED → PAID → MERCHANT_ACCEPTED → PREPARING → READY_FOR_PICKUP → RIDER_ASSIGNED → PICKED_UP → DELIVERING → DELIVERED → COMPLETED。
取消与退款走独立分支:CANCELLED_BY_USER、REJECTED_BY_MERCHANT、REFUND_PENDING 等。
关键约束 :同一 order_id 的状态迁移只能由 state-machine 模块判定合法性。
yaml
# ops/state-transition.yaml(示意)
transitions:
- from: PAID
to: MERCHANT_ACCEPTED
actor: merchant
command: accept_order
- from: MERCHANT_ACCEPTED
to: PREPARING
actor: merchant
command: start_preparing
- from: READY_FOR_PICKUP
to: RIDER_ASSIGNED
actor: system
command: assign_rider
- from: DELIVERING
to: DELIVERED
actor: rider
command: confirm_delivery
- from: PAID
to: CANCELLED_BY_USER
actor: diner
command: cancel_before_accept
guard: within_cancel_window
guard 把业务规则写进配置:例如用户只能在商家接单前取消。
非法迁移应返回明确错误码,而不是静默失败或写脏数据。
支付回调只触发 PAID 或资金失败相关迁移,不得跳过商家接单直接到「配送中」。
各国支付接口不同,支付通道对接支持按需定制开发;状态机只认「已支付 / 未支付」领域事件,不绑具体 PSP 字段。
多端读模型:同 ID、同版本
各端 App 不应各缓存一套状态枚举。
read-model 按端输出 DTO:用户端隐藏内部调度字段,骑手端隐藏毛利字段。
但每条响应必须带 state_version;客户端若本地版本落后,强制拉全量。
yaml
# order-hub/read-model/diner-view.yaml(示意)
view: diner_order_detail
fields:
- order_no
- state_code
- state_label_key # 走 i18n,不写死泰文/英文句子
- state_version
- eta_minutes
- amount_snapshot
hidden:
- rider_phone_raw
- merchant_cost_breakdown
state_label_key 与资金域解耦:换语言只改 catalog,不改 state_code。
这能避免「骑手端英文、用户端泰文、状态名对不上同一枚举」的联调噩梦。
并发改单:乐观锁与 command 幂等
东南亚午高峰会出现两路并发:商家点「出餐完成」,骑手同时点「已取餐」。
若只靠数据库最后写入胜出,状态可能跳过 READY_FOR_PICKUP 直进配送。
order_hub 表应带 state_version;每次合法迁移 version +1,冲突时返回 409 CONFLICT,客户端拉最新视图再决策。
yaml
# order-hub/command-api/idempotency.yaml(示意)
idempotency:
header: X-Idempotency-Key
ttl_hours: 24
scope: per_actor_per_command
同一骑手重复点「送达」,幂等键应返回首次成功结果,而不是二次推进状态。
event-outbox 与业务事务同事务写入:状态落库与待广播事件同 commit,避免「库已变、端未收到」。
各端消费事件时可按 order_id + state_version 去重,旧版本事件直接丢弃。
RBAC:角色、权限点与数据范围
权限分三层:能进哪个菜单 、能发哪个 command 、能看哪几行数据。
东南亚连锁试水常见组织:总部运营、城市分站、门店店长、客服只读。
yaml
# ops/role-grant.yaml(示意)
roles:
- id: hq_ops
commands:
- manual_refund
- adjust_delivery_fee
data_scope:
type: ALL_CITIES
- id: city_manager
commands:
- cancel_order
- reassign_rider
data_scope:
type: CITY_LIST
values: ["BKK", "CNX"]
- id: store_manager
commands:
- accept_order
- start_preparing
- reject_order
data_scope:
type: MERCHANT_LIST
from_org: store_binding
- id: rider
commands:
- confirm_pickup
- confirm_delivery
data_scope:
type: ASSIGNED_ORDERS_ONLY
command-api 收到请求时顺序校验:JWT 是否有效 → 角色是否含该 command → 订单是否在 data_scope 内 → 状态机是否允许迁移。
缺任一步都应拒绝,并写审计日志。
禁止 「超级管理员绕过状态机直接 UPDATE」作为长期方案;人工介入也应走 manual_override command,带审批单号。
审计:谁能改什么,必须可追溯
audit-trail 表建议字段:order_id、command、from_state、to_state、actor_id、actor_role、city_code、client_ip、at。
改配送费、改地址、人工退款,各对应独立 command,不得混在「编辑订单」一个大按钮里。
总部运营调整费用时,应快照改前改后金额,便于分站对账。
税务相关字段各国要求不同,报表与税项导出支持按需定制对接;系统提供可配置项,不默认某一国税制。
部署片段:先起状态机,再起各端
bash
# 示意:曼谷试点机,配置外置
set -euo pipefail
docker compose up -d mysql redis
cp ops/state-transition.yaml /data/config/
cp ops/role-grant.yaml /data/config/
docker compose up -d order-hub-state-machine order-hub-command-api
curl -fsS localhost:8090/sm/health | grep '"transitions_loaded":true'
docker compose up -d order-hub-read-model authz-core
docker compose up -d diner-app merchant-app rider-app admin-console
curl -fsS localhost:8091/commands/_ready
启动顺序解决竞态:若 command-api 先于状态机 YAML 加载,会出现「接口已开、迁移表为空」。
健康检查应验证迁移表无环、无 unreachable 状态。
role-grant.yaml 变更后需热加载或滚动重启,并跑一组越权测例(见验收清单)。
与中台组织树对齐
光合同城海外版外卖共享中台用户核心资料与统一后台底座。
org-tree 把总部、分站、门店编码写进 JWT,与 data_scope 对齐。
多业态低成本试水时,可先只开外卖模块菜单;权限模板仍按组织树预留,后续叠加跑腿或团购不必重建账号体系。
统一后台侧新增权限能力时,已接入海外模块可同步升级(以当期方案为准)。
支付与税务边界(不写统一能力)
海外版不提供全球统一的自动结算或默认税则。
支付成功回调只推进 PAID;退款走 REFUND_PENDING → REFUNDED,与状态机同一写入口。
具体国家通道、小费规则、税号字段,支持按需定制对接开发。
权限上,出纳角色可读导出,不可发 confirm_delivery;骑手不可发 manual_refund。
验收清单
- 同一
order_id在 diner / merchant / rider / admin 四端state_code一致 - 非法迁移(如
CREATED直跳DELIVERED)全部被拒,且有审计行 - 曼谷店长账号无法查询清迈门店订单(越权返回 403)
- 骑手无法对未指派给自己的单执行
confirm_delivery - 改配送费后,四端
amount_snapshot与state_version同步递增 - 停掉
read-model再恢复,客户端版本落后时强制刷新 audit-trail可按order_id导出完整 command 链- 支付回调重复投递幂等,不产生双
PAID事件 - 人工退款必须带
approval_ref,无审批号拒绝执行 - 配置变更后越权测例脚本全绿
反模式:端上各自改状态
常见失败是商家 App 里写 UPDATE orders SET status=5。
骑手 App 又用另一套数字,后台再映射成中文标签。
短期演示能跑,第二城复制时映射表爆炸,无人敢改枚举。
正确做法是端上只发 command,状态码只在 order-hub 一处定义。
另一个反模式是把「城市过滤」写在前端路由里,后端不做 scope 校验。
前端隐藏菜单挡不住直接调 API;command-api 必须在服务端过滤 city_code。
联调脚本:四端状态快照比对
低成本试水可把验收写成脚本,而不是靠人工刷四台手机。
bash
# 示意:同一 order_no 拉四端 read-model,比对 state_code
ORDER_NO="$1"
DINER=$(curl -s "localhost:8092/views/diner/orders/$ORDER_NO" | jq -r .state_code)
MERCH=$(curl -s "localhost:8093/views/merchant/orders/$ORDER_NO" | jq -r .state_code)
RIDER=$(curl -s "localhost:8094/views/rider/orders/$ORDER_NO" | jq -r .state_code)
ADMIN=$(curl -s "localhost:8095/views/admin/orders/$ORDER_NO" | jq -r .state_code)
test "$DINER" = "$MERCH" && test "$MERCH" = "$RIDER" && test "$RIDER" = "$ADMIN" \
&& echo "OK all=$DINER" || echo "MISMATCH diner=$DINER merch=$MERCH rider=$RIDER admin=$ADMIN" >&2
每推进一个 command 后跑一遍,能立刻定位「哪一端缓存或视图落后」。
越权测例应单独脚本:用清迈店长 token 请求曼谷 order_id,期望 403 而非空列表蒙混。
技术小结
海外版外卖系统要先把订单流转 和权限组织拆成可部署模块:状态机唯一写入口、command 与 RBAC 绑定、数据范围跟组织树走。
东南亚连锁多业态试水,应用配置和脚本就能核对「谁能改什么、改完几端是否一致」,而不是上线后靠人工对表。
支付与税务按国家差异做按需对接;架构层只保证状态与审计可复现,不把各国规则写死在 core 里。