从Servlet到SSM:一个乡镇自来水收费系统的Java Web架构演进与实现

8 0
sunny 发表于 2026-8-14 13:38:52 | 查看全部 阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
Servlet到SSM:一个乡镇自来水收费系统的Java Web架构演进与实现
本文导读

最近翻了一个老项目——乡镇自来水收费系统,用SSM(Spring+SpringMVC+MyBatis)+JSP写的,功能不复杂但很典型:用户管理、水表管理、抄表录入、费用计算、收费开票、报表统计。这种项目在三四线城市和乡镇特别常见,很多水务公司还在用类似的系统。这篇文章我从Java Web架构的演进讲起,以这个项目为例,把SSM三层架构的设计思路、核心代码、配置文件和部署踩坑都讲透,适合做Java Web方向毕设或者刚入行的后端同学参考。
一、Java Web架构演进简史

Java Web开发,了解技术演进的脉络很重要——你现在用的框架,都是前人踩坑踩出来的。大致经历了这么几个阶段:
阶段
代表技术
特点
主要问题

第一阶段

Servlet

Java代码输出HTML,所有逻辑写在一个类里

代码臃肿,前后端混在一起,维护噩梦

第二阶段

JSP+JavaBean

JSP负责页面,JavaBean封装数据和业务

JSP里还是有大量Java代码,职责不清

第三阶段

Struts+JDBC

MVC模式开始普及,Struts管Controller

配置繁琐,JDBC手写SQL太痛苦,Action线程安全问题

第四阶段

SSH(Struts+Spring+Hibernate)

Spring管依赖注入,Hibernate做ORM

Hibernate学习曲线陡,复杂SQL优化难,Struts漏洞多

第五阶段

SSM(SpringMVC+Spring+MyBatis)

SpringMVC替代Struts,MyBatis替代Hibernate

配置文件多,部署打包麻烦

第六阶段

Spring Boot+MyBatis-Plus

约定优于配置,自动装配,起步依赖

对新手来说屏蔽了太多底层原理,不利于理解
这个自来水收费系统用的是第五阶段的SSM,在当时(大概2018-2020年)是企业级Java Web的主流方案。现在新项目一般直接上Spring Boot了,但SSM的项目存量还很大,而且理解SSM是理解Spring Boot的基础——Spring Boot本质上就是把SSM的配置自动化了。
二、为什么这个项目选SSM

做技术选型不能只看"什么最新用什么",要看项目需求和团队能力。这个项目选SSM的原因:
第一,功能需求明确。自来水收费系统是典型的CRUD+业务计算,用户量不大(乡镇级别,同时在线几十人),不需要微服务那套重量级架构,SSM单体应用完全够用。
第二,开发效率和可控性的平衡。MyBatis相比Hibernate,SQL是自己写的,优化起来直观,出了问题好排查;SpringMVC相比Struts,配置更简洁,注解驱动开发效率高。
第三,生态成熟。SSM的文档、教程、问题解决方案网上一搜一大把,遇到坑基本都能找到答案,对小团队来说很重要。
第四,部署简单。打个war包扔Tomcat里就能跑,乡镇的运维人员也能搞定,不需要Docker、K8s那套。
三、SSM三层架构详解

SSM的核心是分层——表现层、业务层、持久层,各层职责明确,通过Spring的依赖注入(DI)组装在一起。
java_mvc.jpg
1 SSM框架三层架构图(SpringMVC表现层+Spring业务层+MyBatis持久层)
层次
框架
职责
关键组件

表现层

SpringMVC

接收请求、参数封装、调用业务层、返回视图/JSON

Controller、Interceptor、ViewResolver

业务层

Spring

事务管理、依赖注入、业务逻辑编排

Service、AOP、TransactionManager

持久层

MyBatis

SQL映射、数据库操作、结果集封装

Mapper接口、Mapper XML、SqlSessionFactory
请求的完整流程是:用户发请求→DispatcherServlet拦截→HandlerMapping找到对应的Controller→Controller调用Service→Service调用Mapper→Mapper通过MyBatis执行SQL→结果一层层返回→Controller把数据交给View渲染→响应给用户。整个流程里,对象的创建和依赖关系都由Spring容器管理,你不用自己new对象。
四、项目整体设计

4.1 功能模块

乡镇自来水收费系统的功能模块划分如下:
ssm_arch.jpg
2 自来水收费管理系统功能模块图
模块
核心功能
说明

系统管理

用户管理、角色权限、密码修改、操作日志

基于SpringMVC拦截器做权限控制

基础资料

用水类型、水价标准、区域管理

水价支持阶梯定价,居民/商业/工业不同价格

用户水表

用户信息、水表档案、过户、销户

一个用户可以有多块水表

抄表管理

抄表录入、历史读数、用水量计算

支持手工录入和批量导入

水费管理

费用计算、账单生成、滞纳金计算

按月生成账单,逾期按日加收滞纳金

收费管理

收费登记、票据打印、退费处理

支持现金、微信、支付宝等多种支付方式

统计查询

收费日报、月报、欠费统计、用水分析

导出Excel报表
4.2 数据库核心表

数据库用MySQL 5.7,核心表设计:
表名
核心字段
说明

sys_user

id, username, password, role_id, status

系统用户表,密码MD5加密

water_user

id, name, phone, address, area_id

用水户信息表

water_meter

id, meter_no, user_id, install_date, status

水表档案表,和用水户一对多

meter_reading

id, meter_id, read_date, last_num, current_num, usage

抄表记录表

price_config

id, user_type, base_price, ladder_price

水价配置表,支持阶梯水价

bill

id, bill_no, user_id, meter_id, amount, status, month

账单表,status: 0未付1已付

payment

id, bill_id, amount, pay_method, pay_time, operator

收费记录表
这里有个设计要点:账单表要冗余存储用水量和金额,不能只存水表ID和月份——因为水价可能会调整,历史账单的金额应该固定不变,不能跟着水价变。这个坑很多新手会踩,到时候对账对不上就麻烦了。
五、核心模块代码实现

5.1 用户登录与权限拦截

登录用SpringMVC的Controller处理,密码MD5加密后比对。权限控制用拦截器(Interceptor),检查Session里有没有登录信息:
// Controller层
@Controller
@RequestMapping("/login")
public class LoginController {

    @Autowired
    private SysUserService sysUserService;

    @PostMapping("/doLogin")
    public String doLogin(String username, String password,
                          HttpSession session, Model model) {
        SysUser user = sysUserService.findByUsername(username);
        if (user == null) {
            model.addAttribute("msg", "用户不存在");
            return "login";
        }
        String md5Pass = DigestUtils.md5DigestAsHex(password.getBytes());
        if (!md5Pass.equals(user.getPassword())) {
            model.addAttribute("msg", "密码错误");
            return "login";
        }
        if (user.getStatus() == 0) {
            model.addAttribute("msg", "账号已禁用");
            return "login";
        }
        session.setAttribute("loginUser", user);
        return "redirect:/index";
    }
}
// 权限拦截器
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {
        Object user = request.getSession().getAttribute("loginUser");
        if (user == null) {
            response.sendRedirect(request.getContextPath() + "/login");
            return false;
        }
        return true;
    }
}
生产环境建议把MD5换成BCrypt,MD5已经不安全了。但这个项目是内部系统,用户量小,MD5也能接受——不过能升级还是升级。
5.2 水表信息管理(MyBatis CRUD)

MyBatis的好处是SQL和Java代码分离,Mapper接口写方法,Mapper XML写SQL,灵活好优化:
// Mapper接口
public interface WaterMeterMapper {
    List<WaterMeter> findByCondition(@Param("meterNo") String meterNo,
                                     @Param("userId") Integer userId,
                                     @Param("status") Integer status);
    WaterMeter findById(Integer id);
    int insert(WaterMeter meter);
    int update(WaterMeter meter);
    int deleteById(Integer id);
}
<!-- Mapper XML -->
<select id="findByCondition" resultType="WaterMeter">
    SELECT * FROM water_meter
    <where>
        <if test="meterNo != null and meterNo != ''">
            AND meter_no LIKE CONCAT('%', #{meterNo}, '%')
        </if>
        <if test="userId != null">
            AND user_id = #{userId}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </where>
    ORDER BY id DESC
</select>
MyBatis的动态SQL(<where>、<if>)做条件查询特别方便,比JDBC拼字符串强太多了。这也是MyBatis比Hibernate灵活的地方——复杂查询自己写SQL,优化空间大。
5.3 抄表与费用计算(业务逻辑层)

费用计算是核心业务逻辑,放在Service层。阶梯水价的计算逻辑:
@Service
public class MeterReadingServiceImpl implements MeterReadingService {

    @Autowired
    private WaterMeterMapper meterMapper;
    @Autowired
    private MeterReadingMapper readingMapper;
    @Autowired
    private PriceConfigMapper priceMapper;
    @Autowired
    private BillMapper billMapper;

    @Transactional
    @Override
    public void addReading(Integer meterId, Integer currentNum, String readDate) {
        WaterMeter meter = meterMapper.findById(meterId);
        if (meter == null) {
            throw new RuntimeException("水表不存在");
        }
        // 查询上次抄表读数
        MeterReading lastReading = readingMapper.findLastByMeterId(meterId);
        int lastNum = lastReading != null ? lastReading.getCurrentNum() : 0;
        int usage = currentNum - lastNum;
        if (usage < 0) {
            throw new RuntimeException("当前读数不能小于上次读数");
        }
        // 保存抄表记录
        MeterReading reading = new MeterReading();
        reading.setMeterId(meterId);
        reading.setReadDate(readDate);
        reading.setLastNum(lastNum);
        reading.setCurrentNum(currentNum);
        reading.setUsage(usage);
        readingMapper.insert(reading);
        // 计算费用(阶梯水价)
        BigDecimal amount = calculateFee(meter.getUserId(), usage);
        // 生成账单
        Bill bill = new Bill();
        bill.setBillNo(generateBillNo());
        bill.setUserId(meter.getUserId());
        bill.setMeterId(meterId);
        bill.setAmount(amount);
        bill.setStatus(0); // 未付款
        bill.setMonth(readDate.substring(0, 7));
        billMapper.insert(bill);
    }

    private BigDecimal calculateFee(Integer userId, int usage) {
        // 根据用户类型查水价配置,简化版:三档阶梯
        PriceConfig config = priceMapper.findByUserType(getUserType(userId));
        BigDecimal amount = BigDecimal.ZERO;
        if (usage <= 10) {
            amount = config.getBasePrice().multiply(new BigDecimal(usage));
        } else if (usage <= 20) {
            amount = config.getBasePrice().multiply(new BigDecimal(10))
                .add(config.getLadderPrice().multiply(new BigDecimal(usage - 10)));
        } else {
            amount = config.getBasePrice().multiply(new BigDecimal(10))
                .add(config.getLadderPrice().multiply(new BigDecimal(10)))
                .add(config.getLadderPrice().multiply(new BigDecimal(1.5))
                    .multiply(new BigDecimal(usage - 20)));
        }
        return amount.setScale(2, RoundingMode.HALF_UP);
    }
}
注意抄表和生成账单要放在同一个事务里(@Transactional),要么都成功要么都失败,不能出现抄表记录存了但账单没生成的情况。生产环境这个事务很关键,对账全靠它。
5.4 收费与账单状态更新


@Service
public class PaymentServiceImpl implements PaymentService {

    @Autowired
    private BillMapper billMapper;
    @Autowired
    private PaymentMapper paymentMapper;

    @Transactional
    @Override
    public void pay(Integer billId, String payMethod, Integer operatorId) {
        Bill bill = billMapper.findById(billId);
        if (bill == null) {
            throw new RuntimeException("账单不存在");
        }
        if (bill.getStatus() == 1) {
            throw new RuntimeException("账单已支付,请勿重复操作");
        }
        // 计算滞纳金(逾期30天以上按日0.05%加收)
        BigDecimal lateFee = calculateLateFee(bill);
        BigDecimal totalAmount = bill.getAmount().add(lateFee);
        // 保存收费记录
        Payment payment = new Payment();
        payment.setBillId(billId);
        payment.setAmount(totalAmount);
        payment.setPayMethod(payMethod);
        payment.setOperator(operatorId);
        paymentMapper.insert(payment);
        // 更新账单状态
        bill.setStatus(1);
        billMapper.update(bill);
    }
}
六、关键配置文件解析

SSM项目配置文件多,这也是它被吐槽的地方。核心有四个:web.xml、applicationContext.xml(Spring配置)、spring-mvc.xml(SpringMVC配置)、mybatis-config.xml(MyBatis配置)。挑关键的看:
<!-- web.xml:核心配置,启动Spring容器和SpringMVC -->
<context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath:applicationContext.xml</param-value>
</context-param>
<listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<servlet>
    <servlet-name>springmvc</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:spring-mvc.xml</param-value>
    </init-param>
</servlet>
<servlet-mapping>
    <servlet-name>springmvc</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>
<!-- 字符编码过滤器,解决中文乱码 -->
<filter>
    <filter-name>encodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>UTF-8</param-value>
    </init-param>
</filter>
<!-- applicationContext.xml:Spring核心配置,管数据源、事务、扫描Service -->
<context:component-scan base-package="com.water.service"/>
<!-- 数据源,用Druid连接池 -->
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
    <property name="driverClassName" value="com.mysql.jdbc.Driver"/>
    <property name="url" value="jdbc:mysql://localhost:3306/water_fee?useUnicode=true&characterEncoding=utf-8"/>
    <property name="username" value="root"/>
    <property name="password" value="123456"/>
</bean>
<!-- SqlSessionFactory -->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
    <property name="dataSource" ref="dataSource"/>
    <property name="configLocation" value="classpath:mybatis-config.xml"/>
    <property name="mapperLocations" value="classpath:mapper/*.xml"/>
</bean>
<!-- Mapper接口扫描 -->
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
    <property name="basePackage" value="com.water.mapper"/>
</bean>
<!-- 事务管理 -->
<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"/>
</bean>
<tx:annotation-driven transaction-manager="txManager"/>
配置文件虽然多,但每一块职责很清晰:web.xml管Web容器启动,applicationContext.xml管业务层和持久层,spring-mvc.xml管表现层。理解了这个分工,配置就不复杂了。
七、部署与踩坑记录

这个项目部署到乡镇服务器上的时候踩了几个坑,给大家避避雷:
1:Tomcat启动报ClassNotFoundException

本地IDEA里跑好好的,打包扔Tomcat里启动就报ClassNotFoundException。原因是IDEA的Artifacts配置没把依赖jar包打进去,或者pom.xml里有些依赖scope设成了provided。解决方法:检查pom.xml,确保所有运行时需要的依赖scope是compile,然后用mvn clean package重新打包,解压war包看看WEB-INF/lib里jar包全不全。
2:数据库连接超时

系统跑一段时间后就报数据库连接超时,重启Tomcat就好,过一阵又犯。原因是MySQL默认8小时无操作自动断开连接,而连接池没有配置验证和重连。解决方法:Druid连接池加validationQuery、testWhileIdle、timeBetweenEvictionRunsMillis这几个配置,让连接池定期检测空闲连接,断开了自动重连。生产环境这个配置一定要加,不然半夜系统挂了都不知道。
3:中文乱码

页面提交中文到后台变成乱码,或者数据库存进去是问号。三个地方要统一编码:web.xml加CharacterEncodingFilter(UTF-8)、JSP页面pageEncoding设为UTF-8、MySQL连接URL加useUnicode=true&characterEncoding=utf-8。三处都对了才不会乱码,少一个都不行。
八、从SSM到Spring Boot的升级思路

如果要把这个项目升级到Spring Boot,思路是这样的:
1. 引入spring-boot-starter-web替代SpringMVC配置,spring-boot-starter替代Spring核心配置;
2. 引入mybatis-spring-boot-starter,Mapper XML位置在application.yml里配置,不用SqlSessionFactoryBean了;
3. 数据源用spring-boot-starter-jdbc+druid-spring-boot-starter,配置写在application.yml;
4. 事务管理自动配置,@Transactional直接用就行;
5. 打jar包用内置Tomcat启动,不用外部Tomcat了。
升级后最大的变化是配置文件从四五个XML变成一个application.yml,启动从扔Tomcat变成java -jar。但底层的架构思想没变——还是三层架构,还是Spring管依赖注入,还是MyBatis做ORM。所以说SSM学透了,Spring Boot上手很快。
九、常见问题FAQ

Q:现在学SSM还有必要吗?直接学Spring Boot不行吗?
A:如果只是做项目,直接学Spring Boot没问题。但如果想搞懂原理、排查深层问题,SSM的知识是基础。Spring Boot的自动装配本质上就是把SSM的那些XML配置用Java代码自动做了,你不懂SSM的话,出了问题都不知道从哪查。建议先花一周了解SSM的基本原理,再用Spring Boot做项目。
Q:MyBatis和JPA/Hibernate怎么选?
A:业务复杂、SQL多、需要精细优化的项目选MyBatis,比如这个收费系统;业务简单、CRUD为主、不想写SQL的项目选JPA。大厂里MyBatis用得更多,因为SQL可控、好优化、DBA好审查。
Q:SSM项目怎么做前后端分离?
A:Controller方法上加@ResponseBody,返回JSON而不是JSP视图;前端用Vue/React调用接口。SpringMVC本身就支持RESTful,改起来不难。这个收费系统如果要前后端分离,把所有Controller的返回值改成JSON,前端单独部署就行。
Q:这个项目做毕设够不够?
A:功能上完全够,甚至偏丰富了。毕设要注意:一是需求分析要写清楚,画用例图;二是数据库设计要规范,画ER图,说清楚表之间的关系;三是核心代码要讲明白,特别是费用计算和事务控制;四是测试部分要有测试用例和测试结果截图;五是可以加点亮点,比如Excel导入导出、图表统计、短信催费提醒。
十、总结与延伸学习路径

这个乡镇自来水收费系统虽然是个老项目,用的是SSM架构,但它涵盖了Java Web开发的核心知识点:MVC分层、依赖注入、ORM映射、事务管理、拦截器、连接池。把这个项目吃透,再学其他Java Web框架就触类旁通了。
如果想继续深入Java后端,可以按这个路径学习:
1. 先把SSM原理搞透,特别是Spring的IoC和AOP、MyBatis的SQL映射和缓存;
2. 学Spring Boot,理解自动装配原理,会用starter快速搭建项目;
3. 学Redis,做缓存和分布式Session,解决高并发场景的性能问题;
4. 学消息队列(RabbitMQ/Kafka),做异步处理和解耦;
5. 最后学Spring Cloud微服务,把单体应用拆成多个服务。
做后端开发,架构思维比具体技术更重要。从Servlet到SSM到Spring Boot到微服务,变的是工具和框架,不变的是分层、解耦、高内聚低耦合这些设计原则。理解了演进的逻辑,就不会被新技术牵着走。

—— 技术一直在变,但解决问题的思路是相通的。
water_system.jpg

有问题欢迎评论区交流,需要SSM完整配置文件模板可以关注后续更新~

回复 转播

使用道具 举报

回复

高级模式
B Color Image Link Quote Code Smilies |上传

本版积分规则

学研领航向全体高校师生打造的一站式综合交流与资源服务平台,集知识学习、经验分享、资源下载、互动问答、职场成长、兼职实践于一体,覆盖校园生活、专业学习、求职就业、兴趣发展等全场景需求。

快捷导航

小黑屋
Copyright © 2026 学研领航 版权所有 陕ICP备2025077879号-1
关灯 在本版发帖 返回顶部
快速回复 返回顶部 返回列表