简介这是一套基于 ASP.NET 页面开发的值班管理系统源码面向计算机相关专业学生、课程设计与毕业设计开发者也适合需要搭建内部值班安排、上下班登记和查询功能的中小团队。系统围绕值班排班、上下班登记、历史值班查询、部门及用户管理等业务展开采用 Access 数据库保存数据可在相应开发环境中查看与调试。压缩包共 77 个文件约 8.39MB主要由 31 个 aspx 页面、若干脚本样式、图片资源、6 个 dll 业务组件、配置文件及 mdb 数据库文件构成目录涵盖页面展示、脚本样式、数据访问与业务逻辑等层次。已有 494 人学习可用作管理系统课程设计参考、入门练习或二次开发基础帮助读者理解页面层、业务层和数据访问层的基本组织方式并据此梳理值班类系统的功能划分与实现思路。1. 值班表不是Excel的替代品ASP.NET值班管理系统真正要解决的三件事某公司运维组六个人值班表一直挂在一份共享表格里。月底排下个月的班组长手动填三十行遇到请假、调班就在群里喊一声谁看见了谁改。三个月后出了两次事故一次是长假里某天没人被排上一次是交接时上一班说“我发群里了”下一班说“没看到”。这类问题靠责任心解决不了缺的是工具。ASP.NET值班管理系统要干的事就三件把轮班规则变成可配置的数据而不是留在脑子里的默契把交接动作变成带时间戳和签名的记录而不是聊天记录里的口头承诺把班前提醒交给后台任务而不是靠人记得设闹钟。它适合的是几十人规模、有固定轮班节奏、又不值得上大型服务台套件的团队——一个 ASP.NET Core 项目加一个数据库两个人两周能跑出可用版本。下面按“骨架怎么搭、排班怎么算、提醒怎么发、坑在哪”的顺序讲。2. 选型先定死ASP.NET Core 加 EF Core 的四表骨架怎么搭2.1 为什么这个场景用 Razor Pages 而不是 Blazor 或 WebForms值班系统的界面构成很单调排班日历、人员列表、交接单填写、几张统计表。这类“表单加列表加少量报表”的应用Razor Pages 是最省事的选择——一个页面一个 PageModel服务端渲染浏览器拿到就是完整 HTML手机上打开也不会有首屏白屏。有人会推荐 Blazor Server交互确实顺但它依赖长连接值班室那种网络时好时坏的环境里断线重连的体验很糟WebForms 就更不必提了控件拖拽在老项目里还能维护新起项目没必要背这个包袱。真正需要前后端分离的只有一处排班日历上拖拽调班。这块我一般单独开一个小型 API 控制器返回 JSON页面用原生 fetch 调用其余全部走 Razor Pages。分层的划法是DutyRoster.Web页面与 API、DutyRoster.Core实体、规则、服务、DutyRoster.InfrastructureEF Core、消息发送、导出。值班系统的业务逻辑集中在“排班”和“交接”两块把它们放在 Core 里将来换成控制台批处理或者定时任务直接引用同一个 DLL不用复制代码。数据库选择上SQL Server 和 PostgreSQL 都行。团队里如果 Windows Server 是主力选 SQL Server如果部署在 Linux 容器里PostgreSQL 更轻。下面示例按 SQL Server 写换成 PostgreSQL 只需替换 provider 包和连接串实体代码不用动。2.2 用 Code First 建四张核心表人员、班次、排班明细、交接记录值班系统的数据模型别一上来就铺开四张表能撑住 90% 的场景。人员表存谁可以值班、属于哪个组、是否在职班次表存有哪几种班白班、夜班、值班手机每种班的起止时间和需要几个人排班明细是核心一行代表“某天某班某人”调班就是改这张表的 StaffId交接记录挂在排班明细上记录交班人、接班人、交接时间和备注。// Core/Entities/DutyStaff.cs public class DutyStaff { public int Id { get; set; } public string Name { get; set; } string.Empty; public string EmployeeNo { get; set; } string.Empty; public string? Phone { get; set; } // 短信通道用 public string? Email { get; set; } // 邮件通道用 public bool IsActive { get; set; } true; // 离职后置 false历史记录保留 public int GroupId { get; set; } // 分组用于“本组内轮换” }// Core/Entities/DutyShift.cs public class DutyShift { public int Id { get; set; } public string Name { get; set; } default!; // 白班 / 夜班 / 值班手机 public TimeOnly StartTime { get; set; } // 09:00 public TimeOnly EndTime { get; set; } // 21:00 public bool CrossDay { get; set; } // 夜班 21:00-09:00 置 true public int RequiredCount { get; set; } 1; // 该班次每天要几个人 public int SortOrder { get; set; } // 展示顺序 }// Core/Entities/DutyAssignment.cs public enum AssignmentStatus { Planned 0, Swapped 1, Canceled 2 } public class DutyAssignment { public int Id { get; set; } public int ShiftId { get; set; } public int StaffId { get; set; } public DateOnly DutyDate { get; set; } // 归属日跨天班按开始日算 public AssignmentStatus Status { get; set; } AssignmentStatus.Planned; public string? Remark { get; set; } public byte[] RowVersion { get; set; } default!; // 调班并发用 public DutyShift Shift { get; set; } default!; public DutyStaff Staff { get; set; } default!; public HandoverRecord? Handover { get; set; } }// Infrastructure/DutyDbContext.cs public class DutyDbContext : DbContext { public DutyDbContext(DbContextOptionsDutyDbContext options) : base(options) { } public DbSetDutyStaff Staffs SetDutyStaff(); public DbSetDutyShift Shifts SetDutyShift(); public DbSetDutyAssignment Assignments SetDutyAssignment(); public DbSetHandoverRecord Handovers SetHandoverRecord(); protected override void OnModelCreating(ModelBuilder b) { b.EntityDutyAssignment(e { // 同一班次同一人同一天只能有一条防止重复生成 e.HasIndex(x new { x.DutyDate, x.ShiftId, x.StaffId }).IsUnique(); e.Property(x x.RowVersion).IsRowVersion(); e.HasOne(x x.Shift).WithMany().HasForeignKey(x x.ShiftId); e.HasOne(x x.Staff).WithMany().HasForeignKey(x x.StaffId); }); b.EntityHandoverRecord(e { e.HasIndex(x x.AssignmentId).IsUnique(); // 一次排班只有一份交接单 }); } }几个字段值得单独说。DutyDate用DateOnly而不是DateTime是为了彻底躲开“日期加时区”的坑跨天夜班 21:00 到次日 09:00如果存完整时间戳统计“谁这个月值了几个班”时就得按开始时间分组稍不留神把凌晨那半段算到次日去。约定“按开始日归属”之后统计逻辑就变成一句GroupBy(x x.DutyDate.Month)。RowVersion是给调班用的乐观锁两个人同时改同一条排班后提交的那个会拿到并发异常而不是静默覆盖。IsActive用软删除因为历史排班和交接记录要能查到离职人员。2.3 从空目录到能打开排班页的最小命令环境准备只有两步装 SDK装 EF Core 工具。下面这段是完整初始化流程复制到终端就能跑。# 1. 建解决方案与三个项目 dotnet new sln -n DutyRoster dotnet new webapp -n DutyRoster.Web -o src/DutyRoster.Web dotnet new classlib -n DutyRoster.Core -o src/DutyRoster.Core dotnet new classlib -n DutyRoster.Infrastructure -o src/DutyRoster.Infrastructure dotnet sln add src/DutyRoster.Web src/DutyRoster.Core src/DutyRoster.Infrastructure # 2. 引用关系Web 依赖 Core 和 InfrastructureInfrastructure 依赖 Core dotnet add src/DutyRoster.Infrastructure reference src/DutyRoster.Core dotnet add src/DutyRoster.Web reference src/DutyRoster.Core src/DutyRoster.Infrastructure # 3. 装包数据库选 SQL Server dotnet add src/DutyRoster.Infrastructure package Microsoft.EntityFrameworkCore.SqlServer dotnet add src/DutyRoster.Web package Microsoft.EntityFrameworkCore.Design # 4. 建迁移并建库连接串先写进 appsettings.Development.json dotnet tool install --global dotnet-ef dotnet ef migrations add Init \ --project src/DutyRoster.Infrastructure \ --startup-project src/DutyRoster.Web dotnet ef database update \ --project src/DutyRoster.Infrastructure \ --startup-project src/DutyRoster.Web--project指向迁移文件放哪--startup-project告诉工具去哪个项目读连接串和 DI 配置。这两个参数漏一个最常见的报错是“Unable to create DbContext”原因不是代码错是工具在 Web 项目里找不到迁移程序集。连接串里建议加TrustServerCertificateTrue只在开发环境用生产环境老老实实配证书。在Program.cs里注册上下文和页面路由var builder WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); builder.Services.AddDbContextDutyDbContext(opt opt.UseSqlServer(builder.Configuration.GetConnectionString(Duty))); builder.Services.AddScopedRosterGenerator(); // 排班服务第 3 章实现 builder.Services.AddHostedServiceReminderWorker(); // 提醒任务第 4 章实现 var app builder.Build(); app.MapRazorPages(); app.Run();跑起来以后第一件事是种班次数据别在页面里手工加。写个DbInitializer在app.Run()之前调用判断Shifts表是否为空为空就插入“白班 09:00-21:00”“夜班 21:00-09:00 CrossDaytrue”两条。种子数据的意义在于所有排班算法的单元测试都依赖固定班次测试环境每次重建库都能立刻跑不用先点界面。这一步做完打开/Shifts页面能看到两条记录骨架就算立住了。3. 排班引擎把“轮班规则”从 if-else 里赶出去3.1 规则表怎么设计才不用每次改代码最容易被写死的部分就是“谁下一个值班”。如果代码里写staffList[index % staffList.Count]那么只要出现“老张下个月不参加轮班”“新来的两个人插到第二组后面”这类需求就得改代码重新发布。规则必须是数据。我的做法是加两张表RotationRule存一个轮值序列比如“运维组主轮值”RotationMember存这个序列里的人和顺序。规则本身只有三个参数序列、周期起点日期、适用的班次。生成排班时把日期按天展开序列按位置循环取人遇到请假或节假日再走排除逻辑。这样一来换人只改RotationMember的SortOrder调整轮值顺序在界面上拖动就行。// Core/Entities/RotationRule.cs public class RotationRule { public int Id { get; set; } public string Name { get; set; } default!; // 如“运维组主轮值” public int ShiftId { get; set; } // 该规则服务的班次 public DateOnly AnchorDate { get; set; } // 周期起点决定循环从谁开始 public bool SkipWeekend { get; set; } // 周末是否跳过 public bool SkipHoliday { get; set; } // 法定节假日是否跳过 public ListRotationMember Members { get; set; } new(); } public class RotationMember { public int Id { get; set; } public int RotationRuleId { get; set; } public int StaffId { get; set; } public int SortOrder { get; set; } // 序列位置从 0 开始 }AnchorDate这个字段是整套设计的锚。没有它每次重新生成都要从“第一个人”开始历史排班就会和规则脱节有了它任意一天的值班人可以由“该日期与锚点相差的天数 mod 人数”唯一确定幂等性就有了保证——同一天重复生成结果一样不会把排班表搅乱。3.2 生成整月排班表的服务实现生成逻辑分四步取规则、算候选日期、按序列取人、写库前做冲突校验。下面这段是核心服务输入年月返回生成条数。// Core/Services/RosterGenerator.cs public class RosterGenerator { private readonly DutyDbContext _db; public RosterGenerator(DutyDbContext db) _db db; public async Taskint GenerateAsync(int year, int month, CancellationToken ct default) { var rules await _db.SetRotationRule() .Include(r r.Members.OrderBy(m m.SortOrder)) .ToListAsync(ct); var start new DateOnly(year, month, 1); var end start.AddMonths(1).AddDays(-1); var holidays await _db.SetHoliday() .Where(h h.Date start h.Date end) .Select(h h.Date).ToListAsync(ct); var leaves await _db.SetLeaveRequest() .Where(l l.StartDate end l.EndDate start) .Select(l new { l.StaffId, l.StartDate, l.EndDate }).ToListAsync(ct); var created 0; foreach (var rule in rules) { if (rule.Members.Count 0) continue; var exists await _db.Assignments .Where(a a.ShiftId rule.ShiftId a.DutyDate start a.DutyDate end) .Select(a a.DutyDate).ToListAsync(ct); var existSet exists.ToHashSet(); for (var d start; d end; d d.AddDays(1)) { if (existSet.Contains(d)) continue; // 幂等已生成跳过 if (rule.SkipWeekend (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)) continue; if (rule.SkipHoliday holidays.Contains(d)) continue; // 序列指针与锚点相差天数对人数取模 var offset d.DayNumber - rule.AnchorDate.DayNumber; var idx ((offset % rule.Members.Count) rule.Members.Count) % rule.Members.Count; // 顺延候选人在休假就往后找下一个 int picked -1; for (var k 0; k rule.Members.Count; k) { var cand rule.Members[(idx k) % rule.Members.Count].StaffId; var onLeave leaves.Any(l l.StaffId cand d l.StartDate d l.EndDate); if (!onLeave) { picked cand; break; } } if (picked 0) continue; // 全员休假留给人工 _db.Assignments.Add(new DutyAssignment { ShiftId rule.ShiftId, StaffId picked, DutyDate d, Status AssignmentStatus.Planned }); created; } } await _db.SaveChangesAsync(ct); return created; } }几个参数值得盯。offset允许为负生成的月份早于锚点日期所以取模之后要再 Count再% Count否则负数取模会得到负索引直接越界——这是排班代码里最常见的翻车点之一。SkipWeekend和SkipHoliday是两把独立开关实际使用中经常是“主轮值跳周末值班手机不跳”所以别合并成一个字段。顺延查找那层for循环最多转一圈遇到全员休假就放弃把这一天的空缺暴露给人工处理这比硬塞一个人进去要安全。生成完必须校验不能只信算法。校验项至少三条同一人同一天是否被排了两个班尤其跨天夜班和白班可能重叠、连续值班天数是否超过上限比如连续不超过 5 天、每人当月班次数偏差是否在 ±2 以内。校验结果做成一个列表返回给页面红色标出冲突行让排班人自己决定改规则还是改结果。3.3 调班、代班与并发冲突的乐观锁处理调班的操作路径有两种管理员直接改或者员工发起申请由管理员确认。不管哪种最终都是更新DutyAssignment.StaffId。问题在于两个人同时看到“周三夜班是老张”一个改成小李一个改成小王谁后提交谁生效前一个的修改无声消失——这就是典型的丢失更新。解决方式是让 EF Core 的RowVersion参与判断。前端把当前行的版本号放进隐藏域提交时一并带上EF Core 生成的 UPDATE 语句会附WHERE RowVersion old影响行数为 0 就抛DbUpdateConcurrencyException。// Web/Pages/Schedule/Edit.cshtml.cs节选 public async TaskIActionResult OnPostSwapAsync(int id, int newStaffId, byte[] rowVersion) { var item await _db.Assignments.FindAsync(id); if (item is null) return NotFound(); _db.Entry(item).Property(x x.RowVersion).OriginalValue rowVersion; item.StaffId newStaffId; item.Status AssignmentStatus.Swapped; item.Remark ${DateTime.Now:yyyy-MM-dd HH:mm} 由 {User.Identity?.Name} 调班; try { await _db.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { // 冲突时重新加载最新值回显提示用户看清楚了再改 var fresh await _db.Assignments.AsNoTracking().FirstAsync(x x.Id id); TempData[Conflict] $该班次已被改为 {fresh.StaffId}请刷新后重试; return RedirectToPage(/Schedule/Index, new { year fresh.DutyDate.Year, month fresh.DutyDate.Month }); } return RedirectToPage(/Schedule/Index); }OriginalValue这一行是这段代码的关键Find 出来的实体其RowVersion已经被数据库里的最新值填充如果直接改再保存EF 会拿最新版本去比等于没做并发检查。必须显式把客户端提交的旧版本写回OriginalValue比较才有意义。冲突之后的处理也很重要直接把最新数据查回来放进提示里用户知道被谁改了而不是看到一个干巴巴的“操作失败”。调班之后还要做一件事写操作日志。日志表至少记四个字段——谁、什么时候、把哪条排班从谁改成谁。值班出问题复盘时这张日志表是唯一的黑匣子事后追责和排查都靠它。我一般把日志写在同一个事务里SaveChanges之前Add一条避免主表成功日志丢失。4. 提醒与交接后台任务、消息通道和状态机4.1 用 BackgroundService 做班前提醒和去重提醒的逻辑很朴素每天早上扫一遍今天和明天的班找到还没提醒过的发一条消息记一笔发送日志。但朴素逻辑里有两个坑——重复发送和进程重启后漏发。前者靠发送日志的唯一索引挡住后者靠“每次都按时间窗口重新扫”来解决而不是在内存里维护一个已发列表。// Web/Workers/ReminderWorker.cs public class ReminderWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILoggerReminderWorker _logger; public ReminderWorker(IServiceScopeFactory f, ILoggerReminderWorker l) (_scopeFactory, _logger) (f, l); protected override async Task ExecuteAsync(CancellationToken ct) { using var timer new PeriodicTimer(TimeSpan.FromMinutes(10)); while (await timer.WaitForNextTickAsync(ct)) { using var scope _scopeFactory.CreateScope(); var db scope.ServiceProvider.GetRequiredServiceDutyDbContext(); var notifier scope.ServiceProvider.GetRequiredServiceINotifier(); var now DateTime.Now; var horizon now.AddHours(12); // 提前 12 小时提醒 // 只取未提醒且在窗口内的排班 var due await db.Assignments .Include(a a.Shift).Include(a a.Staff) .Where(a a.Status ! AssignmentStatus.Canceled) .Where(a !db.ReminderLogs.Any(r r.AssignmentId a.Id r.Kind PreShift)) .Where(a a.DutyDate.ToDateTime(a.Shift.StartTime) horizon a.DutyDate.ToDateTime(a.Shift.StartTime) now) .Take(200) .ToListAsync(ct); foreach (var a in due) { var ok await notifier.SendAsync(a.Staff.Phone ?? a.Staff.Email ?? , $[值班提醒] {a.DutyDate:MM-dd} {a.Shift.Name} {a.Shift.StartTime:HH:mm}-{a.Shift.EndTime:HH:mm}, ct); db.ReminderLogs.Add(new ReminderLog { AssignmentId a.Id, Kind PreShift, SentAt DateTime.Now, Success ok }); } await db.SaveChangesAsync(ct); } } }IServiceScopeFactory是必须的BackgroundService是单例DbContext是 Scoped单例里直接注入 Scoped 服务会报“Cannot consume scoped service from singleton”。每一轮循环自己开一个 scope用完就释放连接池不会被长时间占住。Take(200)是给单次运行设上限万一某天数据异常量大不至于把进程卡在一个循环里出不来。PeriodicTimer比Task.Delay循环好在不会漂移也不用自己算下一次执行时间。ReminderLogs表上要给(AssignmentId, Kind)建唯一索引。这个索引是防止重复提醒的最后一道防线哪怕两个实例同时跑插入冲突的那条会被数据库拒绝消息最多发一次。4.2 交接班状态机的字段设计与流转校验交接是值班系统里最容易被做烂的部分。很多实现只做一个“已交接”复选框结果想知道“上一班什么时候写的交接单、下一班什么时候确认的”就查不出来。正确做法是把它做成状态机四个状态待接班Planned、已接班OnDuty、已交班HandedOver、已确认Confirmed。每次流转都记录操作人和时间。// Core/Entities/HandoverRecord.cs public class HandoverRecord { public int Id { get; set; } public int AssignmentId { get; set; } public HandoverStatus Status { get; set; } HandoverStatus.Planned; public DateTime? CheckedInAt { get; set; } // 接班打卡时间 public DateTime? HandedOverAt { get; set; } // 交班提交时间 public DateTime? ConfirmedAt { get; set; } // 下一班确认时间 public string? Content { get; set; } // 交接内容遗留问题、告警状态 public string? ConfirmedBy { get; set; } public byte[] RowVersion { get; set; } default!; }流转校验不能靠页面按钮的显示隐藏必须在服务端拦。比如没接班就不能交班没交班就不能确认跨两级跳的状态一律拒绝public static class HandoverRules { public static bool CanTransit(HandoverStatus from, HandoverStatus to) (from, to) switch { (HandoverStatus.Planned, HandoverStatus.OnDuty) true, (HandoverStatus.OnDuty, HandoverStatus.HandedOver) true, (HandoverStatus.HandedOver, HandoverStatus.Confirmed) true, _ false }; }这个switch表达式写法比一长串if清楚得多将来加“退回重写”这种状态只在里面加一行。校验放在 PageModel 的OnPost里先查当前状态再判断不合法就返回带错误提示的原页面不要抛异常——值班员在手机上操作看到 500 错误页只会打电话找你。4.3 消息通道抽象短信、邮件、企业IM 可替换提醒走哪个通道别写死在业务代码里。定义一个接口几个实现DI 里按配置选。短信要钱邮件免费但没人看内部 IM 触达最好但需要接口权限实际部署时往往是“IM 为主短信兜底”所以接口要支持一次注册多个实现。// Core/Abstractions/INotifier.cs public interface INotifier { string Channel { get; } Taskbool SendAsync(string to, string content, CancellationToken ct default); } // Infrastructure/Notifiers/EmailNotifier.cs public class EmailNotifier : INotifier { private readonly SmtpOptions _opt; public string Channel email; public EmailNotifier(IOptionsSmtpOptions opt) _opt opt.Value; public async Taskbool SendAsync(string to, string content, CancellationToken ct default) { if (string.IsNullOrWhiteSpace(to)) return false; using var client new SmtpClient(_opt.Host, _opt.Port) { EnableSsl true }; client.Credentials new NetworkCredential(_opt.User, _opt.Password); using var mail new MailMessage(_opt.From, to, 值班提醒, content); try { await client.SendMailAsync(mail, ct); return true; } catch (Exception) { return false; } // 失败不抛出交给上层写日志重试 } }接口里SendAsync返回bool而不是抛异常是有意为之发送失败不能让整个提醒任务崩掉一条短信发不出去不影响后面九条。失败的结果写进ReminderLogs.Successfalse配一个每日巡检页面把失败记录列出来人工补发。Channel属性的作用是在日志里区分是哪条通道发的排查“为什么他没收到”时一眼能看出是通道问题还是目标地址为空。5. 五个最容易翻车的地方现象、原因和修复手法5.1 跨天夜班的日期算错一天现象夜班 21:00 到次日 09:00统计里这个班被算在了第二天月底工时对不上。原因很多实现用Assignment.CreatedAt或者直接用DateTime.Now参与统计而不是用业务字段。跨天班次如果按结束时间归属凌晨那部分就落到次日了。解决明确“按开始日归属”并在实体上写注释所有统计查询统一用DutyDateDateTime字段只用于记录操作时刻不参与任何业务聚合。同时加一条单元测试构造一个跨年夜的夜班断言它属于 12 月 31 日。5.2 排班页打开要八秒N1 查询拖垮列表现象月度排班页有三十天、每天两个班加载超过五秒。原因页面里对每条排班都访问assignment.Staff.Name和assignment.Shift.Name但查询时只ToList()了主表没Include懒加载关掉之后触发几百次单表查询。解决查询端一次性Include(a a.Staff).Include(a a.Shift)再加上AsNoTracking()只读页面不需要变更跟踪。改完从五秒降到两百毫秒以内。经验是任何列表页只要循环里出现导航属性先检查有没有预加载。5.3 权限只做了菜单接口还能直接调现象普通员工看不到“排班管理”菜单但用浏览器直接访问/Schedule/Edit?idxx依然能改。原因权限校验写在了导航栏的渲染逻辑里页面本身的OnGet没有做检查。解决把权限判断下沉到 PageModel 的构造函数或者过滤器用基于策略的授权[Authorize(Policy CanEditRoster)]菜单隐藏只作为体验优化。凡是在服务端能通过 URL 直达的入口都要有独立的授权判断。5.4 两个人同时调同一个班前一个人的修改消失了现象管理员 A 把周三夜班从老张改成小李几秒后管理员 B 也改了最后只剩 B 的结果A 以为自己点错了。原因更新时没有并发检查后写覆盖前写。解决排班表加RowVersion页面带上隐藏的版本字段提交时把客户端版本赋给OriginalValue捕获DbUpdateConcurrencyException并回显最新值。这条血泪经验的核心是只要有两个人可能改同一行就必须有并发控制别指望“这种情况很少”。5.5 发布到生产环境后连接串读不到现象本地跑得好好的部署到服务器启动就报数据库连接失败。原因开发环境的连接串写在appsettings.Development.json生产环境变量没设回退到默认配置里一个空串。解决生产环境统一用环境变量注入连接串命名遵循ConnectionStrings__Duty这种双下划线形式启动时加一段自检GetConnectionString(Duty)为空就直接抛异常退出别让应用带着坏配置跑起来。带着坏配置启动的后果不是报错而是每个请求都慢吞吞地超时排查起来费时费力。6. 让行政也能改规则权限颗粒度、月底对账和一个保底习惯值班系统上线三个月后最常见的工单不是“功能不好用”而是“帮我把老张从轮值里去掉”。如果每次都要开发改数据这系统就活不长。所以最后一步是把操作权交出去同时给自己留一条后路。权限按策略分三档就够ViewRoster只能看EditRoster能调班和改规则Admin能改人员和班次定义。策略里不写人名写成角色角色在数据库里配。关键点是每个页面独立标[Authorize]而不是靠菜单隐藏——第 5 章那条坑的修复方式。builder.Services.AddAuthorization(o { o.AddPolicy(EditRoster, p p.RequireAssertion(ctx ctx.User.IsInRole(RosterAdmin) || ctx.User.HasClaim(perm, roster.edit))); o.AddPolicy(Admin, p p.RequireRole(SysAdmin)); });月底的对账导出别直接查排班表生成那样每次调格式都要重新跑全量查询。我的做法是先落快照月底点一次“结算”把当月的排班明细、交接确认时间、调班次数写进MonthlySettlement表导出只读快照。好处是历史月份的数据不随人员离职、班次改名而变动行政拿着上月报表和本月报表能对得上。导出用 ClosedXML一个月的量一般不超过两千行内存里拼完直接写流没必要上模板引擎。using var wb new XLWorkbook(); var ws wb.Worksheets.Add(值班工时); ws.Cell(1, 1).Value 日期; ws.Cell(1, 2).Value 班次; ws.Cell(1, 3).Value 值班人; ws.Cell(1, 4).Value 交接确认时间; var rows await _db.MonthlySettlements .Where(s s.Year year s.Month month) .OrderBy(s s.DutyDate).ToListAsync(); for (var i 0; i rows.Count; i) { ws.Cell(i 2, 1).Value rows[i].DutyDate.ToString(yyyy-MM-dd); ws.Cell(i 2, 2).Value rows[i].ShiftName; ws.Cell(i 2, 3).Value rows[i].StaffName; ws.Cell(i 2, 4).Value rows[i].ConfirmedAt?.ToString(MM-dd HH:mm) ?? 未确认; } wb.SaveAs(stream);最后一个习惯也是我踩过最疼的一次正式发布排班前先存一份快照。规则改了、人员调了生成本月排班先在页面上预览校验结果确认没问题再点“发布”发布时把整月排班复制到快照表正式表只读。曾经有一次锚点日期被误改重新生成把上半月的排班全洗了一遍因为没快照只能对着日志一条条手工恢复。有了快照出问题一条 SQL 就能回滚。值班系统不是什么高深技术它的价值全在细节的严谨程度上——规则可配、操作留痕、改动可回滚这三点做到团队就真的敢用。希望帮到你。本文还有配套的精品资源点击获取