如果你正在准备JavaWeb方向的课程设计、毕业设计,或者想给小区、园区做一个“够用不花哨”的内部管理系统,那么“智能小区物业管理系统”这个题目几乎是绕不开的经典。我最近刚好完整做了一套基于JavaWeb + MySQL的JSP+Servlet+Bootstrap物业管理系统,从数据库设计到部署演示完整跑通,技术栈是java + jsp + servlet + bootstrap + javascript + mysql,这里把整个开发过程、关键代码和踩坑记录整理出来,希望能帮你少走几周弯路。
这篇文章适合三类人:第一类是正在做JavaWeb课程设计的学生,需要一套能答辩、能演示的完整项目;第二类是刚学完Servlet/JSP基础、想通过一个完整项目把知识串起来的新手;第三类是打算给小型物业团队做内部管理工具、又不想引入SpringBoot全套重框架的开发者。我会把技术选型逻辑、数据库设计思路、核心功能实现细节、以及我在实际开发中遇到的坑和解决办法,都摊开来讲。
1. 这套“老技术栈”为什么还能撑起一个物业系统
先说一个很多人会问的问题:现在新项目都用SpringBoot微服务,为什么还要用JSP+Servlet这种“复古组合”?我的答案是:项目场景决定技术选型,不是所有系统都需要微服务。一个服务几百户的小区物业系统,核心诉求是业主信息管理、物业费账单、报修工单、车位登记、公告发布这五件事,并发量不超过几十人,用SpringBoot + MyBatis Plus + Vue全家桶属于杀鸡用牛刀,而且对新手极不友好——你还没弄懂HTTP请求是怎么流转的,先被一堆注解和自动装配淹没了。
JSP+Servlet这套组合有一个不可替代的价值:它把Web开发最底层的“请求—处理—响应”链路完整暴露在你面前。浏览器发来一个POST请求,Servlet的doPost方法被调用,你手动设置编码、解析参数、调用DAO层操作数据库、再把数据set到request域里、forward到JSP页面渲染。每一步都没有魔法,跑完一遍,你对JavaWeb的底层逻辑就有了肌肉记忆。以后再学SpringMVC,你会发现它只是把这一套流程替你自动化了。
从系统本身的定位看,这套技术栈也完全够用:
- Bootstrap负责UI:栅格系统、表格样式、模态框、徽章,直接套用就能得到一个像模像样的管理后台,不需要写CSS。
- JavaScript负责交互:表单校验、确认弹窗、日期选择、动态加载下拉框,用原生JS加jQuery就能搞定。
- Servlet负责业务控制:参数接收、业务调用、页面跳转,逻辑清晰,也就是MVC里的Controller层。
- JSP负责视图渲染:用
request.getAttribute()拿数据、<c:forEach>或脚本片段循环表格行,天然适合这种管理页面。 - MySQL负责数据存储:开源、免费、资料多,一张表对应一个业务实体,设计上直来直去。
还有一个现实原因:课设和毕设的评审老师看重的往往不是技术多新,而是“这个系统能不能完整跑起来、功能周不周全、数据结构合不合理”。JSP+Servlet的项目结构一眼可见,讲答辩时你能清清楚楚说清楚每一层干什么,比背SpringBoot的自动配置逻辑容易太多了。
1.1 先明确系统的真实业务边界
做管理系统最容易犯的毛病是功能膨胀。看几个相关系统的参考页面,就觉得啥都要有:大屏监控、智能门禁对接、在线缴费、车辆识别……最后把自己累死。我做的这套系统,业务边界控制在三条核心线上:
- 人:管理员维护业主档案,业主可以登录查看自己的信息和账单。
- 钱:物业费账单生成、缴费状态管理、欠费统计。
- 事:业主提交报修工单,管理员处理工单、回复处理结果。
在这个基础上加了车位的登记管理、以及公告通知发布,一共六个核心模块。这个规模对一个人、一个学期、一门课设来说是正好的——功能有主有次,数据库表之间有关联,演示起来有完整业务闭环。
1.2 为什么不用SpringBoot:一个反直觉的结论
我见过太多人一上来就选SpringBoot做课设,结果卡在环境配置上出不来——Maven依赖下载失败、版本冲突、启动报红,最后连“Hello World”都没跑通。反倒是先老老实实用Servlet写一个项目,把请求、转发、重定向、Session、Filter这些概念搞明白,再去碰SpringBoot,水到渠成。
我做这个项目用的环境版本也一并给你参考:
- JDK 1.8(Servlet/JSP教材和Tomcat对它支持最稳)
- IDEA 2023版本(也可以用自己的编辑器,只要能配置Tomcat)
- Tomcat 9(对应Servlet 4.0,JSP页面标准语法全支持)
- MySQL 5.7 或 8.0(我用的是8.0,但驱动配置和5.7略有差异,第5节讲)
- 不用Maven,手动放jar包(原因也放到第5节讲,这里先卖个关子)
2. 业务功能拆解与数据库表设计:先想清楚8张表再动手写代码
我吃过一个亏:第一次做管理系统时没规划数据库,写代码写到一半发现缺字段,回头加列、改DAO、改页面,牵一发动全身。所以这次我花了整整两个晚上先做数据库设计,把业务实体梳理清楚。实际上,数据库表设计就是这个系统的“地基”,表与表之间的关系理顺了,后面Servlet写起来会非常顺。
2.1 权限模型:管理员与业主双角色
整套系统先分两大类用户,这个权限模型贯穿所有功能:
- 管理员(admin):在后台管理所有数据——业主档案、房屋、缴费记录、报修工单、公告。
- 业主(owner):登录后只能看自己的信息、自己的账单、提交报修、查看报修进度。
这个模型在数据库里有两种实现方式。一种是一张用户表加一个role字段;另一种是管理员和管理员分开两张表。我选了后者,原因是这个系统里管理员和业主的字段差异比较大——管理员只需要账号密码,业主需要姓名、手机号、身份证号、关联房屋,放一张表会有大量空字段,设计上不干净。
对应的表清单如下:
| 表名 | 用途 | 核心字段 |
|---|---|---|
| admin | 管理员账号 | id, username, password |
| owner | 业主档案 | id, name, phone, id_card, house_id, status |
| house | 房屋信息 | id, building_no, unit_no, room_no, area, owner_id |
| fee | 物业费账单 | id, owner_id, house_id, fee_month, amount, status |
| repair | 报修工单 | id, owner_id, content, status, create_time, handle_time |
| parking | 车位登记 | id, owner_id, park_no, type, status |
| notice | 公告通知 | id, title, content, create_time |
| visitor | 访客登记 | id, owner_id, visitor_name, phone, visit_time(记录小区访客进出) |
2.2 重点表结构:房屋、业主、缴费、报修这四张核心表
house 表代表物理存在的房屋,是物业管理的根本对象。物业费、车位、报修最终都要挂到某套房子上。这里有一个设计细节:业主和房屋的关系我用了两种处理方式,house表里存owner_id表示“当前户主”,owner表里不存房屋信息。这样更新户主时只需要改house表的owner_id一个字段。
owner 表是业主档案,字段注意这几个:id_card身份证号要做唯一约束,因为一个身份证对应一个业主;phone是用来催缴物业费的联系方式;status字段区分“常住/空置”,空置房屋的物业费经常有收缴纠纷,这个字段后面统计报表会用到。
fee 表是物业费账单,设计上有几个关键点:
fee_month表示账单月份,格式用'2025-06'这种字符串,不要用DATETIME。为什么?因为账单就是按月统计的,用字符串能直接GROUP BY fee_month做月度汇总。amount用DECIMAL(10, 2),不用FLOAT——涉及钱的字段绝对不能用浮点类型,二进制无法精确表示0.1,早期用浮点算钱会出诡异的小数误差。status用TINYINT:0未缴、1已缴,比直接存汉字更省空间,查询更快。
repair 表是报修工单,它的核心是状态流转。我的状态机设计为:业主提交(状态0,待处理)→ 管理员标记处理中(状态1)→ 管理员完成处理填写处理结果(状态2,已完成)。对应地存create_time(提交时间)、handle_time(处理时间),时间戳用于演示时展示“处理时效”,答辩时是加分细节。
2.3 为什么不搞外键约束、为什么字段要冗余
我建表时坚守一个原则:不建物理外键,只建立逻辑关联。比如owner.house_id和house.id,我不加FOREIGN KEY约束,而是在DAO层手动校验。这样做有两个好处:一是插入测试数据时不用死磕外键顺序,省掉一堆麻烦;二是后期如果要拆表、改字段,不用先删外键。反过来,数据库表之间的“谁属于谁”这种关系,通过字段名就能表达清楚,Java代码里维护逻辑一致性。
另外一个设计原则是适度冗余。比如fee表里我冗余了owner_name字段。正常范式设计下,查到账单后需要join owner表拿业主姓名,但列表页每次都join,SQL写起来啰嗦,演示性能也没必要。我的做法是:生成账单时就把业主名称同时冗余写进去。这样列表查询直接SELECT * FROM fee WHERE ...就拿到了展示需要的全部字段。代价是如果业主改名,冗余字段会过期——但物业场景里业主几乎不改名,这个取舍是划算的。
下面给出核心的建表SQL,直接可以在Navicat或命令行执行:
CREATE TABLE `owner` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) UNIQUE NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '登录密码(MD5)', `name` VARCHAR(50) NOT NULL COMMENT '真实姓名', `phone` VARCHAR(20) COMMENT '联系电话', `id_card` VARCHAR(18) UNIQUE COMMENT '身份证号', `house_id` INT COMMENT '关联房屋表', `status` TINYINT DEFAULT 1 COMMENT '1-常住 0-空置' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `house` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `building_no` VARCHAR(10) NOT NULL COMMENT '楼栋号,如A3', `unit_no` VARCHAR(10) COMMENT '单元号', `room_no` VARCHAR(10) NOT NULL COMMENT '房号', `area` DECIMAL(8, 2) COMMENT '建筑面积(平米)', `owner_id` INT COMMENT '当前户主ID,空表示未售出' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `fee` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `owner_id` INT NOT NULL COMMENT '业主ID', `owner_name` VARCHAR(50) COMMENT '冗余业主姓名', `house_id` INT COMMENT '关联房屋', `fee_month` VARCHAR(10) NOT NULL COMMENT '账单月份,如2025-06', `amount` DECIMAL(10, 2) NOT NULL COMMENT '应缴金额', `status` TINYINT DEFAULT 0 COMMENT '0-未缴 1-已缴', `pay_time` DATETIME COMMENT '缴费时间,未缴时为空' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意我把owner.house_id和house.owner_id都保留了,这和“不建外键”不冲突,逻辑上的关联由代码保证。同时建表统一用utf8mb4——如果用了老的utf8,后面存业主姓名里的生僻字、表情符号会报“Incorrect string value”错误,这是很常见的坑。
3. 项目骨架搭建与请求分发机制:Servlet也要“偷懒”
数据库设计好了,接下来是整个项目最关键的骨架搭建。很多新手做JSP+Servlet项目失败,不是不会写代码,而是项目结构一团乱、Servlet一个功能写一个类导致文件爆炸、数据库连接散落各处无法维护。我这次设计了一套可以反复使用的骨架。
3.1 目录结构与依赖导入:手动放jar包还是用Maven
先说依赖管理。这可能是整套项目最影响起步速度的决策。
如果你时间比较紧、或者只是要交一个能跑的课设,我建议不用Maven,直接把需要的jar包扔到WEB-INF/lib目录下。这种方式看着原始,但有三个实实在在的好处:
- 不依赖网络,不会出现“Maven卡死”“依赖下载失败”这种环境问题。
- IDEA部署Web项目时,lib目录下的jar包会自动打包进WAR,不用配置。
- 结构透明,Servlet、JSP、MySQL驱动的jar包就摆在那里,看得见摸得着。
需要准备的jar包其实就一个核心的:mysql-connector-java-x.x.x.jar(我用的是8.0.33版本,对应MySQL8.0)。如果有需要,可以再加jstl-1.2.jar(用JSTL标签替代JSP脚本片段)。Bootstrap和JavaScript都是静态文件,直接放到webapp/static下,不需要构建工具处理。
目录结构我建议这样组织:
src/main/java ├── com.demo.pms.entity // 实体类,一张表对应一个类 ├── com.demo.pms.dao // 数据访问层,纯JDBC操作 ├── com.demo.pms.service // 业务层,处理业务逻辑 ├── com.demo.pms.web // Servlet控制层 └── com.demo.pms.util // 工具类:DBUtil、MD5Util src/main/webapp ├── static │ ├── css │ ├── js │ ├── bootstrap ├── admin // 管理员相关JSP ├── owner // 业主相关JSP └── index.jsp // 登录页3.2 三层架构:entity、dao、service、web各管什么
JSP+Servlet项目最怕的就是“Servlet里写JDBC代码”——业务逻辑、数据库操作、请求处理全都堆在一个类里,几百行根本没法维护。我采用的是标准的Web三层架构:
- entity层(实体类):数据库表的对象映射,字段和表结构一一对应,就是那一堆
private String name; public String getName()之类的getter/setter。 - dao层(数据访问层):写JDBC的地方。面向接口编程,比如
OwnerDao接口定义insert、update、delete、findById、search等方法,OwnerDaoImpl实现它。每张表一个dao。 - service层(业务层):处理业务规则。比如提交报修工单时,需要同时校验业主状态、生成编号、记录时间,这些“多步骤操作”写在这里。
- web层(Servlet控制器):只做三件事——取参数、调service、决定跳转到哪个JSP。
这样分层最大的好处是每一层都能独立测试。我写完DAO层可以单独写个main方法测试SQL查询,不需要启动Tomcat;Servlet写好后业务逻辑不用大改,因为变动只发生在页面跳转。
3.3 BaseServlet:用反射让Servlet数量减一半
如果你用的是IDE的New Servlet向导,它会给你每个Servlet生成doGet、doPost、doPut一堆方法,然后你在每个方法里用if/else判断要执行的操作。这样写2个模块还好,6个模块就有几十个方法,代码量爆炸。
我的做法是写一个通用的BaseServlet,利用Java反射实现“一个Servlets处理一个模块的所有请求”。核心代码长这样:
public abstract class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if (action == null || action.isEmpty()) { action = "list"; } try { // 反射调用:方法名 = action参数值 Method method = this.getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, request, response); } catch (NoSuchMethodException e) { throw new ServletException("找不到处理请求的方法: " + action, e); } catch (Exception e) { throw new ServletException("请求处理失败: " + action, e); } } }你不用super.service(),而是直接让每个子类继承。假设有OwnerServlet extends BaseServlet,那么访问ownerServlet?action=list时,反射会自动调用OwnerServlet类里的list(HttpServletRequest, HttpServletResponse)方法;访问ownerServlet?action=add时调用add方法。
这样每个模块只需要“1个Servlet类 + 多个处理方法”,而不是“1个Servlet类 + 每个操作一个Action类”。这个方法在后面分页、模糊查询里会体现出巨大优势。
3.4 DBUtil:数据库连接不能散落到处写
新手最常见的写法是在每个DAO方法里写Class.forName(...)、DriverManager.getConnection(...),然后每个方法里写五遍try-catch-finally关闭连接。我不建议这么干,而是要封装一个DBUtil工具类统一管理。
考虑到课设项目并发量低,我没有引入C3P0或Druid连接池(这些配置对新手反而容易出问题),而是用最简洁的单例DriverManager实现:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/property_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private DBUtil() {} public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps != null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }一个容易被忽略的细节:这里的URL后面必须加serverTimezone=Asia/Shanghai。MySQL8.0对时区要求非常严格,不加它连接时直接报Server returns invalid timezone,错误信息看一眼就慌。characterEncoding=utf8则保证JDBC传输中文不乱码。
4. 三大核心业务线的实现细节
骨架搭好之后,实际业务实现就是往里填肉。这一节我挑三个最典型的业务线展开,分别是业主信息管理(标准的CRUD加分页搜索)、物业费账单(状态流转)、报修工单(多角色操作)。这三条线能写透,其他模块比如车位、公告就是同一个模板。
4.1 业主信息管理:分页 + 模糊查询 + 删除保护
业主列表页几乎是所有管理系统的第一个功能,也是最能体现“基本功”的地方。为什么?因为列表页不只是SELECT * FROM owner然后循环输出,它要有搜索、分页,还要考虑搜索结果里“没有数据”时的提示。
分页的原理其实不复杂,就是SQL加LIMIT offset, size。我先算总记录数,再按每页10条计算总页数,前端传page和keyword参数。搜索条件用name LIKE匹配,注意要处理“关键词为空”的情况:
// OwnerDaoImpl 中的分页查询方法 public List<Owner> findPage(int page, int pageSize, String keyword) { String baseSql = "SELECT * FROM owner WHERE 1=1"; if (keyword != null && !keyword.isEmpty()) { baseSql += " AND name LIKE CONCAT('%', ?, '%')"; } baseSql += " LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(baseSql)) { int paramIndex = 1; if (keyword != null && !keyword.isEmpty()) { ps.setString(paramIndex++, "%" + keyword + "%"); } ps.setInt(paramIndex++, (page - 1) * pageSize); ps.setInt(paramIndex, pageSize); ResultSet rs = ps.executeQuery(); List<Owner> list = new ArrayList<>(); while (rs.next()) { list.add(mapToOwner(rs)); } return list; } catch (SQLException e) { throw new RuntimeException(e); } }这里有几个很实用的细节:
WHERE 1=1是拼接条件的技巧。后续所有条件都直接加" AND ...",不用判断“是不是第一个条件”,代码简洁且不会出错。- 一定要用
PreparedStatement拼接参数,不能直接字符串拼接。直接拼接等于把SQL注入漏洞敞着门,演示时老师如果测试输入' OR '1'='1直接把你数据全查出来,场面极度尴尬。 CONCAT('%', ?, '%')而不是LIKE '%?%',后者会把问号当字面量,查询结果永远为空。
删除操作有个业务保护逻辑:有缴费记录或报修工单的业主不能直接删除。我的做法是在删除前先查一下fee表和repair表,因为数据库里虽然有逻辑关联,但没有外键约束,删了业主导致账单变成“孤儿数据”会让系统出现脏关联。代码里加一步校验:
// OwnerServiceImpl public boolean deleteOwner(int id) { if (feeDao.countByOwnerId(id) > 0) { throw new BusinessException("该业主存在缴费记录,不能删除"); } return ownerDao.deleteById(id) > 0; }4.2 物业费账单:批量生成与状态流转
缴费管理是这个系统最核心的业务,也是答辩时最可能被追问细节的地方。物业费账单不能每次手动录入,而是按月批量生成。不用定时任务,而是提供一个“生成上月账单”的按钮——管理员点击后,系统遍历所有状态为“常住”的业主,按房屋面积和单价算出应缴金额,插入fee表。
计算逻辑在Service层实现:
public int generateMonthlyBill(String month, BigDecimal unitPrice) { List<House> houses = houseDao.findAllOccupied(); int count = 0; for (House house : houses) { Owner owner = ownerDao.findByHouseId(house.getId()); if (owner == null) continue; // 面积 x 单价 = 应缴金额 BigDecimal amount = house.getArea().multiply(unitPrice).setScale(2, RoundingMode.HALF_UP); Fee fee = new Fee(); fee.setOwnerId(owner.getId()); fee.setOwnerName(owner.getName()); fee.setHouseId(house.getId()); fee.setFeeMonth(month); fee.setAmount(amount); fee.setStatus(0); // 未缴 feeDao.insert(fee); count++; } return count; }注意unitPrice单价建议做成系统参数存数据库,而不是写死在代码里。不同小区、不同物业收费标准不一样,演示时展示“调整单价重新生成账单”的效果会显得业务思考很完整。
缴费状态流转是0未缴 → 1已缴。我设计了一个“确认收款”按钮,点击后把status置为1,同时记录pay_time = NOW()。JSP页面上用Bootstrap的标签组件展示状态:
<span class="badge ${fee.status == 0 ? 'bg-danger' : 'bg-success'}"> ${fee.status == 0 ? '未缴' : '已缴'} </span>这里能体现Bootstrap的实用之处——拿不同的徽章颜色区分状态,比纯文字直观很多。
还有一个小设计:缴费列表页我加了“统计栏”,在页面顶部展示三个数字:本月应收、已收、未收。统计SQL很简单:
SELECT COUNT(*) AS total_count, SUM(CASE WHEN status = 1 THEN amount ELSE 0 END) AS paid_amount, SUM(CASE WHEN status = 0 THEN amount ELSE 0 END) AS unpaid_amount FROM fee WHERE fee_month = ?;这个统计栏做出来后,演示时老师一眼就能抓住系统的业务价值。
4.3 报修工单:业主提交 + 管理员处理的状态闭环
报修工单是这套系统里“角色协作”最明显的模块。它的业务规则是:
- 业主登录后,在“我的报修”里填写报修内容并提交,系统记录
create_time。 - 管理员在后台看到新工单,点击“开始处理”,状态从0变为1,记录开始处理时间。
- 管理员处理完成,填写处理结果,状态变为2,记录完成时间。
数据库设计里的status和handle_time字段就能派上用场。核心的处理逻辑在RepairService中:
public void updateStatus(int repairId, int newStatus, String handleResult) { Repair repair = repairDao.findById(repairId); if (repair == null) { throw new BusinessException("工单不存在"); } repair.setStatus(newStatus); if (newStatus == 1) { repair.setHandleTime(new Date()); // 开始处理时间 } if (newStatus == 2) { repair.setResult(handleResult); // 处理结果 } repairDao.update(repair); }状态改变时做一些额外操作,是Service层该做的事。比如状态为1时记录时间,状态为2时保存处理结果。这样分离,DAO层只需要实现“更新这个对象”,业务规则全部凝聚在Service层。
业主端的处理逻辑不同:业主登录后查询工单,只能看到owner_id = 当前登录业主ID的数据。这个过滤条件在DAO层实现:
public List<Repair> findListByOwnerId(int ownerId) { String sql = "SELECT * FROM repair WHERE owner_id = ? ORDER BY create_time DESC"; // ...执行查询 }注意加ORDER BY create_time DESC,把最新提交的工单排在前面,比默认无序体验好非常多。
5. 前后端联调中的五个高频坑:从乱码到静态资源404
这部分单独开一节,因为整个项目开发过程中,我在代码功能实现上花的时间,远没有在排查这些“莫名其妙的问题”上花的时间多。这几个坑几乎是JSP+Servlet项目里人人都得踩一遍,提前告诉你,至少能省下两三天。
5.1 乱码问题:三个地方必须同时设置
中文乱码是JavaWeb排名第一的经典问题,它不是一个原因,而是三个原因叠加:
- JSP页面编码:每个JSP文件头部必须有
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,加上HTML的<meta charset="UTF-8">。 - Servlet接收请求参数:POST请求在读取参数前必须设置
request.setCharacterEncoding("UTF-8"),否则Tomcat默认用ISO-8859-1解码,中文全变问号。GET请求的参数编码要看Tomcat配置,最稳妥的是在Tomcat的server.xml里给Connector加URIEncoding="UTF-8"。 - 响应输出:
response.setContentType("text/html;charset=UTF-8")。
我的处理方式是写一个EncodingFilter,加在登录和业务Servlet之前统一处理:
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }这里还需要检查Tomcat的server.xml,确保Connector标签里配置了URI编码。因为GET请求的查询参数是放在URL上的,Tomcat解码URL时用什么字符集由这个配置决定。
5.2 MySQL8与MySQL5.7:驱动类名和URL差一行,整整卡了半天
如果你用的是MySQL8.0,注意以下配置和网上大部分讲MySQL5.7的老教程不同:
- 驱动类名:MySQL8要用
com.mysql.cj.jdbc.Driver,MySQL5.7及以前是com.mysql.jdbc.Driver。 - URL要加时区参数:
jdbc:mysql://localhost:3306/数据库名?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai。
刚换8.0的人最容易踩的错是继续用旧驱动类名,然后看到ClassNotFoundException: com.mysql.jdbc.Driver,第一反应是jar包没放对,其实是类名换了。另外一个就是serverTimezone,MySQL8默认时区问题不设置会直接抛异常。
5.3 静态资源404:Bootstrap的css、js加载不出来
页面写好了,打开一看“裸奔”状态——没有样式,表格丑得没法看。打开F12控制台,一堆Bootstrap的css、js请求404。这个问题的根源是路径写错了。
在JSP页面引用静态资源时,如果用相对路径比如static/css/bootstrap.min.css,当当前URL路径不同(有时候在/admin/owner/list,有时候在/owner/login),相对路径拼出来的结果就错了。正确做法是:
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/bootstrap/css/bootstrap.min.css">${pageContext.request.contextPath}会动态输出项目的上下文路径(比如/property),确保无论哪个页面、哪层路径,最终请求的URL都能正确拼到webapp/static目录。这是JSP页面引用静态资源的铁律——所有css、js、图片的路径都要以它开头。
类似的坑还有:页面跳转用相对路径owner/list时,如果当前URL目录层级不同,Form或超链接会跳到错误路径,所以Servlet的注解映射路径建议都用/开头的“应用内绝对路径”。
5.4 JSP里可以写Java代码,但别真的乱写
JSP是一个可以直接嵌入Java代码的页面技术,但这不是让你把业务逻辑全写在<% %>里。我见过有人把JDBC代码直接写在JSP页面里,页面变成几百行的“意大利面”,前端和后端高度耦合,改一个字段动全身。
我建议的原则是:
- JSP里只做显示逻辑:循环表格、if判断状态显示不同颜色的徽章。
- 尽量用EL表达式
${参数}取值,用JSTL标签<c:forEach>循环,不要用<% for (int i=0; ...) %>这种脚本片段。 - 关于数据的计算、过滤、转换,全都在Servlet或Service层完成,JSP拿到的已经是排好序、过滤好的数据。
一个实际的例子:缴费列表页我想展示“逾期未缴”的标记,应该在Service层计算出boolean overDue字段放进实体或Map里,而不是在JSP里判断月份比当前早再输出红色字。这样做的好处是页面只关心“显示什么”,不关心“怎么算出来”。
5.5 404/405错误的排查思路:先分清是哪种404
联调过程中最容易遇到的HTTP错误是404和405,我总结一下排查顺序:
404页面找不到:先看两个地方。第一,Servlet的
@WebServlet("/ownerServlet")注解里的路径,和JSP表单里action="/ownerServlet?action=add"的路径是否完全一致(注意区分大小写);第二,Servlet是否编译成功并被Tomcat加载,看IDEA的部署面板里有没有把这个Servlet加入。405 Method Not Allowed:通常是Servlet里只重写了doGet但前端表单用了method="post",或者反过来。解决方式是每个Servlet统一重写doGet和doPost,并让两者都调用同一个处理方法。我通常只在Service方法里写核心逻辑,doGet和doPost都转发到
service(request, response):
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { service(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { service(req, resp); }记得如果你继承了BaseServlet并在里面重写了service,子类里不需要再重复这一点——但要注意别写super.service(),否则会抛405,因为HttpServlet的service会检查请求方法。
6. 演示与验收前最后要做的三件事
功能全部跑通之后,离“能拿去演示、能交作业、能进答辩”还差最后三件事。我做完前三遍的时候自认为系统已经完美了,结果在自测时发现了非常多只有“外人视角”才看得到的问题。
6.1 准备一套有故事性的初始化数据
空数据库演示是最尴尬的——点开缴费列表什么都没有,点开业主管理只有一行,老师会觉得系统没有内容。我准备了一套约50条记录的初始化数据,数据之间有关联关系、有状态差异:
- 10位业主,分布在3栋楼的不同单元。
- 每位业主都有1到3个月的物业费账单,其中一部分已缴、一部分未缴。
- 报修工单里有“待处理”“处理中”“已完成”三种状态各几条,完成时间分布合理。
- 公告3条,时间从一周前到最新。
- 车位登记包含已售和空闲两种状态。
这些数据我用一个init.sql脚本统一管理,在Navicat里执行一遍就能复现整个演示环境。注意有一点:初始化数据里管理员密码不要用明文。我用的密码是经过MD5处理的,比如明文admin123存进数据库的是它的MD5值。登录时把用户输入的密码也MD5后对比,这样数据库泄露也不会直接暴露密码。对应地写一个工具类:
public class Md5Util { public static String md5(String source) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] bytes = md.digest(source.getBytes("UTF-8")); // 转成16进制字符串返回 } catch (Exception e) { throw new RuntimeException("MD5加密失败", e); } } }6.2 加一个登录拦截Filter,别把后门露给别人
很多课设项目只做了登录页,但没做真正的“登录保护”——不登录直接访问URL,也能看到管理页面。这在答辩时被点出来会非常扣分。我的做法是加一个AuthFilter拦截所有业务请求,检查Session里有没有登录标识:
@WebFilter("/admin/*") 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; Object loginUser = req.getSession().getAttribute("loginUser"); if (loginUser == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }提示:静态资源(css、js、图片)不要被拦截,否则连登录页都加载不出样式。所以Filter的urlPatterns绑定到/admin/*、/owner/*这类业务路径,而不是/*。同时登录Servlet和login.jsp本身要放行,否则会出现“登录页也进不去”的死锁。
6.3 打包WAR与部署环境检查
演示前最后一步,是把项目打成WAR包丢到独立的Tomcat里跑一遍,确认不是只在IDEA里能跑。打包方式有两种:
- IDEA里:File → Project Structure → Artifacts → 加一个 Web Application: Exploded 或 Archive,然后 Build → Build Artifacts。
- 或者直接把
webapp目录下的内容复制到Tomcat的webapps/ROOT下面。
用独立Tomcat部署时注意三个检查点:
- JDK版本:Tomcat9要求JDK8及以上,本机装了JDK17跑旧项目可能出现class版本不兼容。
- MySQL服务:数据库要处于启动状态,且DBUtil里配置的账号密码要和部署环境一致。我吃过一次亏:本机MySQL密码是
123456,部署到另一台机器密码不同,登录时报数据库连接失败。 - 端口访问:默认是8080端口,如果被占用,修改Tomcat的server.xml里的Connector端口并重启,注意不要和其他服务冲突。
部署完用http://服务器IP:8080/项目名/login.jsp访问一遍完整流程:登录、查业主、生成账单、处理工单、发布公告,每个操作都点击确认没有异常了,才算真正完成。
实际上做完这一整套,回头看你会发现:这个项目最大的收获不是那几个功能页面,而是你对“浏览器发一个请求之后,服务器里到底发生了什么”这件事有了彻底的理解。后面学什么SpringBoot、MyBatis都能对应上——Tomcat还在那里,只是Servlet被DispatcherServlet替代了,JSP被Thymeleaf替代了,JDBC被MyBatis封装了,但请求和响应的本质一点没变。