ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SSM+JSP+MySQL乡镇自来水收费系统:从数据库建模到月末批量出账

2026/9/13 19:27:16 拓冰建站 浏览量
SSM+JSP+MySQL乡镇自来水收费系统:从数据库建模到月末批量出账 简介这份乡镇自来水收费系统源码是一套面向Java毕业设计或课程设计的完整项目基于SSM框架与JSP前端技术搭配MySQL 5.7数据库和Tomcat 7运行环境适合需要快速搭建管理类Web系统、理解SpringSpringMVCMyBatis整合流程的学生开发者。系统围绕水费收缴业务划分为管理员与用户两类角色管理员可维护水表档案、审核更换水表请求、执行抄表与缴费管理并处理公告、留言及用户信息用户则可在线提交换表申请、自助缴费、查看公告与留言、修改个人资料功能覆盖面较完整。包内共1942个文件以JavaScript、CSS、JSP等前端资源Java业务类与XML映射文件以及SQL数据库脚本、Eclipse/Idea工程配置为主另有说明文档和LW论文文档总计42.56MB便于直接导入部署或对照学习。目前已有60人学习下载适合作为课设答辩或毕设二次开发的基础参考尤其是希望获得完整前后端代码和数据库设计思路的读者。1. 乡镇自来水收费系统这套 SSMJSPMySQL 源码解决的不只是毕业设计乡镇自来水厂的收费场景和城市水务公司完全不同用户规模从几百户到一万户出头抄表员按片区手工抄表月底对着 Excel 台账算钱错账漏账全靠人工兜底。一套 SSMJSPMySQL 的乡镇自来水收费系统核心是把「户号—水表—抄表记录—账单—缴费」这条链路用数据库固定下来让月末结算从两天手工核对压到一次批处理。三个技术词各管一段SSM 负责业务分层和事务控制JSP 承担录入与查询页面MySQL 存住账目并做月末批量出账。对做毕业设计的人来说这是业务真实、前后端都有工作量的选题对刚入行 Java 的人它又是一套练三层架构、动态 SQL、存储过程和 Tomcat 部署的最小完整系统。下面按数据库建模、SSM 主流程、MySQL 批量出账、部署验收四个环节展开。2. 数据库设计乡镇自来水收费系统的表、字段与约束怎么定数据模型决定这套系统能撑到多少户也决定答辩时被问「为什么这么建表」能不能答上来。乡镇收费系统的共性是用户、水表、抄表、账单拆成独立表而不是每户一行把读数和金额全塞进去。拆开的代价是查询多一次 join收益是换表、调价、补抄、退费这些真实操作都有地方落。2.1 用户、水表、抄表三张核心表一户多表如何建模t_user 存的是「谁该交钱」包括户号、户名、地址和用户类型t_meter 存的是「哪块表在计量」通过 user_id 挂回用户t_reading 存的是「每个账期读到了什么数」挂在 meter_id 而不是 user_id 上。三张表分开的关键理由是乡镇场景里一户多表很常见井水和自来水分开计量或者一宅两表。如果读数挂在用户下换表后历史数据会跟着旧表一起丢。抄表记录里同时存 this_reading 和 last_readinglast_reading 在写入时由最近一条记录反查得到。这种冗余用空间换查询便利计费时不需要再逐条比较相邻记录。整张表上最重要的约束是 (meter_id, period) 的唯一索引它保证同一块表同一个账期只能有一条有效记录是防重复抄表的第一道闸门。2.2 费率与账单表阶梯水价的版本化设计费率不要写死在 Java 常量里。t_tariff 用 period 表示生效账期tier_no 表示第几档配合 min_usage、max_usage 描述阶梯区间。调价时插入一条新 period 的记录历史账单按旧费率保留这比改代码重新部署稳得多。对最常见的两档阶梯费率表里每个账期就是两行数据。账单表 t_bill 上的 (user_id, period) 唯一索引承担防重复出账的职责。status 字段区分未缴、已缴、欠费三种状态不要用 paid_amount 是否为零去反推状态——零金额可能是减免单也可能是还没生成账单。滞纳金不建议直接改原账单金额单独记录或另建罚金字段方便对账。2.3 建表 SQL 与三个常见设计失误下面这组 DDL 是这套系统最常复用的部分字段按最小清单设计足够跑通抄表、计费、缴费和月度报表CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, user_no VARCHAR(20) NOT NULL COMMENT 户号对外可见, user_name VARCHAR(50) NOT NULL COMMENT 户名, address VARCHAR(200) COMMENT 用水地址, phone VARCHAR(20), user_type TINYINT NOT NULL DEFAULT 0 COMMENT 0居民 1商业, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在用 0停用, UNIQUE KEY uk_user_no (user_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用水户; CREATE TABLE t_meter ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL COMMENT 归属用户, meter_no VARCHAR(30) NOT NULL COMMENT 表编号, install_date DATE, init_reading DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 装表底度, current_reading DECIMAL(10,2) COMMENT 最近一次抄表读数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0停用, UNIQUE KEY uk_meter_no (meter_no), KEY idx_meter_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT水表; CREATE TABLE t_reading ( id INT PRIMARY KEY AUTO_INCREMENT, meter_id INT NOT NULL, period CHAR(6) NOT NULL COMMENT 账期如202406, this_reading DECIMAL(10,2) NOT NULL COMMENT 本次读数, last_reading DECIMAL(10,2) NOT NULL COMMENT 上次读数, usage_amount DECIMAL(10,2) NOT NULL COMMENT 本期用量本次-上次, read_date DATE, reader VARCHAR(20) COMMENT 抄表员, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待计费 1已计费, UNIQUE KEY uk_reading (meter_id, period), KEY idx_reading_period_status (period, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT抄表记录; CREATE TABLE t_tariff ( id INT PRIMARY KEY AUTO_INCREMENT, tariff_name VARCHAR(30) COMMENT 费率名称, user_type TINYINT COMMENT 0居民 1商业, period CHAR(6) NOT NULL COMMENT 生效账期, tier_no INT NOT NULL COMMENT 阶梯序号从1开始, min_usage DECIMAL(10,2) NOT NULL DEFAULT 0, max_usage DECIMAL(10,2) COMMENT NULL表示上不封顶, price DECIMAL(8,4) NOT NULL COMMENT 单价元/吨, KEY idx_tariff_period (period, user_type, tier_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT费率版本; CREATE TABLE t_bill ( id INT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(40) NOT NULL, user_id INT NOT NULL, period CHAR(6) NOT NULL, usage_amount DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL COMMENT 应收金额, paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未缴 1已缴 2欠费, due_date DATE, create_time DATETIME, UNIQUE KEY uk_bill (user_id, period), KEY idx_bill_status (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT水费账单;表之间的关系和关键字段可以浓缩成一张对照表答辩画 ER 图时直接照着画表挂靠关系唯一约束一句话职责t_user无user_no谁该交钱t_meteruser_id → t_usermeter_no哪块表在计量t_readingmeter_id → t_meter(meter_id, period)每个账期读到的数t_tariff无无按 period 取版本每吨多少钱、几档t_billuser_id → t_user(user_id, period)一笔应收账目这套建模最容易犯的三个失误t_reading 不加唯一索引重复提交或并发双击会直接产生两笔账后面对账全是坑金额字段用 FLOAT 或 DOUBLE几千条数据看不出问题累计上万条后小数位对不上必须用 DECIMAL(10,2)Java 侧用 BigDecimal 参与运算period 用 DATETIME 存每月 1 号查询反而要先 DATE_FORMAT 再比对直接用 CHAR(6) 存 202406排序、区间查询、分组都走字符串比较索引也能命中。提示以上建表语句在 MySQL 5.7 和 8.0 都能用。如果本地装的是 MySQL 8.0注意驱动类名和时区参数部署章节会专门讲。3. SSM 三层架构把抄表、计费、缴费串成可维护的主流程3.1 包结构分层与 Controller 只做参数收口SSM 项目最怕的是把 SQL 写在 JSP 里、把逻辑堆在 Controller 里。这套系统按下面这样分包com.water ├── controller // URL 入口只做参数绑定和结果封装 │ ├── UserController.java │ ├── ReadingController.java │ └── BillController.java ├── service // 业务规则事务边界开在这里 │ ├── ReadingService.java │ └── BillService.java ├── mapper // MyBatis 接口与 XML │ ├── ReadingMapper.java │ └── ReadingMapper.xml ├── pojo // 实体与表一一对应 ├── common // Result、PageBean、BizException └── config // Spring、SpringMVC 配置类Controller 的角色是参数收口不做任何业务判断。以抄表录入为例前端 JSP 表单提交 meterId、period、thisReading 三个参数Controller 把异常翻译成前端能展示的文案Controller RequestMapping(/reading) public class ReadingController { Autowired private ReadingService readingService; RequestMapping(/save) ResponseBody public Result save(RequestParam Integer meterId, RequestParam String period, RequestParam BigDecimal thisReading) { try { readingService.saveReading(meterId, period, thisReading); return Result.ok(抄表成功); } catch (DuplicateKeyException e) { // 唯一索引 (meter_id, period) 兜住重复提交 return Result.fail(该水表本账期已抄过请核对后再提交); } catch (BizException e) { return Result.fail(e.getMessage()); } } }需要说明的是RequestParam 会在参数缺失时直接抛异常前端 JSP 的表单字段名必须和这里一致。Result 是统一返回体包含 code、message、data 三个字段JSP 里用 Ajax 拿到 code 后决定弹提示还是刷新列表。测试时最常遇到的 400 错误基本都是表单字段名和这里对不上。3.2 抄表录入MyBatis 动态 SQL 与重复提交拦截抄表的核心逻辑在 Service 层先反查上次读数校验本次读数合法性再插入记录并更新水表当前读数。整个方法加 Transactional插入失败时水表读数不会留下半截数据Transactional(rollbackFor Exception.class) public void saveReading(Integer meterId, String period, BigDecimal thisReading) { Meter meter meterMapper.selectById(meterId); BigDecimal lastReading readingMapper.selectLastReading(meterId); // 校验本次读数小于上次底度一般是抄错或换表未登记 if (thisReading.compareTo(lastReading) 0) { throw new BizException(本次读数小于上次底度 lastReading); } readingMapper.insertReading(meterId, period, thisReading, lastReading); // 同步水表当前读数仅用于列表展示不作为计费依据 meterMapper.updateCurrentReading(meterId, thisReading); }selectLastReading 的实现是「按 meter_id 取最近一条记录」按 id 倒序 LIMIT 1。比用 MAX(period) 更稳妥因为补录的历史记录不会干扰当前底度的计算。对应的 Mapper XML 如下插入语句直接在本期与上期之间做减法得到本期用量避免在 Java 侧多传一个参数insert idinsertReading parameterTypemap INSERT INTO t_reading (meter_id, period, this_reading, last_reading, usage_amount, read_date, status) VALUES (#{meterId}, #{period}, #{thisReading}, #{lastReading}, #{thisReading} - #{lastReading}, NOW(), 0) /insert update idupdateReadingStatus parameterTypelist UPDATE t_reading SET status 1 WHERE id IN foreach collectionlist itemid open( separator, close) #{id} /foreach /update#{thisReading} - #{lastReading}预编译后是? - ?MySQL 会直接算出数值这是 MyBatis 的常见写法。updateReadingStatus 里的 用于批量回写抄表状态参数是账单生成后的抄表记录 id 集合。手写 SQL 时注意 foreach 的 collection 属性值接口方法参数要用 Param(list) 标注否则 MyBatis 找不到集合。接口方法签名与 XML 的对应关系可以用一张小表说明接口方法XML id说明insertReading(...)insertReading插入抄表记录selectLastReading(Integer meterId)selectLastReading取最近一次底度updateReadingStatus(ListInteger ids)updateReadingStatus批量回写为已计费3.3 阶梯计费在 Service 侧的分段计算水价最常见的两档或三档阶梯分段逻辑放在 Service 里比放在 SQL 里好维护。下面方法按 t_tariff 的累计上限计算假设 0 到 20 吨每吨 2.8 元、20 到 30 吨每吨 4.2 元、30 吨以上每吨 6.5 元tier_no 从 1 递增max_usage 存该档累计上限最后一档 max_usage 为 NULL/** * tariffs 按 tier_no 升序max_usage 为 NULL 的档作为最后一档不封顶 */ public BigDecimal calcAmount(BigDecimal usage, ListTariff tariffs) { BigDecimal amount BigDecimal.ZERO; BigDecimal prevMax BigDecimal.ZERO; // 上一档的累计上限 for (Tariff tier : tariffs) { if (usage.compareTo(prevMax) 0) { break; // 用量不超过上一档上限后续档不参与 } BigDecimal upper tier.getMaxUsage() null ? usage : tier.getMaxUsage(); BigDecimal inTier usage.min(upper).subtract(prevMax); amount amount.add(inTier.multiply(tier.getPrice())); prevMax upper; } return amount.setScale(2, RoundingMode.HALF_UP); }关键参数是 prevMax它累计了前面所有档的上限。以 25 吨为例第一档 upper20inTier20累计 56 元prevMax 变成 20第二档 usage 25 大于 prevMax 20upper30inTiermin(25,30)-205再加 21 元总计 77 元。如果费率行没按 tier_no 排序结果会错所以查询一定要 ORDER BY tier_no。提示金额计算全程用 BigDecimal 的 compareTo、subtract、multiply不要用 或直接加减。float 的精度问题在月度报表汇总时会直接暴露。4. 月末批量出账MySQL 存储过程与联合索引的实战用法4.1 为什么月末结算不循环调 Service一个月结一次账几千条抄表记录要生成对应账单。常见做法是在 Java 里 for 循环调 BillService 逐条 insert。这种写法在小乡镇能用但每一条 insert 都是一次网络往返和一次单独事务一万户的量级会明显变慢而且一旦中途失败回滚的是单条而不是整个账期。存储过程的优势在于校验、汇总、插入、回写状态放在同一个数据库事务里只发一次调用事务边界完整。劣势是调试困难、版本管理弱。所以职责要划清楚阶梯计费这类要排序、要计算的分段逻辑留在 Java 侧存储过程只做四件确定性高的事——检查该账期是否已出账、按用户汇总用量、按基准价生成账单、回写抄表状态。两条路线对比如下方案事务边界一万户耗时量级调试难度适用规模Java 循环调 Service单条插入各自事务分钟级低千户以内存储过程批量出账整个账期一个事务秒级中千户以上Java 批量提交 ExecutorType.BATCH一个事务分批提交秒级中两者之间4.2 关账存储过程先防重复再批量写账下面这个存储过程是月末核心。它先检查目标账期是否已有账单有就直接报错终止没有则按用户聚合待计费抄表记录生成账单并把抄表记录回写为已计费DELIMITER $$ CREATE PROCEDURE sp_generate_bill( IN p_period CHAR(6), -- 账期如 202406 IN p_due_days INT -- 缴费期限天数一般传 30 ) BEGIN DECLARE v_count INT DEFAULT 0; DECLARE v_price DECIMAL(8,4); START TRANSACTION; -- 防重复出账目标账期已有任何账单直接终止本次操作 SELECT COUNT(*) INTO v_count FROM t_bill WHERE period p_period; IF v_count 0 THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT period already billed; END IF; -- 取第一档单价作为基准价阶梯差额计算见 3.3 的 Java 算法 SELECT price INTO v_price FROM t_tariff WHERE period p_period AND tier_no 1; -- 按用户聚合待计费抄表量生成账单 INSERT INTO t_bill (bill_no, user_id, period, usage_amount, amount, status, due_date, create_time) SELECT CONCAT(p_period, -, u.user_no), u.id, p_period, t.usage_amount, t.usage_amount * v_price, 0, DATE_ADD(CURDATE(), INTERVAL p_due_days DAY), NOW() FROM ( SELECT m.user_id, SUM(r.usage_amount) AS usage_amount FROM t_reading r JOIN t_meter m ON r.meter_id m.id WHERE r.period p_period AND r.status 0 GROUP BY m.user_id ) t JOIN t_user u ON t.user_id u.id; -- 回写抄表状态防止再次累计 UPDATE t_reading SET status 1 WHERE period p_period AND status 0; COMMIT; END$$ DELIMITER ;调用方式CALL sp_generate_bill(202406, 30);参数说明p_period 是 CHAR(6) 账期p_due_days 控制账单缴费截止日。WHERE 里同时带 period 和 status直接命中第 2 章建的联合索引 idx_reading_period_status。如果该账期已出过账第二次调用会看到 SQLSTATE 45000 的报错这本身就是防重复的验收点。如果项目确实要阶梯计费有两种演进路径一是在这里把子查询换成按 tier_no 分段的三段 CASE二是把 3.3 计算好的金额写入扩展列 amount_calc由这个过程读取落账。前者简单但调价要改存储过程后者多一张表的成本但费率完全可配置更符合 2.2 的版本化设计思路。在 Java 侧关账任务用 Spring 定时任务触发JdbcTemplate 直接调用存储过程Component public class MonthCloseTask { Autowired private JdbcTemplate jdbcTemplate; // cron: 秒 分 时 日 月 周每月 1 日 00:30 关上一账期 Scheduled(cron 0 30 0 1 * ?) public void closeLastMonth() { String period lastPeriod(); // 自行实现取上个月账期字符串 jdbcTemplate.update(CALL sp_generate_bill(?, ?), period, 30); log.info(账期 {} 出账完成, period); } }注意 Scheduled 需要任务扫描生效在配置类上加 EnableScheduling。定时关账适合测试环境验证生产环境怕定时任务误触改成管理员在页面上手动点「月末关账」更稳妥这也是答辩时能讲的业务取舍。4.3 联合索引 (period, status) 与分页查询的 Explain 验证第 2 章已经为 t_reading 声明了 idx_reading_period_status(period, status)。这个索引同时服务两类查询出账时找「某账期未计费记录」以及页面展示「某账期抄表明细」。用 EXPLAIN 验证是否命中EXPLAIN SELECT * FROM t_reading WHERE period 202406 AND status 0;type 应为 refkey 显示 idx_reading_period_statusrows 远小于全表。如果当初只建了 period 单列索引status 的过滤要靠回表rows 会大一个数量级。账单分页查询同样要注意过滤和排序的组合。下面这条 SQL 在 JSP 的「欠费列表」页面很常见SELECT u.user_no, u.user_name, b.period, b.amount, b.status FROM t_bill b JOIN t_user u ON b.user_id u.id WHERE b.status 2 ORDER BY b.create_time DESC LIMIT #{offset}, #{pageSize};配合 idx_bill_status (status, create_time)过滤和排序能共用一个索引避免 filesort。LIMIT 深分页在乡镇数据量下问题不大如果被问到性能边界可以答「数据量到几十万后再改基于游标的 keyset 分页」这是一个加分点。提示索引不是越多越好。t_reading 上 (meter_id, period) 负责防重idx_reading_period_status 负责出账与查询两条足够。再多加索引会让写入变慢抄表录入恰恰是这套系统写操作最多的场景。5. 部署到 MySQLTomcat 与答辩验收配置改动、故障排查和造数压测5.1 war 包部署的四个必改配置war 包放进 Tomcat webapps 目录启动即可但有四个配置不改正就会在答辩现场翻车。第一JDK 8 MySQL 8 必须用驱动类名 com.mysql.cj.jdbc.Driver老驱动只兼容 MySQL 5.x第二连接串要带 serverTimezone 和 characterEncoding否则连不上或中文乱码第三jdbc.properties 的账号密码要和本地库一致第四清掉 Tomcat work 目录下旧 JSP 编译产物否则改了页面不生效。# 1. 停掉 Tomcat避免 war 解压冲突 sudo systemctl stop tomcat9 # 2. 复制 war 到 webappswar 文件名决定访问路径 cp water.war /opt/tomcat/webapps/ # 3. 清空 work 缓存保证 JSP 重新编译 rm -rf /opt/tomcat/work/Catalina/localhost/water # 4. 启动并跟踪日志看到 Server startup 再访问 sudo systemctl start tomcat9 tail -f /opt/tomcat/logs/catalina.out访问路径就是 http://localhost:8080/water/ 。测试期把日志级别调到 DEBUGMyBatis 的 SQL 会打出来接口报错时定位最快。5.2 答辩现场故障优先级表现象定位思路处理方式启动报 ClassNotFoundException: com.mysql.jdbc.DriverMySQL 驱动类名不匹配jdbc.properties 改 com.mysql.cj.jdbc.Driver并升级 mysql-connector-java 8.x启动成功但页面查不到数据库没导入或配置不对确认 water 库存在url/username/password 与本地一致JSP 页面中文乱码请求编码或字符集不对连接串加 characterEncodingutf8表统一 utf8mb4配 CharacterEncodingFilter功能改了页面没变化Tomcat 缓存旧 JSP删 work 目录重启或删掉旧 war 重新部署存储过程权限报错应用账号无 EXECUTE 权限用 root 执行一次 CALL再给账号授权5.3 用一条 SQL 造一万条抄表数据压测月末出账答辩前最好验证月末出账性能。手工录一万条不现实用数字表批量插入利用 information_schema.columns 的笛卡尔积生成序号把一万条待计费记录分散到 200 块表上INSERT INTO t_reading (meter_id, period, this_reading, last_reading, usage_amount, read_date, status) SELECT 1 (n % 200), -- 200 块表轮询分配 DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), %Y%m), 200 n, 100 n, 100, CURDATE(), 0 FROM ( SELECT (i : i 1) AS n FROM information_schema.columns, (SELECT i : 0) r LIMIT 10000 ) t;然后调用关账存储过程并再次调用验证防重复CALL sp_generate_bill( DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), %Y%m), 30 ); -- 再次调用同一账期应看到 SQLSTATE 45000 报错 CALL sp_generate_bill( DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), %Y%m), 30 );最后对账SELECT COUNT(*) FROM t_bill WHERE status 0; SELECT COUNT(*) FROM t_reading WHERE status 0 AND period DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), %Y%m);两条结果都为 0说明出账与回写一致。本文还有配套的精品资源点击获取