简介无论是企业信息管理系统还是日常办公自动化前后端交互与数据持久化都是Web开发的核心命题。在Java技术栈中Servlet负责请求接收与逻辑控制JSP用于动态页面渲染JDBC则提供统一的数据库访问能力三者构成的JavaWeb基础链路至今仍是理解框架原理的基石。基于这套原生技术构建的系统具备结构透明、运行轻量、易于二次开发等特点对于高校课设、毕设及中小型业务场景尤其适用。本文以一套校园任务管理系统为例涵盖数据库表设计、三层架构拆分、状态流转控制、过滤器鉴权、乱码处理、Tomcat部署等完整环节并给出实际开发中的问题排查清单帮助读者从底层掌握JavaWeb核心机制。 不用花哨的框架也不上SpringBoot就用最传统的JSP Servlet把一套校园任务管理系统从数据库设计到前端页面完整做出来。这篇文章我会把完整的设计思路、核心代码实现、环境配置以及我实际开发中踩过的坑一次性讲清楚。如果你正在做课程设计、毕业设计或者想补一补JavaWeb最底层的工作流程这篇内容可以直接当作参考手册用。先说这个项目的基本情况。校园任务管理系统核心解决的是学校或院系内部的任务发布、领取、进度跟踪和统计问题。传统的做法是QQ群发通知或者Excel汇总缺点是任务状态不透明、进度不可控、统计麻烦。用Web系统来做老师可以发布任务、指定负责人或团队学生可以查看任务详情、提交进度或成果双方都能实时看到任务状态变化。技术选型上我特意选了JSP Servlet JDBC这套最经典的组合没有用任何框架。原因很简单JSP/Servlet是JavaWeb的地基。无论你以后用SpringMVC还是Spring Boot本质上都是在Servlet这套体系之上做封装。把JSP和Servlet的原理吃透理解请求是怎么从浏览器走到服务器、经过Servlet处理、再通过JSP渲染页面的后面学任何框架都会轻松很多。1.1 项目整体功能架构这个系统的用户角色分三种管理员、教师、学生。不同角色看到的功能菜单完全不同权限控制是在Servlet层通过过滤器统一处理的没有在页面上做隐藏按钮这种不安全的方案。功能清单如下角色核心功能管理员用户管理教师/学生账号的增删改查、院系管理、系统数据统计教师发布任务、修改任务、审核任务成果、查看任务完成情况学生查看任务列表、领取任务、提交任务成果、查看个人任务记录任务本身的属性包括任务标题、任务描述、任务类型个人任务/团队任务、优先级高/中/低、截止时间、发布人、领取人、任务状态待领取、进行中、待审核、已完成、已驳回。这些字段的设计会直接影响后期的业务逻辑复杂度所以建表的时候就要从实际场景出发把状态流转想清楚。1.2 数据库表设计思路数据库是整个系统的核心设计得好不好直接决定后期开发效率。task_system这个库我一共建了5张表用户表、院系表、任务表、任务领取记录表、系统日志表。用户表设计时需要注意一个细节用户类型字段user_type用int类型1代表管理员、2代表教师、3代表学生而不是直接用字符串。原因有两个一是int类型存储空间更小、查询效率更高二是在Java代码里用常量或枚举映射更清晰。密码字段password存的是MD5加密后的密文明文密码在任何情况下都不允许直接入库这是最基本的安全底线。任务表是业务逻辑的核心有几个字段需要特别说明CREATE TABLE task ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, content TEXT, task_type TINYINT DEFAULT 1 COMMENT 1个人 2团队, priority TINYINT DEFAULT 2 COMMENT 优先级 1高 2中 3低, publisher_id INT NOT NULL COMMENT 发布人ID, assignee_id INT COMMENT 指派人ID可为空, status TINYINT DEFAULT 0 COMMENT 0待领取 1进行中 2待审核 3已完成 4已驳回, deadline DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );注意status字段用了TINYINT而不是VARCHAR这个选择很关键。VARCHAR存状态值比如pending、done虽然可读性好但在Java代码里要做大量字符串比较而且写SQL查询时要特别注意单引号很容易出错。用数字配合常量类的方式代码里可以这么写public class TaskStatus { public static final int WAITING 0; public static final int PROCESSING 1; public static final int REVIEWING 2; public static final int COMPLETED 3; public static final int REJECTED 4; }这样在业务代码里判断状态就是if (task.getStatus() TaskStatus.COMPLETED)清晰且不容易出低级拼写错误。2. 基于JSP/Servlet的三层架构与关键代码实现JSP/Servlet项目最常见的坑是所有逻辑全写在Servlet里、所有页面全是JSP脚本片段。这种写法在代码量少的时候没什么问题一旦业务复杂起来改一个需求牵一发动全身。这套系统在架构上做了严格的层级划分目的是为了后续扩展和维护时不吃苦头。2.1 分层设计与包结构整个项目按照com.campus.task来分包com.campus.task ├── bean/ // 实体类对应数据库表的POJO │ ├── User.java │ ├── Task.java │ └── TaskLog.java ├── dao/ // 数据访问层JDBC操作数据库 │ ├── UserDao.java │ ├── TaskDao.java │ └── impl/ // DAO实现类 ├── service/ // 业务逻辑层处理复杂业务规则 │ ├── TaskService.java │ └── impl/ ├── servlet/ // 控制器层接收请求、调用service、转发页面 │ ├── LoginServlet.java │ ├── TaskServlet.java │ └── UserServlet.java ├── filter/ // 过滤器统一处理编码和登录鉴权 │ ├── EncodingFilter.java │ └── AuthFilter.java └── util/ // 工具类 ├── DBUtil.java // 数据库连接 ├── Md5Util.java // 密码加密 └── DateUtil.java // 日期格式化实体类的属性与数据库表字段一一对应。以User为例public class User { private int id; private String username; private String password; private String realName; private int userType; // 1管理员 2教师 3学生 private int departmentId; private String phone; private String email; private Date createTime; // getter/setter省略 }这里有个细节数据库的datetime类型映射到Java的Date类型在页面上展示时需要格式化。我写了个DateUtil工具类统一输出成yyyy-MM-dd HH:mm:ss格式避免每个页面各自处理导致的格式混乱。2.2 Servlet核心控制器实现Servlet在项目里就是MVC中的Controller角色负责3件事接收HTTP请求、解析参数并调用Service层、根据结果选择页面或重定向。任务管理的核心Servlet是TaskServlet用了method参数做分发一个Servlet处理多个操作减少类的数量WebServlet(/task) public class TaskServlet extends HttpServlet { private TaskService taskService new TaskServiceImpl(); protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String method request.getParameter(method); if (list.equals(method)) { listTasks(request, response); } else if (detail.equals(method)) { taskDetail(request, response); } else if (myTasks.equals(method)) { myTasks(request, response); } else if (toAdd.equals(method)) { request.getRequestDispatcher(/jsp/task_add.jsp).forward(request, response); } else { response.sendError(HttpServletResponse.SC_NOT_FOUND); } } protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String method request.getParameter(method); if (add.equals(method)) { addTask(request, response); } else if (updateStatus.equals(method)) { updateStatus(request, response); } else if (submitResult.equals(method)) { submitResult(request, response); } else { response.sendError(HttpServletResponse.SC_NOT_FOUND); } } }在详细说明各业务方法之前我先说一下这个系统最关键的技术点数据如何从数据库通过DAO层取出、经过Service处理、再交给JSP页面进行渲染。这条链路是JSP/Servlet项目的命脉理解了它整个项目就没有秘密了。以任务列表为例TaskDaoImpl里的查询方法public ListTask findTasksByPublisher(int publisherId) { ListTask list new ArrayList(); String sql SELECT * FROM task WHERE publisher_id ? ORDER BY create_time DESC; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, publisherId); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Task task new Task(); task.setId(rs.getInt(id)); task.setTitle(rs.getString(title)); task.setContent(rs.getString(content)); // ...手动映射每个字段 list.add(task); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这里有一件很重要的事情JDBC操作必须使用PreparedStatement而不是直接拼接SQL字符串。我记得在做第一个版本的时候图省事把参数直接拼到了SQL里后来测试发现用户在任务标题里输入一个英文单引号整个查询就报错更严重的是存在SQL注入的风险。PreparedStatement用占位符设置参数不仅安全而且对于数据库来说预编译的SQL可以复用执行计划性能更好。2.3 JSP页面与表单数据交互用户端的任务列表页是jsp/task_list.jsp这里我用了JSTL标签库来遍历数据而不是在JSP里写Java代码片段。区分这个概念很重要虽然JSP本质上允许写Java代码但良好的项目实践是避免在页面上写%%脚本因为页面只负责展示业务逻辑应该留在Servlet和Service里。JSTL让页面更加简洁也更容易维护。核心代码片段如下% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % html head title任务列表/title link relstylesheet href${pageContext.request.contextPath}/css/style.css /head body div classcontainer h2任务列表/h2 table classtable table-bordered thead tr th任务标题/th th优先级/th th任务状态/th th截止时间/th th操作/th /tr /thead tbody c:forEach items${taskList} vartask tr tda href${pageContext.request.contextPath}/task?methoddetailid${task.id}${task.title}/a/td td c:choose c:when test${task.priority 1}高/c:when c:when test${task.priority 2}中/c:when c:otherwise低/c:otherwise /c:choose /td td c:choose c:when test${task.status 0}待领取/c:when c:when test${task.status 1}进行中/c:when c:when test${task.status 2}待审核/c:when c:when test${task.status 3}已完成/c:when c:otherwise已驳回/c:otherwise /c:choose /td tdfmt:formatDate value${task.deadline} patternyyyy-MM-dd HH:mm//td td a href${pageContext.request.contextPath}/task?methoddetailid${task.id} classbtn btn-info查看/a /td /tr /c:forEach /tbody /table /div /body /html${pageContext.request.contextPath}这个表达式在JSP里很常用它的作用是获取Web应用的应用名用来动态拼接URL。这里有个常见问题如果应用部署的用户访问路径是http://localhost:8080/task_system/那contextPath就是/task_system。写死/app是行的但一旦你改了项目部署名所有链接就全会404。用这个表达式最稳妥它是标准做法。3. 核心业务场景的实现与操作流程3.1 登录认证与会话管理登录是系统的入口也是安全性要求最高的一环。登录流程设计成用户提交用户名和密码表单给LoginServletServlet从请求中取出参数调用UserService的login方法验证成功后将用户信息存入Session重定向到对应角色的首页失败则回到登录页并给出提示。这里直接解释一下如何通过登录实现之后所有页面都能获取当前用户的信息。登录成功后把User对象放进了HttpSession后续所有的Servlet和JSP页面里都可以通过session.getAttribute获取不用每次请求都去查数据库protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password Md5Util.md5(request.getParameter(password)); UserDao userDao new UserDaoImpl(); User user userDao.findByUsernameAndPassword(username, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); // 30分钟超时 // 按角色跳转不同的首页 if (user.getUserType() 1) { response.sendRedirect(request.getContextPath() /admin/index.jsp); } else if (user.getUserType() 2) { response.sendRedirect(request.getContextPath() /teacher/index.jsp); } else { response.sendRedirect(request.getContextPath() /student/index.jsp); } } else { request.setAttribute(loginError, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } }注意一个重要的区别sendRedirect是重定向浏览器地址会改变产生一次新的请求forward是服务器内部转发地址栏不变Request域里setAttribute的数据还能拿到。这里登录失败用了forward因为要把loginError这个属性带到登录页去显示登录成功用了sendRedirect因为登录成功后这次request已经不需要了而且要防止用户刷新页面时重复提交表单。密码加密这块我对密码做了一层MD5加盐处理而不是简单的MD5。原始密码123456的MD5是e10adc3949ba59abbe56e057f20f883e这种已知的哈希值用彩虹表很容易反查出明文。我在项目中是这样处理的public class Md5Util { public static String md5(String text) { // 加盐字符串 String salt campus_task_system_2024; String input salt text; try { MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(input.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : digest) { String hex Integer.toHexString(0xff b); if (hex.length() 1) { sb.append(0); } sb.append(hex); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(MD5加密失败, e); } } }3.2 过滤器实现登录拦截与编码处理过滤器是Servlet规范中的一个重要组件在请求到达Servlet之前会先经过过滤器链。我在系统中配了两个核心过滤器EncodingFilter处理全站的中文乱码AuthFilter做登录拦截和权限验证。中文乱码问题在JSP/Servlet开发中极其常见它的本质是浏览器与服务器之间的编码约定不一致。浏览器提交表单时用什么编码、服务器解析时用什么编码必须统一为UTF-8否则中文就会出现问号或乱码。EncodingFilter的做法是WebFilter(/*) public class EncodingFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }加了这个过滤器之后POST请求的中文参数就没问题了但GET请求的参数还有可能会有乱码。原因在于GET参数是放在URL里的Tomcat 8.0以后默认就使用UTF-8解码URI所以高版本Tomcat上GET不会乱如果你用的是老版本Tomcat 7需要在server.xml里加URIEncodingUTF-8。这个区别从现象上看很像代码的问题但实际上完全是容器配置的问题。AuthFilter才是真正的权限控制核心WebFilter(/*) public class AuthFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); String contextPath req.getContextPath(); String path uri.substring(contextPath.length()); // 放行不需要登录的资源登录页、登录接口、静态资源 if (path.equals(/login.jsp) || path.equals(/login) || path.startsWith(/css/) || path.startsWith(/js/) || path.startsWith(/images/)) { chain.doFilter(req, resp); return; } // 检查Session中是否有用户 HttpSession session req.getSession(false); Object user session ! null ? session.getAttribute(loginUser) : null; if (user null) { // 未登录重定向到登录页 resp.sendRedirect(contextPath /login.jsp); return; } chain.doFilter(req, resp); } }需要注意listener等配置问题我这里就不再展开了。权限细粒度控制比如学生不能访问教师新增任务的页面还需要在各自的Servlet里再做一层校验。可以用Session里的User对象判断userType再决定是否拥有操作权限这个不难但是必须做否则系统只要能登录任何角色都能访问所有URL这就完全失去权限控制的意义了。3.3 教师发布任务与状态流转教师发布任务的流程中最核心的是状态机的流转设计。我定义了5种任务状态它们之间的流转规则是操作原状态新状态操作人发布任务无待领取(0)教师领取任务待领取(0)进行中(1)学生提交成果进行中(1)待审核(2)学生审核通过待审核(2)已完成(3)教师审核驳回待审核(2)已驳回(4)教师重新提交已驳回(4)待审核(2)学生状态流转在TaskServlet的updateStatus方法里实现private void updateStatus(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int taskId Integer.parseInt(request.getParameter(taskId)); int newStatus Integer.parseInt(request.getParameter(status)); Task task taskService.getTaskById(taskId); if (task null) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } // 校验状态流转是否合法 if (!taskService.isValidTransition(task.getStatus(), newStatus, currentUserType)) { request.setAttribute(errorMsg, 非法的操作当前状态不能转换到目标状态); request.getRequestDispatcher(/jsp/error.jsp).forward(request, response); return; } taskService.updateStatus(taskId, newStatus); // 记录日志 TaskLog log new TaskLog(); log.setTaskId(taskId); log.setOperatorId(currentUserId); log.setAction(更新状态为: newStatus); taskService.addLog(log); response.sendRedirect(request.getContextPath() /task?methoddetailid taskId); }为什么要把状态流转校验单独抽成一个方法因为实际运行中发现如果前端页面只显示允许点击的按钮恶意用户完全可以自己拼URL来强制刷新状态。例如一个学生领取了别人的任务拉到已完成。后端必须对每次状态更新进行合法性校验这是Web安全里很重要的原则永远不要信任前端的限制。3.4 学生领取任务与提交成果学生端的操作相对简单但易出错的是表单文件上传。我先做了不涉及文件上传的纯文本成果提交这对理解JSP的表单请求流程更有帮助。提交成果的JSP表单form action${pageContext.request.contextPath}/task methodpost input typehidden namemethod valuesubmitResult input typehidden nametaskId value${task.id} input typehidden namestatus value2 div classform-group label任务进展说明/label textarea nameresultContent classform-control rows5 placeholder请描述你完成的任务内容、遇到的困难以及解决方案.../textarea /div button typesubmit classbtn btn-primary提交审核/button /form这里用了三个hidden字段来传递method、taskId和status。实际上在所有的JSP表单里method这个参数几乎成了约定俗成的做法很多传统JavaWeb项目都是这么设计的一个Servlet对应一个业务实体通过method参数区分增删改查操作。这种方案的优点是直观缺点是Servlet代码会越来越长需根据实际情况取舍。数据库层面提交成果就是新增一条任务日志记录同时更新任务状态为待审核public void submitResult(int taskId, int userId, String content) { String sql UPDATE task SET status 2 WHERE id ? AND assignee_id ?; // ...执行更新 String logSql INSERT INTO task_log (task_id, operator_id, action, detail, create_time) VALUES (?, ?, 提交成果, ?, NOW()); // ...插入日志 }在单表简单操作场景下JDBC的编程方式或许有些繁琐。进阶做法是引入事务管理把状态更新和日志插入放到同一个事务里避免更新状态成功但日志没记上的数据不一致问题。4. 开发环境配置与常见问题排查开发这套系统的时候我用的环境是IDEA 2024.2 Apache Tomcat 9.0 MySQL 8.0 JDK 8。这个组合比较成熟遇到问题有大量现成解决方案可以借鉴。下面把环境配置中容易出错和实际开发中遇到的高频问题一起整理作为速查表供参考。4.1 IDEA中JSP相关配置的3个关键设置IDEA里配置JSP开发环境有3个地方必须设置好否则会严重影响开发体验。第一个是JSP语法高亮。新导入的项目或者首次打开JSP文件有时候编辑器里全是灰色纯文本没有高亮。这时候你需要检查IDEA是否安装了Java EE相关的插件同时打开File - Project Structure - Facets确保Web模块正确关联了对应的Web资源目录。如果Facets没有配置好IDEA不知道你把JSP文件放在哪里语法提示和高亮自然就不会生效。要确认artifacts里已经打成了war exploded包并且Output Layout中有可用的lib目录存放依赖。第二个是热部署配置。通常在Tomcat配置里On Update Action和On Frame Deactivation都选择Update classes and resources这样修改Java代码、JSP或者CSS时IDEA会自动把变更推送到正在运行的Tomcat中不需要每次手动重启。但对一些严重的代码结构变化比如新增Servlet仍然建议Redeploy。第三个是jsp页面中的函数点击引用无法跳转的问题。这个在IDEA里可能会遇到JSP中转到了某个JavaScript函数定义处在JSP文件里按住Ctrl点击某个函数名结果无法跳转到定义。原因通常是IDEA对JSP内嵌的JS代码索引不完整尤其是函数定义在外部.js文件时。解决办法是确保外部js文件通过