
简介JavaWeb课程设计常用的个人博客系统项目包覆盖源代码、数据库SQL脚本、开发文档及报告PDF适合计算机专业学生完成期末大作业、课程设计也可作为毕业设计或初期项目演示的参考。资源基于JavaWeb分层架构包含前端页面、后端处理逻辑与数据库交互能帮助理解博客类项目的完整开发流程。压缩包共128个文件主要有Java源码与编译后的class文件、CSS样式、JavaScript脚本、SQL脚本、项目截图和PDF报告整体大小仅12.38MB目录结构清晰方便按模块阅读。代码经过运行测试答辩平均评分96分功能覆盖登录注册、文章发布与管理、头像更换、收藏判断等常见模块配套文档和SQL脚本可快速初始化环境也便于在此基础上扩展学习。目前已有561人学习使用下载后按文档说明即可搭建运行遇到问题还支持远程教学指导。1. 个人博客系统这道JavaWeb大作业为什么年年被选进课程设计清单个人博客系统几乎是每届JavaWeb课程设计的“标配选题”体量不大单人可完成却把登录鉴权、文章的增删改查、分类评论、数据库设计这些最核心的考点全占了。你手上这份交付物包含源代码、文档、SQL和报告PDF说明这门课的目标不是“跑起来”而是“讲得清、能答辩、可复现”。我会从技术选型、数据库设计、核心代码实现讲到部署排错最后补上三个让项目从“能交”变成“能讲”的进阶点。适合正在赶课设、想补一份完整JavaWeb实战案例、或想把旧项目整理成可答辩状态的同学。不需要框架经验Servlet JSP 关系型数据库这套传统组合足够也足够稳。2. 技术选型与工程结构ServletJSP数据库的组合为什么是课设首选2.1 三层架构与MVC一次请求的完整生命周期写JavaWeb课设最忌的一件事就是把所有代码堆在JSP里。刚写完时看着很爽到了写文档和答辩的时候导师问一句“这一段逻辑为什么放在这里”你完全答不上来。血泪经验是严格分三层表现层用JSP负责渲染控制层用Servlet接收请求并做流程调度业务层与持久层分别处理规则和数据库操作。这套分层对应到JavaWeb就是标准的MVC。一次用户操作比如“发表一篇新文章”请求会这样流转浏览器提交表单到Servlet的doPost方法Servlet负责解析参数、校验权限、组装数据对象然后调用Service层处理业务逻辑Service再通过DAO层访问数据库。数据返回后Servlet选择转发到哪个JSP页面去渲染结果。全程数据是单向流动的每一层只跟相邻层打交道。这样做最大的收益不是代码量变少而是答辩时有东西可讲。你可以在文档里画一张请求流转图用文字描述即可说清楚每一层为什么存在。比如“为什么用户ID不从表单传而是从Session里取”这一句话就能体现你对安全的理解。很多同学课设分数低不是功能没做完是“讲不出来”而分层结构就是让你讲得出来的地基。后面所有代码示例我都按这个结构给出Servlet负责控制Service负责业务规则DAO负责SQL访问JSP只做展示。如果你拿到手的项目源码是扁平结构建议先按这个思路梳理一遍再动手改后面会轻松很多。2.2 工程目录划分src、web、sql、doc四个目录的职责边界拿到一份宣称“完整交付”的课设源码第一件事不是打开IDE直接跑而是先看清目录结构。一份能得高分的JavaWeb课设工程目录通常是下面这样的blog-system/ ├── src/ │ └── main/ │ ├── java/ │ │ ├── com/example/blog/ │ │ │ ├── controller/ # Servlet 控制器层 │ │ │ ├── service/ # 业务逻辑层 │ │ │ ├── dao/ # 数据库访问层 │ │ │ ├── entity/ # 实体类User/Article/Comment 等 │ │ │ ├── filter/ # 登录拦截器等过滤器 │ │ │ └── util/ # DBUtil 等工具类 │ │ └── resources/ # 配置文件如果有 │ └── webapp/ │ ├── admin/ # 后台管理页面 │ ├── css/ js/ images/ │ ├── WEB-INF/ │ │ ├── lib/ # 依赖 jar 包 │ │ └── web.xml │ ├── index.jsp │ ├── login.jsp │ └── article_list.jsp ├── sql/ │ └── blog.sql # 建库建表 初始化数据 ├── doc/ │ ├── 需求说明文档.md │ └── 课程设计报告.pdf └── README.md注意几个容易被扣分的位置。第一建表SQL脚本一定放在独立的sql目录而不是贴在Word文档里让导师自己复制第二WEB-INF/lib下必须放全依赖jar包缺一个启动就报ClassNotFoundException第三JSP页面按admin与前台分开存放不然过滤器的拦截路径没法写清楚。我见过的很多“跑不起来”的课设源码问题都出在jar包缺失或JDK版本不匹配而不是代码本身有Bug。所以拿到源码后第一步不是读代码而是核对三个版本JDK版本、Servlet容器版本、数据库版本。这三个版本不匹配后面每一步都会翻车而且报错信息极具迷惑性后面第5章我会逐个把这些坑拆开讲。2.3 本地环境准备JDK版本、Servlet容器、驱动与依赖的搭配环境配置是最没技术含量、但最卡人的一步。先说版本搭配的参考组合JDK 8或11都可以Servlet容器用8.5或9.x数据库建议用5.7或8.x数据库驱动用Connector/J 5.1.49或8.0.x。不要追求最新版本课设追求的是稳定。一个常见的认知误区是“JDK装得越新越好”。比如用JDK 17跑老项目大概率会因为模块化限制导致反射访问报错。课设项目基本都是JDK 8时代写的老老实实用JDK 8最省事。即使你本地已有更高版本也建议通过IDE的项目结构单独指定一个JDK 8。数据库驱动的版本选择同样有讲究。Connector/J 8.0版本对时区敏感连接串里必须带serverTimezoneAsia/Shanghai否则跑起来后你查询结果里的时间字段会莫名其妙少8小时。这个问题太经典了后面我会单独拿出一节来讲。建议先统一用 5.1.49 版本的驱动它对时区不敏感课设场景下能少踩一个坑。3. 数据库设计与SQL脚本五张核心表支撑起博客系统的完整闭环3.1 需求到表结构从用户故事反推字段设计数据库设计是课设报告里占分最重的一部分也是导师最可能追问的部分。个人博客系统的核心需求可以拆成几个用户故事用户能注册登录用户能发表文章、编辑自己的文章、删除自己的文章文章归属于一个分类用户可以评论文章每篇文章有创建时间和阅读量。从这些故事反推最少需要五张表用户表、分类表、文章表、评论表、标签表。其中文章和标签是多对多关系需要一张中间关联表所以实际是六张表。我不会刻意省掉关联表因为多对多关系正是课设报告里的加分点——它展示了你对第三范式的基本理解。字段设计上有一个原则每个表必须有主键、尽量用自增整型、时间字段统一用datetime类型、文本字段按长度选择varchar或text。文章内容用text或longtext而文章摘要用varchar(200)就够了。密码字段要注意不要用明文至少做一次MD5加密存储字段长度定64位因为MD5的十六进制表示正好是32位但如果你后面升级到盐值加哈希64位更稳妥。评论表需要同时存文章ID和用户ID作为外键这样你才能在一次查询里拿到“谁在什么时间评论了哪篇文章”。外键在课设层面可以加也可以不加但表结构里至少要有这个字段并建立索引。生产环境通常不建物理外键但课设报告里画出外键关系反而容易得分这个度你自己把握。3.2 建表SQL脚本字段注释、外键索引与字符集一次到位下面是一份可直接复用的建表SQL。注意字符集统一用utf8mb4而不是utf8因为utf8在MySQL里存不了emoji。你不想用户在评论区发个表情符号就让整条插入报错吧。-- 建库指定字符集避免乱码 CREATE DATABASE IF NOT EXISTS blog_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE blog_db; -- 1. 用户表 CREATE TABLE tb_user ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 用户ID自增主键, username VARCHAR(30) NOT NULL UNIQUE COMMENT 登录用户名唯一约束, password VARCHAR(64) NOT NULL COMMENT 密码MD5加密后存储, nickname VARCHAR(30) NOT NULL COMMENT 展示昵称用于页面显示, email VARCHAR(50) DEFAULT NULL COMMENT 邮箱可为空, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像图片路径, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间默认当前时间 ) ENGINEInnoDB COMMENT博客用户表; -- 2. 分类表 CREATE TABLE tb_category ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 分类ID, name VARCHAR(50) NOT NULL UNIQUE COMMENT 分类名称, description VARCHAR(200) DEFAULT NULL COMMENT 分类描述 ) ENGINEInnoDB COMMENT文章分类表; -- 3. 文章表 CREATE TABLE tb_article ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 文章ID, title VARCHAR(100) NOT NULL COMMENT 文章标题, summary VARCHAR(200) DEFAULT NULL COMMENT 摘要列表页展示, content LONGTEXT NOT NULL COMMENT 正文内容, user_id INT NOT NULL COMMENT 作者ID关联tb_user, category_id INT DEFAULT NULL COMMENT 分类ID关联tb_category, view_count INT DEFAULT 0 COMMENT 阅读数, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 发布时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 最后更新时间, KEY idx_user_id (user_id), KEY idx_category_id (category_id) ) ENGINEInnoDB COMMENT博客文章表; -- 4. 评论表 CREATE TABLE tb_comment ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 评论ID, article_id INT NOT NULL COMMENT 评论所属文章, user_id INT NOT NULL COMMENT 评论者用户ID, content VARCHAR(500) NOT NULL COMMENT 评论内容, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 评论时间, KEY idx_article_id (article_id), KEY idx_user_id (user_id) ) ENGINEInnoDB COMMENT文章评论表; -- 5. 标签表 CREATE TABLE tb_tag ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 标签ID, name VARCHAR(30) NOT NULL UNIQUE COMMENT 标签名称 ) ENGINEInnoDB COMMENT标签表; -- 6. 文章-标签关联表处理多对多关系 CREATE TABLE tb_article_tag ( article_id INT NOT NULL COMMENT 文章ID, tag_id INT NOT NULL COMMENT 标签ID, PRIMARY KEY (article_id, tag_id), KEY idx_tag_id (tag_id) ) ENGINEInnoDB COMMENT文章与标签多对多关联表;这份脚本里有几个设计点要能讲出来。第一tb_user.username加了UNIQUE约束注册时重名会直接报SQL异常你需要在代码里捕获这个重复异常并转成友好提示。第二tb_article.create_time用了DEFAULT CURRENT_TIMESTAMP这样插入时不用显式写时间但Service层仍然可以主动设置时间以便灵活控制。第三索引不是随便加的idx_user_id和idx_category_id分别对应用户查自己的文章列表、按分类筛选文章这两个高频查询。执行这份脚本前要确认数据库版本。MySQL 5.5及以下版本不支持DEFAULT CURRENT_TIMESTAMP用于datetime字段的默认值会直接报语法错误。课设环境普遍是5.7或8.0问题不大但如果你用的是老版本MariaDB需要手动调整。3.3 初始化数据与联表查询让报告里的ER图真正能跑光有表结构还不行课设演示时要展示“有数据的系统”所以初始化数据也是交付物的一部分。注意插入的顺序先插入用户和分类再插入文章最后插入评论与标签关联否则外键指向的记录还不存在会插入失败。-- 初始化数据密码统一为 123456 的 MD5 值 e10adc3949ba59abbe56e057f20f883e INSERT INTO tb_user (username, password, nickname, email) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, 博主, adminexample.com), (test, e10adc3949ba59abbe56e057f20f883e, 测试用户, testexample.com); INSERT INTO tb_category (name, description) VALUES (Java, Java相关技术文章), (数据库, 数据库设计与优化); INSERT INTO tb_article (title, summary, content, user_id, category_id) VALUES (博客系统上线啦, 我的第一篇博客, 这是一篇测试博客内容……, 1, 1); INSERT INTO tb_comment (article_id, user_id, content) VALUES (1, 2, 恭喜上线期待更多内容);初始化数据里的密码是MD5后的固定值报告里要写明“初始密码为123456”否则导师演示时登录不进去印象分会受影响。这是很多同学容易忽略的细节。就联表查询而言个人博客系统里最核心的三条SQL要烂熟于心。第一条是查文章列表同时带出作者昵称和分类名用一个左连接搞定第二条是查某个分类下的所有文章第三条是统计每篇文章的评论数。这三条SQL写进报告里比贴十个页面截图更能说明你对数据库的理解。4. 核心功能编码落地从登录鉴权到文章发布的完整链路4.1 登录拦截器Filter Session 的会话保持写法个人博客系统的核心安全诉求是未登录用户不能进入后台管理页面不能执行删除和编辑操作。这个需求用Filter统一拦截而不是在每个Servlet里重复判断。package com.example.blog.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 注解注册避免在 web.xml 里配置 */ WebFilter(urlPatterns {/admin/*, /article/edit, /article/delete}) 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 uri req.getRequestURI(); if (uri.endsWith(/login.jsp) || uri.endsWith(/user/login)) { chain.doFilter(request, response); return; } HttpSession session req.getSession(false); Object loginUser session ! null ? session.getAttribute(loginUser) : null; if (loginUser null) { // 未登录重定向到登录页并记录来源URL便于登录后跳回 resp.sendRedirect(req.getContextPath() /login.jsp?redirect uri); return; } chain.doFilter(request, response); } }这段代码有几个关键点要说明。req.getSession(false)的false参数很关键它表示“如果当前没有Session不要创建一个新的”。如果不传false未登录用户每次访问都会被创建一个空Session虽然返回null的判断逻辑一样但会白白产生大量无用的Session对象对内存是一种浪费。重定向时带上redirect参数是一个易被忽略的体验优化。用户访问后台页面被拦到登录页登录成功后读这个参数直接跳回原页面而不是每次都回到首页这种细节在答辩时提一句导师会认为你考虑到了真实使用场景。urlPatterns的配置需要你对自己项目的URL结构非常清楚。这里拦截了/article/edit和/article/delete这样的单路径如果你的项目里是/article?actionedit这样的写法Filter就拦截不住因为这本质上只有一个URL没法按参数区分。比较好的实践是后台操作统一走/admin/*前缀这样拦截器配置一次管到底。4.2 文章的增删改查从表单提交到DAO层的数据流转下面用“发表文章”这个流程把Servlet到DAO的完整链路串一遍。先说清楚为什么必须有Service层。很多课设把业务逻辑直接写在Servlet里看起来代码量少了但一旦出现“发文章后要同时增加用户文章数”这种跨表操作Servlet里的代码就会膨胀得没法看。package com.example.blog.controller; import com.example.blog.entity.User; import com.example.blog.service.ArticleService; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/article/add) public class ArticleAddServlet extends HttpServlet { private final ArticleService articleService new ArticleService(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 解决中文乱码请求体编码 响应编码 request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); // 2. 从请求中读取表单参数 String title request.getParameter(title); String summary request.getParameter(summary); String content request.getParameter(content); String categoryId request.getParameter(categoryId); // 3. 后端参数校验任何字段为空都打回不依赖前端 if (title null || title.trim().isEmpty() || content null || content.trim().isEmpty()) { request.setAttribute(error, 标题和正文不能为空); request.getRequestDispatcher(/article_edit.jsp).forward(request, response); return; } // 4. 从Session获取当前登录用户而不是信任前端传值 User loginUser (User) request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } // 5. 组装实体并交给Service层 int cid categoryId ! null !categoryId.isEmpty() ? Integer.parseInt(categoryId) : 0; int newId articleService.addArticle(title, summary, content, loginUser.getId(), cid); // 6. 成功则重定向避免刷新页面时重复提交 if (newId 0) { response.sendRedirect(request.getContextPath() /article/detail?id newId); } else { request.setAttribute(error, 发布失败请稍后重试); request.getRequestDispatcher(/article_edit.jsp).forward(request, response); } } }第1步的编码设置是无数翻车现场的病根。request.setCharacterEncoding(UTF-8)只对POST请求的正文生效如果是GET请求的中文参数乱码需要在Servlet容器层面配置URIEncoding后面第5章会讲。第3步的后端校验很多人会省认为前端表单已经做了必填验证可一旦绕过前端直接提交请求空数据就会进数据库违背了“永远不要信任前端输入”这条安全底线。第6步的sendRedirect与forward的选择要能讲明白。发文章成功后如果用forward转发到详情页地址栏仍然是/article/add用户刷新一次就重新插入一条一模一样的文章这种“表单重复提交”问题是课设答辩中被问频率最高的坑之一。重定向让浏览器重新请求详情页地址栏变成/article/detail?idx刷新就安全了。对应的Service层与DAO层代码如下package com.example.blog.service; import com.example.blog.dao.ArticleDao; import com.example.blog.entity.Article; public class ArticleService { private final ArticleDao articleDao new ArticleDao(); /** * 新增文章返回自增主键ID */ public int addArticle(String title, String summary, String content, int userId, int categoryId) { // 业务规则标题长度不能超过100超过则截断 if (title.length() 100) { title title.substring(0, 100); } Article article new Article(); article.setTitle(title); article.setSummary(summary); article.setContent(content); article.setUserId(userId); article.setCategoryId(categoryId); return articleDao.insert(article); } }DAO层用PreparedStatement防SQL注入这是课设里必须体现的安全意识。下面这段代码的关键点在于所有参数都是用setString、setInt绑定的而不是直接拼接进SQL字符串里。package com.example.blog.dao; import com.example.blog.entity.Article; import com.example.blog.util.DBUtil; import java.sql.*; public class ArticleDao { /** * 插入文章返回数据库生成的自增ID */ public int insert(Article article) { String sql INSERT INTO tb_article (title, summary, content, user_id, category_id) VALUES (?, ?, ?, ?, ?); // RETURN_GENERATED_KEYS 让 JDBC 返回自增主键值 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, article.getTitle()); ps.setString(2, article.getSummary()); ps.setString(3, article.getContent()); ps.setInt(4, article.getUserId()); ps.setInt(5, article.getCategoryId()); int rows ps.executeUpdate(); if (rows 0) { try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { return rs.getInt(1); // 拿到自增主键 } } } } catch (SQLException e) { e.printStackTrace(); } return 0; } }这代码里的Statement.RETURN_GENERATED_KEYS是获取自增ID的标准写法。如果不加这个参数执行完insert后你需要额外查一次SELECT MAX(id) FROM tb_article不仅多一次查询在并发场景下还会取错ID。课设虽然不要求高并发但这个写法本身就体现专业性。4.3 多表联查实操文章列表与评论数的同步展示文章列表页需要同时展示作者昵称、分类名和评论数这就是典型的多表联查场景。三种实现方式第一种是查文章后在Java代码里循环查作者和评论数第二种是数据库里用连接查询一次带出第三种是子查询。课设推荐第二种一次SQL拿到全部数据响应速度也快。-- 文章列表联查一次取出标题、作者、分类、评论数 SELECT a.id, a.title, a.summary, a.view_count, a.create_time, u.nickname AS author_name, c.name AS category_name, (SELECT COUNT(*) FROM tb_comment co WHERE co.article_id a.id) AS comment_count FROM tb_article a LEFT JOIN tb_user u ON a.user_id u.id LEFT JOIN tb_category c ON a.category_id c.id ORDER BY a.create_time DESC;这个SQL里最值得讲解的是LEFT JOIN而不是INNER JOIN的选择。如果某篇文章没有设置分类LEFT JOIN依然会返回这篇文章只是category_name是NULL如果用INNER JOIN这篇文章会直接从列表里消失。对于博客系统来说文章是主体分类是附属信息所以用LEFT JOIN保证主表数据不丢。Comment_count用相关子查询实现代码可读性好课设代码量也不大。如果数据量到了十万级以上这种写法会出现性能问题但在课设演示场景下完全够用答辩时能主动说出“这个写法在数据量增大后有性能隐患可以改成LEFT JOIN加GROUP BY”这句话反而显得你有思考深度。对应的Java端DAO代码需要把查询结果映射成视图对象public ListArticleVO selectArticleList() { String sql SELECT a.id, a.title, a.summary, a.view_count, a.create_time, u.nickname AS author_name, c.name AS category_name, (SELECT COUNT(*) FROM tb_comment co WHERE co.article_id a.id) AS comment_count FROM tb_article a LEFT JOIN tb_user u ON a.user_id u.id LEFT JOIN tb_category c ON a.category_id c.id ORDER BY a.create_time DESC; ListArticleVO list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { ArticleVO vo new ArticleVO(); vo.setId(rs.getInt(id)); vo.setTitle(rs.getString(title)); vo.setSummary(rs.getString(summary)); vo.setViewCount(rs.getInt(view_count)); vo.setCreateTime(rs.getTimestamp(create_time)); vo.setAuthorName(rs.getString(author_name)); vo.setCategoryName(rs.getString(category_name)); vo.setCommentCount(rs.getInt(comment_count)); list.add(vo); } } catch (SQLException e) { e.printStackTrace(); } return list; }ArticleVO的作用是封装联查结果而不是直接用实体类接收。它额外包含了authorName、categoryName、commentCount这三个不属于tb_article表字段的属性。如果你把这三个字段塞回Article实体类里会让实体类变得不纯粹后面做插入和更新时容易误操作到不属于本表的字段。分层到这个粒度报告里就有的写了。5. 项目落地避坑指南从编码到部署的四个典型翻车现场5.1 现象启动后访问任意路径都报 ClassNotFoundException: com.mysql.jdbc.Driver这个报错是课设里出现概率最高的没有之一。现象很直接浏览器访问登录页能正常打开但一点“登录”按钮就白屏控制台打出ClassNotFoundException或者No suitable driver found。原因几乎永远是同一个数据库驱动的jar包没有放到WEB-INF/lib目录下。很多同学下载了mysql-connector的jar包然后随手放在了桌面上或者放进了IDE的某个外部依赖里。本地跑的时候IDE可能通过classpath找到了驱动但部署到Servlet容器后容器只会从WEB-INF/lib加载jar包外部路径一概不认。解决把驱动jar包复制到webapp/WEB-INF/lib/目录下然后在IDE里执行一次Clean清理构建缓存并重新发布再重启Servlet容器。注意如果驱动版本是8.x驱动类名要写成com.mysql.cj.jdbc.Driver而不是老的com.mysql.jdbc.Driver。这个差异在5.1版本和8.0版本之间切换时最容易踩坑。5.2 现象数据库里的时间是对的页面上显示的却是00:00血泪经典。数据库里通过Navicat或命令行查询create_time显示的是正确时间但JSP页面上输出出来时间变成了当天00:00:00。原因有两个层面。第一实体类里接收时间字段用了java.sql.Date而不是java.sql.Timestamp。Date只保留日期部分时分秒全部丢弃所以变成了00:00。第二如果所有时间都偏移8小时那是数据库连接串缺少serverTimezone参数导致的时区问题。解决实体类的时间字段换成java.util.Date或java.sql.Timestamp从ResultSet取值用rs.getTimestamp()而不是rs.getDate()。时区问题则检查数据库连接串确保包含时区参数并保持前后一致。我一般建议两个问题一起排查因为它们的表现容易混淆单独修一个问题看起来还在。5.3 现象登录成功后访问后台页面又被踢回登录页用户明明刚登录成功跳转到后台首页好用但点击任意一个功能链接后又被重定向到登录页。关掉浏览器再重新登录也是一样的问题。原因分析Session失效。常见情况是项目里用了request.getSession(true)在Filter里判断登录状态同时把登录用户信息存进了一个新创建的Session而后续请求带的Session ID在服务器端找不到对应的Session对象。更常见的原因是什么部署路径不一致。Servlet容器的会话Cookie默认只对当前路径有效如果你的项目部署在/blog路径下而代码里重定向写死了根路径Cookie匹配不上Session就被“丢”了。解决所有重定向统一用request.getContextPath() /目标路径拼接不要写死/login.jsp。同时检查登录Servlet里存Session的key与Filter取Session的key是否完全一致比如登录时存了loginUserFilter里取的是user永远匹配不上。这种低级错误最容易在熬夜赶工时发生。5.4 现象Filter一加上页面样式全部消失登录页变成纯文本加了登录Filter之后发现登录页本身的CSS样式全丢了页面只剩下裸露的表单和文字。打开浏览器开发者工具CSS文件请求全部返回302重定向到登录页。原因一目了然但容易被忽略Filter的urlPatterns写成了/*把所有请求都拦了回来包括CSS、JS、图片这些静态资源。JSP页面在容器中可以编译执行但CSS是静态文件请求路径被Filter拦住后由于没有登录状态被重定向到了登录页浏览器收到的是HTML而不是CSS内容自然不会生效。解决要么把Filter的urlPatterns限定到需要保护的路径如/admin/*要么在Filter内部加一个静态资源放行判断。后者更稳妥因为即使后台页面也有CSS引用。具体代码就是在doFilter开头判断URI是否以.css、.js、.png、.jpg结尾是则直接放行。这个坑如果你自己没踩过很可能在答辩现场被导师问“你的Filter怎么配置的”时卡壳。6. 从“能交作业”到“敢开讲”三个低成本进阶改造点第一个进阶点给文章列表加分页。课设里最常见的问题是“一次性查出所有文章”数据量小的时候无感但导师会问“如果有一万篇文章怎么办”。给DAO方法加LIMIT与OFFSET参数在Servlet里接收页码参数JSP页面底部渲染上一页与下一页链接。这个改造点大约需要40行代码但能让你在答辩时回答“查询性能”问题时直接拿出方案而不是说“没考虑过”。第二个进阶点为密码加密加盐。前文的SQL脚本里密码是简单MD5这在课设里已经及格但要再进一步可以在注册时生成随机盐值用MD5(盐值 密码)存储。改造点集中在注册与登录两处注册时生成盐存进数据库登录时先查出盐再做拼接核对。代码量不大但体现了“MD5不够安全”的认知这是面试官和导师都愿意听到的话。第三个进阶点阅读量计数。文章的view_count字段已经在表里但你来访页面时加1的方式有讲究。直接在详情Servlet里每次执行UPDATE tb_article SET view_count view_count 1 WHERE id ?简单有效。这里可以主动提一句“这个写法在并发场景有可能丢计数理想用Redis但课设层面这个方案足够”这句话就是加分项。这三个改造点的共同特点是改动范围小不破坏现有结构每一处都能在答辩时引出“为什么这么做”的讨论。比起临时加一堆功能模块把这三处讲透的性价比更高。最后说个习惯我在交课设前的最后一晚一定会做三件事——第一把数据库脚本在干净环境里重新执行一遍确保不是“只能在我电脑上跑”第二用浏览器的隐身模式测试一遍完整流程排除缓存和Session残留的干扰第三把报错控制台清空重新从启动到演示完整走一遍确认没有红色异常刷屏。这三个习惯救过我很多次项目能不能跑、能不能当众跑是完全不同的两回事。希望帮到你。本文还有配套的精品资源点击获取