
简介一套基于 JSPServletJDBCMySQL 构建的后台管理系统原生源码面向 Java Web 初学者或课程设计人群完整覆盖用户登录、注册、产品信息增删改查、分页展示、图片上传以及未登录会话的过滤器拦截等典型业务场景。压缩包共92个文件包含14个Java源文件、12个JSP页面、28个class编译文件及SQL脚本、工程配置与导入说明文档整体大小仅3.04MB便于快速部署学习。资源附带了数据库脚本和工程导入步骤可对照源码梳理 Servlet 请求处理、JDBC 数据库操作、Session 状态管理等关键实现代码结构清晰、注释充分方便二次开发。已有3371人浏览学习适合需要掌握原生 Java Web 开发流程、完成课程设计或搭建个人管理项目的开发者参考。1. 接手JavaWeb老项目前先看明白这套原生栈不是所有后台管理系统都用得上微服务。JSPServletJDBCMySQL 这套组合现在依然跑在大学课程设计、中小企业内部系统和外包交付里。它的核心流程直白浏览器发 HTTP 请求Servlet 接收并调用 JDBC 访问 MySQLJSP 把结果渲染回页面。没有 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里要解释几个看着不起眼、实际很关键的字段设计。password 用 VARCHAR(64) 就是为了放 SHA-256 摘要SHA-256 输出 64 个十六进制字符你要是写成 VARCHAR(32) 后面存摘要就会报 Data too long。username 加了 UNIQUE注册时数据库层面直接帮你挡住重复用户名代码里就不用额外查一遍再插一条。file_path 存的是文件保存后的磁盘路径或访问路径不是用户上传的原始文件名——原始名字单独放在 original_name留作展示用。建表时还容易忽略的是字符集。DEFAULT CHARSETutf8mb4 而不是 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 ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/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.DriverMySQL 8.x 用 com.mysql.cj.jdbc.Driver。你如果拿 8.x 的 jar 配 5.x 的类名启动直接 ClassNotFoundException。URL 里的 characterEncodingutf8 负责连接层的中文传输serverTimezoneAsia/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 methodpost onsubmitreturn checkRegisterForm() input typetext idusername nameusername / input typepassword idpassword namepassword / button typesubmit注册/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 的 SQLLIMIT 后面跟两个参数第一个是跳过多少条第二个是取多少条。很多人栽在偏移量的计算上其实公式是 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 的 currentPage999 把查询拖崩。每次查询之前算一遍 totalPage 其实非常贵吗不贵后台系统数据量在几十万以内时COUNT(*) 加上一次索引扫描完全可以接受不用上来就搞游标分页那种高级玩法。3.2 PageResult 封装把页数据和总数一起传到 JSP分页接口如果只回一个 List前端就没法画页码条因为不知道总共有多少页。最常见做法是定义一个 PageResult 类把当前页数据、当前页码、每页大小、总条数、总页数全部装进去Service 层算好Servlet 塞进 requestJSP 用 EL 表达式读取。public class PageResultT { private ListT list; private int currentPage; private int pageSize; private int totalCount; public PageResult(ListT 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 ListUser findPage(int offset, int pageSize) throws Exception { String sql SELECT id, username, nickname, role FROM sys_user ORDER BY id DESC LIMIT ?, ?; ListUser 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 拿到请求参数后先做一次标准化再交给 DaocurrentPage 取不到就默认 1pageSize 默认 10超过 100 就强行改成 100防止有人调个 999999 把服务拖慢。3.3 JSP 分页条页码生成与当前页高亮数据齐了剩下的就是把分页条画出来。用 JSTL 的 c:forEach 循环生成页码当前页用 c:when 高亮上一页下一页按边界条件决定是否展示链接。%-- list.jsp 的表格和分页条 --% % taglib urihttp://java.sun.com/jsp/jstl/core prefixc % table trthID/thth用户名/thth昵称/thth角色/th/tr c:forEach items${pageResult.list} varuser tr td${user.id}/td td${user.username}/td td${user.nickname}/td td${user.role 1 ? 管理员 : 普通用户}/td /tr /c:forEach /table div classpagination c:if test${pageResult.currentPage 1} a hreflist?currentPage${pageResult.currentPage - 1}上一页/a /c:if c:forEach begin1 end${pageResult.totalPage} vari c:choose c:when test${i pageResult.currentPage} span classcurrent${i}/span /c:when c:otherwise a hreflist?currentPage${i}${i}/a /c:otherwise /c:choose /c:forEach c:if test${pageResult.currentPage pageResult.totalPage} a hreflist?currentPage${pageResult.currentPage 1}下一页/a /c:if /divc:forEach 的 begin 和 end 能直接用 EL 表达式的整数省了在 JSP 里写 scriptlet 的麻烦。currentPage 高亮用的是 c:choose c:when这比两个 c:if 更干净——c:if 没有 else 分支两个独立判断容易出现都成立或都不成立的边界问题。上一页的展示条件 currentPage 1如果当前已是第一页不显示链接下一页展示条件 currentPage totalPage已是最后一页就只显示页码不显示链接。这里有个细节我要提醒分页条生成全部页码如果总页数真的到了几百页页面会被页码撑爆。常见做法是想一下封顶策略比如生成 segment currentPage 附近的页码范围但会加重 JSP 的复杂度。后台管理系统数据量在几千条以下时我一般直接全量生成不去做花活。数据量上来了先考虑加查询条件而不是先改造分页条。另外分页链接里如果你想保留页大小可以带 pageSize10 参数Servlet 读出来作为默认值这样列表能支持每页条数切换。4. 图片上传与回显multipart、路径策略、访问控制4.1 multipart 表单Servlet 怎么拿到二进制文件图片上传的起点在浏览器端form 表单必须设置 enctypemultipart/form-data。不设置这个属性文件框只会把文件名当作普通表单字段传上去服务端拿不到二进制内容。这是很多人的第一个翻车点Servlet 里取文件request.getParameter(file) 返回 null一头雾水。form action${pageContext.request.contextPath}/upload methodpost enctypemultipart/form-data input typetext namedescription / input typefile namefile / button typesubmit上传/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 是单个文件 5MBmaxRequestSize 是整个请求 20MB这两个参数别把值想反了。fileSizeThreshold2MB 表示文件超过 2MB 就直接写磁盘小于 2MB 留在内存这是性能取舍。getSubmittedFileName() 是 Servlet 3.1 提供的方法能直接拿到浏览器传来的原始文件名在 Servlet 3.0 环境下你得自己解析 Content-Disposition 头那个解析代码很容易出错。老项目如果还在 Servlet 3.0建议先升级容器而不是去写那套解析代码。4.2 存储路径选型项目内目录还是外部目录文件存哪这决定整个系统后续部署的灵活性。最容易想到的方案是存到应用项目的 upload 目录下但这里有个致命问题Tomcat 重新部署 war 包时旧应用目录会被清掉上传的图片全部蒸发。血泪经验告诉我生产环境不要在应用目录里存用户上传文件。存储位置优点缺点适用场景应用目录内路径短、访问方便重新部署丢文件扩容难纯演示、临时使用独立外部目录部署不丢、好备份需要配置回显接口正式后台管理系统我一般会在服务器固定一个目录比如 Linux 上 /opt/admin_uploadWindows 上 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?id1。这个 Servlet 把数据库里的文件路径取出来校验路径合法性后把文件内容通过输出流写给浏览器。注意这里用 getCanonicalPath() 做了一次路径规范化防止 file_path 里带上 ../ 这样的序列逃出上传目录这就是常说的路径穿越漏洞——没有这道检查攻击者可以构造 id 对应一条指向 /etc/passwd 的记录把敏感文件读出去。contentType 先按 image/jpeg 写死生产环境建议根据文件的真实扩展名动态设置比如 png 对应 image/pnggif 对应 image/gif。设置错了浏览器可能提示下载而不是直接展示。整个流程到这里闭环用户上传 - Servlet 存外部目录 - 数据库记录路径 - JSP 请求 /image 接口 - Servlet 读文件返回。图片访问接口要顺手加个日志至少要记录谁在什么时候访问了哪张图方便排查异常访问。5. 后台管理系统避坑清单四个高频翻车现场5.1 SQL 注入登录接口被引号拼出来的事故现象在某次测试时我在登录框用户名的位置填了 admin or 11密码随便填结果直接进了后台。更严重的是如果攻击者对表结构有了解还能通过报错信息把数据库版本、表名一点点撬出来。原因UserDao 里使用了字符串拼接的 SQL比如 SELECT * FROM sys_user WHERE username username AND password password 。当 username 的内容变成 admin or 11拼接出来的 SQL 就变成 WHERE usernameadmin or 11恒真密码校验直接被绕过了。解决所有跟用户输入相关的 SQL 一律用 PreparedStatement 占位符。这是 Java 基础里的老生常谈执行姿势却是最容易偷懒的地方。注册、登录、分页、详情查询任何一条 SQL 都不能靠字符串拼参数。如果你接手的老项目里全是拼出来的语句先全面排查一遍哪怕逻辑再多也要改完这是发布前必须完成的事项。5.2 文件类型伪装改个扩展名就能上传了现象上传图片的接口只校验了文件后缀jpg、png、gif 都能过。后来有人传了一个文件名 shell.jsp 的“图片”服务端照样接收一访问就直接执行了 JSP 脚本服务器权限被人拿到。原因扩展名是用户可控的改个名字就能骗过后端校验。而且我把文件存在了应用目录内Tomcat 会把它当作动态资源解析等于把攻击者的脚本放到了可执行目录里。解决两层夹击。第一层存储位置放在应用目录外这样即使文件里带着 JSP 脚本Tomcat 也没有机会把它当 Servlet 解析只能用 /image 这种 Servlet 去读二进制内容输出。第二层校验文件真实类型。扩展名继续做白名单但真正决定是否放行的依据是文件头也就是 magic number——JPG 文件头是 FFD8FFPNG 是 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 里没加 characterEncodingutf8Java 往 MySQL 传输时又换成项目默认编码。解决趁早把这几处一次性改对。POST 在 Servlet 的 doPost 开头写 req.setCharacterEncoding(UTF-8)注意只写一次写在参数读取前。GET 去 Tomcat 的 conf/server.xml 里给 Connector 加 URIEncodingUTF-8我一般会同时加 useBodyEncodingForURItrue让 body 和 URL 保持同一种编码。JDBC 连接上DbUtil 的 URL 里保证有 useUnicodetruecharacterEncodingutf8MySQL 表结构已经用 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 后面追加 serverTimezoneAsia/Shanghai。如果你用的是 MySQL 8 默认的 caching_sha2_password 认证插件同时还可能报 Public Key Retrieval is not allowed解决手段是在 URL 里加 allowPublicKeyRetrievaltrue或者干脆创建用户时指定 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 和外部存储目录严格分开这是我改过最多次的地方。以后你再看到这类 JSPServletJDBC 的老项目心里先有个底它把 HTTP、会话、SQL 这几件事都摊在了面上。想提升性能就把 DriverManager 换成连接池想减少重复代码把公共的 Servlet 逻辑抽出来想追新就把 JSP 替换成模板引擎或者整体往 Spring Boot 迁。这个方向真正值得投入的地方反而在于你能把底层机制讲清楚——那些面试题里问的 Session、Filter、SQL 注入你在这种项目里全都亲手敲过。希望帮到你。本文还有配套的精品资源点击获取