前阵子帮朋友的小餐馆做了一个扫码点餐系统技术栈就是标题里这套组合FastAPI 提供后端接口HTML 写两个前端页面——顾客扫码用的点餐页和店里用的后台管理系统SQLite3 负责全部数据存储。后台管理系统挂在收银台电脑上打开浏览器就能维护菜品、看订单、更新状态每张桌上贴一个二维码顾客扫开就是点餐页面选菜、加购物车、提交订单一气呵成。整套东西从设计到上线前后用了一个多星期中间踩了不少坑。这篇文章把整个项目的设计思路、数据库结构、核心接口、页面实操、部署细节和典型排错经验完整复盘一遍适合正在做毕设、接私活或者想给自家小店搞一套轻量系统的朋友参考。1. 整体设计思路为什么是 FastAPI HTML SQLite31.1 先厘清这里的“小程序”到底是什么标题里写了“扫码点餐小程序”很多人第一反应是微信小程序。但实际做出来的是 H5 页面也就是一个纯网页顾客用微信、支付宝自带的扫码功能扫桌上的二维码浏览器直接打开点餐页面交互体验和原生小程序非常接近但落地成本低得多。微信小程序需要注册开发者账号、下载开发工具、提交代码审核、过审后才能上线对小餐馆来说这些流程太笨重。而 H5 扫码点餐没有审核环节写完代码放进服务器二维码一生成就能用。老板要改菜价、下架某个菜后台管理系统里改完顾客端刷新就是最新状态。这个方案最大的优势是“即扫即用”不需要顾客下载任何东西也不强制关注公众号。如果以后真的要做微信小程序后端 API 完全可以复用FastAPI 只管提供 JSON 数据前端换成小程序的 WXML 页面就行。所以项目一开始接口层就设计成与前端无关的纯 JSON API这是整个项目最值得保留的架构决策。1.2 技术选型三个理由说服你放弃重框架先说 FastAPI。它比 Flask 多了两个杀手锏一是自动生成 Swagger 接口文档开发调试时打开/docs就能看到所有接口还能直接在页面上测试参数二是基于 Pydantic 做类型校验请求参数对不对、类型对不对框架帮你拦截不用在业务代码里手写一堆 if 判断。对于这种需要快速交付的小项目开发体验非常舒服。再说 SQLite3。很多人一听“SQLite”就觉得是玩具数据库但实际上它比想象中能扛。小餐馆高峰时段同时操作的人数也就二三十SQLite3 完全扛得住。它的最大好处是零运维——不需要单独装数据库服务一个.db文件就是整个数据库备份就是复制文件换机器直接拷贝走。Python 自带的sqlite3模块连驱动都不用装。最后说 HTML。这里的 HTML 不是那种手写静态页面的简单页面而是搭配原生 JavaScript 的轻量单页应用。顾客端和管理端都只需要fetch调用接口、操作 DOM完全不需要 Node.js 构建链不需要 Webpack/Vite服务器上只要有一个进程能托管这些 HTML 文件就行。选型的时候我也想过 Django MySQL、Vue3 前后端分离但冷静下来算了一笔账Django 自带 admin 后台却要配 MySQL 服务器和迁移流程对小项目是杀鸡用牛刀Vue3 打包出来的 dist 文件虽然也是静态的但开发时要起 dev server、配代理、学组件化沟通过程比直接写 HTML 长太多。这套轻量组合的价值不在于技术多前沿而在于把从“需求”到“能跑”的距离压缩到了最短。1.3 系统角色划分顾客端和管理端别混在一起整个系统只有两类使用者需求完全不同代码上必须从第一天就划清边界。顾客端面向的是门外汉操作必须极简进页面看到分类和菜品点“加入购物车”去结算。顾客不需要知道订单怎么流转只需要一个页面展示“已下单”“商家已接单”“可以取餐”的状态。管理端面向的是老板和后厨核心操作是维护菜品、查看订单、改订单状态。后厨要的是一个“大屏看板”式的体验页面自动刷新新订单进来自动置顶点一下“接单”、再点一下“完成”整个制作链路就闭环了。接口设计上同样要分开顾客端接口放在/api/menu、/api/orders下管理端接口全部挂在/api/admin前缀下。管理端接口要做登录校验顾客端接口不做。中间用依赖注入统一实现鉴权后面在代码里会详细演示。2. 数据库设计与接口规划动代码前先把表建好2.1 三张核心表把点餐闭环串起来数据库我只设计了四张表但已经完整覆盖了“分类 - 菜品 - 订单 - 订单明细”这条业务链CREATE TABLE categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sort_order INTEGER DEFAULT 0 ); CREATE TABLE dishes ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, name TEXT NOT NULL, price REAL NOT NULL, image_path TEXT, description TEXT, is_available INTEGER DEFAULT 1, sales_count INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_no TEXT NOT NULL, total_amount REAL NOT NULL, status INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime(now, localtime)), paid_at TEXT ); CREATE TABLE order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER, dish_name TEXT NOT NULL, price REAL NOT NULL, quantity INTEGER NOT NULL );这里有一个关键的细节order_items表里刻意冗余了dish_name和price两个字段。为什么要冗余因为菜品是在变的。老板可能把“宫保鸡丁”改名为“宫廷鸡丁”可能把价格从 28 调到 32甚至可能直接删除某个菜。如果不做冗余历史订单查出来就会显示“菜品已不存在”或者价格对不上。点餐那一刻的快照必须原样保存这是餐饮系统的基本素养也是很多新手做订单系统最容易忽视的问题。订单状态用整数status表示0 待支付、1 已支付、2 制作中、3 已完成、4 已取消。用整数而不是字符串是为了查询和比较效率但代码里不能到处写裸数字。我在项目里定义一个常量类class OrderStatus: UNPAID 0 PAID 1 COOKING 2 DONE 3 CANCELLED 4每个表都带created_at字段后面做今日营业额统计时SQLite 里一句WHERE date(created_at) CURRENT_DATE就能搞定非常省事。2.2 API 接口清单顾客端与管理端分开设计接口清单一开始就规划好前后端都能对着表开发。统一返回结构是{code: 0, msg: ok, data: ...}前端判断code 0就代表成功其他状态码走错误分支。方法路径说明需要登录GET/api/menu获取分类和菜品列表顾客端否POST/api/orders顾客提交订单否GET/api/orders/{order_id}查询订单状态顾客端否POST/api/admin/login管理员登录换取 token否GET/api/admin/dishes菜品列表含停售状态是POST/api/admin/dishes新增菜品是PUT/api/admin/dishes/{dish_id}编辑菜品是DELETE/api/admin/dishes/{dish_id}删除菜品逻辑删除是GET/api/admin/orders查看全部订单是PATCH/api/admin/orders/{order_id}/status更新订单状态是GET/api/admin/statistics/today今日订单数和营业额是管理端接口全是/api/admin前缀后续加权限、加审计日志只需要拦住这个前缀就行。另外/api/menu接口返回的数据我做了预处理把菜品按分类分组前端拿到之后直接[循环渲染]不用再自己groupBy省得前端代码里出现一堆数组处理逻辑。2.3 用 Pydantic 把接口参数卡死FastAPI 最大的优势就是参数校验。我把请求体全部定义成 Pydantic 模型比如创建订单的 Schemafrom pydantic import BaseModel, Field class OrderItemIn(BaseModel): dish_id: int Field(..., gt0) quantity: int Field(..., ge1, le99) class OrderCreate(BaseModel): table_no: str Field(..., min_length1, max_length10) items: list[OrderItemIn] Field(..., min_length1)这段代码的效果是顾客传空数组、桌号为空、数量为 0FastAPI 直接返回 422 错误根本不会进业务函数。以前用 Flask 时这些判断全得手写接口一多就是灾难。这里必须强调一个安全原则订单总金额绝不能信任前端传过来的值。前端可以传total_amount但后端一律无视而是根据items里的dish_id去数据库查真实价格乘数量求和total 0 for item in payload.items: dish get_dish_by_id(item.dish_id) if dish is None or dish[is_available] 0: raise HTTPException(status_code400, detail菜品不存在或已下架) total dish[price] * item.quantity这个逻辑防止有人把接口当成抢单工具改个参数把 100 块的菜变成 0.01 块。价格计算放后端前端永远只展示结果。3. 核心实操从空目录到能跑的完整系统3.1 项目目录结构与启动骨架项目的目录结构如下麻雀虽小五脏俱全order_system/ ├── main.py # FastAPI 应用入口 ├── database.py # SQLite 连接与依赖 ├── schemas.py # Pydantic 请求/响应模型 ├── routers/ │ ├── customer.py # 顾客端接口 │ └── admin.py # 管理端接口 ├── static/ │ ├── customer.html # 顾客扫码点餐页 │ ├── admin.html # 后台管理页 │ └── uploads/ # 菜品图片存储目录 ├── requirements.txt └── db.sqlite3 # 运行时自动生成main.py的组装非常简单关键是把静态目录挂载好from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from routers import customer, admin app FastAPI() app.include_router(customer.router) app.include_router(admin.router) app.mount(/static, StaticFiles(directorystatic), namestatic)这里把 HTML 页面直接放在static目录里访问http://你的域名:8000/static/customer.html?table_no1就能打开顾客端。不过更推荐的做法是把顾客页单独加一个短路由比如/t/{table_no}重定向到customer.html这样二维码内容固定以后换服务器、换端口都不需要重贴桌上的二维码。这是一个我实际用过之后觉得特别值得的小设计。启动项目用uvicorn main:app --reload --port 8000 --host 0.0.0.0--reload只留在开发环境生产环境去掉。3.2 顾客点餐页面扫码、加购、下单三步走顾客页面的交互链路很短扫码打开、选择菜品、提交订单。我重点说一下餐桌号是怎么绑定的。二维码内容是https://域名/t/1后端路由拿到1后重定向到customer.html?table_no1前端通过这行代码取桌号const params new URLSearchParams(location.search); const tableNo params.get(table_no); if (!tableNo) { document.getElementById(main).innerHTML p请扫描桌台二维码点餐/p; return; }如果没带桌号就直接提示用户扫桌台码避免有人直接打开网址订单一来桌号却是空的后厨根本不知道怎么送餐。菜单渲染我用了最简单的“分类按钮 菜品卡片”布局。fetch(/api/menu)拿到按分类分组的数据渲染成 HTML 字符串塞进容器。加购物车用一个 JavaScript 对象做 Map键是菜品 ID值是菜品信息和数量点“去结算”时组装成items数组提交const order await fetch(/api/orders, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({ table_no: tableNo, items: cartItems // [{dish_id: 1, quantity: 2}, ...] }) }); const result await order.json();提交成功后页面显示订单号和大金额同时启动一个setInterval轮询GET /api/orders/{order_id}检查订单状态。当状态变成 2制作中时提示“商家已接单请稍等”变成 3已完成时提示“餐已备好请到前台取餐”。这个轮询机制虽然朴素但顾客体验提升非常大而且完全绕开了 WebSocket 的复杂度。3.3 后台管理页面菜品与订单的管理逻辑后台管理页面我用的是单页结构加 Tab 切换功能分三块菜品管理、订单管理、今日统计。菜品管理最核心的逻辑是列表加载和增删改查。加载列表时把停售菜品也展示出来但前端用一个“上架/下架”按钮来控制is_available。下架操作不是物理删除而是把is_available设为 0顾客端接口WHERE is_available 1自然就过滤掉了。为什么要逻辑删除因为order_items表里的dish_id虽然已经冗余了菜名和价格但删除这张表对应的菜品记录累计销量sales_count就没了后台历史也对不上没必要。订单管理页面是后厨最关心的。加载订单后按状态分组待接单status1的订单置顶且高亮后厨一抬头就能看到。页面上每个订单两三个按钮接单、完成、取消。每次操作调用PATCH /api/admin/orders/{order_id}/status成功回调里重新loadOrders()。这里有个习惯很重要所有增删改操作的 fetch 成功后必须重新加载对应列表不要让用户自己按 F5。管理端的请求要带登录凭证。登录接口返回一个 token 字符串前端localStorage.setItem(token, token)随后每次请求都在头部带上fetch(url, { headers: {Authorization: Bearer localStorage.getItem(token)} });后端的鉴权我用 FastAPI 的依赖注入统一处理async def verify_admin(authorization: str Header(...)): if not authorization or authorization.removeprefix(Bearer ) ! ADMIN_TOKEN: raise HTTPException(status_code401, detail未登录或登录过期)现在这个 token 是写死在配置里的对朋友的店来说够了。真要往生产级走把它换成 JWT也就改一个依赖的事接口层不用动。3.4 图片上传与文件路径的处理细节菜品图片是本项目最容易埋雷的地方。直接用原始文件名保存会出两个问题中文文件名可能在部分浏览器里乱码重名文件会互相覆盖。我改用uuid4生成新文件名import uuid from fastapi import UploadFile app.post(/api/admin/dishes/upload) async def upload_image(file: UploadFile): ext os.path.splitext(file.filename)[-1].lower() if ext not in [.jpg, .jpeg, .png, .webp]: raise HTTPException(status_code400, detail不支持的图片格式) new_name uuid.uuid4().hex ext path fstatic/uploads/{new_name} with open(path, wb) as f: f.write(await file.read()) return {code: 0, data: f/static/uploads/{new_name}}数据库里存的是相对路径/static/uploads/xxx.jpg而不是绝对路径。这样做的好处是前端直接拿这个字段拼到src里就能显示部署时如果换服务器目录也不需要四处改路径。上传时后端必须做两件事扩展名白名单和文件大小限制。我在代码里只保留了扩展名判断但真实使用中建议再加一个大小判断比如读文件后检查len(content)是否超过 2MB超了就拒绝。否则有人传一张 20MB 的照片上来不仅占磁盘空间顾客端加载菜单也会变得非常慢。3.5 部署上线从单进程到 Nginx 托管开发环境里直接uvicorn main:app跑就行但生产环境不能这么裸奔。我用的方案是 systemd 守护进程 Nginx 反代。先写一个 systemd 服务文件/etc/systemd/system/order.service[Unit] DescriptionScan Order System Afternetwork.target [Service] WorkingDirectory/opt/order_system ExecStart/usr/bin/python3 -m uvicorn main:app --host 127.0.0.1 --port 8000 Restartalways Userwww-data [Install] WantedBymulti-user.targetRestartalways保证进程挂掉后自动拉起Userwww-data避免用 root 运行服务。然后 Nginx 配置反代和静态资源分离server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /opt/order_system/static/; } }这里有个性能细节让 Nginx 直接托管/static/目录FastAPI 只处理 API 请求。因为 uvicorn 本质上是应用服务器处理静态文件不是它的强项Nginx 处理静态文件的速度快得多而且自带缓存。如果流量小保持 FastAPI 托管静态文件也能跑但知道这个优化点对未来扩展有帮助。防火墙这一步非常容易卡住新手云服务器除了要在控制台安全组放行端口本机的防火墙也得放行ufw allow 80/tcp、ufw allow 8000/tcp都执行一遍然后再检查端口是否监听。我见过太多项目代码没问题最后全卡在端口被防火墙挡住的。4. 上线前后最容易踩的 5 个坑4.1 SQLite 并发写入database is locked上线第一天高峰时段陆续收到几个“下单失败”的反馈后端日志里躺着sqlite3.OperationalError: database is locked。这个错误的原因很明确SQLite 同一时刻只允许一个写事务。如果连接对象被多个请求共享或者一个连接长期持有不关闭第二个写请求就会排队超过超时时间就直接报错。解决办法有三条缺一不可。第一每个请求都用独立连接用完马上关闭第二创建连接时设timeout10给排队留够缓冲第三开启 WAL 模式让读写互不阻塞import sqlite3 from fastapi import Depends def get_db(): conn sqlite3.connect(db.sqlite3, timeout10) conn.row_factory sqlite3.Row conn.execute(PRAGMA journal_modeWAL;) conn.execute(PRAGMA foreign_keysON;) try: yield conn finally: conn.close()FastAPI 里通过Depends(get_db)注入连接每个请求独立开、独立关既解决了并发问题也避免了连接泄漏。实测高峰时两分钟内 30 单完全无压力。WAL 模式下读写并发、性能都不是问题真正限制 SQLite 的场景是海量数据流写入小餐饮系统根本到不了那个量级。4.2 中文乱码不是数据库的问题是保存和读取的编码没对齐后台管理系统里输入“宫保鸡丁”提交后顾客端显示“鍐滀繚楦′竵”这种乱码第一反应是数据库编码问题但 SQLite 本身存的就是 UTF-8几乎不会出乱码。实际排查顺序应该是先看 HTML 页面有没有声明字符集。!DOCTYPE html html langzh-cn head meta charsetutf-8 ... /head很多从网上抄来的 HTML 模板或者用记事本手工写的页面可能根本没带这一行。没有声明charset浏览器默认按系统编码解析自然就乱了。第二个容易中招的点是手动使用json.dumps返回值时忘了设ensure_asciiFalse。FastAPI 自带的 JSONResponse 默认就是ensure_asciiFalse不会把中文转成\u....但如果你图方便自己return Response(json.dumps(data))中文就会变成 Unicode 转义序列。解决办法就是在json.dumps(data, ensure_asciiFalse)。开发时统一用 UTF-8 编码保存所有文件HTML、Python、SQLite 数据库全部一个编码走天下乱码问题基本可以根除。4.3 扫码页面打不开八成是网络或防火墙手机扫了二维码页面一直转圈——这个现象排查起来其实很固定。如果是在局域网测试先确认手机和服务器连的是同一个 Wi-Fi然后看服务器内网 IPLinux 上用ip addr查看。二维码内容应该是http://192.168.x.x:8000/static/customer.html?table_no1千万不能用localhost或127.0.0.1那是服务器自己访问自己手机根本访问不到。如果是在云服务器上端口放行是两层的云厂商控制台的安全组规则放行对应端口本机防火墙也要放行。我自己的排查顺序是先curl http://localhost:8000/api/menu确认服务正常再curl http://服务器公网IP:8000/api/menu确认端口通不通就查安全组和防火墙。两步定位不会瞎折腾。平时用短链接路由/t/{table_no}重定向到具体页面后二维码内容就稳定了。以后即使服务器 IP 变了只要改短链路由的重定向目标桌上的二维码一次都不用重印营业也不受影响。4.4 SQL 语法惯性把 MySQL 的习惯带到了 SQLite从 MySQL 转过来写 SQLite 的人特别容易在这条上栽跟头。我在做订单统计时很自然地写了UPDATE orders JOIN ... SET ... WHERE ...结果 SQLite 直接报语法错误。SQLite 的 SQL 语法比 MySQL 精简得多不支持UPDATE JOIN、不支持INSERT ... ON DUPLICATE KEY UPDATE很多 MySQL 的特性它一概不认识。解决办法有两种要么把复杂操作拆成两条简单 SQL在一个事务里执行要么在应用层用 Pydantic 处理完数据再入库。还有一个特别隐蔽的坑SQLite 默认关闭外键约束。如果创建表时写了FOREIGN KEY但没在连接后执行PRAGMA foreign_keys ON约束根本不会生效。我头一次建完表也吃了这个暗亏后来才在get_db()里把这个 PRAGMA 加进去。新手写菜品和订单明细的外键关系时一定要记得开启它。4.5 后台操作完不刷新前端的状态管理习惯后台管理系统最初跑起来的时候新增菜品后列表不更新非要手动刷新浏览器才能看到。原因很简单fetch成功后没有重新加载列表数据。所有增删改接口的then回调里统一调用loadDishes()或loadOrders()让页面状态始终跟着数据走。这是一个前端状态管理的原始形态——没有 Vue/React 的状态流但要养成同一个习惯任何操作成功后刷新当前视图。这个习惯养成了后面如果升级到 Vue3 后台管理系统你会发现思路是完全一致的只是从手动调用刷新函数变成了响应式数据自动更新。我把这一节的内容整理成一张速查表方便直接对照排查现象根因快速解决方案下单报 database is locked连接长期占用、并发写锁每请求独立连接、timeout10、开启 WAL中文显示成乱码HTML 缺 charset 声明或 json.dumps 转义页面加meta charsetutf-8ensure_asciiFalse扫码打不开页面端口没放行、用了 localhost、防火墙拦截检查安全组和 ufw二维码用内网/公网 IPUPDATE JOIN 语法报错SQLite 不支持 MySQL 扩展语法拆成多条 SQL在事务中执行后台操作后列表不更新fetch 成功后没重新加载成功回调统一调用 loadXxx()我的习惯是每写完一个功能就把这个功能可能出的问题一并写进项目的 README 里。朋友店里出状况时翻一下速查表就能解决不用半夜打电话找我问“这个报错是什么意思”。跑完这个项目我最想强调的还是选型这件事。FastAPI HTML SQLite3 这套组合在“小餐馆扫码点餐”这个场景下开发效率、部署难度、维护成本几乎是每一项都占优。真遇到需要接微信小程序或者订单量暴涨FastAPI 接口层随时可以升级SQLite 平滑切到 MySQL 或 PostgreSQL 也不需要改前端一行代码。技术没有高低之分能解决问题、维护简单、家人朋友用得顺手就是好系统。最后分享一个实用到不能再实用的小习惯每天凌晨用 crontab 复制一份数据库文件文件名带上日期一个月后回头看这个动作可能已经替你节省了无数补录订单的时间。