地级市团队做本地生活平台搭建 ,技术评审里常有一个分叉:业务模块可以分期上线,但订单与用户核心资料能不能共用一张表、一套写入口 ?若外卖、跑腿、同城团购各维护 order_main,运营就要在多个后台之间切换;同一用户在不同模块里是不同 user_id,营销和对账只能人工拼。
下文从表结构、状态机写路径、Service 分层说明「统一订单中枢 + 多业态扩展」怎么拆。示例为教学示意,以光合同城当期交付为准。

痛点:多后台根因往往是多写入口
早期拼装方案常见结构:
- 外卖模块自带
wm_order,跑腿模块自带errand_order,团购模块自带group_order - 各模块独立 Admin 控制台,运营按业态切换 URL
- 用户表按模块复制,靠手机号后匹配「假装是同一人」
后果很直观:改配送状态进 A 后台,财务导出在 B 后台;加营销券要在三个系统各配一遍。本地生活平台搭建 若目标是统一后台一体化,架构上应先收敛写路径,再谈 UI 有多少菜单。
地级市评审时可先问供应商三个问题:订单主表是否全业态共用?状态迁移是否只有一个 Service 写入口?Admin 是否按 biz_type 筛单而不是按模块分域名?答不上来,后面运营多半要在多个后台之间切换。
用户与商家核心资料:禁止按模块复制
多后台方案常伴随「每个模块一张 user 表」。正确做法是 mid-shared 层维护用户商家核心资料,各业态只引用 ID。
sql
CREATE TABLE um_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
mobile VARCHAR(20) NOT NULL UNIQUE,
nickname VARCHAR(64) NULL,
member_level TINYINT NOT NULL DEFAULT 0,
city_code VARCHAR(12) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE um_merchant (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(128) NOT NULL,
city_code VARCHAR(12) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'active|suspended|closed',
settle_mode VARCHAR(16) NOT NULL,
created_at DATETIME NOT NULL
);
-- 商家开通哪些业态,用关联表表达,而不是再建 merchant 副本
CREATE TABLE um_merchant_biz (
merchant_id BIGINT NOT NULL,
biz_type VARCHAR(16) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
PRIMARY KEY (merchant_id, biz_type)
);
本地生活平台搭建 验收时可固定一个测试手机号:在 takeout 下单后在 group_buy 再下一单,断言两笔单的 user_id 相同。若靠运营后台手工「绑定手机号」才算同一人,说明用户商家资料未打通,后续营销券无法跨业态复用。
模块目录:订单中枢与业态插件分离
text
local-life-platform/
├── apps/
│ ├── user-app/ # 用户端:下单、取消、评价
│ ├── merchant-app/ # 商家端:接单、拒单、出餐/核销
│ ├── rider-app/ # 配送端:取货、在途、送达
│ └── admin-console/ # 统一运营后台:跨业态筛单、导出
├── order-hub/ # 订单中枢(唯一写入口)
│ ├── command-api/
│ ├── state-machine/
│ ├── read-model/
│ └── event-outbox/
├── biz-plugins/
│ ├── takeout/ # 外卖扩展字段与履约策略
│ ├── errand/ # 跑腿扩展
│ └── group-buy/ # 同城团购扩展
├── mid-shared/
│ ├── user-master/ # 统一用户主数据
│ ├── merchant-master/
│ └── marketing-core/ # 券、满减、会员标签
└── ops/
├── state-transition.yaml
└── biz-type-routing.yaml
验收要点:takeout、errand 等插件禁止 直连数据库 UPDATE 主订单表;所有状态迁移经 order-hub/command-api。
统一订单表:主表 + 业态扩展表
主表存跨业态通用字段;业态差异放扩展表,避免为每个模块复制整张订单。
sql
-- 订单主表:全业态共用
CREATE TABLE ol_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL UNIQUE,
biz_type VARCHAR(16) NOT NULL COMMENT 'takeout|errand|group_buy|...',
user_id BIGINT NOT NULL,
merchant_id BIGINT NOT NULL,
city_code VARCHAR(12) NOT NULL,
status VARCHAR(32) NOT NULL,
state_version INT NOT NULL DEFAULT 0 COMMENT '乐观锁',
pay_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_user_biz (user_id, biz_type),
INDEX idx_merchant_status (merchant_id, status)
) COMMENT='本地生活统一订单主表';
-- 外卖扩展:仅 takeout 订单有行
CREATE TABLE ol_order_takeout_ext (
order_id BIGINT PRIMARY KEY,
delivery_type VARCHAR(16) NOT NULL COMMENT 'platform|rider|self',
expect_time DATETIME NULL,
rider_id BIGINT NULL,
CONSTRAINT fk_takeout_order FOREIGN KEY (order_id) REFERENCES ol_order(id)
);
-- 跑腿扩展
CREATE TABLE ol_order_errand_ext (
order_id BIGINT PRIMARY KEY,
pickup_addr VARCHAR(256) NOT NULL,
dropoff_addr VARCHAR(256) NOT NULL,
goods_weight_kg DECIMAL(6,2) NULL,
CONSTRAINT fk_errand_order FOREIGN KEY (order_id) REFERENCES ol_order(id)
);
-- 状态变更审计:只追加
CREATE TABLE ol_order_state_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
from_status VARCHAR(32) NOT NULL,
to_status VARCHAR(32) NOT NULL,
actor_type VARCHAR(16) NOT NULL COMMENT 'user|merchant|rider|admin|system',
actor_id BIGINT NOT NULL,
command VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_order_time (order_id, created_at)
);
设计约束:
user_id、merchant_id引用mid-shared主数据,禁止各模块自建用户副本。biz_type决定加载哪张扩展表;Admin 控制台按biz_type筛单,但 URL 仍是同一套。- 所有
status变更只经state-machine,并写ol_order_state_log。
状态机:按履约语义而非按端命名
外卖与跑腿可共用上层状态,差异在扩展字段与合法迁移分支:
yaml
# ops/state-transition.yaml(示意)
common:
- from: CREATED
to: PAID
actor: system
command: pay_success
- from: PAID
to: MERCHANT_ACCEPTED
actor: merchant
command: accept_order
- from: MERCHANT_ACCEPTED
to: COMPLETED
actor: system
command: fulfill_done
takeout_only:
- from: MERCHANT_ACCEPTED
to: PREPARING
actor: merchant
command: start_preparing
- from: PREPARING
to: RIDER_DELIVERING
actor: rider
command: start_delivery
errand_only:
- from: MERCHANT_ACCEPTED
to: RIDER_PICKUP
actor: rider
command: confirm_pickup
- from: RIDER_PICKUP
to: RIDER_DELIVERING
actor: rider
command: start_delivery
启动时校验:每个 biz_type 的迁移图无孤儿状态;非法迁移在 command-api 层直接拒绝。
Service 伪代码:唯一写入口
java
@Service
public class OrderCommandService {
public OrderView acceptOrder(AcceptOrderCmd cmd, Operator op) {
Order order = orderRepo.findByIdForUpdate(cmd.getOrderId());
stateMachine.assertTransition(order, "MERCHANT_ACCEPTED", op);
order.setStatus("MERCHANT_ACCEPTED");
order.setStateVersion(order.getStateVersion() + 1);
orderRepo.save(order);
stateLogRepo.append(order.getId(), cmd.getFromStatus(),
"MERCHANT_ACCEPTED", op, "accept_order");
outbox.publish(new OrderStatusChangedEvent(order.getId(),
order.getBizType(), order.getStatus()));
return readModel.toView(order);
}
}
读侧 可按端裁剪字段(用户端不返回内部结算字段),但 order_id 与 state_version 必须一致。各端缓存通过 event-outbox 失效,避免「骑手端已送达、用户端还在备餐中」。
Outbox 与读模型:多端同步不靠口头对齐
写路径收敛后,读侧仍可能缓存订单快照。推荐 Outbox 模式:状态变更与事件写入同一事务,异步投递到各端缓存失效队列。
java
@Service
public class OrderOutboxPublisher {
@Transactional
public void publishAfterCommit(Long orderId, String bizType, String status) {
outboxRepo.insert(new OutboxRow(orderId, bizType, status, "OrderStatusChanged"));
// 定时任务或 MQ 消费者拉取 pending 行,广播到 user-app / merchant-app / rider-app
}
}
读模型 read-model 可按端做 VO 裁剪,但查询必须带 state_version。客户端收到事件后,若本地版本落后则拉全量;若超前则丢弃,防止乱序。
地级市团队技术评审时可看一条链路:商家在 merchant-app 点「开始备餐」→ command-api 写主表 → state_log 追加 → Outbox 投递 → 用户端列表刷新。任一步绕过 command-api 直连 UPDATE,都应视为架构缺陷。
Admin 控制台:一套入口筛多业态
统一后台的实现要点:
java
@RestController
@RequestMapping("/admin/orders")
public class AdminOrderController {
@GetMapping
public Page<OrderSummaryVO> list(
@RequestParam(required = false) String bizType,
@RequestParam(required = false) String status,
@RequestParam(required = false) Long merchantId,
Pageable page) {
// 同一接口,按 bizType 过滤;不再为每个模块单独部署 Admin
return orderQueryService.pageUnified(bizType, status, merchantId, page);
}
}
运营导出 CSV 时,列头来自主表 + 当前 biz_type 对应扩展表,格式统一,减少跨系统拼表。
与中台能力共享的衔接
mid-shared/marketing-core 发券时,只认 user_id 与 biz_type 白名单,不关心订单在哪个子库:
sql
CREATE TABLE mk_coupon_grant (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
template_id BIGINT NOT NULL,
biz_scope VARCHAR(64) NOT NULL COMMENT 'takeout,errand 或 takeout|group_buy',
valid_from DATETIME NOT NULL,
valid_to DATETIME NOT NULL
);
中台新增一种券模板,已接入 order-hub 的业态只要 biz_scope 匹配即可生效------这就是能力共享 在代码层的落点,而不是每个业务系统各自维护一套 coupon 表。
无中台 vs 有中台:技术对照
无统一订单中枢时,常见实现是三个独立 Spring Boot 工程、三套 Admin 域名、导出 CSV 列头各不同。运营改一条满减,要在三个后台各配;财务周会前人工 VLOOKUP 拼表。
有统一后台一体化实现时,只有一个 order-hub 写入口、一个 Admin 控制台;biz_type 只是筛选项和扩展表路由。加跑腿模块时,新增 ol_order_errand_ext 与 YAML 迁移分支即可,不 新建第二套 Admin,也不重导 um_user。
这就是本地生活平台搭建 里「统一后台、数据互通、能力共享」在工程上的落点:读者不必被「中台」二字吓到,核心就是一张主订单表、一套写 Service、一个运营入口。
评审清单:六项可跑通的检查
- 同一
user_id跨takeout与group_buy各下一单,主表ol_order.user_id一致。 - 非法状态迁移(如从
CREATED直跳DELIVERED)被stateMachine拒绝并写审计。 - 商家改价只经
command-api,数据库层无各端直连 UPDATE 权限。 - Admin 导出 CSV 列头统一,含
biz_type与state_version。 - 在
marketing-core改券模板,两业态下单页均可用,且未开通业态不出现券入口。 - 关闭
errand路由后,代码仍在库但 Admin 筛选项不出现,证明「加模块 ≠ 加后台」。
部署与分期上线建议
地级市本地生活平台搭建首期建议:
- 只启用
takeout+group_buy两个biz_type,其余插件代码在库但路由关闭。 - 用同一
user_id完成跨业态下单,断言ol_order.user_id一致。 - 在 Admin 改一条
mk_coupon_grant,验证两业态下单页均可用。 - 第三期再加
errand:仅新增ol_order_errand_ext与迁移分支,不新建第二套 Admin。
光合同城走统一后台一体化成品交付:内置多业务模块,模块数据互通、后台统一管理。上文表结构与状态机为拆解示意;实际字段、插件清单以当期方案为准。支持私有化源码部署与按需定制开发。
适合谁
适合国内地级市技术负责人或本地服务商:要在本地生活平台搭建评审里验「统一订单表 + 单一写入口 + 统一 Admin」,而不是接受「每个业态一套后台、靠手机号后匹配用户」的拼装方案。
不适合只买静态页面、不涉及真实订单写路径的项目------那种需求用静态站即可,但试运营一上量,多写入口的技术债会迅速放大运营成本。
本地生活平台搭建 的技术分水岭,往往不在功能菜单长度,而在订单与用户有没有先收敛到一套中枢。先把 order-hub 与统一 Admin 立住,再按 biz_type 叠插件,比每个模块各起炉灶,后期扩业态时省下的不止是开发人天,还有运营每天少切的那几次后台。