C#宿舍门禁管理系统开发实战:从串口通信到EF Core数据建模
发布时间:2026/9/14 6:02:43 作者:尧图编辑部 阅读量:1,286

简介宿舍门禁管理系统是面向学生宿舍场景的C#桌面应用适合高校学生、开发人员以及正在做课程设计或毕业设计的人群参考。资源以完整项目源码为核心围绕AccessControl技术实现刷卡开门、身份验证、权限分级、实时监控、异常报警和报表统计等功能业务流程清晰。包内共85个文件以cs源码、png界面截图、resx资源文件、doc/docx设计文档为主同时包含exe可执行程序、dll依赖库、sqlite数据库等整体仅6.5MB目录结构规整便于按模块定位学习。已有474人学习下载。除可直接运行的编译程序外还附带需求分析、功能设计、用户界面设计文档及实验手册可辅助理解从数据库表设计、界面交互到刷卡逻辑的完整实现链路适合用于二次开发和功能扩展。1. C# 宿舍门禁管理系统到底在管什么C# 宿舍门禁管理系统表面上是“刷卡开门”实际要解决的是三个问题门外的人是不是这栋楼的学生、这个时间点允不允许他进、他进出之后能不能被管理员查到。宿舍场景跟写字楼门禁最大的差别在于它不只关心“能不能进”还要关心“进来之后谁负责”——晚归、请假外出、管理员手动放行每一条都要落在记录上。常见做法是 WinForm 或 WPF 做管理端串口接读卡器SQL Server 或 SQLite 存数据读卡器只负责输出卡号判断逻辑全部由 C# 程序完成。对刚接触 C# 的开发者来说这是一个把面向对象、事件、委托、数据库全串起来的完整练手项目对做过的熟手来说真正的难点反而不是设备通信而是重复刷卡、断电重连、晚归规则这些状态一致性问题。这篇文章按数据模型、设备通信、通行决策、排错验证的顺序把一套宿舍门禁系统从建表讲到能稳定跑起来。2. 宿舍门禁管理系统的架构拆分与 C# 数据模型设计2.1 门禁系统的三层职责设备接入、业务规则与事件存储宿舍门禁和常见的 C# 上位机项目一样天然适合拆成三层。设备接入层负责跟读卡器、电锁、门磁打交道业务规则层负责判断“这张卡能不能开这个门”事件存储层负责把每一次刷卡、每一次手动开门都落库。很多从 C# 入门项目走过来的人会把这三级混在一起在DataReceived事件里直接拼 SQL先把权限查出来再写日志最后控制继电器。这个写法在小规模测试时没问题但一旦宿舍楼里有十几栋楼、几十个门点规则变更或者读卡器型号更换就会牵一发动全身。所以我的习惯是设备层只负责做一件事把读卡器上传的字节流解析成卡号字符串然后交给业务层。业务层拿到卡号后查到对应的学生、对应的门点规则返回允许或拒绝。事件存储层不关心开没开门它只负责把这次判定结果、卡号、时间、门点、处理人全部写进日志表。这样分层之后换读卡器只需要改设备层调规则只需要改业务层做报表只需要查日志表互不干扰。从 C# 面向对象的角度看这三层对应三个核心类CardReaderService负责串口通信AccessController负责决策AccessLogRepository负责持久化。后面的代码都是围绕这三个类展开的写的时候时刻提醒自己一个方法只干一件事。这是 C# 入门到进阶最常见的分水岭代码能跑只是第一步改得动才是第二步。2.2 用 EF Core 建门禁核心表学生、卡片、门点与通行记录数据模型是整个系统的地基。宿舍门禁的表设计比一般业务系统多一个关键点卡片和学生不是一一绑死的。学生补卡、换卡是常态如果直接在 Student 表上加一个 CardNo 字段换一次卡就要 update 一次学生记录历史刷卡记录对不上旧卡审计就乱了。所以卡片要单独建表学生和卡片是 1:N 关系但同一时刻只有一张卡处于生效状态。下面是我常用的建表 SQLSQL Server 和 SQLite 都能跑差异只在自增列的写法CREATE TABLE Student ( StudentId INT IDENTITY(1,1) PRIMARY KEY, StudentNo NVARCHAR(20) NOT NULL UNIQUE, Name NVARCHAR(50) NOT NULL, Building NVARCHAR(20), RoomNo NVARCHAR(20), Status TINYINT DEFAULT 1, -- 1 在籍 0 离校 CreatedAt DATETIME2 DEFAULT GETDATE() ); CREATE TABLE Card ( CardId INT IDENTITY(1,1) PRIMARY KEY, StudentId INT FOREIGN KEY REFERENCES Student(StudentId), CardNo NVARCHAR(20) NOT NULL UNIQUE, IsActive BIT DEFAULT 1, IssuedAt DATETIME2 DEFAULT GETDATE(), RevokedAt DATETIME2 NULL ); CREATE TABLE DoorPoint ( DoorId INT IDENTITY(1,1) PRIMARY KEY, Building NVARCHAR(20), DoorName NVARCHAR(50), DevicePort NVARCHAR(10), -- 串口号如 COM3 IsEnabled BIT DEFAULT 1 ); CREATE TABLE AccessLog ( LogId BIGINT IDENTITY(1,1) PRIMARY KEY, StudentId INT NULL, CardNo NVARCHAR(20), DoorId INT, AccessTime DATETIME2 DEFAULT GETDATE(), Result TINYINT, -- 1 允许 2 拒绝 3 手动放行 Reason NVARCHAR(100) ); CREATE INDEX IX_AccessLog_Time ON AccessLog(AccessTime); CREATE INDEX IX_AccessLog_Student ON AccessLog(StudentId, AccessTime);这张表设计里最容易忽略的是 AccessLog 的索引。宿舍门禁高峰期集中在早上上课前和晚上下课后几分钟内就有几千条记录写入查询又都是“某栋楼某段时间的通告记录”没有(DoorId, AccessTime)的复合索引数据量过十万后分页查询会明显变慢。日志表只建主键是新手最常见的坑C# 里用 EF Core 跑久了就会发现写入没问题慢全慢在报表查询上。对应的 EF Core 实体类可以直接用 Fluent API 配关联关键是不要在所有实体之间互相导航到晕头。我一般只保留 Student - Cards、Card - AccessLogs、DoorPoint - AccessLogs 这三个方向的导航属性反向需要时用查询现查省掉很多配对问题。2.3 门禁查询的 C# 写法按时间、按人、按门点统计模拟测试阶段一天生成不了多少数据但真实宿舍一个月下来轻松破十万条。所以查询接口从一开始就要养成两个习惯只查需要的列、只加载需要的关联。下面这段是“查某栋楼某天所有拒绝记录”的 EF Core 写法直接绑定到 WinForm 的 DataGridView 上就能用public ListAccessLogView GetRejectedLogs(string building, DateTime day) { var start day.Date; var end start.AddDays(1); return _context.AccessLogs .Where(a a.Result 2 a.Door.DoorName.Contains(building) a.AccessTime start a.AccessTime end) .Select(a new AccessLogView { StudentName a.Student ! null ? a.Student.Name : 未知, StudentNo a.Student ! null ? a.Student.StudentNo : , CardNo a.CardNo, DoorName a.Door.DoorName, AccessTime a.AccessTime, Reason a.Reason }) .OrderByDescending(a a.AccessTime) .ToList(); }这里有两个细节。第一时间范围用 start end而不是AccessTime.Date day因为Date属性在 SQL 转换时通常会被翻译成CAST索引会失效。第二Select投影成AccessLogView而不是直接返回实体避免把学生、门点这些关联对象整个拉回来C# 写 Web API 或者上位机时这个习惯都能省不少内存。查询场景推荐索引说明按时间范围查IX_AccessLog_Time查进出记录、晚归名单按学生查历史IX_AccessLog_Student查个人轨迹按门点时间查复合索引 (DoorId, AccessTime)查某门某时段3. 读卡器通信与刷卡通行的 C# 实现3.1 宿舍门禁常见的读卡器与通信方式宿舍门禁里最常用的读卡器分两类韦根Wiegand接口和 RS485 接口。韦根 26/34 是门禁行业的老标准读卡器到控制板之间只有 DATA0、DATA1 两根线传输距离短C# 程序一般接触不到韦根原始电平因为中间还有一块门禁控制器负责把韦根信号转成串口或网络数据。RS485 则更常被直接用在上位机方案里一个串口可以挂多台设备每台设备有独立地址C# 通过SerialPort类就能收发。如果你是自己从零做一套宿舍门禁而不是采购整套门禁控制器我更推荐走 RS485 加标准读卡器芯片的方案。原因很简单C# 的SerialPort类开箱即用不需要额外驱动RS485 半双工通信协议简单帧格式可以自己控制一台工控机通过 USB 转 485 线就能带几十个门点。缺点是轮询模式下实时性不如韦根直连控制板但宿舍门禁对开门延时的要求是几百毫秒完全是够用的。顺便说一句这是典型的 C# 上位机开发场景和工业上接传感器、读仪表是同一套技术栈。3.2 C# SerialPort 接收卡号的实现与卡号解析串口通信的第一坑是参数不匹配。读卡器模块说明书上会写波特率、数据位、校验位最常见的组合是 9600, 8, N, 1。写代码时不要凭感觉猜先用串口调试助手手动发一条命令确认能收到返回再开始写 C# 程序。下面是接收并解析卡号的完整实现public sealed class CardReaderService : IDisposable { private SerialPort _port; public event Actionstring CardReceived; public CardReaderService(string portName, int baudRate 9600) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.DataReceived OnDataReceived; _port.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int bytesToRead _port.BytesToRead; byte[] buffer new byte[bytesToRead]; _port.Read(buffer, 0, bytesToRead); // 不同厂家的卡号帧格式不一样这里以常见的 4 字节卡号 校验字节为例 if (buffer.Length 5) return; // 取中间 4 字节作为卡号注意字节序 uint cardNo BitConverter.ToUInt32(buffer, 1); cardNo (cardNo 0xFF000000) 24 | (cardNo 0x00FF0000) 8 | (cardNo 0x0000FF00) 8 | (cardNo 0x000000FF) 24; CardReceived?.Invoke(cardNo.ToString(D10)); } public void Dispose() { if (_port ! null _port.IsOpen) { _port.DataReceived - OnDataReceived; _port.Close(); _port.Dispose(); } } }这段代码有三个关键点。第一DataReceived事件在 .NET 的串口实现里是后台线程触发的所以在事件里不能直接操作 WinForm 控件要通过BeginInvoke或者同步上下文切回 UI 线程否则会抛跨线程异常。第二BitConverter.ToUInt32拿到的字节序取决于设备协议很多国产读卡器帧头占一位数据是低位在前直接ToUInt32会得到反的卡号。上面的位运算统一转成大端序是 C# 读卡号最常见的修正手段。第三事件触发后要立即Read把数据从串口缓冲取走否则BytesToRead会一直有值事件会反复触发。设备地址和多门点轮询的处理方式也不难每台设备发“读卡号”命令时带上地址字节返回数据也是地址 卡号 校验的结构程序按地址分发到不同的门点即可。如果读卡器是走 TCP 而不是串口的核心逻辑完全一样只是把SerialPort换成Socket用NetworkStream做读写这部分原理和串口是一套。3.3 通行判定核心逻辑鉴权、写日志与继电器控制卡号解析出来之后就进入业务层的核心方法。宿舍门禁的判定逻辑通常是这样对着门禁日志能查到学生就允许查不到先别急着拒绝还要判断是不是手动放行。允许之后才执行继电器动作同时把日志写进 AccessLog。public AccessDecision Decide(string cardNo, int doorId) { var now DateTime.Now; // 1. 查卡片是否有效且学生是否在籍 var activeCard _context.Cards .FirstOrDefault(c c.CardNo cardNo c.IsActive); if (activeCard null) { return Reject(cardNo, doorId, 未找到生效卡片); } var student _context.Students .FirstOrDefault(s s.StudentId activeCard.StudentId s.Status 1); if (student null) { return Reject(cardNo, doorId, 学生已离校); } // 2. 查门点是否启用 var door _context.DoorPoints.FirstOrDefault(d d.DoorId doorId d.IsEnabled); if (door null) { return Reject(cardNo, doorId, 门点未启用); } // 3. 查规则如晚归限制这里 RuleService 在下一章展开 if (!_ruleService.IsAllowed(student, door, now)) { return Reject(cardNo, doorId, 当前时段不允许通行); } return Allow(cardNo, doorId, student, 正常刷卡); }Allow和Reject是两个私有方法统一做两件事调用继电器控制服务开门或不开门然后写日志。这里强烈建议把“开门”和“写日志”放在同一个事务边界内或者至少保证日志先写成功再开门。门禁系统有一个隐藏需求每一次拒绝也要入库。后面查“谁在半夜试图刷卡”时拒绝记录比允许记录更有价值这也是宿舍安全和写字楼门禁的明显差别。4. 门禁规则、晚归处理与并发去重的 C# 落地4.1 宿舍场景的通行规则时段、白名单与黑名单宿舍门禁的通行规则比普通办公楼复杂的地方在于时间语义。办公楼门禁通常按“上班时间”和“下班时间”一刀切宿舍却是 24 小时分段的早上 6:00 到 8:30 集中出楼中午 11:30 到 14:00 自由进出晚上 22:00 后进入“晚归模式”23:30 后只能由管理员远程开门。这些规则不是写死在一个if else里而是应该做成可配置的数据。我一般用一张 Rule 表和一张 RuleDetail 表来实现。Rule 定义规则适用范围比如“3 号楼女生层”RuleDetail 定义每一天的时段。规则的判定逻辑放到RuleService里核心代码是这样的public bool IsAllowed(Student student, DoorPoint door, DateTime time) { var rules _context.Rules .Where(r r.Building student.Building || r.Building *) .ToList(); foreach (var rule in rules) { // 时间窗口判断周一1 ... 周日7 var detail rule.Details .FirstOrDefault(d d.DayOfWeek (int)time.DayOfWeek); if (detail null) continue; if (time.TimeOfDay detail.StartTime time.TimeOfDay detail.EndTime) { return rule.Action 1; // 1 允许 0 拒绝 } } // 默认拒绝宿舍门禁的安全策略是白名单制 return false; }这段代码代表了门禁系统的一个关键设计思路默认拒绝而不是默认允许。宿舍门禁和很多 C# 上位机项目不同它的用户群体固定也就没必要做“除了黑名单都能进”的开放策略。白名单制的额外好处是忘记配规则的门点默认是锁死的不会因为漏配导致半夜大门敞开。4.2 用 C# 处理晚归记录与管理员手动开门晚归是宿舍门禁特有的需求。常见做法是晚归时段内刷卡系统允许开门但要在 AccessLog 里打上标记同时通过 WinForm 界面弹出提醒或者写入一张单独的 LateReturn 表方便辅导员第二天导出名单。下面是晚归记录的核心逻辑public void ProcessLateReturn(Student student, DoorPoint door, DateTime time) { var late new LateReturn { StudentId student.StudentId, DoorId door.DoorId, LateTime time, IsNotified false }; // 写入日志和晚归表 _context.LateReturns.Add(late); var log new AccessLog { StudentId student.StudentId, CardNo student.Cards.First(c c.IsActive).CardNo, DoorId door.DoorId, AccessTime time, Result 1, Reason 晚归已记录 }; _context.AccessLogs.Add(log); _context.SaveChanges(); // 触发 UI 通知用事件或者 SignalR 推送 LateReturnCreated?.Invoke(late); }晚归判定不要放在Decide方法内部而是作为独立服务调用原因很简单晚归不只是“让不让他进门”这件事它还会牵扯到后续的通知、报表、手动复核。把它独立出来Decide方法保持单一职责后续要发推送、同步到企业微信或钉钉都只需要在ProcessLateReturn里加代码不影响主流程。管理员手动开门在 C# 里实现起来很直白界面按钮调用ManualOpen(studentId, doorId, operatorName)写入一条Result 3的日志。注意手动开门也要走日志不能只在界面上弹出“开门成功”就完事不然出问题后无法追责。我见过不少系统在这里偷懒等到宿舍丢东西查监控时才发现手动开门记录是空的那就麻烦了。4.3 并发重复刷卡与日志写入的防抖策略宿舍门禁最具迷惑性的 bug 是重复刷卡。学生站在门前刷了一下卡没听到蜂鸣器响又刷了一下或者同一张卡在门禁上一刷机房网线抖动导致程序重启重启后日志表里出现两条记录。物理世界的事件和网络传输都不是可靠的但门禁日志必须可靠。解决重复刷卡我使用的是内存级别的窗口去重配合数据库唯一约束双保险。内存去重用ConcurrentDictionary实现窗口时间一般设 5 秒public class CardAntiRebounce { private readonly ConcurrentDictionarystring, DateTime _lastSeen new ConcurrentDictionarystring, DateTime(); private readonly TimeSpan _window TimeSpan.FromSeconds(5); public bool IsDuplicate(string cardNo) { var now DateTime.Now; if (_lastSeen.TryGetValue(cardNo, out var lastTime) now - lastTime _window) { return true; } _lastSeen[cardNo] now; return false; } }内存去重解决的是“同一进程内的重复事件”它的问题在程序重启后会失效。所以要再上一道数据库约束AccessLog 表加一个(CardNo, AccessTime)的唯一约束或者把 DeduplicationKey 作为一个生成的列这样即使进程重启后重复写入数据库也会拒绝第二条。C# 里捕获DbUpdateException后看一眼InnerException是不是唯一键冲突是就直接吞掉不阻塞主流程。这个方法对其他 C# 上位机场景同样适用比如读卡器轮询时一条命令被发了两次或者传感器数据上报了两遍。防抖不是门禁的专属需求任何“对外部设备事件做响应”的系统都该有一层防抖。5. 门禁系统联调排错串口模拟、日志诊断与字节序验证读到卡号解析那节最容易遇到的一个现象是串口调试助手发数据能收到上位机程序里就是收不到或者收到的卡号跟卡面上印的不一样。这两类问题几乎都和串口事件线程、帧格式、字节序有关不是看代码能看出来的必须靠验证手段一步步定位。我的做法是写一个极简的 Console 诊断程序把串口收到的每个字节按十六进制原样打印出来dotnet run -- --port COM3 --baud 9600using System; using System.IO.Ports; class Program { static void Main(string[] args) { var port new SerialPort(args[0], int.Parse(args[1])); port.DataReceived (s, e) { var sp (SerialPort)s; int count sp.BytesToRead; byte[] buf new byte[count]; sp.Read(buf, 0, count); var hex BitConverter.ToString(buf); Console.WriteLine(${DateTime.Now:HH:mm:ss.fff} {hex}); }; port.Open(); Console.WriteLine(监听中按任意键退出...); Console.ReadKey(); } }把读卡器接到串口上刷一次卡看打印出来的十六进制的长度和帧头帧尾。这个动作能一次性排除三件事串口参数是否匹配、上位机程序是否真的收到了数据、卡号的字节序到底对不对。十六进制里前几个字节如果是AA 55之类的固定帧头说明协议正常如果打印出来全是乱码多半是波特率不对如果能收到但数字对不上再按帧格式调整字节序。这个诊断工具在任何 C# 上位机调试里都值得保留小到门禁大到读温度传感器、接 PLC都能先看原始字节再谈业务逻辑。联调时的最后一个技巧是不要只测“能开门”的路。把卡停用、把学生设为离校、把门点禁用、在晚归时段刷卡每一条都刷一遍确认拒绝记录都写进了日志。门禁系统出安全事故十次有九次不是设备坏了而是某个应该拒绝的路径没有被覆盖。把拒绝和允许同样重视单例跑通之后这套系统才算真正能交出去。本文还有配套的精品资源点击获取