☰
JavaWeb老项目实战:JSP+Servlet+JDBC+MySQL后台管理系统开发
2026/10/9 9:05:31 网站建设 项目流程

简介:一套基于 JSP+Servlet+JDBC+MySQL 构建的后台管理系统原生源码,面向 Java Web 初学者或课程设计人群,完整覆盖用户登录、注册、产品信息增删改查、分页展示、图片上传以及未登录会话的过滤器拦截等典型业务场景。压缩包共92个文件,包含14个Java源文件、12个JSP页面、28个class编译文件及SQL脚本、工程配置与导入说明文档,整体大小仅3.04MB,便于快速部署学习。资源附带了数据库脚本和工程导入步骤,可对照源码梳理 Servlet 请求处理、JDBC 数据库操作、Session 状态管理等关键实现,代码结构清晰、注释充分,方便二次开发。已有3371人浏览学习,适合需要掌握原生 Java Web 开发流程、完成课程设计或搭建个人管理项目的开发者参考。

1. 接手JavaWeb老项目前,先看明白这套原生栈

不是所有后台管理系统都用得上微服务。JSP+Servlet+JDBC+MySQL 这套组合,现在依然跑在大学课程设计、中小企业内部系统和外包交付里。它的核心流程直白:浏览器发 HTTP 请求,Servlet 接收并调用 JDBC 访问 MySQL,JSP 把结果渲染回页面。没有 Spring 管 Bean,没有 MyBatis 拼 SQL,每一层都露在外面。

这套技术栈最大的价值是你能亲眼看到请求怎么被处理、Session 怎么维持、SQL 怎么执行。对想学 JavaWeb 的人来说,它反而是最没有黑匣子的方案。下面按后台管理系统常见功能——登录、注册、分页列表、图片上传——把实现顺序、参数和边界说清楚,照着搭一次就能组织这类源码。适合被框架搞晕的初学者,也适合要快速接手老项目的一线开发。

2. 数据模型与登录注册:先让用户进得来

2.1 用户表和图片表:字段少不是简陋,是够用

这类后台系统逃不开两张表:用户表管账号权限,图片表管上传文件和用户的关联。我一般不建议一上来就建十张表把权限模型做全,那会让 JDBC 代码量翻倍。常见做法是先让用户能注册、能登录,后面要扩展再往表里加字段。

CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(32) DEFAULT '', role TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_image ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, original_name VARCHAR(128), saved_name VARCHAR(64), file_path VARCHAR(255), upload_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里要解释几个看着不起眼、实际很关键的字段设计。password 用 VARCHAR(64) 就是为了放 SHA-256 摘要,SHA-256 输出 64 个十六进制字符,你要是写成 VARCHAR(32) 后面存摘要就会报 Data too long。username 加了 UNIQUE,注册时数据库层面直接帮你挡住重复用户名,代码里就不用额外查一遍再插一条。file_path 存的是文件保存后的磁盘路径或访问路径,不是用户上传的原始文件名——原始名字单独放在 original_name,留作展示用。

建表时还容易忽略的是字符集。DEFAULT CHARSET=utf8mb4 而不是 utf8,因为 utf8 在 MySQL 里最多存 3 字节,遇到生僻字或 Emoji 会存不进去。如果已经建了表,就执行 ALTER TABLE sys_user CONVERT TO CHARACTER SET utf8mb4。这张表建好,后面的 JDBC、注册登录就都有地基了。

2.2 JDBC 连接管理:先跑通 DriverManager,再谈连接池

JDBC 连接 MySQL 最直白的写法是每次请求都 DriverManager.getConnection。这个做法能跑通,但并发一上来就卡,因为每次都要经历建连、握手、鉴权这一整套流程。常见做法是先写一个静态工具类把连接和关闭统一管起来,等项目真的扛不住并发再换成连接池,换的成本很低。

public class DbUtil { private static final String URL = "jdbc:mysql://127.0.0.1:3306/admin_db" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "你自己的密码"; private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new RuntimeException("找不到 MySQL 驱动,检查 mysql-connector-java 是否在 WEB-INF/lib", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement st, ResultSet rs) { 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) {} } } }

驱动类名是个经典坑:MySQL 5.x 用 com.mysql.jdbc.Driver,MySQL 8.x 用 com.mysql.cj.jdbc.Driver。你如果拿 8.x 的 jar 配 5.x 的类名,启动直接 ClassNotFoundException。URL 里的 characterEncoding=utf8 负责连接层的中文传输,serverTimezone=Asia/Shanghai 解决 MySQL 8 的时区校验,没加这个参数连接就会在握手阶段抛异常,后面第 5 章会细说。

关闭资源的顺序必须反着来:先关 ResultSet,再关 Statement,最后关 Connection。Connection 不关的话,MySQL 默认的 wait_timeout 会累计一堆睡眠连接,很多新手系统跑几天就报 Too many connections,就是这么来的。

2.3 注册链路:校验、摘要、回显三步走到位

注册接口最常见的翻车点不是 SQL 写错,而是只做了跳转没做错误回显。用户注册失败看到白屏或异常页面,体验直接崩。这里我先说前端,JSP 页面里用一段简单的 JavaScript 做第一层校验,减少无效请求。

<script> function checkRegisterForm() { var name = document.getElementById("username").value; var pwd = document.getElementById("password").value; if (name.length < 4) { alert("用户名至少4位"); return false; } if (pwd.length < 6) { alert("密码至少6位"); return false; } return true; } </script> <form action="${pageContext.request.contextPath}/register" method="post" onsubmit="return checkRegisterForm()"> <input type="text" id="username" name="username" /> <input type="password" id="password" name="password" /> <button type="submit">注册</button> </form>

前端校验只是第一道关卡,真正的决定权在 Servlet。因为用户可以绕过页面直接抓包提交,Servlet 里必须把参数校验再完整做一遍。下面这段注册逻辑,把校验、失败回显、成功跳转分得清清楚楚。

@WebServlet("/register") public class RegisterServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); if (username == null || password == null || username.trim().length() < 4 || password.trim().length() < 6) { req.setAttribute("msg", "用户名至少4位,密码至少6位"); req.getRequestDispatcher("/register.jsp").forward(req, resp); return; } UserDao dao = new UserDao(); try { boolean ok = dao.insert(username, password); if (ok) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } else { req.setAttribute("msg", "用户名已存在"); req.getRequestDispatcher("/register.jsp").forward(req, resp); } } catch (Exception e) { log("注册失败", e); req.setAttribute("msg", "服务器开小差了,联系管理员"); req.getRequestDispatcher("/register.jsp").forward(req, resp); } } }

setCharacterEncoding("UTF-8") 必须在读取任何参数之前执行,写在 getParameter 之后就没效果了。失败用 forward 带 msg 回注册页,成功用 sendRedirect 去登录页,这个区别在于转发是服务端内部跳转,地址栏不变,用户刷新会把表单再提交一次;重定向是浏览器重新发请求,刷新不会重复注册。再来看 Dao 层的插入,重点在密码摘要和参数化 SQL。

public class UserDao { public boolean insert(String username, String password) throws Exception { String passwordHash = DigestUtils.sha256Hex(password); String sql = "INSERT INTO sys_user (username, password) VALUES (?, ?)"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, passwordHash); return ps.executeUpdate() == 1; } } }

用 PreparedStatement 而不是字符串拼接,这是 Java 基础里最值得强调的一点。字符串拼接的 INSERT INTO ... VALUES ('" + username + "', ...) 一旦用户名里带上单引号,SQL 语句结构就被破坏了,这就是第 5 章要展开的注入问题。PreparedStatement 把参数用 ? 占位,传值交给驱动处理,从根本上避开拼接。密码摘要用 SHA-256,存的是不可逆的哈希值,就算数据库泄露,密码原文也不会直接暴露。try-with-resources 会自动关闭 PreparedStatement 和 Connection,代码比手动 finally 干净得多。

2.4 登录与 Session:用 Filter 拦 URL,不是藏页面

登录成功后要做的第一件事,是把用户身份放进 Session。Session 是服务端为每个会话分配的一段存储空间,客户端靠一个 JSESSIONID 的 Cookie 认领它。JavaWeb 里最常见的登录判断是直接从 session 里取用户对象,取不到就重定向回登录页。

@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String path = req.getRequestURI().substring(req.getContextPath().length()); if (path.equals("/login.jsp") || path.equals("/login") || path.equals("/register.jsp") || path.equals("/register") || path.startsWith("/static/")) { chain.doFilter(request, response); return; } HttpSession session = req.getSession(false); if (session == null || session.getAttribute("user") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

这段 Filter 的逻辑是:白名单放行登录、注册和静态资源,其余所有请求都检查 session。这里必须用 req.getSession(false),而不是 req.getSession(),因为 false 版本在没有 session 时返回 null,不会强制创建一个新会话;用 getSession() 会把未登录的请求也变成一个已建立会话的状态,白白浪费服务端内存。login.jsp 和 register.jsp 本身要放行,否则用户还没登录就被拦去了登录页。

这里顺带说一句,有些项目会做“根据角色隐藏菜单”之类的页面控制,那只算是用户体验层面的处理,真正拦住未授权访问的是 Filter。Filter 做鉴权的另一个好处是不用改每个 Servlet——登录逻辑散落在各个页面里,改一处漏一处,Filter 统一后,所有请求在进 Servlet 之前就被流程走到了。如果你要拦截的是 /admin/ 开头的路径,把 @WebFilter 的路径改成 @WebFilter("/admin/*"),再补上白名单判断就行。放行静态资源时要注意,CSS、JS 和图片路径要和后台页面区分开,避免登录页样式被拦导致裸界面。

3. 分页列表的实现顺序:SQL、封装、JSP 分页条

3.1 分页 SQL 的边界:LIMIT 参数不是猜出来的

后台管理系统里用得最频繁的查询就是分页列表。MySQL 分页的核心是一条带 LIMIT 的 SQL:LIMIT 后面跟两个参数,第一个是跳过多少条,第二个是取多少条。很多人栽在偏移量的计算上,其实公式是 offset = (currentPage - 1) * pageSize。

-- currentPage = 2, pageSize = 10 SELECT id, username, nickname, role FROM sys_user ORDER BY id DESC LIMIT 10, 10;

LIMIT 10, 10 的含义是从第 11 条开始取 10 条,也就是第二页。第一页是 LIMIT 0, 10,写 LIMIT 10, 10 就是直接跳过了第一页数据,漏了十条。这里还有个新手常犯的错:ORDER BY 一定要写,不写的话 MySQL 不保证返回顺序,翻页的时候数据可能闪现,用户会觉得你在开奖。

关注分页时要注意两个边界:一是 currentPage <= 0 时,把它强行拽回 1;二是 currentPage 大于 totalPage 时,拽回 totalPage 或返回空列表。Java 代码里我会先做一次标准化,防止用户手动改 URL 的 currentPage=999 把查询拖崩。每次查询之前算一遍 totalPage 其实非常贵吗?不贵,后台系统数据量在几十万以内时,COUNT(*) 加上一次索引扫描完全可以接受,不用上来就搞游标分页那种高级玩法。

3.2 PageResult 封装:把页数据和总数一起传到 JSP

分页接口如果只回一个 List,前端就没法画页码条,因为不知道总共有多少页。最常见做法是定义一个 PageResult 类,把当前页数据、当前页码、每页大小、总条数、总页数全部装进去,Service 层算好,Servlet 塞进 request,JSP 用 EL 表达式读取。

public class PageResult<T> { private List<T> list; private int currentPage; private int pageSize; private int totalCount; public PageResult(List<T> list, int currentPage, int pageSize, int totalCount) { this.list = list; this.currentPage = currentPage; this.pageSize = pageSize; this.totalCount = totalCount; } public int getTotalPage() { if (pageSize <= 0) return 0; return (totalCount + pageSize - 1) / pageSize; } }

getTotalPage 这里用了 (totalCount + pageSize - 1) / pageSize,这是向上取整的经典写法。如果总共有 95 条、每页 10 条,直接 95 / 10 得 9,但实际需要 10 页。加上 pageSize - 1 之后,(95 + 9) / 10 = 10,正好。你要是不想记这个公式,用 (totalCount % pageSize == 0) ? totalCount / pageSize : totalCount / pageSize + 1 也行,只是多一步判断。

Dao 层的分页查询要写两个方法,一个查当前页数据,一个查总数。

public List<User> findPage(int offset, int pageSize) throws Exception { String sql = "SELECT id, username, nickname, role FROM sys_user ORDER BY id DESC LIMIT ?, ?"; List<User> list = new ArrayList<>(); try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, offset); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { User u = new User(); u.setId(rs.getLong("id")); u.setUsername(rs.getString("username")); u.setNickname(rs.getString("nickname")); list.add(u); } } } return list; } public int count() throws Exception { String sql = "SELECT COUNT(*) FROM sys_user"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getInt(1); } } return 0; }

注意分页查询里用 ps.setInt 绑参数,不用把 offset 拼进 SQL 字符串,原因跟注册那章一样——避免被注入。Servlet 拿到请求参数后,先做一次标准化再交给 Dao:currentPage 取不到就默认 1,pageSize 默认 10,超过 100 就强行改成 100,防止有人调个 999999 把服务拖慢。

3.3 JSP 分页条:页码生成与当前页高亮

数据齐了,剩下的就是把分页条画出来。用 JSTL 的 c:forEach 循环生成页码,当前页用 c:when 高亮,上一页下一页按边界条件决定是否展示链接。

<%-- list.jsp 的表格和分页条 --%> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> <table> <tr><th>ID</th><th>用户名</th><th>昵称</th><th>角色</th></tr> <c:forEach items="${pageResult.list}" var="user"> <tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.nickname}</td> <td>${user.role == 1 ? '管理员' : '普通用户'}</td> </tr> </c:forEach> </table> <div class="pagination"> <c:if test="${pageResult.currentPage > 1}"> <a href="list?currentPage=${pageResult.currentPage - 1}">上一页</a> </c:if> <c:forEach begin="1" end="${pageResult.totalPage}" var="i"> <c:choose> <c:when test="${i == pageResult.currentPage}"> <span class="current">${i}</span> </c:when> <c:otherwise> <a href="list?currentPage=${i}">${i}</a> </c:otherwise> </c:choose> </c:forEach> <c:if test="${pageResult.currentPage < pageResult.totalPage}"> <a href="list?currentPage=${pageResult.currentPage + 1}">下一页</a> </c:if> </div>

c:forEach 的 begin 和 end 能直接用 EL 表达式的整数,省了在 JSP 里写 scriptlet 的麻烦。currentPage 高亮用的是 c:choose + c:when,这比两个 c:if 更干净——c:if 没有 else 分支,两个独立判断容易出现都成立或都不成立的边界问题。上一页的展示条件 currentPage > 1,如果当前已是第一页,不显示链接;下一页展示条件 currentPage < totalPage,已是最后一页就只显示页码不显示链接。

这里有个细节我要提醒:分页条生成全部页码,如果总页数真的到了几百页,页面会被页码撑爆。常见做法是想一下封顶策略,比如生成 segment = currentPage 附近的页码范围,但会加重 JSP 的复杂度。后台管理系统数据量在几千条以下时,我一般直接全量生成,不去做花活。数据量上来了先考虑加查询条件,而不是先改造分页条。另外分页链接里如果你想保留页大小,可以带 &pageSize=10 参数,Servlet 读出来作为默认值,这样列表能支持每页条数切换。

4. 图片上传与回显:multipart、路径策略、访问控制

4.1 multipart 表单:Servlet 怎么拿到二进制文件

图片上传的起点在浏览器端,form 表单必须设置 enctype="multipart/form-data"。不设置这个属性,文件框只会把文件名当作普通表单字段传上去,服务端拿不到二进制内容。这是很多人的第一个翻车点:Servlet 里取文件,request.getParameter("file") 返回 null,一头雾水。

<form action="${pageContext.request.contextPath}/upload" method="post" enctype="multipart/form-data"> <input type="text" name="description" /> <input type="file" name="file" /> <button type="submit">上传</button> </form>

服务端在 Servlet 3.0 之后可以用 @MultipartConfig 注解声明这个 Servlet 接收 multipart 请求,然后通过 request.getPart("file") 获取文件部分。Servlet 3.0 之前得靠 commons-fileupload 这个第三方库解析,那套 API 可读性差多了。现在 Tomcat 8.5 以上基本都支持 Servlet 3.1,直接原生接口就行。

@WebServlet("/upload") @MultipartConfig( maxFileSize = 5 * 1024 * 1024, maxRequestSize = 20 * 1024 * 1024, fileSizeThreshold = 2 * 1024 * 1024 ) public class UploadServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); Part filePart = req.getPart("file"); if (filePart == null || filePart.getSize() == 0) { req.setAttribute("msg", "请选择文件"); req.getRequestDispatcher("/upload.jsp").forward(req, resp); return; } String submittedFileName = filePart.getSubmittedFileName(); System.out.println("收到的文件名: " + submittedFileName); // 后续步骤:校验、存储、写库 } }

maxFileSize 是单个文件 5MB,maxRequestSize 是整个请求 20MB,这两个参数别把值想反了。fileSizeThreshold=2MB 表示文件超过 2MB 就直接写磁盘,小于 2MB 留在内存,这是性能取舍。getSubmittedFileName() 是 Servlet 3.1 提供的方法,能直接拿到浏览器传来的原始文件名,在 Servlet 3.0 环境下你得自己解析 Content-Disposition 头,那个解析代码很容易出错。老项目如果还在 Servlet 3.0,建议先升级容器而不是去写那套解析代码。

4.2 存储路径选型:项目内目录还是外部目录

文件存哪,这决定整个系统后续部署的灵活性。最容易想到的方案是存到应用项目的 upload 目录下,但这里有个致命问题:Tomcat 重新部署 war 包时,旧应用目录会被清掉,上传的图片全部蒸发。血泪经验告诉我,生产环境不要在应用目录里存用户上传文件。

存储位置优点缺点适用场景
应用目录内路径短、访问方便重新部署丢文件,扩容难纯演示、临时使用
独立外部目录部署不丢、好备份需要配置回显接口正式后台管理系统

我一般会在服务器固定一个目录,比如 Linux 上 /opt/admin_upload,Windows 上 D:\admin_upload,把路径放到一个配置类里,不要硬编码散落到各个 Servlet。

public class UploadConfig { // 从外部配置文件读取,这里演示直接用常量 public static final String DIR = "/opt/admin_upload/"; public static String saveFile(Part part, String originalName) throws IOException { String ext = ""; int dotIndex = originalName.lastIndexOf("."); if (dotIndex >= 0) { ext = originalName.substring(dotIndex).toLowerCase(); } String newName = UUID.randomUUID().toString().replace("-", "") + ext; String fullPath = DIR + newName; part.write(fullPath); return fullPath; } }

part.write(fullPath) 是 Servlet 提供的落盘方法,它会按 Part 里封装的输入流直接写到指定路径。这里把文件名重写成 UUID,主要目的是避免两个用户上传同名文件互相覆盖。newName 只保留了原始文件的扩展名,却丢了原始文件名,所以数据库里要留存 original_name,页面展示时给用户看原始名,物理文件用 UUID 名。

还有个容易漏的点:文件扩展名要转成小写,否则用户传一个 .JPG 上来,扩展名判断的时候白名单匹配会失败。存储路径按天分目录也是一种常见做法,比如 DIR + "2025/06/" + newName,压缩备份和清理过期文件都方便,不过初学者先做到独立目录就够用,不要一开始就把目录层次做复杂。

4.3 图片回显:img 标签访问不到磁盘路径的解法

图片存到外部目录之后,JSP 里的 img 标签没法直接指向磁盘路径。浏览器能访问的是 HTTP 路径,所以需要写一个 Servlet 专门做图片回显。这是整个图片上传流程里最容易被忽略的一环,很多新人把文件存好数据库写好后,发现页面图片全部裂开。

@WebServlet("/image") public class ImageServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String id = req.getParameter("id"); if (id == null || id.isEmpty()) { resp.sendError(400); return; } ImageDao dao = new ImageDao(); SysImage image = null; try { image = dao.findById(Long.parseLong(id)); } catch (NumberFormatException e) { resp.sendError(400); return; } if (image == null) { resp.sendError(404); return; } File dir = new File(UploadConfig.DIR); File file = new File(image.getFilePath()); // 路径穿越检查:确保目标文件在允许的目录内 String baseDir = dir.getCanonicalPath(); String targetPath = file.getCanonicalPath(); if (!targetPath.startsWith(baseDir)) { resp.sendError(403); return; } resp.setContentType("image/jpeg"); Files.copy(file.toPath(), resp.getOutputStream()); } }

JSP 里的使用方式就变成:img src="${pageContext.request.contextPath}/image?id=1"。这个 Servlet 把数据库里的文件路径取出来,校验路径合法性后,把文件内容通过输出流写给浏览器。注意这里用 getCanonicalPath() 做了一次路径规范化,防止 file_path 里带上 ../ 这样的序列逃出上传目录,这就是常说的路径穿越漏洞——没有这道检查,攻击者可以构造 id 对应一条指向 /etc/passwd 的记录,把敏感文件读出去。

contentType 先按 image/jpeg 写死,生产环境建议根据文件的真实扩展名动态设置,比如 png 对应 image/png,gif 对应 image/gif。设置错了浏览器可能提示下载而不是直接展示。整个流程到这里闭环:用户上传 -> Servlet 存外部目录 -> 数据库记录路径 -> JSP 请求 /image 接口 -> Servlet 读文件返回。图片访问接口要顺手加个日志,至少要记录谁在什么时候访问了哪张图,方便排查异常访问。

5. 后台管理系统避坑清单:四个高频翻车现场

5.1 SQL 注入:登录接口被引号拼出来的事故

现象:在某次测试时,我在登录框用户名的位置填了 admin' or '1'='1,密码随便填,结果直接进了后台。更严重的是,如果攻击者对表结构有了解,还能通过报错信息把数据库版本、表名一点点撬出来。

原因:UserDao 里使用了字符串拼接的 SQL,比如 SELECT * FROM sys_user WHERE username='" + username + "' AND password='" + password + "'。当 username 的内容变成 admin' or '1'='1,拼接出来的 SQL 就变成 WHERE username='admin' or '1'='1',恒真,密码校验直接被绕过了。

解决:所有跟用户输入相关的 SQL 一律用 PreparedStatement 占位符。这是 Java 基础里的老生常谈,执行姿势却是最容易偷懒的地方。注册、登录、分页、详情查询,任何一条 SQL 都不能靠字符串拼参数。如果你接手的老项目里全是拼出来的语句,先全面排查一遍,哪怕逻辑再多也要改完,这是发布前必须完成的事项。

5.2 文件类型伪装:改个扩展名就能上传了

现象:上传图片的接口只校验了文件后缀,jpg、png、gif 都能过。后来有人传了一个文件名 shell.jsp 的“图片”,服务端照样接收,一访问就直接执行了 JSP 脚本,服务器权限被人拿到。

原因:扩展名是用户可控的,改个名字就能骗过后端校验。而且我把文件存在了应用目录内,Tomcat 会把它当作动态资源解析,等于把攻击者的脚本放到了可执行目录里。

解决:两层夹击。第一层,存储位置放在应用目录外,这样即使文件里带着 JSP 脚本,Tomcat 也没有机会把它当 Servlet 解析,只能用 /image 这种 Servlet 去读二进制内容输出。第二层,校验文件真实类型。扩展名继续做白名单,但真正决定是否放行的依据是文件头,也就是 magic number——JPG 文件头是 FFD8FF,PNG 是 89504E47。一个小片段就能完成识别:

byte[] header = new byte[8]; try (InputStream in = part.getInputStream()) { in.read(header, 0, 8); } String hex = bytesToHex(header); boolean isJpg = hex.startsWith("FFD8FF"); boolean isPng = hex.startsWith("89504E47");

只看扩展名的校验就是一张纸,攻击者可以伪造任意扩展名。文件头校验不是绝对安全,但配合随机文件名、独立存储目录、Servlet 回显三层,已经能把常规的 webshell 攻击路径堵死。这里顺带说一句,保存文件名用 UUID 重新生成,等于把用户可控的“文件名”从存储路径里彻底去掉,就算原文件名里带路径分隔符或 .. 也没有意义了。

5.3 中文乱码:GET 和 POST 是两个战场

现象:注册时输入中文用户名,保存到数据库后变成 ??????,页面上显示一堆问号。更诡异的是,同一个程序里 POST 过来的中文正常,GET 的搜索参数又乱掉,修一个另一个就坏。

原因:这是三层编码没统一。第一层,请求体的编码没设置,POST 参数靠 request.setCharacterEncoding("UTF-8") 解决,这行必须放在读取参数之前;第二层,GET 的参数在 Tomcat 那层是被 URL 带过来的,默认按 ISO-8859-1 解码,setCharacterEncoding 对 GET 不生效;第三层,JDBC 连接 URL 里没加 characterEncoding=utf8,Java 往 MySQL 传输时又换成项目默认编码。

解决:趁早把这几处一次性改对。POST 在 Servlet 的 doPost 开头写 req.setCharacterEncoding("UTF-8"),注意只写一次,写在参数读取前。GET 去 Tomcat 的 conf/server.xml 里给 Connector 加 URIEncoding="UTF-8",我一般会同时加 useBodyEncodingForURI="true",让 body 和 URL 保持同一种编码。JDBC 连接上,DbUtil 的 URL 里保证有 useUnicode=true&characterEncoding=utf8,MySQL 表结构已经用 utf8mb4,这三层对齐后中文基本不会再乱。代码层的万能手段是 new String(str.getBytes("ISO-8859-1"), "UTF-8"),但这是后悔药,治标不治本,生产环境不建议养这种编码转换的习惯。

5.4 MySQL 8 驱动和时区:连接失败的两连击

现象:用 mysql-connector-java 5.x 的 jar 连 MySQL 8,启动时报 ClassNotFoundException: com.mysql.jdbc.Driver。换成 8.x 的 jar 之后,又报 The server time zone value '�й���ʱ��' is unrecognized,连接还是建立不起来。

原因:MySQL 8 把驱动类名改成了 com.mysql.cj.jdbc.Driver,旧类名在新 jar 里虽然兼容但返回的是 deprecated 的包装类。时区的报错是因为 MySQL 8 默认使用系统时区,而本机时区格式驱动认不出来,JDBC 驱动需要明确指定一个可识别的时区。

解决:驱动 jar 和类名要配套,MySQL 8.x 项目用 8.x 的 mysql-connector-j,代码里写 com.mysql.cj.jdbc.Driver。连接 URL 后面追加 serverTimezone=Asia/Shanghai。如果你用的是 MySQL 8 默认的 caching_sha2_password 认证插件,同时还可能报 Public Key Retrieval is not allowed,解决手段是在 URL 里加 allowPublicKeyRetrieval=true,或者干脆创建用户时指定 mysql_native_password 认证。这几个参数看着像玄学,实际都是 MySQL 8 的认证和时区机制在起作用。安装配置数据库时顺便把 character_set_server 设成 utf8mb4,可以省掉后面一堆乱码问题。

6. 从跑通到交付:部署顺序、验证清单和几个调试习惯

拿到这套源码并让它跑起来,我建议按这个顺序走:先看数据库脚本,确认 MySQL 版本,执行建表语句,再改 DbUtil 里的数据库地址、用户名、密码。然后把它打成 war 包丢进 Tomcat 的 webapps,启动 Tomcat,访问 /login.jsp。第一次启动最容易出的问题就是驱动 jar 没在 WEB-INF/lib 下——直接用开发环境的依赖,却没打进程 war 包里,运行时必然 ClassNotFoundException。

验证清单可以按功能维度过:注册新用户看数据库是否有记录,密码字段是否能看出是哈希而非明文;登录成功后直接访问一个受保护页面,比如 /admin/list.jsp,确认没登录时会被重定向到 login.jsp,而不是返回页面内容;分页列表里手动把 URL 的 currentPage 改成 0 和负数,确认系统不会崩;上传一张超过 5MB 的图,确认被 maxFileSize 挡住并给出友好提示。这套流程跑完,基本可以交付给下一个环节了。

调试上面,我一直有个习惯:先看日志,再翻代码,最后才猜配置。很多 Servlet 报错信息里藏着完整堆栈,分析问题时把异常堆栈从头看到尾。连接数据库失败时先确认 MySQL 服务活着,用命令行 mysql -u root -p 连一下,分清是服务挂掉还是驱动问题。部署升级时别覆盖上传目录,Tomcat 的 webapps 和外部存储目录严格分开,这是我改过最多次的地方。

以后你再看到这类 JSP+Servlet+JDBC 的老项目,心里先有个底:它把 HTTP、会话、SQL 这几件事都摊在了面上。想提升性能,就把 DriverManager 换成连接池;想减少重复代码,把公共的 Servlet 逻辑抽出来;想追新,就把 JSP 替换成模板引擎,或者整体往 Spring Boot 迁。这个方向真正值得投入的地方,反而在于你能把底层机制讲清楚——那些面试题里问的 Session、Filter、SQL 注入,你在这种项目里全都亲手敲过。希望帮到你。

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

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

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

立即咨询