校园外卖小程序要支持商家自主上架和接单,落点不是只做一个“新增商品”页和“接单”按钮,而是把商家建品或改品、平台审核、商品快照、下单、通知、接单、出票、缺货退款和结算串成可追踪状态机。单校区团队可先用标准后台或SaaS验证流程,自研则先定数据表、接口幂等和权限边界;有校门交接和楼栋分拣的项目,还要把校区、楼栋、站点作为订单字段固定下来,避免商家出餐状态被误当成已送达。
商家端负责商品维护、订单处理和对账查看,平台端负责审核、规则、校区地址和资金结算。把商家可见范围限制在本店,把店主、接单店员、财务拆成不同角色,再用接口鉴权校验商家归属,能避免跨店查单和越权退款。
角色 | 可见范围 | 可操作 | 验收动作 |
|---|---|---|---|
商家店主 | 本店商品、订单、账单 | 建品、改价、上下架、接单、退款申请 | 改价后历史订单仍保留原价 |
接单店员 | 本店待接与制作中订单 | 接单、拒单、出餐、缺货标记 | 无法查看其他商家账单 |
平台审核员 | 待审商品与资质 | 通过、驳回、下架、到期关闭下单 | 审核记录可追溯到操作人 |
财务 | 本店结算与退款流水 | 对账、提现申请查看 | 完成单数与可提现金额分开 |
商品字段至少包括名称、图片、分类、售价、规格、库存、售卖时段、上下架状态和资质关联。平台审核不要只看一个开关,应把主体资料、商品价格、规格和库存变更拆成审核记录;批量导入后做人工抽检,避免菜单图片识别结果直接把错误价格发布到前台。
自研时最容易漏掉的是历史订单快照。商品价格和规格会变,订单必须保存下单时的商品版本、名称、单价和数量,否则商家改价后无法解释旧订单金额。下面示例依赖 PostgreSQL 14+,用于说明表结构,不是某产品内部实现。
CREATE TABLE product (
id uuid PRIMARY KEY,
merchant_id uuid NOT NULL,
campus_id uuid NOT NULL,
name text NOT NULL,
price_cents integer NOT NULL CHECK (price_cents >= 0),
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0),
status text NOT NULL CHECK (status IN ('DRAFT','PENDING_REVIEW','ON_SALE','OFF_SALE','REJECTED')),
version integer NOT NULL DEFAULT 1,
sale_start timestamptz,
sale_end timestamptz
);
CREATE TABLE product_change (
id uuid PRIMARY KEY,
product_id uuid NOT NULL,
before_json jsonb,
after_json jsonb NOT NULL,
status text NOT NULL CHECK (status IN ('PENDING','APPROVED','REJECTED')),
reviewer_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id uuid PRIMARY KEY,
merchant_id uuid NOT NULL,
campus_id uuid NOT NULL,
building_id uuid NOT NULL,
status text NOT NULL,
accept_deadline timestamptz NOT NULL,
accepted_by uuid,
accepted_at timestamptz,
version integer NOT NULL DEFAULT 1
);
CREATE TABLE order_item_snapshot (
order_id uuid NOT NULL,
product_id uuid NOT NULL,
product_version integer NOT NULL,
name_snapshot text NOT NULL,
price_cents_snapshot integer NOT NULL,
qty integer NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, product_id)
);
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
dedupe_key text NOT NULL UNIQUE,
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','SENT','FAILED')),
retry_count integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE ledger (
id bigserial PRIMARY KEY,
order_id uuid NOT NULL,
party text NOT NULL CHECK (party IN ('PLATFORM','MERCHANT','RIDER','USER')),
entry_type text NOT NULL,
amount_cents integer NOT NULL,
idempotency_key text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);审核路径建议为:商家提交商品变更,平台生成待审记录;审核通过后更新商品状态和版本;下单时读取当前可售版本并写入订单快照。资质到期可配置为关闭对应店铺或商品下单,但关闭原因要进入审核记录,方便商家补材料后恢复。
接单接口必须解决重复点击、并发接单和超时接单。推荐用状态条件更新加版本号,把订单状态从待接单改为已接单,同时写入通知事件;通知或打印失败不应回滚接单,而应由外盒任务重试。示例依赖 Node.js 20、TypeScript 5、Express 4、pg 8、zod 3 和 PostgreSQL 14+。
import express from 'express';
import { Pool } from 'pg';
import { z } from 'zod';
const app = express();
app.use(express.json());
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const bodySchema = z.object({
merchantId: z.string().uuid(),
operatorId: z.string().uuid(),
expectedVersion: z.number().int().positive()
});
app.post('/api/merchant/orders/:orderId/accept', async (req, res) => {
const parsed = bodySchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ code: 'BAD_REQUEST' });
const { merchantId, operatorId, expectedVersion } = parsed.data;
const { orderId } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
const updated = await client.query(
`UPDATE orders
SET status = 'ACCEPTED', accepted_at = now(), accepted_by = $3, version = version + 1
WHERE id = $1 AND merchant_id = $2 AND status = 'PENDING_ACCEPT'
AND version = $4 AND accept_deadline > now()
RETURNING id, version`,
[orderId, merchantId, operatorId, expectedVersion]
);
if (updated.rowCount !== 1) {
await client.query('ROLLBACK');
return res.status(409).json({ code: 'STATE_CONFLICT' });
}
await client.query(
`INSERT INTO outbox (dedupe_key, event_type, payload)
VALUES ($1, 'ORDER_ACCEPTED', jsonb_build_object('orderId', $2::text))
ON CONFLICT (dedupe_key) DO NOTHING`,
[`accept:${orderId}`, orderId]
);
await client.query('COMMIT');
return res.json({ ok: true, version: updated.rows[0].version });
} catch (error) {
await client.query('ROLLBACK');
return res.status(500).json({ code: 'INTERNAL' });
} finally {
client.release();
}
});
app.listen(3000);状态转换可设为:待支付、已支付、待接单、已接单、备餐中、待取餐、配送中、已送达、已完成,另有已取消、退款中、部分退款。只有已支付且未超过接单时限的订单可接;拒单、缺货取消和部分退款分别记录操作人、原因和金额。通知、语音提醒和打印属于事件消费者,接单成功后异步处理,失败进入重试队列。
上架审核要覆盖“新建”和“修改”两类:新建商品审核名称、图片、价格、规格和库存;修改商品审核价格、规格、售卖时段等敏感字段。平台通过后前台才可售,商家不能直接跳过审核改价。订单快照保证历史订单不受后续改价影响。
异常输入 | 预期处理 | 记录项 |
|---|---|---|
重复点击接单 | 第二次请求返回状态冲突,不重复写事件 | 订单号、操作人、版本变化 |
接单超时 | 条件更新失败,订单保持待接或进入取消流程 | 截止时间、失败原因 |
缺货部分退款 | 只退缺货商品,按优惠分摊扣减商家应结 | 退款单、商品行、分摊金额 |
打印失败 | 接单成功,打印事件重试并告警 | 打印机标识、重试次数 |
跨店查单 | 鉴权拒绝,不返回其他商家数据 | 请求账号、目标商家、拒绝原因 |
通知和出票要区分“已发送”“已送达”“已操作”三种结果。打印适配器只接收订单快照和出票模板,不直接读商品当前价格;通知模板写清取餐点、楼栋和预计时间。消息失败可重试,但订单状态仍以数据库条件更新为准。
结算用账本记录用户实付、商品与包装费、配送费、平台抽成、商家应结、骑手收入、优惠承担和退款冲回。每笔账本写入幂等键,避免重复退款或重复结算。下面的查询用于检查订单级金额是否闭合,实际字段需按项目账务规则调整。
SELECT o.id,
o.user_paid_cents - COALESCE(r.refund_cents, 0)
- (o.merchant_payable_cents + o.platform_commission_cents + o.rider_income_cents) AS diff_cents
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(amount_cents) AS refund_cents
FROM ledger
WHERE entry_type = 'REFUND'
GROUP BY order_id
) r ON r.order_id = o.id
WHERE ABS(o.user_paid_cents - COALESCE(r.refund_cents, 0)
- (o.merchant_payable_cents + o.platform_commission_cents + o.rider_income_cents)) > 0;预期结果是正常单差额为0;部分退款、优惠承担方不同或结算后退款时,差额应能由退款规则和调整记录解释。若不能解释,先查优惠分摊、退款冲回和结算批次,再查提现记录。验收时准备一笔正常单、一笔缺货部分退款、一笔商家改价后的旧订单和一次跨店越权访问,记录版本、操作人、状态变化和金额结果。
回到经营判断,验收要回答三个问题:商家能否独立完成建品、改价和接单;校园订单能否按校区、楼栋和交接点分拣;一笔含优惠和退款的订单能否对清商家、骑手和平台三本账。三项都有可复核记录,系统配置才算支撑了商家自助经营。
可先用标准后台或SaaS跑通建品审核、接单、出票和退款流程,把字段清单、权限和异常单写入验收记录;自研时再按本文数据模型拆分。
可以设计为商家提交、平台审核后生效。价格、规格和售卖时段属于敏感字段,历史订单依赖商品快照保留原价,不能只改商品当前表。
不是所有项目都必须。高峰订单密集时自动接单和打印能减少漏单,但设备型号、网络、出票模板和通知渠道要现场核对,打印失败不能影响订单状态落库。
补校区、楼栋、宿舍、校门交接点和站点字段,并让商家出餐、校门核单、楼栋分拣、校内送达分别记录状态。验收时用两个楼栋和一次错分拣测试状态是否可追溯。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。