最近被一个开店的朋友“点菜”了:他店里高峰时段服务员要同时顾着写单、传菜、结账,经常手忙脚乱,点错单、漏单的事隔三差五就发生。市面上的扫码点餐系统倒是不少,但年费对一家六张桌子的小馆子来说实在不划算,而且多数还捆绑了用不上的营销功能。于是我用晚上和周末的时间,给他做了一套基于 FastAPI + HTML + SQLite3 的扫码点餐 H5 页面和后台管理系统。
系统最终长这样:顾客坐下,扫桌上的二维码,手机浏览器里打开点餐页面,左侧是菜品分类,右侧是菜品列表,点菜加入购物车,提交后订单实时出现在管理后台;前台和厨房在同一台电脑上打开后台,点击按钮就能确认订单、切换制作状态,还能维护菜品和查看今日营收。全程不需要注册小程序、不需要提交审核、不用装数据库服务器,一台普通电脑就能跑。
下面我会把整套系统的设计思路、数据库表结构、FastAPI 接口实现、两个前端页面怎么组织、二维码生成方式、SQLite3 并发写入的坑,以及最后的部署上线流程全部摊开讲。这篇内容对你有没有用,取决于你是不是也遇到过这类问题:不想为小店每年交几千块 SaaS 年费、想要一个能跑通的 FastAPI 实战项目、或者单纯想搞明白 SQLite3 在小业务里到底行不行。有的话,接着往下看就对了。
1. 先想清楚:小店扫码点餐到底要解决什么问题
1.1 需求拆解:看起来是“点餐”,实际上是一条订单流水线
很多人在动手写扫码点餐之前,脑子里只有“做个页面给顾客点菜”这一个画面,结果做着做着就发现逻辑一团乱。我的做法是把整个就餐流程列成一张清单,再反推系统要支持哪些动作:
- 顾客入座,扫码打开点餐页面;
- 浏览菜品分类与菜品,加入购物车;
- 提交订单,携带桌号;
- 前厅或后厨实时看到新订单,确认后开始制作;
- 制作完成,订单标记为“已完成”,顾客线下结账;
- 老板在后台维护菜品、查看营业数据。
对应到系统功能上,就拆分成了三块:菜单展示(分类 + 菜品)、下单流程(购物车 + 订单提交)、订单管理(状态流转 + 菜品维护 + 营收统计)。顾客端只需要做三件事:看菜单、加购物车、提交订单;后台做剩下所有事。把这个边界划清楚,写代码就不会在功能上不断“膨胀”。
1.2 技术选型:为什么偏偏是这一套组合
技术选型我向来反对“别人用啥我用啥”。这套系统最终落在 FastAPI + HTML + SQLite3,是围绕“轻量、可维护、成本低”三个词做的决定。
FastAPI 作为后端框架,我看中的是三点。第一,它自带基于 Pydantic 的请求参数校验,前端传过来的数据如果不合法会直接返回 422 错误,省去大量手写判断;第二,自动生成 Swagger 文档,接口写完立刻能在浏览器里测试;第三,异步支持好,虽然 SQLite 场景下异步收益不大,但以后如果要接更多服务,底子还在。对比 Flask,FastAPI 的“类型即文档”特性在多接口项目里优势非常明显。
HTML + 原生 JavaScript 做前端,可能不少人觉得“不上 Vue 太原始了”。但回到需求本身:顾客扫码打开页面,最看重的是首屏速度和低门槛。一套没有构建步骤、直接由 FastAPI 托管静态文件的 H5 页面,部署时就是“拷贝文件夹”这么简单,完全不需要在服务器上跑 Node 打包。管理后台的使用人数只有一到两个人,页面逻辑也有限,原生 JS 完全能承载。
SQLite3 做数据库,是最容易引起争议的选择。很多人第一反应是“这种项目怎么也得用 MySQL 吧”。我的判断标准很简单:看并发和运维。一家小店高峰时段同时下单的请求撑死二三十个,SQLite3 单文件、零配置,备份就是复制文件,对小商户来说就是最优解。MySQL 需要单独装服务、管理账号、处理连接池,对一个小项目而言是纯负担。等哪天订单量大到 SQLite 扛不住了,再迁移到 PostgreSQL 也不迟,接口层已经用 SQL 封装好了,迁移成本可控。
还要澄清一个容易误会的点:标题里说的“小程序”,在这个项目里指的是 H5 形态的扫码点餐页面,不是微信原生小程序。顾客用微信扫码后,打开的是一个普通网页,不需要安装、不需要跳转小程序、也不需要过审。之所以选 H5,是因为原生小程序的上线链路长、开发维护成本和“轻量”目标不符。以后要是真想升级成原生小程序,当前的 FastAPI 接口可以原样复用,前端页面换成小程序框架就行。
下面用一张表对比一下 SQLite3 和 MySQL 在小项目里的真实差异:
| 维度 | SQLite3 | MySQL |
|---|---|---|
| 安装配置 | 无,Python 自带 | 需装服务端、初始化、账号管理 |
| 单文件备份 | 直接拷贝数据库文件 | 需要 mysqldump 或物理备份 |
| 并发写入 | 单写者,小并发足够 | 支持高并发写 |
| 事务 | 支持,且默认 ACID | 支持,能力强 |
| 运维成本 | 几乎为零 | 需要关注内存、慢查询、连接数 |
| 适合场景 | 单机应用、小店铺 | 多应用共享、高并发、大数据量 |
这套系统面向的就是“单机、小并发、要省心”的场景,所以 SQLite3 不是妥协,而是最合适的答案。
2. 数据库设计:四张表还原整个点餐闭环
2.1 表结构:不要一上来就画 ER 图,先想清楚数据怎么流动
先别急着画花里胡哨的 ER 图。点餐系统的数据流其实很朴素:管理端维护“菜品”,顾客选购菜品生成“订单”,一张订单对应多条“订单明细”。所以四张表就够了:
- categories:菜品分类表,存储分类名称和排序;
- dishes:菜品表,存储名称、价格、图片、所属分类、上下架状态;
- orders:订单表,存储桌号、订单状态、订单总金额、创建时间;
- order_items:订单明细表,存储订单里每一道菜的菜名、单价、数量。
完整建表 SQL 如下,我把它放到项目根目录的 schema.sql 里,用命令sqlite3 ordering.db < schema.sql就能初始化:
-- 菜品分类表 CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sort INTEGER DEFAULT 0 ); -- 菜品表 CREATE TABLE IF NOT EXISTS dishes ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, name TEXT NOT NULL, price_cents INTEGER NOT NULL, -- 价格以"分"为单位存储 image TEXT DEFAULT '', is_available INTEGER DEFAULT 1, sort INTEGER DEFAULT 0, FOREIGN KEY (category_id) REFERENCES categories(id) ); -- 订单表 CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_no TEXT NOT NULL, status INTEGER DEFAULT 0, -- 0待确认 1制作中 2已完成 3已取消 total_cents INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); -- 订单明细表 CREATE TABLE IF NOT EXISTS order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER, dish_name TEXT NOT NULL, price_cents INTEGER NOT NULL, quantity INTEGER NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id) );2.2 几个值得注意的设计细节
第一个细节:订单明细里必须冗余 dish_name 和下单时的 price_cents。这是很多新手容易漏掉的关键点。如果订单明细只存 dish_id,等哪天老板把菜价改了,历史订单的金额就跟着变了,对账时会莫名对不上。冗余字段的本质是“给订单拍一张快照”——不管以后菜单怎么改,这笔订单当时点的什么菜、什么价,永远不变。
第二个细节:金额一律用整数“分”存储。浮点数在计算机里没法精确表示小数,菜单里写 9.9 元,用 float 存完再累加,可能是 9.899999...。订单一旦涉及合计金额,浮点误差会越滚越明显。把价格换算成分,所有计算都是整数运算,显示时再除以 100,清爽又可靠。
第三个细节:订单状态用整数字段,不用字符串。点餐流程的状态是固定的几档:待确认、制作中、已完成、已取消。用 0/1/2/3 这样的小整数存,既省空间,又让代码里的筛选逻辑非常直观(WHERE status = 0查新订单)。页面要做状态文案映射时,在代码里维护一个常量字典就行。
2.3 初始化数据:先把菜单喂进去
建完表之后需要初始化分类和菜品。我写了一个 seed.py,通过 sqlite3 模块插入样例数据,方便开发时调试页面:
import sqlite3 conn = sqlite3.connect("ordering.db") cur = conn.cursor() categories = ["凉菜", "热菜", "主食", "饮品"] for i, name in enumerate(categories): cur.execute("INSERT INTO categories (name, sort) VALUES (?, ?)", (name, i)) dishes = [ (1, "拍黄瓜", 1200), (1, "凉拌木耳", 1500), (2, "宫保鸡丁", 2800), (2, "鱼香肉丝", 3200), (3, "米饭", 200), (3, "面条", 1500), (4, "可乐", 500), (4, "柠檬水", 800), ] for cat_id, name, price_cents in dishes: cur.execute( "INSERT INTO dishes (category_id, name, price_cents) VALUES (?, ?, ?)", (cat_id, name, price_cents), ) conn.commit() conn.close()实际项目里菜品图片一般会放到静态目录或者 CDN,字段里的 image 存一个相对路径,前端直接拼路径就能访问。
3. FastAPI 后端:把菜单和订单做成接口
3.1 项目目录结构:小项目就别硬上大型脚手架
小型项目保持简单反而好维护。我的目录结构是这样的:
ordering/ ├── main.py # FastAPI 入口,注册路由 ├── database.py # 数据库连接管理 ├── schemas.py # Pydantic 模型 ├── schema.sql # 建表 SQL ├── seed.py # 初始化数据 ├── static/ │ ├── index.html # 顾客点餐页 │ ├── admin.html # 管理后台 │ ├── css/ │ │ ├── index.css │ │ └── admin.css │ └── js/ │ ├── index.js │ └── admin.js └── images/ # 菜品图片,也可以并入 static接口全部写在 main.py 里,大概两百多行就能覆盖整条业务链路。以后接口多了,再拆成 routers/customer.py 和 routers/admin.py 也不迟,FastAPI 的 APIRouter 做这个很顺手。
3.2 数据库连接管理:每个请求独立连接
SQLite3 的 connection 对象默认不是线程安全的,多个线程共享同一个连接容易出现数据错乱甚至崩溃。我在 database.py 里用 FastAPI 的依赖注入实现“每请求独立连接,用完即关”:
import sqlite3 from fastapi import Depends DB_PATH = "ordering.db" def get_db(): conn = sqlite3.connect(DB_PATH, timeout=10) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close()这个模式的优点很实在:每个请求拿到一个干净的连接,不会出现跨线程复用;timeout=10 表示等待锁的最长时间,后面讲 SQLite 并发时会细说;row_factory 设置成 sqlite3.Row,查询结果可以用row["name"]按列名取值,转 JSON 非常方便。
3.3 核心接口:顾客端三件事和一个下单事务
顾客端其实只需要三个接口:查分类、查菜品、提交订单。菜单接口我选择一次返回分类 + 菜品,避免前端发多次请求。返回结构是一个数组,每个分类下面挂着它自己的菜品列表:
from fastapi import FastAPI, Depends, HTTPException from pydantic import BaseModel from typing import List app = FastAPI() @app.get("/api/menu") def get_menu(db=Depends(get_db)): categories = db.execute( "SELECT * FROM categories ORDER BY sort, id" ).fetchall() dishes = db.execute( "SELECT * FROM dishes WHERE is_available = 1 ORDER BY sort, id" ).fetchall() result = [] for cat in categories: items = [ { "id": d["id"], "name": d["name"], "price": d["price_cents"], # 前端拿到的是"分" "image": d["image"], } for d in dishes if d["category_id"] == cat["id"] ] result.append({"id": cat["id"], "name": cat["name"], "items": items}) return result提交订单的接口是整个系统里最需要小心的地方。一次下单要同时写 orders 表和 order_items 表,必须放在同一个事务里,要么都成功,要么都失败,不能出现“订单主表写进去了,明细表没写进去”的中间态。实现如下:
class OrderItem(BaseModel): dish_id: int quantity: int class OrderCreate(BaseModel): table_no: str items: List[OrderItem] @app.post("/api/orders") def create_order(order: OrderCreate, db=Depends(get_db)): if not order.table_no.strip(): raise HTTPException(status_code=400, detail="桌号不能为空") if not order.items: raise HTTPException(status_code=400, detail="购物车不能为空") total = 0 dish_info = {} for item in order.items: row = db.execute( "SELECT id, name, price_cents FROM dishes WHERE id = ? AND is_available = 1", (item.dish_id,), ).fetchone() if row is None: raise HTTPException(status_code=404, detail=f"菜品 {item.dish_id} 不存在或已下架") dish_info[item.dish_id] = (row["name"], row["price_cents"]) total += row["price_cents"] * item.quantity try: cur = db.execute( "INSERT INTO orders (table_no, total_cents) VALUES (?, ?)", (order.table_no, total), ) order_id = cur.lastrowid for item in order.items: name, price = dish_info[item.dish_id] db.execute( "INSERT INTO order_items (order_id, dish_id, dish_name, price_cents, quantity) " "VALUES (?, ?, ?, ?, ?)", (order_id, item.dish_id, name, price, item.quantity), ) db.commit() except Exception: db.rollback() raise HTTPException(status_code=500, detail="订单提交失败") return {"order_id": order_id, "total_cents": total}几个值得展开的点:
- 计算 total 时直接查数据库里的 price_cents,绝不相信前端传上来的价格。价格是店铺的资产,前端可能被篡改,所以服务端必须重新查一遍。
- 菜品存在性校验要在创建订单之前做,发现无效菜品直接返回 404,整个下单流程不执行。
- 明细表里的 dish_name 和 price_cents 来自数据库查出的当前值,这就是订单快照。
- 异常时 rollback,保证事务原子性。
3.4 管理端接口:菜品 CRUD 与订单状态流转
管理端的接口相对机械,但几个关键点要写清楚。菜品新增和更新可以用同一个 Pydantic 模型:
class DishCreate(BaseModel): category_id: int name: str price_cents: int image: str = "" sort: int = 0 @app.post("/api/admin/dishes") def add_dish(dish: DishCreate, db=Depends(get_db)): db.execute( "INSERT INTO dishes (category_id, name, price_cents, image, sort) " "VALUES (?, ?, ?, ?, ?)", (dish.category_id, dish.name, dish.price_cents, dish.image, dish.sort), ) db.commit() return {"ok": True} @app.put("/api/admin/dishes/{dish_id}") def update_dish(dish_id: int, dish: DishCreate, db=Depends(get_db)): row = db.execute("SELECT id FROM dishes WHERE id = ?", (dish_id,)).fetchone() if row is None: raise HTTPException(status_code=404, detail="菜品不存在") db.execute( "UPDATE dishes SET category_id = ?, name = ?, price_cents = ?, image = ?, sort = ? " "WHERE id = ?", (dish.category_id, dish.name, dish.price_cents, dish.image, dish.sort, dish_id), ) db.commit() return {"ok": True}上架、下架菜品不需要单独做接口,一个 UPDATE 字段就够了。前端做个开关,把 is_available 传过来即可。
订单状态流转接口是管理端最核心的接口,设计原则是“订单提交后不可修改内容,只能流转状态”:
@app.put("/api/admin/orders/{order_id}/status") def update_order_status(order_id: int, status: int, db=Depends(get_db)): if status not in (0, 1, 2, 3): raise HTTPException(status_code=400, detail="非法状态") row = db.execute("SELECT id FROM orders WHERE id = ?", (order_id,)).fetchone() if row is None: raise HTTPException(status_code=404, detail="订单不存在") db.execute("UPDATE orders SET status = ? WHERE id = ?", (status, order_id)) db.commit() return {"ok": True}点餐是交易行为,对订单内容的任何修改都应该有严格限制。如果确实需要“加菜”或者“退菜”,建议单独做加菜接口和退菜接口,而不是开放编辑订单明细的接口,否则对账时会非常混乱。
营收统计接口也不复杂,统计当天的订单数和总金额:
@app.get("/api/admin/stats") def get_stats(db=Depends(get_db)): row = db.execute( "SELECT COUNT(*) AS order_count, COALESCE(SUM(total_cents), 0) AS revenue " "FROM orders WHERE date(created_at) = date('now', 'localtime') " "AND status != 3" ).fetchone() return { "order_count": row["order_count"], "revenue": row["revenue"], }这里有个细节:在 SQLite 里date('now')返回的是 UTC 时间,而创建订单时用的是datetime('now', 'localtime'),所以统计当天订单一定要在 SQL 里统一用 localtime,否则会出现“今天的单统计到了昨天”的怪事。
4. 前端页面:顾客点餐页与后台管理页怎么组织
4.1 点餐页:移动端优先的 H5 页面
顾客点餐页是纯 HTML + 原生 JS,我把它做成“移动端优先”,因为页面百分之百在手机浏览器里打开。页面结构分三块:顶部是店铺标题和桌号信息,主体是左右两栏(左侧分类导航、右侧菜品列表),底部是悬浮的购物车栏。
核心 HTML 骨架:
<div class="page"> <div class="header"> <h1>老王家常菜</h1> <span id="tableNo">桌号:1</span> </div> <div class="menu"> <div class="category-nav" id="categoryNav"></div> <div class="dish-list" id="dishList"></div> </div> <div class="cart-bar"> <span id="cartCount">0 件</span> <span id="cartTotal">¥0.00</span> <button onclick="submitOrder()">提交订单</button> </div> </div>页面加载时,先从 URL 参数里取出桌号,再请求菜单接口:
const params = new URLSearchParams(location.search); const tableNo = params.get('table') || '1'; document.getElementById('tableNo').textContent = '桌号:' + tableNo; fetch('/api/menu') .then(res => res.json()) .then(data => renderMenu(data)) .catch(err => alert('菜单加载失败,请稍后重试'));渲染分类和菜品时,我选择一次性渲染全部,而不是点击分类再按需加载。原因很简单:菜单数据量不大,小馆子撑死几十道菜,一次渲染完体验最流畅,也不用处理“分类切换时菜品请求状态”这种边界问题。
购物车用一个普通数组维护,每个元素包含 dish_id、name、price_cents、quantity。加菜、减菜都是在数组上做增减,然后同步更新底部购物车栏的件数和金额。提交订单时把数组映射成接口需要的格式:
function submitOrder() { if (cart.length === 0) { alert('购物车是空的'); return; } const payload = { table_no: tableNo, items: cart.map(item => ({ dish_id: item.dish_id, quantity: item.quantity })) }; fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(res => res.json()) .then(data => { alert('下单成功!订单号:' + data.order_id); cart = []; renderCart(); }); }接口返回的金额单位是分,前端展示时统一除以 100 再格式化。这点我在所有涉及金额的地方都用了一个formatMoney(cents)函数,避免漏掉转换导致价格多出一百倍。
特别想提醒一点:提交成功后别急着清空购物车逻辑,先把接口返回的订单号展示给顾客,方便出问题时前台核对。这个小细节能让店铺运营顺畅很多。
4.2 管理后台:一个页面搞定菜品、订单和统计
管理后台同样用单页面实现,顶部用 Tab 切换三个区块:菜品管理、订单管理、今日统计。虽然功能比顾客端多,但也没引入任何框架。
菜品管理区是一张表格,包含菜品名称、分类、价格、上下架状态、操作列。操作列有“编辑”和“上/下架”按钮。新增菜品放在一个简单弹窗里,表单字段就那几个,提交后刷新表格。
订单管理区是整套系统的重头戏,它需要完成两件事:实时感知新订单和快速流转状态。我用了轮询方案,每 5 秒请求一次接口:
function fetchOrders() { fetch('/api/admin/orders') .then(res => res.json()) .then(data => renderOrders(data)) .catch(err => console.error('获取订单失败', err)); } setInterval(fetchOrders, 5000); fetchOrders();轮询是这里最合适的方案,而不是 WebSocket 或者 SSE。理由很务实:单店内网环境、同时在线管理端最多一两个人、订单频率低,高峰期也就几分钟一单,5 秒轮询足够实时,实现成本又几乎为零。如果以后要做多门店实时大屏,再引入 WebSocket 也不迟。
渲染订单时,我按状态分成几列展示。每个订单卡片显示桌号、下单时间、明细列表和总金额,状态按钮把订单推到下一状态。比如“待确认”卡片上有“确认制作”按钮,点击后调用状态接口,把状态改成 1。
今日统计区更简单,页面加载时请求一次 /api/admin/stats,展示订单数和营收。为了不误导老板,我在营收统计里排除了已取消的订单,页面上再加一行小字说明,避免对账时产生误解。
4.3 桌台二维码:不变的内容,变化的入口
扫码点餐最关键的关联点是“哪个桌子扫的码”。我的做法是:每个桌子的二维码只编码两样东西——服务器地址和桌号,形如http://192.168.1.100:8000/?table=3。二维码本身是固定图片,菜单内容的变化全部由服务端页面实时提供,二维码不需要重新生成。
桌号放在 URL 参数而不是路径里,是因为前端用 URLSearchParams 解析一行代码就行,而且和静态文件托管兼容性更好。
生成二维码我用 Python 的 qrcode 库,批量生成每个桌子的二维码:
import qrcode BASE_URL = "http://192.168.1.100:8000/?table=" for i in range(1, 7): url = BASE_URL + str(i) img = qrcode.make(url) img.save(f"table_{i}.png") print(f"桌号 {i} -> {url}")BASE_URL 要根据实际部署环境改。如果是在局域网内,就用服务器的局域网 IP;如果是公网域名,就用完整域名。二维码打印尺寸建议不小于 8cm×8cm,贴在桌面角落或者桌腿内侧,顾客坐下就能扫到。打印前务必自己先扫一遍,确认能正确打开页面。
5. SQLite3 并发写入的“锁”问题与实战解法
5.1 “database is locked”是怎么发生的
把系统跑起来之后,我第一次做压力测试就撞上了 SQLite3 最著名的坑:并发写入时抛database is locked。
先解释一下原因。SQLite 用文件锁控制并发:默认的 journal 模式下,只要有一个连接在执行写事务,其他连接的任何写操作都会被阻塞;更麻烦的是,在某些情况下读操作也会被写阻塞。FastAPI 默认是多线程处理请求的,多个请求同时进来、同时执行 INSERT 时,不会有队列机制去协调它们,后面的写入者拿不到写锁,直接报错。
这个报错在小并发下不容易出现,但一旦你开了多个浏览器标签同时下单、或者管理后台的轮询请求和顾客下单请求撞在一起,就会偶发。对小店铺来说,偶发一次就是一次客诉,不能忍。
5.2 三个组合拳:WAL、timeout、短事务
我的解决办法是三个手段一起上。
第一,开启 WAL(Write-Ahead Logging)模式。在数据库连接建立后立即执行PRAGMA journal_mode=WAL;。开启后,读操作不会被写操作阻塞,写操作之间仍然互斥,但并发能力比默认模式显著提升:
def get_db(): conn = sqlite3.connect(DB_PATH, timeout=10) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") try: yield conn finally: conn.close()journal_mode 是数据库级设置,第一个连接设置了之后,后续所有连接都会沿用,不需要每次重复设置,但我习惯写上,保证一致性。
第二,设置合理的 busy timeout。sqlite3.connect(DB_PATH, timeout=10)里的 timeout 是指当数据库被锁住时,等待多少秒再报错。默认值偏短,调到 10 秒后,偶发锁冲突时连接会等待其他事务完成,而不是立刻失败。
第三,保持事务短平快。事务里只做必要的 INSERT 和 UPDATE,绝不在事务里做耗时操作(比如请求外部接口、下载图片)。事务占用的时间越短,锁冲突的概率越低。我把“查菜品价格、计算总额、插入订单、插入明细、提交”控制在几十毫秒内,就是一个非常健康的事务粒度。
开启 WAL 后,项目目录下会多出ordering.db-wal和ordering.db-shm两个文件,这是正常的。但备份数据库时要注意:直接复制ordering.db可能在 WAL 未合并时丢掉最近的数据。稳妥的做法是用 SQLite 的在线备份命令:
sqlite3 ordering.db ".backup backup.db"或者备份前执行一下PRAGMA wal_checkpoint(TRUNCATE);,把 WAL 内容合并回主数据库文件。
5.3 其他几个实战小坑
除了锁问题,SQLite3 在 FastAPI 里还有几个容易踩的小坑,我一次性列出来:
| 现象 | 原因 | 解法 |
|---|---|---|
| database is locked | 多个连接并发写 | WAL + timeout + 短事务 |
| 查询结果取不到列名 | row_factory 未设置 | 设置 sqlite3.Row |
| 数据库文件找不到 | 运行目录和文件目录不一致 | 基于项目绝对路径拼接 |
生产环境里,如果 uvicorn 的启动目录和数据库文件不在同一目录,sqlite3.connect("ordering.db")会找不到文件。我最后的处理是:在 database.py 里用绝对路径,基于当前文件位置拼接:
import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DB_PATH = os.path.join(BASE_DIR, "ordering.db")还有人图省事在应用启动时建立一个全局连接,所有请求共用。这在多线程下是事故源:sqlite3 连接默认check_same_thread=True,跨线程使用直接抛错。即使把 check_same_thread 关了,并发下的数据一致性也很难保证。不要偷这个懒,坚持每个请求独立连接。
6. 部署上线:一台小主机把整套系统跑起来
6.1 静态文件与 API 同源部署
开发时前端页面可以单独起一个静态文件服务,但上线时我推荐让 FastAPI 同时托管 API 和静态文件,做到“同源部署”。好处是前端访问/api/menu不需要配置跨域,浏览器层面也不会出 CORS 的幺蛾子。同源部署的核心代码:
from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.path.join(BASE_DIR, "static") app = FastAPI() # 先注册 API 路由,再挂载静态文件 # 其他 /api 路由写在这里... app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.get("/") def index(): return FileResponse(os.path.join(STATIC_DIR, "index.html")) @app.get("/admin") def admin(): return FileResponse(os.path.join(STATIC_DIR, "admin.html"))注意挂载顺序:/api/*的路由必须先于 StaticFiles 声明。如果直接把 StaticFiles 挂在/,它会拦截所有请求,导致 API 404。我个人的习惯是 API 挂/api前缀,静态文件挂/static,根路径单独用两个显式路由返回页面,结构最清晰。
6.2 用 systemd 托管 uvicorn 进程
生产环境我不建议直接uvicorn main:app挂在前台,一旦 SSH 断开进程就没了。在 Linux 服务器上,我用 systemd 把服务注册成守护进程,开机自启、异常自动重启:
[Unit] Description=FastAPI Ordering System After=network.target [Service] User=www-data WorkingDirectory=/opt/ordering ExecStart=/usr/bin/python3 -m uvicorn main:app --host 0.0.0.0 --port 8000 Restart=always RestartSec=3 Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target把文件保存到/etc/systemd/system/ordering.service,然后执行:
sudo systemctl daemon-reload sudo systemctl enable ordering sudo systemctl start ordering sudo systemctl status orderingRestart=always是个保险丝,进程被意外杀死后会 3 秒自动拉起,这对没有专职运维的小店来说非常重要。
6.3 局域网访问与外网访问怎么选
如果系统只服务一家店的门店场景,局域网部署就够了。服务器连进店内路由器,--host 0.0.0.0监听所有网卡,店员和顾客通过局域网 IP 访问。192.168.x.x 这种内网地址在店内 Wi-Fi 下非常稳定,也自然避免了公网暴露的风险。
如果老板还想在家里远程看店的营收数据,就需要把服务暴露到更广的网络。常规做法是放在一台有公网 IP 的云主机上,配一个域名,用 Nginx 做反向代理,顺手把 HTTPS 配上。这里想提醒一句:任何形式的公网暴露,都必须先给管理后台加一层访问控制,最简单的方案是在 Nginx 层做 Basic Auth,或者直接在 FastAPI 里对/admin路径做登录校验。管理后台裸奔在公网上,相当于把自家钥匙放在门口垫子下面,哪怕只是看一眼数据,也要随手关门。
数据库备份也别忘了。SQLite 支持在线备份,我写了一个简单的备份脚本,每天凌晨复制一份到另一个目录:
#!/bin/bash cd /opt/ordering /usr/bin/sqlite3 ordering.db ".backup /opt/backups/ordering_$(date +\%Y\%m\%d).db" find /opt/backups -name "ordering_*.db" -mtime +7 -delete配合 crontab 每天执行一次,小店的数据安全就有了兜底。
最后说点个人体会。这套系统从动手到能上线,实际花的时间并不长,真正花时间的反而是“理解业务”这一步。我在做之前和朋友反复确认了他们的点餐流程,才敢动手设计表结构;很多看似简单的功能,比如“订单状态到底有哪几档”“要不要支持扫码抢单”“后台需不需要显示实时排队进度”,都是在聊天里一步步变得清晰的。如果你也想做类似的系统,我的建议是先把手绘的点餐流程理顺,再写代码,不要一上来就琢磨用什么框架、上不上 WebSocket。另外,第一批真实使用者的反馈永远比技术设计更重要,我第一次给朋友试用时,他最不满意的不是功能缺了什么,而是“确认制作”按钮的字太小,他在厨房里戴着老花镜都看不清。这种教训是文档里学不来的,也是做这类小项目最有趣的部分。