首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >SQLite 预留状态实战:最后一份菜与迟到付款怎样处理

SQLite 预留状态实战:最后一份菜与迟到付款怎样处理

原创
作者头像
用户11635299
修改于 2026-10-19 21:57:40
修改于 2026-10-19 21:57:40
110
举报

堂食与外卖同时销售最后一份菜时,页面显示“有货”并不等于本次下单能成功。更难处理的是:预留已经超时释放,支付成功通知却随后才到。此时既不能忽略真实付款,也不能再次扣走后来顾客买到的那一份。

下面把问题收缩到单菜品预留:用SQLite保存可售量、预留和付款记录,分别验证不足拒绝、超时只释放一次,以及迟到付款进入待退款处置。示例金额统一为每份100分,时刻用整数表示,便于固定边界条件。

1. 先把预留状态与付款事实拆开

对象

本例保存什么

约束

stock

sku与available

可售量不能小于0

hold

数量、状态、deadline

held只能确认或释放一次

payment

渠道付款号、预留、金额和结果

付款号重复时核对原参数

held表示本次仍占用可售量;confirmed表示这份占用已经确认,不再由超时任务释放;released表示占用已经返还。付款成功是一项独立事实,即使预留已经released,也要记录这笔付款并给出后续处置状态。

本例把now < deadline定义为有效期内,now >= deadline定义为到期。付款与清理任务使用相同边界,避免一边认为仍有效、另一边已经释放。这里的refund_pending只是一条待处理结果,没有发起真实退款。

2. 条件释放和归还可售量必须在同一事务内

release_due先尝试把到期且仍为held的记录改为released。只有影响行数为1,才把数量加回stock。重复执行时,状态条件不再满足,因此不会反复增加可售量。pay也调用同一释放逻辑,避免必须等清理任务运行才能发现已过期。

reserve把扣减可售量与建立预留放在同一事务中;即使预留编号重复导致插入失败,扣减也会回滚。下面是完整代码,保存为demo.py运行,不需要第三方包。

代码语言:python
复制
import sqlite3
from contextlib import closing


def reserve(db, hold_id, qty, deadline):
    if type(qty) is not int or qty <= 0:
        raise ValueError("positive integer quantity required")
    with db:
        changed = db.execute(
            "UPDATE stock SET available=available-? "
            "WHERE sku=1 AND available>=?", (qty, qty)
        ).rowcount
        if changed != 1:
            return False
        db.execute("INSERT INTO hold VALUES (?, ?, 'held', ?)",
                   (hold_id, qty, deadline))
    return True


def release_due(db, hold_id, now):
    changed = db.execute(
        "UPDATE hold SET state='released' "
        "WHERE id=? AND state='held' AND deadline<=?",
        (hold_id, now)
    ).rowcount
    if changed:
        qty = db.execute("SELECT qty FROM hold WHERE id=?",
                         (hold_id,)).fetchone()[0]
        db.execute("UPDATE stock SET available=available+? WHERE sku=1",
                   (qty,))
    return bool(changed)


def expire(db, hold_id, now):
    with db:
        return release_due(db, hold_id, now)


def pay(db, payment_id, hold_id, amount_fen, now):
    # Assume signature, merchant and payment status were verified upstream.
    with db:
        previous = db.execute(
            "SELECT hold_id, amount_fen, result FROM payment WHERE id=?",
            (payment_id,)
        ).fetchone()
        if previous:
            if previous[:2] != (hold_id, amount_fen):
                raise ValueError("payment ID payload conflict")
            return previous[2]
        row = db.execute("SELECT qty, state FROM hold WHERE id=?",
                         (hold_id,)).fetchone()
        if row is None or type(amount_fen) is not int or amount_fen != row[0] * 100:
            raise ValueError("unknown hold or amount mismatch")
        if db.execute("SELECT 1 FROM payment WHERE hold_id=?",
                      (hold_id,)).fetchone():
            raise ValueError("another payment needs reconciliation")
        release_due(db, hold_id, now)
        changed = db.execute(
            "UPDATE hold SET state='confirmed' "
            "WHERE id=? AND state='held' AND deadline>?",
            (hold_id, now)
        ).rowcount
        result = "confirmed" if changed else "refund_pending"
        db.execute("INSERT INTO payment VALUES (?, ?, ?, ?)",
                   (payment_id, hold_id, amount_fen, result))
        return result


def main():
    with closing(sqlite3.connect(":memory:")) as db:
        db.executescript("""
        CREATE TABLE stock (sku INTEGER PRIMARY KEY, available INTEGER CHECK(available>=0));
        CREATE TABLE hold (id TEXT PRIMARY KEY, qty INTEGER CHECK(qty>0),
          state TEXT CHECK(state IN ('held','released','confirmed')), deadline INTEGER);
        CREATE TABLE payment (id TEXT PRIMARY KEY, hold_id TEXT UNIQUE,
          amount_fen INTEGER, result TEXT);
        INSERT INTO stock VALUES (1, 1);
        """)
        assert reserve(db, "dine-in", 1, 100)
        assert not reserve(db, "takeout", 1, 100)
        assert expire(db, "dine-in", 100)
        assert not expire(db, "dine-in", 101)
        assert pay(db, "p1", "dine-in", 100, 102) == "refund_pending"
        assert pay(db, "p1", "dine-in", 100, 103) == "refund_pending"
        assert db.execute("SELECT available FROM stock").fetchone()[0] == 1
        assert reserve(db, "takeout-2", 1, 200)
        assert pay(db, "p2", "takeout-2", 100, 150) == "confirmed"
        assert not expire(db, "takeout-2", 201)
        assert db.execute("SELECT available FROM stock").fetchone()[0] == 0
        assert db.execute("SELECT COUNT(*) FROM payment").fetchone()[0] == 2
        print("last portion: second reservation rejected")
        print("expiry release: exactly once")
        print("late payment: refund_pending, stock unchanged")
        print("timely payment: confirmed")
        print("payment records: 2")


if __name__ == "__main__":
    main()

3. 本地运行与实际输出

代码语言:txt
复制
python demo.py

本次使用Python 3.12.10、SQLite 3.49.1执行了上述代码,输出为:

代码语言:txt
复制
last portion: second reservation rejected
expiry release: exactly once
late payment: refund_pending, stock unchanged
timely payment: confirmed
payment records: 2

最后一份先被dine-in占用,另一个预留直接失败。到期释放执行两次,第二次返回False;随后两次p1付款处理都返回同一个refund_pending,可售量仍为1。这个断言同时检查了没有吞掉付款,也没有因为重复回调再次返还份数。

随后新预留在有效期内付款,状态变为confirmed。即使再执行超时清理,可售量仍为0。两次独立付款最终只有两条记录,重复回调没有增加第三条。

4. 接入支付渠道时还缺哪些部分

示例从“付款已经验证成功”开始。真实接口必须先验证签名、商户归属、原订单、金额和渠道状态;未知预留、金额不符或同一预留出现另一付款号,需要保留渠道原始证据并进入对账流程,不能把本例的异常返回当成完整支付处理方案。

数据库事务只能覆盖本库数据。待退款记录还需要可靠任务、渠道幂等编号及结果查询,不能在重试时新建另一笔退款。生产环境还要统一可信时钟,决定过期按收到通知的时刻还是渠道支付时刻裁决,本例选择处理时刻,只用于复现状态边界。

这里测试的是一组固定顺序的状态路径,没有模拟多连接同时付款,也没有测试真实支付、原料库存或加菜出餐。迁移到多菜品时,还需确定整单失败策略和锁定顺序,不能把单菜品事务直接当作跨服务原子性。

作者:微订(上海逊柯计算机科技有限公司)。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 先把预留状态与付款事实拆开
  • 2. 条件释放和归还可售量必须在同一事务内
  • 3. 本地运行与实际输出
  • 4. 接入支付渠道时还缺哪些部分
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档