零基础校园外卖平台起步:批量送达幂等与校门分拣状态机

简介: 零基础校园外卖应先小范围试跑校门交接、楼栋分拣与退款对账,再选系统。本文以批量送达误置异常单为例,给出订单状态机、批次幂等约束、MySQL DDL与Go事务,并列出正常输出和异常反例。

零基础做校园外卖平台,建议先完成学校政策摸底,再用少量商家和人工方式试跑校门交接、楼栋分拣、送达通知与退款对账;当人工接单开始漏单、对账变麻烦时,再采购系统。技术选型不要只看“支持中转分拣”的功能名,而要用一笔餐和异常单验收订单状态、批次操作与幂等处理。下面从午高峰一个具体工程故障切入:分拣员少收一份餐,仍点击“批量送达”,系统把未交接订单错误置为已送达,导致学生没收到餐、客服重复退款、对账无法追溯。

一、起步顺序:先定义闭环,再谈系统

校园外卖的起步不是先买一套大系统,而是先确认学校允许校外餐入校的范围、能否设中转点、宿舍送到哪里,再用一个校区或几栋楼试跑。试跑阶段至少记录五类信息:商家出餐时间、校门交接人、楼栋分拣结果、校内骑手、异常处理结果。人工台账能跑通,再把这些字段固化为系统约束。

阶段 主要产出 技术验收点
政策与需求摸底 校门通行规则、楼栋地址表、首批商家清单 地址能否按校区、楼栋、站点分层
小范围试跑 正常单、缺餐单、退款单的处理记录 状态能否从下单走到送达且不回退
系统采购 一笔餐演示、异常反例演示、费用拆项 批量操作是否幂等,异常单是否隔离

只有试跑暴露出人工接单的漏单、错单和对账问题,系统采购才有明确验收对象。标准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、私有化部署还是后续定制。若使用云数据库,本地示例与云上环境的差异主要在连接白名单、备份恢复、监控告警、时区和事务隔离级别;本文的预期输出不能当作云上压测结果或真实客户运行记录。起步阶段先把一笔餐的状态、异常和账务跑清楚,比堆功能名更有利于后续扩展。

作者信息:主品牌与公司全称未提供,请先在后台品牌识别中补充,再按“作者:品牌(公司全称)。”格式核对文末署名。

相关文章
|
12天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
4927 33
|
13天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
4570 52
|
7天前
|
Linux 开发工具 iOS开发
2026最新codex下载安装保姆级使用教程(下载+安装+登录+使用,含所有历史版本)
本文详解OpenAI Codex国内快速上手指南:提供Win/macOS/Linux全平台免安装包、npm/Homebrew等多方式安装、账号/API Key双登录、简体中文切换,覆盖下载、安装、汉化、使用及高频避坑(如微软商店失败、npm超时等),10分钟即可跑通。
1635 3
|
13天前
|
iOS开发 Docker MacOS
【2026实测】Docker Desktop下载、安装、汉化、配置教程(附官网安装包)
Docker Desktop 是 Docker 官方推出的桌面容器管理工具,集成引擎、CLI、图形界面与 Kubernetes,支持 Win/macOS/Linux。2026 最新版 4.88.1 强化 ARM 原生运行、镜像加速及资源可视化,附中文汉化与国内镜像配置指南。
2196 7
|
5天前
|
人工智能 安全 API
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(dsh)是DeepSeek AI于2026年开源的轻量级Agent运行框架,基于Cordis内核,践行“一切皆插件”理念。支持Web/桌面双端,开箱即用;内置多模型接入、丰富插件生态(超3.5万),适配编程、办公、创作等场景。(239字)
1083 0
|
7天前
|
云安全 人工智能 安全
|
19小时前
|
云安全 人工智能 安全
|
6天前
|
人工智能 并行计算 调度
本地 AI 一键部署工具 LocModel:Windows 端集成画图、视频生成、Agent 的开源模型运行方案
LocModel 是专为 Windows 设计的一站式本地 AI 工具,支持零配置启动、自动硬件检测、GGUF 模型一键加载、文生图/视频生成及 Agent 自动化任务。适配 8G 显存,内置显存分片调度,轻松实现 AI 漫剧批量创作与离线大模型调用。(239 字)

热门文章

最新文章