县域服务商选同城履约系统,先按订单形态分路线:外卖、商超、跑腿等即时单,核心是商户订单、骑手调度、售后与分账;经销商、生鲜、乡镇网点等统仓共配,核心是库存、波次分拣、车辆线路和回单。两条都做时,把平台交易侧和仓配作业侧分开核对。若首期做县城多商户外卖加跑腿,选型重点应放在订单状态、结算冲回、提现和分站权限,而不是功能表长度。下面用常见工程故障展开:结算完成后发生部分退款,系统只改了订单状态,没有生成资金反向流水,商家可提现余额因此偏大。
一、先分两条路线,再落到一个故障
县域服务商的订单可以分成两类。第一类是即时履约:用户下单、商家接单、骑手取送、平台抽成和售后都在较短周期内完成。第二类是统仓共配:库存、波次、分拣、装车、车辆线路、签收和回单占主要比重。两类都做时,用一条订单链路硬套全部环节,容易把交易结算和仓内作业混在一起。

微订配送调度台,集中显示待指派订单、配送员状态与指派操作。
| 核对维度 | 平台交易侧 | 仓配作业侧 | 选型含义 |
|---|---|---|---|
| 核心对象 | 商户、商品、用户、订单、骑手、分站 | 仓库、库存、波次、车辆、线路、签收单 | 先确认首期主业务,再定接口 |
| 关键状态 | 待支付、待接单、配送中、已送达、退款、已结算 | 待入库、分拣中、装车、在途、签收、回单 | 状态机要能对应资金与责任 |
| 资金重点 | 平台抽成、商家应结、骑手收入、退款冲回、提现 | 采购应付、配送成本、回单对账 | 冲回规则必须可验证 |
| 异常重点 | 重复回调、部分退款、结算后退款、超额提现 | 缺货、错分、漏装、回单缺失 | 用异常单验收,而非只看正常单 |
混合经营时,两侧通过订单号、分站、状态、金额和签收结果交换数据。选型时把“谁能生成资金流水”“退款后谁负责冲回”“提现被拒后余额如何恢复”写进验收,比只问有没有分账更可核对。

用户支付经分账规则和区域节点分配至商家、骑手与平台的流程示意。
二、数据模型与约束:余额不能替代流水
金额统一用整数分,避免浮点误差。结算流水只追加不修改,余额表保存当前可用额,退款单和提现单都带业务幂等键。核心约束如下。
| 表 | 关键字段 | 约束与用途 |
|---|---|---|
| biz_order | order_no、tenant_id、site_id、status、pay_amount_cents | 订单号唯一;状态限定在待支付到已结算之间;金额不小于零 |
| settlement_ledger | biz_key、account_type、account_id、amount_cents、entry_type | biz_key 唯一;account_type 限定商家、骑手、平台;金额不能为零;流水只追加 |
| account_balance | tenant_id、site_id、account_type、account_id、available_cents、version | 四字段联合主键;可用余额不小于零;更新时带版本或行锁 |
| refund_record | refund_no、order_no、amount_cents、status | 退款单号唯一;状态限定 received、posted、failed;金额大于零 |
| payout | payout_no、account_type、account_id、amount_cents、status | 提现单号唯一;状态限定 requested、approved、paid、rejected |
其中 biz_key 必须唯一表达一次业务动作,例如“退款单号:商家:冲回”。支付回调重复、消息重投或人工补单时,数据库唯一约束会挡住第二笔。跨分站项目还要把 tenant_id 和 site_id 放进所有查询与唯一键,否则 A 站退款可能扣到 B 站余额。
三、核心实现:退款冲回与提现冻结
依赖为 PostgreSQL 14 及以上。复现步骤:1. 建表并初始化一个商家余额;2. 调用退款冲回函数;3. 重复调用同一退款单;4. 将余额调低后再调用;5. 查看流水、余额和退款单状态。以下为设计示例,金额单位为分;上线前在测试库执行并记录版本、输入和输出。
CREATE TABLE biz_order (
order_no varchar(64) PRIMARY KEY,
tenant_id varchar(32) NOT NULL,
site_id varchar(32) NOT NULL,
status varchar(16) NOT NULL CHECK (status IN ('wait_pay','wait_accept','delivering','done','refunded','settled')),
pay_amount_cents bigint NOT NULL CHECK (pay_amount_cents >= 0)
);
CREATE TABLE settlement_ledger (
id bigserial PRIMARY KEY,
tenant_id varchar(32) NOT NULL,
site_id varchar(32) NOT NULL,
order_no varchar(64) NOT NULL,
account_type varchar(16) NOT NULL CHECK (account_type IN ('merchant','rider','platform')),
account_id varchar(64) NOT NULL,
entry_type varchar(24) NOT NULL CHECK (entry_type IN ('sale','commission','delivery_fee','refund_reverse','withdraw')),
amount_cents bigint NOT NULL CHECK (amount_cents <> 0),
biz_key varchar(160) NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE account_balance (
tenant_id varchar(32) NOT NULL,
site_id varchar(32) NOT NULL,
account_type varchar(16) NOT NULL CHECK (account_type IN ('merchant','rider','platform')),
account_id varchar(64) NOT NULL,
available_cents bigint NOT NULL DEFAULT 0 CHECK (available_cents >= 0),
version integer NOT NULL DEFAULT 0,
PRIMARY KEY (tenant_id, site_id, account_type, account_id)
);
CREATE TABLE refund_record (
refund_no varchar(64) PRIMARY KEY,
tenant_id varchar(32) NOT NULL,
site_id varchar(32) NOT NULL,
order_no varchar(64) NOT NULL,
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
status varchar(16) NOT NULL DEFAULT 'received' CHECK (status IN ('received','posted','failed'))
);
CREATE TABLE payout (
payout_no varchar(64) PRIMARY KEY,
tenant_id varchar(32) NOT NULL,
site_id varchar(32) NOT NULL,
account_type varchar(16) NOT NULL,
account_id varchar(64) NOT NULL,
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
status varchar(16) NOT NULL DEFAULT 'requested' CHECK (status IN ('requested','approved','paid','rejected'))
);
CREATE OR REPLACE FUNCTION post_refund_reverse(
p_tenant text, p_site text, p_refund_no text, p_order_no text,
p_account_type text, p_account_id text, p_amount_cents bigint
) RETURNS TABLE(result text, balance_cents bigint) AS {mathJaxContainer[0]} LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION request_payout(
p_tenant text, p_site text, p_payout_no text, p_account_type text,
p_account_id text, p_amount_cents bigint
) RETURNS text AS {mathJaxContainer[1]} LANGUAGE plpgsql;
正常输入与输出
初始化商家 M001 在 t1/site-a 的可用余额 5000 分,执行退款冲回 1200 分。预期输出为 posted 和 3800;结算流水新增一条 -1200,biz_key 为 RF001:reverse;退款单状态变为 posted。再次用同一退款单调用,预期返回 duplicate_or_invalid_state,余额仍为 3800,流水不新增。
异常反例
| 场景 | 输入 | 预期结果 | 业务含义 |
|---|---|---|---|
| 重复回调 | 同一 refund_no 第二次调用 | duplicate_or_invalid_state,余额不变 | 支付回调重投不会重复扣款 |
| 商家已提现后余额不足 | 余额 800,退款 1200 | 抛出 insufficient_balance,余额和流水回滚 | 普通余额硬减会卡住;需要设计欠款账户或人工审核 |
| 跨分站误扣 | 用 site-b 账号执行 site-a 退款 | 找不到余额记录,事务回滚 | 分站维度必须进入余额与流水条件 |
余额不足不是把错误吞掉就能解决。系统可提供两种可选配置:一是建立商家应收或欠款账,把已提现后的退款冲回记为待补款;二是保留平台风险准备金,按规则审核后垫付,再向商家追偿。选型时要求演示这两种处理下的流水、余额、退款单和提现单变化,才能判断系统是否真正支持结算后退款。
四、把代码约束变成选型验收问题
平台交易侧选型,可以直接用上面的故障验收:退款回调重复时是否幂等;结算后退款是否生成反向流水;余额不足时是否有欠款或垫付路径;分站是否进入余额、流水、提现和报表;提现被拒后余额如何恢复。仓配作业侧则另看库存变更、波次分拣、车辆线路和回单是否与订单号、签收结果衔接。混合经营时,接口至少交换订单状态、站点、金额和签收结果,并明确异常由哪一侧负责。
若部署到云上,可选关系型数据库承载交易库、消息队列承接退款回调;本地演示与云环境在网络权限、监控、备份和事务边界上存在差异,本文示例不绑定具体云资源,也不代表任何现网配置。验收时保留测试数据、SQL 版本和输出记录,比只看功能清单更容易判断系统边界。
五、常见问题
问:有分账功能是否就足够?
分账只说明资金可以分配,结算后退款、重复回调、提现冻结和分站归属还要单独验证。用同一退款单连续调用两次,观察流水和余额是否只变一次。
问:商家已提现后发生退款怎么办?
可设计欠款账户、风险准备金或人工审核路径,并把冲回记录写入流水。若系统只能从余额硬减,需核对余额为零时的处理规则。
问:混合经营要几套系统?
按交易侧和仓配作业侧分别核对。即时订单、商户结算和骑手任务可归平台侧;库存、分拣、装车和回单可归仓配侧;两侧通过订单号、站点、状态和签收结果交换数据,接口责任写入交付清单。
作者:微订(上海逊柯计算机科技有限公司)。