同城外卖系统能不能自己掌握数据,先看交付方式:标准 SaaS 通常只能按账号权限导出业务数据,私有化部署能让你管理自有服务器与数据库,源码交付再放开二次修改空间;自研控制力最强,但成本和运维压力也最高。但“掌握”真正可验证的标准只有一条:导出的数据与源端口径能对上,迁移之后商家余额、骑手收益和平台抽成不失真。本文用一个迁移对账故障说明,订单按时间导出、结算按流水号导出时,两个水位不一致会让商家余额少算,以及如何用快照水位和对账视图把它拦住。
一、三种交付形态,各控制什么
先把交付形态分清,再谈数据在谁手里。判断依据不是后台有没有导出按钮,而是数据库权限、迁移能力和运维责任分别落在谁身上。
| 交付形态 | 数据控制对象 | 数据库与备份 | 迁移与二次开发 | 运维责任 |
|---|---|---|---|---|
| 标准 SaaS | 按角色权限导出业务数据副本 | 由服务方托管,导出字段与频率受版本和子账号权限约束 | 按接口或导出文件迁移,二次开发空间有限 | 服务方承担约定技术维护 |
| 私有化部署 | 系统运行在自有服务器,数据库和文件由运营方管理 | 运营方安排备份、恢复与监控 | 可按部署文档迁移,代码修改范围以授权为准 | 服务器安全、备份与故障恢复转到运营方或受托团队 |
| 源码交付 | 在私有化基础上获得源代码 | 同上,并可按需改造数据结构 | 可二次修改与自建对接 | 同上,另需承担版本升级与定制部分维护 |
表格里最容易被忽略的是第二列和第三列的差别:能导出报表,说明运营方拿到了数据副本;能管理数据库,才说明运营方掌握了数据的存储与恢复能力。二者之间隔着迁移与对账这道工程工序。
二、把“掌握”拆成四个可验证条件
只看合同里的“数据归甲方”不够,要落到可执行的技术条件。建议在项目方案里写清四项:可导出、可核对、可迁移、可签收。
- 可导出:明确字段清单、文件格式、时间范围、导出频率和操作权限,敏感列可按岗位隐藏。
- 可核对:以结算流水为对账主账,订单只作业务明细,两侧金额必须能逐笔追溯到订单号与流水号。
- 可迁移:导出边界用水位而不是时间,避免切换瞬间的在途订单被漏掉。
- 可签收:迁移批次有状态流转,每一跳有校验结果和双方确认记录。
迁移批次可以按下面的状态推进,任何一步校验不通过就回退,原库保持可读。
| 状态 | 进入条件 | 允许的下一步 |
|---|---|---|
| PREPARING | 登记导出字段清单、水位口径和在途订单处理策略 | FROZEN |
| FROZEN | 暂停写入结算流水,读取当前最大流水号作为水位 | EXPORTED |
| EXPORTED | 导出流水与关联订单,记录文件摘要与行数 | VERIFIED |
| VERIFIED | 源端与目标端逐商家比对可用余额,差异为 0 | CUTOVER |
| CUTOVER | 切换流量,冻结期在途订单按既定策略处理完毕 | SIGNED |
| SIGNED | 双方签收数据清单与差异报告 | 结束 |
| ROLLBACK | 任一校验失败,保留原库并恢复写入 | 重新 FROZEN |
三、数据模型与字段约束
模型只需四张核心表:订单记业务事实,结算流水记资金事实,提现单记资金流出,迁移批次记导出边界。金额统一用定点小数,状态用约束枚举,避免脏数据进入对账口径。
CREATE TABLE orders (
order_id text PRIMARY KEY,
merchant_id text NOT NULL,
rider_id text,
status text NOT NULL CHECK (status IN ('CREATED','PAID','ACCEPTED','DELIVERED','COMPLETED','CANCELLED','REFUNDED')),
pay_amount numeric(12,2) NOT NULL CHECK (pay_amount >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
CONSTRAINT completed_requires_time CHECK (status <> 'COMPLETED' OR completed_at IS NOT NULL)
);
CREATE TABLE settlement_ledger (
ledger_id bigserial PRIMARY KEY,
order_id text NOT NULL REFERENCES orders(order_id),
merchant_id text NOT NULL,
entry_type text NOT NULL CHECK (entry_type IN ('SALE','REFUND','ADJUST')),
amount numeric(12,2) NOT NULL,
idempotency_key text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT refund_must_be_negative CHECK (entry_type <> 'REFUND' OR amount < 0)
);
CREATE TABLE withdrawal (
withdrawal_id bigserial PRIMARY KEY,
merchant_id text NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount > 0),
status text NOT NULL CHECK (status IN ('PENDING','PAID','REJECTED')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE migration_batch (
batch_id text PRIMARY KEY,
ledger_watermark bigint NOT NULL CHECK (ledger_watermark >= 0),
cutover_at timestamptz NOT NULL,
in_flight_policy text NOT NULL CHECK (in_flight_policy IN ('HOLD','CARRY','REFUND')),
state text NOT NULL CHECK (state IN ('PREPARING','FROZEN','EXPORTED','VERIFIED','CUTOVER','SIGNED','ROLLBACK')),
created_at timestamptz NOT NULL DEFAULT now()
);
三个约束值得单独说:idempotency_key 唯一,保证同一笔订单的销售流水只落一条;refund_must_be_negative 让退款以负向流水冲回,不需要修改原销售记录;ledger_watermark 是导出边界的唯一依据,后续对账全部围绕它展开。
四、核心实现:按流水水位导出并对账
关键动作只有一个:冻结写入后取当前最大流水号,之后所有导出都以这个水位为界。订单不按自身时间导出,而是取“水位内出现过的订单”,这样订单与流水永远成套。
-- 1. 冻结时读取水位(在 FROZEN 状态执行)
SELECT COALESCE(MAX(ledger_id), 0) AS watermark FROM settlement_ledger;
-- 2. 按水位导出结算主账
SELECT ledger_id, order_id, merchant_id, entry_type, amount, idempotency_key, created_at
FROM settlement_ledger
WHERE ledger_id <= 1200
ORDER BY ledger_id;
-- 3. 导出这些流水对应的订单,而不是按订单创建时间导出
SELECT o.order_id, o.merchant_id, o.status, o.pay_amount, o.completed_at
FROM orders o
WHERE EXISTS (
SELECT 1 FROM settlement_ledger l
WHERE l.order_id = o.order_id AND l.ledger_id <= 1200
);
对账侧用一个视图统一口径:可用余额等于流水合计减去待处理与已支付的提现合计。源端和目标端都跑同一段 SQL,差异为 0 才允许进入 CUTOVER。
CREATE OR REPLACE VIEW merchant_balance AS
SELECT m.merchant_id,
COALESCE(l.ledger_sum, 0) AS ledger_sum,
COALESCE(w.held_sum, 0) AS held_sum,
COALESCE(l.ledger_sum, 0) - COALESCE(w.held_sum, 0) AS available
FROM (SELECT DISTINCT merchant_id FROM settlement_ledger) m
LEFT JOIN (
SELECT merchant_id, SUM(amount) AS ledger_sum
FROM settlement_ledger GROUP BY merchant_id
) l USING (merchant_id)
LEFT JOIN (
SELECT merchant_id, SUM(amount) AS held_sum
FROM withdrawal WHERE status IN ('PENDING','PAID') GROUP BY merchant_id
) w USING (merchant_id);
-- 源端与目标端各执行一次,比对差异
SELECT s.merchant_id, s.available AS source_available,
t.available AS target_available, s.available - t.available AS diff
FROM merchant_balance s
JOIN target_merchant_balance t USING (merchant_id)
WHERE s.available <> t.available;
五、正常输入输出与一个异常反例
先看正常路径。商家 M1 的一笔订单完成,结算流水 90.00 元,提现申请 30.00 元仍处待处理,可用余额应为 60.00 元。
INSERT INTO orders(order_id, merchant_id, status, pay_amount, completed_at)
VALUES ('O1001','M1','COMPLETED',100.00,'2025-05-31 20:10:00+08');
INSERT INTO settlement_ledger(order_id, merchant_id, entry_type, amount, idempotency_key)
VALUES ('O1001','M1','SALE',90.00,'O1001:SALE');
INSERT INTO withdrawal(merchant_id, amount, status) VALUES ('M1',30.00,'PENDING');
SELECT merchant_id, ledger_sum, held_sum, available FROM merchant_balance;
M1 | 90.00 | 30.00 | 60.00
再看异常。订单 O2001 在 5 月 31 日 23:58 创建,6 月 1 日 00:03 完成并写入结算流水,流水号 1201。如果导出时按订单创建时间切边界(早于 6 月 1 日 0 点),订单 O2001 会被导出;但流水导出条件是流水号不大于 1200,1201 被排除在外。结果目标库只有 O1001 的 90.00 元,而源库已累计 180.00 元,商家余额少算 90.00 元。
INSERT INTO orders(order_id, merchant_id, status, pay_amount, created_at, completed_at)
VALUES ('O2001','M1','COMPLETED',100.00,'2025-05-31 23:58:00+08','2025-06-01 00:03:00+08');
INSERT INTO settlement_ledger(order_id, merchant_id, entry_type, amount, idempotency_key)
VALUES ('O2001','M1','SALE',90.00,'O2001:SALE');
-- 错误做法:按订单时间切边界,得到 2 笔订单但只有 1 条流水
SELECT o.order_id FROM orders o WHERE o.created_at < '2025-06-01 00:00:00+08';
O1001
O2001
目标库余额: M1 | 90.00
源库余额: M1 | 180.00(提现前)
差异: 90.00
修复方式是把水位取到 1201,或者在冻结窗口内等 O2001 的流水落库后再取水位;如果确实需要在流水落库前切换,就把这类订单标记为在途,按 in_flight_policy 选择 HOLD(源端处理完再补导)、CARRY(目标端按订单补记流水)或 REFUND(退款关闭)。修复后两端可用余额一致,差异查询返回 0 行。
M1 | 180.00 | 30.00 | 150.00
差异查询: 0 rows
这个反例说明的是流程问题而非数据库问题:订单时间和流水时间本来就不同步,用订单时间做导出边界,等于默认两者同时发生。真实系统里补单、异步结算、售后退款都会打破这个假设。
六、业务验收与选型核对
把上面的模型翻译成采购语言,就是签约前要逐项确认的清单。
- 导出字段:用户、商家、商品、订单、配送、账单、退款、附件分别有哪些字段,能否按角色隐藏敏感列。
- 对账口径:以订单还是流水为主账,退款与调整如何冲回,余额由哪几张表计算得出。
- 迁移边界:导出按时间还是按自增水位,切换窗口内完成但未结算的订单如何处理。
- 备份与恢复:备份频率、保留周期、恢复演练方式,以及切换失败后的回退方案。
- 交接签收:账号、域名、支付商户、云资源、后台角色逐项列明管理员与费用承担,迁移完成后双方签收差异报告。
- 个人信息:导出文件中的手机号、地址等字段如何授权、留存与销毁,谁能导出要写进权限矩阵。
这六项里,第二项和第三项是区分“能导出”和“能接手”的关键。前者决定数据副本是否完整,后者决定迁移当天会不会出现对不平的账。选型演示时,建议直接要求对方用一笔跨零点的订单走一遍导出与对账,看水位是按订单还是按流水取的。
常见问题
Q1:后台能导出订单表,是不是就等于掌握数据了?
不等于。导出是数据副本,掌握数据库意味着能控制存储、备份与恢复。判断标准是导出后能否与源端口径对平,以及终止合作时能否拿到完整备份。
Q2:私有化部署一定给源码吗?
不一定。部署授权和源码交付是两种形态,前者只把系统装到自有服务器,后者才提供源代码与二次修改空间,签约时要写清属于哪一种。
Q3:迁移为什么不能按订单创建时间切?
订单完成与结算流水写入之间存在时间差,按订单时间切会把“订单已导出、流水未导出”的订单漏掉,直接表现为商家余额少算。按自增流水号取水位可以避开这个问题。
Q4:对账差异不为 0 时怎么办?
停在 VERIFIED 状态不切换,先输出差异明细,逐笔追溯订单号、流水号和提现单号;确认是在途订单就按策略补处理,确认是重复流水就检查幂等键,处理完重新比对。