资讯动态

C#合同管理系统实战:WinForms+SQL Server+Dapper源码解析

发布时间:2026/10/9 6:21:49 来源:尧图企业网站定制
简介这是一套基于C#与SQL Server开发的合同管理系统源码附带完整数据库文件面向需要完成课程设计、毕业设计或希望学习窗体应用与数据库开发的读者。系统围绕客户、项目、合同信息及合同执行控制等核心模块展开管理员可维护各项业务数据普通用户可查询客户、项目与合同明细权限划分清晰业务逻辑完整。压缩包共包含123个文件整体仅2.82MB主要文件类型包括C#源代码51个cs文件、窗体资源文件resx/resources、配置文件、可执行程序及SQL Server数据库文件mdf/ldf并附有解决方案文件目录结构完整。借助完整源码与数据库可打开解决方案、附加数据库并修改连接语句后直接调试运行便于理解系统设计思路和关键实现细节也适合在此基础上扩展新功能。目前已有153人学习浏览。1. 基于C#的合同管理系统这套「源码数据库」到底能解决什么你在公司里大概率见过这种场景合同散落在不同人电脑里Excel台账已经一年多没人更新哪天到期、哪份是盖章原件、今年总共签了多少钱全凭行政同事的记忆。基于C#的合同管理系统源码数据库解决的就是这个问题——把合同登记、查询、到期提醒、附件归档、状态变更全部收到一个桌面程序里。它不依赖外网不需要运维一台Windows机器装上SQL Server就能跑。适合三类人公司内部想替换Excel台账的IT接小项目的外包开发者以及需要完整C#实战案例的初学者。2. 技术栈与项目骨架为什么这套系统普遍用WinForms 本地数据库2.1 桌面端比Web端更适合合同管理的三个理由合同管理系统看起来谁都能做成网页版但你会发现小团队落地时桌面端的比例相当高。原因很现实第一合同录入和查询都是低频操作用户就几个行政和业务人员没必要部署IIS或Nginx第二合同数据涉及金额和法人信息放在自己机器的数据库里比放公网更有安全感第三WinForms程序编译出来一个exe双击就跑局域网里共享一个数据库实例就够了。C#做这套系统的历史包袱很少。.NET Framework 4.x在Windows上基本预装Visual Studio编译一次目标机器不用装运行时就能跑。数据库这边标题里的「数据库」一般是指SQL Server或MySQL其中一个——SQL Server在中小公司Windows服务器上最常见MySQL则方便后期接其他语言。如果是单机自用SQLite也能替代但SQL Server的日期函数、事务机制、备份工具更顺手我倾向于在交付时固定用SQL Server Express。数据库适合场景部署成本注意点SQL Server Express局域网多人用体量小装一个免费实例十几分钟注意登录模式和防火墙MySQL 5.7/8.0已有公司MySQL环境几乎没有额外部署注意字符集utf8mb4SQLite单人单机纯自己用零配置文件即库并发写弱不适合多用户2.2 拿到项目后怎么还原并跑通从解压到看到登录窗口你从标题那个zip包里解压后先别急着双击sln。先把目录结构摸一遍正常情况下会有一个解决方案文件、一个WinForms项目文件夹、一个数据库脚本或备份文件。数据库脚本通常是.sql结尾备份文件是.bak结尾也有放.mdf直接附加的。这一步别跳过因为「数据库脚本」和「数据库备份」的还原方式完全不同。先还原数据库再打开代码。SQL Server里新建一个数据库然后对.sql脚本执行或者用「还原数据库」功能选择.bak文件。接着打开Visual Studio等待NuGet包还原完成——项目里如果引用了Dapper或EF Core第一次打开会联网拉包。connectionStrings add nameContractDB connectionStringData Source.\SQLEXPRESS;Initial CatalogContractManager;Integrated SecurityTrue;TrustServerCertificateTrue;MultipleActiveResultSetsTrue providerNameSystem.Data.SqlClient / /connectionStrings这段是App.config里的连接字符串。Data Source.\SQLEXPRESS指向本机SQL Server Express实例Initial Catalog是数据库名要和脚本建库时的库名一致。Integrated SecurityTrue用Windows身份登录开发机上最省事。MultipleActiveResultSets打开后同一个连接里可以同时跑多个查询WinForms界面刷新列表时不容易报「连接正忙」。连接字符串写不对后面全是「无法连接数据库」的红字错误这块几乎人人踩过。我的习惯是先跑通连接再动业务代码用Visual Studio自带的「服务器资源管理器」手动连一次库能展开表结构说明连接字符串没问题。2.3 代码结构怎么安排别把SQL堆在按钮点击事件里合同系统规模不大但直接在每个按钮里写SqlConnection后面必翻车。常见做法是分三层UI层只放窗体数据访问层放Repository类实体层放ContractInfo、ContractParty这些模型。数据访问层用Dapper比EF Core更合适——合同系统的查询场景多、表关系简单Dapper直接写SQL报错时一眼能看出问题在哪个语句。public class ContractRepository { private readonly string _connStr ConfigurationManager.ConnectionStrings[ContractDB].ConnectionString; public ContractInfo GetById(int id) { using var conn new SqlConnection(_connStr); return conn.QueryFirstOrDefaultContractInfo( SELECT * FROM ContractInfo WHERE Id Id, new { Id id }); } }using var是C# 8.0的语法编译器自动在方法结束时释放连接。QueryFirstOrDefault是Dapper的扩展方法第一参数是SQL第二参数是匿名对象传参。这里刻意没有用字符串拼接Id因为所有数据库访问都走参数化能挡掉最基础的SQL注入。注意实体类ContractInfo的属性名要和表字段对得上Dapper默认按名字匹配对不上会返回null而不是报错——这是Dapper的坑后面排查找不到原因时先对一遍字段名。3. 数据库设计合同台账、状态字段与审批链路的三层拆解3.1 四张核心表合同主表、合同主体、审批记录、附件合同管理系统数据库设计的第一原则合同本身是一笔业务不是一个Excel行。Excel里一行能写客户、金额、日期、备注、附件路径但到了数据库里这些信息应该拆到多张表。否则会出现一个客户签约十次每次都要重复录入公司全称和信用代码的蠢事。合同主表保存合同自身的属性合同主体表保存签约方审批记录表保存状态变更历史附件表保存合同扫描件和补充协议。拆开后按合同主表查询是常态按客户维度统计也能做。表名关键字段说明ContractInfoId, ContractNo, Title, Amount, SignDate, EffectiveDate, ExpireDate, Status金额用decimal(18,2)日期用datetimeContractPartyId, ContractId, PartyName, CreditCode, ContactPerson, ContactPhone甲方乙方分开行存用PartyType区分ApprovalLogId, ContractId, FromStatus, ToStatus, Comment, CreateUser, CreateTime每次状态变更写一条ContractAttachmentId, ContractId, FileName, FilePath, FileSize, UploadTime附件文件存路径不存二进制ContractInfo里的Status是整个系统的核心字段后面单独讲。ContractParty表的关键点是Pa型别甲方、乙方、担保方三类都放一张表加一个PartyType字段区分。很多人会把甲方乙方做成ContractInfo里的两个字段一旦出现三方协议就尴尬了。ContractAttachment存文件路径而不是文件本身是为了数据库体积可控。合同扫描件动辄几十兆全塞进varbinary会导致备份文件急速膨胀。文件放共享目录数据库只留路径和大小查询附件列表快备份也轻。3.2 状态字段为什么用int状态码而不是「审批中」这种中文字符串很多初学者喜欢给状态字段直接存「已生效」「已归档」数据库里看着一目了然但代码里没法比较大小也没法做状态机控制。合同系统里状态应该用整数存显示的时候再映射成中文字面。我的惯用状态编码是0草稿、10审批中、20已生效、30履行中、40已到期、50已归档、90已作废。留了10的步进而不是1是为了给后补状态腾位置——比如审批中可能要拆成「待法务审批」和「待用印」插一个15和18都顺畅不用重排所有数字。状态流转必须沿固定路径走草稿只能提交为审批中审批中只能变为已生效或已作废已生效之后才能转为履行中或已归档。代码里要限制这种跳跃不能放任Status字段随便UPDATE。审批记录表在此刻就有用了每次变更时插入一条ApprovalLog记录从哪个状态到哪个状态、是谁在什么时候改的。这个表既是审计留痕也是将来追溯合同全生命周期的唯一线索。3.3 建表SQL脚本从能跑通到扛得住半年数据量数据库脚本是整套系统的地基。很多人直接复制网上教程建两张表就开工结果跑到三个月后查询越来越慢到期提醒越来越不准。这里给一套足够支撑小公司几百上千份合同的表结构SQL Server语法。CREATE TABLE ContractInfo ( Id INT IDENTITY(1,1) PRIMARY KEY, ContractNo NVARCHAR(50) NOT NULL, Title NVARCHAR(200) NOT NULL, Amount DECIMAL(18,2) NOT NULL DEFAULT 0, Currency NVARCHAR(10) NOT NULL DEFAULT NCNY, PartyA NVARCHAR(200) NOT NULL, PartyB NVARCHAR(200) NOT NULL, SignDate DATETIME NULL, EffectiveDate DATETIME NULL, ExpireDate DATETIME NULL, Status INT NOT NULL DEFAULT 0, TempFlag BIT NOT NULL DEFAULT 0, Remark NVARCHAR(500) NULL, CreateUser NVARCHAR(50) NOT NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); GO CREATE INDEX IX_ContractInfo_ExpireDate ON ContractInfo(ExpireDate); CREATE INDEX IX_ContractInfo_Status ON ContractInfo(Status); CREATE INDEX IX_ContractInfo_ContractNo ON ContractInfo(ContractNo);IDENTITY(1,1)自增主键不用业务编号做主键——合同编号ContractNo会重排、会改格式一旦设为唯一键后修改成本极高。Amount用DECIMAL(18,2)而不是float或double是因为合同金额是精确数值浮点类型在累加和比较时会出误差。TempFlag字段标定这是正式合同还是草稿数据避免统计合同额时把凑数的草稿行也算进去。ExpireDate和Status上各建了索引这是到期提醒和状态筛选的查询条件。合同数据量小的时候索引看不出来价值但等数据过千条不带索引的ExpireDate区间查询会明显变慢。建索引的代价是插入和更新时多写一次索引合同系统写少读多完全值得。4. 核心功能实现用Dapper把增删改查写成能交付的代码4.1 数据访问层为什么Dapper比EF Core更顺手合同管理系统的数据访问层不需要复杂的延迟加载和状态追踪。EF Core的导航属性确实省事但合同系统的列表查询往往是多条件动态拼SQLEF的表达式树写起来绕生成的SQL还不好肉眼审查。Dapper是半自动ORMSQL全部自己写映射交给框架出错时排查链路短。Repository类统一封装所有SQL操作后窗体里的按钮事件只调用方法。这样做的好处是SQL只存在一个文件里后面修改表结构或优化索引不用满项目翻按钮代码。public ListContractInfo SearchContracts(string keyword, DateTime? beginDate, DateTime? endDate, int status) { using var conn new SqlConnection(_connStr); var sql new StringBuilder(SELECT * FROM ContractInfo WHERE 11); var param new DynamicParameters(); if (!string.IsNullOrWhiteSpace(keyword)) { sql.Append( AND (ContractNo LIKE kw OR Title LIKE kw OR PartyA LIKE kw)); param.Add(kw, % keyword %); } if (beginDate.HasValue) { sql.Append( AND SignDate begin); param.Add(begin, beginDate.Value.Date); } if (endDate.HasValue) { sql.Append( AND SignDate endNextDay); param.Add(endNextDay, endDate.Value.Date.AddDays(1)); } if (status 0) { sql.Append( AND Status status); param.Add(status, status); } return conn.QueryContractInfo(sql.ToString(), param).ToList(); }WHERE 11是一种拼SQL技巧后续所有条件都用AND开头不用判断「这是第一个条件吗」。keyword里用户输入什么就拼什么但值是参数化传入的%符号也在参数里所以不存在注入风险。日期区间用半开区间——SignDate 当天零点 并且 次日零点这样能覆盖当天一整天避免「今天签的合同查不到」这种经典bug。status参数默认0在表单里代表「全部」真正的草稿状态是0所以条件用status 0才启用过滤否则会把草稿数据一起过滤掉。4.2 新增与修改参数化写法和两个易错点合同的增删改查里新增和修改最容易出问题的地方反而不在SQL本身而在C#侧的日期和空值处理。界面上的日期选择器可能不选值文本框可能是空串这些都要在进入数据库之前处理干净。public int SaveContract(ContractInfo contract) { using var conn new SqlConnection(_connStr); if (contract.Id 0) { var sql INSERT INTO ContractInfo (ContractNo, Title, Amount, Currency, PartyA, PartyB, SignDate, EffectiveDate, ExpireDate, Status, TempFlag, Remark, CreateUser) VALUES (ContractNo, Title, Amount, Currency, PartyA, PartyB, SignDate, EffectiveDate, ExpireDate, Status, TempFlag, Remark, CreateUser); SELECT CAST(SCOPE_IDENTITY() AS INT);; return conn.QuerySingleint(sql, contract); } else { var sql UPDATE ContractInfo SET ContractNoContractNo, TitleTitle, AmountAmount, CurrencyCurrency, PartyAPartyA, PartyBPartyB, SignDateSignDate, EffectiveDateEffectiveDate, ExpireDateExpireDate, RemarkRemark WHERE IdId; return conn.Execute(sql, contract); } }这里的判断逻辑是Id0视为新增否则视为更新。SCOPE_IDENTITY()取当前会话刚插入的自增主键而且是在同一个连接和事务上下文里不会被其他并发插入干扰。更新语句刻意没有改Status和CreateUser——状态变更走专用的审批方法创建人不能通过编辑界面篡改。两个易错点新增时SignDate等日期字段如果界面没填C#侧要赋DateTime的默认值而不是null否则数据库可能报「不能将DBNull转换为DateTime」。金额字段同理TextBox转decimal时要先做TryParse用户输入「abc」不会弹崩溃。4.3 状态变更事务保证合同和审批记录不脱节状态变更必须和审批日志写进同一个事务。如果先UPDATE状态再INSERT日志中间进程崩溃状态变了但日志没了后面审计对不上。反过来先写日志再更新状态日志里多了根本没发生的变更记录。两个都错正解是包在一个事务里。public bool ChangeStatus(int contractId, int fromStatus, int toStatus, string userName, string comment) { using var conn new SqlConnection(_connStr); using var tx conn.BeginTransaction(); try { var rows conn.Execute( UPDATE ContractInfo SET StatusToStatus WHERE IdId AND StatusFromStatus, new { ToStatus toStatus, Id contractId, FromStatus fromStatus }, tx); if (rows 0) return false; conn.Execute( INSERT INTO ApprovalLog (ContractId, FromStatus, ToStatus, Comment, CreateUser, CreateTime) VALUES (ContractId, FromStatus, ToStatus, Comment, CreateUser, GETDATE()), new { ContractId contractId, FromStatus fromStatus, ToStatus toStatus, Comment comment, CreateUser userName }, tx); tx.Commit(); return true; } catch { tx.Rollback(); return false; } }注意UPDATE语句里带了StatusFromStatus这个条件。多了一层校验如果当前状态已经不是界面显示的那个状态说明有人改了数据更新影响行数为0直接返回false不写日志。这能挡住两个用户同时审批同一份合同的并发问题。事务部分BeginTransaction()之后所有操作都要把事务对象传给Execut的最后看tx.Commit(), 一旦Commit成功状态和日志同时可见; 中间任何一步抛异常Rollback把状态和日志都撤回去。这套做法在合同场景里是刚需——审批记录丢了将来法律纠纷时没人说得清这份合同什么时候生效的。4.4 到期提醒查询不只是SQL还有任务调度到期提醒是合同管理系统的价值点之一。SQL写法本身不难查ExpireDate在某个区间内的合同。SELECT ContractNo, Title, PartyA, PartyB, ExpireDate FROM ContractInfo WHERE Status NOT IN (50, 90) AND ExpireDate today AND ExpireDate DATEADD(DAY, 30, today);这里排除已归档和已作废的合同,N.DATEADD(DAY, 30, today)算的是30天后的零点加上ExpireDate 就是「从今天到未来30天」。问题在于谁来触发这条SQL——WinForms程序不会自己醒来跑。常见做法是做一个无界面的检查exeWindows任务计划每天9点运行一次发现有到期合同就弹提醒。这个exe独立于主程序主程序关了它也能跑这就是标题里「源码数据库」这类方案落地时最容易被忽略的一环程序功能做全了但没人触发。5. 避坑与排查合同系统最常见的5个翻车现场5.1 现象新电脑上打开程序报「无法连接到数据库」或「Cannot open database」这是还原项目后最常见的错误。原因一般有三个SQL Server Express服务没启动、连接字符串里的Initial Catalog和实际库名不一致、或者登录模式是Windows认证但程序用了SQL账号跑。排查路径第一步先打开SQL Server Management Studio用Windows身份登录看左边列表里有没有目标数据库。能看到库说明数据库还原没问题问题在连接字符串看不到库说明还原步骤没走完。解决确认实例名Express默认是.\SQLEXPRESS开发机没装Express装了开发版的话要改为.\MSSQLSERVER或直接写localhost。5.2 现象今天到期的合同到期提醒没发出来这个问题我在交付的系统里踩到过。合同数据的ExpireDate存的是当天的某个时间点比如2025-06-18 15:30而提醒程序的today是2025-06-18 00:00。你如果用ExpireDate today做比较15:30比00:00大根本查不出来。而且提醒程序是每天定时跑的当天跑了发现明天到期结果当天到期的那份反而一直漏。解决比较之前统一转日期格式要么SQL里用CONVERT(date, ExpireDate)要么在参数侧把结束日期DATEADD(DAY, 1, today)再比较总之别让时间部分参与判断。5.3 现象合同金额显示成「10000.0000000001」或者小数位对不上原因几乎可以断定是用float或double存了金额。浮点数在二进制里无法精确表示0.1累加后误差会放大。合同金额属于钱必须用decimal(18,2)。C#侧对应的类型也是decimal。排查时可以打开数据库表设计看Amount字段的数据类型如果是float或real要写ALTER TABLE脚本转成decimal——注意先备份数据因为转换时精度不够会四舍五入。5.4 现象合同状态变了但ApprovalLog表里查不到任何记录多数原因是有人直接手改了数据库或者新增了一个不经过ChangeStatus方法的编辑入口。我见过比较严重的案例开发图省事在编辑合同页面顺手加了个状态下拉框保存时走的是SaveContract的UPDATE语句原原本本绕过了审批日志。解决业务代码里强制状态变更只能走ChangeStatus数据库层面可以加约束禁止直接UPDATE ContractInfo的状态列但这个做法对运维太麻烦。代码审查时把这作为一个硬性检查项。5.5 现象把数据库文件.mdf直接发给别人对方附加失败.mdf附加数据库有两个癖好——要求文件权限和版本匹配。比如SQL Server 2016建的库用SQL Server 2012附加就会报版本不支持。更坑的是.mdf文件在使用中被占用必须先停服务或分离才能拷走。落地时我建议的打包方式不是给.mdf而是把建库脚本和初始化数据做成一个.sql文件新机器执行一次就重建完全绕开附加的权限和版本问题。如果是已有数据要迁移用备份还原而不是附加BACKUP出一条.bak目标机器RESTORE进去。6. 进阶从能用走到好用的三个验证与加固手段进度条显示在界面上执行备份时可以加一段压缩——备份文件直接拖到另一台机器RESTORE一下看能不能完整打开验证备份可用。最后是统计报表按季度汇总合同金额时常用SQL写法是按ExpireDate或SignDate配合GROUP BY DATEPART(QUARTER, SignDate)。但要注意的是合同金额的统计口径有两种——按签订时间归集和按生效时间归集两者会差出不少。做报表前先和业务确认按哪个口径不然数字对不上业务那边的表。我自己的习惯是把这三件事做完再交付到期提醒做一次提前一周的造数测试、备份做一次恢复演练、报表字段和业务核对一遍。第一次做合同系统时恰恰是那句话「状态没留痕」险些出了大问题。后来每个状态变更都强制走审批日志心里踏实多了。这套做下来系统才敢说不是个能看不能用的Demo。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑