ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java+JSP+MySQL打造学校教材管理系统:表结构、事务与部署全解析

Java+JSP+MySQL打造学校教材管理系统:表结构、事务与部署全解析 简介一套面向学校教务与教材管理场景的Java Web项目基于JSPServletMySQL技术栈实现适合有Java基础的初学者学习Web开发。压缩包共81个文件大小仅3.91MB内含26个JSP页面、11个Java源文件、22个编译后的Class文件以及SQL脚本、CSS样式、XML配置和JAR依赖等无需繁琐收集即可直接导入IDE运行。系统实现管理员登录认证、角色权限控制以及教材信息的增删改查代码体现MVC分层、JDBC数据访问、SQL防注入等实战要点。项目中包含数据库建表脚本和完整源码目录可从登录验证到数据持久化完整梳理一个Web应用的全流程。已有1229人学习适合作为毕业设计参考、课程实训项目或快速搭建教材管理原型的素材。1. 学校教材管理系统为什么 JavaJSPMySQL 这套组合在校园 Web 项目里依旧能打教务处的老师拿着一份 Excel 对不上账上学期采购了 500 本《高等数学》期末盘点只剩 80 本中间谁领的、什么时候领的、有没有归还全靠登记本上的字迹。这就是学校教材管理系统要解决的原始问题。做 JavaJSPMySQL 这套技术栈的 Web 项目重点不在把增删改查写出来而在顺着教材入库、出库、盘点这条业务线把表结构、事务边界和部署路径想清楚。它在互联网圈子里不算新潮但在校园环境里是最不挑机器的方案机房那台只有 JDK 1.8 和 Tomcat 7 的老服务器既不让你装微服务也不给你 Redis一个 war 包丢进 webapps 就能跑。这篇笔记按实际落地顺序讲四张核心表怎么建、JDBC 怎么封装、Servlet 分页和出库事务怎么写、Tomcat 下部署会踩哪些坑最后落到 Excel 批量导入和库存预警两个能直接交差的功能。课程设计、期末作业、接手学校旧项目这三类场景都适用。2. 教材系统的表结构设计四张核心表与 JDBC 封装先想清楚再写代码一个教材管理系统说白了就两件事教材信息在库里躺着出入库动作在库里留痕。很多新手一上来就建一张大表把书名、库存、领用人全塞进去结果一本教材被领用 20 次就得插 20 行书名跟着重复存 20 遍。我一般会把数据拆成四张表用户、教材基本信息、独立库存、出入库流水各管各的事。2.1 用户、教材、库存、出入库记录四张表的字段怎么定才不返工先看用户表。管理员和普通教师共用一个登录入口用 role 字段区分就行没必要拆两张表。密码字段至少要存 MD5 摘要绝不能用明文这是校园系统最容易被忽视的底线。-- 用户表管理员与教师共用一张表role 区分权限 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码 MD5 摘要不存明文, real_name VARCHAR(32) NOT NULL COMMENT 姓名, role TINYINT DEFAULT 1 COMMENT 1管理员, 2普通教师, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意几个细节username 加了 UNIQUE 约束防止注册接口被重复写入create_time 用 DATETIME 而不是 TIMESTAMP避免 2038 年问题——学校系统要跑很久这种边界别省。password 用 VARCHAR(64) 是因为 MD5 摘要固定 32 位预留一倍空间方便以后升级成 SHA-256 加盐。接下来是教材基础信息表。价格单位用「分」而不是「元」是因为浮点数做加减乘除会积累误差0.1 元在二进制里根本表示不精确。这个坑在盘点对账时会让你抓狂不如一开始就用整数。-- 教材基础信息表一本书的静态属性价格单位用分 CREATE TABLE textbook ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL COMMENT ISBN全局唯一, book_name VARCHAR(128) NOT NULL, author VARCHAR(64), publisher VARCHAR(64), edition VARCHAR(32) COMMENT 版次如 第3版, category VARCHAR(32) COMMENT 分类如 理工/文科, unit_price INT DEFAULT 0 COMMENT 单价单位:分, cover_path VARCHAR(255) COMMENT 封面图片相对路径, status TINYINT DEFAULT 1 COMMENT 1上架, 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_isbn (isbn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;isbn 加了 UNIQUE重复导入教材时会直接报错拦住比在 Java 代码里先查一遍再插入更可靠。status 字段用来做下架而不是删除记录——教材可能还有历史出库记录关联着物理删除会让报表对不上账。库存为什么不直接写在 textbook 表里因为教材信息是静态的一年改不了几次而库存是每次出入库都要更新的热点字段。拆成独立表之后textbook 只关心「书是什么样的」textbook_stock 只关心「还剩多少」各查各的互不干扰更新库存时也不会把整行教材信息锁住。-- 库存表一本教材一条记录available 是高频更新字段 CREATE TABLE textbook_stock ( id INT PRIMARY KEY AUTO_INCREMENT, textbook_id INT NOT NULL, total_in INT DEFAULT 0 COMMENT 累计入库数量, available INT DEFAULT 0 COMMENT 当前可用库存, warn_threshold INT DEFAULT 20 COMMENT 低于此值页面预警, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_textbook (textbook_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;warn_threshold 是库存预警阈值我习惯把它做成每本书可单独配置而不是全局写死一个数——公共课教材用量大阈值可以设 50冷门选修课设 10 就够。update_time 用 ON UPDATE 自动维护查询「哪些书库存有变动」时直接按它排序。最后是出入库流水表这是整套系统的账本。它的原则只有一个只追加不修改不删除。-- 出入库记录表只追加不改写是盘点和审计的唯一凭证 CREATE TABLE textbook_record ( id INT PRIMARY KEY AUTO_INCREMENT, textbook_id INT NOT NULL, record_type TINYINT NOT NULL COMMENT 1入库, 2出库, 3盘点调整, quantity INT NOT NULL COMMENT 数量恒为正数方向由 record_type 决定, operator_id INT NOT NULL COMMENT 操作人关联 sys_user.id, remark VARCHAR(255) COMMENT 备注如 领用给 2025级计科1班, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_textbook (textbook_id), KEY idx_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;quantity 永远存正数入库还是出库靠 record_type 区分。这样设计的好处是统计时一句SUM(CASE WHEN record_type1 THEN quantity ELSE 0 END)就算出入库总量比正负混存要直观得多。这张表只追加就算操作错了也别 UPDATE新插一条 record_type3 的盘点调整记录把账调平审计线索就完整了。四张表之间我没有加物理外键用的是逻辑关联加程序校验。学校系统并发低物理外键会把批量导入和删数据的顺序锁死代码里控制好引用关系就够了。2.2 DBHelper 封装PreparedStatement 与连接管理背后的两个细节表建好了接下来是访问数据库的工具类。JSP 项目里最常见的做法是写一个静态 DBHelper封装驱动加载、连接获取和资源释放。package com.school.tms.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; /** * 教材管理系统 JDBC 工具类。 * 连接参数集中放在这里换库换密码只改这一处。 */ public class DBHelper { private static final String DRIVER com.mysql.cj.jdbc.Driver; // MySQL 8.0 的驱动类名带 cj private static final String URL jdbc:mysql://127.0.0.1:3306/school_tms ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai allowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD your_password; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动加载失败检查 WEB-INF/lib 下驱动 jar, e); } } private DBHelper() {} public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } /** 统一关闭资源先 ResultSet再 Statement最后 Connection顺序乱了会漏连接 */ public static void close(ResultSet rs, Statement st, Connection conn) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (st ! null) { try { st.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }驱动的静态加载块只执行一次Class.forName 在 MySQL 8.0 之后其实可以省略但保留着能让驱动加载失败时第一时间在日志里暴露而不是等到第一次 getConnection 才报一堆底层异常。URL 里的参数一个都不能少characterEncodingutf8 管中文读写serverTimezoneAsia/Shanghai 解决 MySQL 8.0 的时间差问题allowPublicKeyRetrievaltrue 配合 useSSLfalse 跳过 RSA 公钥交换——这几个参数在部署阶段的报错里出现频率极高后面排错章节会细说。这套 DBHelper 没用连接池。校园系统的并发不高一个学期几千条操作DriverManager 每次拿物理连接完全扛得住还省掉了 C3P0、DBCP 的 jar 包冲突。如果哪天系统要撑 100 以上的并发换 Druid 连接池只需要改 getConnection 一个方法业务代码不用动。查询业务我从来不用 Statement只认 PreparedStatement。参数用占位符绑定既避免 SQL 注入又省得拼接字符串时引号转义出错。public ListTextbook searchByName(String keyword) { String sql SELECT id, isbn, book_name, author, publisher, unit_price FROM textbook WHERE book_name LIKE ? ORDER BY id DESC; ListTextbook list new ArrayList(); try (Connection conn DBHelper.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Textbook t new Textbook(); t.setId(rs.getInt(id)); t.setIsbn(rs.getString(isbn)); t.setBookName(rs.getString(book_name)); t.setAuthor(rs.getString(author)); t.setUnitPrice(rs.getInt(unit_price)); list.add(t); } } } catch (SQLException e) { throw new RuntimeException(查询教材失败, e); } return list; }ORDER BY id DESC 让新录入的教材排在最前面这是列表页的默认行为比按 book_name 排序更符合「刚加的书应该出现在第一页」的直觉。try-with-resources 会自动关闭 PreparedStatement 和 ResultSetConnection 也在 try 括号里省掉了手动 close 的重复代码。这里有个取舍如果一个方法里要拿多个连接做多步操作就不要用 try-with-resources 包 Connection否则连接会在第一个 try 块结束时提前关闭后面的事务逻辑就全断了。3. 用 ServletJSP 把教材 CRUD 跑通登录拦截、分页列表与表单校验表结构和 JDBC 工具类就位后业务层就是典型的 Servlet 接收请求、调 DAO、把结果塞进 request、forward 给 JSP 渲染。这个模式在 Spring MVC 里叫 MVC在 JSP 项目里就是原生的 Servlet JSP。这一章把登录、分页列表、新增教材三个环节串起来讲每一段都是能直接抄的代码。3.1 登录拦截 Filter一个过滤器挡住未授权访问不用每个页面复制判断登录校验最容易犯的错误是在每个 JSP 页面顶部复制一段「session 里有没有 user」的判断。页面一多就漏漏一个页面就是安全漏洞。正确做法是用 Filter 统一拦截。package com.school.tms.web.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 白名单登录页、登录接口、静态资源不拦截 String uri request.getRequestURI(); String ctx request.getContextPath(); String path uri.substring(ctx.length()); if (path.equals(/login.jsp) || path.equals(/login) || path.startsWith(/css/) || path.startsWith(/js/) || path.startsWith(/images/)) { chain.doFilter(request, response); return; } // 不存在的会话不创建防止空 session 刷内存 HttpSession session request.getSession(false); Object loginUser session null ? null : session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(ctx /login.jsp); return; } chain.doFilter(request, response); } }这段代码里request.getSession(false)是关键之一。带 false 表示拿不到已存在的会话就返回 null而不是现场新建一个——用脚本批量扫页面时如果没有这个保护每次请求都会生成一个空 Session 把服务器内存撑爆。ctx /login.jsp是另一个关键项目部署在 Tomcat 的 ROOT 下路径是/login.jsp部署成带上下文路径的子目录时是/tms/login.jsp用request.getContextPath()拼出来的路径两种部署方式都对。静态资源放在白名单里不然登录页的 CSS 会被自己拦截你会看到一个裸的 HTML 登录表单。如果用的是 Servlet 2.5 的 Tomcat 6WebFilter注解不生效需要去 web.xml 里配filter-mapping拦截路径写法一样效果没有区别。3.2 教材分页列表Servlet 计算页码与 MySQL LIMIT 的配合教材列表不可能一次全查出来几百本还行上千本时 JSP 页面渲染会明显变慢。分页的核心是把「第几页、每页几条」翻译成 MySQL 的LIMIT offset, size这个换算公式是offset (pageNum - 1) * pageSize。WebServlet(/book/list) public class BookListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int pageNum 1; int pageSize 10; String p req.getParameter(pageNum); // 参数必须校验用户手动改 URL 传负数或字母会抛 NumberFormatException if (p ! null p.matches(\\d)) { pageNum Integer.parseInt(p); } if (pageNum 1) { pageNum 1; } String keyword req.getParameter(keyword); BookDao dao new BookDao(); int total dao.count(keyword); // 总页数向上取整50 条数据每页 10 条就是 5 页51 条就是 6 页 int totalPages (total pageSize - 1) / pageSize; if (totalPages 0 pageNum totalPages) { pageNum totalPages; // 防止页码越界点下一页点到空页 } ListTextbook books dao.findPage(keyword, pageNum, pageSize); req.setAttribute(books, books); req.setAttribute(pageNum, pageNum); req.setAttribute(totalPages, totalPages); req.setAttribute(total, total); req.getRequestDispatcher(/WEB-INF/jsp/bookList.jsp).forward(req, resp); } }正则校验字符串参数这种 java 基础操作反而是这套系统里最容易被跳过的安全防线。用户手动把 URL 改成?pageNumabcInteger.parseInt 直接抛异常页面 500。加了matches(\\d)之后非数字参数会被静默忽略回到第一页。我一般会把这段参数校验封装成一个getIntParam工具方法三四个列表页共用别每个 Servlet 复制一遍。DAO 里对应的分页查询长这样public ListTextbook findPage(String keyword, int pageNum, int pageSize) { StringBuilder sql new StringBuilder( SELECT id, isbn, book_name, author, publisher, unit_price FROM textbook); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append( WHERE book_name LIKE ? OR author LIKE ?); String kw % keyword.trim() %; params.add(kw); params.add(kw); } sql.append( ORDER BY id DESC LIMIT ?, ?); params.add((pageNum - 1) * pageSize); params.add(pageSize); ListTextbook list new ArrayList(); try (Connection conn DBHelper.getConnection(); PreparedStatement ps conn.prepareStatement(sql.toString())) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 封装 Textbook同上一章 } } } catch (SQLException e) { throw new RuntimeException(分页查询失败, e); } return list; }注意 keyword 为空时不能拼 WHERE 子句否则 SQL 变成WHERE ORDER BY直接报语法错误。LIMIT 的两个参数第一个是跳过的行数第二个是取多少行位置不能换。当 keyword 存在时count 方法也要带上同样的 WHERE 条件否则会出现「总数 100 条过滤后只有 5 条页面上却显示 10 页」的错位。JSP 页面里的分页导航核心是当前页的前后跳转。上一页和下一页的页码由 Servlet 算好放在 request 里JSP 只负责拼接 URL% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % div classpagination c:if test${pageNum 1} a href${pageContext.request.contextPath}/book/list?pageNum${pageNum-1}上一页/a /c:if span第 ${pageNum} / ${totalPages} 页/span c:if test${pageNum totalPages} a href${pageContext.request.contextPath}/book/list?pageNum${pageNum1}下一页/a /c:if /divpageNum 1时才显示上一页pageNum totalPages时才显示下一页首页和末页不会出现点了没反应的按钮。带 keyword 时分页链接里还要带上keyword${keyword}否则翻页后搜索条件就丢了用户翻到第二页看到的是全量数据这是个很隐蔽的体验坑。3.3 新增教材的表单提交后端校验为什么不能省新增教材的表单页面很简单bookName、isbn、author、publisher、unitPrice 几个输入框。但提交到 Servlet 后后端校验一条都不能少。WebServlet(/book/add) public class BookAddServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); // 表单 POST 提交的中文全靠这一行 String isbn req.getParameter(isbn); String bookName req.getParameter(bookName); String priceStr req.getParameter(unitPrice); // 基础非空校验 if (isbn null || isbn.trim().isEmpty() || bookName null || bookName.trim().isEmpty()) { setError(req, resp, 书名和 ISBN 不能为空); return; } // ISBN 格式校验10 位或 13 位数字允许含连字符和末位 X if (!isbn.trim().matches([0-9]{9}[0-9Xx]|[0-9]{13})) { setError(req, resp, ISBN 格式不正确); return; } // 价格转分字符串转整数抛异常就说明用户填了非数字 int unitPrice; try { unitPrice (int) Math.round(Double.parseDouble(priceStr) * 100); } catch (NumberFormatException e) { setError(req, resp, 价格必须为数字); return; } if (unitPrice 0) { setError(req, resp, 价格不能为负数); return; } BookDao dao new BookDao(); // ISBN 重复检查先查一次数据库的 UNIQUE 约束兜底 if (dao.existsByIsbn(isbn.trim())) { setError(req, resp, 该 ISBN 已存在); return; } dao.insert(isbn.trim(), bookName.trim(), unitPrice); resp.sendRedirect(req.getContextPath() /book/list); } private void setError(HttpServletRequest req, HttpServletResponse resp, String msg) throws ServletException, IOException { req.setAttribute(error, msg); req.getRequestDispatcher(/WEB-INF/jsp/bookEdit.jsp).forward(req, resp); } }req.setCharacterEncoding(UTF-8)必须放在读取任何参数之前否则 POST 提交的中文会变成乱码这个问题会在后面的排错章节详细展开。ISBN 的正则校验区分了 10 位老版和 13 位新版并允许末位是 X这是校验里最容易漏的边界——不少书的老版 ISBN 末位就是 X。价格转分的计算要先取整再乘以 100反过来先乘 100 会多出浮点误差19.9 * 100在二进制里是 1989.9999强转 int 会变成 1989差一分钱。前端 JS 校验只做体验优化不能作为安全边界。用户完全可以绕过页面直接用工具发 POST 请求后端校验才是真正的闸门。重复 ISBN 的处理是双保险业务层先查一遍给出友好提示数据库的 UNIQUE 约束兜底挡住并发下的重复插入——两个同时提交的请求可能都通过了 existsByIsbn 检查但数据库只会让一个插入成功。4. 教材出库与库存联动JDBC 事务的落地写法与回滚时机教材出库是整套系统业务价值最高的操作教师在页面上选教材、填数量、提交系统扣库存、写领用记录。如果这两个动作不同步就会出现「记录显示领了库存却没扣」或者反过来「库存扣了记录没写」月底对账时变成一笔糊涂账。MySQL 事务处理在这里不是可选项而是必须项。4.1 扣库存与写记录必须原子出库事务的原由出库操作拆开看是两个 SQL先UPDATE textbook_stock SET available available - 10再INSERT INTO textbook_record ...。假设执行完第一条后服务器断电库存扣了但流水没写这本书的 10 本库存就这样凭空消失了。反过来INSERT 成功后 UPDATE 失败账上多了 10 本幽灵库存。这就是事务的原子性要求一组 SQL 要么全部成功要么全部回滚不允许停在中间状态。对一个学校教材系统来说这个操作每天只有几十次但对账时一次不一致就够头疼半个月。用事务把这两个 SQL 包起来是最直接的后悔药。4.2 JDBC 手动事务写法setAutoCommit(false) 到 commit/rollback 的完整套路JDBC 默认每条 SQL 执行完自动提交事务需要手动关掉自动提交再在合适的时机 commit 或 rollback。public void stockOut(int textbookId, int quantity, int operatorId, String remark) throws BusinessException { // 扣库存条件里带 available ?扣不成负数 String updateSql UPDATE textbook_stock SET available available - ? WHERE textbook_id ? AND available ?; // 写领用流水record_type2 表示出库 String insertSql INSERT INTO textbook_record(textbook_id, record_type, quantity, operator_id, remark) VALUES (?, 2, ?, ?, ?); try (Connection conn DBHelper.getConnection()) { // 第一步关掉自动提交事务从这里开始 conn.setAutoCommit(false); try { // 第二步扣库存返回 0 说明库存不足或教材不存在 try (PreparedStatement ps1 conn.prepareStatement(updateSql)) { ps1.setInt(1, quantity); ps1.setInt(2, textbookId); ps1.setInt(3, quantity); int rows ps1.executeUpdate(); if (rows 0) { throw new BusinessException(库存不足或教材不存在); } } // 第三步写流水 try (PreparedStatement ps2 conn.prepareStatement(insertSql)) { ps2.setInt(1, textbookId); ps2.setInt(2, quantity); ps2.setInt(3, operatorId); ps2.setString(4, remark); ps2.executeUpdate(); } // 第四步两条都成功提交 conn.commit(); } catch (Exception e) { // 第五步任何一步失败回滚并抛出业务异常 conn.rollback(); throw new BusinessException(出库失败操作已回滚 e.getMessage(), e); } } catch (SQLException e) { throw new BusinessException(数据库连接异常, e); } }这段代码里最值钱的不是 commit 和 rollback 的位置而是 UPDATE 语句里那个AND available ?条件。它把「扣库存」和「校验库存够不够」合并成了一个原子操作如果当前库存不足UPDATE 影响行数是 0代码直接抛异常回滚不需要先 SELECT 查一遍库存再 UPDATE。如果先 SELECT 再 UPDATE高并发下两个请求同时查到库存 10都认为够用各自扣 6 本最终库存会变成负数——学校系统并发低这个概率不大但这个写法能让你彻底不用操心。还有一点Connection 是在 try-with-resources 里创建的事务提交或回滚后连接会走到 try 块末尾自动关闭。不要把conn.close()写在 commit 之前那样连接一关后面的回滚就没得执行了。事务要保持短平快别在事务中间调用外部 HTTP 接口、做文件读写、等待用户输入这些慢操作会把数据库连接占住不放连接池一满整个系统就卡死了。4.3 出入库报表的 GROUP BY 写法按教材聚合出入库总量出库事务跑通后教务处下一个需求往往是「每本教材这学期进了多少、出了多少」。这就要对流水表做聚合按教材分组统计。SELECT t.id, t.book_name, SUM(CASE WHEN r.record_type 1 THEN r.quantity ELSE 0 END) AS total_in, SUM(CASE WHEN r.record_type 2 THEN r.quantity ELSE 0 END) AS total_out FROM textbook t LEFT JOIN textbook_record r ON r.textbook_id t.id GROUP BY t.id, t.book_name ORDER BY t.id;LEFT JOIN 保证没有出入库记录的教材也出现在结果里total_in 和 total_out 都是 0而不是直接被 JOIN 过滤掉。SUM 配合 CASE WHEN 做条件聚合比先查入库再查出库跑两条 SQL 然后内存里合并要干净得多。ORDER BY t.id 让报表顺序和教材录入顺序一致看着不跳。这个报表 SQL 在 MySQL 5.7 之后的默认配置下跑大概率会碰到 ONLY_FULL_GROUP_BY 报错——它要求 SELECT 里的非聚合列必须出现在 GROUP BY 里。上面这个 SQL 里 GROUP BY 已经带上了 t.book_name所以能过如果偷懒只写GROUP BY t.idMySQL 5.7.5 以上版本对主键做了函数依赖识别t.book_name 因为函数依赖于 t.id 其实也能过但一旦 JOIN 了其他表的列就报错最稳妥的写法还是把涉及的列都列全。这个坑的具体表现和处理排错章节再展开。5. 部署与常见问题排查Tomcat 环境下五个必踩的坑本地开发环境跑得好好的部署到服务器就翻车这是 JSP 项目的常态。这五个坑我基本每次做这类系统都会遇到按「现象 → 原因 → 解决」列出来照着排查能省半天时间。5.1 MySQL 8 驱动连不上SSL 报错与 Public Key Retrieval现象Tomcat 启动后第一次访问页面日志报java.sql.SQLException: Public Key Retrieval is not allowed或者Unable to load authentication plugin caching_sha2_password再或者干脆是Communications link failure。原因网上很多 mysql 安装教程默认装的是 8.0但项目 WEB-INF/lib 里的驱动 jar 还是 5.x 时代传下来的驱动类名com.mysql.jdbc.Driver在 8.0 里已经改了。另一个原因是 MySQL 8.0 默认认证插件是 caching_sha2_password客户端第一次连接时要用 RSA 公钥交换密码URL 里没加 allowPublicKeyRetrieval 就直接拒绝。解决换mysql-connector-java-8.0.x.jar驱动类名改成com.mysql.cj.jdbc.DriverURL 里补齐下面这几个参数。MySQL 8.0 的连接参数里这五个是最关键的一组参数作用不设置的后果useSSLfalse关闭 SSL 握手连接变慢日志刷 SSL 警告allowPublicKeyRetrievaltrue允许客户端获取 RSA 公钥Public Key Retrieval is not allowedserverTimezoneAsia/Shanghai指定服务器时区时间字段差 8 小时characterEncodingutf8指定字符集中文乱码useUnicodetrue兼容旧版驱动标识某些驱动版本中文乱码5.2 中文乱码页面、连接、表三层字符集逐个查现象表单提交的中文入库后变成???或者某个 JSP 页面直接显示乱码。原因字符集要过三关任何一关不一致就乱码。第一关是 JSP 页面本身的编码第二关是 JDBC 连接 URL 里的 characterEncoding第三关是 MySQL 表的 CHARSET。最常见的是页面用 GBK、连接用 UTF-8、表用 latin1三层各说各话。解决三层统一成 UTF-8。JSP 第一行写% page pageEncodingUTF-8 %HTML 的 meta 标签也写 UTF-8连接 URL 带上 characterEncodingutf8建表统一CHARSETutf8mb4。如果是 Tomcat 8 之前的版本GET 请求的参数默认按 ISO-8859-1 解码需要在 server.xml 的 Connector 上加URIEncodingUTF-8改完重启 Tomcat。POST 请求则在 Servlet 里req.setCharacterEncoding(UTF-8)必须在读参数之前调用。5.3 JSP 404 与静态资源丢失部署路径的两种写法现象部署后访问http://ip:8080/项目名/首页正常点「教材管理」链接报 404或者页面排版全乱CSS、图片全加载不出来。原因代码里把路径写死了。有人写a href/book/list本地部署在 ROOT 下访问的是根路径没问题部署到带上下文路径的项目目录后/book/list指向的是 8080 端口根而不是/项目名/book/list自然 404。另一个常见原因是把 JSP 页面直接放在 webapp 根目录静态资源引用写成了相对路径。解决所有路径都用request.getContextPath()拼前缀。JSP 页面里用${pageContext.request.contextPath}Servlet 跳转用req.getContextPath()。如果项目要求 JSP 不能直接被浏览器访问把 JSP 全部挪到WEB-INF/jsp目录下外部 URL 访问不到只能通过 Servlet forward 进入安全性提升一大截代价是路径必须全部走 Servlet 路由工作量稍微多一点点。5.4 Tomcat 端口占用与内存溢出现象启动 Tomcat 报Port 8080 was already in use或者系统跑几天后页面越来越慢最后报java.lang.OutOfMemoryError: PermGen spaceTomcat 7或MetaspaceTomcat 8。原因端口被占通常是有另一个 Tomcat 实例或者机器上装了别的 Web 服务占着 8080。内存溢出则是反复热部署导致的类加载器泄漏Tomcat 7 默认的 PermGen 只有 64MJSP 多、部署次数多就爆。解决端口冲突先netstat -ano | findstr 8080看是哪个进程确认不是重要服务就改 Tomcat 的 conf/server.xml 端口三个地方都要改Connector port8080、Server port8005、Connector port8009。内存问题改启动参数Tomcat 的 bin 目录下新建 setenv.batWindows或 setenv.shLinux写JAVA_OPTS-Xms256m -Xmx1024m -XX:MetaspaceSize128m -XX:MaxMetaspaceSize256m。另外养成习惯改完 JSP 或 Java 代码重启整个 Tomcat 进程不要图省事用 Manager 页面的热部署那是 Metaspace 泄漏的最快路径。5.5 MySQL 5.7 的 ONLY_FULL_GROUP_BY 报错现象跑 4.3 节那种报表 SQL 时MySQL 直接报Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column ... which is not functionally dependent on columns in GROUP BY clause。原因MySQL 5.7 之后 sql_mode 默认启用 ONLY_FULL_GROUP_BY它不认「SELECT 里的普通列没在 GROUP BY 里出现」这种宽松写法而 5.6 及之前是允许的。解决把 SELECT 里出现的非聚合列全部写进 GROUP BY比如前面的报表 SQL 改成GROUP BY t.id, t.book_name。网上有的教程让你改 MySQL 的 sql_mode 把 ONLY_FULL_GROUP_BY 去掉我强烈不建议——那是把黑匣子打开了以后别的开发在服务器上跑一条不规范的 SQL 也能过出了问题排查难度翻倍。改自己的查询语句比改全局配置安全得多。6. 两个加分项让教材系统更好用Excel 批量导入与库存预警CRUD 跑通、部署稳定这只是及格。教务处老师真正愿意每天打开系统靠的是两个功能开学季几百本教材不用一本本手输库存快见底时系统主动提醒。6.1 用 POI 批量导入教材xlsx 解析与批处理每年开学前采购清单是一张 Excel几百行数据手工录进系统要录一天。用 Apache POI 解析 xlsx批处理插入几秒钟搞定。public int importBooks(InputStream in) throws Exception { Workbook wb WorkbookFactory.create(in); // 自动识别 xls 和 xlsx Sheet sheet wb.getSheetAt(0); String sql INSERT INTO textbook(isbn, book_name, author, publisher, unit_price) VALUES (?,?,?,?,?); try (Connection conn DBHelper.getConnection()) { conn.setAutoCommit(false); // 整个导入是一个事务 try (PreparedStatement ps conn.prepareStatement(sql)) { int count 0; // 第 0 行是表头从第 1 行开始读 for (int i 1; i sheet.getLastRowNum(); i) { Row row sheet.getRow(i); if (row null) continue; // 空行直接跳过 String isbn getCellValue(row.getCell(0)); String name getCellValue(row.getCell(1)); if (isbn null || name null) continue; // 关键列为空跳过 int priceInCents parsePrice(getCellValue(row.getCell(3))); ps.setString(1, isbn); ps.setString(2, name); ps.setString(3, getCellValue(row.getCell(2))); ps.setInt(4, priceInCents); ps.addBatch(); if (count % 500 0) { ps.executeBatch(); // 500 条一批避免一条条提交 } } ps.executeBatch(); conn.commit(); return count; } catch (Exception e) { conn.rollback(); throw e; } } }POI 这里有一个血泪经验Excel 里 ISBN 如果存成数字格式POI 读出来会变成9.78102E11这种科学计数法导入的 isbn 全是错的。解决方法是让教务处把 ISBN 列设为文本格式或者导入前在模板里给 ISBN 列加一个「文本」的单元格格式。getCellValue方法要判断 CellType字符串返回字符串数字按 BigDecimal 转字符串不能用row.getCell(0).toString()一把梭。整个导入包在一个事务里任何一行格式错误全批回滚导入失败时把错误行列号返回给用户比导入一半卡住强得多。6.2 库存预警查询让最缺的书排在最前面库存预警不复杂一个 SQL 搞定查出所有available warn_threshold的教材按可用库存升序排让最缺的书出现在首页列表最上面。SELECT t.book_name, s.available, s.warn_threshold FROM textbook_stock s JOIN textbook t ON t.id s.textbook_id WHERE s.available s.warn_threshold ORDER BY s.available ASC;这个查询在首页放一个「库存预警」卡片数据量不会超过几十条性能完全不是问题。ORDER BY available ASC 是这里的关键排序库存越少越靠前教务老师打开首页第一眼就知道哪本书最紧急。如果学校要求邮件提醒常见做法是加一个定时任务每天跑一次这个查询查出来就发邮件——但为了一套学校系统引入 Quartz 定时任务框架性价比不高我一般会在首页做醒目的红字提醒让老师在浏览系统时天然看到比邮件更直接。我给学校做完这套系统后养成的习惯是每次出库先确认事务边界每次上线先备份数据库每次做数据导入先拿两行样例数据跑通再放全量。这套 JavaJSPMySQL 的组合不新潮但胜在直白——任何一个后来接手的人打开源码十分钟就能顺着表结构和 Servlet 路由理清一条业务链路这比任何高深架构都更能让系统活得久。希望帮到你。本文还有配套的精品资源点击获取
返回列表