Python校园打印店预约系统:防超卖、事务与文件处理 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 负责 ORMFlask-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_namedev): app Flask(__name__) app.config.from_object(fconfig.{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 个 workerMySQL 和主程序同机。这个配置扛一天几千次请求毫无压力因为打印店的量级本来就不大。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_keyTrue) slot_date db.Column(db.Date, nullableFalse, indexTrue) start_time db.Column(db.Time, nullableFalse) end_time db.Column(db.Time, nullableFalse) capacity db.Column(db.Integer, nullableFalse, default10) booked db.Column(db.Integer, nullableFalse, default0) is_closed db.Column(db.Boolean, nullableFalse, defaultFalse) __table_args__ ( db.UniqueConstraint(slot_date, start_time, nameuq_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_keyTrue) paper_type db.Column(db.String(20), defaultA4) # A4 / A3 / 相纸 color db.Column(db.Boolean, defaultFalse) # 黑白 / 彩色 duplex db.Column(db.Boolean, defaultTrue) # 单面 / 双面 unit_price db.Column(db.Numeric(6, 2), nullableFalse) # 每页价格 effective_from db.Column(db.DateTime, nullableFalse) is_active db.Column(db.Boolean, defaultTrue)用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时 1CANCELLED、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], checkTrue, timeout60, stdoutsubprocess.DEVNULL, stderrsubprocess.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里的timeout60一定要加。我遇到过一个 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都读到booked9都判断 9 10 成立都加 1最后booked11超卖。这就是所谓的检查后使用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_iduser_id, slot_idslot_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, bPK\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_okTrue) 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(timezoneAsia/Shanghai) scheduler.add_job(roll_daily_slots, cron, hour1, minute0, idroll_slots) scheduler.add_job(cancel_stale_orders, interval, minutes5, idcancel_stale) scheduler.add_job(clean_files, interval, hours1, idclean_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_noorder_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日志格式我特意用keyvalue因为它既能人读也能用 grep 过滤后期接日志分析工具也方便。别用纯中文句子看着舒服但不好检索。另外要单独记一张操作流水表记录每一次状态变更谁改的、从什么状态改到什么状态、什么时候。这张表在退款纠纷的时候就是证据。我遇到过学生说我明明取消了查流水发现他点的是改约不是取消一目了然。7.3 一个被低估的细节让老板能反悔系统做得再自动也得给人留后门。老板会遇到各种意外打印机卡纸、墨盒没墨、临时有事要关门。如果系统里没有临时关闭时段和批量延后的按钮他就只能硬扛然后骂系统不好用。所以我加了两个功能实现成本很低但使用率极高一是一键关闭时段。老板点一下该时段不再接新单已下单的自动推送一条因故延后请重新选时段的通知并且全额退。二是批量顺延。卡纸的时候后面所有订单统一往后推 15 分钟系统自动改约并通知老板不用一个个处理。这两个功能本质上就是把线下的临时决策搬到线上它不涉及任何复杂技术但它决定了这个系统是帮老板干活还是给老板添活。7.4 后续还能怎么扩展这个项目做完之后我总结了一下它的可延展方向供你参考。第一个是按班级或社团批量下单一个人传文件、填人数系统拆成多份这在期末打印复习资料的时候特别有用。第二个是耗材统计每单记录了纸张类型和页数月底自动出一张本月用了多少张 A4、多少墨老板拿去对成本很方便。第三个是与校园卡余额打通这一步涉及对接通常是学校的信息部门来做不是个人能搞定的但如果你的项目是课程设计把接口设计出来、用模拟数据跑通就已经很加分了。我个人在实际操作中的体会是这个系统的技术难度大概只占三成剩下七成是理解那个站在复印机前面的人在想什么。你把这个想明白了代码怎么写都是对的想不明白代码写得再漂亮也没人用。