最近被一个开店的朋友“点菜”了他店里高峰时段服务员要同时顾着写单、传菜、结账经常手忙脚乱点错单、漏单的事隔三差五就发生。市面上的扫码点餐系统倒是不少但年费对一家六张桌子的小馆子来说实在不划算而且多数还捆绑了用不上的营销功能。于是我用晚上和周末的时间给他做了一套基于 FastAPI HTML SQLite3 的扫码点餐 H5 页面和后台管理系统。系统最终长这样顾客坐下扫桌上的二维码手机浏览器里打开点餐页面左侧是菜品分类右侧是菜品列表点菜加入购物车提交后订单实时出现在管理后台前台和厨房在同一台电脑上打开后台点击按钮就能确认订单、切换制作状态还能维护菜品和查看今日营收。全程不需要注册小程序、不需要提交审核、不用装数据库服务器一台普通电脑就能跑。下面我会把整套系统的设计思路、数据库表结构、FastAPI 接口实现、两个前端页面怎么组织、二维码生成方式、SQLite3 并发写入的坑以及最后的部署上线流程全部摊开讲。这篇内容对你有没有用取决于你是不是也遇到过这类问题不想为小店每年交几千块 SaaS 年费、想要一个能跑通的 FastAPI 实战项目、或者单纯想搞明白 SQLite3 在小业务里到底行不行。有的话接着往下看就对了。1. 先想清楚小店扫码点餐到底要解决什么问题1.1 需求拆解看起来是“点餐”实际上是一条订单流水线很多人在动手写扫码点餐之前脑子里只有“做个页面给顾客点菜”这一个画面结果做着做着就发现逻辑一团乱。我的做法是把整个就餐流程列成一张清单再反推系统要支持哪些动作顾客入座扫码打开点餐页面浏览菜品分类与菜品加入购物车提交订单携带桌号前厅或后厨实时看到新订单确认后开始制作制作完成订单标记为“已完成”顾客线下结账老板在后台维护菜品、查看营业数据。对应到系统功能上就拆分成了三块菜单展示分类 菜品、下单流程购物车 订单提交、订单管理状态流转 菜品维护 营收统计。顾客端只需要做三件事看菜单、加购物车、提交订单后台做剩下所有事。把这个边界划清楚写代码就不会在功能上不断“膨胀”。1.2 技术选型为什么偏偏是这一套组合技术选型我向来反对“别人用啥我用啥”。这套系统最终落在 FastAPI HTML SQLite3是围绕“轻量、可维护、成本低”三个词做的决定。FastAPI 作为后端框架我看中的是三点。第一它自带基于 Pydantic 的请求参数校验前端传过来的数据如果不合法会直接返回 422 错误省去大量手写判断第二自动生成 Swagger 文档接口写完立刻能在浏览器里测试第三异步支持好虽然 SQLite 场景下异步收益不大但以后如果要接更多服务底子还在。对比 FlaskFastAPI 的“类型即文档”特性在多接口项目里优势非常明显。HTML 原生 JavaScript 做前端可能不少人觉得“不上 Vue 太原始了”。但回到需求本身顾客扫码打开页面最看重的是首屏速度和低门槛。一套没有构建步骤、直接由 FastAPI 托管静态文件的 H5 页面部署时就是“拷贝文件夹”这么简单完全不需要在服务器上跑 Node 打包。管理后台的使用人数只有一到两个人页面逻辑也有限原生 JS 完全能承载。SQLite3 做数据库是最容易引起争议的选择。很多人第一反应是“这种项目怎么也得用 MySQL 吧”。我的判断标准很简单看并发和运维。一家小店高峰时段同时下单的请求撑死二三十个SQLite3 单文件、零配置备份就是复制文件对小商户来说就是最优解。MySQL 需要单独装服务、管理账号、处理连接池对一个小项目而言是纯负担。等哪天订单量大到 SQLite 扛不住了再迁移到 PostgreSQL 也不迟接口层已经用 SQL 封装好了迁移成本可控。还要澄清一个容易误会的点标题里说的“小程序”在这个项目里指的是 H5 形态的扫码点餐页面不是微信原生小程序。顾客用微信扫码后打开的是一个普通网页不需要安装、不需要跳转小程序、也不需要过审。之所以选 H5是因为原生小程序的上线链路长、开发维护成本和“轻量”目标不符。以后要是真想升级成原生小程序当前的 FastAPI 接口可以原样复用前端页面换成小程序框架就行。下面用一张表对比一下 SQLite3 和 MySQL 在小项目里的真实差异维度SQLite3MySQL安装配置无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, timeout10) conn.row_factory sqlite3.Row try: yield conn finally: conn.close()这个模式的优点很实在每个请求拿到一个干净的连接不会出现跨线程复用timeout10 表示等待锁的最长时间后面讲 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(dbDepends(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, dbDepends(get_db)): if not order.table_no.strip(): raise HTTPException(status_code400, detail桌号不能为空) if not order.items: raise HTTPException(status_code400, 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_code404, detailf菜品 {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_code500, 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, dbDepends(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, dbDepends(get_db)): row db.execute(SELECT id FROM dishes WHERE id ?, (dish_id,)).fetchone() if row is None: raise HTTPException(status_code404, 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, dbDepends(get_db)): if status not in (0, 1, 2, 3): raise HTTPException(status_code400, detail非法状态) row db.execute(SELECT id FROM orders WHERE id ?, (order_id,)).fetchone() if row is None: raise HTTPException(status_code404, 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(dbDepends(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 classpage div classheader h1老王家常菜/h1 span idtableNo桌号1/span /div div classmenu div classcategory-nav idcategoryNav/div div classdish-list iddishList/div /div div classcart-bar span idcartCount0 件/span span idcartTotal¥0.00/span button onclicksubmitOrder()提交订单/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/?table3。二维码本身是固定图片菜单内容的变化全部由服务端页面实时提供二维码不需要重新生成。桌号放在 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(ftable_{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、短事务我的解决办法是三个手段一起上。第一开启 WALWrite-Ahead Logging模式。在数据库连接建立后立即执行PRAGMA journal_modeWAL;。开启后读操作不会被写操作阻塞写操作之间仍然互斥但并发能力比默认模式显著提升def get_db(): conn sqlite3.connect(DB_PATH, timeout10) conn.row_factory sqlite3.Row conn.execute(PRAGMA journal_modeWAL) try: yield conn finally: conn.close()journal_mode 是数据库级设置第一个连接设置了之后后续所有连接都会沿用不需要每次重复设置但我习惯写上保证一致性。第二设置合理的 busy timeout。sqlite3.connect(DB_PATH, timeout10)里的 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_threadTrue跨线程使用直接抛错。即使把 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(directorySTATIC_DIR), namestatic) 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] DescriptionFastAPI Ordering System Afternetwork.target [Service] Userwww-data WorkingDirectory/opt/ordering ExecStart/usr/bin/python3 -m uvicorn main:app --host 0.0.0.0 --port 8000 Restartalways RestartSec3 EnvironmentPYTHONUNBUFFERED1 [Install] WantedBymulti-user.target把文件保存到/etc/systemd/system/ordering.service然后执行sudo systemctl daemon-reload sudo systemctl enable ordering sudo systemctl start ordering sudo systemctl status orderingRestartalways是个保险丝进程被意外杀死后会 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。另外第一批真实使用者的反馈永远比技术设计更重要我第一次给朋友试用时他最不满意的不是功能缺了什么而是“确认制作”按钮的字太小他在厨房里戴着老花镜都看不清。这种教训是文档里学不来的也是做这类小项目最有趣的部分。