简介:这是一份采用C#语言与Windows Forms框架开发的停车场收费系统完整源码,面向希望学习桌面应用开发的初学者,覆盖车辆入场登记、出场计费、收费记录查询及基础管理功能。压缩包共95个文件,包含.cs源码文件、.resx与.resources界面资源文件、.sql数据库脚本以及.mdf/.ldf数据库文件,总大小仅1.32MB,轻量易用。系统按模块划分清晰,从添加停车、车辆出库、自动计费到收费查询与管理设置,完整呈现了典型业务闭环。目前已有1323人学习,适合通过实际项目理解Winform控件布局、事件驱动编程和SQL Server数据操作。阅读源码可掌握停车场收费流程的设计思路,包括停车时长计算、收费标准配置、数据表关联查询等关键技巧,同时项目内附有运行截图和工程配置文件,便于快速搭建环境并对照验证。这套代码麻雀虽小五脏俱全,是练习C#数据库编程和桌面UI设计的良好范本。
1. 停车场收费系统,为什么用 WinForm 而不是 Web
在我接过的十几个 WinForm 项目里,停车场收费系统是最典型的“业务不复杂、但边界条件特别多”的单机应用。一个车场一天上千次进出,收费算错一次,车主和保安当场就要吵起来。C# 写 WinForm 做这类项目,胜在开发速度快、部署简单——一台收银电脑装个 .NET Framework 就能跑,不用配 IIS、不用折腾前端框架,数据库用 Access 或 SQLite 都行。适合刚入门 C# 的开发者做练手,也适合小停车场、单位内部车场做轻量收费管理。这篇我按自己做过的一套方案,把从界面搭建到计费引擎再到踩坑记录完整拆开讲。
2. 搭界面框架:先用控件属性把值守台做出来,再谈业务
2.1 主窗体骨架与状态栏:WinForm 界面美化从布局开始
很多人上来就找界面美化库,其实停车场这种值守场景,界面最重要的是“信息密度够、操作路径短”。我一般先做主窗体,用一个MenuStrip放功能菜单,StatusStrip显示当前时间、车场剩余车位、操作员姓名,中间用SplitContainer把左侧车辆列表和右侧操作区分开。
新建项目后,主窗体加载逻辑我习惯这样写:
public partial class FrmMain : Form { private readonly System.Windows.Forms.Timer _timer = new System.Windows.Forms.Timer(); private readonly string _operatorName = "admin"; public FrmMain() { InitializeComponent(); InitStatusBar(); } private void InitStatusBar() { _timer.Interval = 1000; _timer.Tick += (s, e) => { toolStripStatusLabelTime.Text = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); }; _timer.Start(); toolStripStatusLabelOperator.Text = $"操作员:{_operatorName}"; } }这段代码做了三件事:启动一个每秒触发一次的Timer刷新状态栏时间,在窗体加载时给操作员标签赋值,然后保持状态栏常驻。这里有个细节:Timer一定要设置Interval,默认是 100 毫秒,如果不设会让 UI 线程频繁刷新,出现界面卡顿的“玄学”问题。
主窗体里推荐用TableLayoutPanel固定操作区位置,避免不同分辨率屏幕下控件乱跑。操作区建议放这几个核心控件:车牌号TextBox、入场/出场两个Button、收费金额Label、收费规则说明GroupBox。每个控件记得改Name属性,我见过太多人用完默认名textBox1、button2,后续写事件处理时根本分不清哪个是哪个。WinForm 控件属性里Name是第一个要养成的习惯。
2.2 ListView 展示车辆记录:列头设置与状态着色
车辆列表我首选ListView,因为 WinForm 原生控件里它显示行数据最直观,也能通过ListViewItem.ForeColor区分不同状态。配合View.Details模式显示列头和滚动条,比DataGridView轻量不少,而且不用额外引 DLL。
private void InitVehicleListView() { listViewVehicles.View = View.Details; listViewVehicles.FullRowSelect = true; listViewVehicles.GridLines = true; listViewVehicles.Columns.Add("车牌号", 140); listViewVehicles.Columns.Add("入场时间", 180); listViewVehicles.Columns.Add("状态", 100); listViewVehicles.Columns.Add("车位编号", 100); } private void RefreshVehicleList(IEnumerable<ParkingRecord> records) { listViewVehicles.BeginUpdate(); listViewVehicles.Items.Clear(); foreach (var record in records) { var item = new ListViewItem(record.PlateNo); item.SubItems.Add(record.EntryTime.ToString("yyyy-MM-dd HH:mm:ss")); item.SubItems.Add(record.Status == 0 ? "在场" : "已出场"); item.SubItems.Add(record.BayNo); if (record.Status == 0) item.ForeColor = Color.Green; else item.ForeColor = Color.Gray; listViewVehicles.Items.Add(item); } listViewVehicles.EndUpdate(); }BeginUpdate和EndUpdate这对方法非常实用,批量刷新 ListView 时如果不包一层,每条 Item 都会触发一次重绘,车辆一多界面就闪。Columns.Add的前两个参数是列标题和列宽,最后一个参数可以传入HorizontalAlignment控制对齐。状态用颜色区分是停车场系统里最直观的做法,绿色表示在场、灰色表示已出场,后续如果要加“超时未缴费”还能用红色标出。
到这里,界面的“壳”已经立住了。下一步要解决数据从哪来、怎么存的问题。
3. 数据层设计:Access 选型、Dapper 封装与三张核心表
3.1 为什么选 Access:单机部署场景下最顺手
停车场收费系统跑在收银电脑上,并发量极低,但要求断电重启后数据不丢。我在这个场景下通常选 Access(.accdb),理由很实际:文件型数据库、无需安装服务、WinForm 通过OleDb直接连,整机重装系统后拷走.accdb文件就是完整备份。SQLite 也可以,但要额外引System.Data.SQLite,在有 Office 环境的 Windows 上 Access 驱动是现成的。不过 Access 有一个明显的短板——并发写入能力弱,所以本文方案里所有写操作走同一把锁,读操作不做缓存,后面避坑章节会专门讲这个问题。
连接字符串直接写死在配置文件里不合适,我用一个静态类统一管理:
public static class DbConfig { // 注意:Provider 版本要和本机 Office 驱动匹配 public static string ConnectionString = @"Provider=Microsoft.ACE.OLEDB.12.0;Data Source=|DataDirectory|ParkingDb.accdb;"; } public static class ParkingDb { public static string ConnStr { get; set; } static ParkingDb() { var baseDir = AppDomain.CurrentDomain.BaseDirectory; var dbFile = Path.Combine(baseDir, "App_Data", "ParkingDb.accdb"); ParkingDb.ConnStr = string.Format( @"Provider=Microsoft.ACE.OLEDB.12.0;Data Source={0};", dbFile); } }|DataDirectory|是 WinForm 项目里常用的占位符,但不显式设置的话默认指向bin\Debug,会导致发布后找不到数据库文件。我一般直接拼BaseDirectory + App_Data目录,并把数据库文件属性设为“如果较新则复制”,这样发布后数据库文件会跟着 exe 走。注意 Access 驱动有两代:老的是Microsoft.Jet.OLEDB.4.0(只能读 .mdb),新的是Microsoft.ACE.OLEDB.12.0(读写 .accdb)。64 位系统装 64 位 Office 时,项目平台目标要选 x64,否则会报“未注册”错误。
3.2 三张核心数据表:停车记录、收费标准、会员表
我建表的原则是“能拆就拆,别把规则硬编码在 C# 里”。停车收费系统的数据表至少要有三张,结构如下:
| 表名 | 字段 | 说明 |
|---|---|---|
| t_ParkingRecord | RecordId (自增主键), PlateNo, EntryTime, ExitTime, Status, Fee, BayNo | 每一辆车的一次进出记录 |
| t_RateRule | RuleId, RateName, FreeMinutes, UnitMinutes, UnitPrice, DailyCap | 收费标准,支持多条并行 |
| t_Member | MemberId, PlateNo, MemberType, ExpireDate | 会员车月租车,出场不收费或打折 |
这里有个关键设计:计费规则不要写死在代码里。如果某天停车场老板说“白天 5 元/小时、夜间 2 元/小时”,改数据库就行,不用重新编译 exe。t_RateRule表里的DailyCap字段表示单日封顶,比如 24 元封顶,超过按 24 元算。
用 Dapper 做数据访问层是 WinForm 项目里很常见的做法,它轻量、性能好,还支持依赖注入和参数化查询:
public List<ParkingRecord> GetAllParkingRecords() { using (var conn = new OleDbConnection(ParkingDb.ConnStr)) { var sql = @"SELECT RecordId, PlateNo, EntryTime, ExitTime, Status, Fee, BayNo FROM t_ParkingRecord ORDER BY EntryTime DESC"; return conn.Query<ParkingRecord>(sql).ToList(); } }Dapper 的Query<T>会自动把表字段映射到类属性,前提是属性名和列名一致。如果列名是下划线风格(比如Plate_No),要加[Column("Plate_No")]特性,否则映射不上。初次上手的人最容易在这里翻车:查询不报错,但返回的列表每项都是默认值。Dapper 在 Access 上用OleDb连接时有一点要注意——不能依赖 Dapper 的DynamicParameters做隐式类型转换,Access 的 OLEDB 对参数类型要求严格,日期参数必须显式指定OleDbType.Date。
3.3 初始化脚本与数据校验
建好表后,我习惯用一个InitDatabase()方法在程序启动时自动建表,而不是手动在 Access 里点来点去。自动建表的 SQL 里要特别注意AUTOINCREMENT的写法,Access 的语法和 SQL Server 不同:
private static void EnsureTablesCreated() { using (var conn = new OleDbConnection(ParkingDb.ConnStr)) { conn.Open(); var createRecordSql = @" CREATE TABLE IF NOT EXISTS t_ParkingRecord ( RecordId AUTOINCREMENT PRIMARY KEY, PlateNo VARCHAR(20) NOT NULL, EntryTime DATETIME NOT NULL, ExitTime DATETIME, Status INTEGER DEFAULT 0, Fee CURRENCY DEFAULT 0, BayNo VARCHAR(10) )"; using (var cmd = new OleDbCommand(createRecordSql, conn)) { cmd.ExecuteNonQuery(); } } }Access 的CREATE TABLE IF NOT EXISTS实际是 OLEDB 驱动的行为,部分版本下要捕获OleDbException判断是“表已存在”错误再忽略,不能完全依赖IF NOT EXISTS。AUTOINCREMENT是 Access 的自增列写法,从 1 开始步长 1。CURRENCY类型对应 C# 的decimal,适合存金额,比DOUBLE更精确。这个初始化方法放在Program.cs的Main入口里,程序启动先建表再弹主窗体,避免首次运行因为缺表而白屏。
4. 计费引擎与车牌识别对接:从入场到出场的完整业务链路
4.1 入场处理:校验车牌、查询会员、写入记录
入场流程逻辑上分四步:车牌号非空校验、车牌格式规范化、查会员判断是否月租车、插入停车记录。车牌识别是另一个子系统,通过摄像头 SDK 回调拿到识别结果后自动填充TextBox,再触发入场按钮。没有摄像头的场景下,值守员手动输入车牌号也能走完整流程。
车牌号截取和格式校验是新手最容易写错的地方。摄像头识别的结果经常带“无车牌”“看不清”之类的噪音,甚至识别成“京A12345”这种少一位的情况。我一般先做一次字符串清洗,再按中国车牌规则做个简单校验:
private string NormalizePlateNo(string raw) { if (string.IsNullOrWhiteSpace(raw)) return string.Empty; // 去掉首尾空格和中间的空白字符 var cleaned = raw.Trim().Replace(" ", ""); // 部分 SDK 会返回“车牌-京A12345”这种带前缀的格式 if (cleaned.Contains("京") || cleaned.Contains("沪")) { var idx = cleaned.IndexOfAny(new[] { '京', '沪', '粤', '苏', '浙' }); if (idx > 0) cleaned = cleaned.Substring(idx); } return cleaned.ToUpperInvariant(); } private bool IsValidPlateNo(string plateNo) { if (string.IsNullOrEmpty(plateNo) || plateNo.Length < 7 || plateNo.Length > 8) return false; // 简易判断:前两位应该是汉字+大写字母,其余是字母或数字 var pattern = @"^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5,6}$"; return Regex.IsMatch(plateNo, pattern); }IndexOfAny是 C# 字符串处理里比较冷门但好用的一组截取方法,适合这种需要找定位字符的场景。正则里的\u4e00-\u9fa5匹配所有汉字,最后5,6位是因为新能源车牌多一位。这套校验不算严格,但足以把明显错误的数据挡在门外。入场按钮点击后的逻辑里,还要查一次会员表,如果该车牌在有效期内,入场记录直接标记为“会员车”,出场时跳过收费计算。
4.2 计费引擎:按入场时间算清每一分钟,跨天规则单独处理
出场计费是整个系统最核心的部分,算法设计上分成三层:基础费率、免费时长、单日封顶。单位时间价格统一用“元/30分钟”或“元/小时”存储,计算时把分钟数换算成计费单元,不足一个单元按一个单元算——这是停车场行业最常见的“向上取整”规则。
public decimal CalculateFee(DateTime entryTime, DateTime exitTime, RateRule rule, ParkingRecord record) { if (record.MemberId.HasValue && record.MemberExpireDate > DateTime.Now) { // 月租车免费出场,但要记录 record.Fee = 0; return 0; } int totalMinutes = (int)(exitTime - entryTime).TotalMinutes; // 免费时长判断 if (totalMinutes <= rule.FreeMinutes) { record.Fee = 0; return 0; } // 先减去免费时长再计费 totalMinutes -= rule.FreeMinutes; // 向上取整到计费单元 int units = (int)Math.Ceiling(totalMinutes / (double)rule.UnitMinutes); decimal fee = units * rule.UnitPrice; // 单日封顶 decimal dailyCap = rule.DailyCap > 0 ? rule.DailyCap : decimal.MaxValue; return Math.Min(fee, dailyCap); }计费引擎有三个坑要提前规避。第一个是Ceiling的精度问题:totalMinutes / UnitMinutes一定要先把其中一个转成double或decimal,否则整数相除会直接丢掉余数。第二个是免费时长的处理顺序:先判断“总时长小于免费时长则不收费”,再“减去免费时长后向上取整”,这两步顺序反了会把免费时长当成额外赠送,算错钱。第三个是跨天问题:晚上 11 点进场、凌晨 2 点出场,如果只算总分钟数,可能触发“夜间费率”和“单日封顶”两个规则,需要拆成两段计费再累加。
拆段计费的逻辑我放在CalculateFee外面,属于“收费规则服务”的一部分。判断方式是检查入场日期和出场日期是否同一天:跨天时从入场时间到当天 24 点按白天的规则算,24 点到出场时间按夜间规则算。这里需要注意DateTime的日期比较:用entryTime.Date != exitTime.Date,而不是entryTime.Day != exitTime.Day,因为跨月时 Day 会重置,用Date属性最稳妥。
4.3 车牌识别 SDK 的回调与 UI 更新
车牌识别相机通常通过 TCP 或 HTTP 回调识别结果。SDK 的回调线程不是 UI 线程,直接往TextBox里赋值会抛跨线程异常。标准做法是先用Invoke或BeginInvoke把操作封送到 UI 线程。如果用的是async/await模式,比BackgroundWorker更简洁:
private async void BtnEntry_Click(object sender, EventArgs e) { string plateNo = NormalizePlateNo(txtPlateNo.Text.Trim()); if (!IsValidPlateNo(plateNo)) { lblStatus.Text = "车牌号格式不正确,请重新输入"; return; } var existing = await Task.Run(() => _parkingService.GetActiveRecordByPlateNo(plateNo)); if (existing != null) { lblStatus.Text = "该车辆已入场,请勿重复操作"; return; } var record = new ParkingRecord { PlateNo = plateNo, EntryTime = DateTime.Now, Status = 0, BayNo = txtBayNo.Text.Trim() }; int newId = await _parkingService.InsertRecordAsync(record); lblStatus.Text = $"入场成功,记录编号 {newId}"; RefreshVehicleList(_parkingService.GetAllParkingRecords()); }async void用在事件处理器里是合法的,因为 WinForms 事件回调签名是void,但方法内部的await Task.Run已经把耗时操作移到了线程池。事件处理器里的异常不会被外面捕获,所以InsertRecordAsync内部要做好 try-catch 并返回错误信息。这里的Invoke方法在小规模 UI 更新时够用,但要注意如果 UI 线程繁忙,Invoke会同步阻塞回调线程,导致车牌识别器继续积压数据、画面掉帧。更稳妥的方式是BeginInvoke异步封送,回调线程不等 UI 处理完就返回。
到这里,入场到出场的链路已经完整了。但项目真正难的不是主流程,是那些“一天只出现几次但每次出现都让人头大”的边界情况。
5. 停车场收费系统避坑记:跨天计费、线程更新与数据库并发
5.1 跨过零点的收费翻车:计费时段拆分是硬需求
我做第一个版本的时候,计费引擎直接拿(exitTime - entryTime).TotalMinutes乘单价,交付后第三天就出事了:一辆车晚上 11 点进场,凌晨 1 点出场,按 5 元/小时的规则收了 15 元,但车主说夜间应该半价。我查了一下,入场时间 23:10,出场时间 01:30,总时长 140 分钟,向上取整到 3 小时,15 元——车主说平时夜间收费 2 元/小时,只该收 5 元。
原因就是没有按时间段拆开计费。24 点前那 50 分钟属于白天的费率,24 点后那 90 分钟属于夜间费率。解决方式是把计费引擎拆成“按每个自然日分块”,每块内再判断所属时段:
public decimal CalculateFeeByDaySegment( DateTime entryTime, DateTime exitTime, RateRule dayRule, RateRule nightRule, TimeSpan nightStart, TimeSpan nightEnd) { decimal totalFee = 0; DateTime cursor = entryTime; while (cursor < exitTime) { DateTime segmentEnd = cursor.Date.AddDays(1); if (segmentEnd > exitTime) segmentEnd = exitTime; totalFee += CalculateSegmentFee(cursor, segmentEnd, dayRule, nightRule, nightStart, nightEnd); cursor = segmentEnd; } return totalFee; }while循环按天切片,每天再交给CalculateSegmentFee判断具体落在白天还是夜间区间。这里的nightStart和nightEnd我建议用TimeSpan配置,比如22:00到06:00,而不是硬编码在方法里。如果你的停车场夜间跨两天(比如晚上 10 点到早上 8 点),这个方案依然适用,因为日切分和时段判断互不干扰。这个坑的教训是:计费规则在数据库里怎么存,代码就怎么读,千万别在 C# 里写“临时判断”。
5.2 跨线程更新 UI 的经典报错:从“闪退”到定位到原因
WinForm 初学者最容易遇到的崩溃就是InvalidOperationException: 线程间操作无效,从不是创建控件的线程访问它。现象是程序跑着跑着,车牌识别相机的回调一进来,界面就崩了,有时还是偶发。
原因在于 WinForm 的控件只能在创建它的线程(UI 线程)上操作,而车牌识别 SDK 的 TCP 接收线程回调里直接执行了txtPlateNo.Text = result。解决方式是统一封装一个UiHelper,所有跨线程控件更新都走这个方法:
public static class UiHelper { public static void SetText(Control control, string text) { if (control.InvokeRequired) { control.BeginInvoke(new Action(() => control.Text = text)); } else { control.Text = text; } } }在回调里调用UiHelper.SetText(txtPlateNo, plateNo),问题就消失了。要注意BeginInvoke是异步的,如果连续快速回调多次,界面可能来不及刷新,极端情况下会看到文本被旧值覆盖。这种情况可以在设置前加一个时间戳比对,只更新最新的识别结果。另外,InvokeRequired在控件句柄未创建时可能返回 false,所以回调方法里建议先判断IsHandleCreated,否则在高并发回调下会偶发空引用。
5.3 Access 并发写入:OleDb 的锁机制让人又爱又恨
Access 有一个非常隐蔽的并发问题:同时打开多个连接,一个线程在写,另一个线程读同一条记录时,会偶发“Locked”异常。现象是系统运行几小时后,某个出场操作突然报错“文件正在使用中”,重启程序又好了。
原因是 Access 的默认锁粒度是整表锁,写入期间所有其他连接都会被阻塞。解决方式有两个方向:第一是全局只维护一个OleDbConnection,所有操作串行执行;第二是为每次操作创建短连接,但遇到锁冲突时重试。我选了第二个方向,因为短连接更贴近后续迁移 SQL Server 的习惯:
public T ExecuteWithRetry<T>(Func<OleDbConnection, T> action, int maxRetry = 3) { int retryCount = 0; while (retryCount < maxRetry) { using (var conn = new OleDbConnection(ParkingDb.ConnStr)) { try { conn.Open(); return action(conn); } catch (OleDbException ex) when (ex.Message.Contains("Locked")) { retryCount++; Thread.Sleep(200 * retryCount); } } } throw new InvalidOperationException("数据库多次锁冲突,请稍后重试"); }重试策略里指数退避的200 * retryCount是个经验值,太小(比如 50ms)的话高并发下会连续撞锁,太大(比如 1s)会让车主等得不耐烦。配合INSERT操作时,最好在事务里显式提交,因为OleDbCommand默认行为在部分驱动下是隐式事务,异常回滚不可控。数据库层面还有个习惯:每天凌晨清理历史记录,只保留最近三个月的出场记录,Access 文件体积太大会让全表扫描越来越慢,这个操作放在程序启动时用DELETE FROM t_ParkingRecord WHERE ExitTime < ?做一次即可。
5.4 车牌识别不到的兜底:手动输入别做成“摆设”
摄像头识别率到不了 100%,最常见的场景是雨雪天、车牌污损、驶入角度偏斜导致识别失败。系统不能因此卡住入口。我的设计是:识别失败时状态栏变红提示,同时要求值守员手动输入车牌。但手动输入也有坑——识别结果和手动输入在同一时刻触发,可能会把刚输入的字符覆盖掉。解决方式是在手动输入获得焦点时,禁用 SDK 回调的自动填充:
private void TxtPlateNo_Enter(object sender, EventArgs e) { _cameraService.EnableAutoFill = false; txtPlateNo.Clear(); } private void TxtPlateNo_Leave(object sender, EventArgs e) { _cameraService.EnableAutoFill = true; }这个“焦点控制自动填充”的做法比“用一个 flag 控制回调”更直观,Enter事件里清空输入框能避免上次车牌残留。还有个小细节:手动输入时不要限制只能输入汉字和字母,因为有些特殊车牌(使馆车、警车)格式不同,正则校验太严格会把人卡死。建议校验放宽到“长度 6~10 且不包含空格”,把严格校验留给计费逻辑,不在输入这一步拦截。
6. 上线前调试技巧:给计价规则做一套内存验证器,比实车测试快十倍
收费标准改一次,就要在出入口一遍遍模拟进出车辆,这个效率太低。我在后期给项目加了一个隐藏的“计费验证器”:在菜单栏加一个FrmFeeSimulator窗体,里面放开始时间、结束时间、费率三列输入,点击计算后直接调用CalculateFeeByDaySegment输出结果,方便快速验证边界条件。
这个验证器的核心代码只有几十行,但价值很大:
private void BtnCalc_Click(object sender, EventArgs e) { var entry = DateTime.ParseExact(txtEntry.Text, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); var exit = DateTime.ParseExact(txtExit.Text, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); var rule = new RateRule { FreeMinutes = int.Parse(txtFree.Text), UnitMinutes = int.Parse(txtUnit.Text), UnitPrice = decimal.Parse(txtPrice.Text), DailyCap = int.Parse(txtCap.Text) }; var fee = _feeEngine.CalculateFeeByDaySegment( entry, exit, new RateRule { /* 白天费率 */ }, new RateRule { /* 夜间费率 */ }, new TimeSpan(22, 0, 0), new TimeSpan(6, 0, 0)); lblResult.Text = $"应收:{fee:F2} 元"; listDebug.Items.Add($"{entry:MM-dd HH:mm} ~ {exit:MM-dd HH:mm} => {fee:F2} 元"); }调试列表listDebug会把每次模拟结果追加在一起,这样连续测试十几组边界数据后,肉眼就能看出哪条规则没覆盖。我把常见的边界用例整理成了一份核对清单:免费时长恰好卡在临界值、跨天跨月、单日封顶触发、月租车过期当天、新能源车牌超长识别。每次上线前按这份清单跑一遍,比在现场拿真车测试要省一个小时的人工。
这个调试器还有一个隐藏好处:它能帮助你在和停车场管理人员沟通收费标准时,直接把数字摆到桌面上验证。对方说“超过一小时按两小时算”?你当场就能输入一组时间点确认规则。说“夜间免费”?那就直接把夜间费率写成 0。定价规则这东西,代码写得再优雅,不如现场验一遍让人放心。我后来在每个 WinForm 项目里都会留一个类似的隐藏调试入口,作为给自己的“后悔药”。
做停车场收费系统,最大的感悟是:技术上不难,难的是把收费规则理解透彻。规则里“不足一小时按一小时算”“免费 15 分钟”“24 小时封顶 30 元”这几句话,翻译成代码时每一步都有边界。我第一版就因为跨天没拆分吃了亏,后来每次写计费引擎,都先花半小时把规则拆成可以验证的用例表再动手。这个顺序千万别颠倒。希望帮到你。
本文还有配套的精品资源,点击获取