简介:基于C#与SQL Server的合同管理系统,是一套包含完整源码及数据库的打包资源,适合课程设计、毕业设计或企业合同管理模块开发者参考。系统聚焦客户、项目、合同信息和合同执行控制,管理员可管理全流程数据,普通用户可查询合同明细,模块划分清晰,能直接呈现合同管理系统的需求分析与功能落地。压缩包共123个文件,体积仅2.82MB,以51个.cs源码文件为业务核心,23个.resx与23个.resources资源文件管理界面与本地化内容,另含config配置、sln解决方案、mdf/ldf数据库文件和可执行程序,结构完整,方便本地运行与二次修改。目前已有153人学习下载。通过该资源可掌握C#窗体应用开发、SQL Server数据访问、业务分层及基础权限控制的实际写法,也可在现有客户、项目、合同模块上继续扩展,是一份兼顾学习与二次开发价值的合同信息管理系统资料。
1. 为什么还有人愿意打开一个"C#合同管理系统(源码+数据库).zip"
在很多公司,合同管理还停留在Excel台账加共享文件夹的原始阶段:到期续签靠行政翻日历,审批只留一封邮件,财务对账要跑到档案室翻纸质件。这时候一个带着源码和数据库文件的C#合同管理系统,就成了比预算两万外包更现实的选择。这类资源包通常是一个完整的Visual Studio解决方案加数据库文件(.mdf/.db/.accdb或建库脚本),解压后能直接编译。这篇笔记按数据库设计、增删改查、审批流、到期提醒的顺序讲透,让新手拿到就能跑,熟手能避开那些让系统半年后崩掉的隐藏坑。
2. 合同管理系统的数据库设计:表结构决定这个源码能撑多久
2.1 核心表与字段:合同主表、明细表和审批日志是底线
拿到任何一份合同管理源码,先别急着按F5,打开数据库文件看看表结构。哪怕UI做得再花哨,底层也逃不出三张核心表:合同主表、合同明细表、审批日志表。
合同主表(Contract)是台账主体,字段一般包括:合同编号、合同名称、甲方、乙方、签订日期、生效日期、到期日期、合同金额、付款方式、当前状态、附件路径、负责人、备注。这些字段看着平淡,但类型选错后面全是泪。金额必须用 decimal(18,2),不要用float,否则累加对账时会差出几分钱;日期用 date 或 datetime,别用varchar,不然到期提醒的日期比对会变成字符串比较,跨年必翻车。
一个能直接跑的标准建表SQL如下:
CREATE TABLE Contract ( Id INT IDENTITY(1,1) PRIMARY KEY, ContractNo NVARCHAR(50) NOT NULL UNIQUE, ContractName NVARCHAR(200) NOT NULL, PartyA NVARCHAR(200) NOT NULL, PartyB NVARCHAR(200) NOT NULL, SignDate DATE NULL, StartDate DATE NULL, ExpireDate DATE NULL, Amount DECIMAL(18,2) NOT NULL DEFAULT 0, PayMode NVARCHAR(50) NULL, Status TINYINT NOT NULL DEFAULT 0, AttachmentPath NVARCHAR(500) NULL, OwnerName NVARCHAR(50) NULL, Remark NVARCHAR(500) NULL, CreatedBy NVARCHAR(50) NULL, CreatedTime DATETIME NULL DEFAULT GETDATE() );这里的逻辑很直接:ContractNo 加唯一索引,保证同编号合同不会重复录入;ExpireDate 做成可空,因为采购框架合同可能没有明确到期日;Status 用 TINYINT 0-9 的数字状态而不是字符串,状态机扩展时不用改字段长度。明细表(ContractItem)用来拆收款计划或履约节点,主表 Id 作为外键,一条主合同对应多条明细。审批日志表(ContractAuditLog)记录谁在什么时间做了什么动作、批注是什么,这张表是上线后用来审计和复盘的关键证据,再小的系统也不该省。
类型选型上我一般坚持三条:金额 decimal(18,2),日期 date/datetime,布尔改用 smallint。很多二手源码用 bit 表示是否作废,本身没问题,但 SQLite 和 Access 对 bit 支持不一致,统一 smallint 换库时少一层麻烦。
2.2 数据库选型:SQL Server、SQLite 还是 Access,先看文件后缀再动手
源码包里的"数据库"通常有三种形态:.mdf 的 SQL Server(多半是 LocalDB 或 Express),.db/.sqlite 的 SQLite,.accdb/.mdb 的 Access。老一点的包还有只给一个 create.sql 让你自己建库的。判断方法很简单,打开 App.config 或 Web.config 看 connectionStrings,或者直接看数据库文件后缀。
三种方案对比:
| 特征 | SQL Server LocalDB | SQLite | Access |
|---|---|---|---|
| 文件形态 | .mdf 附加后运行 | 单文件 .db | 单文件 .accdb |
| 部署复杂度 | 需安装LocalDB或Express | 免安装,自带驱动 | 兼容性好但并发差 |
| 适合场景 | 公司内正常并发 | 单机、小团队、绿色版 | 历史遗留项目 |
| 备份方式 | 备份数据库或分离复制 | 直接复制文件 | 复制文件 |
如果包里有 .sql 建库脚本,我最推荐直接跑脚本而不是附加 .mdf。因为 .mdf 附加时常见文件占用、日志文件缺失、版本不兼容三种坑,脚本重放则干净确定。跑脚本的常见做法是用 sqlcmd 或 SSMS 执行;如果你只想先看表结构,用 DbBrowser for SQLite 打开本地 .db 文件是最快的,比在代码里连半天库实在得多。
2.3 拿到源码第一步:初始化数据库而不是先编译
很多人拿到压缩包习惯先解压、双击 .sln、然后 Ctrl+F5 等着报错。正确顺序是先把数据库立起来,再编译代码。以最常见的 SQL Server LocalDB 为例,最小初始化步骤如下:
# 启动 LocalDB 实例 sqllocaldb create ContractLocalDB sqllocaldb start ContractLocalDB # 执行建库脚本(文件路径按实际解压位置调整) sqlcmd -S "(localdb)\ContractLocalDB" -E -i create_database.sql命令里 -S 指定实例名,create_database.sql 是包里的建库脚本;如果包里没有现成脚本,就把 2.1 节的建表语句整理成一个文件执行。执行完用 sqlcmd 查一下表是否建成功:
sqlcmd -S "(localdb)\ContractLocalDB" -E -Q "SELECT name FROM sys.tables"看到 Contract、ContractItem、ContractAuditLog 三张表就算立住了。这一步做在前面的价值是:后面任何"数据库连接失败"的报错,你都能立刻判断是代码问题还是库没起好,而不是对着一个编译不过的工程瞎猜。
3. 用C#把合同增删改查跑起来:从DbHelper到DataGridView批量导入
3.1 一个通用数据访问类DbHelper:把连接、增删改查收拢在一处
合同管理这类中小系统,不值得为每个表写一套仓储代码。一个静态 DbHelper 就能覆盖绝大多数增删改查。以下是我常用的最小实现:
using System; using System.Data; using System.Data.SqlClient; public static class DbHelper { private static readonly string connStr = System.Configuration.ConfigurationManager.ConnectionStrings["ContractDB"].ConnectionString; public static int ExecuteNonQuery(string sql, params SqlParameter[] paras) { using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (paras != null) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteNonQuery(); } } public static DataTable ExecuteDataTable(string sql, params SqlParameter[] paras) { using (SqlConnection conn = new SqlConnection(connStr)) { using (SqlDataAdapter da = new SqlDataAdapter(sql, conn)) { if (paras != null) da.SelectCommand.Parameters.AddRange(paras); DataTable dt = new DataTable(); da.Fill(dt); return dt; } } } public static object ExecuteScalar(string sql, params SqlParameter[] paras) { using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (paras != null) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteScalar(); } } }逻辑说明:三个方法对应三种诉求——ExecuteNonQuery 跑增删改,返回影响行数;ExecuteDataTable 查列表,绑定给 DataGridView;ExecuteScalar 查单值,比如合同总数。using 保证连接用完及时释放,这是老源码最容易漏的点,连接不释放,跑两天必报"连接池已满"。参数化 SQL 从第一行代码就要养成,后面在避坑章会专门讲拼接SQL带来的注入问题。
3.2 合同列表与搜索:DataGridView绑定DataTable,搜索条件用参数拼接
合同管理的主界面百分之九十是同一个模板:上方几个搜索框,中间一个 DataGridView,下方分页。搜索条件通常是合同名称模糊匹配、状态筛选、到期日期范围。一个可以直接迁移的查询方法如下:
public DataTable SearchContract(string keyword, int status, DateTime? start, DateTime? expire) { string sql = "SELECT Id, ContractNo, ContractName, PartyA, PartyB, ExpireDate, Amount, Status FROM Contract WHERE 1=1"; var paras = new List<SqlParameter>(); if (!string.IsNullOrWhiteSpace(keyword)) { sql += " AND (ContractName LIKE @kw OR ContractNo LIKE @kw)"; paras.Add(new SqlParameter("@kw", "%" + keyword.Replace("%", "[%]") + "%")); } if (status >= 0) { sql += " AND Status = @st"; paras.Add(new SqlParameter("@st", status)); } if (start.HasValue) { sql += " AND SignDate >= @start"; paras.Add(new SqlParameter("@start", start.Value)); } if (expire.HasValue) { sql += " AND ExpireDate <= @expire"; paras.Add(new SqlParameter("@expire", expire.Value)); } return DbHelper.ExecuteDataTable(sql, paras.ToArray()); }这里有个容易被忽略的参数:keyword 里用户可能输入 % 或 _,这两个在 LIKE 里是通配符,不转义的话用户搜"50%预付"会把所有合同拉出来。Replace 把 % 换成 [%],SQL Server 会把它当普通字符,SQLite 同样兼容这种写法。状态值 status 用 -1 表示"全部",避免在 SQL 里拼 OR 把索引搞废。
3.3 新增、修改和删除:明细表要用事务,别只写主表
合同录入除了插主表,还可能同时写入几条履约节点到 ContractItem。新手常见错误是分两次 ExecuteNonQuery,第一次成功第二次失败,台账和明细就不一致了。正确做法是包在一个事务里:
using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { string sqlMain = @"INSERT INTO Contract(ContractNo,ContractName,PartyA,PartyB,SignDate,StartDate,ExpireDate,Amount,Status,Remark) VALUES(@no,@name,@a,@b,@sign,@start,@expire,@amount,0,@remark); SELECT SCOPE_IDENTITY();"; using (SqlCommand cmd = new SqlCommand(sqlMain, conn, tran)) { cmd.Parameters.AddWithValue("@no", txtNo.Text.Trim()); cmd.Parameters.AddWithValue("@name", txtName.Text.Trim()); // 其余参数略 int newId = Convert.ToInt32(cmd.ExecuteScalar()); foreach (DataRow row in itemDt.Rows) { string sqlItem = @"INSERT INTO ContractItem(ContractId,ItemName,ItemDate,ItemAmount) VALUES(@cid,@name,@date,@amount)"; using (SqlCommand cmd2 = new SqlCommand(sqlItem, conn, tran)) { cmd2.Parameters.AddWithValue("@cid", newId); // 明细参数略 cmd2.ExecuteNonQuery(); } } } tran.Commit(); } catch { tran.Rollback(); throw; } } }逻辑说明:BeginTransaction 之后所有 SqlCommand 都要带上同一个 tran 对象,Commit 或 Rollback 控制整体结果。ExecuteScalar 配合 SCOPE_IDENTITY() 拿回主表刚才生成的 Id,作为明细表的外键。这个事务模式同样适用于"签约审批通过时写审计日志+更新状态"这种多步操作。
3.4 用SqlBulkCopy批量导入历史合同:Excel台账迁移的最快路径
公司上线合同系统最痛的往往是历史数据:几百条旧合同还躺在Excel里,手工录入能录到怀疑人生。C# 里 SqlBulkCopy 就是为这种批量导入设计的。Excel 读出 DataTable 后直接灌进 SQL Server:
DataTable dt = ReadExcelToDataTable(filePath); // 从Excel读取,列名与目标表对应 using (SqlBulkCopy bc = new SqlBulkCopy(connStr)) { bc.DestinationTableName = "Contract"; bc.ColumnMappings.Add("合同编号", "ContractNo"); bc.ColumnMappings.Add("合同名称", "ContractName"); bc.ColumnMappings.Add("甲方", "PartyA"); bc.ColumnMappings.Add("乙方", "PartyB"); bc.ColumnMappings.Add("签订日期", "SignDate"); bc.ColumnMappings.Add("到期日期", "ExpireDate"); bc.ColumnMappings.Add("金额", "Amount"); bc.BatchSize = 500; bc.BulkCopyTimeout = 60; bc.WriteToServer(dt); }ColumnMappings 把 Excel 列名和表字段挂上钩,Excel 里漏填的字段在导入时会被写成默认值,所以导入前先跑一遍数据校验脚本,把起始日期晚于到期日期的行清掉。BatchSize 按 500 一批提交,既快又不会把事务日志顶到爆炸。导入前一定先备份一次库,历史数据迁移没有后悔药,这句话是血泪经验。
4. 审批流程和到期提醒:决定系统能不能真正用起来的两个关卡
4.1 合同状态机:草稿、待审、已生效、到期、作废
合同系统真正区别于Excel表的,是状态流转。我见过不少源码把状态存成字符串,界面下拉框里让用户随便选,结果台账里同时出现"生效中""已生效""执行中"三种写法,统计报表直接没法看。正确的做法是把状态定义进状态机,只有合法路径能到达的状态才允许更新。
常用状态表如下:
| 状态值 | 名称 | 可到达的下一个状态 |
|---|---|---|
| 0 | 草稿 | 1(提交审批)、5(作废) |
| 1 | 待审批 | 2(通过)、4(驳回) |
| 2 | 已生效 | 3(到期)、5(作废) |
| 3 | 已到期 | 无(唯一动作是续签生成新合同) |
| 4 | 已驳回 | 0(修改后重新提交) |
| 5 | 作废 | 无 |
代码层面,状态更新不应该散落在各个按钮事件里,而是收拢成一个 UpdateStatus 方法:
public bool UpdateStatus(int contractId, byte newStatus, string operatorName, string auditRemark) { string sql = @"UPDATE Contract SET Status=@newStatus, LastOperator=@op WHERE Id=@id"; int rows = DbHelper.ExecuteNonQuery(sql, new SqlParameter("@newStatus", newStatus), new SqlParameter("@op", operatorName), new SqlParameter("@id", contractId)); if (rows > 0) { string logSql = @"INSERT INTO ContractAuditLog(ContractId, OperatorName, ActionTime, OldStatus, NewStatus, Remark) VALUES(@cid,@op,GETDATE(),@old,@new,@remark)"; DbHelper.ExecuteNonQuery(logSql, /* 参数略 */); } return rows > 0; }逻辑说明:先改状态再写日志,日志里的 OldStatus 是更新前查出来的值,NewStatus 是本次目标值。这套设计保证任何时间点都能回答"这个合同是谁在什么时候改成生效的"。状态值用数字后,界面显示用字典翻译成中文,统计报表直接按 Status 分组,不用再做文本清洗。
4.2 审批权限怎么落到按钮上:角色字段 + 按钮可见性
小团队合同系统不需要重型工作流引擎,最实用的是"提交—审批—驳回"两级流程。审批人通常是合同归口部门负责人或财务。实现上不需要在数据库里做一个通用权限表,只要在用户表加 Role 字段(0普通员工,1审批人,2管理员),登录后把角色存在全局静态变量里,界面加载时控制按钮:
public void LoadPermission(User currentUser) { btnSubmit.Visible = currentUser.Role >= 0; btnApprove.Visible = currentUser.Role >= 1; btnReject.Visible = currentUser.Role >= 1; btnDelete.Visible = currentUser.Role == 2; btnImport.Visible = currentUser.Role == 2; }按钮可见性只是入口控制,真正的校验要在 UpdateStatus 方法里再查一次当前用户角色,否则懂行的用户直接调用按钮事件也能绕过界面。老一点的源码爱用 MessageBox 提示"无权限",老工程师更推荐直接置灰按钮加后端校验双重保障,既友好又不给内鬼留口子。
4.3 到期提醒:定时器扫描 + 提醒表,别在内存里弹窗
到期提醒是合同管理系统能不能让老板觉得"值"的关键功能。最朴素的实现是不停弹窗提示:启动时 Timer 每 24 小时扫一次库,发现 30 天内到期的合同就 MessageBox。这个方案开发十秒,上线半年后就会被骂——开机瞬间弹出一堆对话框,业务人员得挨个点掉。
我一般会改成"提醒落库"模式:每日凌晨定时扫描一次,符合条件的合同写入 ContractRemind 表,用户在系统首页的待办列表里看到提醒,点进去是合同详情。这种做法把提醒从"骚扰弹窗"变成"待办事项",领导还能查看哪些合同快到期了没处理。扫库的 SQL 在 SQL Server 和 SQLite 里写法不同:
-- SQL Server:30天内到期 SELECT ContractNo, ContractName, PartyB, ExpireDate FROM Contract WHERE Status = 2 AND DATEDIFF(day, GETDATE(), ExpireDate) BETWEEN 0 AND 30; -- SQLite:用 julianday 计算日期差 SELECT ContractNo, ContractName, PartyB, ExpireDate FROM Contract WHERE Status = 2 AND julianday(ExpireDate) - julianday('now') BETWEEN 0 AND 30;参数说明:BETWEEN 0 AND 30 表示从今天开始到未来30天;如果是当天到期也包含在0这一天。若要提前60天提醒,把30改成60即可。定时器在WinForms里用 System.Windows.Forms.Timer,服务类程序用 System.Threading.Timer,注意定时器里不要直接弹UI,跨线程会抛异常。
4.4 源码包的另一道坎:C#防反编译不能靠隐藏
标题是源码包,很多人在意的是"源码被拿走怎么办"。C#编译出来的程序集可以被ILSpy、dnSpy直接反编译回可读代码,公司内部系统可能无所谓,但对外交付的合同系统如果带敏感对接信息(数据库连接串含密码),至少要处理两层。第一层是连接字符串加密,把 App.config 里 connectionStrings 用工具加密或自己写RSA加解密;第二层是混淆程序集,打乱命名和流程,让反编译工具读到的类名变成 a/b/c。
注意混淆不是万能的,加了壳的程序可能被杀毒软件误报。要交付给甲方时,我通常建议在发布目录里附一份部署说明,明确说明主程序经过混淆,让现场安装人员提前加入杀毒白名单,免得部署当天被安全软件拦下。
5. 接手的合同管理源码最容易翻车的五个坑:现象、原因、解决
5.1 .mdf附加失败,报错"数据库正在使用"或"文件权限不足"
现象:双击 .mdf 附加,SQL Server 提示文件被占用,或者权限不足无法附加。 原因:源码包里的 .mdf 是打包者从LocalDB实例分离出来的,有些甚至是从另一台机器的 Express 实例来的,文件权限继承自原环境;在只读目录或系统盘 Program Files 下解压也会触发权限问题。 解决:把整套源码解压到非系统盘,例如 D:\ContractSystem;用 sqllocaldb create 新建独立实例而不是用默认实例;附加时右键文件给当前用户完全控制权限。如果你不想附加,直接按第2章的脚本重新建库更省心。
5.2 连接字符串写死绝对路径,换电脑就崩
现象:源码在自己电脑上跑得好好,发给同事解压后直接报"建立与 SQL Server 的连接时发生错误"。 原因:App.config 里写的是 C:\Users\打包者\ContractDB.mdf,换机器路径不存在。 解决:把连接字符串改成相对路径或直接用 LocalDB 实例名,例如 Server=(localdb)\ContractLocalDB;Database=Contract;Integrated Security=true;数据库文件放在项目根目录的 App_Data 下,部署时整包拷走即可。顺带把连接字符串抽到配置文件外,发布时单独检查。
5.3 到期日期用字符串存储,跨年提醒全部失灵
现象:到期提醒在12月还能查出来,到1月突然少了大量本该提醒的合同。 原因:建表时 ExpireDate 用 varchar,查询时 DATEDIFF(day, GETDATE(), ExpireDate) 把字符串隐式转日期,跨年时字符串排序与日期排序不一致,导致 BETWEEN 判断失效。 解决:把表字段改为 date,返回不了就写一条转换SQL更新旧数据,例如 UPDATE Contract SET ExpireDate = CONVERT(date, ExpireDateText)。以后写新代码一律用参数化日期类型,不存字符串日期。
5.4 登录后权限按钮控制失效,普通员工能点"删除"
现象:界面隐藏了删除按钮,但按住回车或调用方法仍然能删合同。 原因:只在前端控制 Visible,后端 UpdateStatus 没校验角色,懂行的人直接调用内部方法或改按钮状态。 解决:后端每个写操作都取当前登录用户,校验角色后再执行;删除合同改成逻辑删除——增加 IsDeleted 字段,查询默认过滤 IsDeleted=0,这样即使误操作也能恢复,是真正意义上的后悔药。
5.5 历史数据批量导入后金额对不上,报表少了几万
现象:用 SqlBulkCopy 导入Excel合同后,汇总统计和财务核对差了数笔。 原因:Excel 里金额列有千分位逗号、有空行、有"约10万"这类文本;亦或导入时漏了主表-明细的外键关联,明细金额没进汇总。 解决:导入前写一段校验,把非数字行挑出来人工修正;金额列用 Convert.ToDecimal 而不是隐式转换;导入后立刻跑一个分组SUM和一个 COUNT 与Excel原表对比,不一致就回滚重导。这套校验写一次,以后每次迁移都能复用。
6. 从"能跑"到"能交差":上线前再补三个动作
第一个动作是每天自动备份。合同数据是资产,不是用来练手的玩具。WinForms 程序可以在启动时判断今天是否已备份,用一个定时任务在凌晨执行一次 BACKUP DATABASE 或直接复制 SQLite 文件到备份目录。
string backupSql = "BACKUP DATABASE ContractDB TO DISK=@path"; DbHelper.ExecuteNonQuery(backupSql, new SqlParameter("@path", @"D:\ContractBackup\contract_" + DateTime.Now.ToString("yyyyMMdd") + ".bak"));第二个动作是导出台账Excel。老板和财务最爱看的就是合同台账,别为了这个需求去引入报表控件,DataTable 直接写入 CSV 或 Excel 就够用,导出时注意金额格式化保留两位小数,日期统一 yyyy-MM-dd,Excel打开不乱码。
第三个动作是给删除操作留审计。即便做了逻辑删除也要记日志,ContractAuditLog 里加上"删除"动作,谁在什么时候删了哪条合同,都能查出来。权限越小的系统越要留这条线,业务上出了纠纷它比口头解释靠谱。
做合同系统交付,最深的教训我是在第一次交付时踩的:当时只顾着把功能写全,连接字符串忘改成客户环境,现场折腾了两天才跑起来。后来每套部署都把数据库初始化脚本和连接配置单独放一个checklist,上线前逐个打勾。这个习惯帮我少加了很多班。希望帮到你。
本文还有配套的精品资源,点击获取