我在办公室遇到过一个特别实际的需求:打印机只有一台,连在一台旧主机上,同事要打印 PDF 的时候,得先把文件发到微信再登录那台电脑,或者在 U 盘里拷来拷去,最后跑到打印机边上操作。来回折腾几次之后,我决定给那台"打印机主机"写一个 Web 服务,让它变成一台真正的 PDF 打印服务器:前端用 HTML+CSS+JS 做一个简洁的网页,后端用 FastAPI 处理上传、调度和调用系统打印命令,最终把 PDF 送到连接在服务器上的物理打印机。
这个项目的核心思路并不复杂:网页负责收文件,FastAPI 负责把文件安全地落到服务器磁盘,然后调用操作系统的打印命令,把文件交给打印机驱动。难点和坑点全在"调用系统打印"这一层。这篇文章就把我完整实现这个打印服务器的过程拆开讲清楚,包括技术选型、接口设计、命令调用、常见踩坑和上线后的修复记录,适合正在做内部工具、熟悉 Python 但没碰过打印相关开发的读者参考。
1. 这个打印服务器到底要解决什么问题
1.1 传统共享打印的三个麻烦
很多人第一反应是:Windows 不是自带打印机共享吗?直接在"设备和打印机"里右键共享,局域网里的电脑添加网络打印机不就行了?理论上是这样,但实际用起来有三个绕不开的麻烦。
第一,共享打印机依赖"主机"开着,而且每台要打印的电脑都得先安装同一个驱动。访客电脑、临时来开会的人、换了新笔记本的同事,每次都要重新配驱动,一旦驱动版本和系统不兼容,共享打印机列表里就会出现一个黄色感叹号,怎么删都删不干净。
第二,浏览器直接打印网页或者 PDF 时,页边距和缩放经常是乱的。同一份 PDF,在 A 电脑上打出来正常,在 B 电脑上打出来就缺边少角。原因是各家浏览器的打印预览和 PDF 渲染内核不一样,这个东西很难在客户端统一。
第三,打印机物理位置固定,但人不会固定坐在打印机旁边。手机里收到一份 PDF,想打印就得先传到电脑再操作,体验非常割裂。
1.2 换成 Web 打印服务器之后,链路变成了什么样
在传统的共享打印方案里,客户端电脑要和打印机驱动打交道;在 Web 打印服务器方案里,所有和驱动相关的脏活累活全部集中到服务器上,客户端只需要一个浏览器。
整个流程是这样的:
- 用户在浏览器打开打印服务器页面。
- 拖拽一个 PDF 文件到网页,填写打印份数、是否双面等选项。
- 前端用 fetch 把 PDF 和选项打包成 FormData 发送到 FastAPI 后端。
- FastAPI 校验 PDF 文件,保存到服务器磁盘,把打印任务加入队列。
- 后端调用操作系统打印命令,把 PDF 文件交给打印机驱动。
- 打印机输出纸质文件,前端轮询接口拿到打印结果。
这样做的好处是:客户端彻底零安装、零驱动,所有和打印机相关的配置只需要在服务器上做一次。浏览器、手机、访客电脑,只要能访问到这个 Web 页面,就能直接打印。
1.3 我最终实现的页面和功能
我的前端页面没有做成复杂的后台管理系统,只保留了三个核心区域:拖拽上传区、文件列表区、打印状态区。用户进来以后,不需要看说明文档就能完成操作。
后端 FastAPI 提供了五个核心接口:
| 接口 | 方法 | 作用 |
|---|---|---|
/api/upload | POST | 接收 PDF,校验并保存,返回 file_id |
/api/print | POST | 根据 file_id 和打印选项发起打印任务 |
/api/status/{file_id} | GET | 查询某个任务的打印状态 |
/api/history | GET | 查看所有打印记录 |
/api/printers | GET | 获取服务器上可用的打印机列表 |
2. Web 服务驱动物理打印机的原理拆解
2.1 想清楚一件事:后端并没有"直接"和打印机通信
第一次做这个项目的时候,我有一个误区,以为 FastAPI 要直接通过某种打印协议去连接打印机。查了一圈发现完全不需要把事情搞复杂。
操作系统装完打印机驱动之后,打印机在系统里就变成了一个"打印机对象"。用户平时打印文件,是应用程序把这个文件发给操作系统,操作系统再把数据交给打印机驱动去处理。Web 后端要做的,仅仅是把 PDF 文件"当作已经打开的文件一样"交给系统去打印。
换句话说,打印服务器的核心开发工作不是"连接打印机",而是"找一个可靠的方式,在命令行里触发系统打印"。
2.2 Windows 下:为什么选 SumatraPDF 作为打印工具
在 Windows 服务器上,最简单粗暴的命令是print /D:"打印机名" 文件.pdf。但这个命令有个致命问题:它本质上是把文件内容当作纯文本逐行发送到打印机,对于 PDF 这种复杂格式,打印出来要么是乱码,要么只有零星几行字符。
想要在命令行里打印 PDF,常用的方案有这么几个:
| 工具 | 命令形式 | 特点 |
|---|---|---|
| SumatraPDF | SumatraPDF.exe -print-to "打印机名" -silent 文件.pdf | 免费、轻量、支持静默打印 |
| Adobe Acrobat | Acrobat.exe /t 文件.pdf "打印机名" | 商业软件,稳定但贵 |
| Ghostscript + gsprint | gsprint.exe -printer "打印机名" 文件.pdf | 免费,适合批量 Ghostscript 转换后的 PS 文件 |
我最后选了 SumatraPDF。原因很实际:它体积只有几 MB,绿色版直接解压就能用,命令行参数稳定,-print-to指定打印机,-silent不弹任何窗口,这对服务器后台运行非常关键。
需要注意的是,把 SumatraPDF 放在一个没有空格、没有中文的路径下,比如C:\print_tools\SumatraPDF.exe。这不是洁癖,而是后面用 subprocess 调用时,可以减少大量引号和转义的麻烦。
2.3 Linux 下:CUPS 和 lp 命令是标准答案
如果打印服务器跑在 Linux 上(比如一台 Ubuntu 服务器),那情况比 Windows 简单得多。Linux 的打印体系由 CUPS(Common UNIX Printing System)统一管理,命令行工具是lp或者lpr。
添加打印机之后,直接执行:
lp -d "HP_LaserJet" /data/uploads/job_001.pdf打印份数和双面通过参数控制:
lp -d "HP_LaserJet" -n 2 -o sides=two-sided-long-edge /data/uploads/job_001.pdfCUPS 的生态比 Windows 干净太多,命令就是命令,没有花里胡哨的弹窗问题。如果你的服务器系统是 Linux,我强烈建议优先考虑lp,这套接口我做下来基本没遇到什么玄学问题。
2.4 打印的假脱机机制:接口返回不代表打印完成
有一个概念必须提前建立:调用打印命令之后,接口立刻返回"成功",并不代表打印机已经吐纸了。操作系统有一个"打印假脱机"(Print Spooler)服务,它把打印任务放进队列,打印机驱动按照队列顺序慢慢消费。
所以后端在调用系统打印命令时,subprocess返回码为 0,只说明"系统任务列表已经成功接收这个任务",不代表纸张已经出来。这就是为什么我需要设计任务状态轮询,而不是用户点了打印按钮之后,前端就傻傻等待接口返回。
在任务状态设计上,我划分了四个状态:
| 状态 | 含义 |
|---|---|
pending | 任务已接收,等待打印 |
printing | 正在调用打印命令 |
completed | 系统已接收打印任务 |
failed | 命令执行出错 |
注意,completed的语义是"系统已接收,假脱机队列正在处理",不是"纸质文件已经出来"。这一点要跟业务方讲清楚,否则用户会觉得状态显示有问题。
3. FastAPI 后端:接口设计与任务状态管理
3.1 依赖安装与目录结构
后端我用的是 Python 3.11 + FastAPI。依赖只需要装三个:
pip install fastapi uvicorn python-multipartpython-multipart是必须的,FastAPI 处理文件上传(multipart/form-data)依赖它。少了这个库,一接收文件就直接报 500。
我的项目目录结构是:
print_server/ ├── main.py # FastAPI 入口 ├── print_utils.py # 打印命令封装 ├── task_store.py # 任务状态管理 ├── uploads/ # 上传的 PDF 临时存放 └── static/ ├── index.html ├── style.css └── main.js3.2 上传 PDF 接口:校验和落盘
上传接口是整条链路的入口,必须做两层校验:文件类型校验和文件内容校验。
from fastapi import FastAPI, UploadFile, File, Form, HTTPException from pathlib import Path import uuid app = FastAPI() UPLOAD_DIR = Path("uploads") UPLOAD_DIR.mkdir(exist_ok=True) @app.post("/api/upload") async def upload_pdf(file: UploadFile = File(...)): # 第一层:后缀名校验 if not file.filename.lower().endswith(".pdf"): raise HTTPException(status_code=400, detail="只支持 PDF 文件") # 生成唯一任务 ID,保存时统一用 job_id.pdf,避免中文文件名问题 job_id = str(uuid.uuid4()) save_path = UPLOAD_DIR / f"{job_id}.pdf" # 第二层:内容校验,用 pypdf 读取页数,无法打开的文件直接拒绝 content = await file.read() try: from pypdf import PdfReader import io reader = PdfReader(io.BytesIO(content)) if len(reader.pages) == 0: raise HTTPException(status_code=400, detail="PDF 没有有效页") except Exception: save_path.unlink(missing_ok=True) raise HTTPException(status_code=400, detail="PDF 文件损坏或已加密") save_path.write_bytes(content) return {"file_id": job_id, "filename": file.filename, "pages": len(reader.pages)}这里有个很关键的细节:我把上传的文件统一重命名为job_id.pdf,彻底抛弃原始文件名。
理由有两个:第一,中文文件名在 Windows 命令行里经常出现编码问题,后面调用打印命令时十次有八次坑在这个地方;第二,用户上次传的"项目计划书.pdf"和这次传的"项目计划书.pdf"重名时,如果按原名保存,文件覆盖和任务追查都会变得混乱。
用uuid就一劳永逸了,所有任务都是独立文件,日志里只用file_id关联,绝对不会有命名冲突。
3.3 打印接口:用同步 def 还是异步 def?
真正调用系统打印命令的接口,写法上有讲究。
@app.post("/api/print") async def start_print( file_id: str = Form(...), printer_name: str = Form(None), copies: int = Form(1), duplex: bool = Form(False), ): file_path = UPLOAD_DIR / f"{file_id}.pdf" if not file_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") return await run_print_task(file_path, printer_name, copies, duplex)注意,我没有在start_print这个异步函数里直接执行subprocess.run(),因为这会阻塞 FastAPI 的事件循环。一个打印任务可能耗时几秒甚至几十秒,如果直接同步执行,服务器处理其他请求的能力会大幅下降。
我的做法是把subprocess调用放到线程池里:
import asyncio import subprocess async def run_print_task(file_path, printer_name, copies, duplex): loop = asyncio.get_running_loop() result = await loop.run_in_executor( None, # 使用默认线程池 lambda: subprocess.run( build_print_command(file_path, printer_name, copies, duplex), capture_output=True, timeout=60, ), ) return handle_print_result(result)build_print_command根据服务器操作系统返回不同的命令列表。
在 Windows 上:
def build_print_command(file_path, printer_name, copies, duplex): sumatra = r"C:\print_tools\SumatraPDF.exe" cmd = [sumatra, "-print-to", printer_name, "-silent", str(file_path)] # 构造份数和双面参数 settings = [] if copies and copies > 1: settings.append(f"{copies}x") if duplex: settings.append("duplex") if settings: cmd.append("-print-settings") cmd.append(",".join(settings)) return cmd在 Linux 上:
def build_print_command(file_path, printer_name, copies, duplex): cmd = ["lp", "-d", printer_name, "-n", str(copies)] if duplex: cmd.append("-o") cmd.append("sides=two-sided-long-edge") cmd.append(str(file_path)) return cmd命令构造完毕之后,用subprocess.run(cmd, capture_output=True, timeout=60)去执行。这里我在传参的时候直接传了命令列表,没有加shell=True。这很重要,shell=True在路径含空格时会引入命令行注入风险,而且引号层的转义会非常折磨人。
3.4 任务状态管理:一份 JSON 文件就够了
打印服务器的业务复杂度不高,不需要为一个内部工具引入 MySQL 或者 Redis。我用一个task_store.py管理任务状态,所有数据保存在服务器本地 JSON 文件里。
import json import threading from pathlib import Path from datetime import datetime STORE_FILE = Path("tasks.json") _lock = threading.Lock() def init_store(): if not STORE_FILE.exists(): STORE_FILE.write_text(json.dumps({})) def update_task(file_id: str, status: str, message: str = ""): with _lock: data = json.loads(STORE_FILE.read_text(encoding="utf-8")) task = data.setdefault(file_id, {}) task["status"] = status task["message"] = message task["updated_at"] = datetime.now().isoformat() STORE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")打印的每个阶段都调用update_task更新状态。JSON 文件虽然简单,但已经能支撑整个打印服务器的状态查询需求。如果以后任务量上来了,再平滑迁移到 SQLite 也不迟。
3.5 并发控制:同一台打印机不能同时打两份文件
打印任务和普通 Web 请求不一样,打印机驱动对并发异常敏感。如果两个请求同时调用SumatraPDF -print-to 打印机名,轻则第二个任务卡在队列里出不来,重则直接把打印假脱机服务搞崩。
我在整个应用里加了一个全局打印锁:
from asyncio import Lock print_lock = Lock() @app.post("/api/print") async def start_print(...): async with print_lock: return await run_print_task(...)这样所有打印请求都是串行的。虽然并发高了以后,后面的任务需要排队等待,但打印这个场景本身就不追求高并发,稳定永远比速度重要。
4. 前端页面:拖拽上传与打印状态交互
4.1 页面的三个核心区域
前端就是一个index.html,三个区域从上到下排列:拖拽上传区、打印选项区、状态列表区。
页面不复杂,但是有几个交互细节必须处理好,否则用户会觉得很"糙"。
4.2 拖拽上传的坑:必须阻止浏览器默认行为
绝大多数人第一次写 HTML5 拖拽上传时都会踩同一个坑:把文件拖进浏览器窗口,浏览器直接打开了 PDF,根本没有触发上传事件。
原因在于,浏览器对拖放文件有一个默认行为:当成打开文件处理。所以必须手动阻止默认事件。
const dropZone = document.getElementById('dropZone'); ['dragover', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }); }); dropZone.addEventListener('drop', (e) => { const file = e.dataTransfer.files[0]; if (file && file.type === 'application/pdf') { uploadFile(file); } else { alert('请上传 PDF 文件'); } });dragover和drop都要preventDefault(),只阻止drop不阻止dragover的话,拖拽过程中鼠标会显示禁止图标,而且松开文件还是会直接打开。
4.3 用 fetch 上传 FormData:文件必须以 multipart/form-data 方式发送
用户选择或拖入 PDF 文件之后,前端把文件和一些表单字段打包成 FormData,用 fetch 发送给后端。
async function uploadFile(file) { const formData = new FormData(); formData.append('file', file); formData.append('copies', document.getElementById('copies').value || '1'); formData.append('duplex', document.getElementById('duplex').checked); formData.append('printer_name', document.getElementById('printer').value || ''); try { const resp = await fetch('/api/upload', { method: 'POST', body: formData, }); const data = await resp.json(); if (!resp.ok) { alert(data.detail || '上传失败'); return; } startPrint(data.file_id); } catch (err) { alert('网络错误,请确认打印服务器可达'); } }注意这里用 FormData 上传文件,后端 FastAPI 才能用UploadFile和Form同时接收到文件和额外字段。有些前端代码喜欢用JSON.stringify把文件转成 base64,那样搞,文件大一倍不说,后端的UploadFile也接不到。
4.4 打印选项:份数、双面、打印机怎么传给后端
打印选项在设计上要跟随"打印机能力"。比如有些打印机根本不支持双面,你前端给个双面开关,后端命令也传了duplex,但实际上打印机会忽略或者打出来是错的。
比较好的做法是:页面打开时先请求/api/printers,拿到服务器上真实存在的打印机列表,动态生成下拉框。这样用户只能选择已经接好的打印机,避免"打印机名称填错导致打印失败"这种低级错误。
前端轮询打印状态的代码:
let pollTimer = null; function startPrint(fileId) { if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(async () => { const resp = await fetch(`/api/status/${fileId}`); const data = await resp.json(); renderStatus(fileId, data.status, data.message); if (data.status === 'completed' || data.status === 'failed') { clearInterval(pollTimer); pollTimer = null; } }, 1500); }轮询间隔我调的是 1.5 秒。太快了没意义,打印任务的处理时间基本在秒级;太慢了用户体验很拖沓。1.5 秒是一个比较舒服的中间值。
4.5 失败的处理:把日志暴露给用户
打印接口返回failed时,需要把后端捕获的stderr展示给用户。原因很简单:打印失败的场景千奇百怪,如果不显示系统底层错误信息,用户只会对着页面干瞪眼。
后端会把subprocess.stderr里的有效信息截断后存进任务状态:
if result.returncode != 0: err_msg = result.stderr.decode("utf-8", errors="ignore")[:200] update_task(file_id, "failed", err_msg or "打印命令执行失败")前端把message渲染成一个醒目的红色提示框。虽然普通用户看不懂里头的内容,但至少可以截图发给运维排查,这就够了。
5. 部署到服务器:权限、共享与防火墙的实战踩坑
5.1 不能直接开个终端挂着跑
开发时我用uvicorn main:app --host 0.0.0.0 --port 8000起服务,但这只能用于调试。部署到打印机服务器上,必须让 FastAPI 变成一个后台服务。
在 Windows 上,我用 NSSM(Non-Sucking Service Manager)把 uvicorn 注册成 Windows 服务:
nssm install PrintServer "C:\Python311\python.exe" "C:\print_server\run.py" nssm set PrintServer AppDirectory "C:\print_server" nssm start PrintServerrun.py的内容就一行启动逻辑:
import uvicorn if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000)在 Linux 上,直接写一个 systemd 服务单元:
[Unit] Description=PDF Print Server After=network.target [Service] User=printuser Group=printuser WorkingDirectory=/home/printuser/print_server ExecStart=/home/printuser/print_server/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 Restart=always [Install] WantedBy=multi-user.target为什么一定要注册成服务?因为服务能开机自启、崩溃自动重启、脱离桌面会话独立运行。打印服务器这个东西,没人会每天都去盯着。
5.2 驱动安装顺序:先把系统打点通了再写代码
这是我个人的血的教训:先装好打印机,用系统自带的"打印测试页"功能确认打印机本身没问题,再用命令行手动执行一次打印命令确认命令没问题,最后才去调 Web 服务。
如果你先写代码再装驱动,一旦最终打印失败,排查链路会非常长:是 FastAPI 的问题?是命令参数的问题?还是驱动的问题?系统里每个环节都像黑盒。
正确顺序是这样的:
- 物理连接打印机,安装官方驱动。
- 系统里随便打开一个 PDF 文件,手动点打印,确认打印机能出纸。
- 用管理员打开命令行,手动执行
SumatraPDF.exe -print-to "打印机名" -silent test.pdf,确认命令行能出纸。 - 最后启动 Web 服务,通过页面测试上传和打印。
每走一步确认一步,后面出了问题,你就知道毛病出在哪个层次。
5.3 服务账号权限:最常见的"打印没反应"
这个问题是我在 Linux 部署时遇到的。systemd 服务用的是printuser用户,结果页面正常上传 PDF,后端也显示打印命令成功,但打印机完全没动静。
后面排查发现,printuser没有权限操作 CUPS 的打印队列。虽然lp命令本身可以执行,但系统会静默拒绝,命令返回码照样是 0。
解决办法是把运行用户加入lp和lpadmin用户组:
sudo usermod -aG lp,lpadmin printuser sudo systemctl restart printserverWindows 上类似的问题出现在文件路径权限上:FastAPI 服务账号没有访问C:\print_tools或uploads目录的权限,打印命令执行时找不到文件,返回"拒绝访问"。
这里给一个通用排查思路:先用"运行服务的那个账号"的身份,在服务器上手动执行一遍打印命令。手动能成功,说明命令本身没问题,问题大概率在权限;手动失败,说明命令构造或驱动配置有问题。
5.4 局域网访问与防火墙
服务跑起来以后,局域网其他电脑访问的是http://服务器IP:8000。这一步最大的坑就是防火墙。
Windows 首次启动 uvicorn 时,系统会弹窗询问是否允许 Python 通过防火墙。如果点了取消,后面所有局域网用户都会发现页面打不开。
如果没弹窗或者手滑点了取消,去"高级安全 Windows Defender 防火墙"里手动放行 TCP 8000 端口。Linux 上用 ufw 的话:
sudo ufw allow 8000/tcp放行之后,先用服务器本机访问http://127.0.0.1:8000确认服务正常,再用另外一台电脑访问局域网 IP 确认网络通了。这两步分开验证,别混在一起查。
5.5 打印机的网络连接方式
如果是网络打印机(打印机自带网口或者 WiFi),建议服务器上用固定 IP 添加打印机,不要用主机名。因为打印机的 DHCP 地址一旦变化,CUPS 那边的连接会断开,打印任务全部挂起。
如果是 USB 打印机,要注意打印线材质量。打印服务器这种场景,打印机在打印大文件时 USB 线不稳定会导致输出中断,看起来像驱动问题,实际上换根短线就好。
6. 上线后的真实问题修复记录
6.1 中文文件名与路径编码:第一个上线必炸的问题
上线第一天,就有同事上传了一份"2024年度总结.pdf",页面提示打印成功,但打印机没反应。
我跑到服务器上手动执行打印命令,发现打印命令报错,提示找不到文件。但是在资源管理器里看,那个文件明明就在uploads目录里。后来发现,问题出在 Python 和 Windows 命令行的编码不一致上。
Windows 的命令行默认编码是 GBK,而 Python 拿到的文件路径是 Unicode。当文件名包含中文时,subprocess 把命令参数传给系统时,在特定的系统语言环境配置下会乱码,系统找不到对应文件。
解决方法是釜底抽薪式的:上传保存时,文件名直接用 UUID,路径里永远不出现中文。文件名问题从此彻底消失。
6.2 打印任务堆积与 Spooler 卡死
上线一周后,有一次同事连续打印 10 份文件,打到第 3 份就卡住了,后面所有任务全部显示失败。
按照前面的排查思路,我先在服务器上打开"查看打印队列",发现第 3 个任务卡在队列里,状态是"正在打印",但打印机已经停止工作。
这是 Windows 打印假脱机服务(Spooler)被卡死的典型现象。修复方法:
- 停止 Print Spooler 服务。
- 删除
C:\Windows\System32\spool\PRINTERS目录下的所有临时文件。 - 重新启动 Print Spooler 服务。
但要治本,必须从后端避免这个问题。我做了两个改进:
第一个是前面说过的全局打印锁,让所有打印请求串行执行,从源头避免并发冲突。第二个是加了一个超时机制:打印任务在 60 秒内没有完成,后端主动终止这个任务的命令执行,防止单个异常任务把后续任务全部堵死。
result = await loop.run_in_executor( None, lambda: subprocess.run( cmd, capture_output=True, timeout=60, # 超时主动终止 ), )6.3 加密 PDF 和损坏文件的防御
有一个问题差点让我怀疑打印服务器代码写错了:某个同事上传了一个加密 PDF,页面提示打印成功,但打印机没出来。后来我发现,SumatraPDF 遇到加密 PDF 时,如果命令行没提供密码,它就静默退出,返回码不是 0,但也不会打印任何错误信息。
所以光靠命令行执行结果来判断成功与否是不可靠的。我在上传接口里加了内容级校验:用 pypdf 尝试读取页数,遇到加密或损坏的 PDF 直接拒绝。这样把坏文件挡在入口,而不是等到打印环节才暴露问题。
from pypdf import PdfReader import io try: reader = PdfReader(io.BytesIO(content)) if reader.is_encrypted: raise HTTPException(status_code=400, detail="暂不支持加密 PDF") page_count = len(reader.pages) except Exception: raise HTTPException(status_code=400, detail="PDF 文件损坏或无法解析")6.4 浏览器兼容性和大文件超时
前端用 fetch 上传文件在一些老旧浏览器上有兼容问题。我测试过公司电脑上还存活的 IE11、老版本的 Windows Edge,fetch 在部分版本上对大文件上传的支持不稳定。对于内部工具,我最终的策略是直接放弃不支持 fetch 的老浏览器,在页面加载时做一次能力检测,提示用户使用 Chrome 或 Edge。
大文件方面,一个几百 MB 的 PDF 在普通的局域网里传输没问题,但 FastAPI 在读取整个文件到内存时要注意占用。我实际上传接口用了await file.read(),一次性读入内存,这种方式对超大文件不友好,但考虑到打印场景下绝大多数 PDF 都在 100 MB 以内,够用了。如果以后真有人传几个 GB 的文件,再改造成流式落盘也不迟。
6.5 日志:打印服务器必须留痕
上线跑了一段时间后,我发现一个问题:某个用户打印失败,来找我排查,但我完全不知道他刚才做了什么操作。页面上报"网络错误"还是"打印失败",后端跑了哪些命令,命令输出是什么,这些全都没有记录。
后来我加了一个简单的日志中间件,每次调用/api/print都会把完整的命令列表和返回码写进日志文件。排查问题的时候一眼就能定位到具体环节。
import logging logging.basicConfig( filename="print_server.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) logging.info("print command: %s", " ".join(cmd)) logging.info("returncode: %s stderr: %s", result.returncode, result.stderr)Linux 上可以直接把日志交给 systemd journal,但用这个简单方案的好处是跨平台统一。
最后分享一个我在实际维护中的体会:打印服务器这东西,技术难度其实不大,真正值钱的是稳定。当打印任务排队、权限、文件命名、损坏防御这些脏活都被处理干净之后,它就是一个非常省心的小工具。团队里谁要打 PDF,打开浏览器拖进去选一下,打印机自己就出纸了,不用再跑到那台旧主机前排队操作。这种小工具带来的舒适感,用过的人都说值。