简介:这套基于SSM框架的停车场管理系统资源,面向正在学习Java Web开发或需要完成课程设计、毕业设计的开发者,围绕车位管理、车辆进出登记、收费记录等核心业务,演示Spring、SpringMVC与MyBatis的整合实践。资源压缩包共908个文件,大小18.08MB,主要包含43个Java源码文件、584个JavaScript脚本、86张JPG图片、76个GIF动图,以及HTML页面、CSS样式、XML和YAML配置文件、SQL数据库脚本等,既覆盖后端业务逻辑与持久层映射,也包含前端交互与界面素材。当前已有2618人浏览学习,适合作为SSM项目入门与二次开发的参考。借助完整的工程目录、代码注释和数据库初始化脚本,使用者可快速在本机部署运行,理解SSM各层之间的调用关系,并在此基础上扩展预约车位、月卡管理等功能。
1. SSM 停车场项目的分层信号:从类名读出业务边界
编译产物里那串.class文件比源码清单更能说明真实设计:UserController、UserServiceImpl、UserMapper是三件套的典型排布,Record和Parking对应停车业务的两张主表,ManageCashController、ManageCashServiceImpl把现金结算单独拆成一条线,carPlateRecognition作为独立组件出现,说明车牌识别没有污染主业务。这套系统解决的其实是停车场里三件最麻烦的事——用户身份、车位状态、出场收费。对学过 Spring MVC 和 MyBatis 基础、想通过完整 ssm java 项目把分层边界和事务写扎实的人来说,它比单纯的功能 demo 更接近真实工程:至少开始考虑模块边界、状态字段设计与重复结算的并发问题。
2. 从实体反推持久层:Parking、Record 与 UserMapper 的表设计
2.1 为什么先看实体再定表结构
一个 SSM 项目拿到手时,先扫 entity 或 model 包。Parking和Record本身对应两张表:parking保存车位状态,record保存每一次入场到出场的生命周期。两者是一对多关系,一个车位可以产生多条停车记录,但同一时刻只能绑定一辆车。设计的关键点在于,车位的"当前状态"和停车记录的"过程状态"要分开存放,不要让 record 表反推出来的状态跟 parking 表互相写死。
2.2 parking 表与 record 表的字段推演
基于常见做法,parking表最核心的字段是车位编号、区域、状态、当前车牌。区域和车位号通常要联合唯一,因为每个停车场的区域编码互不重叠:
CREATE TABLE parking ( id INT NOT NULL AUTO_INCREMENT COMMENT '主键', area VARCHAR(16) NOT NULL COMMENT '区域编号', position_no VARCHAR(16) NOT NULL COMMENT '车位号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0 空闲,1 占用', plate_no VARCHAR(16) DEFAULT NULL COMMENT '当前占用车牌', PRIMARY KEY (id), UNIQUE KEY uk_area_position (area, position_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车位表';status 用 TINYINT 而不是 VARCHAR(1),是为了 MyBatis 映射时直接拿到 Integer 状态值,省去字符串转数字的步骤。plate_no 允许为空,因为空闲车位没有必要保留上一次的车牌。area 和 position_no 联合唯一,能挡住"同一区域录入两个 001 车位"这类的脏数据。
record表记录的是某次停车行为,核心字段围绕"什么时候进、什么时候出、收了多少钱"三个问题:
CREATE TABLE record ( id INT NOT NULL AUTO_INCREMENT COMMENT '主键', plate_no VARCHAR(16) NOT NULL COMMENT '车牌号', park_id INT NOT NULL COMMENT '车位主键', entry_time DATETIME NOT NULL COMMENT '入场时间', exit_time DATETIME DEFAULT NULL COMMENT '出场时间,NULL表示未出场', fee DECIMAL(8,2) NOT NULL DEFAULT 0 COMMENT '应收金额', pay_status TINYINT NOT NULL DEFAULT 0 COMMENT '0 未结,1 已结', PRIMARY KEY (id), KEY idx_plate (plate_no), KEY idx_exit_time (exit_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='停车记录表';exit_time 允许 NULL 是本表设计里最关键的决定:只要 exit_time 为空,就表示车辆还在场内。查询未出场车辆直接用WHERE exit_time IS NULL,不需要额外的状态字段。pay_status 是"是否结账"标记,但要注意它和 exit_time 在业务里必须保持一致性——一条记录理论上不可能出现"已出场但未结账"的状态,所以代码里动这两个字段时必须放在同一个事务里。
2.3 UserMapper 动态 SQL 在管理端页面的写法
UserMapper 在管理端最常见的场景是按用户名、角色做条件筛选。这类查询用 MyBatis 动态 SQL 处理比 Java 里拼 SQL 稳妥得多:
<select id="selectUserByCondition" resultType="com.parking.entity.User"> SELECT id, username, realname, phone, role, create_time FROM user <where> <if test="username != null and username != ''"> AND username LIKE CONCAT('%', #{username}, '%') </if> <if test="role != null and role != ''"> AND role = #{role} </if> </where> ORDER BY create_time DESC </select><where>标签会自动去掉第一个条件前的 AND,比手写where 1=1更干净,也更难被注入。LIKE CONCAT('%', #{username}, '%')是 MyBatis 里的标准写法,#{username}最终会被编译成预编译参数,而${username}会直接拼进 SQL;前者安全,后者在用户名字段上就存在注入风险。在 SSM 框架里,这个差别值得记牢。
2.4 出场操作的事务边界
停车业务中最容易写出脏数据的是出场流程。结束记录和释放车位必须放在同一事务里:
@Service public class ManageServiceImpl { @Transactional(rollbackFor = Exception.class) public void leaveParking(Integer recordId, Integer parkId, BigDecimal fee) { int rows = recordMapper.finishRecord(recordId, fee); if (rows != 1) { throw new BusinessException("记录已结算或不存在"); } parkingMapper.releasePark(parkId); } }releasePark 对应的 SQL 是带条件的 UPDATE:
UPDATE parking SET status = 0, plate_no = NULL WHERE id = #{parkId} AND status = 1AND status = 1这个条件属于乐观锁思路:如果车位已经空闲,UPDATE 的影响行数是 0,也不会再执行任何后续操作。配合@Transactional,才能保证"记录结单"和"车位释放"要么都成功,要么都失败。
| 设计点 | 常见错误设置 | 建议 |
|---|---|---|
| 状态字段 | status VARCHAR(1) | TINYINT 映射省事 |
| 出场时间 | exit_time NOT NULL DEFAULT 当前时间 | 允许 NULL 标记未出场 |
| 唯一约束 | 只有自增主键 | area + position_no 联合唯一 |
| 模糊查询 | ${username}拼接 | #{username}预编译 |
3. 从 UserController 到 UserServiceImpl:SSM 请求链路上的分层决策
3.1 没有 Service 接口是不是不规范
类列表里只有UserServiceImpl,没有UserService接口。放在十年前,这会被当成分层不规范;但现在的实战里,直接让 Controller 依赖实现类越来越常见。Spring MVC 的注解扫描会把UserServiceImpl注册成 Bean,@Autowired按类型注入时并不强制要求接口存在。差别主要在两点:第一,没有接口就无法用 JDK 动态代理做基于接口的 AOP,需要依赖 CGLIB;但 Spring 默认对类代理也支持,所以不会影响事务功能。第二,如果团队习惯写 mock 做单元测试,有接口更顺手;没有接口时 mock 实现类也行,但要保证被 mock 的方法可覆盖。
@RestController @RequestMapping("/api/user") public class UserController { private final UserServiceImpl userService; @Autowired public UserController(UserServiceImpl userService) { this.userService = userService; } }这里用的是构造器注入,不是常见的@Autowired字段注入。构造器注入的好处是依赖关系固定,后续做测试时可以直接 new UserController(mockService) 而不需要反射改字段;字段注入虽然也能工作,但在 Sonar 规则里通常会报警告。如果你接手的老代码是字段注入,不必急于改造,但新写的 Controller 建议走构造器。
3.2 登录接口的会话落点与密码安全
UserController 里最常用的是登录和注册接口。SSM 项目管理会话最常见的手段是 HttpSession:登录成功后把用户标识写入 Session,后续请求从 Session 里取用户。这里有两个注意点。第一,Session 里只放用户 ID 或轻量对象,不要放整个 user 表实体,否则修改对象属性后,Session 里的值和数据库不同步,排查时会被表里的数据误导。第二,密码在数据库里必须是加盐哈希,比如 bcrypt 或 SHA-256 加盐;登录接口做校验时用加密后的值比对,不直接拿明文操作。
@PostMapping("/login") public Result login(@RequestBody LoginRequest req, HttpSession session) { User user = userService.login(req.getUsername(), req.getPassword()); if (user == null) { return Result.fail("用户名或密码错误"); } session.setAttribute("loginUserId", user.getId()); session.setAttribute("role", user.getRole()); return Result.success(user); }把查库跟比对的过程写在 UserServiceImpl 而不是 Controller 里,是这个分层最重要的原因:Controller 只做参数接收、调用、结果包装,业务判断被隔离在 Service 层,后续单元测试可以直接针对 service 方法断言。
3.3 ManageController 的权限拦截:拦截器路径容易踩的细节
ManageController 和 ManageCashController 是管理员的入口。用 Spring MVC 的 HandlerInterceptor 做权限控制是 SSM 项目里的标准方案:
public class ManagerAuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); Integer role = (Integer) session.getAttribute("role"); if (session != null && Integer.valueOf(1).equals(role)) { return true; } response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } }注意先request.getSession(false)而不是request.getSession(),后者会在没有 Session 时自动创建一个新 Session,导致拦截器内部多一次无意义的 Session 创建。配合 XML 配置:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/api/manager/**"/> <mvc:exclude-mapping path="/api/manager/login"/> <bean class="com.parking.interceptor.ManagerAuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>exclude-mapping是这套配置最容易出错的地方。如果登录接口挂在/api/manager/login,却没有在拦截器里排除,就会出现"登录页被登录拦截器拦下"的死循环,前端拿到 401 还以为是账号问题。这个坑在实际项目里不止一次见过,检查顺序固定在拦截器配置里。
| 分层 | 代表类 | 职责边界 | 事务相关 |
|---|---|---|---|
| 控制层 | UserController | 接收参数、Session、响应包装 | 不处理事务 |
| 业务层 | UserServiceImpl | 登录校验、状态流转、规则 | @Transactional |
| 持久层 | UserMapper | SQL 执行、参数绑定 | 无 |
4. carPlateRecognition 车牌识别:从图像输入到入库的边界设计
4.1 为什么识别模块要放在独立组件里
carPlateRecognition 类出现的位置,决定了一个系统的伸缩性。如果识别逻辑被写在 ManageController 里,那后续一旦更换识别引擎,就要改动路由代码;如果识别逻辑独立成 Bean,Controller 只依赖识别接口,整个升级过程就会变成替换实现类,主流程不受影响。
实际工程里,识别引擎通常是独立服务,常见对接方式有三种:本机调用开源模型、HTTP 调用局域网里的识别服务、调用云服务商的识别 API。停车场这种场景摄像头和识别服务都在内网,第二种方式最普遍。
public interface PlateRecognizer { RecognizeResult recognize(BufferedImage image); } @Component public class HttpPlateRecognizer implements PlateRecognizer { private final String serverUrl = "http://192.168.1.20:8081/plate/recognize"; @Override public RecognizeResult recognize(BufferedImage image) { byte[] jpegData = imageToJpeg(image); String respBody = postImage(serverUrl, jpegData); return parseResult(respBody); } }这里没有贴上传细节,注意力集中在接口抽象上。parseResult 把 HTTP 响应体 JSON 解析成置信度字段和车牌号,识别结果里带一个 plate 和 confidence,而不是只带一个字符串。这样后续不管换识别厂商还是本地模型,Controller 那一层完全感知不到。
4.2 识别结果不能直接入库的三个理由
第一,置信度低于阈值的识别结果不可靠,可能把相似字符互相混淆。第二,月卡车和临时车的判断接口需要提前查用户表,这部分业务逻辑不该在识别组件里做。第三,有人工介入确认的流程时,系统必须保留"待确认"这中间状态,直接写死入库会让人工确认无从下手。
推荐的做法是分层推进:识别模块只负责返回候选集,Service 层做规则判断,Controller 层返回给前端人员确认。
@PostMapping("/api/manager/entry") public Result entry(@RequestBody EntryRequest req) { RecognizeResult plate = recognizeService.recognize(req.getImage()); if (plate.getConfidence() < 0.85) { return Result.failWithCandidates("置信度过低,请人工确认", plate.getCandidates()); } recordService.createRecord(plate.getPlate(), req.getParkId()); return Result.success(); }0.85这个阈值到底设置多少,要结合车场里实际摄像头画面决定,并没有一个通用数字。常见做法是试跑一周,统计误识别率,动态调整,不要迷信某个固定值。
4.3 识别失败时的兜底流程
识别组件再准确也会有漏网的时候。夜间停车光线不足、车牌遮挡、图片模糊,都会导致识别不出或识别成乱码。系统里必须有人工补录入口:
@PostMapping("/api/manager/manual-entry") public Result manualEntry(@RequestBody EntryRequest req) { if (!PlateNumberUtils.isValid(req.getPlateNo())) { return Result.fail("车牌号格式不正确"); } recordService.createRecord(req.getPlateNo(), req.getParkId()); return Result.success(); }补录入口与自动识别入口需要拆开,因为两者校验链路不同:自动识别只需要判断置信度,人工补录则要单独做车牌格式合法性和车位校验。很多项目把入口混用一个接口,导致后面上线新需求时互相影响。
| 边界情况 | 产生原因 | 处理方式 |
|---|---|---|
| confidence < 0.85 | 光照差、遮挡 | 返回候选车牌让管理员选择 |
| 识别不出 | 图片模糊 | 走人工补录入口 |
| 识别成功但用户表未登记 | 临时车辆 | 按临时车流程记录 |
5. ManageCash 出场结算:计费配置、重复结算与流水对账
5.1 计费规则为什么要配置化
ManageCashController 和 ManageCashServiceImpl 管的是出场计费与现金收银。最忌讳的做法是把"首小时免费、后续每小时两元"这种规则直接写死在 Java if 里。停车场调价是运营常态,每次调价都重新部署 Java 服务,运维风险太高。
常见做法是一张配置表,计费时从表里读取规则:
| 配置项 | 默认值 | 说明 |
|---|---|---|
| free_minutes | 30 | 免费停放分钟数,超过才计费 |
| first_hour_fee | 5.00 | 首小时封顶费用 |
| hourly_fee | 2.00 | 超出首小时后每小时费用 |
| daily_cap | 20.00 | 24 小时内最高收费 |
计费算法在 ManageCashServiceImpl 里实现,需要注意首小时与后续小时分开算,而不是按总时长乘以一个单价。封顶逻辑要放在最后:先算原始费用,再跟 daily_cap 比较取最小值,这样算出的费用才不会在跨天场景下失控。
5.2 出口多台管理机器时的重复结算问题
停车场出口可能同时有多台收费终端,同一辆车只要在一台机器上结算即可,但如果两台机器同时点了结算,就会产生重复收费的风险。在 SSM 里对付这个问题,最稳妥的方案不是加 Redis 分布式锁(复杂度高、引入新依赖),而是把结算动作写成一条条件 UPDATE:
UPDATE record SET exit_time = NOW(), fee = #{fee}, pay_status = 1 WHERE id = #{recordId} AND exit_time IS NULL这段 SQL 的巧妙之处在于数据库层面把"查询未结算"和"更新为已结算"合并成一个原子操作。如果同一时刻两台终端同时执行,其中一台会因为 exit_time 变成非 NULL 而影响行数为 0。
@Transactional(rollbackFor = Exception.class) public Receipt settle(Integer recordId) { BigDecimal fee = feeService.calcFee(recordId); int rows = recordMapper.settleRecord(recordId, fee); if (rows != 1) { throw new BusinessException("该订单已结算,请勿重复操作"); } parkingMapper.releasePark(recordId); return buildReceipt(recordId, fee); }这里rows != 1的校验要放在releasePark之前,抛异常后事务回滚才不会释放车位。如果把 releasePark 写在 UPDATE 之前,事务的原子性就会被打破,脏数据就出现了。
5.3 按天统计现金流水
现金结算完成后要沉淀流水报表。常写的一段 SQL 是按日期分组统计订单数和金额:
SELECT DATE(exit_time) AS biz_day, COUNT(id) AS receipt_cnt, SUM(fee) AS total_fee FROM record WHERE pay_status = 1 AND exit_time >= #{startDate} AND exit_time < #{endDate} GROUP BY DATE(exit_time) ORDER BY biz_day由于用了半开区间>= start AND < end,不需要额外处理 day 末尾时间。要注意的是GROUP BY DATE(exit_time)无法使用普通索引,单表数据到几十万条时这个查询会变慢;常见优化办法是落一张日报表缓存,定时任务每天汇总昨日数据,离线的统计查询只读日报表,不再碰 record 主表。
6. 验证重复结算防御是否有效的并发模拟
6.1 用真实接口构造一个"同一辆车两端口同时结算"的场景
出场并发问题在单机手工测试时几乎测不出来,因为手工操作天然串行。要验证结算接口有没有真的挡住重复单,需要先把它放在并发场景里跑。假设本地起了项目,数据库里已经存在一条 id=1 的未结记录和对应占用中的车位,接下来并发打接口:
seq 1 10 | xargs -P 10 -I {} \ curl -s -X POST http://localhost:8080/ssm-parking/api/manager/settle \ -H 'Content-Type: application/json' \ -d '{"recordId": 1}'seq 1 10生成十个序号;xargs -P 10开十个进程,同时发请求;-d里传的是固定的 recordId=1,模拟两台出口终端碰巧同时结算同一条记录。十个请求里应当只有一个返回成功单号,其他返回"该订单已结算,请勿重复操作"。
6.2 检查数据不变量,反向定位问题层
确认后查两张表:
SELECT id, plate_no, exit_time, pay_status FROM record WHERE id = 1; SELECT id, status, plate_no FROM parking WHERE id = 1;期望看到:record 的 exit_time 有值、pay_status = 1,parking 的 status = 0、plate_no 被清空。只要这两张表结果正确,说明条件 UPDATE 和事务边界没有失效。
如果出现 record 已结算但 parking 仍为占用,范围先缩小到业务层:检查生效的是不是同一个 service 方法,是否用了@Transactional(rollbackFor = Exception.class),再检查是否有"同类里自己调自己"的问题。同一个类里方法互相调用,事务注解会失效,因为 Spring 的事务依赖代理对象,内部调用走的不是代理。这个原因在排查并发问题时占比很高,日志里一般也看不出异常,属于 SSM 框架里经典隐性问题。跑完这组并发后把输出收集到同一个文件里,对比成功与失败的数量,就能快速看出"更新行数为 0"的分支是否真正生效。
本文还有配套的精品资源,点击获取