零基础做校园外卖平台,建议先完成学校政策摸底,再用少量商家和人工方式试跑校门交接、楼栋分拣、送达通知与退款对账;当人工接单开始漏单、对账变麻烦时,再采购系统。技术选型不要只看“支持中转分拣”的功能名,而要用一笔餐和异常单验收订单状态、批次操作与幂等处理。下面从午高峰一个具体工程故障切入:分拣员少收一份餐,仍点击“批量送达”,系统把未交接订单错误置为已送达,导致学生没收到餐、客服重复退款、对账无法追溯。
一、起步顺序:先定义闭环,再谈系统
校园外卖的起步不是先买一套大系统,而是先确认学校允许校外餐入校的范围、能否设中转点、宿舍送到哪里,再用一个校区或几栋楼试跑。试跑阶段至少记录五类信息:商家出餐时间、校门交接人、楼栋分拣结果、校内骑手、异常处理结果。人工台账能跑通,再把这些字段固化为系统约束。
| 阶段 | 主要产出 | 技术验收点 |
|---|---|---|
| 政策与需求摸底 | 校门通行规则、楼栋地址表、首批商家清单 | 地址能否按校区、楼栋、站点分层 |
| 小范围试跑 | 正常单、缺餐单、退款单的处理记录 | 状态能否从下单走到送达且不回退 |
| 系统采购 | 一笔餐演示、异常反例演示、费用拆项 | 批量操作是否幂等,异常单是否隔离 |
只有试跑暴露出人工接单的漏单、错单和对账问题,系统采购才有明确验收对象。标准SaaS、私有化部署、源码安装或定制的选择,也应服务于这些已确认流程,而不是替代学校答复或承诺单量。
二、具体故障:批量送达把异常单误置为已送达
午高峰时,校外餐品到校门中转点,分拣员按楼栋收单。假设批次B001中有O1001、O1002、O1003三单,其中O1003少一份餐,已被现场标记为异常待处理。如果系统允许把整批订单一键“送达”,且不校验每单当前状态,O1003也会被改为已送达并触发取餐通知。学生到取餐点找不到餐,客服只能人工退款;更麻烦的是,重复点击或网络重试会再次发通知、再次写事件,账单和售后记录对不上。

微订骑手任务列表与校园宿舍配送组合。
根因通常有四类:订单状态没有数据库约束,任何代码都能改;批量操作没有批次版本,前端拿着旧页面也能提交;没有幂等键,重复请求被当成新操作;通知先发后落库,事务回滚后消息已经发出。解决办法不是加一个“确认弹窗”,而是把状态机、批次校验和幂等记录放进同一个数据库事务。

微订校园骑手查看商家订单明细、核对宿舍地址并逐单收单。
三、数据模型与字段约束
核心表分为订单、批次、批次明细、事件、幂等请求和通知发件箱。订单状态只允许按业务链路向前流转;批次明细用唯一键防止同一订单在同一批次同一阶段重复入账;幂等请求表用唯一键保存一次操作的请求哈希和结果。
CREATE TABLE campus_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
campus_id BIGINT NOT NULL,
building_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
version INT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_order_no (order_no),
CONSTRAINT ck_order_status CHECK (status IN (
'CREATED','MERCHANT_ACCEPTED','OUT_FOR_CAMPUS','AT_GATE',
'SORTED','CAMPUS_DELIVERING','DELIVERED','EXCEPTION','CANCELED'
))
);
CREATE TABLE transfer_batch (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
batch_no VARCHAR(32) NOT NULL,
station_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
version INT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_batch_no (batch_no),
CONSTRAINT ck_batch_status CHECK (status IN ('OPEN','SORTING','CLOSED'))
);
CREATE TABLE batch_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
batch_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
stage VARCHAR(16) NOT NULL,
scanned_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_batch_order_stage (batch_id, order_id, stage),
CONSTRAINT fk_bo_batch FOREIGN KEY (batch_id) REFERENCES transfer_batch(id),
CONSTRAINT fk_bo_order FOREIGN KEY (order_id) REFERENCES campus_order(id)
);
CREATE TABLE operation_request (
idempotency_key VARCHAR(80) PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_body JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);
CREATE TABLE order_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NOT NULL,
to_status VARCHAR(32) NOT NULL,
operator_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(120) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_event_idem (idempotency_key),
KEY idx_event_order (order_id)
);
CREATE TABLE notification_outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
channel VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_outbox_event_channel (event_id, channel)
);
字段约束要解释回业务:campus_order.status约束阻止代码把订单写成未知状态;version用于条件更新,避免旧页面覆盖新状态;operation_request.idempotency_key保证同一操作重复提交只生效一次;notification_outbox把“要发通知”先落库,再由独立发送器处理,避免事务回滚后消息已发出。
| 约束 | 对应业务问题 | 不设置时的反例 |
|---|---|---|
| 订单状态CHECK | 状态只能按履约链路流转 | 已送达被改回分拣中,客服无法判断 |
| 批次唯一键 | 同一订单同一批次不重复入账 | 重复扫码生成两条分拣记录 |
| 幂等请求主键 | 重复点击或网络重试只生效一次 | 重复通知、重复记账 |
| 通知发件箱 | 先落库后发送,可重试 | 事务回滚但学生已收到取餐消息 |
四、核心实现:带幂等和批次校验的批量送达
下面的Go示例假设调用方已经开启数据库事务,函数返回错误时由调用方回滚。正常路径依次完成:写幂等请求、锁批次、校验批次版本、查出批次内未送达订单、要求请求中的订单号与数据库当前集合完全一致、逐单条件更新、写事件和通知发件箱、关闭批次、保存幂等结果。任何一步不满足就返回冲突错误,整批不生效。
package delivery
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
)
type BatchDeliverCmd struct {
BatchNo string `json:"batchNo"`
BatchVersion int `json:"batchVersion"`
OrderNos []string `json:"orderNos"`
IdempotencyKey string `json:"idempotencyKey"`
}
type BatchDeliverResult struct {
BatchNo string `json:"batchNo"`
Delivered []string `json:"delivered"`
Duplicate bool `json:"duplicate"`
}
type ConflictError struct {
Code string
Detail string
}
func (e *ConflictError) Error() string {
return e.Code + ": " + e.Detail }
func hashCmd(c BatchDeliverCmd) string {
cp := c
sort.Strings(cp.OrderNos)
cp.IdempotencyKey = ""
b, _ := json.Marshal(cp)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func MarkBatchDelivered(ctx context.Context, tx *sql.Tx, cmd BatchDeliverCmd) (*BatchDeliverResult, error) {
if cmd.IdempotencyKey == "" || cmd.BatchNo == "" || len(cmd.OrderNos) == 0 {
return nil, &ConflictError{
Code: "BAD_REQUEST", Detail: "missing required fields"}
}
reqHash := hashCmd(cmd)
res, err := tx.ExecContext(ctx,
`INSERT IGNORE INTO operation_request(idempotency_key, request_hash, status) VALUES (?, ?, 'PROCESSING')`,
cmd.IdempotencyKey, reqHash)
if err != nil {
return nil, err
}
n, _ := res.RowsAffected()
if n == 0 {
var savedHash, body, status string
if err := tx.QueryRowContext(ctx,
`SELECT request_hash, status, response_body FROM operation_request WHERE idempotency_key=?`,
cmd.IdempotencyKey).Scan(&savedHash, &status, &body); err != nil {
return nil, err
}
if savedHash != reqHash {
return nil, &ConflictError{
Code: "IDEMPOTENCY_KEY_REUSED", Detail: "same key with different request"}
}
if status != "SUCCESS" {
return nil, &ConflictError{
Code: "REQUEST_IN_PROGRESS", Detail: "previous request not completed"}
}
var out BatchDeliverResult
if err := json.Unmarshal([]byte(body), &out); err != nil {
return nil, err
}
out.Duplicate = true
return &out, nil
}
var batchVersion int
var batchStatus string
if err := tx.QueryRowContext(ctx,
`SELECT version, status FROM transfer_batch WHERE batch_no=? FOR UPDATE`,
cmd.BatchNo).Scan(&batchVersion, &batchStatus); err != nil {
return nil, err
}
if batchStatus != "OPEN" && batchStatus != "SORTING" {
return nil, &ConflictError{
Code: "BATCH_STATUS_CONFLICT", Detail: batchStatus}
}
if batchVersion != cmd.BatchVersion {
return nil, &ConflictError{
Code: "BATCH_VERSION_CONFLICT",
Detail: fmt.Sprintf("want %d got %d", cmd.BatchVersion, batchVersion)}
}
rows, err := tx.QueryContext(ctx, `
SELECT o.id, o.order_no, o.status, o.version
FROM campus_order o
JOIN batch_order bo ON bo.order_id = o.id
WHERE bo.batch_id = (SELECT id FROM transfer_batch WHERE batch_no=?)
AND o.status <> 'DELIVERED'
ORDER BY o.order_no`, cmd.BatchNo)
if err != nil {
return nil, err
}
defer rows.Close()
type item struct {
id int64
no string
status string
version int
}
var dbItems []item
for rows.Next() {
var it item
if err := rows.Scan(&it.id, &it.no, &it.status, &it.version); err != nil {
return nil, err
}
dbItems = append(dbItems, it)
}
if err := rows.Err(); err != nil {
return nil, err
}
want := append([]string(nil), cmd.OrderNos...)
sort.Strings(want)
got := make([]string, 0, len(dbItems))
for _, it := range dbItems {
got = append(got, it.no)
}
sort.Strings(got)
if strings.Join(want, ",") != strings.Join(got, ",") {
return nil, &ConflictError{
Code: "BATCH_STATE_CONFLICT",
Detail: fmt.Sprintf("expected=%v current=%v", want, got)}
}
for _, it := range dbItems {
if it.status != "CAMPUS_DELIVERING" {
return nil, &ConflictError{
Code: "ORDER_STATUS_CONFLICT",
Detail: fmt.Sprintf("%s=%s", it.no, it.status)}
}
}
delivered := make([]string, 0, len(dbItems))
for _, it := range dbItems {
r, err := tx.ExecContext(ctx, `
UPDATE campus_order
SET status='DELIVERED', version=version+1, updated_at=NOW(3)
WHERE id=? AND status='CAMPUS_DELIVERING' AND version=?`,
it.id, it.version)
if err != nil {
return nil, err
}
affected, _ := r.RowsAffected()
if affected != 1 {
return nil, &ConflictError{
Code: "ORDER_UPDATE_CONFLICT", Detail: it.no}
}
ev, err := tx.ExecContext(ctx, `
INSERT INTO order_event(order_id, event_type, from_status, to_status, operator_id, idempotency_key)
VALUES (?, 'BATCH_DELIVERED', 'CAMPUS_DELIVERING', 'DELIVERED', 'system', ?)`,
it.id, cmd.IdempotencyKey+"#"+it.no)
if err != nil {
return nil, err
}
eventID, _ := ev.LastInsertId()
if _, err := tx.ExecContext(ctx, `
INSERT INTO notification_outbox(order_id, event_id, channel, status)
VALUES (?, ?, 'WECHAT', 'PENDING')
ON DUPLICATE KEY UPDATE id=id`, it.id, eventID); err != nil {
return nil, err
}
delivered = append(delivered, it.no)
}
if _, err := tx.ExecContext(ctx, `
UPDATE transfer_batch SET status='CLOSED', version=version+1, updated_at=NOW(3)
WHERE batch_no=? AND version=?`, cmd.BatchNo, cmd.BatchVersion); err != nil {
return nil, err
}
out := BatchDeliverResult{
BatchNo: cmd.BatchNo, Delivered: delivered}
body, _ := json.Marshal(out)
if _, err := tx.ExecContext(ctx,
`UPDATE operation_request SET status='SUCCESS', response_body=? WHERE idempotency_key=?`,
string(body), cmd.IdempotencyKey); err != nil {
return nil, err
}
return &out, nil
}
依赖与复现步骤:准备MySQL 8.0.32或兼容版本、Go 1.22及MySQL驱动;执行上面的DDL;插入一条批次B001、两条状态为CAMPUS_DELIVERING的订单并建立批次明细;在事务中调用MarkBatchDelivered,成功后提交。本文代码为设计示例,未连接真实云资源执行,以下输出为预期结果。
五、正常输入输出与异常反例
正常输入:批次B001版本7,订单O1001版本3、O1002版本4,二者状态均为CAMPUS_DELIVERING。请求携带批次版本7、订单号列表和唯一幂等键。预期输出如下。
{
"batchNo": "B001",
"delivered": ["O1001", "O1002"],
"duplicate": false
}
数据库预期变化:两条订单变为DELIVERED且版本各加1,批次变为CLOSED且版本变为8,生成两条事件和两条待发送通知。用同一个幂等键重放同一请求,预期返回duplicate:true,不新增事件和通知。
异常反例一:批次中O1003少餐,状态为EXCEPTION,但操作员仍把它放入请求。函数在订单状态校验处返回ORDER_STATUS_CONFLICT,事务回滚,O1001和O1002也不会被置为送达,通知不进入发件箱。预期响应:
{
"code": "ORDER_STATUS_CONFLICT",
"detail": "O1003=EXCEPTION"
}
异常反例二:前端页面停留过久,提交的批次版本仍是6,而数据库已变为7。函数返回BATCH_VERSION_CONFLICT,不执行任何更新。这能阻止旧页面覆盖新批次状态,也提醒操作员刷新后重新核对订单。
六、业务验收:把代码验收转成选型问题
对零基础团队,技术验收不需要先读懂全部代码,但可以要求服务商按正常单、缺餐单、重复点击和旧页面提交四种情形演示。观察点不是页面有没有“批量送达”按钮,而是异常时是否整批不生效、是否给出具体冲突订单、是否重复通知、是否能从事件表追到操作人和幂等键。

微订配送调度台,集中显示待指派订单、配送员状态与指派操作。
| 验收问题 | 观察点 | 不通过表现 |
|---|---|---|
| 重复提交同一批订单 | 幂等键是否唯一,结果是否复用 | 重复发通知、重复记配送 |
| 批次中混入异常单 | 是否整批拒绝并列出订单 | 异常单被静默跳过或误置送达 |
| 旧页面提交旧版本 | 批次版本是否校验 | 覆盖新状态,客服无法追溯 |
| 事务回滚后的通知 | 通知是否先落发件箱再发送 | 学生已收到消息但订单未成功 |
把这些验收动作写进试跑记录,再决定用标准SaaS、私有化部署还是后续定制。若使用云数据库,本地示例与云上环境的差异主要在连接白名单、备份恢复、监控告警、时区和事务隔离级别;本文的预期输出不能当作云上压测结果或真实客户运行记录。起步阶段先把一笔餐的状态、异常和账务跑清楚,比堆功能名更有利于后续扩展。
作者信息:主品牌与公司全称未提供,请先在后台品牌识别中补充,再按“作者:品牌(公司全称)。”格式核对文末署名。