写在前面:本地生活平台的复杂度不来自功能数量,来自它是三个维度的叉乘。本文给出一套可落地的建模方法:城市作为租户的数据隔离方案、品类作为业务开关的配置化、三种履约形态的订单模型拆分,以及三套资金账与三条合规线。配套提供城市配置表建表 SQL、密度测算脚本、履约范围判定脚本与季度涉税报送聚合 SQL。适合平台型项目的产品与技术负责人。
一、三维网格:先定分叉,再谈功能
多数本地生活项目的问题不在功能,而在架构假设错了------把「一个 APP 覆盖全国」当前提,所有城市数据挤一张表,用 city_id 区分。
三个维度必须在第一版就定义清楚:
| 维度 | 本质 | 定错后的返工代价 |
|---|---|---|
| 城市 | 数据隔离的最小单元(租户) | 价格、活动、结算无法独立;统计需全表扫 |
| 品类 | 业务模型开关(决定履约/资质/表单) | 每加一个品类要改核心表 |
| 履约形态 | 订单模型的类型分叉 | 塞一张表后拆表,接近重做 |
城市作为租户的设计要点:
- 配置隔离:城市的营业规则、结算周期、抽佣比例、活动全部按
city_id存储,总部只保留模板。 - 数据隔离:订单、商家、商品按城市分片(逻辑分片即可,
city_id作为分区键),避免跨城联表。 - 权限隔离:城市运营只能操作本城数据。
- 复制方式:扩城 = 复制一份配置模板 + 初始化基础数据,不是在同一池子里插数据。
品类作为开关 的落地方式:把品类抽象成「能力组合」,而不是 category_id 字段。
yaml
# category_profile.yaml ------ 品类能力配置
retail_food:
name: 餐饮外卖
fulfillment: delivery # 履约形态:delivery / pickup / onsite
license_required: [食品经营许可证]
sku_model: dish_with_spec # 菜品带规格(辣度/份量/加料)
order_extra: [餐具份数, 备注]
inventory: false # 不按库存扣减,按可售状态
beauty_salon:
name: 美业到店
fulfillment: onsite
license_required: [营业执照, 卫生许可证]
sku_model: service_with_staff # 项目绑定技师
order_extra: [指定技师, 到店时间]
inventory: slot # 按技师时段占用
home_service:
name: 家政上门
fulfillment: onsite
license_required: [营业执照]
sku_model: service_with_duration
order_extra: [服务地址, 房屋面积]
inventory: slot
三个品类对应三套 sku_model 与 inventory 策略------这是「品类不是分类,是开关」的具体含义。
二、三种履约形态的订单模型必须拆开
这是本地生活架构上第一个必须定死的分叉:
| 形态 | 售卖对象 | 核心链路 | 关键表 |
|---|---|---|---|
| 到店核销 | 券 / 时段 | 购券 → 到店 → 核销 | 券实例表 + 核销记录表 |
| 到家配送 | 时效 | 下单 → 派单 → 配送 → 签收 | 配送单表 + 骑手轨迹 |
| 上门服务 | 人 + 时段 | 下单 → 派单 → 服务 → 验收 | 排班表 + 时段占用表 |
把三者塞进一张 order 表的典型后果:
- 做「超时赔付」时发现没有配送时间字段,只能加字段并回填历史数据;
- 做「核销防重」时发现没有核销记录,无法判断券是否已被使用;
- 做「技师排班冲突」时发现没有时段占用表,只能靠订单表反推,性能与正确性都不可接受。
正确的结构是主订单表 + 形态子表 :order 存公共字段(用户、金额、状态机主链),order_voucher / order_delivery / order_onsite 各存形态特有字段。查询时按形态 join,写入时按形态分别落表。
sql
-- 主订单表:只放跨形态公共字段
CREATE TABLE biz_order (
id BIGSERIAL PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE,
city_id BIGINT NOT NULL, -- 租户键
merchant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
biz_type SMALLINT NOT NULL, -- 1 到店 2 到家 3 上门
total_amount NUMERIC(10,2) NOT NULL,
state SMALLINT NOT NULL, -- 公共状态机主链
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_order_city_state ON biz_order (city_id, state, created_at DESC);
-- 到店:券实例 + 核销记录(核销幂等靠唯一键)
CREATE TABLE order_voucher (
order_id BIGINT PRIMARY KEY REFERENCES biz_order(id),
voucher_code VARCHAR(32) NOT NULL UNIQUE,
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
status SMALLINT DEFAULT 0 -- 0未用 1已核销 2已过期 3已退
);
CREATE TABLE voucher_redeem (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES biz_order(id),
store_id BIGINT NOT NULL,
operator VARCHAR(64) NOT NULL, -- 核销操作人
redeem_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (order_id) -- 一券只能核销一次
);
-- 到家:配送单(一单可拆多段)
CREATE TABLE order_delivery (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES biz_order(id),
rider_id BIGINT,
dispatch_at TIMESTAMPTZ,
pickup_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
distance_m INT,
timeout_flag BOOLEAN DEFAULT FALSE -- 超时标记供赔付计算
);
-- 上门:时段占用(人员 × 日期 × 起始分钟 唯一)
CREATE TABLE order_onsite (
order_id BIGINT PRIMARY KEY REFERENCES biz_order(id),
staff_id BIGINT NOT NULL,
serve_date DATE NOT NULL,
start_min SMALLINT NOT NULL,
end_min SMALLINT NOT NULL,
UNIQUE (staff_id, serve_date, start_min) -- 防同时段重复派单
);
三、供给密度:扩城的判定公式
本地生活最反直觉的一条:决定成败的是供给密度,不是功能完整度。
粗糙但有效的模型:
单城日承载量 = 可服务商家数 × 单店日均接单能力
单城日需求 = 日活用户 × 下单转化率 × 人均频次
健康度 = 承载量 / 需求(建议留 1.2 倍以上余量)
三个过程指标比结果更早预警:
| 指标 | 计算 | 告警含义 |
|---|---|---|
| 履约半径内商家数 | 3km 内可下单商家计数 | 低于阈值则体验必然差 |
| 平均接单时长 | 下单 → 商家接单 中位数 | 上涨早于用户流失 |
| 单城订单密度 | 单城日单量 / 商家数 | 过低则商家陆续退出 |
python
# city_health.py ------ 单城健康度测算
# 用法: python city_health.py city_config.json metrics.json
import json, sys
def health(cfg: dict, m: dict) -> dict:
capacity = m["active_merchants"] * cfg["avg_orders_per_merchant"]
demand = m["dau"] * cfg["order_cvr"] * cfg["freq_per_user"]
ratio = capacity / demand if demand else float("inf")
radius_ok = m["merchants_in_3km_median"] >= cfg["min_merchants_3km"]
return {
"capacity": round(capacity),
"demand": round(demand, 1),
"ratio": round(ratio, 2),
"radius_ok": radius_ok,
"verdict": "可扩城" if ratio >= 1.2 and radius_ok else "先补供给",
}
if __name__ == "__main__":
cfg = json.load(open(sys.argv[1], encoding="utf-8"))
m = json.load(open(sys.argv[2], encoding="utf-8"))
print(json.dumps(health(cfg, m), ensure_ascii=False, indent=2))
阈值放 city_config.json,按城市覆盖:avg_orders_per_merchant(单店日均单量,餐饮 25---35)、order_cvr(0.1---0.15)、freq_per_user(人均月频次 2---3)、min_merchants_3km(60 起)。四个值随城市等级调整,一线与三线不应共用。
扩城判据不是「功能做完了」,而是「第一个城市 ratio 与 radius_ok 同时成立并稳定一到两个季度」。缺这条线,就会在亏损城市继续加投。
四、Geo 分层与地图合规
4.1 三层地理模型
| 层级 | 用途 | 存储 |
|---|---|---|
| 城市 | 租户键、结算单位 | 独立表 |
| 商圈/网格 | 运营投放、骑手调度 | 网格编码 |
| POI | 商家地址、用户地址 | 经纬度 + 结构化地址 |
配送范围用多边形围栏而非半径圆:直线距离一公里在实际地形里可能绕行三公里(河、高架、封闭小区)。围栏数据可由图商等时圈能力生成,或运营手动绘制。
python
# geo_check.py ------ 点是否在配送围栏内(射线法,轻量场景够用)
def in_polygon(lng: float, lat: float, poly: list) -> bool:
"""poly: [(lng, lat), ...] 闭合或非闭合均可"""
inside = False
n = len(poly)
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > lat) != (yj > lat):
x_cross = (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi
if lng < x_cross:
inside = not inside
j = i
return inside
def check_order(addr: dict, merchant: dict) -> dict:
if addr["city_id"] != merchant["city_id"]:
return {"ok": False, "reason": "跨城订单,下单前拦截"}
if not in_polygon(addr["lng"], addr["lat"], merchant["fence"]):
return {"ok": False, "reason": "超出配送范围"}
return {"ok": True, "reason": ""}
要点:跨城订单在提交前拦截,别等支付完成再退------退一单成本远高于拦一单。
4.2 地图合规三条硬线
| 要求 | 依据 | 产品动作 |
|---|---|---|
| 互联网地图服务需测绘资质 | 《地图管理条例》第三十三条(国务院令第 664 号) | 使用有资质图商服务,不自采 POI 对外展示 |
| 地图须经审核并标注审图号,有效期 2 年 | 同上,第三十八条及地图审核相关规定 | 页面左下角标注审图号,到期重新送审 |
| 地图数据服务器须在境内 | 同上,第三十四条 | 境内部署,跨境业务单独评估 |
另需注意:每六个月须将新增标注内容备案;地图安全审校人员需经省级以上培训考核。擅自使用未经审核批准的地图提供服务,罚款上限二十万,情节严重可停业整顿、降级或吊销资质。
五、三套账与资金合规
平台资金三个流向,须分账记录:
| 账户类型 | 内容 | 结算对象 |
|---|---|---|
| 平台佣金 | 交易抽成 | 平台 |
| 商家结算 | 扣佣后货款 | 平台内经营者 |
| 从业者劳务 | 骑手/技师/服务人员报酬 | 平台内从业人员 |
分账的三个理由:对账(三方各一份账)、资金合规(平台留存商户结算资金自行清分属「二清」,须走持牌机构分账)、税务(见下节)。
六、涉税信息报送:2025 年后的新义务
《互联网平台企业涉税信息报送规定》(国务院令第 810 号,2025 年 6 月 20 日公布并施行)要点:
| 条款 | 要求 |
|---|---|
| 第三条 | 施行之日起 30 日内(或开展业务起 30 日内)报送平台域名、业务类型、运营主体统一社会信用代码与名称 |
| 第四条 | 季度终了的次月内,报送平台内经营者与从业人员的身份信息及上季度收入信息 |
| 第四条(豁免) | 从事配送、运输、家政等便民劳务活动,依法享受税收优惠或不需要纳税的从业人员,不报送其收入信息 |
| 第六条 | 平台对报送信息的真实性、准确性、完整性负责;已尽核验义务且因对方过错导致不实的,不追究平台责任 |
| 第十条 | 未按期报送或瞒报漏报:责令改正,逾期罚 2---10 万;情节严重责令停业整顿并罚 10---50 万 |
系统侧要提前准备的结构化能力:
- 商家身份档案:统一社会信用代码、经营者名称、联系方式、行政许可,落库为结构字段且可导出(同时满足《电子商务法》第二十七条的核验登记与定期更新义务)。
- 季度收入聚合:按商家 × 季度聚合流水,字段口径与报送模板对齐。
- 从业人员分类标记:区分商家经营者与自然人从业人员,并对配送/家政类打上豁免标记。
sql
-- 季度涉税报送聚合(示例口径:按商家聚合已结算流水)
SELECT
m.unified_code AS 统一社会信用代码,
m.merchant_name AS 经营者名称,
date_trunc('quarter', o.created_at) AS 所属季度,
count(*) AS 订单数,
sum(o.total_amount) AS 交易总额,
sum(o.commission_amount) AS 平台佣金
FROM biz_order o
JOIN merchant m ON m.id = o.merchant_id
WHERE o.state = 4 -- 已完成
AND o.created_at >= date_trunc('quarter', CURRENT_DATE - INTERVAL '3 month')
AND o.created_at < date_trunc('quarter', CURRENT_DATE)
GROUP BY 1, 2, 3
ORDER BY 5 DESC;
七、功能优先级分层
| 层级 | 内容 | 判断标准 |
|---|---|---|
| 第一层 | 商家入驻与资质审核、商品上架、下单支付、订单状态、核销/配送/派单、退款 | 不做无法开门 |
| 第二层 | 商家端接单、骑手端/服务人员端、地址管理、评价售后、结算对账、消息 | 决定体验下限 |
| 第三层 | 券与活动、会员储值、分享分销、数据看板、内容 | 决定增长 |
| 第四层 | 智能调度、推荐排序、跨品类营销、供应链 | 做深了才有意义 |
常见错误是把第四层写进第一版,而第一层的资质审核与售后尚未扎实。第一层不扎实,第二层以上全部空转。
八、需求评审易漏八项
| 序号 | 易漏项 | 后期代价 |
|---|---|---|
| 1 | 城市级价格与活动是否独立 | 全局配置改一次影响所有城 |
| 2 | 券的核销幂等与跨店限制 | 重复核销无法追溯 |
| 3 | 配送超时的判定口径与赔付 | 无配送时间字段则无法计算 |
| 4 | 上门服务的时段冲突 | 无占用唯一键则重复派单 |
| 5 | 跨城订单拦截节点 | 支付后才发现,退款成本高 |
| 6 | 商家资质的有效期与到期提醒 | 证照过期仍在营业 |
| 7 | 从业人员的劳务结算与代扣 | 补做需重构结算链路 |
| 8 | 涉税报送字段的可导出性 | 报送时现拼,易漏易错 |
九、小结
本地生活平台的架构核心是三条:城市即租户 (配置与数据隔离)、品类即开关 (能力组合而非分类字段)、形态即子表(主订单 + 形态子表)。运营核心是一条:先算密度,再谈扩城。合规核心是三条线:平台核验与连带责任(《电子商务法》第二十七条、第三十八条)、涉税信息按季报送(国务院令第 810 号)、地图使用有资质图商(《地图管理条例》第三十三条)。