海外版外卖系统架构:订单怎么流转、权限怎么分

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

曼谷试点常见场景:总部运营在后台改配送费,商家端仍显示旧价;骑手 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-apiread-model 分进程
  • 状态机:枚举 + 迁移表 YAML 配置,启动时校验无孤儿状态
  • 权限:JWT Claims 携带 rolescity_codesmerchant_ids;网关层做 scope 过滤
  • 消息:Outbox 表 + 消息队列,保证状态变更至少一次投递到各端缓存
  • 数据:MySQL 8,order_hub 独立 schema;乐观锁字段 state_version
  • 审计:audit-trail 只追加,禁止 UPDATE 历史行
  • 部署:Docker Compose 或 K8s;配置外置 volume,便于多城复制

订单状态机:唯一写入口

海外版外卖订单建议按履约链路划分状态,而不是按端命名:

CREATEDPAIDMERCHANT_ACCEPTEDPREPARINGREADY_FOR_PICKUPRIDER_ASSIGNEDPICKED_UPDELIVERINGDELIVEREDCOMPLETED

取消与退款走独立分支:CANCELLED_BY_USERREJECTED_BY_MERCHANTREFUND_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_idcommandfrom_stateto_stateactor_idactor_rolecity_codeclient_ipat

改配送费、改地址、人工退款,各对应独立 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_PENDINGREFUNDED,与状态机同一写入口。

具体国家通道、小费规则、税号字段,支持按需定制对接开发

权限上,出纳角色可读导出,不可发 confirm_delivery;骑手不可发 manual_refund

验收清单

  • 同一 order_id 在 diner / merchant / rider / admin 四端 state_code 一致
  • 非法迁移(如 CREATED 直跳 DELIVERED)全部被拒,且有审计行
  • 曼谷店长账号无法查询清迈门店订单(越权返回 403)
  • 骑手无法对未指派给自己的单执行 confirm_delivery
  • 改配送费后,四端 amount_snapshotstate_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 里。

相关推荐
Python 实战手记1 小时前
企业微信主体变更公证书办理全解析:适用场景、材料规范、踩坑点与Python信息校验脚本
开发语言·python·企业微信
longxiaozhang61 小时前
C#异常处理:程序出错了怎么办?
开发语言·数据库·c#
郝学胜-神的一滴1 小时前
Effective Python 条款 1:确认你正在使用的 Python 版本
开发语言·数据结构·python·程序人生·算法
pengyu1 小时前
【Kotlin 协程修仙录 · 炼虚境 · 初阶】 | 虚空造物:Channel 基础与协程间通信的管道艺术
android·前端·kotlin
提线木偶1 小时前
CORS 到底谁说了算?一份跨域配置的避坑指南
前端·后端
浩风祭月1 小时前
Agent Plugins 1.0怎么把一套技能同时带进VS Code和Copilot CLI?
ai编程·vs code·github copilot·mcp·copilot cli·agent plugins
一木 之林1 小时前
五、C++新特性、关键字与编译原理
java·jvm·算法
路多辛1 小时前
全能型 Go Agent 框架 covonaut v1.0.8 发布:新增行内补全与多后端可观测性
开发语言·golang·agent
大模型丫丫1 小时前
FastAPI 入门指南:从零开始构建高性能 Python API
开发语言·python·fastapi