FEATURED · 精选文章

基于SSM的乡镇自来水收费系统设计与阶梯计费实现

发布时间 / 2026/9/13 18:38:49
来源 / 创域科博编辑部
栏目 / 资讯中心
基于SSM的乡镇自来水收费系统设计与阶梯计费实现 简介乡镇自来水收费系统是一套面向毕业设计和课程设计的JavaWeb项目源码基于SSM整合框架采用JSP作为页面展示层数据库使用MySQL。运行环境配置为Eclipse或IDEA、JDK1.8、Tomcat7适合计算机相关专业学生用于功能设计参考、框架代码学习或二次开发。系统分为管理员端和用户端管理员可以管理水表、审核更换水表请求、抄表并处理水费缴费同时管理公告、留言与用户信息用户能够申请更换水表、在线缴纳水费、查看公告、发布留言以及修改个人资料业务流程完整。资源包共包含1942个文件大小42.56MB核心类型有Java源码、JSP页面、JavaScript脚本、CSS样式、XML配置文件、SQL数据库脚本以及说明文档和LW文档前后端代码齐全导入开发工具即可运行。已有60人学习读者可借助完整的前后端代码快速搭建可演示的收费系统在理解SSM分层结构、完成毕业设计或准备课程设计答辩时直接作为项目支撑。1. 乡镇自来水收费系统在 SSM 技术栈下的核心设计问题乡镇自来水收费系统这类 Java 毕业设计表面上要做的是「用户管理 抄表 收费」三个功能模块实际上真正的难点在计费模型一个乡镇的水费不是简单的「单价 × 用量」而是阶梯水价——用量落在哪个档位单价就不同。而且乡镇场景下用户类型复杂有居民户、经营户、农业灌溉户每类用户的阶梯阈值和基础水价都不一样。SSM JSP MySQL 这套组合恰好适合承载这种业务Spring 管事务和依赖SpringMVC 管请求路由MyBatis 管 SQLJSP 承担服务端渲染MySQL 存业务数据。本文按一个可运行的毕业设计源码包实际涉及的技术点展开从表结构设计、框架整合配置到阶梯计价算法最后落在部署和答辩。2. 收费业务的数据模型与 MySQL 表结构设计2.1 用户、水表与抄表记录一张抄表记录表承载全部计费依据乡镇自来水收费系统的核心数据流是用户档案 → 水表绑定 → 抄表 → 计费 → 收费 → 报表。用户档案表和普通管理系统的用户表不同除了姓名、电话、地址还必须带user_type用户类型和area_id片区/村组因为乡镇水费往往按片区划分抄表员按用户类型计算水价。水表表建议单独建不要和用户表合并。原因很实际乡镇场景下用户换表、增容、分户都很频繁如果水表字段直接挂在用户表上每一次换表都得更新用户记录历史数据就丢了。正确做法是用户表只保留当前绑定的水表 ID水表表有自己独立的生命周期字段CREATE TABLE user_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_no VARCHAR(32) NOT NULL COMMENT 用户编号, user_name VARCHAR(64) NOT NULL, user_type TINYINT NOT NULL COMMENT 1居民 2经营 3农业灌溉, phone VARCHAR(20), address VARCHAR(255), area_id BIGINT COMMENT 片区ID, meter_id BIGINT COMMENT 当前绑定水表ID, status TINYINT DEFAULT 1 COMMENT 1正常 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE water_meter ( id BIGINT PRIMARY KEY AUTO_INCREMENT, meter_no VARCHAR(64) NOT NULL COMMENT 水表编号/表号, user_id BIGINT NOT NULL, install_date DATE, initial_reading DECIMAL(10,2) DEFAULT 0 COMMENT 安装时初始读数, max_reading DECIMAL(10,2) COMMENT 量程上限, status TINYINT DEFAULT 1 );抄表记录表是整个计费的数据源头设计上有一个关键点抄表读数要和计费周期绑定。也就是说一条抄表记录里既要存current_reading本次读数也要存last_reading上次读数还需要一个period字段标识计费周期比如 202501这样后续统计月度用水量、出账、对账都靠这个字段分组避免用时间字段去模糊匹配。CREATE TABLE meter_reading ( id BIGINT PRIMARY KEY AUTO_INCREMENT, meter_id BIGINT NOT NULL, user_id BIGINT NOT NULL, period VARCHAR(6) NOT NULL COMMENT 计费周期 如 202501, last_reading DECIMAL(10,2) NOT NULL, current_reading DECIMAL(10,2) NOT NULL, usage_amount DECIMAL(10,2) GENERATED ALWAYS AS (current_reading - last_reading) STORED, reader_id BIGINT COMMENT 抄表员, read_time DATETIME, status TINYINT DEFAULT 0 COMMENT 0待计费 1已计费 2已收费, UNIQUE KEY uk_meter_period (meter_id, period) );这里用了 MySQL 5.7 的生成列usage_amount由数据库自动计算避免 Java 层手工算错。唯一键uk_meter_period保证同一个水表同一个周期只有一条抄表记录这是防止重复计费的第一道防线。2.2 水价方案与阶梯计费用两张表支持任意档位扩展阶梯水价是这类系统里最容易设计错的表。常见错误是把三档水价直接写成三个字段price1、price2、price3一旦政策改成四档就抓瞎。正确做法是把阶梯规则抽出来做成方案表 规则表两张表CREATE TABLE tariff_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_name VARCHAR(64) NOT NULL, user_type TINYINT NOT NULL COMMENT 适用用户类型, effective_date DATE NOT NULL, expire_date DATE, status TINYINT DEFAULT 1 ); CREATE TABLE tariff_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_id BIGINT NOT NULL, step_no INT NOT NULL COMMENT 第几档, max_usage DECIMAL(10,2) COMMENT 该档上限NULL表示无上限, price DECIMAL(10,4) NOT NULL, base_fee DECIMAL(10,2) DEFAULT 0 COMMENT 基础水费/固定费用 );计费算法在逻辑上做两件事先根据用户类型找到当前生效的tariff_plan再把用户当月的usage_amount按tariff_rule从低档到高档逐档切割水量。收费记录表charge_record记录了每一笔实际收费是报表统计的核心。它必须冗余存储「当时计算出的账单明细快照」不能通过关联查询去反查当时的水价——因为水价方案会调整关联当前tariff_rule得到的金额可能和用户实际交的对不上。明细快照字段包括period、usage_amount、total_fee、penalty_fee违约金、paid_fee实收、charge_time、operator_id。2.3 数据模型中的关键约束与业务边界上面的表结构中有几个约束值得在答辩时展开。第一是逻辑删除 vs 物理删除用户销户不能直接 DELETE否则水表、抄表记录的外键关联会出问题建议统一加status字段做状态流转正常/停用/销户保留历史数据用于日后的审计和聚合查询。第二是Decimal 精度金额和读数一律用DECIMAL(10,2)禁止用FLOAT/DOUBLEJava 侧对应使用BigDecimal不要用double做水费乘法否则 0.1 0.2 的经典精度问题会让你对账对不上。第三是水价的历史版本tariff_plan设计了effective_date换水价时不要更新旧方案的记录而是插入新记录并废除旧方案这保证了历史账单永远能和当时的水价对上账。收费方式上乡镇系统有两种模式先抄表后收费后付费和预充值扣费预付费。毕业设计通常做后付费模式因为逻辑直观、页面流程好展示。预付费会在水表表上加balance字段每次抄表计费时自动扣除逻辑上多了「余额不足阻断计费」的分支工作量大约多出三分之一时间充裕可以当加分项。3. SSM 框架整合的关键配置与项目骨架3.1 spring-mybatis 数据源与事务配置SSM 项目的骨架是 spring springmvc mybatis 三个配置文件。很多毕业设计源码里配置文件杂乱核心配置其实只有三个文件applicationContext.xmlSpring 主配置管数据源、事务、MyBatis、spring-mvc.xml管控制器扫描、视图解析器、mybatis-config.xml管别名、驼峰映射。以下是一份可以直接用于 MySQL 8 的applicationContext.xml核心配置context:component-scan base-packagecom.townwater context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/town_water?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ property nameinitialSize value5/ property namemaxActive value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nameconfigLocation valueclasspath:mybatis-config.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.townwater.mapper/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceManager/ tx:annotation-driven transaction-managertransactionManager/这里有一个 SSM 项目最常见的坑几乎每个做过 SSM 的人都被它卡过MapperScannerConfigurer 的扫描包路径必须和 DAO 接口所在包一致且该包下的每个接口都要有对应的 XML 或注解 SQL否则启动时直接报Invalid bound statement。另外context:component-scan里要 exclude 掉 Controller 注解否则 SpringMVC 和 Spring 的容器职责重叠事务注解会失效。MyBatis 的mybatis-config.xml中必须开启驼峰映射否则数据库的user_name映射不到 Java 属性userName在 select 时不手动加别名的情况下configuration settings setting namemapUnderscoreToCamelCase valuetrue/ /settings typeAliases package namecom.townwater.model/ /typeAliases /configuration3.2 SpringMVC 的请求分层与 JSP 视图解析SpringMVC 配置里视图解析器决定了 JSP 页面存放在哪个目录下。毕业设计通常用WEB-INF/views目录把 JSP 放在 WEB-INF 下可以阻止用户通过 URL 直接访问未经过控制器转发的页面这是 JSP 项目一个重要的安全边界bean idviewResolver classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean控制器设计上建议按业务模块建 Controller类上标注RequestMapping(/charge)方法上按操作类型拆 URL。乡镇水务系统典型的分层方式是ChargeController负责收费请求、ReadingController负责抄表录入、ReportController负责统计报表。所有控制器方法返回字符串配合ModelAndView或Model往 JSP 传数据。JSP 页面里有两个容易写错的地方。第一是表单提交路径在 WEB-INF 下的 JSP 中${pageContext.request.contextPath}是必写的前缀很多源码里直接写死/town_water/charge/add项目一旦改了 contextPath 全部 404。第二是数据回显spring mvc的ModelAttribute注解可以自动把实体类放入 ModelJSP 里用${chargeRecord.period}回显比手动request.setAttribute省很多代码。3.3 统一返回结构与全局异常处理界面跳转和 AJAX 请求在这个系统里并存页面跳转用 Model 传数据提交表单后的金额计算、查重校验用 AJAX。为了统一建议封装一个Result对象public class Result { private int code; // 200成功 500失败 private String msg; private Object data; public static Result success(Object data) { ... } public static Result error(String msg) { ... } }Controller 中返回Result的方法加ResponseBody返回字符串的方法走视图解析器两者靠方法签名区分互不干扰。再加一个ControllerAdvice全局异常处理类捕获DuplicateKeyException重复抄表和DataIntegrityViolationException数据约束违反返回给前端友好的提示信息不把堆栈直接抛到 Tomcat 页面上去。4. 阶梯计价与收费流程是系统最核心的代码逻辑4.1 阶梯水价计算用策略方法替代 if-else 嵌套水费计算是这类系统中真正有业务含量的代码。既然表结构已经设计成方案表 规则表Java 侧就不要用if判断用户类型再去查不同的价格字段而是写一个独立的方法接收userType和usageAmount返回账单明细。下面是一个可运行的阶梯计算实现public BillingResult calcTariff(Long userId, BigDecimal usage) { // 1. 查用户档案拿到用户类型 UserProfile user userMapper.selectById(userId); // 2. 查询当前生效的水价方案 TariffPlan plan tariffPlanMapper.selectEffective( user.getUserType(), new Date()); // 3. 查询该方案全部阶梯规则按 step_no 升序 ListTariffRule rules tariffRuleMapper.selectByPlanId(plan.getId()); BigDecimal remaining usage; BigDecimal totalFee BigDecimal.ZERO; ListBillItem items new ArrayList(); for (int i 0; i rules.size(); i) { TariffRule rule rules.get(i); if (remaining.compareTo(BigDecimal.ZERO) 0) break; BigDecimal stepUsage; if (rule.getMaxUsage() null) { // 最后一档无上限 stepUsage remaining; } else { BigDecimal upper rule.getMaxUsage(); if (i 0) { BigDecimal preMax rules.get(i - 1).getMaxUsage(); upper upper.subtract(preMax); } stepUsage remaining.min(upper); } BigDecimal stepFee stepUsage.multiply(rule.getPrice()) .setScale(2, RoundingMode.HALF_UP); totalFee totalFee.add(stepFee); items.add(new BillItem(rule.getStepNo(), stepUsage, rule.getPrice(), stepFee)); remaining remaining.subtract(stepUsage); } // 加上基础水费如垃圾处理费、水资源费附加 totalFee totalFee.add(plan.getExtraFee()); return new BillingResult(totalFee, items); }这段代码有两个容易写错的点。第一是upper的计算tariff_rule表里max_usage存的是该档的累计上限第一档 20 吨第二档 40 吨计算第二档的实际可计价水量时要用40 - 20 20吨而不是直接用max_usage去乘价格。第二是减法的顺序remaining.min(upper)计算的是这一档实际消费的水量循环结束后remaining如果还大于 0 说明用量超出了方案总档位这段水量应单独按最高单价计算或报错提示。4.2 抄表 → 出账 → 收费的完整事务边界从业务上讲抄表和收费不是同一个操作。抄表是录入数据收费是根据抄表数据出账并收款。这两个操作要拆成两个 Service 方法并且各自带事务边界Transactional(rollbackFor Exception.class) public Long insertReading(MeterReading reading) { // 1. 幂等校验同一水表同一周期只能有一条记录 int count readingMapper.countByMeterAndPeriod( reading.getMeterId(), reading.getPeriod()); if (count 0) { throw new BusinessException(该周期已抄表); } // 2. 校验读数不能小于上次读数水表倒走 if (reading.getCurrentReading().compareTo(reading.getLastReading()) 0) { throw new BusinessException(本次读数不能小于上次读数); } // 3. 插入抄表记录状态为已计费 reading.setStatus(1); readingMapper.insert(reading); return reading.getId(); } Transactional(rollbackFor Exception.class) public ChargeRecord charge(Long readingId, Long operatorId) { // 1. 查抄表记录锁定行 MeterReading reading readingMapper.selectByIdForUpdate(readingId); if (reading.getStatus() 2) { throw new BusinessException(该账单已收费禁止重复操作); } // 2. 调阶梯计价 BillingResult bill calcTariff(reading.getUserId(), reading.getUsageAmount()); // 3. 计算违约金逾期按日计 BigDecimal penalty calcPenalty(reading.getPeriod()); // 4. 写收费记录 ChargeRecord record new ChargeRecord(); record.setReadingId(readingId); record.setTotalFee(bill.getTotalFee()); record.setPenaltyFee(penalty); record.setPaidFee(bill.getTotalFee().add(penalty)); record.setOperatorId(operatorId); chargeRecordMapper.insert(record); // 5. 更新抄表记录状态 readingMapper.updateStatus(readingId, 2); return record; }这里selectByIdForUpdate是行级锁作用是在多用户同时点击「收费」按钮时只有先拿到锁的事务能够插入收费记录后到的事务会读到已被更新的status2进而抛出异常。这一步是防止重复收费的关键也是答辩时最值得演示的技术点——去掉它用两个浏览器窗口同时对一个账单发起收费大概率能造出两条收费记录。4.3 欠费违约金策略模式在参数化配置中的应用欠费和违约金是乡镇水费系统里一个很实际的需求用户当期没交费下期抄表时要按拖欠天数算滞纳金。常见做法是配置表里存一个日违约金比例比如万分之五系统的参数管理页面允许管理员调整。计算逻辑放在penaltyServicepublic BigDecimal calcPenalty(String period, BigDecimal unpaidAmount) { // 假设逾期按天计算每天万分之一封顶 30 天 LocalDate dueDate LocalDate.parse(period 01) .plusDays(20); // 抄表后 20 天内缴费免违约金 long overdueDays ChronoUnit.DAYS.between(dueDate, LocalDate.now()); if (overdueDays 0) return BigDecimal.ZERO; long penaltyDays Math.min(overdueDays, 30); return unpaidAmount.multiply(new BigDecimal(0.001)) .multiply(new BigDecimal(penaltyDays)) .setScale(2, RoundingMode.HALF_UP); }违约金计算要封顶天数否则欠了两年的水费用户直接不敢来交钱。参数化配置是这类系统的一个隐藏加分项把逾期免滞纳金天数、日费率、封顶天数都放到字典表里管理员在页面上能调而不是写死在代码里。再补充一个Transactional使用时的坑同一个类内部的this.calcTariff(...)调用不经过 Spring AOP 代理事务不会传播到该方法中。上面的代码中calcTariff没有独立注解它靠外层方法的事务统一管理如果给calcTariff自己也加Transactional调用链 A→B 时会失效因为这个调用没走代理对象。这是 SSM 面试八股文里常考的一个点在论文和答辩 PPT 中值得写一笔。5. 部署运行与进阶从源码压缩包到能演示的完整项目5.1 基于 Tomcat MySQL 8 的最小部署步骤拿到源码压缩包后最快跑通的路径是解压到 IDEA → 改配置 → 建库导数据 → 配置 Tomcat → 启动。具体的操作顺序和注意点如下# 1. 建库注意字符集 mysql -uroot -p -e CREATE DATABASE town_water DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入 SQL源码包里通常有 town_water.sql mysql -uroot -p town_water /path/to/town_water.sql # 3. 确认 Tomcat 版本和 JDK 兼容性 # JDK 8 对应 Tomcat 8.5/9.xJDK 11 对应 Tomcat 9/10 java -version然后改applicationContext.xml里的数据库连接信息以及db.properties里的账号密码。这里有一个高频坑com.mysql.jdbc.Driver是 MySQL 5.x 的老驱动MySQL 8 必须用com.mysql.cj.jdbc.Driver且 URL 要加serverTimezoneAsia/Shanghai否则启动直接报时区异常或驱动类找不到。注意检查pom.xmlMaven 项目或WEB-INF/lib手动导包项目里的mysql-connector-java版本5.1.x 的 jar 包配 MySQL 8 会出现认证协议不兼容的报错升级到 8.0.x 即可。5.2 三个最常见的踩坑位置第一JSP 编译后的 class 文件在哪里。很多人在 IDEA 里改完 JSP 发现刷新浏览器不生效本质是 Tomcat 把 JSP 编译成了 Servlet 的 class 文件存放在 Tomcat 的work/Catalina/localhost目录下。改 JSP 后必须点击 IDEA 的Update resources或重启 Tomcat 触发重新编译这一点和修改普通 Java 类的热部署机制不同。第二Invalid bound statement (not found)报错。这个报错八成是 mapper XML 的 namespace 写错了或 XML 文件中mapper namespacecom.townwater.mapper.ChargeRecordMapper和接口的全限定名不一致。打开 target/classes 目录检查 XML 是否被打包进去如果 XML 放在src/main/java下还需要在pom.xml里加配置resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources第三列表页分页失效或查不出数据。SSM 项目通常用 PageHelper 插件做分页检查PageHelper.startPage(pageNum, pageSize)后面必须紧跟第一条 Mapper 查询语句中间不能插入任何其他 MyBatis 执行操作否则分页会作用到错误的 SQL 上这是 PageHelper 使用中经典的一个坑。5.3 给答辩加分的聚合统计MySQL 的 GROUP BY 按周期汇总系统做出来后答辩时最能撑场面的功能是「收费报表按周期汇总」。下面这条 SQL 写在ReportMapper.xml里一张图展示某段时间内每个片区的收费总额和收费占比SELECT a.area_name, DATE_FORMAT(c.charge_time, %Y-%m) AS charge_month, COUNT(DISTINCT c.user_id) AS user_count, SUM(c.paid_fee) AS total_fee FROM charge_record c LEFT JOIN user_profile u ON c.user_id u.id LEFT JOIN area a ON u.area_id a.id WHERE c.charge_time #{startTime} AND c.charge_time #{endTime} GROUP BY a.area_name, DATE_FORMAT(c.charge_time, %Y-%m) ORDER BY charge_month DESC, total_fee DESC这条查询和基本的表数据查询不一样它涵盖了 JOIN、聚合函数、日期函数三个考点。注意DATE_FORMAT(c.charge_time, %Y-%m)按月份分组时如果索引建在charge_time上用索引字段做表达式会导致索引失效数据量小无所谓答辩时能主动提出这个点会显得思路清晰。报表在 JSP 页面展示时一般用 JSTL 的c:forEach遍历结果集渲染成 HTML 表格柱状图可选 ECharts但要注意 ECharts 的 JS 文件要放到webapp/static/js目录下JSP 用script src${pageContext.request.contextPath}/static/js/echarts.min.js引用路径写错的话控制台只报 404 而不会报错排查时要关注 Network 面板。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻