news 2026/9/11 15:36:57

SSM停车场项目实战:从分层设计到并发结算

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM停车场项目实战:从分层设计到并发结算

简介:这套基于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文件比源码清单更能说明真实设计:UserControllerUserServiceImplUserMapper是三件套的典型排布,RecordParking对应停车业务的两张主表,ManageCashControllerManageCashServiceImpl把现金结算单独拆成一条线,carPlateRecognition作为独立组件出现,说明车牌识别没有污染主业务。这套系统解决的其实是停车场里三件最麻烦的事——用户身份、车位状态、出场收费。对学过 Spring MVC 和 MyBatis 基础、想通过完整 ssm java 项目把分层边界和事务写扎实的人来说,它比单纯的功能 demo 更接近真实工程:至少开始考虑模块边界、状态字段设计与重复结算的并发问题。

2. 从实体反推持久层:Parking、Record 与 UserMapper 的表设计

2.1 为什么先看实体再定表结构

一个 SSM 项目拿到手时,先扫 entity 或 model 包。ParkingRecord本身对应两张表: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 = 1

AND 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
持久层UserMapperSQL 执行、参数绑定

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_minutes30免费停放分钟数,超过才计费
first_hour_fee5.00首小时封顶费用
hourly_fee2.00超出首小时后每小时费用
daily_cap20.0024 小时内最高收费

计费算法在 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"的分支是否真正生效。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 15:31:33

PCA点云法向量估计:原理、参数调优与工程实现

简介&#xff1a;面向三维点云处理与几何计算场景&#xff0c;这份资源以主成分分析方法为主线&#xff0c;帮助开发者与学习者解决点云主方向提取和表面法向量计算问题。压缩包内仅包含一个Python源文件&#xff0c;包体大小只有2KB&#xff0c;代码紧凑却覆盖了点云数据读取、…

作者头像 李华
网站建设 2026/9/11 15:31:30

网络安全——SQL注入漏洞

一、SQL注入概述 1、SQL注入漏洞攻击者**利用Web应用程序对用户输入验证上的疏忽&#xff0c;在输入的数据中包含对某些数据库系统有特殊意义的符号或命令**&#xff0c;让攻击者有机会直接对后台数据库系统下达指令&#xff0c;进而实现对后台数据库乃至整个应用系统的入侵。2…

作者头像 李华
网站建设 2026/9/11 15:30:36

医院室内定位技术选型:蓝牙AoA、UWB、Wi-Fi与RFID对比解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 15:29:25

ADAS域控RCP与HIL协同验证实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华