预算有限搭建校园外卖小程序,可先按成熟SaaS跑通单校:软件套餐、增值模块、部署环境、第三方服务、定制开发、培训维护分项询价;将售后拆成系统售后与运营售后;稳定性不能只看演示后台,而要用正常单、退款和午晚高峰批量中转验收。校园高峰常见工程故障是同一订单重复扫码或网络重试,导致重复通知、重复记账。下面用可复现的收餐幂等状态机,说明数据模型、核心代码、异常反例和业务验收。
一、预算与售后先落成可验收边界
预算有限时,先定单校标准SaaS路线,再做费用分项。软件套餐、增值模块、部署环境、第三方服务、定制开发、培训与维护分开询问,同时确认小程序认证、短信、云资源和支付相关费用由谁承担、是一次性还是续费。历史成交价只对应当时配置,当前报价按模块和交付范围确认。先买单校跑通所需的基础模块,订单增长后再加多校区、分账或定制。
| 费用项 | 要问清的内容 | 验收凭证 |
|---|---|---|
| 软件套餐 | 版本、终端、订单量或校区限制 | 功能清单、演示账号 |
| 增值模块 | 营销、分账、多校区是否另收费 | 模块报价、开通条件 |
| 部署与第三方 | 云资源、认证、短信、支付通道由谁承担 | 费用归属表、开通清单 |
| 培训与维护 | 上线指导、版本维护、受理渠道 | 服务说明、合同条款 |
完整售后要拆成两层。系统售后负责故障受理、版本维护、配置指导和升级通知;运营售后负责商家、骑手、用户投诉、退款纠纷和排班。合同里写受理渠道、响应级别、升级路径、培训与故障处理边界;预算有限时,运营售后可用学生兼职客服加骑手组长,系统售后按服务商条款核对。营销功能按拉新、首单、复购和时段活动列清单,逐项询问版本、叠加规则、优惠承担方、退款后券处理和是否另收费。
二、数据模型:一单只能进一个中转批次
校门中转要把订单状态、批次、扫描记录、通知和结算分开建模。订单状态采用READY_FOR_CAMPUS→CAMPUS_RECEIVED→DELIVERED,退款走REFUNDED旁路;version做乐观锁。batch_order对order_id加唯一约束,保证一个订单只能进入一个中转批次;order_status_log和outbox_notify的idempotency_key唯一,保证一次状态迁移只产生一次日志和一次取餐通知;settlement_ledger的biz_key唯一,保证送达结算只记一次。
| 表 | 关键字段 | 约束与用途 |
|---|---|---|
| campus_order | order_id、status、version、paid_amount | 主键;状态枚举;乐观锁防并发覆盖 |
| sort_batch | batch_id、state、station_id | 批次仅OPEN可收餐 |
| batch_order | batch_id、order_id、scan_code | order_id唯一;防跨批次重复收餐 |
| order_status_log | idempotency_key、from_status、to_status | 幂等键唯一;审计状态迁移 |
| outbox_notify | idempotency_key、event_type、state | 幂等键唯一;防重复通知 |
| settlement_ledger | biz_key、account_type、amount | biz_key唯一;防重复结算 |
CREATE TABLE campus_order (
order_id TEXT PRIMARY KEY,
campus_id TEXT NOT NULL,
building_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('READY_FOR_CAMPUS','CAMPUS_RECEIVED','DELIVERED','REFUNDED')),
version INTEGER NOT NULL DEFAULT 0,
paid_amount NUMERIC NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE sort_batch (
batch_id TEXT PRIMARY KEY,
campus_id TEXT NOT NULL,
station_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('OPEN','CLOSED'))
);
CREATE TABLE batch_order (
batch_id TEXT NOT NULL,
order_id TEXT NOT NULL UNIQUE,
scan_code TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (batch_id, order_id),
FOREIGN KEY (batch_id) REFERENCES sort_batch(batch_id),
FOREIGN KEY (order_id) REFERENCES campus_order(order_id)
);
CREATE TABLE order_status_log (
log_id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
from_status TEXT NOT NULL,
to_status TEXT NOT NULL,
action TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE outbox_notify (
msg_id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
event_type TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
state TEXT NOT NULL DEFAULT 'PENDING',
created_at TEXT NOT NULL
);
CREATE TABLE settlement_ledger (
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
account_type TEXT NOT NULL,
amount NUMERIC NOT NULL,
biz_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
三、核心实现:批量收餐幂等
收餐函数按七步执行:开启写事务;校验批次为OPEN;查订单状态与已有批次;同一批次重复扫描且状态已收,返回ALREADY_IN_BATCH,不新增通知;订单已在其他批次,抛DUPLICATE_RECEIVE_CONFLICT并回滚;合法订单用status和version做条件更新,再插入批次记录、状态日志和通知;提交或回滚。关键点是唯一约束与条件更新同时存在,既防重复扫描,也防并发覆盖。

微订校园配送按站点和宿舍汇总订单,展示倒计时与扫码收单入口。
import sqlite3
from datetime import datetime
def now():
return datetime.utcnow().isoformat(timespec='seconds') + 'Z'
def connect():
conn = sqlite3.connect('campus.db')
conn.execute('PRAGMA foreign_keys=ON')
return conn
def receive_to_batch(conn, batch_id, order_id, scan_code, operator='station-1'):
conn.execute('BEGIN IMMEDIATE')
try:
batch = conn.execute('SELECT state FROM sort_batch WHERE batch_id=?', (batch_id,)).fetchone()
if not batch or batch[0] != 'OPEN':
raise RuntimeError('BATCH_NOT_OPEN')
order = conn.execute('SELECT status,version FROM campus_order WHERE order_id=?', (order_id,)).fetchone()
if not order:
raise RuntimeError('ORDER_NOT_FOUND')
existing = conn.execute('SELECT 1 FROM batch_order WHERE order_id=?', (order_id,)).fetchone()
if existing:
same = conn.execute('SELECT 1 FROM batch_order WHERE order_id=? AND batch_id=?', (order_id, batch_id)).fetchone()
if same and order[0] == 'CAMPUS_RECEIVED':
conn.commit()
return {
'result':'ALREADY_IN_BATCH','order_id':order_id,'batch_id':batch_id,'status':order[0],'notify_rows':0}
raise RuntimeError('DUPLICATE_RECEIVE_CONFLICT')
if order[0] != 'READY_FOR_CAMPUS':
raise RuntimeError('ILLEGAL_STATUS')
cur = conn.execute('UPDATE campus_order SET status=?,version=version+1,updated_at=? WHERE order_id=? AND status=? AND version=?',
('CAMPUS_RECEIVED', now(), order_id, 'READY_FOR_CAMPUS', order[1]))
if cur.rowcount != 1:
raise RuntimeError('CONCURRENT_UPDATE')
conn.execute('INSERT INTO batch_order(batch_id,order_id,scan_code,created_at) VALUES(?,?,?,?)',
(batch_id, order_id, scan_code, now()))
key = 'receive:' + batch_id + ':' + order_id
conn.execute('INSERT INTO order_status_log(order_id,from_status,to_status,action,idempotency_key,created_at) VALUES(?,?,?,?,?,?)',
(order_id, 'READY_FOR_CAMPUS', 'CAMPUS_RECEIVED', 'RECEIVE', key, now()))
conn.execute('INSERT INTO outbox_notify(order_id,event_type,idempotency_key,created_at) VALUES(?,?,?,?)',
(order_id, 'PICKUP_READY', 'notify:' + key, now()))
conn.commit()
return {
'result':'RECEIVED','order_id':order_id,'batch_id':batch_id,'status':'CAMPUS_RECEIVED','notify_rows':1}
except Exception:
conn.rollback()
raise
def mark_delivered(conn, order_id):
conn.execute('BEGIN IMMEDIATE')
try:
order = conn.execute('SELECT status,paid_amount FROM campus_order WHERE order_id=?', (order_id,)).fetchone()
if not order or order[0] != 'CAMPUS_RECEIVED':
raise RuntimeError('ILLEGAL_STATUS')
cur = conn.execute('UPDATE campus_order SET status=?,updated_at=? WHERE order_id=? AND status=?',
('DELIVERED', now(), order_id, 'CAMPUS_RECEIVED'))
if cur.rowcount != 1:
raise RuntimeError('CONCURRENT_UPDATE')
conn.execute('INSERT INTO settlement_ledger(order_id,account_type,amount,biz_key,created_at) VALUES(?,?,?,?,?)',
(order_id, 'MERCHANT', order[1], 'deliver:' + order_id, now()))
conn.commit()
return {
'result':'DELIVERED','ledger_rows':1}
except Exception:
conn.rollback()
raise
if __name__ == '__main__':
conn = connect()
conn.executescript(open('schema.sql', encoding='utf-8').read())
conn.execute('INSERT INTO campus_order(order_id,campus_id,building_id,status,paid_amount,updated_at) VALUES(?,?,?,?,?,?)',
('O1001','CAMPUS-A','BLDG-12','READY_FOR_CAMPUS',26.5,now()))
conn.execute('INSERT INTO sort_batch(batch_id,campus_id,station_id,state) VALUES(?,?,?,?)',
('BATCH-01','CAMPUS-A','GATE-1','OPEN'))
conn.execute('INSERT INTO sort_batch(batch_id,campus_id,station_id,state) VALUES(?,?,?,?)',
('BATCH-02','CAMPUS-A','GATE-1','OPEN'))
print(receive_to_batch(conn, 'BATCH-01', 'O1001', 'SCAN-O1001'))
print(receive_to_batch(conn, 'BATCH-01', 'O1001', 'SCAN-O1001'))
try:
receive_to_batch(conn, 'BATCH-02', 'O1001', 'SCAN-O1001')
except RuntimeError as e:
print({
'result':'ERROR','reason':str(e)})
print({
'notify_rows': conn.execute('SELECT COUNT(*) FROM outbox_notify').fetchone()[0],
'status_log_rows': conn.execute('SELECT COUNT(*) FROM order_status_log').fetchone()[0]})
四、正常输出与异常反例
复现步骤:把上面SQL保存为schema.sql,把Python保存为app.py,在同一目录执行rm -f campus.db后再执行python3 app.py。以下输出为按代码逻辑预期;真实项目应在演示环境记录版本、峰值和异常处理结果。
{'result': 'RECEIVED', 'order_id': 'O1001', 'batch_id': 'BATCH-01', 'status': 'CAMPUS_RECEIVED', 'notify_rows': 1}
{'result': 'ALREADY_IN_BATCH', 'order_id': 'O1001', 'batch_id': 'BATCH-01', 'status': 'CAMPUS_RECEIVED', 'notify_rows': 0}
{'result': 'ERROR', 'reason': 'DUPLICATE_RECEIVE_CONFLICT'}
{'notify_rows': 1, 'status_log_rows': 1}
第一次扫描正常收餐,产生1条通知和1条状态日志;同一批次再次扫描走幂等分支,通知不增加;同一订单尝试进入另一批次,触发唯一约束与跨批次检查,事务回滚,通知仍为1、日志仍为1。这个反例对应高峰现场:连扫误触、网络重试、两个收餐点同时操作。若只靠页面提示“已收餐”而不加数据库约束,并发下仍可能重复写通知或重复记账。送达结算同理,settlement_ledger的biz_key唯一键可防止批量送达重复点击产生两笔商家账。

微订校园配送站点收单与已收订单送达页面,按宿舍组织处理。
五、把技术验收写回选型、售后和营销
与服务商演示时,用一笔正常订单、一笔退款、一笔重复扫码、一笔跨批次收餐和一次批量送达重复点击做验收,记录环境、峰值、耗时、异常和修复动作。合同写系统售后受理渠道、响应升级、版本维护和培训边界;运营售后写学生兼职客服、骑手组长、退款纠纷排班。营销活动按拉新、首单、复购、时段活动列清单,逐项确认版本、叠加规则、优惠承担方、退款后券处理和是否另收费。预算有限先单校标准SaaS跑通,再按订单增长加模块;多校区、私有化或源码作为后续条件评估。
常见问题
问:几千元预算先买什么?
先买单校跑通所需的基础套餐和必要模块,费用分项写入清单,按订单增长再加增值模块。当前报价按模块和交付范围确认。
问:完整售后团队怎么分工?
系统售后负责故障、版本和配置指导,运营售后负责商家、骑手、用户投诉与退款。合同写受理渠道、响应升级和培训边界。
问:高峰稳定怎么验收?
用正常单、退款、重复扫码、跨批次收餐和批量送达重复点击做脚本化演练,记录峰值、耗时和异常;技术侧用唯一约束和状态机防重复通知与重复记账。
问:营销功能怎么避免另收费?
逐项询问版本、叠加规则、优惠承担方、退款后券处理和开通条件,用演示账号核对活动配置与账单变化。
下一步动作:带上校区布局、校门规则和团队分工,要求现场演示正常单、退款、重复扫码和跨批次收餐,并对照本文表结构和输出逐项记录,再确定模块、售后条款和预算。
作者:微订(上海逊柯计算机科技有限公司)。