☰
JAVA在线打印系统毕业设计:JSP+Servlet+MySQL三层架构与部署全解析
2026/10/4 1:15:20 网站建设 项目流程

简介:这是一份基于Java的校园在线打印系统毕业设计源码包,面向计算机相关专业毕业生、Java Web开发者及毕业设计备赛学生。系统以SpringBoot+Mybatis构建后端,Thymeleaf模板渲染页面,前端集成Layui框架,配合MySQL数据库,实现了首页商品展示、印品类型详情、订单管理、后台管理等核心功能,项目结构按标准Maven模式分层,便于快速理解业务与二次开发。压缩包共258个文件,包含68个Java源码、30个HTML页面、34个JavaScript脚本、9个CSS样式、2个SQL脚本及多项图片、配置与字体资源,源码覆盖控制层、业务层、持久层完整链路,SQL脚本可直接创建数据库表,静态资源还原了可运行的前端界面,整体体积仅2.45MB,方便下载研读。此份源码已有426人学习,既能帮助读者快速搭建一个完整的Web打印业务流,也能直观掌握SpringBoot与Mybatis整合、Layui后台布局及订单状态流转等关键编码思路,对完成课程设计或毕业设计具有较高的参考价值。

1. 从毕业设计到能跑的服务:这个 JAVA 在线打印系统到底能给你什么

每年到这个季节,总有人拿着「JAVA 在线打印系统」的题目来问我要路子。原因不外乎两点:这题不像电商、OA 那么烂大街,又比贪吃蛇、图书管理这类纯 CRUD 有讲头。这套在线打印系统源码我拆过不止一遍,它的核心是 JSP + Servlet + MySQL 三层结构,业务上涵盖了用户登录、文件上传、订单计价、打印任务状态流转,这些恰好都是 Java Web 里最常被面试官追问的点。它适合两类人:一是毕设选题定了在线打印但还没开工的,想直接拿一份完整工程跑通再改成自己的;二是学完 Java 基础但没完整做过项目的人,想看看一个能答辩的 Web 系统到底长什么样。下面我按拆包的顺序,把架构、跑通方法、核心代码和几个真正会让人翻车的细节过一遍。

2. 分层识源码:JSP + Servlet + MySQL的请求流转与表结构

拿到这套源码第一件事,千万别急着点运行。先把工程结构展开看一遍,搞清楚谁在承接请求、谁在读写数据库,后面所有模块调不通的问题,大半都能在这一章里找到答案。

2.1 一条打印请求从前端到数据库经过了哪些层

这套系统用的是最经典的 JSP + Servlet + 三层架构,没有引入 Spring 那套重量级框架。不是技术保守,而是毕业设计答辩时,你用手画一张请求流程图,说清楚数据经过了哪三层,比讲一堆注解要实在得多。

一次完整的在线打印请求,在代码里是这样走的:

  1. 浏览器提交打印表单,JSP 页面把用户选的打印机、纸张、份数、彩色还是黑白封装成请求参数。
  2. web.xml 里配置的 Servlet 拦截到 URL,比如/order,在 doPost 方法里接收参数。
  3. Servlet 层做参数校验,比如份数不能大于 100,文件大小不能超过设定值。
  4. 校验通过后调用 Service 层,典型如OrderService.createOrder()。
  5. Service 层调用 DAO 层的OrderDao.insert(order),DAO 层用 JDBC 执行 INSERT。
  6. 数据库返回自增主键,Service 层再把订单号写回,跳转到支付页或成功页。

我习惯在源码里直接搜response.sendRedirect和request.getRequestDispatcher这两个方法,跳转语句一出来,整个页面的流转路径就清晰了。这套系统里,/print/upload是上传入口,/print/order是下单入口,/admin/task/list是后台任务列表,三个入口对应三段独立的 Servlet。

2.2 数据库五张表:用户、文件、打印机、订单和任务怎么串起来

在线打印系统的数据结构不复杂,但表之间的外键关系要能自圆其说,答辩老师最爱问「为什么任务表和订单表分开建」。我给这套源码里的核心表结构按常见实现补全一下,方便你对照理解:

CREATE TABLE `t_user` ( `id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` VARCHAR ( 64 ) NOT NULL COMMENT '登录名', `password` VARCHAR ( 128 ) NOT NULL COMMENT 'MD5加密后的密码', `phone` VARCHAR ( 20 ) DEFAULT NULL COMMENT '手机号,用于登录', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表'; CREATE TABLE `t_file` ( `id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '文件ID', `user_id` INT ( 11 ) NOT NULL COMMENT '上传用户', `file_name` VARCHAR ( 255 ) NOT NULL COMMENT '原始文件名', `file_path` VARCHAR ( 255 ) NOT NULL COMMENT '服务端存储路径', `file_size` BIGINT DEFAULT 0 COMMENT '文件大小,单位字节', `upload_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COMMENT = '上传文件表'; CREATE TABLE `t_printer` ( `id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '打印机ID', `printer_name` VARCHAR ( 128 ) NOT NULL COMMENT '打印机名称或IP', `location` VARCHAR ( 128 ) DEFAULT NULL COMMENT '所在位置', `status` TINYINT DEFAULT 1 COMMENT '1可用 0离线', PRIMARY KEY (`id`) ) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COMMENT = '打印机表'; CREATE TABLE `t_order` ( `id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` VARCHAR ( 32 ) NOT NULL COMMENT '业务订单号', `user_id` INT ( 11 ) NOT NULL, `file_id` INT ( 11 ) NOT NULL, `printer_id` INT ( 11 ) NOT NULL, `total_amount` INT ( 11 ) NOT NULL COMMENT '总金额,单位分', `status` TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COMMENT = '打印订单表'; CREATE TABLE `t_print_task` ( `id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '任务ID', `order_id` INT ( 11 ) NOT NULL COMMENT '关联订单', `task_status` TINYINT DEFAULT 0 COMMENT '0排队中 1打印中 2已完成 3失败', `pages` INT DEFAULT 1 COMMENT '打印页数', `copies` INT DEFAULT 1 COMMENT '打印份数', `color_type` TINYINT DEFAULT 0 COMMENT '0黑白 1彩色', `duplex` TINYINT DEFAULT 0 COMMENT '0单面 1双面', `finish_time` DATETIME DEFAULT NULL COMMENT '完成时间', PRIMARY KEY (`id`) ) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COMMENT = '打印任务表';

这里有三处值得注意。路径字段file_path用的是 VARCHAR(255),不是 TEXT,因为存储的是相对路径,比如uploads/202406/xxx.pdf,相对路径的好处是项目迁移时不用改数据库里的绝对路径。金额字段total_amount我用 INT 以「分」为单位,不用 DOUBLE,Java 里0.1 + 0.2的浮点误差在计费系统里是典型坑,用整型分可以彻底绕开。

任务表和订单表分开是合理的:一张订单可以包含多个文件、多次打印任务,比如一个订单里同时打印两份不同的 PDF。如果合在一张表里,每次加一个文件就要复制一遍订单号,冗余严重;拆开后,订单表只关心支付状态,任务表只关心打印状态,两件事互不干扰。

2.3 连接管理用 JDBC 工具类,别把 Connection 散落在每个 DAO 里

拆这套源码时我第一个看的就是数据库连接怎么管理。最稳妥的写法是写一个DBUtil工具类,把驱动的加载和连接的关闭统一收口:

import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/print_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { // 驱动类在 MySQL 8.x 下必须显式加载 Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL驱动加载失败,请检查 jar 包"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

serverTimezone=Asia/Shanghai这个参数不能省,新版 MySQL 驱动的时区校验很严格,缺了它连接会直接报错。useSSL=false则是因为本地开发环境没必要做 SSL 握手,省去一层开销。

DAO 层的写法也有讲究。我在源码里见到有人直接在 Servlet 里写Class.forName,这属于把代码写死,换环境就得改 Servlet 源码。正确做法是数据访问统一收到 DAO 类里,Servlet 只调方法:

public User findByUsername(String username) { String sql = "SELECT id, username, password, phone FROM t_user WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setPassword(rs.getString("password")); u.setPhone(rs.getString("phone")); return u; } } } catch (SQLException e) { e.printStackTrace(); } return null; }

所有 SQL 都用PreparedStatement拼参数,禁止用字符串拼接 SQL。findByUsername如果写成"SELECT * FROM t_user WHERE username='" + username + "'",输入' or '1'='1就能绕过登录校验,这是答辩时的送命题。

3. 快速跑通:环境版本搭配与导入部署的六个关键点

源码拿到手,环境不对照样跑不起来。这一章我按自己平时的部署习惯,把从解压到看到首页的整个流程拆成六步,每步都有具体的版本和配置。

3.1 环境别乱追新:JDK 8、Tomcat 8.5、MySQL 5.7 的搭配逻辑

这套源码是按传统 Java Web 工程写的,环境配错是启动失败的第一大原因。我给的组合是:

组件推荐版本选型理由
JDK1.8源码的编译级别是 Java 8,高版本 JDK 会有模块化访问限制
Tomcat8.5.xServlet 3.1 规范,支持注解配置,和 JDK 8 配合最稳
MySQL5.7 或 8.05.7 默认字符集够用,8.0 需注意驱动版本要用com.mysql.cj.jdbc.Driver
IDEEclipse 或 IDEA两者都行,导入时统一把编码切成 UTF-8

为什么不用 Tomcat 9 或更高?Tomcat 9 对应 Servlet 4.0,本身兼容,但这种老工程里如果引了某些旧版依赖,在高版本容器下会出现NoClassDefFoundError,排查起来非常浪费时间。毕业设计阶段,稳定跑通比追新版本重要得多。

3.2 导入与配置:从解压到数据库连接串改对,只差这一步

导入前先做一件事:把整个工程目录检查一遍,确认里面有src、WebContent(或webapp)、pom.xml(如果是 Maven 工程)这三层结构。如果是普通的 Dynamic Web Project,没有pom.xml,那依赖的 jar 包一定在WebContent/WEB-INF/lib下,数一下 jar 个数,少了马上补。

数据库连接配置通常在src目录下的db.properties里:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/print_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456

密码是最容易出问题的地方。如果你的本地 MySQL 密码是root123,改完这里就完事。但要注意,MySQL 8.0 默认的认证插件是caching_sha2_password,而老版本 JDBC 驱动只认mysql_native_password。解决方法是把驱动换成mysql-connector-java 8.0.x,或者在 MySQL 里执行:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';

改完连接串、导入 SQL 脚本建库建表之后,才算把准备工作做完。

3.3 启动顺序与验证:先看日志再看页面,别一上来就疯狂刷新

启动 Tomcat 的正确顺序是:先确认 MySQL 服务在跑,再启动 Tomcat。如果反过来,Tomcat 启动时数据源初始化失败,即使后面 MySQL 起来了,应用也不会自动重新连接。

在 IDEA 或 Eclipse 里部署后,先切到Console标签看日志:

# 看到这一行,说明应用部署成功 [localhost-startStop-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [D:\apache-tomcat-8.5.98\webapps\print.war] # 看到这一行,说明容器加载完成,没有报错 INFO: Server startup in [3,842] milliseconds

如果启动日志里出现Exception或Caused by:,不要往下翻了,直接看第一行异常信息,百分之八十是数据库连接问题。

应用里自带一个初始化 Servlet 或ApplicationListener,启动时会自动初始化打印机列表,如果日志里没有「初始化打印机数量=xx」这类输出,说明数据库连接没通。

验证页面的顺序是固定的:先访问http://localhost:8080/print/看首页,再注册一个新账号,然后用这个账号登录进入用户中心。如果首页能出来但登录后 500,问题多半在 Session 或者用户表的字段匹配上。

4. 核心业务落到代码:文件上传、计价逻辑与任务状态流转

跑通只是第一步。答辩时老师问的不只是「能不能跑」,而是「这个功能怎么实现的」。在线打印系统最核心的三个业务点,必须能指着代码讲出来。

4.1 文件上传:要用 multipart 解析库,容量和类型双重限制

原生 Servlet 解析上传文件非常痛苦,要自己读InputStream拆 boundary。这套源码里用的是 Apache Commons FileUpload,这是一个成熟的解析库。上传 Servlet 的核心写法:

@WebServlet("/print/upload") @MultipartConfig(maxFileSize = 10485760, // 单个文件最大10MB maxRequestSize = 20971520, // 整个请求最大20MB fileSizeThreshold = 4096) // 超过4KB写临时文件 public class UploadServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); // 从会话里取用户 ID,确认登录状态 HttpSession session = request.getSession(); Integer userId = (Integer) session.getAttribute("userId"); if (userId == null) { response.sendRedirect("login.jsp"); return; } Part filePart = request.getPart("file"); String submittedFileName = filePart.getSubmittedFileName(); // 白名单校验,防止上传 jsp、exe 等危险文件 String ext = submittedFileName.substring(submittedFileName.lastIndexOf(".") + 1).toLowerCase(); if (!"pdf".equals(ext) && !"doc".equals(ext) && !"docx".equals(ext)) { request.setAttribute("error", "仅支持 PDF / DOC / DOCX 格式"); request.getRequestDispatcher("upload.jsp").forward(request, response); return; } // 用时间戳重命名,避免文件名中文乱码和服务端同名覆盖 String newName = System.currentTimeMillis() + "_" + submittedFileName; String savePath = getServletContext().getRealPath("/uploads") + File.separator + newName; filePart.write(savePath); // 保存文件记录到数据库 FileDao fileDao = new FileDao(); fileDao.insert(userId, submittedFileName, "uploads/" + newName, filePart.getSize()); response.sendRedirect("order.jsp?fileId=" + fileDao.getLastInsertId()); } }

@MultipartConfig的三个参数值得专门讲。maxFileSize限制的是单个文件,但用户可能一次传五个文件,所以还要用maxRequestSize卡住整个请求的总大小。fileSizeThreshold的意思是文件小于 4KB 时直接放内存,超过则落临时文件,避免大文件挤爆堆内存。

文件名处理是这里最大的坑。用户上传的文件名可能叫「毕业论文终稿(2).pdf」,里面有中文和空格。保存到服务器前必须重命名,我习惯用System.currentTimeMillis()加下划线前缀,这样既避免中文乱码,又不会和别人的文件冲突。同时,数据库里存的是原始文件名,页面展示时用数据库里的名字,服务端用重命名后的名字,两边各司其职。

扩展名白名单必须用lastIndexOf(".")而不是endsWith(".pdf")。如果用户传的文件名末尾是xxx.pdf.jpg,endsWith拦截不住后面再拼一个.jsp的情况,而lastIndexOf取的是最后一个点后面的部分,能挡住这种绕过。

4.2 计价逻辑:金额用「分」存储,页数乘以单价再加装订费

计价规则通常是答辩时的高频问题。这套系统的计价核心可以抽象成一张配置表:

项目价格(分)说明
黑白单面10 分/页最基础的打印
黑白双面16 分/页双面打印,价格按页数算但一份纸印两面
彩色单面50 分/页彩色耗材成本高
彩色双面80 分/页
装订费200 分/本可选,按本数收

计价代码要注意浮点陷阱,我写成这样:

public int calcAmount(PrintTask task, int pageCount) { // 单价都定义为 int,单位是分 int singlePrice = 0; if (task.getColorType() == 0 && task.getDuplex() == 0) { singlePrice = 10; } else if (task.getColorType() == 0 && task.getDuplex() == 1) { singlePrice = 16; } else if (task.getColorType() == 1 && task.getDuplex() == 0) { singlePrice = 50; } else if (task.getColorType() == 1 && task.getDuplex() == 1) { singlePrice = 80; } // 双面打印时,页数除以2,向上取整 int effectivePages = (task.getDuplex() == 1) ? (pageCount + 1) / 2 : pageCount; int amount = effectivePages * singlePrice * task.getCopies() + (task.isBinding() ? 200 : 0); return amount; }

关键在双面打印的页数换算。一份 15 页的 PDF,双面打印需要 8 张纸,所以(15 + 1) / 2的向上取整写法比Math.ceil更直观。向上取整的意义在于第 15 页不能和第 16 页拼在一张纸上,多出来的半张纸也是成本。

为什么金额要用分不用元?因为 Java 的double做乘法会出2.0 * 0.1 = 0.20000000000000001,订单金额出现这种尾巴没法看。用分做单位全部走整数运算,最后展示时再除以 100 转成字符串。

4.3 任务状态机:七种状态之间谁允许跳到谁,用常量类管住

打印任务最怕的是状态乱跳。已取消的订单不能再变成已支付,这个约束必须写在代码里。我的做法是在系统内定义一组状态常量:

public class TaskStatus { public static final int PENDING_PAY = 0; // 待支付 public static final int PAID = 1; // 已支付,排队中 public static final int PRINTING = 2; // 打印中 public static final int COMPLETED = 3; // 已完成 public static final int CANCELED = 4; // 已取消 public static final int FAILED = 5; // 打印失败 // 允许的状态迁移路径,key 是当前状态,value 是可达状态 public static final Map<Integer, List<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(PENDING_PAY, Arrays.asList(PAID, CANCELED)); TRANSITIONS.put(PAID, Arrays.asList(PRINTING, CANCELED)); TRANSITIONS.put(PRINTING, Arrays.asList(COMPLETED, FAILED)); } public static boolean canTransition(int from, int to) { return TRANSITIONS.get(from) != null && TRANSITIONS.get(from).contains(to); } }

不要小看这个 Map 的作用。没有它,代码里谁都敢写task.status = 3,哪天出现一个从待支付直接跳到已完成的订单,你查三天都查不出来。有了canTransition,每次更新前先校验,非法流转直接抛异常,数据层面的脏状态从根本上被拦住。

实际执行更新时,SQL 要带上当前状态条件,防止并发下两次更新都成功:

public int updateStatus(int taskId, int fromStatus, int toStatus) { String sql = "UPDATE t_print_task SET task_status = ? WHERE id = ? AND task_status = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, toStatus); ps.setInt(2, taskId); ps.setInt(3, fromStatus); // 必须匹配当前状态 return ps.executeUpdate(); // 返回0说明状态不是 fromStatus,更新失败 } catch (SQLException e) { e.printStackTrace(); return -1; } }

WHERE id = ? AND task_status = ?这叫乐观锁的简陋版。两个人同时点了「取消」和「支付」,只有一个人的 UPDATE 能成功 — 另一个人因为task_status已被改成PAID,不再匹配PENDING_PAY,更新结果返回 0,代码拿到 0 就提示「操作失败,请刷新」。

4.4 支付回调与打印完成:异步状态更新怎么模拟

真实打印系统里,支付成功回调是第三方支付平台异步通知的。但这套毕业设计系统没有接入支付宝或微信支付,它用的是「余额扣除」或「模拟支付」模式。

模拟支付的做法很朴素:支付 Servlet 里把订单状态从0 待支付改成1 已支付,同时扣减用户余额,两个操作放在同一个事务里。事务的写法值得单独提一下:

Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 扣减用户余额 userDao.deductBalance(conn, userId, amount); // 更新订单状态 orderDao.updateStatus(conn, orderId, TaskStatus.PAID); conn.commit(); // 两件事都成功才提交 } catch (SQLException e) { if (conn != null) try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } throw new ServletException("支付失败,订单已回滚", e); } finally { if (conn != null) try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { } }

注意这里为什么不用之前的DBUtil.getConnection()工具类获取连接然后直接操作?因为工具类的关闭方法是立刻conn.close(),而事务要求同一个连接里完成多条 SQL,最后统一提交或回滚。如果每个 DAO 方法都自己拿连接、自己关,事务就散了。

至于「打印完成」这种异步事件,毕业设计级别不需要真的对接打印机硬件。系统在后台任务列表里放了一个「模拟完成」按钮,管理员点击后把任务状态从2 打印中改成3 已完成,并记录完成时间。如果你想做得更真实一点,可以在打印任务列表页面加一个定时刷新:

// 每15秒轮询一次任务状态,页面自动刷新 setInterval(function () { fetch('task/status?taskId=' + taskId) .then(res => res.json()) .then(data => { if (data.status === 3) { document.getElementById('taskState').innerText = '已完成'; } }); }, 15000);

这段代码的作用是让前台页面能感知后台的状态变化,不用用户手动刷新页面。15 秒的轮询间隔不会给服务器造成压力,又保证了演示时任务的完成状态能及时出现在屏幕上。

5. 避坑清单:部署和演示前最容易翻车的五个位置

这章整理的是我从接手这类源码起反复踩过的坑,每一条都对应一个具体的报错或界面异常。建议你在正式答辩前,把这五条逐个对着源码检查一遍。

5.1 上传的中文文件名变成乱码,页面还报 404

现象:用户上传「毕业论文终稿.pdf」,系统里显示????.pdf,打开打印预览时图片加载不出来。

原因:Tomcat 8.5 默认的 URI 编码是 UTF-8,但如果你在 Eclipse 里把工作区的编码设成 GBK,或者项目里web.xml没有声明编码过滤器,文件名经过 request 解析时就乱码了。

解决:在web.xml里加一个编码过滤器,或者确认项目的Content Type设置。最直接的办法是在上传 Servlet 的doPost最开头加上request.setCharacterEncoding("UTF-8"),然后检查 Tomcat 的conf/server.xml中 Connector 段落:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />

这个配置里没有URIEncoding时,Tomcat 8.5+ 默认用 UTF-8 解析 URL,但表单 POST 的 body 编码取决于页面charset。所以推荐在 JSP 文件第一行确认统一为<%@ page contentType="text/html;charset=UTF-8" %>。

5.2 上传超过 10MB 的文件,一点就报 413 或直接弹错误页

现象:传一个 20MB 的 PDF,页面直接 500,后台日志报FileUploadException: The request was rejected because its size exceeds the configured maximum。

原因:@MultipartConfig(maxFileSize = 10485760)把单文件限死在 10MB。这个限制是硬性的,超过就拒绝请求,不是警告。

解决:需要调大限制,同时调整 Tomcat 层级的限制。Tomcat 的maxPostSize默认只有 2MB,如果走的是 multipart 表单 POST,这个参数会先拦截。在server.xml的 Connector 里加:

<Connector port="8080" protocol="HTTP/1.1" maxPostSize="10485760" maxSwallowSize="20971520" />

maxPostSize控制请求体上限,maxSwallowSize控制连接器在响应后还能继续读取多少请求体的字节。这两个值要跟@MultipartConfig保持一致或更大,否则会有「Servlet 层放行了、容器层又给拦回来」的诡异现象。

5.3 明明加了 MySQL 驱动 jar,启动还是报 ClassNotFoundException

现象:Tomcat 启动到一半抛java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver,但你看WEB-INF/lib目录里明明有mysql-connector-java-8.0.x.jar。

原因:jar 包在工程目录里,但没被部署到 Tomcat 的WEB-INF/lib下。Eclipse 里这个情况特别常见 — 你把 jar 拖进了WebContent/WEB-INF/lib,但没执行「右键项目 → Properties → Deployment Assembly → Add → Java Build Path Entries」。

解决:先打开 Eclipse 的Deployment Assembly面板,确认WEB-INF/lib的条目里包含 MySQL 驱动 jar。更稳妥的做法是直接看 Tomcat 的部署目录:

# Tomcat 的 webapps 下展开的应用目录 ls -la apache-tomcat-8.5/webapps/print/WEB-INF/lib/

如果这里没有 jar,说明工程构建时没把依赖带进去。在 IDEA 里则要看Project Structure → Artifacts → Output Layout,把依赖的 jar 勾进WEB-INF/lib后再重新 Build。

5.4 数据库连接报 Communications link failure 或 Connection refused

现象:启动时日志出现com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure,有时是Connection refused (Connection refused)。

原因:绝大多数情况不是驱动写错,而是 MySQL 服务没启动,或者端口不对。MySQL 8 默认端口 3306,但如果机器上装了多个版本,后装的把端口改成了 3307,连接串里还是 3306 就必然连不上。

解决:先用命令行确认端口通不通:

# 检查 MySQL 端口是否在监听 netstat -ano | findstr 3306 # 如果没有任何输出,说明 MySQL 服务没起来 # 在 Windows 服务里启动 MySQL,或者用命令行启动: net start mysql

如果端口通但仍报错,再检查连接串里的serverTimezone参数是否带上。MySQL 8 驱动对时区敏感,缺了serverTimezone=Asia/Shanghai会抛The server time zone value相关的异常,这条几乎每个接手 MySQL 8 的人都会遇到。

5.5 打印预览图片带反斜杠路径,Linux 部署后白屏

现象:本地 Windows 上一切正常,传到 Linux 服务器后,上传文件后预览页图片全部加载失败,浏览器 DevTools 里显示的图片 URL 是uploads/202406\28.jpg。

原因:代码里拼接文件访问 URL 时用了File.separator,Windows 下它是\,拼出来的相对路径变成了uploads\202406\28.jpg。浏览器严格按/解析 URL,反斜杠会导致路径失效。

解决:路径拼接固定写/,不要用File.separator。服务端保存文件时可以用File.separator拼接真实磁盘路径,但存进数据库的相对路径必须统一用/:

// 保存到磁盘用 File.separator,这样 Windows 和 Linux 都对 String savePath = uploadDir + File.separator + newName; filePart.write(savePath); // 存数据库只存 Web 访问路径,固定用斜杠 String dbPath = "uploads/" + newName; fileDao.insert(userId, submittedFileName, dbPath, filePart.getSize());

顺带检查一下 JSP 或 HTML 模板里写死的资源路径前是否带了request.getContextPath()。如果不带,项目部署在根路径没问题,一旦打成 WAR 部署到/print子目录下,所有 CSS、JS、图片都会 404。

6. 答辩前不用慌:JConsole 验证 JVM 状态,再按脚本走一遍完整流程

从「能跑」到「能演示」,只差一步验证。我见过太多人答辩前五分钟系统突然卡死,十有八九是 JVM 堆内存不够或演示流程没走过完整链路。

用 JDK 自带的 JConsole 做一次体检。先启动 Tomcat,再打开命令行运行jconsole,弹出窗口后选择本地进程里的org.apache.catalina.startup.Bootstrap,这就是 Tomcat 的 JVM。连接后看两个指标:堆内存和线程数。

  • 堆内存曲线如果呈锯齿状不断上涨且 GC 后降不下来,说明有对象泄漏,多半是 Session 里存了太多大对象 — 我拆这套源码时发现有人把整个上传文件 byte 数组塞进了 session,铁定是隐患。上传文件应该只在 request 范围内使用,处理完立即释放引用。
  • 线程数如果稳定在几十个,说明没有线程泄漏。如果持续增长,检查数据库连接是否每次用完后真正关闭了 — 之前close()方法里如果漏掉了rs的关闭,连接池里连接就攒不回来。

体检完,按这个脚本走一遍完整演示流程,每步别跳:

  1. 启动 MySQL,确认print_db库存在,检查t_printer表里有没有可用打印机。
  2. 启动 Tomcat,访问http://localhost:8080/print/,首页能打开且样式正常。
  3. 注册新用户(用test这种不会被占用的名字),注册后跳到登录页,重新登录。
  4. 上传一个 2MB 左右的 PDF,点击上传,等待提示成功。
  5. 进入下单页,选择打印机、彩色/黑白、单面/双面、份数 3,点击提交。
  6. 查看订单详情,金额计算是否正确,然后模拟支付。
  7. 切到后台管理端,把任务状态从排队改为打印中,再改为已完成。
  8. 回到用户端,确认任务状态同步为已完成,订单历史里有这条记录。

这套走完一遍大概五分钟,但足以覆盖几乎所有 Demo 环节。当初我替一个学生调试这套系统,结果就发现上传的 PDF 在 Linux 服务器上预览白屏 — 文件名里的中文从数据库取出来经过 URL 编码后和实际磁盘文件名对不上,后来我在浏览器的 Network 面板里抓到真实的请求地址,才发现是缩略图生成时没做 URL 编码,用URLEncoder.encode(fileName, "UTF-8")才解决。从那以后,我每次接手毕业设计源码都会强制走一遍「上传中文文件 → 跨环境部署 → 完整流程演示」三步验证,确认这条路是通的,答辩时才不会在评委面前卡住。

这套资源把完整的 JAVA 在线打印系统源码、数据库初始化 SQL、部署说明和开题相关的设计文档都打包在了一起。按前面章节的流程走,你拿到手先建库、改连接串、部署、走一遍演示脚本,再根据自己学校的要求去改功能细节,整个流程会顺很多。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询