1. 选题拆解:这套交通事故档案管理系统到底解决什么问题
做毕业设计选题那阵子,我见过太多人一上来就盯着"电商秒杀""在线教育""智慧校园"这类红海题,结果做到一半发现功能和市面上开源项目高度重合,答辩时被问一句"你这个和网上那个有什么区别"就哑火了。相比之下,交通事故档案管理系统这个方向看起来朴素,实际上是块被低估的好料——它天然带着"档案管理"的严肃性和"事故处理"的流程性,业务链条长、角色关系清晰、数据表之间有真实的关联约束,正好能把 JSP、Servlet、JavaBean、MySQL、Tomcat 这一整套基础技术栈的功底全部考出来。我前后帮几位同学梳理过类似系统的实现,也自己从零撸过一版可运行的源码,踩过的坑从字符集到事务回滚再到文件存储路径,一个不落。
这篇文章面向三类人:一是正在做类似毕设选题、需要一个能跑起来、能写进论文、能扛住答辩追问的完整方案的同学;二是刚接触 Java Web 传统技术栈、想找一个业务完整的练手项目的初学者;三是已经拿到一套源码但部署不起来、改不动、看不懂数据流的人。我会把这个系统从选题逻辑、架构分层、数据库设计、核心模块实现,一直讲到部署实操和故障排查,把"为什么这么设计"和"具体怎么落地"都摊开说。代码层面以这个技术栈的常见写法为主,涉及参数的地方我会给出计算依据,涉及取舍的地方我会说明我当时的判断。
1.1 纸质卷宗的三处典型瓶颈
要理解这套系统存在的意义,先得搞清楚它替代的东西有多难受。现实中交通事故处理的原始档案基本是纸质卷宗加零散电子表格的混合状态:一份卷宗里夹着现场照片、当事人询问笔录、车辆信息登记表、责任认定材料、保险单据,厚的能有几十页。这种形态在三个环节上会集中暴露问题。
第一是检索。想找"去年雨季在某个路段发生的、涉及两辆以上机动车、有人员受伤的案子",纸面上只能靠人翻目录,翻完还要逐份核对,一个人一天能筛出来的量非常有限。第二是一致性。同一起事故,值班记录上写的是 A 时间,笔录里记的是 B 时间,统计报表里又变成 C 时间,三个地方三份数据,最后谁都不信。第三是留存与借阅。卷宗被借走后没有痕迹,归还时是否完整无人核对,时间一长材料缺失就成了糊涂账。
系统要干的事,说白了就是把这三件事逐个堵死:结构化存储让字段级检索成为可能,单一数据源让同一事实只录入一次、处处引用,操作留痕让每一次查阅、修改、归档都有记录。这个逻辑链条讲清楚了,论文第一章的"研究意义"根本不用凑字数。
1.2 四类角色与一条业务主链
我见过不少同学的系统,用户表里就一个admin,所有功能全给它,答辩老师看一眼就知道是为了省事。真正让系统立得住的做法,是按业务真实分工切出角色。这套交通事故档案管理系统我设计了四类角色,每一类的权限边界都很清楚:
| 角色 | 核心职责 | 典型操作 | 数据可见范围 |
|---|---|---|---|
| 系统管理员 | 账号与基础数据维护 | 建账号、配字典、看全量日志 | 全部(但不可改案件内容) |
| 案件录入员 | 事故信息采集 | 新建案件、补录当事人与车辆、上传证据 | 本人经办及本部门案件 |
| 档案管理员 | 归档与借阅管理 | 审核归档、办理借阅、催还 | 全部已归档案件 |
| 查询用户 | 只读查阅 | 条件检索、查看详情、导出清单 | 按授权范围只读 |
四类角色串起来,就是一条完整的业务主链:受理登记 → 信息补录 → 调查推进 → 审核归档 → 借阅利用 → 统计复盘。这条链路里有两个关键的状态跃迁点必须设计成显式动作,而不是随手改个字段:一是"调查中"到"已归档",必须由档案管理员审核通过才能触发,归档后案件主体信息锁定为只读;二是"已归档"到"借阅中",必须生成借阅记录并带归还期限。有了这两个卡点,系统的"业务味"立刻就不一样了,论文里"业务流程分析"那一章也就有了真实的图可以画。
提示:状态字段千万不要图省事用自由文本或者让前端随便传值,一定要在服务端做白名单校验。我在一次代码审查里见过前端直接把状态传成任意字符串的实现,结果统计报表里出现了一个谁也不知道是什么的状态值,排查了整整一个下午。
1.3 技术选型:为什么是 JSP+Servlet+JavaBean 而不是全家桶
每次聊到这个话题,总有人问"现在谁还用 JSP"。这话在产品语境下没错,但在毕设语境下要拆开看。我当时选这套组合的判断依据有三条,都不是拍脑袋。
第一是可控性。Spring Boot 一行spring-boot-starter-web引下来的依赖树,答辩老师问"你这个请求是怎么从浏览器走到 Controller 的",很多人只能答"框架自动做的"。而 JSP + Servlet 这套,一个 HTTP 请求从web.xml里的url-pattern匹配到具体 Servlet,再到doGet/doPost里拿request.getParameter(),最后request.getRequestDispatcher().forward()到 JSP 渲染,整条链路是可以完整讲出来的。答辩场上,能讲清链路的人和不讲清的人,分数差距很实在。
第二是部署成本。Tomcat 解压即用,把war包往webapps一扔就完事,连 Maven 都可以不要(当然用 Maven 更规范)。我遇到过用全家桶的同学,光是把项目在答辩机房的旧环境里跑起来就折腾了半天,因为 JDK 版本、内嵌容器端口、数据库驱动版本全都要对齐。传统 war 包部署虽然"土",但它的确定性极高。
第三是评分匹配。大多数院校的毕设评分表里,"系统设计完整性""数据库设计规范性""功能实现度"占大头,技术新颖度往往只占很小权重。用 JSP 把六张表的关系、事务、分页、上传、权限全做扎实,比用新框架做一个只有增删改查的壳子,得分高得多。
当然我也得说清楚这套选型的代价:JSP 页面里嵌 Java 代码容易写乱,必须靠 JavaBean + DAO 把业务逻辑抽出去;没有框架帮你做参数绑定,每个请求都要手动取参、手动校验;没有依赖注入,DAO 对象要么new要么写工厂。这些"缺失"恰恰是练习基本功的地方,但你要有心理准备,代码量会比用框架多出三到四成。
2. 整体架构与数据库设计:地基打不好,后面全是返工
我见过太多做到第七周才开始改数据库的同学,改一个字段牵连十几个 JSP 页面和 Servlet,最后索性推倒重来。这个系统的表不算多,但关联关系不算简单,所以第一周我就把表结构、字段类型、索引、外键策略全部定死并写进开发文档。这一节讲的是整体骨架怎么搭,以及六张核心表为什么这么切。
2.1 分层落地的具体形态
这套系统的分层不是纸面上画着好看,是真的一层层落到了包结构上:
src/ ├── com.demo.entity // JavaBean 实体,与表字段一一对应 ├── com.demo.dao // 接口 + 实现,只做 SQL 相关 ├── com.demo.service // 业务逻辑、事务边界 ├── com.demo.servlet // 接收请求、参数校验、组装响应 ├── com.demo.filter // 编码过滤器、登录拦截、权限校验 ├── com.demo.util // 数据库连接、日期、文件、分页工具 └── com.demo.listener // 启动时加载字典缓存、统计在线人数Entity 层就是纯 JavaBean,私有字段加 getter/setter,字段名与表字段用驼峰/下划线对应,这一层不要有任何逻辑。DAO 层只负责一件事:把 SQL 跑出结果并映射成对象,方法的粒度要贴合业务,比如findByCondition(condition, offset, limit)和countByCondition(condition)成对出现,方便分页。Service 层是事务边界所在,凡是涉及多表写入的操作,setAutoCommit(false)就应该出现在这一层,而不是 DAO 里。Servlet 层严格来说只做三件事:取参、校验、转发,超过 50 行的 Servlet 我一定拆。
这里有个我坚持的习惯:Servlet 不直接 new DAO,而是 new Service。看起来多绕一层,但等到你需要在"新增案件"后面追加"写操作日志"时,就知道好处了——只改 Service,Servlet 一行不动。
2.2 六张核心表怎么切
表设计的核心原则是"一个事实只存一个地方"。围绕一起事故,我切出了六张主表,加两张辅助表:
| 表名 | 作用 | 关键字段 | 与其他表的关系 |
|---|---|---|---|
t_user | 账号与角色 | id, username, pwd, real_name, role, dept, status | 被案件、日志引用 |
t_accident | 案件主表 | id, case_no, happen_time, place, type, severity, status, handler_id | 一对多关联当事人、车辆、证据 |
t_party | 当事人 | id, accident_id, name, id_no, party_type, injury_level | 外键指向案件 |
t_vehicle | 涉事车辆 | id, accident_id, plate_no, v_type, owner, insurance, damage | 外键指向案件 |
t_evidence | 证据材料 | id, accident_id, file_name, file_path, file_size, uploader | 外键指向案件 |
t_borrow | 借阅记录 | id, accident_id, borrower, borrow_time, due_time, return_time, status | 外键指向案件与用户 |
t_log | 操作日志 | id, user_id, action, target, ip, create_time | 外键指向用户 |
t_dict | 数据字典 | id, type_code, item_code, item_name, sort | 独立,供下拉框使用 |
t_dict这张表很多人会省掉,直接用硬编码的下拉选项,我当时也纠结过。后来算了一笔账:事故类型、天气状况、路面情况、伤情等级、车辆类型、责任划分,六类枚举加起来三十多个选项,如果硬编码在前端,一旦要加一个"隧道"路面类型,得翻遍所有 JSP。用字典表虽然多一次查询,但配合ServletContextListener在启动时把字典全量加载进内存 Map,运行时零查询开销,这个投入非常值。
t_accident里我特意留了is_deleted字段做软删除。这个决定在答辩时被问过,我的回答是:档案类数据具有留存要求,物理删除一旦误操作无法追溯,软删除配合t_log里的删除记录,能完整还原"谁在什么时候删了什么"。这个回答基本没被追问。
2.3 三个容易埋雷的字段设计
案件编号case_no。我用的规则是SJ + yyyyMMdd + 四位流水号,比如SJ202405120007。生成逻辑是取当天已有最大编号后加一。这里有个并发问题必须正视:两个录入员同时提交,可能生成同一个编号。我的处理是给case_no加唯一索引,Service 层捕获SQLIntegrityConstraintViolationException后重试一次,重试再失败就提示用户刷新。这比在 Java 里加锁简单得多,也可靠得多——毕竟应用层锁在集群环境下完全失效,而数据库唯一约束永远有效。
注意:流水号千万不要用
SELECT COUNT(*)+1来算。删除过一条记录之后,COUNT 会回退,下一个编号就会撞车。正确写法是SELECT MAX(case_no) FROM t_accident WHERE case_no LIKE 'SJ20240512%',从最大号往后推。
状态status。用tinyint存 0/1/2/3,含义分别是待受理、调查中、已归档、借阅中,映射关系放在字典表里。绝对不要在表里存中文,一旦有人手滑存了带空格的"已归档 ",用WHERE status='已归档'就查不出来。
时间字段。事故时间happen_time用datetime,登记时间create_time用datetime并设默认值CURRENT_TIMESTAMP。这里踩过一次坑:事故时间允许用户选择"只精确到日期",前端传的是2024-05-12,后端SimpleDateFormat("yyyy-MM-dd HH:mm:ss")解析直接抛异常。后来的处理是统一用String接收,按长度判断格式再补全时分秒,这个小工具方法DateUtil.parseFlexible()后来被我在好几个项目里复用。
3. 核心模块实现要点逐个拆
骨架搭完了,接下来是真正出活的部分。这一节我按"登录 → 登记 → 检索 → 上传 → 统计"五个模块讲,每个模块都说清楚关键代码位置、参数怎么定、坑在哪。我不打算贴完整源码,而是把最容易写错的地方点出来,这样你拿着自己的代码对照着改就行。
3.1 登录与角色权限:Session + Filter 的双保险
登录这块最朴素的做法是查库比对密码然后session.setAttribute("user", user),能跑,但撑不住提问。我做了三件事让它更像个正经系统。
密码不能明文存。用MessageDigest.getInstance("MD5")加盐哈希,盐值存在用户表单独字段里。MD5 现在当然不够安全,但对比"明文存密码"已经是质变,答辩时你可以主动说"生产环境应换用 BCrypt 并提高迭代次数",这句话说出来是加分的。哈希的写法要注意把字节数组转十六进制字符串,很多人直接new String(bytes),得到一堆乱码,然后发现存进去的密码自己都登不进去。
权限校验放在 Filter 里,不要散落在每个 Servlet。我写了AuthFilter,url-pattern配成/*,在doFilter里判断三件事:请求路径是否在白名单(登录页、静态资源、登录接口);Session 里有没有 user;当前用户的 role 是否有权访问该路径前缀。路径与角色的映射我用一个静态Map<String, String[]>维护,比如/archive/*只允许档案管理员,/admin/*只允许系统管理员。这样新增页面时只要改 Map,不用动 Filter 逻辑。
// 简化的分支逻辑,核心是"默认拒绝" if (isWhiteList(path)) { chain.doFilter(req, resp); return; } User u = (User) session.getAttribute("user"); if (u == null) { resp.sendRedirect(ctx + "/login.jsp"); return; } if (!hasPermission(u.getRole(), path)) { req.setAttribute("msg", "无权访问该功能"); req.getRequestDispatcher("/error/403.jsp").forward(req, resp); return; } chain.doFilter(req, resp);会话超时与重复登录。web.xml里配<session-timeout>30</session-timeout>。重复登录的处理我只做了简单版:登录成功时把当前 sessionId 写进一个ConcurrentHashMap<String, String>,同一个账号再次登录就顶掉旧 session。这个 Map 在应用重启后会清空,生产环境当然要用更好的方案,但在毕设里足够体现思考深度。
3.2 案件登记:一次提交写三张表的事务处理
案件登记是整个系统里最考验数据库功底的模块,因为一次提交要往t_accident、t_party、t_vehicle三张表写数据,任何一张失败都必须全部回滚——否则会出现"有案件没当事人"的脏数据,这种数据一旦进了统计报表,后面所有数字都是错的。
我的做法是在 Service 层开事务,DAO 层接收外部传入的Connection,不自己拿连接:
public boolean registerCase(Accident a, List<Party> parties, List<Vehicle> vehicles) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); int accidentId = accidentDao.insert(conn, a); // 返回自增主键 if (accidentId <= 0) throw new RuntimeException("主表写入失败"); for (Party p : parties) { p.setAccidentId(accidentId); if (!partyDao.insert(conn, p)) throw new RuntimeException("当事人写入失败"); } for (Vehicle v : vehicles) { v.setAccidentId(accidentId); if (!vehicleDao.insert(conn, v)) throw new RuntimeException("车辆写入失败"); } conn.commit(); return true; } catch (Exception e) { DBUtil.rollbackQuietly(conn); log.error("案件登记失败", e); return false; } finally { DBUtil.closeQuietly(conn); } }这里有两个细节值得单独说。第一个是自增主键回填:PreparedStatement要用RETURN_GENERATED_KEYS参数,然后getGeneratedKeys()拿 id,反过来赋值给从表记录,不要图省事用"再查一次 MAX(id)",并发下必错。第二个是连接归还:autoCommit被改成false之后,如果这个连接直接还给连接池,池里其他使用者拿到的还是关闭自动提交的连接,会引发一连串莫名其妙的"数据不生效"问题。所以finally里必须先把autoCommit恢复成true再close()。
前端这一块,我用一个页面搞定主表加动态行:当事人和车辆都用"添加一行"的按钮动态插入input数组,name写成partyNames[]、partyIdNos[]这种形式,Servlet 里用request.getParameterValues("partyNames")一次取回数组,按下标对应组装对象。这个写法比每个字段单独取参干净得多,也避免了"有五个当事人但要写五十行取参代码"的尴尬。
3.3 档案检索:多条件组合查询与手写分页
检索是这个系统被使用频率最高的功能,也是我花时间最多的地方。需求本身很直白:按案件编号、时间区间、事故类型、严重程度、状态、经办人任意组合查询,结果分页展示。难点在于条件是可选的,用朴素写法要么写出几十个if拼 SQL,要么拼接的字符串里混入了用户输入,直接把自己送进 SQL 注入的坑。
我的处理是构造一个CaseQuery对象装所有条件,DAO 里维护两个StringBuilder:一个拼WHERE子句,一个装参数;每加一个条件,就同时往后追加占位符?和对应的值,最后统一setObject。这样 SQL 结构是动态的,但参数永远是预编译的。
StringBuilder sql = new StringBuilder(" FROM t_accident WHERE is_deleted = 0 "); List<Object> params = new ArrayList<>(); if (StringUtils.isNotBlank(q.getCaseNo())) { sql.append(" AND case_no LIKE ? "); params.add("%" + q.getCaseNo().trim() + "%"); } if (q.getStartTime() != null) { sql.append(" AND happen_time >= ? "); params.add(q.getStartTime()); } if (q.getType() != null && q.getType() > 0) { sql.append(" AND acc_type = ? "); params.add(q.getType()); }分页这块,不少人依赖框架的插件,手写的时候就懵。逻辑其实很简单,两条 SQL:一条SELECT COUNT(*)拿总数算总页数,一条带LIMIT ?, ?拿当前页数据。LIMIT的第一个参数是偏移量,计算方式是(当前页 - 1) * 每页条数。每页条数我定为 10,这个数字不是随便选的——按 1366×768 的投影分辨率估算,一行表格占 40 像素左右,加上表头、分页控件和页面头部,10 行刚好能在不滚动的情况下完整展示,答辩演示时不用来回拉滚动条,观感差别很大。
提示:
LIMIT的偏移量必须做二次校验,大于 0 且不超过总数。我见过用户手工改 URL 里的page=99999导致返回空列表、页面显示"共 0 条"的尴尬情况,虽说不算 bug,但体验很差。我的做法是超出范围时自动纠正到最后一页。
3.4 证据材料上传:路径、大小与命名规则
证据上传看着简单,其实是整个系统里最容易在部署环节出问题的地方。核心矛盾在于:文件存哪?存在项目目录里,redeploy 一次全没了;存在项目外,路径又要可配置。
我的方案是存到项目外的固定目录,数据库只存相对路径。Servlet 里拿到文件名后重命名为案件编号_时间戳_原扩展名,这个命名规则同时解决了两个问题:文件名不会重复,从文件名就能反查属于哪个案子。
String uploadDir = getServletContext().getInitParameter("uploadPath"); // web.xml 里配 String ext = fileName.substring(fileName.lastIndexOf(".")); String newName = caseNo + "_" + System.currentTimeMillis() + ext; File target = new File(uploadDir, newName); part.write(target.getAbsolutePath());Servlet 3.0 之后直接用request.getPart()就行,不用再引commons-fileupload,少一个 jar 少一份兼容麻烦。但getPart要求表单是multipart/form-data且 Servlet 上标注@MultipartConfig,这两个条件缺一个都会拿到null,非常容易漏。
大小限制要分两层设:Servlet 层的@MultipartConfig(maxFileSize = 10 * 1024 * 1024)控制单个文件,业务层再限制总数量和总大小(我定的是单案最多 20 个附件、总计不超过 100MB)。为什么是 10MB?现场照片单张通常 2~4MB,询问笔录扫描件 1~2MB,10MB 足够覆盖单份材料的极端情况,同时又不至于让上传超时。前端用 JS 做一次预校验,超限直接弹提示,避免用户等半天才失败。文件类型不要只靠扩展名判断,改个后缀就能绕过,我额外读了文件头的前几个字节做魔数校验,这段代码不长但很能体现严谨性。
3.5 统计看板:ECharts 数据从哪来
统计页面是答辩时最讨巧的部分,因为它是唯一一个"看起来很有技术含量"的可视化模块。但它的本质就是几条聚合 SQL 加一个前端图表库。
我做三张图:按月事故数量趋势(折线)、事故类型分布(饼图)、严重程度构成(柱状图)。数据来源都是t_accident单表聚合,不需要关联,性能上没有压力:
SELECT DATE_FORMAT(happen_time, '%Y-%m') AS ym, COUNT(*) AS cnt FROM t_accident WHERE is_deleted = 0 AND happen_time >= ? GROUP BY ym ORDER BY ym;后端把结果转成List<Map<String,Object>>,用 JSON 序列化后写到页面,前端echarts.init()后直接setOption。这里要注意两个坑:一是Jackson 或 Gson 序列化java.util.Date默认会变成时间戳,前端显示成一串数字,要么在实体上加@JsonFormat,要么在 SQL 里就用DATE_FORMAT转成字符串,我选后者,省事。二是空数据,某个类型一条记录都没有的时候,饼图会显示成空白,要么在后端补零,要么前端判断空数组时显示"暂无数据"占位,后者更省事也更友好。
图表配色我建议别用默认色板,#5470C6这个默认蓝在投影上偏淡,换成饱和度稍高的深色调,答辩现场的效果差距肉眼可见。
4. 实操:把源码在本机跑起来的完整路径
这一段是全篇最"抄作业"的部分。我按顺序把从拿到源码到页面能点开的全过程拆成四步,每一步都写上常见失败点和判断依据。不管你是自己写的还是同学给的,照着走一遍基本都能跑起来。
4.1 环境清单与版本对齐
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 不要用 17 及以上,老项目的 JSP 编译与反射写法可能不兼容 |
| Tomcat | 8.5 或 9.0 | 关键:9 及以下用javax.servlet,10 开始换成jakarta.servlet |
| MySQL | 5.7 或 8.0 | 8.0 必须配serverTimezone参数 |
| IDE | 任意支持 Web 项目的即可 | 重点是能配 Tomcat 运行环境 |
| 浏览器 | Chromium 内核 | 老 JSP 页面在部分新特性上可能有兼容问题 |
版本对齐这件事我强调三遍都不过分,因为它占了部署失败原因的六成以上。最典型的组合错误是:MySQL 装了 8.0,驱动 jar 却用着mysql-connector-java-5.1.x,启动就报Unknown system variable 'query_cache_size'或者连接超时;另一个典型是 Tomcat 10 配javax.servlet的代码,编译直接报"程序包 javax.servlet 不存在"。判断方法很简单:看web.xml里web-app标签的version属性,4.0 对应 Tomcat 9,5.0/6.0 对应 Tomcat 10 及以上。发现不匹配,换 Tomcat 比改代码快得多。
4.2 数据库导入与连接配置
先建库,字符集一定要显式指定:
CREATE DATABASE traffic_archive DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;用utf8mb4而不是utf8,原因是后者在 MySQL 里实际是 3 字节的utf8mb3,存不下部分中文扩展字符和表情符号,当事人姓名或备注里一旦出现就报错。这个细节写进论文的数据库设计章节,是实打实的技术点。
然后导入脚本,注意脚本里如果自带CREATE DATABASE语句,可能会把库名建错,导入前先看一眼前两行。导入完成用SHOW TABLES确认六张主表都在。
连接配置一般写在db.properties或者DBUtil常量里,MySQL 8 的完整 URL 长这样:
jdbc.url=jdbc:mysql://127.0.0.1:3306/traffic_archive?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.user=root jdbc.password=你的密码四个参数缺一不可,我逐个说原因:useUnicode和characterEncoding解决中文乱码;serverTimezone不配会抛The server time zone value is unrecognized;useSSL=false关掉加密连接,本地开发不需要,开着反而可能报证书错误;allowPublicKeyRetrieval=true是 MySQL 8 的caching_sha2_password插件在非 SSL 下认证所必需的。这四个我一次性写全,能省掉后面百分之八十的连接类报错。
4.3 部署与首次访问验证
部署有三种方式,按省事程度排序。第一种,IDE 里配好 Tomcat 后直接 Run,项目会自动部署,调试最方便。第二种,把项目打成war包扔进webapps,启动 Tomcat 后访问http://localhost:8080/项目名/。第三种,在server.xml里配Context指向项目目录,适合项目放在其他盘的情况。
首次访问按这个顺序验证,出错能快速定位到环节:
- 访问根路径,看到登录页 → 说明静态资源和 JSP 编译正常。
- 用初始账号登录 → 说明数据库连接、用户查询、Session 写入正常。
- 进案件列表,看到分页 → 说明查询、分页、列表渲染正常。
- 新建一条测试案件 → 说明事务写入正常。
- 上传一个附件再删掉 → 说明文件路径配置正确。
这五步任何一步卡住,问题范围立刻缩小到一个模块。这套"逐层验证"的方法我用了好几年,比漫无目的地看日志效率高得多。
注意:登录页能打开但登录一直失败,八成是密码哈希对不上。最省事的排查方式是直接在数据库里把某个账号的密码字段手工改成一个你已知的 MD5 值,然后确认能登进去——就能判断是"哈希算法实现有误"还是"比对逻辑有误"。这个土办法救我过好几次。
4.4 二次开发与答辩演示前的收尾
如果你打算在这个基础上做自己的版本,我建议动三处,投入产出比最高。
第一处,加统一的操作日志切面。不用 AOP,在每个 Service 的关键方法末尾调一句logService.record(userId, action, target)就够。日志列表页做出来之后,"系统具备操作审计能力"这句话就有了实物支撑。
第二处,把导入导出做出来。案件列表的 Excel 导出,用Apache POI写三十行代码就能实现,答辩现场点一下导出、打开文件、看到真实数据,这个演示效果比任何 PPT 都强。导出时注意设置response.setContentType("application/vnd.ms-excel")和Content-Disposition,文件名要做 URL 编码,否则中文文件名在部分浏览器里会变成乱码。
第三处,准备演示数据。空系统演示是最致命的。我一般准备 30 条左右的案件数据、覆盖四种状态、时间跨度半年、类型分布不均,这样统计图才有形状,分页才有多页可翻。数据用 SQL 批量插入生成,不要手工点,那样太浪费时间。
5. 常见问题排查速查表与避坑心得
这一节是我这几年攒下来的"病理切片",按出现频率排序。遇到问题先查表,八成能直接命中。
5.1 部署阶段的高频报错
| 现象 | 最可能的原因 | 解决方向 |
|---|---|---|
| 404 找不到页面 | 访问路径少了项目名,或web.xml里url-pattern配错 | 用request.getContextPath()统一拼接,别手写路径 |
| 500 空指针 | Service 或 DAO 对象没实例化,直接调用 | 检查new的位置,或改用工厂/单例 |
| 中文全变问号 | 数据库、连接串、页面、过滤器四处字符集不一致 | 四处统一utf8mb4,先加CharacterEncodingFilter |
| 启动报驱动类找不到 | 驱动 jar 没放进WEB-INF/lib | 放进去,且确认与 MySQL 版本匹配 |
| 页面样式全丢 | 静态资源被登录 Filter 拦了 | 白名单里放行/css/、/js/、/img/ |
| 重启后上传的文件没了 | 文件存在项目目录被覆盖 | 改存项目外目录,路径写进配置 |
连接池报wait timeout | 连接没有正确归还 | 检查每个finally是否都close |
5.2 运行阶段的逻辑坑
改了数据但页面不变:九成是浏览器缓存了静态资源,或者查询命中了session里的旧数据。前者加版本号参数,后者在更新后清掉相关缓存键。
分页最后一条重复出现:ORDER BY的字段有重复值,比如按happen_time排序时有多条同一时刻的记录,MySQL 每次返回的顺序可能不同。解决办法是在ORDER BY后面追加主键,ORDER BY happen_time DESC, id DESC,保证排序稳定。
统计数字和列表条数对不上:最常见的原因是列表查询加了is_deleted=0过滤,统计 SQL 忘了加。这种 bug 特别隐蔽,因为两处代码隔得很远。我的习惯是把"有效数据"的过滤条件抽成一个常量字符串,两处都引用它。
删除主表后从表变孤儿:数据库层面我用ON DELETE CASCADE处理物理级联,但业务上是软删除,所以还要在 Service 层同步把从表标记删除。两套机制并存看着冗余,实际上是互为兜底。
5.3 我踩过的两个印象最深的坑
第一个坑:事务里的日志写丢了。早期我把"写操作日志"放在 Service 的事务里,结果主业务回滚时日志也一起回滚,出了问题查不到任何痕迹。后来改成日志写入用独立的数据库连接,走autoCommit=true,与主事务完全隔离。这个改动很小,但对"审计"这件事的意义是本质的——审计记录不应该因为业务失败而消失。
第二个坑:getRealPath在生产环境返回 null。本地 IDE 里跑得好好的,打成 war 部署后上传全失败,最后定位到getServletContext().getRealPath("/upload")返回了null。原因是 war 包没有展开,路径拿不到。改成从配置文件读绝对路径之后就稳了。这件事让我彻底改掉了"用getRealPath存业务文件"的习惯——它只适合拿临时目录,不适合存需要持久化的东西。
提示:排查问题时优先看
logs目录下的catalina.out或者项目自身的日志文件,不要只看浏览器报错。浏览器能看到的永远是最后一层异常,真正的根因往往在第一次抛出的那句堆栈里,往上翻三层基本就找到了。
6. 毕业文档与答辩:让系统经得起追问
系统做完只是完成了一半,另一半是把它讲清楚。我见过功能做得相当完整的同学,论文章节写得像使用说明书,答辩时被问"你这个数据库设计的第三范式体现在哪"就愣住了。这一节讲文档怎么写、答辩怎么准备,都是实战里总结出来的。
6.1 论文结构与代码实现的对应
论文最忌讳"系统实现"和"系统设计"两张皮,前面写的架构图和后面贴的代码完全不是一回事。我给的建议是:先定代码,再写文档,让文档去描述你已经写出来的东西,而不是先编一套设计再去凑实现。
具体到章节对应关系:
| 论文章节 | 对应代码/产出 |
|---|---|
| 需求分析 | 用例图、四类角色权限表、业务流程状态图 |
| 系统设计 | 包结构截图、分层调用关系、六张表的 E-R 图 |
| 数据库设计 | 建表 SQL、字段说明表、索引设计说明 |
| 详细实现 | 每个核心模块一段关键代码 + 运行截图 |
| 系统测试 | 功能测试用例表 + 异常场景测试记录 |
E-R 图一定要自己画,不要用工具一键生成。一键生成的图往往是"数据库表关系图",只有外键连线,没有实体属性和联系类型,和老师要求的 E-R 图不是一个东西。手工画一遍,考试被问到实体之间的联系类型(一对一还是一对多)才能张口就来。
测试章节也不要糊弄。我建议至少列 20 条测试用例,其中必须有 5 条以上是异常场景:空表单提交、超长输入、非法文件类型、越权访问、重复编号。这五条体现的是"你想过系统会怎么被用坏",含金量远超二十条正常流程测试。
6.2 答辩老师最爱问的几类问题
我把这些年听过和见过的提问归成四类,每一类都给出回答思路。
第一类,问架构链路。"从浏览器输入网址到页面显示,中间经过了哪些环节?"这题的答案要能一条线讲完:DNS 和端口 → Tomcat 的 Connector → 匹配web.xml的url-pattern→ Filter 链 → Servlet 的doGet→ Service → DAO → 数据库 → 结果集映射 →request.setAttribute→forward到 JSP → JSP 编译成 Servlet 执行 → 输出 HTML。能顺畅说完这一串,基本就通过了。
第二类,问设计取舍。"为什么用 JSP 不用前后端分离?""为什么密码用 MD5 不用别的?"这类问题没有标准答案,关键是有没有理由。我的回答框架是先承认局限,再说当时的约束,最后说改进方向。比如密码那题:MD5 加盐在当时的教学环境里实现简单、不引入额外依赖,能满足"不存明文"的底线要求,同时在论文里也明确写了生产环境应换用更抗暴力破解的算法。
第三类,问数据一致性。"两个人同时改同一条案件,会怎样?"这是钩子题。如实回答当时的实现在更新时用update_time做乐观锁校验,版本不一致就拒绝并提示刷新。如果你没做,就说清楚现状和改进方案,千万别硬编。
第四类,现场操作验证。让你当场新增一条数据、查出来、删掉。这种时候最怕的是环境出问题,所以演示前一定把项目跑一遍完整流程,浏览器缓存清掉,数据库连上,账号密码写在小纸条上。我见过太多人栽在"现场连不上数据库"这种事上。
个人经验是,答辩时主动说出系统的三个不足,比被动被挑出来强得多。比如"档案借阅目前只做了登记和归还,没有做逾期自动提醒""统计模块的查询没有做缓存,数据量大时会慢""文件没有做病毒扫描"。主动暴露问题,体现的是你对自己作品的掌控力。
最后分享一个我在整个项目里体会最深的点:档案管理这类系统的价值不在功能多花哨,而在数据靠不靠得住。一个案件从登记到归档,中间经过三个人、五个操作,每一次状态变化都有记录,每一次修改都能追溯,这种"可信"本身就是最难做也最值钱的部分。我在做这套系统的过程中,前两周一直在纠结要不要上框架,最后让我下定决心的其实是一个很土的理由——我想让每一个请求走到哪里都能说得清楚。这个理由到现在我也没觉得过时。