☰
Python校园打印店预约系统:防超卖、事务与文件处理
2026/11/2 2:55:19 网站建设 项目流程

1. 先想清楚:校园打印店到底卡在哪个环节

我做过两个类似的小系统,一个是给某高校的实验室做耗材申领,另一个就是这篇要聊的Python校园打印店预约系统。第一次接到这个需求的时候,我脑子里冒出来的第一个念头是"这玩意儿能有多难"——不就是选个时间段、传个文件、付个钱吗?结果真跟店里老板聊完,才发现难点完全不在写代码上。

打印店的真实场景是这样的:早上八点开门,七点五十门口已经站了五六个人,每个人手里攥着U盘或者微信文件传输助手。老板一边问"打几份、单面双面、要不要装订",一边在电脑上找文件、开Word、点打印,一套流程下来三五分钟。赶上毕业季论文装订、期末周复习资料、社团招新海报,队伍能排到门口台阶下面。学生急,老板更急,因为忙中出错率飙升——打错页数、装订反了、文件版本搞混,一天下来光返工就耗掉两三个小时。

Python校园打印店预约系统要解决的就是这个错配:把"人挤在同一时间到店"变成"人按时间段错峰到店",把"到店才说需求"变成"下单时就把需求说清楚"。它不是一个多高深的技术项目,但它特别适合拿来练手,因为它同时踩到了时间片管理、并发控制、文件处理、状态流转这四个后端入门必学的点,而且业务逻辑足够具体,不会写着写着变成空中楼阁。

适合谁看?如果你已经能写出 CRUD,但还停留在"增删改查跑通就算完"的阶段,这个项目能让你第一次认真面对"两个人同时点提交会发生什么";如果你是学生要做课程设计,它的工作量刚好卡在"看起来简单、做扎实了不简单"的区间。下面我按真实落地的顺序拆,从需求边界一直讲到上线后踩的坑。

2. 需求拆解:把"预约"两个字拆成可执行的功能

2.1 三类角色,三套完全不同的诉求

做这类系统最容易犯的错,是只盯着学生这一端设计,最后老板用两天就弃用了。我见过的失败案例里,最典型的是做了一个特别漂亮的学生端小程序,结果打印店这边需要老板手动在后台点"接单",他一忙起来根本顾不上点,订单一积压,学生到店发现没打,投诉就来了。所以角色一定要拆全。

学生端的核心诉求只有三条:知道什么时候去不用等、知道要花多少钱、知道打出来对不对。注意第三条,很多人会忽略——学生最怕的是"我传的文件和你打出来的不一样",所以预约单上必须明确展示文件名、页数、份数、单双面、装订方式,最好还能让他在提交前预览第一页。

店老板端的诉求跟学生完全相反:他要的是"少操作"。任何需要他主动响应、主动确认的设计都是负担。所以我的做法是默认自动接单,只有在文件异常(比如页数识别失败、格式不支持)的时候才需要他介入。老板端界面上最重要的东西不是订单列表,而是"今天各时段已经排了多少量"这一张表,他靠这个决定要不要临时加开一个时段。

管理员端(通常是学校某个部门或者店里的兼职学生)负责三件事:维护价目表、处理退款和改约、导出对账数据。这一端的操作频率很低,所以我不追求界面好看,只追求"改错了能追溯",所有价格变更和退款操作都写操作日志。

把这三类角色画清楚之后,你会发现功能清单自然就出来了,而且哪些该做、哪些不该做一目了然。

2.2 功能边界:哪些坚决不做

新手做项目最大的诱惑是"功能越多越好",最后做了一堆半成品。这个系统我明确划了几条线,写在这里供你参考。

功能做不做理由
在线支付不做,只做余额/线下涉及资金合规和退款对账,学生项目扛不住,用"取件时付"或"预充值余额"更现实
文件在线预览全文只做首页缩略渲染成本高,且涉及文件隐私,首页缩略足够确认版本
自动排版纠错不做排版是学生自己的责任,系统只负责"原样打印",纠错反而背锅
排队叫号大屏可选店里就一台电脑,做个"当前叫号"的小窗口更实用
改约做,但限次数允许改约两次,防止有人反复占坑
取消退费做,按时间阶梯开始前2小时全额退,之后退一半,超时不到店不退

这张表的价值在于:它替你挡掉了 80% 的"临时加需求"。老板会说"能不能加个会员卡",同学会说"能不能加个论文查重",你一句"这版不做,下一版再说"就有了依据。

2.3 三个非功能指标,比功能更重要

需求文档里最容易被忽略、上线后最容易翻车的,是这三个指标。

第一是时段容量。一个时段能接多少单,不能拍脑袋。我按老板的实际产能算:一台复印机一分钟大概出 20 页单面,一个 30 分钟的时段,扣掉装订和找文件的时间,实际有效产能大约是 300 到 400 页。如果平均一单 30 页,那时段容量就是 10 到 13 单。这个数要写成配置项,因为不同店、不同设备差别很大。我一开始设成 20 单,结果第一个高峰期直接堵死,老板在店里骂人。

第二是防超卖。这是这个项目里唯一真正的技术难点。两个学生同时看到"还剩 1 个名额",同时点提交,如果处理不好就会都成功,导致时段超载。后面第 5 节会专门讲怎么用一条带条件的 UPDATE 语句解决。

第三是文件生命周期。学生的文件传到服务器上,打完就该删。我定的是"取件后 24 小时删除,未取件 72 小时删除",并且删除动作要有日志。这不是技术问题,是责任问题——万一文件泄露,你得能说清楚什么时候删的、谁删的。

3. 技术选型:别为了炫技给自己挖坑

3.1 后端框架:Flask 够用,但别选错扩展

这个项目的接口数量大概在 15 到 20 个之间,没有复杂的权限体系,没有多租户,没有微服务拆分需求。这种量级下,Flask 是最优解,因为它的学习曲线几乎为零,你可以在半天内把路由、模板、数据库连接全部跑通,剩下时间全花在业务逻辑上。

有人会说 Django 自带 Admin,后台不用写。这个说法对一半。Django Admin 确实能省掉老板端的一部分页面,但打印店老板需要的界面是"今天各时段负载表"和"一键标记已取件",这两个东西 Admin 里做起来反而别扭,你还得去改 Admin 的模板。所以我的判断是:如果你完全没接触过 Django,选 Flask;如果你已经熟悉 Django,那就用 Django,别折腾。

Flask 这边我用的组合是 Flask + SQLAlchemy + Flask-Migrate + APScheduler。SQLAlchemy 负责 ORM,Flask-Migrate 管表结构变更(这个很重要,你一定会改表),APScheduler 跑定时任务。模板引擎用 Jinja2 直接渲染,前端不搞 SPA,因为整个系统就十来个页面,上 Vue 或 React 属于用大炮打蚊子,而且会让部署复杂度翻倍。

# app.py 的最小骨架,注意配置分离 from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate db = SQLAlchemy() migrate = Migrate() def create_app(config_name="dev"): app = Flask(__name__) app.config.from_object(f"config.{config_name.capitalize()}Config") db.init_app(app) migrate.init_app(app, db) from routes.order import order_bp from routes.admin import admin_bp app.register_blueprint(order_bp, url_prefix="/api/order") app.register_blueprint(admin_bp, url_prefix="/api/admin") return app

这段代码没什么技术含量,但我特意写成工厂模式,原因是后期一定会遇到"本地开发用 SQLite、线上用 MySQL"的情况,配置分离能让你少改很多地方。

3.2 数据库:SQLite 起步,MySQL 兜底

这是我要重点说的一段,因为很多人会在这里踩坑。

开发阶段用 SQLite 完全没问题,一个文件、零配置、随手就能备份。但 SQLite 有一个致命特性:写操作是串行的,整个库一把锁。这意味着你的防超卖逻辑在 SQLite 上"天然安全",因为并发写根本进不来,会直接报database is locked。你在本地测的时候觉得一切正常,一上线上高并发场景,要么报错,要么响应慢到用户以为卡死。

所以我的建议是:开发用 SQLite,上线前一定切 MySQL(或 PostgreSQL),并且切换之后必须重新做一遍并发测试。这两者的锁行为完全不同,SQLite 靠文件锁,MySQL InnoDB 靠行锁,你的代码在两边表现可能不一样。

切换成本其实很低,SQLAlchemy 屏蔽了大部分差异,你只需要注意两点:一是字段类型,SQLite 没有严格的类型约束,MySQL 有,VARCHAR长度一定要给够;二是时间字段,SQLite 存字符串,MySQL 有DATETIME,注意时区处理(后面会细说)。

# config.py import os class BaseConfig: SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-key") SQLALCHEMY_TRACK_MODIFICATIONS = False MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 单文件 50MB 上限 UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "./uploads") class DevConfig(BaseConfig): SQLALCHEMY_DATABASE_URI = "sqlite:///print_dev.db" class ProdConfig(BaseConfig): SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] SQLALCHEMY_ENGINE_OPTIONS = { "pool_size": 10, "pool_recycle": 1800, "pool_pre_ping": True, }

pool_pre_ping这个参数看着不起眼,但它能救你的命。MySQL 默认 8 小时断开空闲连接,如果你的服务一晚上没人用,第二天第一个请求就会拿到一个失效连接然后报错。加上这个参数,每次取连接前先 ping 一下,自动重连。

3.3 文件存储与前端:能用就行,别过度设计

文件存储我直接用本地磁盘,路径按年/月/日/随机UUID.扩展名组织。为什么不接对象存储?因为一个打印店一天撑死几百个文件,本地磁盘完全够,而且本地读文件快、不要钱、不依赖网络。什么时候该换?当你的文件目录单层超过一万个文件、或者需要多台机器共享存储的时候。这个项目大概率永远到不了那个量级。

前端用 Jinja2 模板 + 一点点原生 JS,不用任何构建工具。上传文件用FormData走 fetch,轮询查订单状态用setInterval,就这么朴素。我特别不建议在这个项目里上前后端分离,因为你一个人写两边,光是联调和跨域就能耗掉你一半时间,而这些东西对你理解"预约系统"这个业务本身毫无帮助。

3.4 部署形态:一台低配机器绰绰有余

线上我用的是一台 2 核 4G 的机器,Nginx 做反向代理,Gunicorn 起 4 个 worker,MySQL 和主程序同机。这个配置扛一天几千次请求毫无压力,因为打印店的量级本来就不大。

Gunicorn 的 worker 数量有个经验公式:(2 × CPU 核数) + 1。2 核就是 5 个,但我一般给 4 个,留点余量给系统进程。

gunicorn -w 4 -b 127.0.0.1:8000 --timeout 60 --access-logfile logs/access.log "app:create_app('prod')"

--timeout 60要留意,默认是 30 秒。文件上传和 PDF 页数解析可能超过 30 秒,尤其是几十兆的扫描件。设 60 秒比较稳。

4. 数据模型:把"预约"这件事建模清楚

4.1 四张核心表,一张不多一张不少

我见过有人把表设计成十几张,结果自己都理不清关系。这个系统的核心其实只有四张表:用户表、时段表、订单表、价目表。

用户表很简单,id / username / password_hash / role / balance / created_at。role用枚举值区分学生、老板、管理员。密码用werkzeug.security里的哈希函数,别自己造轮子。

时段表是核心中的核心。设计上有个关键选择:是按"时间片"建固定记录,还是按"日期 + 时段"动态生成?我选前者,理由是这样可以用数据库的唯一约束来兜底。每天凌晨跑一个定时任务,把未来 7 天的时段记录批量插进去,每个时段一条,带capacity和booked两个字段。

class TimeSlot(db.Model): __tablename__ = "time_slot" id = db.Column(db.Integer, primary_key=True) slot_date = db.Column(db.Date, nullable=False, index=True) start_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False) capacity = db.Column(db.Integer, nullable=False, default=10) booked = db.Column(db.Integer, nullable=False, default=0) is_closed = db.Column(db.Boolean, nullable=False, default=False) __table_args__ = ( db.UniqueConstraint("slot_date", "start_time", name="uq_slot"), db.Index("idx_date_start", "slot_date", "start_time"), )

booked字段看起来是冗余的,因为它等于订单表里该时段的有效订单数。但我坚持把它冗余出来,因为防超卖需要一条原子更新语句,而 COUNT 出来的数字没法原子更新。这就是典型的"用空间换一致性",是值得的。

订单表要包含的信息多,我列一下关键字段:订单号、用户 ID、时段 ID、文件路径、原始文件名、页数、份数、单双面、装订方式、金额、状态、取件码、创建时间、取件时间。其中订单号不要用自增 ID,要用"日期 + 6 位随机数",因为自增 ID 会暴露你的业务量,而且容易被遍历。

价目表我单独拆出来,因为价格一定会变,硬编码在代码里是自找麻烦。

class PriceRule(db.Model): __tablename__ = "price_rule" id = db.Column(db.Integer, primary_key=True) paper_type = db.Column(db.String(20), default="A4") # A4 / A3 / 相纸 color = db.Column(db.Boolean, default=False) # 黑白 / 彩色 duplex = db.Column(db.Boolean, default=True) # 单面 / 双面 unit_price = db.Column(db.Numeric(6, 2), nullable=False) # 每页价格 effective_from = db.Column(db.DateTime, nullable=False) is_active = db.Column(db.Boolean, default=True)

用Numeric(6,2)而不是Float,是因为金额算错一分钱都会有人来找你。浮点数加着加着就出现0.30000000000000004,虽然看着无所谓,但对账的时候会很难受。

4.2 订单状态机:把每个状态想明白再写代码

状态机是这个项目里最值得花时间的地方。我最初只设计了三个状态:待打印、已完成、已取消。上线三天就出问题了——有学生下单后从来没来过,订单永远挂在"待打印",导致时段一直显示占用。

后来我改成了六状态机,每个状态的流转条件都写死:

状态含义可流转到触发条件
PENDING待支付/待确认PAID、CANCELLED用户支付成功 / 超时 15 分钟自动取消
PAID已支付待打印PRINTING、REFUNDING老板点击开始打印 / 用户取消且满足退费规则
PRINTING打印中READY老板标记完成
READY待取件PICKED、EXPIRED用户取件扫码 / 超 72 小时未取
PICKED已取件终态-
CANCELLED/EXPIRED已取消 / 已过期终态-

这里有个细节:状态的每一次变更都要同时更新时段表的booked计数。PAID时 +1,CANCELLED、REFUNDING、EXPIRED时 -1。这个加减必须和状态变更在同一个事务里,否则会出现"订单取消了但名额没释放"的情况。我在测试环境里模拟过一次断网,正好卡在这两步之间,结果那个时段永久少了 1 个名额,只能手动改数据库。

4.3 计价规则:算清楚,别让老板亏钱

计价这部分看着简单,实际上藏着一堆坑。最基本的公式是:

金额 = 页数 × 份数 × 单页价 × 双面系数 + 装订费 + 加急费

但现实情况比这复杂。比如双面打印,10 页纸双面出来是 5 张纸,价格应该按"张"算还是按"面"算?店里的习惯是按"面"报价,但成本按"张"算,所以双面通常给个折扣系数,我用的是 0.9。再比如彩色打印,如果一页里只有一小块是彩色的,按彩色算还是黑白算?这个系统不管,统一按用户选择的算,但要提前在界面上写清楚。

还有一个坑:页数怎么算。用户上传一个 Word 文档,你不能靠文件大小估页数。我的做法是分格式处理:PDF 用pypdf直接读页数,准得很;Word 和 PPT 先用 LibreOffice headless 转成 PDF,再读页数。

import subprocess, os from pypdf import PdfReader def convert_to_pdf(src_path: str, out_dir: str) -> str: """Word/PPT/Excel 转 PDF,返回转换后的路径""" subprocess.run( ["soffice", "--headless", "--convert-to", "pdf", "--outdir", out_dir, src_path], check=True, timeout=60, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) base = os.path.splitext(os.path.basename(src_path))[0] return os.path.join(out_dir, base + ".pdf") def count_pages(pdf_path: str) -> int: with open(pdf_path, "rb") as f: return len(PdfReader(f).pages)

subprocess.run里的timeout=60一定要加。我遇到过一个 Word 文档,里面有损坏的图片对象,LibreOffice 转的时候卡死,整个进程挂在那儿不返回,把 Gunicorn 的 worker 占满,服务直接不可用。加了超时之后,转换失败就降级成"让老板手动确认页数",虽然体验差一点,但服务活着。

还有一点要提醒:转换后的 PDF 才是真正要打印的文件,原始 Word 只是一个中间产物。因为店里电脑装的 Office 版本可能跟学生的不一样,直接打 Word 会跑版。所以我在流程里固定了"上传 → 转 PDF → 用 PDF 打印"这条链路,虽然让用户多等几秒,但能避免大量返工。

5. 核心功能实现:从下单到取件的完整链路

5.1 时段预约与防超卖:一条 UPDATE 解决核心问题

这是整个项目里我花时间最多的地方。先说错误的做法,你可能也这么写过:

# 错误示范:先查后写,经典的竞态条件 slot = TimeSlot.query.get(slot_id) if slot.booked < slot.capacity: slot.booked += 1 db.session.commit() create_order(...)

这段代码在单用户测试下完美,一并发就崩。两个请求同时执行query.get,都读到booked=9,都判断 9 < 10 成立,都加 1,最后booked=11,超卖。这就是所谓的"检查后使用"(TOCTOU)问题。

正确的做法是把"检查"和"更新"合并成一条 SQL,让数据库来保证原子性:

from sqlalchemy import text def try_book_slot(slot_id: int) -> bool: """原子地占用一个名额,成功返回 True""" result = db.session.execute( text(""" UPDATE time_slot SET booked = booked + 1 WHERE id = :slot_id AND booked < capacity AND is_closed = 0 """), {"slot_id": slot_id}, ) return result.rowcount == 1

关键的判断在于result.rowcount。如果更新成功,受影响行数是 1;如果条件不满足(名额满了或者时段关闭),受影响行数是 0。整个过程由数据库的行锁保证,两个并发请求会排队执行,第二个进来的会发现booked < capacity已经不成立,直接返回 0。

调用方的写法也要配套:

def create_order(user_id, slot_id, file_info): ok = try_book_slot(slot_id) if not ok: db.session.rollback() raise BizError("该时段已约满,请换一个时段") try: order = Order(user_id=user_id, slot_id=slot_id, **file_info) db.session.add(order) db.session.commit() return order except Exception: db.session.rollback() # 订单失败必须回滚名额 raise

注意except里的回滚。如果占名额成功但插订单失败(比如唯一约束冲突),必须把名额还回去,否则就会出现"名额被占着但没有订单"的幽灵占用。这个回滚和前面的 UPDATE 必须属于同一个事务,Flask-SQLAlchemy 默认的 session 行为是满足这一点的,但前提是你不能在中间手动commit。

有一个细节我踩过:SQLite 上的rowcount行为。SQLite 在某些驱动版本下对UPDATE的rowcount返回的是"匹配行数"而不是"实际修改行数",虽然在这个场景下两者一致,但如果你写成SET booked = booked + 0就会出问题。所以我建议在本地用 SQLite 开发时也顺手跑一遍并发脚本,确认行为符合预期。

至于测试并发,用concurrent.futures开 20 个线程同时打同一个接口,看最后booked是不是等于capacity,多跑几轮。这个脚本我留在仓库里,每次改完预约逻辑就重跑一遍。

5.2 文件上传:安全校验比功能更重要

文件上传这块,功能两小时能写完,安全校验得写一整天。我把校验拆成四层。

第一层是大小限制,靠 Flask 的MAX_CONTENT_LENGTH,超了直接返回 413,不用进业务逻辑。

第二层是扩展名白名单,只允许pdf / doc / docx / ppt / pptx / xls / xlsx / jpg / png。但光看扩展名不够,因为有人会把.exe改成.pdf。所以还要读文件头:

MAGIC = { b"%PDF": "pdf", b"PK\x03\x04": "zip", # docx/pptx/xlsx 本质是 zip b"\xff\xd8\xff": "jpg", b"\x89PNG": "png", } def sniff_type(head: bytes) -> str | None: for magic, name in MAGIC.items(): if head.startswith(magic): return name return None

读前 4 个字节就够了,成本几乎为零。zip那一项要特殊处理,因为 docx、pptx、xlsx 都是 zip 包,需要进一步看内部结构才能区分,但至少要确认它不是个伪装的可执行文件。

第三层是文件名处理。永远不要用用户上传的原始文件名去存盘。原始文件名里可能有/、..、空格、中文、超长字符,任何一个都能让你出问题。我的做法是存盘用 UUID,原始文件名只存在数据库里用于展示,并且在展示时做 HTML 转义。

import uuid def save_upload(file_storage) -> tuple[str, str]: ext = os.path.splitext(file_storage.filename)[1].lower() if ext not in ALLOWED_EXT: raise BizError("不支持的文件格式") rel_dir = datetime.now().strftime("%Y/%m/%d") abs_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], rel_dir) os.makedirs(abs_dir, exist_ok=True) name = f"{uuid.uuid4().hex}{ext}" path = os.path.join(abs_dir, name) file_storage.save(path) return os.path.join(rel_dir, name), file_storage.filename

第四层是内容检查,这一层不是必须的,但如果你的服务面向全校,建议加。至少要确认 PDF 能被正常解析、页数能读出来、文件不是加密的。加密 PDF 打不开,用户提交了也是白提交。

注意:上传目录绝对不能在 Web 根目录下。我见过有人把uploads放在static里,结果所有人的文件都能被直接 URL 访问到,等于把学生的资料全公开了。正确的做法是文件存在 Web 根目录之外,下载走一个鉴权的接口。

5.3 取件码与叫号:小功能里的大学问

取件码是六位数字,看起来简单,但要考虑三件事:不能重复、不能太好猜、要好念。

不能重复好理解,同一时间不能有两个有效订单用同一个码。不能太好猜是指别用自增或者时间戳后六位,那样别人能推出来。要好念是指别出现000123这种,老板念的时候容易漏。

我的做法是在有效订单集合里循环生成,直到不冲突:

import random def gen_pickup_code() -> str: for _ in range(20): code = f"{random.randint(100000, 999999)}" exists = Order.query.filter( Order.pickup_code == code, Order.status.in_([OrderStatus.PAID, OrderStatus.PRINTING, OrderStatus.READY]), ).first() if not exists: return code raise BizError("取件码生成失败,请重试")

这里靠的是"有效订单集合"这个限定。已完成的订单码可以复用,因为没人会拿一个三个月前的码来取件。但数据库层面不能加唯一约束,否则复用就会冲突。这个取舍要想清楚:唯一性靠应用层保证,数据库只加普通索引。

叫号这块,我在店里那台电脑上做了一个常驻的小窗口,显示"当前可取件:382916、471203",每 10 秒轮询一次。老板打完一单,在后台点一下"完成",前端就能看到。这个功能技术上毫无难度,但老板特别喜欢,因为他不用再翻订单列表找谁是谁。

5.4 定时任务:三个必须自动化的动作

手动维护的定时任务,最后一定会忘。所以我把它全部交给 APScheduler,跟着应用一起启动。

第一个任务是生成时段。每天凌晨 1 点,把第 8 天的时段记录插进去,保持"未来 7 天可约"的滚动窗口。为什么要提前 7 天而不是无限?因为时段数量会无限膨胀,而且太远的预约意义不大——学生自己都不知道下周有没有空。

第二个任务是清理超时未支付订单。每 5 分钟扫一次,PENDING超过 15 分钟的直接置为CANCELLED并回滚名额。这个间隔不能太长,否则名额被占着不放;也不能太短,数据库压力大。

第三个任务是文件清理。每小时扫一次,删除PICKED超过 24 小时、EXPIRED超过 72 小时的文件。注意是删文件,不删订单记录,订单要留着对账。

from apscheduler.schedulers.background import BackgroundScheduler scheduler = BackgroundScheduler(timezone="Asia/Shanghai") scheduler.add_job(roll_daily_slots, "cron", hour=1, minute=0, id="roll_slots") scheduler.add_job(cancel_stale_orders, "interval", minutes=5, id="cancel_stale") scheduler.add_job(clean_files, "interval", hours=1, id="clean_files")

timezone这个参数必须显式指定。APScheduler 默认用系统时区,如果你的服务器是 UTC、业务是北京时间,凌晨 1 点的任务会在早上 9 点跑,那就热闹了。

另外,定时任务在多 worker 部署下会重复执行。4 个 Gunicorn worker,每个都启动一个 scheduler,任务就跑 4 遍。解决办法有两个:一是用--preload配合只在主进程启动 scheduler,二是加数据库锁(用一个job_lock表,执行前尝试插入唯一记录)。我用的是第二种,因为它更可靠,也方便排查"任务到底跑没跑"。

6. 常见问题与排查实录:那些文档里不会写的东西

6.1 时间处理:最容易翻车的地方

时间问题我在这个项目里踩了三次,每一次都很难查。

第一次是时区。数据库里存的是 UTC,页面上直接展示,结果所有订单时间都比实际早 8 小时。学生看到"预约时间 01:00"直接懵了。解决办法是:存储统一用 UTC,展示时统一转成东八区,转换只在一个地方做(Jinja2 的过滤器),绝不在业务代码里到处+8。

第二次是datetime.now()和datetime.utcnow()的混用。代码里有的地方用now(),有的用utcnow(),两者差 8 小时,导致"超时 15 分钟取消"的任务,把刚下单的订单给取消了。这个 bug 在测试环境复现不了,因为测试环境的服务器时区就是东八区,两者看起来一样。上线后才暴露。

从 Python 3.12 开始,utcnow()被标记为废弃,正确做法是datetime.now(timezone.utc)。我在项目里统一封装了一个now_utc()函数,所有地方都调它,杜绝混用。

第三次是日期边界。比如"未来 7 天可约",如果按date.today() + 7算,遇到跨月、闰年都没问题,但如果服务器时区不是东八区,date.today()可能和店里实际的"今天"差一天。所以我把"当前日期"也统一从配置的时区取。

实操心得:凡是涉及时间的项目,第一件事就是把时区策略写进 README,明确"存储用什么、展示用什么、定时任务按什么时区"。这三件事说清楚了,后面能省掉一半的沟通成本。

6.2 页数算错:用户投诉最多的类型

页数算错是这个系统上线后投诉最多的问题,没有之一。原因主要有三种。

第一种是隐藏页。Word 文档里有一些空白页或者被隐藏的内容,转成 PDF 后页数比用户以为的多。这个没法完全避免,我的处理是在下单页面明确写一句"以转换后的 PDF 页数为准,下单前请预览",并且提供删除指定页的功能。

第二种是纸张尺寸不一致。A4 和 A3 混在一个 PDF 里,用户按 A4 下单,结果有一页是 A3,打印出来要么缩小要么裁切。我的做法是在页面转换后读每一页的尺寸,如果发现不一致就在订单上打个标记,提醒老板注意。

第三种是份数理解偏差。用户填"份数 2",以为是"打印 2 页",实际是"整个文件打 2 份"。这种纯属沟通问题,靠界面文案解决:输入框旁边写清楚"每份包含 N 页,共打印 N×2 页"。

现象可能原因排查方法处理方式
页数比预期多隐藏页、空白页转 PDF 后逐页看尺寸和内容提供删页功能,界面提示预览
页数比预期少转换超时被截断查转换日志和 PDF 页数提示重新上传,或降级手动确认
打印尺寸不对混用 A4/A3读 PDF 每页 MediaBox订单标记提醒,线下确认
文件打不开加密 PDFPdfReader.is_encrypted上传阶段直接拒绝

6.3 并发与锁:那些"偶发"的怪问题

除了超卖,还有一个偶发问题很折腾人:订单状态偶尔会"卡住",明明老板点了完成,页面上还是"打印中"。

查了半天,原因是状态更新和booked计数更新分成了两次commit。第一次提交成功,第二次因为某个约束冲突失败,结果状态回滚了但计数没回滚,或者反过来。这种问题在低并发下几乎不出现,高峰期一秒钟几十个请求就开始冒。

解决办法只有一条:任何涉及多个表写入的操作,必须在同一个事务里。Flask-SQLAlchemy 的 session 默认是"提交即结束",所以你要做的就是把所有db.session.commit()集中到业务函数的最后一行,中间任何异常都走rollback()。

def finish_order(order_no: str): with db.session.begin_nested(): order = Order.query.filter_by(order_no=order_no).with_for_update().first() if order.status != OrderStatus.PRINTING: raise BizError("订单状态不允许此操作") order.status = OrderStatus.READY order.printed_at = now_utc() db.session.commit()

with_for_update()是 MySQL 的行锁语法,SELECT ... FOR UPDATE,保证你在读和写之间别人插不进来。SQLite 不支持这个语法,会直接报错,所以这行代码只能在 MySQL 上跑——这也是为什么我一直强调上线前必须切库重测。

6.4 常见问题速查表

下面这张表是我整理的高频问题,基本覆盖了大部分人做这个项目会遇到的坑。

问题典型表现根因解决
数据库锁定database is lockedSQLite 并发写切 MySQL,或降低并发
名额不释放时段显示满但没订单预留与订单不在同一事务合并事务,异常时回滚
上传 413大文件直接被拒MAX_CONTENT_LENGTH太小调到 50MB 并按需分片
中文乱码文件名显示成问号编码不一致统一 UTF-8,展示时转义
转换卡死上传后一直转圈LibreOffice 进程无超时subprocess加timeout
定时任务重复同一订单被取消两次多 worker 各跑一份加数据库锁或--preload
取件码冲突两个订单同码复用未加限定只在有效订单里查重
时间差 8 小时显示时间对不上本地时间与 UTC 混用统一封装时间函数
内存持续上涨跑几天后 OOM文件流未关闭用with open或显式 close
静态资源 404样式全丢Nginx 路径配置检查location /static别名

7. 上线之后:从"能跑"到"老板愿意用"的距离

7.1 灰度上线比一次全量靠谱

第一版我是直接全量上线的,结果第一天就出状况——老板不熟悉界面,把"开始打印"点成了"取消订单",连着取消了五单。虽然能恢复,但他当天就不想用了。

第二版我改成灰度:先在店里贴一张纸,写"本周试运行,线上下单可享 9 折",让学生自愿用。老板那边我只开放了"查看今日时段负载"这一个功能,订单还是他手动记。等他习惯了看这张表,再逐步开放接单、标记完成。整个过程花了三周,但老板的接受度高多了。

这个经验我想说的是:这类面向小商家的系统,最大的阻力不是技术,是习惯。你功能做得再全,他不点,就是零。

7.2 日志要记到"能复盘"的程度

日志这块我的原则是:出了问题,不用登服务器就能查清楚。所以每个关键动作都记一条结构化日志,包含订单号、用户 ID、操作类型、耗时、结果。

import logging, time logger = logging.getLogger("print.order") def timed_action(action: str, order_no: str, func, *args, **kwargs): start = time.monotonic() try: result = func(*args, **kwargs) logger.info("action=%s order=%s cost=%.3fs ok", action, order_no, time.monotonic() - start) return result except Exception as e: logger.exception("action=%s order=%s cost=%.3fs err=%s", action, order_no, time.monotonic() - start, e) raise

日志格式我特意用key=value,因为它既能人读,也能用 grep 过滤,后期接日志分析工具也方便。别用纯中文句子,看着舒服但不好检索。

另外要单独记一张操作流水表,记录每一次状态变更:谁改的、从什么状态改到什么状态、什么时候。这张表在退款纠纷的时候就是证据。我遇到过学生说"我明明取消了",查流水发现他点的是"改约",不是"取消",一目了然。

7.3 一个被低估的细节:让老板能"反悔"

系统做得再自动,也得给人留后门。老板会遇到各种意外:打印机卡纸、墨盒没墨、临时有事要关门。如果系统里没有"临时关闭时段"和"批量延后"的按钮,他就只能硬扛,然后骂系统不好用。

所以我加了两个功能,实现成本很低,但使用率极高:

一是一键关闭时段。老板点一下,该时段不再接新单,已下单的自动推送一条"因故延后,请重新选时段"的通知,并且全额退。

二是批量顺延。卡纸的时候,后面所有订单统一往后推 15 分钟,系统自动改约并通知,老板不用一个个处理。

这两个功能本质上就是"把线下的临时决策搬到线上",它不涉及任何复杂技术,但它决定了这个系统是"帮老板干活"还是"给老板添活"。

7.4 后续还能怎么扩展

这个项目做完之后,我总结了一下它的可延展方向,供你参考。第一个是按班级或社团批量下单,一个人传文件、填人数,系统拆成多份,这在期末打印复习资料的时候特别有用。第二个是耗材统计,每单记录了纸张类型和页数,月底自动出一张"本月用了多少张 A4、多少墨",老板拿去对成本很方便。第三个是与校园卡余额打通,这一步涉及对接,通常是学校的信息部门来做,不是个人能搞定的,但如果你的项目是课程设计,把接口设计出来、用模拟数据跑通,就已经很加分了。

我个人在实际操作中的体会是:这个系统的技术难度大概只占三成,剩下七成是"理解那个站在复印机前面的人在想什么"。你把这个想明白了,代码怎么写都是对的;想不明白,代码写得再漂亮,也没人用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询