东南亚华人团队选型「多语言团购系统」时,技术评审常卡在数据不通: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-Locale、X-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_hold 与 gb_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");
}
}
拒绝测例(建议原样进附件):
- 模块关闭后
/api/gb/**→403,统一后台菜单同步隐藏 - 分站账号导出邻城核销明细 →
403 READY_TO_REDEEM之外状态核销 → 业务错误,门店端不得本地记「已核」- 短码过期后核销 →
EXPIRED,库存/报表可区分于退款 - 无 Token 访问核销接口 →
401 - 不支持的 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 三个会话下单均失败;退款成功后库存回滚可查。禁止「泰语端下架、英语端仍可买」。
联调验收清单
- 同一
user_id切换zh-CN/en-US/th-TH,sku_id不变,仅gb_sku_i18n变化。 - 支付回调将订单推至
READY_TO_REDEEM,短码写入 Redis 并落库;重放回调不产生第二码。 - 核销成功后,订单与券占用均为
REDEEMED,后台联表可导出。 - 商家将
gb_sku.status=0后,交易接口拒绝新购(多语言会话一致)。 - 邻城
storeId核销应失败(station scope)。 - 客户 VPC 内可执行日级备份与订单导出(私有化交付边界)。
- 模块关闭与越权读导出返回
403,菜单同步隐藏。 - 过期短码进入
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"
附件建议固定:
values-sea.yaml(语言列表、fallback、核销回写开关)- 网关路由与
ModuleEnabled=overseas_groupbuy - 状态机表 + 非法迁移测例
- i18n 键/文案覆盖率(上架 SKU 三种 locale 齐全)
- 支付回调幂等与金额校验测例
- 「用户---订单---券---门店」联表导出样例
- 分站 403 与模块关闭 403 日志
- 备份恢复与配置回滚说明
低成本试水可以少开城,但不能少这八项;缺项等于数据不通风险未关闭。
联表导出与读模型:验收材料长什么样
总部周末对账不应再收截图。读模型建议最少包含用户、订单、占用、门店四段主键,语言字段只作展示辅助。
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 不变 + 核销回写 + 联表导出」三项,并补齐网关拒绝、支付幂等、过期退款与部署清单;缺任一项,数据不通会在扩城前先爆。