首页 / 资讯中心 / 文章详情

C#人事管理系统数据库课程设计:ER图、事务与薪资存储过程实战

C#人事管理系统数据库课程设计:ER图、事务与薪资存储过程实战 ★ FEATURED ARTICLE
简介这是一套面向高校数据库课程设计场景的人事管理系统完整项目采用C#与Windows Forms构建桌面界面并接入关系型数据库覆盖员工档案、部门信息、考勤记录、薪资计算、登录注册与密码修改等常见人事业务适合正在完成课程设计或练习C#桌面开发的学生对照学习。压缩包共91个文件约4.1MB以cs源代码、resx资源与resources图片、exe与dll运行文件、config配置为主另含mdf与ldf数据库文件、docx文档及sln解决方案导入数据库后即可运行无需从零搭建。包内还附有数据库课程设计报告与系统运行说明书便于梳理需求分析、数据库模型、系统架构与操作流程源码中登录、菜单、员工、部门、薪资、考勤等模块划分清晰可作为理解界面与数据交互的实践案例。目前已有5724人学习下载。1. 人事管理系统课程设计为什么我建议先画 ER 图再打开 Visual Studio「人事管理系统——数据库课程设计」这个题目很多人的第一步是新建一个 WinForms 项目然后拖一个带菜单的主界面两天后却卡在一句「考勤记录怎么跟工资对上」。带过几轮课程设计之后我发现真正的分界点不在窗体做得多漂亮而在表结构定得是否合理。课程设计要交的不只是一个能跑的 C# 完整数据库 人事管理系统还得有 ER 图、数据字典、约束与索引的说明以及能当场演示的事务和存储过程。因此这套方案反过来做先把部门、岗位、员工、考勤、薪资、账号、权限这七类数据的边界划清再讨论 C# 那一层怎么写。它适合正在做数据库课程设计、需要一份能照着搭也能照着讲的同学也适合想补一套完整 CRUD增删改查加事务案例的开发者。2. 需求与数据建模七个实体、四类角色和一张迟到就会崩的考勤表建模这一步最容易偷懒照着脑海里的印象随手建五张表等到写薪资统计时才发现考勤没有唯一的日期约束同一个人同一天能插入三条记录后面的统计结果就对不上了。这一章把需求、实体和字段约束一次性定死后面写代码时基本不用再回头改表。2.1 四类角色与六条业务线先划清楚谁能改什么角色决定权限表权限表决定界面能看到什么这条链路要一开始就想明白。人事专员负责员工档案的增删改和考勤录入部门主管只能看本部门的员工和考勤不能改薪资财务只能看薪资表并执行月度核算看不到员工的身份证号系统管理员管账号、角色和操作日志不参与业务数据。业务线一共六条入职、调岗、离职、考勤、薪资核算、账号授权。每条业务线要回答三个问题——谁发起、动哪几张表、失败时要不要回滚。比如「调岗」要同时改员工表的部门与岗位并往岗位变动履历表里插一条记录这两步必须放在同一个事务里而「考勤录入」是单表批量插入失败重导即可不需要事务包住几千行。把这张「角色 × 业务线 × 涉及表」的对照表先列出来后面写存储过程和界面菜单时就是照着填不会出现财务点进了员工档案这种低级漏洞。2.2 ER 图落地成七张核心表实体关系不复杂但关系基数要标清楚部门与员工是一对多部门与部门是自关联的一对多支持两级或三级组织结构员工与岗位是多对一员工与考勤是一对多员工与薪资是一对多按月一条用户与角色是多对一角色与权限是多对多。核心表清单如下字段数量按课程设计的体量控制既能体现设计能力又不至于写不完表名作用关键字段预估数据量Department部门树DeptID、ParentID、ManagerID20 行左右Position岗位与岗位基本工资PositionID、BaseSalary15 行左右Employee员工主档EmpNo、DeptID、PositionID、Status200 行左右Attendance日考勤EmpID、WorkDate、AttStatus5000 行左右Salary月度薪资EmpID、SalaryMonth、NetPay1000 行左右SysUser / Role / Permission账号与权限UserID、RoleID、PermCode各几十行OperationLog操作审计UserID、OpTime、OpDesc不限数据量这一列别小看答辩时老师常问「你这套设计支撑多大数据量、索引建在哪」有个量级估计才答得上来。两百人规模的公司考勤表一年五万行左右这个量级下普通 B 树索引足够不需要考虑分区。2.3 字段类型与约束把脏数据挡在数据库门口界面校验可以被绕过数据库约束不会。下面这段建表脚本是整份方案的地基重点看 UNIQUE、CHECK 和外键这三类约束怎么用-- 部门表自关联支持多级组织部门名唯一 CREATE TABLE Department ( DeptID INT IDENTITY(1,1) PRIMARY KEY, DeptName NVARCHAR(50) NOT NULL, ParentID INT NULL, ManagerID INT NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT UQ_Dept_Name UNIQUE (DeptName), CONSTRAINT FK_Dept_Parent FOREIGN KEY (ParentID) REFERENCES Department(DeptID) ); -- 员工表工号唯一状态用编码性别用 CHECK 限死 CREATE TABLE Employee ( EmpID INT IDENTITY(1,1) PRIMARY KEY, EmpNo CHAR(8) NOT NULL, EmpName NVARCHAR(30) NOT NULL, Gender CHAR(1) NOT NULL, IdCard CHAR(18) NULL, DeptID INT NOT NULL, PositionID INT NOT NULL, HireDate DATE NOT NULL, LeaveDate DATE NULL, Status TINYINT NOT NULL DEFAULT 1, -- 1在职 2试用 3离职 Phone VARCHAR(20) NULL, CONSTRAINT UQ_Emp_No UNIQUE (EmpNo), CONSTRAINT CK_Emp_Gender CHECK (Gender IN (M,F)), CONSTRAINT CK_Emp_Status CHECK (Status IN (1,2,3)), CONSTRAINT FK_Emp_Dept FOREIGN KEY (DeptID) REFERENCES Department(DeptID), CONSTRAINT FK_Emp_Pos FOREIGN KEY (PositionID) REFERENCES Position(PositionID) ); -- 考勤表一个人一天只能有一条这条唯一约束是后面所有统计的前提 CREATE TABLE Attendance ( AttID BIGINT IDENTITY(1,1) PRIMARY KEY, EmpID INT NOT NULL, WorkDate DATE NOT NULL, CheckIn TIME(0) NULL, CheckOut TIME(0) NULL, AttStatus TINYINT NOT NULL DEFAULT 1, -- 1正常 2迟到 3早退 4缺卡 5请假 6加班 Remark NVARCHAR(100) NULL, CONSTRAINT UQ_Att_Emp_Date UNIQUE (EmpID, WorkDate), CONSTRAINT FK_Att_Emp FOREIGN KEY (EmpID) REFERENCES Employee(EmpID) );几个选择值得说明。工号用CHAR(8)而不是自增整数因为工号是业务主键对外出现的是它自增 ID 只在表之间做外键。日期和时间拆成DATE加TIME(0)比直接存DATETIME更省空间也让「按天去重」的唯一约束写起来更自然。状态字段统一用TINYINT编码而不是中文字符串一是省空间二是建索引和写 WHERE 条件时不会有排序规则差异带来的隐性性能问题。金额字段一律用DECIMAL(10,2)绝不用FLOAT。这是个血泪经验浮点数做累加几百条薪资算下来末尾会飘出0.009999这种值四舍五入之后总和跟明细对不上答辩现场被追问一句就说不清了。索引方面主键自带聚集索引UQ_Emp_No和UQ_Att_Emp_Date这两个唯一约束会自动生成唯一非聚集索引正好覆盖「按工号查人」和「按人加日期查考勤」两个最高频查询。薪资表再加一个(SalaryMonth, EmpID)的复合索引月度核算和报表都走它。2.4 三范式与反范式的取舍哪里该冗余讲得出理由就行课程设计一定会被问范式。规范化的做法是员工表不存部门名称只存 DeptID部门名通过 JOIN 拿考勤表不存员工姓名只存 EmpID。这样消除了传递依赖改一次部门名不用更新几百行员工记录。但有两处我建议故意反范式。第一处是薪资表里的NetPay实发合计它等于基本工资加奖金减扣款减社保减个税严格按第三范式不该存每次查询实时算就行。可月度薪资一旦封存就不允许再变实发金额必须能在几年后原样读出来所以这里要把结果落成一个字段同时在薪资表上加IsLocked标记锁定后禁止任何修改。第二处是考勤表里的AttStatus理论上该由打卡时间推导但「请假」「加班」这类状态是人工申请出来的机器推不出来所以必须显式存。字典表也算一个小加分项AttStatus的 1 到 6 单独建一张DictItem表存编码和中文名界面的下拉框直接绑这张表比在代码里写一串switch case干净得多将来加一个「外勤」状态只用插一行数据。3. 总体架构与选型C# 三层结构配 ADO.NET把 SQL 能力留在明面上选型这部分课程设计和真实项目的要求是不一样的。真实项目优先开发效率ORM 一把梭没问题课程设计的评分点里往往写着「熟练使用 SQL 语句、视图、存储过程、事务」这时候用 ORM 把 SQL 全藏起来等于把自己的加分项埋了。3.1 为什么是 C# 加 WinForms 加 ADO.NET界面层选 WinForms 而不是 WPF理由很实在课程设计周期通常两三周WinForms 的 DataGridView 直接绑 DataTable 就能出表格增删改查界面两小时一个WPF 的 MVVM 绑定和数据模板在这个体量下属于过度设计。数据访问选 ADO.NET 原生方式。SqlConnection、SqlCommand、SqlDataReader这套 API 写出来的代码每一行都对应一条明确的 SQL老师一眼能看到你的 SQL 水平而且参数化查询、事务、批量复制这些细节都在自己手里出问题好定位。ORM 生成的 SQL 是黑匣子执行计划看不懂的时候调优就无从下手。数据库选 SQL Server是因为它和 C# 同属一套生态SqlClient驱动开箱即用SSMS 里能直接调试存储过程、看执行计划课设环境里装起来也省事。换 MySQL 也能做但自增列写法、TOP与LIMIT、存储过程语法都要改一遍没必要给自己找麻烦。3.2 三层结构的目录与职责边界工程分四个项目或四个文件夹HR.Model放实体类HR.DAL放数据访问HR.BLL放业务规则HR.UI放窗体。依赖方向是单向的UI 只调 BLLBLL 只调 DALDAL 只认 Model。职责边界要卡死几条UI 层不允许出现任何 SQL 字符串也不允许出现SqlConnectionBLL 层负责业务判断比如「离职员工不允许再生成薪资」「薪资月份不能是未来月份」DAL 层只做增删改查不做判断。这条约定看着形式主义实际作用是答辩时你能清楚地说出「这段逻辑放在 BLL 是因为它属于业务规则而不是数据访问」而不是所有代码堆在窗体按钮的 Click 事件里。典型的一个方法调用链是这样的窗体上一个「月度核算」按钮调用SalaryBLL.CalcMonth(2025-06)BLL 先校验月份格式和是否已封存再调SalaryDAL.CalcByProcedure(2025-06)DAL 里执行存储过程并返回受影响行数BLL 把行数返回给界面弹提示。整条链上没有一处 SQL 泄露到上层。3.3 连接字符串与配置文件连接串写在App.config里不要硬编码在代码里改数据库实例时不用重新编译configuration connectionStrings !-- Data Source 换成自己的实例名Initial Catalog 是数据库名 -- add nameHrDb connectionStringData Source.\SQLEXPRESS;Initial CatalogHrDb;Integrated SecurityTrue;TrustServerCertificateTrue providerNameSystem.Data.SqlClient / /connectionStrings /configurationIntegrated SecurityTrue表示用 Windows 身份验证本地课设环境最省事不用管账号密码。如果要求必须用 SQL 账号登录就换成User IDxxx;Passwordxxx同时记得在答辩前确认好这个账号只有db_datareader和db_datawriter权限不给db_owner这一点在「安全性设计」那一节能讲。3.4 SqlHelper参数化查询与事务重载所有 DAL 方法都走同一个帮助类避免每个方法重复写 open/closepublic static class SqlHelper { private static readonly string ConnStr ConfigurationManager.ConnectionStrings[HrDb].ConnectionString; // 通用增删改参数必须用 SqlParameter 传杜绝拼接字符串 public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(sql, conn)) { if (ps ! null ps.Length 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } // 事务重载由调用方创建事务多条命令共享同一个连接 public static int ExecuteNonQuery(SqlConnection conn, SqlTransaction tran, string sql, params SqlParameter[] ps) { using (var cmd new SqlCommand(sql, conn, tran)) { if (ps ! null ps.Length 0) cmd.Parameters.AddRange(ps); return cmd.ExecuteNonQuery(); } } // 查询返回 DataTable直接绑 DataGridView public static DataTable Query(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(ConnStr)) using (var da new SqlDataAdapter(sql, conn)) { if (ps ! null ps.Length 0) da.SelectCommand.Parameters.AddRange(ps); var dt new DataTable(); da.Fill(dt); return dt; } } }三个方法的分工要说清楚。第一个方法自带连接生命周期适合单条独立语句比如插入一条考勤。第二个方法接收外部传入的连接和事务专供跨多条语句的原子操作连接的开闭和事务的提交回滚由调用方负责Helper 不插手。第三个方法用SqlDataAdapter填DataTable好处是断开式数据访问窗体关掉连接也就断了不用手动管理游标。参数化这件事要强调一句SELECT * FROM Employee WHERE EmpName txtName.Text 这种写法除了有注入风险还有个更常见的坑——员工姓名里带个英文单引号比如某些少数民族姓名转写语句直接语法错误。参数化两个问题一起解决。4. 核心模块落地入职调岗事务、考勤批量导入与月度薪资存储过程前面两章定的是图纸这一章是砌墙。三个模块选得很有代表性调岗考事务的原子性考勤导入考幂等和批量性能薪资核算考存储过程和视图的组合能力。这三个写扎实了课程设计的核心分值基本就拿到了。4.1 员工调岗一个必须用事务的场景调岗要动两张表往岗位变动履历表插一条记录保留原部门、原岗位然后更新员工表的当前部门和岗位。如果更新成功而履历插入失败员工的岗位变了但查不到变动记录这种数据不一致在答辩演示时最难解释。先看表结构履历表要能回答「谁在什么时候从哪到哪」CREATE TABLE JobHistory ( HistoryID BIGINT IDENTITY(1,1) PRIMARY KEY, EmpID INT NOT NULL, OldDeptID INT NULL, NewDeptID INT NOT NULL, OldPositionID INT NULL, NewPositionID INT NOT NULL, ChangeDate DATETIME NOT NULL DEFAULT GETDATE(), Operator NVARCHAR(30) NULL, Reason NVARCHAR(200) NULL, CONSTRAINT FK_History_Emp FOREIGN KEY (EmpID) REFERENCES Employee(EmpID) );C# 侧的写法注意事务对象必须同时传给两条命令public bool TransferEmployee(int empId, int newDeptId, int newPosId, string reason, string opUser) { // 先插履历此时 Employee 里还是旧值SELECT 出来的就是 Old* const string sqlHistory INSERT INTO JobHistory(EmpID, OldDeptID, NewDeptID, OldPositionID, NewPositionID, Operator, Reason) SELECT EmpID, DeptID, newDept, PositionID, newPos, op, reason FROM Employee WHERE EmpID id;; // 再更新当前岗位 const string sqlUpdate UPDATE Employee SET DeptID newDept, PositionID newPos WHERE EmpID id AND Status IN (1,2);; using (var conn new SqlConnection(ConnStr)) { conn.Open(); using (var tran conn.BeginTransaction()) { try { int rows SqlHelper.ExecuteNonQuery(conn, tran, sqlHistory, new SqlParameter(newDept, newDeptId), new SqlParameter(newPos, newPosId), new SqlParameter(op, opUser), new SqlParameter(reason, (object)reason ?? DBNull.Value), new SqlParameter(id, empId)); if (rows 0) { tran.Rollback(); return false; } // 员工不存在或已离职 SqlHelper.ExecuteNonQuery(conn, tran, sqlUpdate, new SqlParameter(newDept, newDeptId), new SqlParameter(newPos, newPosId), new SqlParameter(id, empId)); tran.Commit(); return true; } catch (Exception) { tran.Rollback(); // 任一步失败两张表都回到原样 throw; } } } }顺序很关键先插履历再更新员工。如果反过来先更新履历里的OldPositionID读出来就是新值了变动记录等于白记。这类顺序问题没有编译器帮你检查只能靠想清楚语义。另外那个rows 0的判断也别省。它同时挡住了两种情况员工 ID 不存在以及员工已经离职Status 3。后者是业务规则不该让离职员工还能被调岗。4.2 考勤批量导入唯一约束加幂等导入考勤是整套系统里数据量最大的表也是最容易被重复导入搞乱的表。典型场景HR 从打卡机导出一份 Excel导入系统发现有三行日期格式错了改完 Excel 又整份导一次结果前面成功的几百行全部撞唯一约束整个导入失败。更糟的是如果没建唯一约束就会变成一人一天三条记录月度统计直接翻倍。稳妥的做法是分三步先批量灌进临时表再按业务键做「存在则更新、不存在则插入」最后统计成功与失败行数。临时表用#TempAtt作用域只在当前连接内天然隔离-- 第一步Excel 数据先整表灌进临时表不做任何校验速度优先 CREATE TABLE #TempAtt ( EmpNo CHAR(8) NOT NULL, WorkDate DATE NOT NULL, CheckIn TIME(0) NULL, CheckOut TIME(0) NULL, AttStatus TINYINT NOT NULL ); -- 第二步按 (EmpID, WorkDate) 合并重复导入不会产生重复行 MERGE INTO Attendance AS tgt USING ( SELECT e.EmpID, tmp.WorkDate, tmp.CheckIn, tmp.CheckOut, tmp.AttStatus FROM #TempAtt tmp JOIN Employee e ON e.EmpNo tmp.EmpNo -- 工号转内部 ID ) AS src ON tgt.EmpID src.EmpID AND tgt.WorkDate src.WorkDate WHEN MATCHED THEN UPDATE SET CheckIn src.CheckIn, CheckOut src.CheckOut, AttStatus src.AttStatus WHEN NOT MATCHED THEN INSERT (EmpID, WorkDate, CheckIn, CheckOut, AttStatus) VALUES (src.EmpID, src.WorkDate, src.CheckIn, src.CheckOut, src.AttStatus); -- 第三步返回无法匹配的工号交给界面提示用户 SELECT tmp.EmpNo FROM #TempAtt tmp LEFT JOIN Employee e ON e.EmpNo tmp.EmpNo WHERE e.EmpID IS NULL;MERGE的写法在课程设计里是个亮点但它有个必须提醒的地方MERGE在高并发下可能出现竞态两个人同时导入同一批数据理论上会报主键冲突。课设单机环境不用管但答辩被问到时能说出「生产环境通常加WITH (HOLDLOCK)或者在应用层加分布式锁」就是加分回答。C# 侧用SqlBulkCopy把DataTable灌进临时表几千行数据一秒内完成比循环拼INSERT快两个数量级。这里要注意SqlBulkCopy必须和后续的MERGE用同一个连接、同一个事务否则临时表在另一个连接里根本看不见——这个踩坑点后面还会提一次。导入完成后界面要给出明确结果「成功 486 条更新 12 条失败 2 条失败工号E1023、E1087」。这比只弹一句「导入完成」有用得多HR 知道该去补哪两个人的档案。4.3 月度薪资视图做统计存储过程做核算薪资核算的逻辑链条是取每个在职员工的基本工资来自岗位表加上当月奖金减去考勤扣款迟到早退按次数算缺卡按天算减去社保再算个税最后得出实发。这条链上涉及三张表关联加聚合放在 C# 里写就是一堆嵌套循环放在存储过程里一个INSERT ... SELECT就完了。先建一个考勤汇总视图把「每人每月迟到几次、缺卡几天、加班几天」算好CREATE VIEW vw_AttendanceSummary AS SELECT a.EmpID, FORMAT(a.WorkDate, yyyy-MM) AS SalaryMonth, SUM(CASE WHEN a.AttStatus 2 THEN 1 ELSE 0 END) AS LateCount, SUM(CASE WHEN a.AttStatus 3 THEN 1 ELSE 0 END) AS EarlyCount, SUM(CASE WHEN a.AttStatus 4 THEN 1 ELSE 0 END) AS MissCount, SUM(CASE WHEN a.AttStatus 6 THEN 1 ELSE 0 END) AS OvertimeCount FROM Attendance a GROUP BY a.EmpID, FORMAT(a.WorkDate, yyyy-MM);视图的好处是把聚合逻辑固化下来薪资核算、部门报表、个人考勤查询三个地方复用同一份口径不会出现「报表里迟到 3 次、工资单里扣了 4 次」这种自相矛盾。核算存储过程按下面的骨架写重点看事务和幂等处理CREATE PROCEDURE usp_CalcSalary SalaryMonth CHAR(7), -- 格式 2025-06 Operator NVARCHAR(30) AS BEGIN SET NOCOUNT ON; IF SalaryMonth FORMAT(GETDATE(), yyyy-MM) BEGIN RAISERROR(不能核算未来月份的薪资, 16, 1); RETURN; END BEGIN TRY BEGIN TRAN; -- 已封存的月份不允许重算未封存的先删后插保证可重复执行 IF EXISTS (SELECT 1 FROM Salary WHERE SalaryMonth SalaryMonth AND IsLocked 1) BEGIN RAISERROR(该月薪资已封存不能重算, 16, 1); END DELETE FROM Salary WHERE SalaryMonth SalaryMonth; INSERT INTO Salary(EmpID, SalaryMonth, BaseSalary, Bonus, Deduct, Insurance, Tax, NetPay, IsLocked, CreateTime) SELECT e.EmpID, SalaryMonth, p.BaseSalary, ISNULL(s.Bonus, 0), -- 扣款规则迟到早退各扣 50缺卡每天扣 200 ISNULL(a.LateCount, 0) * 50 ISNULL(a.EarlyCount, 0) * 50 ISNULL(a.MissCount, 0) * 200, -- 社保按基本工资 10.5% 估算实际按当地基数 ROUND(p.BaseSalary * 0.105, 2), 0, 0, 0, GETDATE() FROM Employee e JOIN Position p ON p.PositionID e.PositionID LEFT JOIN vw_AttendanceSummary a ON a.EmpID e.EmpID AND a.SalaryMonth SalaryMonth LEFT JOIN SalaryAdjust s -- 奖金调整表可为空 ON s.EmpID e.EmpID AND s.SalaryMonth SalaryMonth WHERE e.Status IN (1, 2); -- 只算在职和试用 -- 第二遍更新个税和实发公式集中在一处便于核对 UPDATE Salary SET Tax ROUND(CASE WHEN BaseSalary Bonus - Deduct - Insurance 5000 THEN (BaseSalary Bonus - Deduct - Insurance - 5000) * 0.03 ELSE 0 END, 2) WHERE SalaryMonth SalaryMonth; UPDATE Salary SET NetPay BaseSalary Bonus - Deduct - Insurance - Tax WHERE SalaryMonth SalaryMonth; COMMIT; END TRY BEGIN CATCH IF TRANCOUNT 0 ROLLBACK; THROW; -- 把原始错误抛回 C# 层方便定位 END CATCH END;几个设计点值得展开。DELETE加INSERT的组合保证了可重复执行HR 发现某人的奖金录错了改完重跑一次就行不会出现两条记录。但前提是加了IsLocked判断封存后的月份一律拒绝否则财务已经把工资发出去了一次误操作就能把历史数据洗掉——这就是为什么封存这个字段必须有它是唯一的后悔药。个税公式简化成了 3% 的单一税率课程设计里足够了但代码里要留注释说明「实际按七级超额累进税率计算」答辩时主动说明简化范围比被问出来强。社保比例同理写成常量而不是魔法数字将来改起来只改一处。THROW而不是RETURN是因为要把错误信息完整带回 C# 层界面才能提示「该月薪资已封存」而不是笼统的「操作失败」。4.4 权限控制菜单级加数据级两层过滤权限分两层。菜单级控制「能不能看到这个功能」数据级控制「能看到谁的数据」。前者靠Role、Permission、RolePermission三张表登录时把当前角色的权限码一次性读进内存主窗体按权限码决定哪些菜单项置灰。后者靠 SQL 条件部门主管查询员工列表时自动拼上AND DeptID myDeptId。数据级过滤一定要放在 BLL 层统一处理不要指望每个窗体自己记得加条件。做法是登录成功后把当前用户的UserId、RoleCode、DeptId存进一个静态的CurrentUser对象DAL 的查询方法接收一个可选的deptFilter参数为null时不加条件人事和财务角色有值时拼进 WHERE。操作日志也是这一节的内容谁在什么时间做了什么写进OperationLog表。INSERT语句放在业务方法成功之后别放在事务里跟业务一起回滚否则失败的操作用来排查问题就没记录了。这类「记录优先级高于一致性」的场景日志和业务分开提交是常见做法。5. 避坑与排查五类在答辩现场最容易翻车的数据问题这一章列的都是实际调试时撞过的墙。每条按「现象、原因、解决」说照着排查能省下不少时间。5.1 现象中文姓名查出来是乱码或者排序结果怪现象很具体Employee表里存进去的姓名在 SSMS 里显示正常在 WinForms 的 DataGridView 里变成问号或者姓「张」的和姓「李」的排序顺序跟拼音对不上。原因通常是两个。一是字段类型用了VARCHAR而不是NVARCHARVARCHAR按当前排序规则的代码页存字符遇到生僻字就存不下二是连接串里没指定字符集或者 C# 侧SqlParameter的SqlDbType被推断成了VarChar。解决办法是三个动作一起做建表时所有存中文的字段统一用NVARCHAR(n)C# 传参时显式指定new SqlParameter(name, SqlDbType.NVarChar, 30) { Value name }别让框架自己推断排序需要按拼音时用ORDER BY EmpName COLLATE Chinese_PRC_CI_AS明确指定排序规则而不是依赖数据库默认值。5.2 现象两个窗口同时改同一个员工后保存的把先保存的覆盖了现象是数据莫名回退A 同事在员工档案里改了电话并保存B 同事同时在另一个窗口改了岗位也保存结果电话变回了旧值。这就是典型的丢失更新。原因是界面加载时读了整行数据保存时用UPDATE ... SET 所有字段全量覆盖不管中间有没有被别人改过。这种「后写覆盖先写」在单机演示时几乎不会触发但逻辑上是错的答辩时被问到并发就答不上来。办法有两个层次。简单起见可以在Employee表加一个RowVersion列TIMESTAMP类型更新时带条件WHERE EmpID id AND RowVersion ver受影响行数为 0 就说明数据已被别人改过提示用户刷新后重试。更简单的做法是把更新语句改成只更新真正变化的字段虽然没完全解决并发但把冲突面缩小了。课程设计里能做到乐观锁这一层已经比大多数同学想得深了。5.3 现象薪资总额和明细对不上差几分钱现象是财务把明细导出来用 Excel 求和跟页面上的总额差了 0.01 到 0.05 之间。原因八成是金额字段用了FLOAT或REAL。浮点数是二进制近似存储0.1 0.2在里面不等于0.3累积几十上百行之后误差就显出来了。另一种可能是 C# 侧用了double做中间计算再写回数据库同样会引入误差。解决办法数据库金额字段统一DECIMAL(10,2)C# 侧对应decimal类型绝不用double所有四舍五入显式调用Math.Round(value, 2, MidpointRounding.AwayFromZero)因为 .NET 默认的银行家舍入四舍六入五取偶和财务习惯的「四舍五入」不一致2.345会被舍成2.34而不是2.35。这个差异在几百条记录累加后就是肉眼可见的差额。5.4 现象导入几千条考勤时提示「对象名 #TempAtt 无效」现象是SqlBulkCopy明明执行成功了紧接着的MERGE语句却报临时表不存在。原因是临时表的作用域绑定在创建它的那个连接和会话上。SqlBulkCopy内部新建了一个连接MERGE用的是另一个连接两者不是同一个会话#TempAtt自然看不到。解决办法是把这三步串在同一个SqlConnection上手工conn.Open()创建临时表、SqlBulkCopy用接收外部连接的重载、MERGE、DROP TABLE全部用同一个连接对象最好再包一层事务。这类「连接必须共享」的坑在批量操作里很常见写的时候顺手加个注释将来自己回来看也明白为什么不能省。5.5 现象员工离职后直接删除历史薪资和考勤全没了现象是删除一个离职员工后薪资报表里当月的总额少了一块考勤统计的人数也对不上。原因是外键设了ON DELETE CASCADE或者干脆用了软删除但代码里到处写DELETE FROM Employee。人事数据是有留存要求的一个人离职三年后还要能查出他当年的工资记录物理删除属于不可逆的操作。解决办法员工只做状态变更Status置 3 并填LeaveDate绝不做物理删除。外键一律用默认的NO ACTION不给级联删除。同时把查询在职员工的 SQL 统一写成WHERE Status IN (1,2)界面上加一个「显示离职人员」的勾选框勾上才带上Status 3。这套做法在答辩时讲出来比讲十个界面特效都有说服力。6. 验收与答辩一份十分钟演示脚本和一张自查清单课设的分数很大程度上取决于现场那十分钟。演示顺序如果按菜单从左到右点一遍十分钟根本走不完而且全程平淡老师记不住你做了什么。我习惯按「一条业务主线加三个技术亮点」组织演示。业务主线走完整生命周期新建一个员工入职→ 给他导一条考勤 → 调岗一次并展示履历表 → 核算当月薪资 → 查看工资单 → 把该员工设为离职。这条线串起了七张表里的大部分讲清楚一件事数据是活的能在系统里流转。三个亮点穿插在主线里讲。第一个是事务调岗时故意在第二次操作中途制造一次失败比如临时把岗位 ID 改成一个不存在的值展示两张表都没变然后改回来再跑一遍成功。第二个是唯一约束考勤导入时把同一份 Excel 导两遍展示第二次是更新而不是新增行数不变。第三个是权限换一个「部门主管」角色的账号登录展示菜单少了薪资模块员工列表里只有本部门的人。自查清单建议答辩前一天逐项过一遍这些是最容易在现场掉链子的地方检查项通过标准备注数据库脚本可重建全新库执行一遍脚本表和初始数据全有别依赖手工改过的本地库连接串可切换改App.config里的实例名即可跑通答辩机和你机器名可能不同三条主线可演示入职、调岗、薪资核算各有数据提前准备演示数据别现场现造异常能提示重复工号、未来月份核算都有明确提示不能只弹「操作失败」存储过程可单独跑在 SSMS 里直接EXEC能出结果老师常要求现场跑一遍日志表有记录关键操作能在OperationLog里查到证明审计链路通问答环节高频出现的几个问题提前想好答案为什么用存储过程而不是在 C# 里算答减少网络往返、逻辑集中、可被多个客户端复用索引为什么建在这几个字段上答覆盖最高频的等值查询和范围查询薪资表为什么冗余NetPay答封存后必须原样可读属于有意的反范式如果数据量涨到一百万行怎么办答分区或者按年归档考勤表按月分区。答不上来也别硬编说「这部分我考虑得不深初步想法是……」比胡诌强。有次答辩一个同学的演示数据里有一条 2 月 30 日的考勤记录被老师一眼看出来追问了半天日期校验放在哪一层最后发现界面用了日历控件但数据库没加CHECK直接绕过界面插进去的脏数据。从那以后我每次建表日期、金额、状态这三类字段的CHECK约束和唯一约束都强制走一遍演示数据也一定用脚本生成而不是手工敲。这份 C# 完整数据库 人事管理系统 的建表脚本、视图与存储过程、WinForms 工程和演示数据脚本都放在一起字段名和模块划分照着改就能换成自己的题目。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站