
简介面向Java初学者的后台管理系统源码基于JSPServletJDBCMySQL原生界面开发完整实现登录、注册、产品信息增删改查、分页查看、图片上传和退出登录等功能并配有过滤器控制访问权限适合课程设计或小型管理项目参考。资源包为zip格式共92个文件主要包含14个Java源文件、12个JSP页面、28个class编译文件以及jar依赖、XML配置、SQL脚本和docx技术说明等整体仅3.04MB轻量易部署。已有3371人学习浏览。压缩包内提供了数据库初始化脚本、技术实现说明、工程导入步骤与运行必读文档可帮助使用者快速在Eclipse/MyEclipse环境下配置JDK1.7与Tomcat7并运行同时理解ServletJDBC的分层调用与分页查询实现思路。1. 原生JSPServletJDBC为什么还要用这套“老四样”做后台管理系统当 Spring Boot 成了多数 Java Web 项目的默认起手式jspjsservletjdbcmysql 这套“原生五件套”反而像一个另类。但如果你要做一个课设、毕设或者一个不想背框架业务约束的内部后台管理系统它依然是最能暴露问题本质的组合HTTP 请求怎么进来、Servlet 怎么接、JDBC 怎么连、SQL 怎么拼、Session 怎么存每一层都摊在你面前。标题里提到的分页、登录、注册、图片上传恰好覆盖了一个后台管理系统最典型的四类功能。这套源码值不值得复刻关键看你是不是愿意为“理解底层”多花两天时间。2. 工程骨架与数据库设计先定目录、连接池和 user 表再写业务很多人拿到这类源码第一反应是打开 JSP 页面看界面我正好相反。一个 JSPServlet 的后台管理系统最容易翻车的往往不是页面而是工程的目录结构、JDBC 连接的写法以及数据库字符集。我把这一章放在最前面就是希望你先搭好骨架再填肉。2.1 War包目录与三层分包把页面、Servlet、DAO 分开常见做法是一个标准 Maven War 工程不依赖 Spring所有 jar 包放进WEB-INF/lib页面放在webapp根目录。不要把所有 JSP 塞在根目录下建议按模块拆文件夹admin/、auth/、common/登录页和注册页单独放。Java 侧按三层分包这是我个人比较固定的习惯src/main/java ├── com.admin.filter // 登录拦截、编码过滤 ├── com.admin.servlet // 所有 Servlet按功能拆AuthServlet、UserServlet、UploadServlet ├── com.admin.dao // JDBC 数据访问 ├── com.admin.entity // User、PageResult 等简单的 POJO ├── com.admin.util // DBUtil、PasswordUtil、UploadUtil配套的pom.xml需要三个关键依赖mysql-connector-java版本跟着数据库走、javax.servlet-apiTomcat 自带scope 设为 provided、jstl如果列表页想用c:forEach而不是 Java 脚本片段。如果你的开发环境不是 Maven就去 Maven 仓库把这三个 jar 下载下来放进WEB-INF/lib效果完全一样。2.2 MySQL 建表与 JDBC 驱动连接5.7 和 8.0 的两个配置差异标题把 mysql 单独列出来说明数据库是这套系统的心脏。建表时有两件事别省一是给用户名字段加唯一索引二是整表用 utf8mb4。登录名重复是后台管理系统最常见的脏数据来源唯一索引能在数据库层兜底。CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT SHA-256(saltpassword), salt VARCHAR(32) NOT NULL, avatar_url VARCHAR(255) DEFAULT NULL COMMENT 头像相对路径, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;连接配置用db.properties写在类路径下避免把数据库密码硬编码到 Java 里。这里有一个必须注意的差异MySQL 5.7 的驱动类是com.mysql.jdbc.DriverMySQL 8.0 改用com.mysql.cj.jdbc.Driver如果混用有时候不报错但一旦 JDBC URL 里没写serverTimezone8.0 会直接拒绝连接。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/admin_demo?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.passwordyour_password如果本地装的是 5.7把 driver 换回com.mysql.jdbc.DriverURL 里的serverTimezone和allowPublicKeyRetrieval可以删掉。新装 MySQL 8.0 的朋友最容易在这条配置上卡半小时报错信息里会出现Public Key Retrieval is not allowed加allowPublicKeyRetrievaltrue就是第一个后悔药。2.3 提取 DBUtil 与 BaseServlet让每个请求少写十行重复代码原生的 JDBC 代码写起来很啰嗦我一般会把“拿连接、关连接”封装到一个DBUtil避免每个 DAO 方法里都复制一遍Class.forName。public class DBUtil { private static String url; private static String user; private static String password; static { try { Properties props new Properties(); props.load(DBUtil.class.getClassLoader().getResourceAsStream(db.properties)); Class.forName(props.getProperty(jdbc.driver)); url props.getProperty(jdbc.url); user props.getProperty(jdbc.username); password props.getProperty(jdbc.password); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } }静态块里做驱动注册加载失败时直接抛出ExceptionInInitializerError比在业务方法里抛ClassNotFoundException更容易在启动阶段暴露问题。调用方只需要关心Connection不需要关心驱动类名这算是 JDBC 原生项目的最小封装。再进一步可以做一个BaseServlet把“根据参数分发到哪个 doXxx 方法”这个动作抽出来利用反射代替每个 Servlet 里重复的if (list.equals(action))判断。这样新增一个功能只需要在子类里写同名方法分页、图片上传这类多个入口的模块维护起来舒服很多。3. 登录注册与会话控制密码摘要、注册去重和“已登录”判定登录、注册是标题里点名的功能也是最容易写出安全问题的部分。原生 JSPServlet 项目里最大的通病是明文存密码其次是注册接口不做用户名重复检查。这两点都会让一个看起来“能跑”的后台管理系统变成反面教材。3.1 注册接口查询用户、插入记录与密码摘要注册的逻辑是接收用户名和密码先查t_user是否已存在不存在才插入。查询和插入都用PreparedStatement永远不要用字符串拼接 SQL这是 JDBC 项目的底线。public int register(String username, String password) throws SQLException { // 1. 查重用户名唯一数据库层有唯一索引兜底 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( SELECT id FROM t_user WHERE username ?)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return -1; // 已存在 } } } // 2. 生成随机盐做 SHA-256 摘要 String salt PasswordUtil.generateSalt(); String hashed PasswordUtil.hash(password, salt); // 3. 插入用户记录 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( INSERT INTO t_user (username, password, salt) VALUES (?, ?, ?))) { ps.setString(1, username); ps.setString(2, hashed); ps.setString(3, salt); return ps.executeUpdate(); } }这里的盐不是固定字符串而是每次注册都随机生成的。PasswordUtil内部用SecureRandom生成 16 字节盐再用MessageDigest做 SHA-256最后把盐和哈希串一起存进数据库。如果某个旧项目里已经是明文密码改造时记得让老用户下一次登录时自动重新哈希而不是直接清空密码。3.2 登录接口表单提交、密码校验与 Session 写入登录比注册多两件事校验密码、写 Session。校验密码时不能用“先查出密码再在 Java 里比对”这种思路之外的方式但要注意查出来的用户可能不存在此时返回统一的“用户名或密码错误”不要告诉前端到底是哪一个不存在。public User login(String username, String rawPassword) throws SQLException { try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( SELECT id, username, password, salt FROM t_user WHERE username ?)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { return null; } String storedHash rs.getString(password); String salt rs.getString(salt); String inputHash PasswordUtil.hash(rawPassword, salt); if (!storedHash.equals(inputHash)) { return null; } User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); return user; } } }登录成功后在 Servlet 里用request.getSession().setAttribute(loginUser, user)写入会话对象。不要把密码塞进 sessionsession 里只放 id 和 username。会话超时时间可以在web.xml里统一设置一般 30 分钟比较合理。session-config session-timeout30/session-timeout cookie-config http-onlytrue/http-only /cookie-config /session-confighttp-only可以防止页面里的脚本通过document.cookie偷走会话标识原生项目很容易漏掉这一行。3.3 登录校验过滤器拦截未登录请求的两种写法后台管理系统不可能在每个 Servlet 里都写一遍“当前用户是否登录”所以要用Filter统一拦截。常见做法是写一个AuthFilter只放行登录页、注册页和注册接口其余请求都检查 session。WebFilter(/*) public class AuthFilter 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(/register.jsp) || path.equals(/auth/login) || path.equals(/auth/register)) { chain.doFilter(request, response); return; } if (req.getSession().getAttribute(loginUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }注意两点第一放行列表里的路径必须和 Servlet 的WebServlet注解里的路径完全一致大小写和斜杠都不能错第二静态资源如/js/、/css/也需要放行否则登录页会加载不出样式。如果你发现登录成功后又跳回登录页基本就是过滤器把成功后的跳转地址也拦截了或者 session 没有真正写进去。4. 分页与搜索排查pageNo、pageSize、limit 的必调参数与边界坑分页是标题里的第一个功能关键词也是后台管理系统列表页的标配。原生 JDBC 下实现分页本质就是拼LIMIT ?, ?和COUNT(*)两条 SQL。这个功能看似简单但 pageNo 的计算、搜索关键词的传递、排序字段的拼接每一个点都有具体的坑。4.1 分页 SQL 与 count 查询分离分页查询需要两条 SQL一条查总数一条查当前页数据。常见做法是先查COUNT(*)再根据 pageNo 和 pageSize 计算偏移量查列表。public PageResultUser listUsers(String keyword, int pageNo, int pageSize) throws SQLException { PageResultUser result new PageResult(); // 1. count 查询条件与列表查询完全一致 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( SELECT COUNT(*) FROM t_user WHERE username LIKE ?)) { ps.setString(1, % keyword %); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { result.setTotal(rs.getInt(1)); } } } // 2. 列表查询LIMIT 的两个参数分别是 offset 和 size int offset (pageNo - 1) * pageSize; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( SELECT id, username, avatar_url, created_at FROM t_user WHERE username LIKE ? ORDER BY id DESC LIMIT ?, ?)) { ps.setString(1, % keyword %); ps.setInt(2, offset); ps.setInt(3, pageSize); try (ResultSet rs ps.executeQuery()) { ListUser users new ArrayList(); while (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setAvatarUrl(rs.getString(avatar_url)); user.setCreatedAt(rs.getTimestamp(created_at)); users.add(user); } result.setList(users); } } // 3. 计算总页数向上取整 result.setPageNo(pageNo); result.setPageSize(pageSize); result.setTotalPage((result.getTotal() pageSize - 1) / pageSize); return result; }两个关键参数的默认值我建议是pageNo 默认 1pageSize 默认 10。如果用户传了非法值比如 pageNo 是 0 或者负数要做一次兜底处理否则 LIMIT 里出现负偏移量MySQL 会直接抛异常。4.2 前端分页条总页数、当前页和跳页参数JSP 页面里分页条的做法是把pageNo、pageSize、keyword都放在同一个搜索表单里翻页用表单提交而不是拼接a href链接。这是新手最容易忽略的地方——直接在地址栏手工改 pageNo会导致搜索关键词丢失。form action${pageContext.request.contextPath}/user/list methodget input typetext namekeyword value${param.keyword} / input typehidden namepageNo value${pageResult.pageNo} / input typehidden namepageSize value${pageResult.pageSize} / button typesubmit搜索/button /form c:if test${pageResult.pageNo 1} a href${pageContext.request.contextPath}/user/list?keyword${param.keyword}pageNo${pageResult.pageNo - 1}pageSize${pageResult.pageSize}上一页/a /c:if span第 ${pageResult.pageNo} / ${pageResult.totalPage} 页/span c:if test${pageResult.pageNo pageResult.totalPage} a href${pageContext.request.contextPath}/user/list?keyword${param.keyword}pageNo${pageResult.pageNo 1}pageSize${pageResult.pageSize}下一页/a /c:if如果你不熟悉 JSTL直接用${pageResult.xxx}的 EL 表达式也能取值前提是 Servlet 里把PageResult放进了 request 属性并且 JSP 页面顶部写了% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %。上一页、下一页链接里必须原样带上 keyword并且做 URL 编码不然中文关键词会乱码。4.3 分页与搜索的排查记录现象、原因、解决分页模块我踩过的坑挑四个最常见的写在这里每一条都是“现象 → 原因 → 解决”的固定套路。现象一翻到第 2 页列表空白但总数显示正常。原因offset算错写成了pageNo * pageSize第二页查的是第 11 到第 20 条但实际数据只有 11 条所以第二页恰好返回空。解决offset 必须用(pageNo - 1) * pageSize并在页面显示当前页码验证。现象二搜索之后点“下一页”结果变成了不带搜索条件的全量列表。原因翻页链接把 keyword 丢了Servlet 收到空的 keyword 就查全表。解决把 keyword 放在隐藏域或者每一个翻页链接里原样传递并在 Servlet 里对 null 做默认空字符串处理。现象三搜索关键词里带%或_返回结果比预期多很多。原因%和_在 LIKE 里是通配符直接拼进参数等于告诉数据库“模糊匹配任意字符”。解决对输入串里的%、_、\做转义SQL 里加ESCAPE \\或者干脆只允许用户输入字母和数字。现象四点击“按时间排序”后页面报错或者排序结果与预期相反。原因排序字段和排序方向被拼进了 ORDER BY但 JDBC 的预编译参数不能用在 ORDER BY 位置一旦用了用户输入直接拼接就容易出现 SQL 注入。解决排序字段做白名单映射只允许id、created_at等指定字段排序方向只允许ASC和DESC其他值一律回退到默认排序。5. 图片上传与回显multipart/form-data、Part 接口和存储路径图片上传是这套后台管理系统里另一个高频功能通常用在用户头像、商品图片、证件上传。JSP 页面里做上传表单必须写成enctypemultipart/form-data后端用 Servlet 3.0 的Part接口接收文件避开直接手动解析请求体的麻烦。5.1 上传页与 Servlet 3.0 的 Part API上传页的基本结构是一个form一个input typefile一个提交按钮。不要漏掉enctype漏掉的后果是后端request.getPart(file)直接抛异常。form action${pageContext.request.contextPath}/upload/avatar methodpost enctypemultipart/form-data input typefile nameavatar acceptimage/* / button typesubmit上传头像/button /form后端 Servlet 处理上传的代码以MultipartConfig注解开启支持然后从 request 里取 Part 并落盘。WebServlet(/upload/avatar) MultipartConfig(maxFileSize 2 * 1024 * 1024, maxRequestSize 4 * 1024 * 1024) public class UploadServlet extends HttpServlet { private static final String UPLOAD_DIR D:/admin_upload/avatar; Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part part req.getPart(avatar); if (part null || part.getSize() 0) { resp.sendRedirect(req.getContextPath() /upload.jsp?error1); return; } // 1. 取原始文件名并提取扩展名限制类型 String submitted part.getSubmittedFileName(); String ext ; if (submitted ! null submitted.contains(.)) { ext submitted.substring(submitted.lastIndexOf(.)).toLowerCase(); } if (!Arrays.asList(.jpg, .jpeg, .png, .gif).contains(ext)) { resp.sendRedirect(req.getContextPath() /upload.jsp?error2); return; } // 2. 用 UUID 重命名避免中文名乱码和覆盖 String newFileName UUID.randomUUID().toString().replace(-, ) ext; File dir new File(UPLOAD_DIR); if (!dir.exists()) { dir.mkdirs(); } part.write(dir.getAbsolutePath() File.separator newFileName); // 3. 保存相对路径到数据库 req.getSession().setAttribute(avatarUrl, /upload/ newFileName); resp.sendRedirect(req.getContextPath() /upload_success.jsp); } }maxFileSize和maxRequestSize是上限兜底前者限制单个文件后者限制整个请求。如果你的业务需要传大图比如 10MB可以调大但建议同时限制前端acceptimage/*后端仍然要校验。真正把图片写盘的part.write()有一个隐含行为它写的是绝对路径或相对于临时目录的路径传绝对路径最省心。5.2 静态资源映射与图片回显图片上传后要能在页面上显示。我推荐的方案是把文件存放在项目外目录然后用一个 Servlet 处理/upload/*的请求读出文件流返回。不推荐把图片写到webapp/upload下因为 Tomcat 重新部署时会清空整个 webapp 目录。WebServlet(/upload/*) public class FileAccessServlet extends HttpServlet { private static final String UPLOAD_ROOT D:/admin_upload; Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String path req.getPathInfo(); // 形如 /avatar/xxx.jpg File file new File(UPLOAD_ROOT, path); if (!file.exists() || !file.isFile()) { resp.sendError(404); return; } resp.setContentType(getContentType(file.getName())); try (OutputStream out resp.getOutputStream(); FileInputStream in new FileInputStream(file)) { in.transferTo(out); } } private String getContentType(String name) { if (name.endsWith(.png)) return image/png; if (name.endsWith(.gif)) return image/gif; return image/jpeg; } }这个 Servlet 同时解决了两个问题第一图片目录在项目外重新部署不丢第二上传统一走/upload/前缀后续如果要加防盗链或访问权限只需要在这个 Servlet 里加逻辑。页面上回显时img src${pageContext.request.contextPath}/upload/avatar/xxx.jpg /即可。5.3 图片上传常见问题排查现象、原因、解决上传这功能是最容易“本地好好的一部署就跪”的模块我把高频问题整理成排查清单。现象一上传大图时 Tomcat 报IllegalStateException: unable to read the entire request body。原因Tomcat 的maxSwallowSize或maxPostSize默认限制也可能是超过MultipartConfig的maxRequestSize。解决先改MultipartConfig的上限不够再调server.xml的maxPostSize生产环境建议在 Nginx 层也放行对应大小。现象二上传成功后访问图片 404。原因图片被写到了 Tomcat 发布目录下的临时位置重启后文件消失。解决检查磁盘上D:/admin_upload是否真的有文件如果确认写了但访问不到多半是/upload/*的路径映射或者 contextPath 没拼对。现象三上传文件名变成乱码或者扩展名变成了.tmp。原因容器从multipart头里取 filename 时用了错误的解码方式或老版本 Tomcat 的getSubmittedFileName()返回 null。解决统一用 UUID 重命名不要依赖用户原始文件名如果必须保留原名再解析Content-Disposition头里的filename*UTF-8。现象四图片上传成功但part.write()落盘的目录不存在导致报错。原因D:/admin_upload/avatar这个目录没人手动创建part.write()不会自动创建多级目录。解决在写盘前调用dir.mkdirs()这一步几乎每个上传功能都应该有。现象五上传小图片没事大图一传就内存暴涨。原因MultipartConfig没限制大小Tomcat 把整个请求体读入内存。解决设置合理的maxFileSize和maxRequestSize并在业务层限制图片尺寸超过阈值直接拒绝。6. 部署、验证与连接池改造让这套老代码也能体面地跑起来代码写完了总得跑起来验证。JSPServletJDBC 项目的部署顺序有一个小讲究先确认 MySQL 服务在跑再启动 Tomcat。很多新人先启 Tomcat后启 MySQL启动日志里全是SQLException榜首的报错是Communications link failure。验证分页、登录、上传这三个功能我习惯直接用命令跑一轮不依赖浏览器手动点。# 1. 检查 MySQL 服务状态Windows 用 net start 或服务管理器 mysql -uroot -p -e SELECT VERSION(); # 2. 导入初始化 SQL mysql -uroot -p admin_demo init.sql # 3. 启动 Tomcat 前台模式实时看日志 cd /opt/tomcat/bin ./catalina.sh run# 4. 验证登录接口能通观察响应码 curl -i -X POST http://localhost:8080/admin/auth/login \ -d usernameadminpassword123456 # 5. 验证分页接口带 pageNo 和 pageSize 返回正常 curl -s http://localhost:8080/admin/user/list?pageNo1pageSize10如果登录接口返回 302 重定向到login.jsp说明登录失败返回 200 且带出页面内容说明登录成功并进了后台。接着再验证 Session 是否生效最简单的办法是加一个-b cookie.txt让 curl 保存并携带 Cookie。项目能跑通之后下一步值得做的改造是把DriverManager换成连接池。原生 JDBC 每次请求都getConnection()在高并发下连接建立开销很大。我一般用 Apache DBCP2 做最小改动BasicDataSource ds new BasicDataSource(); ds.setDriverClassName(com.mysql.cj.jdbc.Driver); ds.setUrl(jdbc:mysql://localhost:3306/admin_demo?useSSLfalseserverTimezoneAsia/Shanghai); ds.setUsername(root); ds.setPassword(your_password); ds.setInitialSize(5); ds.setMaxTotal(20); ds.setMaxIdle(10); ds.setMinIdle(5);把DBUtil.getConnection()最后一行从DriverManager.getConnection(url, user, password)换成ds.getConnection()即可其他 DAO 代码不用动。连接池的四个参数我要解释一下initialSize是启动时建多少个连接maxTotal是上限maxIdle是空闲保留数minIdle是保底空闲数。内部后台管理系统maxTotal20够用如果并发上千这个值要重新评估同时注意 MySQL 服务端的max_connections也要同步调大。我自己的最后习惯是把catalina.out挂在编辑器侧边栏改完代码就盯着日志看 SQL。原生 JDBC 项目里 SQL 和 Java 混在一起SQL 写错是最难排查的但恰恰又最容易通过日志一眼发现。如果日志没开 SQL建议在 DAO 层暂时用System.out.println(ps.toString())输出完整 SQL排查完再删掉。这个习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取