停车场管理系统计费引擎设计与实现:跨天切段、月卡与幂等

发布时间:2026/10/31 2:19:32
停车场管理系统计费引擎设计与实现:跨天切段、月卡与幂等

简介:这份面向高校计算机相关专业毕业设计与课程设计场景的停车场管理系统项目包,围绕车位管理、车辆进出识别、费用结算与后台数据处理等典型业务展开,适合需要完成综合项目实践、参考系统架构或进行二次开发的学生与开发者。压缩包共592个文件、约142.93MB,包含89个java源码、184个class编译文件、135个xml配置、66个jar依赖,以及js、html、sql、properties等前端页面、数据库脚本与配置资源,整体覆盖从界面到业务逻辑的较完整工程结构。项目描述涉及车牌识别、用户界面、后台计费、预约车位与异常报警等模块,并体现数据库表设计、接口集成和输入安全等实现思路。借助现有源码与配置,读者可梳理停车管理系统的模块划分、服务层调用关系和数据库交互方式,也可对照class与jar依赖排查运行环境问题。已有2529人学习下载,适合用作毕业设计参考与功能扩展基础。

1. 需求先立住:停车场管理系统的难点不在道闸而在计费

很多人接到「停车场管理系统」这个毕业设计或课程设计题目,第一反应是画一张进出场流程图,配两张表,做一个车牌号增删改查就收工。真正被追问的地方一定在计费:跨零点怎么切段、免费时长内出场再进场算不算新单、月卡在到期当天失效还是次日失效、车牌识别错一位怎么补救。这道题看着是管理系统,本质是一个带时间窗口的计费系统,道闸只是它最外面的一层皮。这套方案面向两类人:一是要在几周内交出一份能跑、能演示、能讲清楚的毕设或课设的同学;二是需要给一个小型车场搭一套可交付后台的开发者。下面按需求、架构、实现、避坑、验收的顺序展开,中间给的建表语句和计费代码可以改改参数直接用。

2. 需求拆解与角色边界:三类使用者与五个必做场景

需求阶段最容易犯的错,是把「停车场管理系统」当成一个单体 CRUD 项目,功能列表一口气列二十条,最后每一条都只做了个空壳。我一般会先砍到五个必做场景,每个场景都能从头走通、都能演示、都能说出预期结果,剩下的功能标注为「扩展」写在文档里。这样做的好处是答辩时被问到任何一个场景,你都能现场跑一遍,而不是翻着 PPT 说「这个功能也做了」。

2.1 三类使用者:权限不是装饰

这套系统里真实存在的角色只有三类,权限划分要落到菜单和接口两个层面,不能只在前端藏按钮。

角色能做什么不能做什么
系统管理员车场与车位维护、计费规则配置、操作员账号、报表导出不能直接修改已结算的停车记录金额
岗亭收费员入场补录、出场结算、收款放行、当班对账不能改计费规则、不能删记录
车主查询自己的停车与缴费记录、月卡续费申请只能看自己车牌的数据

权限表通常用operator、role、permission、role_permission四张表,接口层用注解拦截。这里有个容易忽略的点:收费员和车主看的是同一张parking_record表,但车主端必须按车牌做数据隔离,不能靠前端传车牌参数,否则改个 URL 就能查到别人的记录。我一般会在 Service 层强制从登录态取车牌,把前端传进来的车牌参数直接丢弃。

毕设里很多同学把权限做成一个role字段判断 if-else,两三个角色还行,一旦要加「财务只读」就得改一堆代码。表结构化的成本不高,值得一开始就做对。

2.2 五个必做场景与各自的验收口径

场景一,临时车完整闭环。车辆入场生成一条在场记录并占用一个车位,出场时按规则算出金额,缴费成功后记录置为已出场、车位释放。验收口径是:入场后车位余量减一,出场缴费后余量加一,金额与手算一致。

场景二,月卡车辆免费通行。入场时按车牌查月卡,有效期覆盖入场日期则标记为月卡车,出场直接放行、金额为零。验收口径是:月卡到期日当天入场仍然免费,次日入场按临时车计费。

场景三,免费时长内进出。入场后十五分钟内出场,金额为零,但仍然生成一条完整的进出记录用于统计。验收口径是:记录存在、金额为零、车位正常释放。

场景四,跨天停车。前一天晚上入场、第二天上午出场,费用按自然日切段,每段分别套用当日封顶。验收口径是:总费用等于两段费用之和,且任一段都不超过封顶值。

场景五,异常补救。车牌识别失败时收费员手工补录入场,或者车辆无入场记录直接到出口,系统要走异常处理流程而不是报错。验收口径是:异常记录能被查到、能被人工定价、处理后状态正确。

这五个场景写进需求文档,每个配一张「输入—处理—预期输出」的表,答辩时直接照着表演示,比讲十页架构图有用。

2.3 需求边界:先砍掉哪些功能

明确不做的部分,和做了的部分一样重要。这套方案通常不接真实道闸硬件,用一个GateAdapter接口模拟抬杆和落杆,答辩时点一下按钮打印日志即可;不接真实支付渠道,用模拟支付生成流水号;不做多车场连锁和跨场结算;不做无感支付和车牌识别算法的自研,识别环节用「手动输入 + 模拟识别结果」代替。

这么砍不是偷懒,而是把有限的时间压在计费准确性和数据一致性上。硬件对接的代码量不小,但答辩老师基本不会追问串口协议,反而一定会问「你这笔钱是怎么算出来的」。把边界写进开题报告和系统说明的第一节,是给自己留后路,也是让评审知道你想过范围管理这件事。

3. 总体架构与数据模型:一条从道闸到报表的完整链路

架构不用画得太花,能说清楚「一次出场请求从哪进、经过谁、落到哪张表」就够了。这套系统的链路很短:道闸或岗亭发起请求,应用层做校验和计费,数据层落记录,报表层做聚合。短链路的系统最怕的是层次混乱,比如把计费逻辑写进 Controller,或者让前端算好金额传给后端,后面改规则就得全链路返工。

3.1 分层架构与技术选型:为什么是 Spring Boot 加 Vue

后端的常规组合是 Spring Boot 3 + MyBatis-Plus + MySQL 8,缓存按需加 Redis,前端 Vue 3 + Element Plus,报表用 ECharts。这套组合在毕设里出现的频率高,不是因为它最优,而是因为参考资料多、出错时容易搜到答案。对于一个以事务和 CRUD 为主的系统,生态成熟度比语言优雅度重要得多。

如果团队只有 Python 基础,Flask 或 Django 加 SQLAlchemy 也完全能做完,只是事务边界要自己写清楚,报表聚合最好直接写 SQL 而不是在 ORM 里绕。选型的分歧点不在语言,在有没有现成的权限框架和代码生成器,能省下的那部分时间才是关键。

分层按四层来切。接入层放 PC 管理端、岗亭端和车主 H5;应用层拆成车辆服务、计费服务、车位服务、月卡服务、报表服务;数据层是 MySQL 主库加可选的 Redis 车位缓存;外设适配层单独抽出来,把道闸和识别设备包成接口。适配层这层薄薄的东西,在答辩时是个加分项,因为它证明你想过换硬件怎么办。

/** * 道闸适配器:真实硬件、模拟器、测试桩都实现这个接口 * 上层业务只依赖接口,换硬件不用改业务代码 */ public interface GateAdapter { /** 抬杆,返回是否成功 */ boolean open(String gateCode); /** 落杆 */ boolean close(String gateCode); /** 查询当前杆状态:0落下 1抬起 2故障 */ int status(String gateCode); } /** * 模拟实现:毕设演示用,动作写日志并入库,方便回放 */ @Service public class MockGateAdapter implements GateAdapter { private static final Logger log = LoggerFactory.getLogger(MockGateAdapter.class); @Override public boolean open(String gateCode) { // 演示环境下不接硬件,只记录动作时间和杆号 log.info("gate open, code={}, at={}", gateCode, LocalDateTime.now()); return true; } @Override public boolean close(String gateCode) { log.info("gate close, code={}", gateCode); return true; } @Override public int status(String gateCode) { return 0; } }

接口里三个方法的职责分别是动作下发、动作下发和状态查询,gateCode对应物理杆编号,通常一个车场有入口杆和出口杆两个。业务层调用open之后要判断返回值再决定是否更新记录状态,不能假定硬件一定成功,这一步在演示时故意让适配器返回 false,就能讲清楚异常分支。

3.2 数据表设计:八张核心表与字段取舍

表不在多,在于每张表都有一个明确的写入方。下面这几张是绕不开的。

-- 车场 CREATE TABLE parking_lot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, lot_name VARCHAR(64) NOT NULL, total_space INT NOT NULL DEFAULT 0 COMMENT '总车位数,冗余字段', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车场'; -- 车位 CREATE TABLE parking_space ( id BIGINT PRIMARY KEY AUTO_INCREMENT, lot_id BIGINT NOT NULL, space_no VARCHAR(16) NOT NULL COMMENT '如 A-001', zone VARCHAR(16) NOT NULL DEFAULT 'A', space_type TINYINT NOT NULL DEFAULT 1 COMMENT '1普通 2充电 3无障碍', status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1占用 2锁定 3维修', UNIQUE KEY uk_lot_space (lot_id, space_no), KEY idx_lot_status (lot_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车位'; -- 停车记录,全系统最核心的一张表 CREATE TABLE parking_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, lot_id BIGINT NOT NULL, plate_no VARCHAR(16) NOT NULL COMMENT '车牌,统一大写去空格', space_id BIGINT DEFAULT NULL, card_type TINYINT NOT NULL DEFAULT 1 COMMENT '1临时车 2月卡 3内部免费', entry_time DATETIME NOT NULL, exit_time DATETIME DEFAULT NULL, entry_gate VARCHAR(16) DEFAULT NULL, exit_gate VARCHAR(16) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0在场 1待缴费 2已出场 3异常', amount DECIMAL(10,2) DEFAULT NULL, -- 生成列:只有在场和待缴费的记录参与唯一约束,出场后可重复入场 active_plate VARCHAR(16) GENERATED ALWAYS AS ( IF(status IN (0, 1), plate_no, NULL) ) STORED, UNIQUE KEY uk_active_plate (active_plate), KEY idx_plate_time (plate_no, entry_time), KEY idx_lot_status (lot_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='停车记录'; -- 计费规则,按时间段生效,改规则不覆盖历史 CREATE TABLE billing_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, lot_id BIGINT NOT NULL, rule_name VARCHAR(64) NOT NULL, free_minutes INT NOT NULL DEFAULT 15, unit_minutes INT NOT NULL DEFAULT 60 COMMENT '计费单位,分钟', unit_price DECIMAL(10,2) NOT NULL DEFAULT 5.00, day_cap DECIMAL(10,2) DEFAULT 40.00 COMMENT '当日封顶,NULL 表示不封顶', effective_from DATETIME NOT NULL, effective_to DATETIME DEFAULT NULL, enabled TINYINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费规则'; -- 月卡 CREATE TABLE monthly_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, lot_id BIGINT NOT NULL, plate_no VARCHAR(16) NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL COMMENT '含当天', status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0停用', KEY idx_plate_lot (lot_id, plate_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月卡'; -- 支付流水 CREATE TABLE payment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, record_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_method TINYINT NOT NULL DEFAULT 1 COMMENT '1现金 2扫码 3余额', pay_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已退款', paid_at DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_record (record_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付单';

parking_record里那个active_plate生成列是整张表的关键设计。车辆在场期间,同一车牌不允许有第二条在场记录;出场之后status变成 2,生成列变 NULL,唯一索引不再约束,同一辆车可以再次入场。没有这个约束,双击入场按钮就会造出两条记录,出场时按车牌查出来两条,代码直接抛异常。

billing_rule用生效区间而不是直接改字段,是为了让历史订单能复盘。规则改了以后,老记录仍能找到当时用的那一条,答辩时被问「你这单为什么收 12 块」,你能把规则快照翻出来。

3.3 状态流转:一条停车记录的一生

停车记录的状态只有四个:在场、待缴费、已出场、异常。入场写 0,出场计算完金额写 1,收款成功写 2,识别失败或人工介入写 3。车位状态三个:空闲、占用、维修。两者要联动,占用时车位改 1,出场时改回 0,且必须在一个事务里。

状态流转的常见错误是允许跳跃,比如从 0 直接改到 2 而跳过 1,导致这笔钱没有支付流水。通常的做法是在 Service 层写一个transition(from, to)校验方法,只允许相邻状态迁移,非法迁移直接抛业务异常。这个东西代码量不到三十行,但能把后面一半的对账问题挡在门外。

4. 核心业务实现:计费引擎、车位分配与月卡校验

前面两章把边界和表定下来,真正花时间的部分在这里。计费和入场出场是整个系统里唯二需要认真对待并发和精度的地方,其余模块基本是标准 CRUD。

4.1 计费引擎:规则表驱动,而不是 if-else 堆叠

最常见的写法是把「首小时 X 元、之后每小时 Y 元、封顶 Z 元」直接写成一串 if-else。规则一改就得改代码、重新部署,而且没有办法回答「这个金额怎么来的」。我一般用规则表加分段计算,把参数外置,代码只负责切段和累加。

/** * 停车费用计算 * @param record 停车记录,必须已设置 exitTime 或在此方法内取当前时间 * @param rule 本次结算适用的计费规则(按入场时间匹配生效区间) * @return 应收金额,保留两位小数 */ public BigDecimal calculate(ParkingRecord record, BillingRule rule) { LocalDateTime in = record.getEntryTime(); LocalDateTime out = record.getExitTime() != null ? record.getExitTime() : LocalDateTime.now(); long totalMinutes = Duration.between(in, out).toMinutes(); // 免费时长:注意用 <=,恰好卡在边界上按免费处理,避免用户投诉 if (totalMinutes <= rule.getFreeMinutes()) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); } // 按自然日切段,跨零点的区间分别套用当日封顶 List<TimeSlice> slices = splitByNaturalDay(in, out); BigDecimal total = BigDecimal.ZERO; for (TimeSlice slice : slices) { long minutes = Duration.between(slice.getStart(), slice.getEnd()).toMinutes(); // 向上取整到计费单位,停 61 分钟按 2 个单位算 long units = (minutes + rule.getUnitMinutes() - 1) / rule.getUnitMinutes(); BigDecimal fee = rule.getUnitPrice() .multiply(BigDecimal.valueOf(units)); // 当日封顶,day_cap 为空表示不封顶 if (rule.getDayCap() != null && fee.compareTo(rule.getDayCap()) > 0) { fee = rule.getDayCap(); } total = total.add(fee); } return total.setScale(2, RoundingMode.HALF_UP); } /** 按自然日切分,零点为界;同一天返回一段 */ private List<TimeSlice> splitByNaturalDay(LocalDateTime in, LocalDateTime out) { List<TimeSlice> list = new ArrayList<>(); LocalDateTime cursor = in; while (cursor.toLocalDate().isBefore(out.toLocalDate())) { LocalDateTime midnight = cursor.toLocalDate().plusDays(1).atStartOfDay(); list.add(new TimeSlice(cursor, midnight)); cursor = midnight; } list.add(new TimeSlice(cursor, out)); return list; }

totalMinutes用toMinutes()取整,秒级差异被抹掉,避免出现 59 分 59 秒被算成一小时的情况。units那段是向上取整的标准写法,unitMinutes默认 60、unitPrice默认 5 元,改这两个参数就能把「每小时 5 元」变成「每半小时 3 元」。

splitByNaturalDay用isBefore判断日期而不是比对时间戳,跨年跨月都不会出问题。day_cap为 NULL 时跳过封顶,这个分支必须有,否则规则表里填一个空值就会抛空指针。

免费时长的边界处理值得多说一句。用<=而不是<,是因为现场最容易吵起来的就是「我明明 15 分钟出来的」。宁可少收一笔,也不要在演示现场被卡住。

4.2 入场与出场:事务边界与幂等处理

入场做三件事:查车牌是否已有在场记录、分配车位、写记录并把车位置为占用。三步必须在一个事务里,车位分配用条件更新防超卖。

@Transactional(rollbackFor = Exception.class) public Long entry(String plateNo, Long lotId, String gateCode) { String plate = normalize(plateNo); // 去空格、转大写 // 1. 幂等:已有在场记录直接返回原记录号 ParkingRecord exist = recordMapper.selectActiveByPlate(lotId, plate); if (exist != null) { return exist.getId(); } // 2. 抢车位:条件更新保证只有一个请求能成功 ParkingSpace space = spaceMapper.selectFreeOne(lotId); if (space == null) { throw new BizException("车位已满"); } int affected = spaceMapper.occupy(space.getId()); if (affected == 0) { throw new BizException("车位已被占用,请重试"); } // 3. 写记录,card_type 在入场时就确定,避免出场时规则已变 ParkingRecord record = new ParkingRecord(); record.setLotId(lotId); record.setPlateNo(plate); record.setSpaceId(space.getId()); record.setCardType(resolveCardType(lotId, plate)); record.setEntryTime(LocalDateTime.now()); record.setEntryGate(gateCode); record.setStatus(0); recordMapper.insert(record); return record.getId(); }

selectActiveByPlate依赖前面那个唯一索引兜底,即使并发绕过查询,插入时也会被索引挡住,捕获DuplicateKeyException转成业务异常即可。occupy的 SQL 是UPDATE parking_space SET status = 1 WHERE id = ? AND status = 0,靠影响行数判断是否抢到,比先查后改稳得多。

card_type在入场时定,这一点很多人会忽略。如果出场时才判断是不是月卡,一辆车在停车期间月卡刚好过期,按出场时间算就要收费,按入场时间算免费。业务上通常以入场时刻的身份为准,写进入场记录,出场直接读字段,既符合直觉也避免了重复查询。

出场比入场多一步金额计算和状态迁移,同样要放在事务里,并且先对记录加行锁,防止两次点击同时结算。

@Transactional(rollbackFor = Exception.class) public ExitResult exit(Long recordId, Integer payMethod) { // 行锁住记录,第二个请求会阻塞到第一个提交后再读,读到 status=2 直接返回 ParkingRecord record = recordMapper.selectByIdForUpdate(recordId); if (record == null) { throw new BizException("记录不存在"); } if (record.getStatus() == 2) { return ExitResult.alreadyExited(record); } if (record.getStatus() == 3) { throw new BizException("异常记录需人工处理"); } record.setExitTime(LocalDateTime.now()); BigDecimal amount = record.getCardType() == 2 ? BigDecimal.ZERO : billingService.calculate(record, ruleService.match(record.getEntryTime())); // 金额为零直接放行,否则先生成待支付单 if (amount.compareTo(BigDecimal.ZERO) > 0) { paymentService.createOrder(record.getId(), amount, payMethod); record.setStatus(1); recordMapper.updateById(record); return ExitResult.needPay(amount); } record.setStatus(2); record.setAmount(BigDecimal.ZERO); recordMapper.updateById(record); spaceMapper.release(record.getSpaceId()); gateAdapter.open(record.getExitGate()); return ExitResult.free(); }

月卡车的金额直接置零而不是走计费再减免,是为了让报表里「月卡通行」和「临时车免费时段」能分开统计。这两类零元订单在运营口径上完全是两回事。

4.3 月卡与免费时长校验:时间窗口怎么写才不出错

月卡用DATE类型存起止日期,end_date含当天。判断逻辑是拿入场时间的日期去比,而不是拿当前时间比:

-- 判断某车牌在指定时刻是否持有有效月卡 SELECT COUNT(1) FROM monthly_card WHERE lot_id = #{lotId} AND plate_no = #{plateNo} AND status = 1 AND #{at} >= start_date AND #{at} <= end_date;

at传的是入场时刻的LocalDate。写成<= end_date而不是< end_date,因为用户理解的「到 3 月 31 日」包含 31 号全天。如果存成DATETIME并把结束时间写成2024-03-31 00:00:00,31 号白天进来的车就会被判成过期,这是踩过不止一次的坑。

免费时长的校验点在计费方法内部,不在入场逻辑里,所以「免费时段内进出」不需要特殊处理,正常出场走一遍计费返回零即可。唯一要注意的是免费车也要释放车位、也要写完整记录,不能因为金额为零就跳过状态更新,否则车位会被永久占住。

5. 避坑与排查:五类最容易在答辩现场翻车的问题

下面这几条不是我凭空想的,是这类系统在联调和演示阶段反复出现的故障。每条按现象、原因、解决写,看的时候可以对照自己的代码过一遍。

5.1 金额算出来差几分钱

现象是同一笔停车记录,本地算出来 12.00,演示机器上算出来 11.99,或者两笔金额相加比手算多一分。原因基本是两个:用double存金额,或者每次计算单独做四舍五入再累加。

解决办法是把所有金额字段改成DECIMAL(10,2),Java 侧统一用BigDecimal,构造时用字符串而不是浮点数。累加过程不做舍入,只在最终结果上setScale(2, RoundingMode.HALF_UP)一次。如果确实需要分段舍入,就在切段时明确写下舍入规则并写进文档,答辩时能解释清楚。

5.2 点两次放行,扣了两次钱

现象是收费员手快点了两下「确认放行」,生成两笔支付单,对账时多出一笔。原因是没有幂等控制,请求进来就无条件走一遍结算。

解决办法分三层:数据库层给payment_order.record_id加唯一索引(同一记录只允许一笔已支付单);Service 层用SELECT ... FOR UPDATE锁住记录,判断状态已是已出场就直接返回;前端在请求发出后立即置灰按钮。三层里数据库那层最可靠,因为它是最后一道闸。

5.3 车位显示还有空位,实际已经停满

现象是首页显示剩余 3 个车位,结果连续三辆车入场后系统提示「车位已满」,或者反过来分配了同一个车位给两辆车。原因是剩余数量用「总数减在场记录数」实时算,而分配车位用的是「先查空闲再更新」。

解决办法是分配时用条件更新UPDATE ... WHERE id = ? AND status = 0,判断影响行数;展示用的剩余数量允许有秒级延迟,不必强求实时一致。如果一定要实时,就把统计结果放 Redis 并设置短过期,别每次都扫全表。

5.4 跨天订单的封顶算错了

现象是一辆车停了两天,按封顶规则应该收 80,系统算出 60 或者 100。原因是切段时按 24 小时切而不是按自然日零点切,或者封顶套在了总价上而不是每一段上。

解决办法是切段逻辑严格以零点为界,封顶值在每段内各自生效、段间求和。写完之后拿三个边界用例验一遍:23:50 入场次日 00:10 出场、连续停满 48 小时、跨月末入场。这三个过了,基本不会再出问题。

5.5 时间字段在不同环境对不上

现象是本地跑出来入场时间是 10:00,部署到另一台机器变成 02:00,或者日志时间和数据库时间差八小时。原因是连接串没指定时区,或者代码里混用了Date和LocalDateTime。

解决办法是统一用LocalDateTime表达业务时间,连接串显式带上时区参数,数据库列用DATETIME而不是TIMESTAMP。展示层做时区转换,存储层不转换。这套约定写进项目 README,团队里所有人照着来,能省掉大量排查时间。

6. 验收与答辩:用一份可复现的测试清单证明系统能用

到了这一步,代码写完了,剩下的事是证明它真的能用。我一般会准备一份测试清单,每条都写清楚输入、操作和预期输出,答辩现场按顺序跑,跑完一页勾一页。清单不用长,十几条覆盖住核心链路就够。

编号场景输入预期输出
T01临时车正常出场入场后 90 分钟出场金额等于 2 个计费单位,车位释放
T02免费时长内出场入场后 10 分钟出场金额 0,记录完整,车位释放
T03恰好卡在免费边界入场后 15 分 00 秒出场金额 0
T04跨零点停车23:50 入,次日 00:10 出按两段计算,各段不超封顶
T05连续停满 48 小时两天整总额等于两日封顶之和
T06月卡有效期内到期日当天入场金额 0,标记为月卡通行
T07月卡过期次日到期日次日入场按临时车计费
T08重复入场同一车牌连续两次入场请求第二次返回原记录,不新增
T09重复放行同一记录两次出场第二次返回已出场,不新增订单
T10车位并发剩余 1 个车位,两辆车同时入场一成一败,无超卖
T11无入场记录出场直接请求出场进入异常流程,可人工定价
T12权限隔离车主 A 查询车主 B 的记录返回空或拒绝

跑这张表的顺序有讲究:先跑 T01 到 T05 证明计费对,再跑 T06、T07 证明月卡对,最后跑 T08 到 T12 证明边界稳。计费是评委最关心的,放在最前面,第一分钟就建立信任。

答辩演示还有个小技巧:故意制造一次异常。比如在 T11 里当场演示无入场记录的车怎么补录、怎么定价、怎么放行,比演示十次正常流程更能说明你考虑过现实情况。评委提问往往集中在异常分支,你主动演一遍,问题就被引导到你准备好的地方。

另外,把计费规则的参数做成可配置的,现场改一个单价再跑一遍 T01,金额随之变化,这个动作的说服力比任何架构图都强。它直接回答了「如果车场改价你怎么办」这个必问题。

从那以后我每次做这类项目,都会在写第一行业务代码之前先把这张测试表填出来,哪条写不出预期输出,就说明需求还没想清楚。希望帮到你。

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