ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

简介:这是一套面向高校数据库课程设计场景的人事管理系统完整项目,采用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 只调 BLL,BLL 只调 DAL,DAL 只认 Model。

职责边界要卡死几条:UI 层不允许出现任何 SQL 字符串,也不允许出现SqlConnection;BLL 层负责业务判断,比如「离职员工不允许再生成薪资」「薪资月份不能是未来月份」;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 name="HrDb" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=HrDb;Integrated Security=True;TrustServerCertificate=True" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>

Integrated Security=True表示用 Windows 身份验证,本地课设环境最省事,不用管账号密码。如果要求必须用 SQL 账号登录,就换成User ID=xxx;Password=xxx,同时记得在答辩前确认好这个账号只有db_datareader和db_datawriter权限,不给db_owner,这一点在「安全性设计」那一节能讲。

3.4 SqlHelper:参数化查询与事务重载

所有 DAL 方法都走同一个帮助类,避免每个方法重复写 open/close:

public 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而不是NVARCHAR,VARCHAR按当前排序规则的代码页存字符,遇到生僻字就存不下;二是连接串里没指定字符集,或者 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 工程和演示数据脚本都放在一起,字段名和模块划分照着改就能换成自己的题目。希望帮到你。

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

返回列表