
一家开在河北博物院西侧范光胡同的小店把“老陕北抿节”做成了类似套餐的形式16 元的抿节面默认搭配 15 种小料并且把免费续面当作基础权益。从产品运营角度看这是把“吃面”做成了“规则组合”从系统开发角度看这件事意味着菜单不只是单纯的商品列表而是一组可以配置的 SKU、配料关系和续面规则。如果门店以后要上线扫码点餐、收银结算、会员储值或者外卖单量统计最先要解决的都不是前端页面而是“一碗面到底包含了什么、能续几次、多拿小料怎么计价”这些底层数据问题。下面我会以这家店的一句话宣传语为业务背景从零设计一套简单但可运行的点餐与续面规则模型。技术栈选择 Python 3 SQLite不需要额外安装数据库服务。整个案例会包含表结构、初始化数据、下单、支付、续面校验和常见报错排查读者可以在本地直接跑通再把同样的思路迁移到 MySQL、PostgreSQL 或者其他后端项目里。有一点需要提前说明这里出现的“15种小料明细”“续面次数”都是示例数据用来演示建模思路。真实门店的配料表、续面次数和门店规则应该以门店实际运营要求为准。1. 先理解“16元的抿节面”为什么不能当成普通商品表1.1 表面是套餐底层是“规则组合”传统的商品表一般只有商品编号、名称、价格、库存几个字段。对于一罐饮料、一袋零食这种做法完全够用但面对“16 元抿节面 15 种小料 免费续面”时就不够用了因为这一句话里包含多种事实抿结面是一个基础 SKU基础售价是 1600 分。这个 SKU 默认包含 15 种小料这里的小料不是都拆分单独收费。顾客吃完基础份量后可以免费续面续面次数和间隔规则需要单独记录。如果某些小料当日没有或者小料需要额外加价规则又不一样。所以“16元”不是商品名称的一部分而是某个 SKU 的当前价格“15种小料”不是一句描述而是商品和配料之间的多对多关系“免费续面”更不是备注而是一张需要被程序校验的权益规则。1.2 系统里应该记住哪些业务事实在设计数据表之前最好把需求拆成系统能够处理的事实。以这家店为例可以拆成四类。第一类是基础商品也就是“抿节系列”。它只表示门店在卖这个品类不应直接绑定价格。比如同样是抿节可以衍生出基础款、加牛肉款、大份款未来还能加辣度标签。第二类是具体 SKU。比如“16 元抿节基础套餐”这个可售版本它有确定价格、是否可续面、是否上架。以后门店调价也不应该直接改商品表而是可以给菜单 SKU 增加生效时间段避免历史订单对不上价格。第三类是配料关系。15 种小料需要单独建立一张配料表再用关系表把它们绑定到某个 SKU 下才能回答“这份套餐为什么卖 16 元”和“顾客选择的套餐里有哪些默认配料”。第四类是续面规则。续面免费不等于无限制纵容门店通常会在桌台、排队、出餐效率之间做取舍。所以最好把最大次数、间隔时间、是否仅限本单、是否允许外带等规则独立成表而不是写死在代码里。一个合理的判断标准是如果内容未来可能因为日期、门店、套餐版本而变化就应该把它做成数据如果永远不变写成常量也不会有太大问题。餐饮菜单几乎天天变所以只要出现套餐组合尽量走配置表。1.3 最终的数据主线我会用一条主线来展开菜单表只描述“有什么可卖”SKU 表描述“某个可售版本卖多少钱、能不能续”配料关系表描述“套餐里包含哪些小料”订单与续面记录表描述“顾客买了几份、实际续了几次面”。这样以后不管门店把 16 元改成 18 元还是把小料从 15 种调整为 12 种都只需要调整数据不需要改业务代码。这套思路也可以平移到奶茶店的小料加料、火锅店的蘸料套餐、快餐店的“加量续饭”业务。数据结构比代码更能决定系统的扩展边界这也是为什么很多点餐系统会把菜单做成“商品 规格 加料 规则”的组合。2. 环境准备先跑通最小 Python SQLite 项目2.1 技术选型为什么要用 Python 和 SQLitePython 自带的sqlite3模块不需要额外安装非常适合做数据建模演示。SQLite 是文件型数据库所有数据都存放在一个.db文件里方便本地快速验证。生产环境如果并发量高一般会更建议使用 MySQL 或者 PostgreSQL。两者的表结构可以按同样思路设计只是连接方式、事务隔离级别和部署方式会有差异。本文先解决“规则和逻辑能不能跑通”不追求一上来就搭大型微服务。2.2 版本和环境检查本地建议使用 Python 3.8 以上版本。先打开终端确认版本python --version能正常输出版本号即可。如果电脑上同时安装了 Python 2可能需要使用python3命令python3 --version本示例只使用标准库因此不需要pip install任何第三方包。确认sqlite3模块可用python -c import sqlite3; print(sqlite3.sqlite_version)输出 SQLite 版本号就说明环境正常。接下来创建一个目录用来保存数据库文件、建表脚本和业务代码。mkdir noodle_shop cd noodle_shop2.3 建议的目录结构为了让“结构清晰”而且“可以逐步执行”推荐按下面的方式组织文件noodle_shop/ ├── schema.sql # 建表语句 ├── seed.sql # 准备菜单与小料数据 ├── noodle_shop.py # 核心下单/支付/续面逻辑 └── demo_run.py # 运行演示如果只是在本地练习也可以把建表和初始化数据合并成一个 Python 脚本。但作为可以长期维护的项目建议把 SQL 和业务代码分开这样 DBA 和开发人员都能快速看懂变更点。3. 数据库设计从“商品”到“套餐规则”的拆表过程3.1 核心表拆成 7 张我先给出最终设计的 7 张核心数据表它们是这套点餐规则的基础表名作用关键字段menu_product商品大类比如“抿节系列”id, name, category, enabledmenu_sku可售 SKU比如“16元抿节基础套餐”id, product_id, sku_name, price_cents, refillableingredient小料/配料字典id, name, category, stock_unit, track_stocksku_ingredientSKU 与配料的关联关系id, sku_id, ingredient_id, requiredrefill_rule续面规则id, sku_id, max_refill_times, interval_minutespurchase_order订单主表id, order_no, status, table_no, total_price_centsorder_item订单明细id, order_id, sku_id, quantity, unit_price_cents, line_price_centsrefill_record续面记录id, order_item_id, refill_time商品和 SKU 分表是为了避免把“抿节”和“16元抿节套餐”混在一个字段里。以后门店如果出“18 元加牛肉抿节套餐”不需要修改基础商品表只需要在 SKU 表新增一行。3.2 建表 SQL 与字段解释schema.sql内容如下CREATE TABLE IF NOT EXISTS menu_product ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 面食, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS menu_sku ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL REFERENCES menu_product(id), sku_name TEXT NOT NULL, price_cents INTEGER NOT NULL CHECK (price_cents 0), refillable INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS ingredient ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, stock_unit TEXT NOT NULL DEFAULT 份, track_stock INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS sku_ingredient ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku_id INTEGER NOT NULL REFERENCES menu_sku(id), ingredient_id INTEGER NOT NULL REFERENCES ingredient(id), required INTEGER NOT NULL DEFAULT 1, UNIQUE (sku_id, ingredient_id) ); CREATE TABLE IF NOT EXISTS refill_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku_id INTEGER NOT NULL UNIQUE REFERENCES menu_sku(id), max_refill_times INTEGER, interval_minutes INTEGER NOT NULL DEFAULT 0, only_original_order INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS purchase_order ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT pending, table_no TEXT, total_price_cents INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS order_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL REFERENCES purchase_order(id), sku_id INTEGER NOT NULL REFERENCES menu_sku(id), quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity 0), unit_price_cents INTEGER NOT NULL CHECK (unit_price_cents 0), line_price_cents INTEGER NOT NULL CHECK (line_price_cents 0) ); CREATE TABLE IF NOT EXISTS refill_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_item_id INTEGER NOT NULL REFERENCES order_item(id), operator TEXT, refill_time TEXT NOT NULL DEFAULT (datetime(now)) );这里有两个容易忽略的设计点。第一价格存储的是price_cents单位是“分”不是“元”。这一点的意义会在后面排错部分专门展开。第二refill_rule.max_refill_times使用NULL表示不限次数使用数字表示最多可以续几次。比如NULL表示免费无限续2表示最多续两次。要表示不允许续面可以直接不给该 SKU 插入续面规则或者把menu_sku.refillable设置为 0。3.3 为什么不能把价格直接存成浮点数在数据库里存金额常见错误是使用FLOAT或REAL。由于二进制浮点数无法精确表示所有十进制小数订单总金额、退款金额在特定数字下会出现类似15.999999的结果。虽然展示时可以格式化参与累计、对账时容易出问题。所以这里价格全部使用整数分。Java 里可以用long或者BigDecimalPython 里可以直接用整数字段对外输出时再转成元。如果数据库本身是 MySQL对应字段可以用DECIMAL(10,2)但更稳妥的做法依然是存分或存DECIMAL(12,2)并且应用层用定点数处理。3.4 续面规则为什么单独建表如果只把refillable字段写在菜单 SKU 上只能表达“能不能续面”无法表达“续几次”“间隔多久”“是否限定本桌订单”。把这些维度放进refill_rule表后续要调整“新店允许续两次老店由于出餐压力只允许续一次”时就可以通过不同 SKU 关联不同规则实现。比如SKU16元抿节基础套餐 max_refill_times NULL interval_minutes 15这句话表达的是“基础套餐免费续面不限次数但两次续面之间至少间隔 15 分钟”。间隔分钟主要用于防止顾客在同一分钟内连续点击实际场景里店员仍然需要人工判断桌台情况。4. 初始化菜单塞入产品、SKU 和 15 种小料4.1 初始化商品与 SKU下面用seed.sql准备最基础的菜单数据INSERT INTO menu_product (id, name, category) VALUES (1, 抿节系列, 面食); INSERT INTO menu_sku ( id, product_id, sku_name, price_cents, refillable ) VALUES ( 1, 1, 抿节基础套餐含15种小料, 1600, 1 ); INSERT INTO refill_rule ( sku_id, max_refill_times, interval_minutes, only_original_order ) VALUES ( 1, NULL, 15, 1 );这里把“16 元”翻译成了1600分把“免费续面”翻译成了refillable1max_refill_timesNULL。要注意refill_rule里默认间隔 15 分钟是为了演示不代表门店真是这样规定。4.2 准备 15 种小料和套餐绑定关系配料表的数据来自菜单实际展示。为了让例子贴近“15 种小料”这个约束我用一组常见配料做演示INSERT INTO ingredient (id, name, category, track_stock) VALUES (1, 香醋, 调味, 0), (2, 蒜泥, 调味, 0), (3, 辣椒油, 调味, 0), (4, 芝麻盐, 干料, 0), (5, 酸豆角, 咸菜, 1), (6, 咸菜丁, 咸菜, 1), (7, 萝卜丝, 配菜, 1), (8, 葱碎, 配菜, 0), (9, 香菜, 配菜, 1), (10, 韭花, 咸菜, 0), (11, 干椒碎, 干料, 0), (12, 花生碎, 干料, 1), (13, 黄瓜丝, 配菜, 1), (14, 豆芽, 配菜, 1), (15, 熟芝麻, 干料, 0);前文说过这是演示数据。如果实际门店没有某些配料直接删除对应行即可。track_stock表示是否需要精确扣库存像自助台小料很难做到“每一筷子都扣一次库存”所以很多门店会设置成 0像单独按份出餐的配菜可以设置成 1。下面绑定套餐和 15 种小料的关系INSERT INTO sku_ingredient (sku_id, ingredient_id, required) SELECT 1, id, 1 FROM ingredient ORDER BY id;执行之后sku_id1的抿节基础套餐就关联到了所有启用状态的配料。required1表示这是默认组成不需要顾客额外确认也能上桌。4.3 验证初始化结果在终端执行sqlite3 noodle_shop.db schema.sql sqlite3 noodle_shop.db seed.sql如果没有命令行sqlite3也可以用 Python 的sqlite3模块执行import sqlite3 conn sqlite3.connect(noodle_shop.db) with open(schema.sql, r, encodingutf-8) as f: conn.executescript(f.read()) with open(seed.sql, r, encodingutf-8) as f: conn.executescript(f.read()) conn.commit() conn.close()执行后可以查询SELECT s.id, s.sku_name, s.price_cents, COUNT(si.id) AS ingredient_count FROM menu_sku s LEFT JOIN sku_ingredient si ON si.sku_id s.id WHERE s.id 1 GROUP BY s.id, s.sku_name, s.price_cents;正常结果应该返回一条记录SKU 名称为“抿节基础套餐含15种小料”价格是 1600配料数量是 15。到这里菜单部分已经具备可扩展性。5. 核心代码下单、支付与续面校验5.1 封装数据库连接在noodle_shop.py中先写数据库连接函数import sqlite3 import uuid from datetime import datetime, timedelta DB_FILE noodle_shop.db def connect(db_fileDB_FILE): conn sqlite3.connect(db_file) conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) return conn def generate_order_no(): timestamp datetime.now().strftime(%Y%m%d%H%M%S) return f{timestamp}-{uuid.uuid4().hex[:6].upper()}数据库连接时开启PRAGMA foreign_keys ON非常关键。SQLite 默认不启用外键约束不开启的话即使存在无效的sku_id插入订单明细也不会报错。开启后数据完整性会更有保障。5.2 下单校验 SK