海外版多语言团购系统架构:主数据互通与核销边界

东南亚华人团队选型「多语言团购系统」时,技术评审常卡在数据不通:i18n 资源、套餐主档、券占用与门店核销是否落在同一部署域与同一主键体系。下文用模块树、i18n 配置、核销状态机与联调验收说明海外版团购成品如何基于中台底座落地。示例为教学示意,侧重架构说明而非菜单演示。

场景痛点:多语言展示层 vs 业务主数据

常见反模式是「前端一套 locale 文件 + 核销小工具另库 + 券平台第三方」。结果是:

  • sku_id 在交易库与核销库不一致
  • 切语言时复制出多行套餐,库存各自扣减
  • 复购券按手机号匹配失败(两套 user_id

低成本试水可以少开城、少开门店,但不能接受主数据分裂。

模块目录树(示意)

text 复制代码
overseas-groupbuy/
├── gateway/
│   └── i18n-filter          # Accept-Language / locale 解析
├── mid-platform/
│   ├── user-service         # 全局用户主档
│   ├── merchant-service     # 商家/门店主档
│   ├── marketing-service    # 券模板与占用
│   └── authz-service        # 分站/角色权限
├── groupbuy/
│   ├── catalog-service      # 套餐主档 + 多语言文案表
│   ├── trade-service        # 下单/支付回调
│   └── redeem-service       # 到店核销
├── admin-api/               # 统一后台入口
└── deploy/
    ├── values-sea.yaml
    └── i18n/
        ├── zh-CN.json
        ├── en-US.json
        └── th-TH.json

原则:locale 文件只承载展示文案;catalog 主档与 user/merchant 主档在 mid-platform 侧唯一;redeem-service 不维护第二套用户表。

技术栈边界(示意)

  • 网关:Nginx / API Gateway,注入 X-LocaleX-Station-Id
  • 服务:Java / Go 均可,关键是同一事务边界内写订单与券占用
  • 存储:MySQL(业务主库)+ Redis(核销短码缓存)+ 对象存储(导出)
  • 配置:按国家启用语言列表与币种,不按语言复制 SKU 行

套餐主档与文案分离(示意)

sql 复制代码
CREATE TABLE gb_sku (
  sku_id        BIGINT PRIMARY KEY,
  merchant_id   BIGINT NOT NULL,
  station_id    BIGINT NOT NULL,
  stock_qty     INT NOT NULL,
  price_minor   BIGINT NOT NULL COMMENT '最小货币单位',
  currency      CHAR(3) NOT NULL,
  status        TINYINT NOT NULL COMMENT '1上架 0下架'
);

CREATE TABLE gb_sku_i18n (
  sku_id        BIGINT NOT NULL,
  locale        VARCHAR(16) NOT NULL,
  title         VARCHAR(256) NOT NULL,
  detail_md     TEXT,
  PRIMARY KEY (sku_id, locale)
);

查询时按 sku_id 取主档,再按 locale join 文案。禁止「泰语一套 sku_id、英语另一套 sku_id」。

核销状态机(示意)

text 复制代码
CREATED → PAID → READY_TO_REDEEM → REDEEMED
                      ↘ REFUNDED
                      ↘ EXPIRED
java 复制代码
// 示意:核销必须带着订单域状态迁移
public RedeemResult redeem(String code, long storeId, long operatorId) {
  CouponHold hold = holdRepo.lockByCode(code);
  if (hold.getStatus() != HoldStatus.READY_TO_REDEEM) {
    throw new BizException("HOLD_STATE_INVALID");
  }
  Order order = orderRepo.lock(hold.getOrderId());
  assertSameStation(order.getStationId(), storeId);
  hold.markRedeemed(storeId, operatorId, Instant.now());
  order.markRedeemed();
  eventBus.publish(new RedeemedEvent(order.getId(), hold.getUserId()));
  return RedeemResult.ok(order.getId());
}

核销成功必须同时更新券占用与订单状态;只改门店本地「已核」列表,视为数据不通。

券占用表与支付回调:把「待核销」写成库事实

东南亚试水若只有「支付成功」弹窗、没有券占用行,扩城后必然对不上。建议支付回调与占用落库同事务边界(或最终一致但可对账)。

sql 复制代码
CREATE TABLE gb_order (
  order_id      BIGINT PRIMARY KEY,
  order_no      VARCHAR(32) NOT NULL UNIQUE,
  user_id       BIGINT NOT NULL,
  sku_id        BIGINT NOT NULL,
  station_id    BIGINT NOT NULL,
  currency      CHAR(3) NOT NULL,
  amount_minor  BIGINT NOT NULL,
  status_code   VARCHAR(32) NOT NULL,
  locale_pref   VARCHAR(16) NULL,
  paid_at       DATETIME NULL,
  redeemed_at   DATETIME NULL,
  INDEX idx_user (user_id, created_at),
  INDEX idx_station_status (station_id, status_code)
);

CREATE TABLE gb_coupon_hold (
  hold_id       BIGINT PRIMARY KEY,
  order_id      BIGINT NOT NULL,
  user_id       BIGINT NOT NULL,
  sku_id        BIGINT NOT NULL,
  redeem_code   VARCHAR(64) NOT NULL UNIQUE,
  status        VARCHAR(32) NOT NULL, -- READY_TO_REDEEM/REDEEMED/EXPIRED/REFUNDED
  store_id      BIGINT NULL,
  operator_id   BIGINT NULL,
  expire_at     DATETIME NOT NULL,
  redeemed_at   DATETIME NULL
);
java 复制代码
// 教学示意:支付成功 -> 待核销
@Transactional
public void onPaid(String orderNo, long amountMinor, String currency) {
  GbOrder order = orderRepo.lockByNo(orderNo);
  if (!order.getAmountMinor().equals(amountMinor)
      || !order.getCurrency().equals(currency)) {
    throw new PaymentMismatchException(orderNo);
  }
  if ("READY_TO_REDEEM".equals(order.getStatusCode())) return; // 幂等
  transit(order, "PAID");
  transit(order, "READY_TO_REDEEM");
  String code = codeGen.next();
  holdRepo.insert(CouponHold.ready(order, code, Duration.ofDays(30)));
  redis.opsForValue().set("redeem:" + code, order.getOrderId(), Duration.ofDays(30));
}

验收:重复回调不生成第二枚短码;金额/币种不一致拒绝;gb_coupon_holdgb_order 状态同时进入待核销。

网关路由与权限拒绝测例

多语言团购系统的入口层要同时处理 locale、模块开关与分站范围,避免业务服务各自解析 Accept-Language 并各写一套权限。

yaml 复制代码
# gateway 片段(教学示意)
routes:
  - id: gb-trade
    path: /api/gb/trade/**
    uri: lb://trade-service
    filters: [Authn, LocaleInject, StationScope, ModuleEnabled=overseas_groupbuy]
  - id: gb-redeem
    path: /api/gb/redeem/**
    uri: lb://redeem-service
    filters: [Authn, StationScope, ModuleEnabled=overseas_groupbuy]
  - id: gb-admin
    path: /api/gb/admin/**
    uri: lb://admin-api
    filters: [Authn, Rbac, StationScope]
java 复制代码
// 教学示意:邻城核销拒绝
public void assertSameStation(long orderStationId, long storeStationId) {
  if (orderStationId != storeStationId) {
    throw new AccessDeniedException("cross-station redeem denied");
  }
}

拒绝测例(建议原样进附件):

  1. 模块关闭后 /api/gb/**403,统一后台菜单同步隐藏
  2. 分站账号导出邻城核销明细 → 403
  3. READY_TO_REDEEM 之外状态核销 → 业务错误,门店端不得本地记「已核」
  4. 短码过期后核销 → EXPIRED,库存/报表可区分于退款
  5. 无 Token 访问核销接口 → 401
  6. 不支持的 locale(未在 enabledLocales)→ 明确错误或回落 fallback(方案二选一,写死并测)
bash 复制代码
# 教学示意
curl -sS -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $STATION_B_TOKEN" \
  "$BASE/api/gb/admin/redeems?stationId=$STATION_A"
# 期望:403

curl -sS -H "Accept-Language: th-TH" -H "Authorization: Bearer $USER" \
  "$BASE/api/gb/trade/skus/$SKU" | jq '.sku_id,.title'
# 期望:sku_id 与 en-US 相同,title 为泰语文案

中台主数据与分站隔离(示意)

yaml 复制代码
# values-sea.yaml(示意)
midPlatform:
  user:
    globalUnique: phone_e164   # 东南亚试水常用手机号 E.164
  station:
    enforceScope: true
groupbuy:
  i18n:
    enabledLocales: ["zh-CN", "en-US", "th-TH"]
    fallback: "en-US"
  redeem:
    requireOrderState: READY_TO_REDEEM
    writeBackOrder: true

enforceScope: true 时,分站账号读取邻城订单应返回 403。中台营销能力升级(如新券模板类型)可由 marketing-service 统一发布,海外版团购模块复用,无需每个烟囱重做。

用户主档与商家主档必须全局唯一:同一 phone_e164 不得因语言包或城市场景再开第二行。复购券、会员标签都依赖这一点;否则「多语言团购系统」只是多套前台皮肤。

sql 复制代码
-- 教学示意:中台用户与商家(海外团购复用)
CREATE TABLE mdm_user (
  user_id      BIGINT PRIMARY KEY,
  phone_e164   VARCHAR(20) NOT NULL UNIQUE,
  status       TINYINT NOT NULL
);

CREATE TABLE mdm_merchant (
  merchant_id  BIGINT PRIMARY KEY,
  station_id   BIGINT NOT NULL,
  status       TINYINT NOT NULL
);

CREATE TABLE mdm_store (
  store_id     BIGINT PRIMARY KEY,
  merchant_id  BIGINT NOT NULL,
  station_id   BIGINT NOT NULL,
  status       TINYINT NOT NULL
);

对照验收:无中台时核销工具自建用户表;有中台时 gb_order.user_id 必须等于 mdm_user.user_id,导出联表禁止再靠手机号模糊匹配。

库存扣减与下架联动

套餐库存与上架状态属于主档,不属于某一种语言。下单扣减、退款回滚、下架拒绝新购,都应打在 gb_sku 上。

java 复制代码
@Transactional
public long placeOrder(PlaceCmd cmd) {
  GbSku sku = skuRepo.lock(cmd.getSkuId());
  if (sku.getStatus() != 1 || sku.getStockQty() <= 0) {
    throw new BizException("SKU_UNAVAILABLE");
  }
  sku.setStockQty(sku.getStockQty() - 1);
  skuRepo.update(sku);
  return orderRepo.insert(GbOrder.created(cmd, sku));
}

验收:status=0 后,zh-CN / en-US / th-TH 三个会话下单均失败;退款成功后库存回滚可查。禁止「泰语端下架、英语端仍可买」。

联调验收清单

  1. 同一 user_id 切换 zh-CN / en-US / th-THsku_id 不变,仅 gb_sku_i18n 变化。
  2. 支付回调将订单推至 READY_TO_REDEEM,短码写入 Redis 并落库;重放回调不产生第二码。
  3. 核销成功后,订单与券占用均为 REDEEMED,后台联表可导出。
  4. 商家将 gb_sku.status=0 后,交易接口拒绝新购(多语言会话一致)。
  5. 邻城 storeId 核销应失败(station scope)。
  6. 客户 VPC 内可执行日级备份与订单导出(私有化交付边界)。
  7. 模块关闭与越权读导出返回 403,菜单同步隐藏。
  8. 过期短码进入 EXPIRED,与 REFUNDED 可区分统计。

部署清单(海外版团购试水)

bash 复制代码
# 教学示意:客户 VPC 最小拉起
docker compose -f deploy/overseas-gb.yml up -d mysql redis
docker compose up -d gateway mid-user mid-merchant marketing groupbuy-trade groupbuy-redeem admin
curl -sS "$BASE/health" | jq .
curl -sS -H "Accept-Language: en-US" "$BASE/api/gb/trade/ping"

附件建议固定:

  1. values-sea.yaml(语言列表、fallback、核销回写开关)
  2. 网关路由与 ModuleEnabled=overseas_groupbuy
  3. 状态机表 + 非法迁移测例
  4. i18n 键/文案覆盖率(上架 SKU 三种 locale 齐全)
  5. 支付回调幂等与金额校验测例
  6. 「用户---订单---券---门店」联表导出样例
  7. 分站 403 与模块关闭 403 日志
  8. 备份恢复与配置回滚说明

低成本试水可以少开城,但不能少这八项;缺项等于数据不通风险未关闭。

联表导出与读模型:验收材料长什么样

总部周末对账不应再收截图。读模型建议最少包含用户、订单、占用、门店四段主键,语言字段只作展示辅助。

sql 复制代码
CREATE TABLE gb_redeem_export_row (
  order_no      VARCHAR(32) NOT NULL,
  user_id       BIGINT NOT NULL,
  sku_id        BIGINT NOT NULL,
  hold_id       BIGINT NOT NULL,
  redeem_code   VARCHAR(64) NOT NULL,
  status_code   VARCHAR(32) NOT NULL,
  store_id      BIGINT NULL,
  station_id    BIGINT NOT NULL,
  currency      CHAR(3) NOT NULL,
  amount_minor  BIGINT NOT NULL,
  paid_at       DATETIME NULL,
  redeemed_at   DATETIME NULL
);
text 复制代码
导出抽样断言(示意)
1) status_code=REDEEMED 时 store_id/redeemed_at 非空
2) 同一 order_no 在 zh/en/th 导出会话下主键列完全一致
3) EXPIRED 与 REFUNDED 分行计数,禁止合并成「关闭」

无中台拼装时,导出往往要跨三个库手工 join,且 user_id 对不上。有中台时,统一后台一次导出即可回答「谁买了、哪家店核了、券还剩什么状态」。这是多语言团购系统能否支撑低成本试水的硬标准。

过期任务与退款回滚(试水也要有)

短码过期与退款回滚是数据不通的高发点:门店以为还能核,总部以为已关闭。

java 复制代码
// 教学示意:日批过期
public int expireHolds(Instant now) {
  List<CouponHold> list = holdRepo.listReadyBefore(now);
  for (CouponHold h : list) {
    h.setStatus(HoldStatus.EXPIRED);
    orderRepo.transit(h.getOrderId(), "EXPIRED");
    holdRepo.update(h);
  }
  return list.size();
}

@Transactional
public void onRefunded(String orderNo) {
  GbOrder order = orderRepo.lockByNo(orderNo);
  transit(order, "REFUNDED");
  CouponHold hold = holdRepo.lockByOrderId(order.getOrderId());
  hold.setStatus(HoldStatus.REFUNDED);
  skuRepo.increaseStock(order.getSkuId(), 1);
}

验收:过期后核销失败;退款后库存回滚且短码失效;导出可分列统计。低成本试水可以不做复杂营销玩法,但不能缺这两条任务。

非法状态迁移也要有测例:REDEEMED -> READY_TO_REDEEM 必须拒绝;EXPIRED 订单不得再次核销;退款中的占用不得被门店端扫码成功。把这三条与网关 403 测例并列,扩城前才能说「主数据与状态机已冻结」。

营销占用与团购订单同域

海外版团购若再外接一套券平台,最常见后果是 coupon_id 对不上订单。中台营销占用应写入同一部署域可追溯字段。

text 复制代码
placeOrder -> marketing.lock(user, template, order_no)
onPaid     -> hold READY_TO_REDEEM
redeem     -> hold REDEEMED + order REDEEMED + marketing.consume
refund     -> marketing.release + stock++

验收:锁定、核销、释放三段流水能按 order_no 串起来;禁止团购库与营销库各记各的、靠手机号事后对。中台营销能力升级时,海外版团购按方案复用模板类型,无需每个国家 fork 引擎。

产品落法边界

光合同城海外版团购为成品系统,支持私有化源码独立部署。多语言、币种、支付通道按国家配置与定制;合规结论由客户侧确定。系统侧不抽成客户平台订单。中台一体化表现为统一后台与主数据互通,而不是把业务库默认放在不可控多租户集群。

适合东南亚华人小团队:首期只启团购模块与必要中台服务;语言先中英加当地一种;核销与导出先验通,再扩门店。专项定制(额外支付、字段、玩法)按书面范围推进,并评估是否触碰状态机与主数据边界。

为什么这样验收:多语言团购系统的失败模式很少是「少一种语言」,更多是主数据分裂与核销不回写。架构说明把模块树、占用表、网关拒绝、导出断言写进附件后,试水结论才可复制到下一城。语言可以下周再加,用户主键与核销状态机不能下周再合并。

两周试水联调日历(可写进附件)

第一周聚焦主数据与购买链:D1 拉起部署与语言列表;D2 注册同一 user_id 并切三种 locale 看 sku_id;D3 支付回调生成待核销占用;D4 导出未核销清单核对金额币种;D5 模块关闭与邻站 403。

第二周聚焦核销与对账:D6 到店核销回写;D7 下架联动三语会话;D8 过期任务;D9 退款回滚库存;D10 联表导出五行抽样与分站越权复测。

日历不是形式,而是把「数据不通」拆成每天可失败的断言。任一天失败就停扩城,避免用门店数量掩盖主数据分裂。

纯技术小结

多语言团购系统的架构重点,是把 i18n 留在文案表,把库存、券占用、核销状态留在唯一业务主档,并让核销服务写回同一订单状态机。海外版团购基于中台用户/商家/营销底座,东南亚低成本试水可以少开城,但验收必须包含「切语言 ID 不变 + 核销回写 + 联表导出」三项,并补齐网关拒绝、支付幂等、过期退款与部署清单;缺任一项,数据不通会在扩城前先爆。

相关推荐
殷紫川1 小时前
用 TRAE Work 半小时搭出一个电影级恐龙科普网站:从调研到部署的完整复盘
ai编程
ttwuai1 小时前
Go 后台图片上传到对象存储后,预览 403/404 怎么排查?
开发语言·golang
全栈弄潮儿1 小时前
一周总结:把 AI 当助手,而不是答案机器
aigc·openai·ai编程
达令哥1 小时前
告别 ARouter!基于 Google 官方 Navigation 3 + KSP 打造 Compose 时代的双轨制路由框架
android·前端
ly76892 小时前
Linux 从入门到实践:系统架构、常用命令、服务管理与故障排查详解
linux·运维·系统架构
绿浪19842 小时前
c# 结构体 能不能直接封送检测
开发语言·c#
Jesse_EC2 小时前
为什么我设置的 contentLength 到了消费端变成了 0?
java
vipxieliang2 小时前
ValidX 的 Date 和 DateTime vs JPA 的 Temporal 对比
java·后端