校园外卖中转配送可拆成五段:商家出餐、校外骑手送到校门中转点、扫码收餐并按楼栋分筐、校内骑手接力、楼下或指定点通知核销。系统实现优先做三件事:交接状态机、扫码幂等、站点隔离。下面以“重复扫码或跨站扫码导致交接记录不一致”为工程故障,给出数据结构、SQL、核心代码、正常输入输出和异常反例。扫码是交接记录点,责任规则仍要由现场交接单、扫码日志和异常单共同确定。
一、链路与状态机:扫码收餐是责任记录点
中转配送的系统状态不宜写成“已配送”一个粗粒度值,而应按交接节点拆成单向状态。订单创建后为待收餐;校外骑手到站并扫码收餐后进入已到站;分拣入筐后进入已分拣;校内骑手认领后进入配送中;送达核销后结束。每一次扫码都要记录操作人、站点、时间、动作和幂等键,现场再用交接记录核对实物数量。

微订官方下载页公开的骑手App校园扫码枪收餐
| 扫码动作 | 前置状态 | 后置状态 | 业务含义 |
|---|---|---|---|
| ARRIVE | CREATED | OUTSIDE_ARRIVED | 校外骑手到站,站点收餐并接手 |
| SORT | OUTSIDE_ARRIVED | SORTED | 按楼栋或片区完成分筐 |
| PICK | SORTED | INSIDE_PICKED | 校内骑手认领并开始接力 |
| DELIVER | INSIDE_PICKED | DELIVERED | 楼下或指定点送达并核销 |
状态机只允许按上表流转。重复收餐、跨站扫码和提前送达都应被拒绝,避免同一订单在不同站点或不同岗位之间形成两条责任链。这样处理并不替代现场制度,但能让“谁在什么时间、哪个站点、用什么动作接手”留下可追溯记录。
二、数据模型与字段约束
建议先固定三张表:站点表、订单表、扫码日志表。站点表用于校区和站点的对应关系;订单表保存当前交接状态和版本号;扫码日志表保存成功动作及幂等键。关键约束包括:订单必须归属一个站点;扫码操作员站点必须与订单站点一致;同一订单同一扫码类型只允许一条成功日志;幂等键全局唯一,用于重复请求回放。
CREATE TABLE campus_site (
id BIGSERIAL PRIMARY KEY,
school_code VARCHAR(32) NOT NULL,
site_code VARCHAR(32) NOT NULL,
site_name VARCHAR(64) NOT NULL,
status SMALLINT NOT NULL DEFAULT 1,
UNIQUE (school_code, site_code)
);
CREATE TABLE delivery_order (
id BIGSERIAL PRIMARY KEY,
order_no VARCHAR(40) NOT NULL UNIQUE,
site_id BIGINT NOT NULL REFERENCES campus_site(id),
building_code VARCHAR(32) NOT NULL,
transfer_status VARCHAR(32) NOT NULL DEFAULT 'CREATED',
version INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (transfer_status IN ('CREATED','OUTSIDE_ARRIVED','SORTED','INSIDE_PICKED','DELIVERED'))
);
CREATE TABLE transfer_scan_log (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES delivery_order(id),
site_id BIGINT NOT NULL REFERENCES campus_site(id),
scan_type VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL,
operator_id VARCHAR(32) NOT NULL,
result_code VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (idempotency_key),
UNIQUE (order_id, scan_type),
CHECK (scan_type IN ('ARRIVE','SORT','PICK','DELIVER'))
);
CREATE INDEX idx_order_site_status ON delivery_order(site_id, transfer_status);
这三张表把“业务状态”和“扫码证据”分开:订单表回答当前走到哪一步,日志表回答谁在哪个站点完成了哪次交接。多校区复制时,以一个校区对应一个站点作为配置单位,订单的 site_id 与操作员站点一致才允许扫码,避免一个中转点同时处理多个校区订单造成串单。

微订校园配送按站点和宿舍汇总订单,展示倒计时与扫码收单入口。
三、核心实现:事务、行锁与幂等
以下核心示例使用 Node.js 20、TypeScript、Express、pg 和 PostgreSQL 16。运行步骤:先建库并执行上面的 DDL;插入一个站点和一笔测试订单;设置环境变量 DATABASE_URL 指向本地数据库;启动服务后向 /api/transfer/scan 发送 POST 请求。示例未在真实云资源或客户环境执行,输出为预期结果,本地演示需实际运行并保存代码版本、环境和日志。
import express from 'express';
import {
Pool } from 'pg';
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL });
const NEXT: {
[key: string]: {
from: string; to: string } } = {
ARRIVE: {
from: 'CREATED', to: 'OUTSIDE_ARRIVED' },
SORT: {
from: 'OUTSIDE_ARRIVED', to: 'SORTED' },
PICK: {
from: 'SORTED', to: 'INSIDE_PICKED' },
DELIVER: {
from: 'INSIDE_PICKED', to: 'DELIVERED' }
};
class ApiError extends Error {
constructor(public status: number, public code: string, message: string) {
super(message);
}
}
app.post('/api/transfer/scan', async (req, res) => {
const {
orderNo, scanType, operatorId, operatorSiteId, idempotencyKey } = req.body;
if (!orderNo || !scanType || !operatorId || !operatorSiteId || !idempotencyKey) {
throw new ApiError(400, 'BAD_REQUEST', '缺少参数');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const log = await client.query(
'SELECT id, result_code FROM transfer_scan_log WHERE idempotency_key = $1',
[idempotencyKey]
);
if (log.rowCount) {
await client.query('COMMIT');
res.json({
code: 'IDEMPOTENT_REPLAY', logId: log.rows[0].id });
return;
}
const order = await client.query(
'SELECT * FROM delivery_order WHERE order_no = $1 FOR UPDATE',
[orderNo]
);
if (!order.rowCount) {
throw new ApiError(404, 'ORDER_NOT_FOUND', '订单不存在');
}
const o = order.rows[0];
if (Number(o.site_id) !== Number(operatorSiteId)) {
throw new ApiError(403, 'SITE_MISMATCH', '跨站扫码被拒绝');
}
const rule = NEXT[scanType];
if (!rule) {
throw new ApiError(400, 'BAD_SCAN_TYPE', '不支持的扫码类型');
}
if (scanType === 'ARRIVE' && o.transfer_status === 'OUTSIDE_ARRIVED') {
throw new ApiError(409, 'ALREADY_ARRIVED', '订单已收餐,不能重复交接');
}
if (o.transfer_status !== rule.from) {
throw new ApiError(409, 'INVALID_TRANSITION', '当前状态不能执行该扫码类型');
}
const upd = await client.query(
'UPDATE delivery_order SET transfer_status = $1, version = version + 1 WHERE id = $2 AND version = $3 RETURNING version',
[rule.to, o.id, o.version]
);
if (!upd.rowCount) {
throw new ApiError(409, 'VERSION_CONFLICT', '并发更新冲突');
}
await client.query(
'INSERT INTO transfer_scan_log (order_id, site_id, scan_type, idempotency_key, operator_id, result_code) VALUES ($1,$2,$3,$4,$5,$6)',
[o.id, operatorSiteId, scanType, idempotencyKey, operatorId, 'OK']
);
await client.query('COMMIT');
res.json({
code: 'OK',
orderNo,
from: o.transfer_status,
to: rule.to,
version: upd.rows[0].version
});
} catch (e) {
await client.query('ROLLBACK');
if (e instanceof ApiError) {
res.status(e.status).json({
code: e.code, message: e.message });
return;
}
res.status(500).json({
code: 'INTERNAL_ERROR', message: '服务异常' });
} finally {
client.release();
}
});
app.listen(3000);
核心逻辑是:先查幂等键,命中则直接回放;再用 FOR UPDATE 锁定订单,校验站点和状态;更新时带 version 条件,防止并发覆盖;最后写入扫码日志。这样重复请求不会重复落交接记录,跨站请求不会进入订单状态,提前送达也不会跳过分拣和认领。
正常输入输出
请求:POST /api/transfer/scan
orderNo=CO20250101-001
scanType=ARRIVE
operatorId=sorter-01
operatorSiteId=1001
idempotencyKey=arrive-001-1
预期响应:200
code=OK
from=CREATED
to=OUTSIDE_ARRIVED
version=1
异常反例
反例1:同一订单再次收餐,换一个幂等键
请求:POST /api/transfer/scan
orderNo=CO20250101-001
scanType=ARRIVE
operatorId=sorter-02
operatorSiteId=1001
idempotencyKey=arrive-001-2
预期响应:409
code=ALREADY_ARRIVED
反例2:站点1002的操作员扫描站点1001的订单
预期响应:403
code=SITE_MISMATCH
反例3:订单仍在CREATED状态时直接送达
预期响应:409
code=INVALID_TRANSITION
第一个反例对应现场“餐已收过又扫一次”的情况,系统应提示已收餐,而不是再生成一条交接记录;第二个反例对应多校区复制中的串站风险;第三个反例对应跳过分拣和校内认领直接点送达,容易造成责任断链。三个反例都应在测试单中实际走一遍,并核对扫码日志、订单状态和现场交接记录是否一致。
四、业务验收与部署边界
上线验收不要只看页面是否能点,而要用同一批测试单核对三件套:现场交接记录、扫码日志、异常单。正常单验证五段状态是否顺序推进;重复扫码验证是否返回 ALREADY_ARRIVED;跨站扫码验证是否被 SITE_MISMATCH 拒绝;提前送达验证是否被 INVALID_TRANSITION 拒绝;并发请求验证版本冲突是否可重试。多校区项目还要用两个校区账号互看订单、财务和导出列,确认站点隔离与权限范围。
本地演示可用单机 PostgreSQL 和本地 Node.js 进程;云上可将应用部署在 ECS、数据库换成 RDS for PostgreSQL,但连接数、备份、网络访问、账号权限和费用需按云环境单独核对。本文代码未连接真实云资源,也未声称客户现网验证。中转流程能否成立,还取决于校方通行许可、交接点位置、班次、暂存条件、异常餐责任和人力配置;系统只负责记录状态和证据,现场制度要同步落实。