
简介这是一份基于C#开发的药店管理系统完整项目源码适合作为毕业设计、课程期末作业也适合希望深入.NET桌面开发的中级开发者。压缩包共912个文件、约10.65MB其中387个cs源文件为主干还包括resx/resources界面资源、dll/exe编译产物、rpt报表文件以及config/xsd/settings等工程配置内含多个sln解决方案与csproj工程文件可用Visual Studio直接打开方便对照编译与调试。当前已有73人学习浏览正在做相关课题的读者可直接参考完整业务系统结构。通过学习这份代码可系统练习ADO.NET或Entity Framework的数据库访问、Windows Forms/WPF界面搭建、MVC分层控制、多线程处理以及用户权限管理等关键知识点同时借助报表和界面资源理解药店进销存、销售记录等典型业务场景的数据流转与实现思路为二次开发或答辩讲解提供较好参照。资源来源于网络分享仅供学习参考请结合官方文档二次开发。1. 这个C#药店管理系统值得下一个能跑完答辩全流程的项目期末还剩两周才想起课程设计还没跑通是多数人看到这类C#药店管理系统源码包的第一反应。解压后见到一堆 ResolveAssemblyReference.cache打开解决方案各种黄色警告这种状态最磨人。这套项目主体是 WinForms ADO.NET SQL Server登录、药品增删改查、库存预警、销售出库、用户权限都有完整实现数据库脚本也在包内。它能正面回答老师三个高频问题数据存在哪、事务怎么保证、权限怎么控制。适合三类人赶毕设需要完整演示系统的本科生期末作业要求C#连数据库的高职生以及想抄一套分层写法的新手开发者。2. 系统架构与数据库设计四张表把药店业务撑起来2.1 技术选型WinForms ADO.NET SQL Server为什么这么搭选WinForms而不是WPF不是因为WPF不好而是答辩场景决定了复杂度。WinForms控件拖拽、事件绑定老师一眼能看懂WPF的样式模板和数据绑定虽然更现代但一旦被问到依赖属性、数据模板很少有人能现场讲透。这套项目用WinForms等于把复杂度全部放在业务逻辑和SQL上UI部分十分钟就能演示完对课程设计来说是最稳的组合。数据访问层在ADO.NET和EF Core之间我更建议保留ADO.NET。EF Core写起来爽几行代码完成增删改查但老师爱问的往往是SqlConnection、DataTable、事务隔离级别这些基础概念。ADO.NET代码量虽大每个方法都能对上一门数据库课的知识点回答起来有底气。这也是很多课程设计模板坚持ADO.NET的原因——不是为了压题是真的好讲。如果你见过网上有人把WinForms硬套MVVM别学普通三层就足够硬上MVVM工作量翻倍还容易露怯。数据库选型上SQL Server Express比MySQL稳。Express免费、安装程序下一步到底、SSMS图形界面操作、和ADO.NET同生态。MySQL在Windows上要额外装驱动连接串一个参数写错就连不上临答辩碰上最急人。项目里常见的实例名是.\SQLEXPRESS这一点在避坑章节会单独说。2.2 四张核心表的字段设计药店业务看着复杂核心就四个实体用户、角色、药品、销售流水。库存不单独建表用Drugs表的Stock字段直接表示因为课程设计里库存只被两处使用——进货增加、销售减少单独建Inventory表只会徒增联表。预警阈值也用字段存查询时Stock StockWarning直接标红不用额外维护规则表。表名关键字段说明DrugsDrugId自增主键、DrugName、Specification规格、Unit单位、Manufacturer厂家、PurchasePrice进价、SalePrice售价、Stock库存、StockWarning预警阈值药品信息与库存合一带IsDeleted软删标记SalesSaleId自增主键、DrugId、Quantity、TotalPrice、OperatorId操作员、SaleTime销售时间每笔销售一条流水外键关联Drugs和UsersUsersUserId自增主键、UserName唯一、PasswordHash、Salt、RoleId密码不只存哈希还存盐值后面细讲RolesRoleId业务编号、RoleName只放管理员和收银员两类复杂权限用不上字段命名用英文列名不要带中文。见过项目用中文列名DataGridView绑定HeaderText倒是方便但写SQL时一个编码不一致就乱码。实际显示名在代码里统一设置HeaderText存储层保持规范就不会出妖蛾子。2.3 建库建表脚本与初始数据CREATE DATABASE PharmacyDB; GO USE PharmacyDB; GO CREATE TABLE Roles ( RoleId INT PRIMARY KEY, RoleName NVARCHAR(20) NOT NULL ); CREATE TABLE Users ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL UNIQUE, PasswordHash NVARCHAR(32) NOT NULL, Salt NVARCHAR(8) NOT NULL, RoleId INT NOT NULL, CONSTRAINT FK_Users_Role FOREIGN KEY (RoleId) REFERENCES Roles(RoleId) ); CREATE TABLE Drugs ( DrugId INT IDENTITY(1,1) PRIMARY KEY, DrugName NVARCHAR(100) NOT NULL, Specification NVARCHAR(50), Unit NVARCHAR(10), Manufacturer NVARCHAR(100), PurchasePrice DECIMAL(10,2), SalePrice DECIMAL(10,2) NOT NULL, Stock INT NOT NULL DEFAULT 0, StockWarning INT NOT NULL DEFAULT 10, IsDeleted BIT NOT NULL DEFAULT 0 ); CREATE TABLE Sales ( SaleId INT IDENTITY(1,1) PRIMARY KEY, DrugId INT NOT NULL, Quantity INT NOT NULL, TotalPrice DECIMAL(10,2) NOT NULL, OperatorId INT NOT NULL, SaleTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Sales_Drug FOREIGN KEY (DrugId) REFERENCES Drugs(DrugId), CONSTRAINT FK_Sales_Operator FOREIGN KEY (OperatorId) REFERENCES Users(UserId) ); INSERT INTO Roles (RoleId, RoleName) VALUES (1, N管理员); INSERT INTO Roles (RoleId, RoleName) VALUES (2, N收银员);几个关键点说明一下。Drugs表加了IsDeleted位做软删除药品一旦产生过销售流水物理删除会被外键约束拦住直接报错用软删标记业务上更合理界面查询统一加IsDeleted 0条件。DECIMAL(10,2)是金额字段的标准写法10位总精度、2位小数单价总价都够。NVARCHAR统一处理中文VARCHAR在中文环境容易出现乱码存储多占点空间换省心值得。脚本里没有插入管理员账号原因很简单PasswordHash是密码加盐之后的MD5手写哈希非常容易算错。程序第一次启动时自动检测Users表是否为空为空就用固定算法生成管理员这部分代码在第3章给出。Roles两条初始数据可以直接手工插因为它不涉及密码和哈希。如果你把别人的初始化脚本整段复制记得连Salt值一起复制否则登录永远验证不过。3. 登录与权限模块MD5加盐、参数化查询和首个管理员账号3.1 登录验证MD5加盐与参数化查询登录模块是整套系统的门面也是老师最爱问的模块。最常见的错误是前端把密码直接拼进SQL字符串SELECT * FROM Users WHERE UserName name AND Password pwd 输入框填个 OR 11整张表都能被拖出来。所以DAL层所有查询必须参数化这不是从众是答辩时能不能站稳的问题。public User ValidateLogin(string userName, string password) { string sql SELECT UserId, UserName, PasswordHash, Salt, RoleId FROM Users WHERE UserName UserName; using (SqlConnection conn new SqlConnection(_connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(UserName, userName); conn.Open(); using (SqlDataReader reader cmd.ExecuteReader()) { if (reader.Read()) { string hash MD5Helper.ComputeMD5(password reader[Salt].ToString()); if (hash reader[PasswordHash].ToString()) { return new User { UserId Convert.ToInt32(reader[UserId]), UserName reader[UserName].ToString(), RoleId Convert.ToInt32(reader[RoleId]) }; } } return null; // 用户名不存在或密码错误统一返回null } } }这段代码的逻辑是先按用户名查出该用户对应的Salt和PasswordHash再在内存里把用户输入的密码和盐拼起来计算MD5比对一致才算登录通过。对比两次哈希而不是直接比对明文密码数据库里即使泄露对方拿到的也不是能直接使用的密码。统一返回null还有个好处登录界面不用区分「用户不存在」和「密码错误」避免暴露用户名的存在性。参数化查询那块AddWithValue会隐式推断参数类型简单字符串没问题日期和数字类型偶尔会推断错所以生产环境很多老手会用Add方法显式指定SqlDbType。课程设计里用AddWithValue讲得通老师问就说清楚它只是方便不代表规范。3.2 首次启动自动创建管理员网上模板最大的坑之一是初始账号admin的密码哈希来自别人的环境你复制过来根本登录不了。这套项目的处理方式是启动时检查Users表为空就自动插入admin密码固定为123456盐值用Guid实时生成。这样不管数据库文件拷贝到哪台机器第一次启动永远存在可登录的账号。public static void EnsureAdminIfEmpty() { using (SqlConnection conn new SqlConnection(_connStr)) { conn.Open(); using (SqlCommand cmd new SqlCommand(SELECT COUNT(*) FROM Users, conn)) { if ((int)cmd.ExecuteScalar() 0) return; } string salt Guid.NewGuid().ToString(N).Substring(0, 8); string hash MD5Helper.ComputeMD5(123456 salt); string sql INSERT INTO Users(UserName, PasswordHash, Salt, RoleId) VALUES(Nadmin, Hash, Salt, 1); using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(Hash, hash); cmd.Parameters.AddWithValue(Salt, salt); cmd.ExecuteNonQuery(); } } }在程序入口的Form_Load里调用一次就行。注意Salt取了8位随机字符串PasswordHash列定义为32是因为MD5输出32个十六进制字符。如果你把摘要算法换成SHA256列长度要跟着改成64这个在改代码时最容易漏掉。注意如果从别人那里拿初始化脚本一定要连同Salt一起复制只复制PasswordHash密码永远验证不过。3.3 权限控制菜单和按钮两级控制权限控制分成菜单可见性和按钮可用性两部分。管理员能看「系统管理」菜单和「删除药品」按钮收银员只留「开单收银」。最简单的实现是登录成功后把当前用户的RoleId存到全局的UserSession里界面在加载时根据角色设置控件状态不用每次点击都回查数据库。if (UserSession.Current.RoleId 1) // 管理员 { btnDeleteDrug.Visible true; miSystem.Visible true; } else // 收银员 { btnDeleteDrug.Visible false; miSystem.Visible false; }这段逻辑放在主窗体的Load事件里。要心里有数按钮隐藏不等于安全懂行的人反射调用照样能跑通方法但课程设计的目标是演示和答辩界面层控制足够。真正严格的权限控制应该在BLL层再验证一道角色判断而不是只靠UI。3.4 登录日志异步写入与多线程系统里还应该记录谁在什么时候登录了答辩时展示「日志模块」是加分项。但直接把日志写在登录方法里会拖慢进入主界面的响应所以常见做法是Task.Run开后台线程去写登录验证通过后就交给线程池。Task.Run(() { LogHelper.WriteLoginLog( UserSession.Current.UserId, UserSession.Current.UserName, DateTime.Now ); });这里有个经典坑日志代码里如果访问了界面控件后台线程直接操作控件会抛「线程间操作无效」。用System.Windows.Forms.Timer定时刷新库存界面没问题因为它跑在UI线程一旦换成System.Timers.Timer或Task.Run刷新DataGridView时就要用Control.Invoke把操作切回UI线程。有经验的人看到报错里写着「线程间操作无效」立刻就知道踩了这条线。4. 药品管理主流程数据绑定、库存预警与事务化销售4.1 药品管理界面DataGridView的数据绑定与刷新药品管理界面核心是一个DataGridView加增删改查按钮。加载数据的标准写法是把DAL层查出的DataTable直接赋给DataSource列头在绑定后设置成中文方便阅读。private void RefreshDrugGrid() { DataTable dt DrugService.GetAllDrugs(); // DAL层返回查询结果 dataGridViewDrugs.DataSource dt; dataGridViewDrugs.Columns[DrugId].HeaderText 编号; dataGridViewDrugs.Columns[DrugName].HeaderText 名称; dataGridViewDrugs.Columns[Specification].HeaderText 规格; dataGridViewDrugs.Columns[SalePrice].HeaderText 售价; dataGridViewDrugs.Columns[Stock].HeaderText 库存; dataGridViewDrugs.Columns[IsDeleted].Visible false; }操作完数据一定要重新调用RefreshDrugGrid不能只刷新当前行。DataGridView的数据源是整个DataTable它不会自动感知数据库里的变化。IsDeleted列设成不显示别让用户看到软删标记这只对程序内部有意义。一个经常被问到的点是为什么用DataTable而不是ListDataTable可以直接作为DataSource列信息自带表格展示零适配课程设计追求的是少写代码且能讲清楚。4.2 库存预警阈值查询与标红显示库存预警的本质就是一个带条件的查询。阈值存在药品表的StockWarning字段里当库存小于等于阈值时说明需要补货。public DataTable GetStockWarningList() { string sql SELECT DrugId, DrugName, Stock, StockWarning FROM Drugs WHERE IsDeleted 0 AND Stock StockWarning; // 执行查询并返回DataTable }界面标红的做法是在DataGridView绑定完成后遍历行判断当前库存和预警值的关系命中就把背景色改掉。foreach (DataGridViewRow row in dataGridViewDrugs.Rows) { DataRowView drv row.DataBoundItem as DataRowView; if (Convert.ToInt32(drv[Stock]) Convert.ToInt32(drv[StockWarning])) { row.DefaultCellStyle.BackColor Color.LightCoral; } }预警阈值直接存在药品行字段里比单独建规则表更适合课程设计。带条件的UPDATE还能顺带防止并发超卖SQL在数据库端判断Stock Qty比先查再判断安全得多。4.3 销售出库一个事务解决库存与流水的一致性销售出库是最容易写错的模块也是老师必问的事务应用场景。正确做法是把「读售价、扣库存、写流水」三步放进同一个SqlTransaction保证要么全部成功要么全部回滚。public bool SubmitSale(int drugId, int quantity, int operatorId) { using (SqlConnection conn new SqlConnection(_connStr)) { conn.Open(); SqlTransaction tran conn.BeginTransaction(); try { // 1. 先读售价 string selSql SELECT SalePrice FROM Drugs WHERE DrugId DrugId AND IsDeleted 0; using (SqlCommand selCmd new SqlCommand(selSql, conn, tran)) { selCmd.Parameters.AddWithValue(DrugId, drugId); object result selCmd.ExecuteScalar(); if (result null) throw new Exception(药品不存在或已停售); decimal price Convert.ToDecimal(result); decimal totalPrice price * quantity; // 2. 扣库存条件带上Stock Qty防止超卖 string updSql UPDATE Drugs SET Stock Stock - Qty WHERE DrugId DrugId AND Stock Qty; using (SqlCommand updCmd new SqlCommand(updSql, conn, tran)) { updCmd.Parameters.AddWithValue(Qty, quantity); updCmd.Parameters.AddWithValue(DrugId, drugId); if (updCmd.ExecuteNonQuery() 0) throw new Exception(库存不足); } // 3. 写销售流水 string insSql INSERT INTO Sales(DrugId, Quantity, TotalPrice, OperatorId, SaleTime) VALUES(DrugId, Qty, Price, Op, GETDATE()); using (SqlCommand insCmd new SqlCommand(insSql, conn, tran)) { insCmd.Parameters.AddWithValue(DrugId, drugId); insCmd.Parameters.AddWithValue(Qty, quantity); insCmd.Parameters.AddWithValue(Price, totalPrice); insCmd.Parameters.AddWithValue(Op, operatorId); insCmd.ExecuteNonQuery(); } } tran.Commit(); return true; } catch { tran.Rollback(); return false; } } }这里有几个关键点。事务保证扣库存和写流水同生共死不会出现销售单有记录但库存没减少的半截账。防止超卖不是靠程序先SELECT再IF判断而是UPDATE语句里带上Stock Qty条件数据库在更新时判断并发场景下比先查后改可靠。总价由售价乘数量在事务内算而不是前端把金额传进来避免前端传个负数导致账目出问题。所有SqlCommand都要传入tran对象漏传一个的话那条命令会在自己的事务外自动提交等于把整个事务撕开一个口子。这是新手最容易忽略的细节检查时逐条核对每一条命令是否都带了tran。4.4 批量导入用SqlBulkCopy把期初数据一次灌入药品数量少时逐条Insert没问题但上百条数据循环Insert会明显卡顿。期初初始化更推荐SqlBulkCopy它走批量写入性能能差一个数量级。using (SqlBulkCopy bulk new SqlBulkCopy(conn)) { bulk.DestinationTableName Drugs; bulk.ColumnMappings.Add(DrugName, DrugName); bulk.ColumnMappings.Add(Specification, Specification); bulk.ColumnMappings.Add(Unit, Unit); bulk.ColumnMappings.Add(Manufacturer, Manufacturer); bulk.ColumnMappings.Add(PurchasePrice, PurchasePrice); bulk.ColumnMappings.Add(SalePrice, SalePrice); bulk.ColumnMappings.Add(Stock, Stock); bulk.ColumnMappings.Add(StockWarning, StockWarning); bulk.WriteToServer(dt); }ColumnMappings按列名映射DataTable里的列名必须和目标表完全一致。如果目标表结构改过字段名或类型映射会静默失败表现为数据写不进去或全是默认值。改表结构时顺手检查一下映射能省去半小时摸不着头脑的排查时间。5. 避坑清单五个翻车点与对应的后悔药5.1 连接串写死换机器就连不上现象把项目文件夹拷到同学电脑运行报错用户sa登录失败或无法连接到服务器。原因连接串里写的是你自己机器的实例名比如Data SourceDESKTOP-XXXX对方机器实例名不一样。另外SQL Server默认不开SQL Server身份验证只开Windows身份验证的话换了登录账号自然连不上。解决连接串统一放App.config写通用实例名.\SQLEXPRESS让对方先用SSMS确认自己实例名再启动程序。更稳妥的方案是程序启动时读取配置第一次运行弹窗让用户填服务器名别把连接串硬编码在DAL构造函数里那样连改的机会都没有。5.2 DataGridView显示空数据改完忘重新绑定现象药品新增成功数据库里有记录但界面DataGridView上死活不显示重启程序才出现。原因新增走的是Insert方法Insert之后没有重新查询数据源。界面上的DataTable还是旧数据DataSource没变化表格自然不会刷新。解决所有增删改操作完成后统一调用RefreshDrugGrid()重新拉取数据。想省事也可以维护内存中的DataTable并在操作后调用ResetBindings()但课程设计里用重新查询的方式代码更好讲也不容易出状态同步的问题。5.3 销售单保存成功但库存没减少现象前台录完销售单Sales表有流水但Drugs表的Stock字段没变化。原因代码只执行了INSERT INTO Sales没有写UPDATE Drugs。或者UPDATE和INSERT虽然都写了但不在同一个SqlTransaction里中间某步异常跳过了后半段。解决用第4章的SubmitSale模式把读价、扣库存、插流水放进一个事务所有SqlCommand绑定同一个tran对象。自检办法很简单程序跑完打开数据库看Sales和Drugs两张表是否同步变化不同步说明事务没封装对。5.4 UI里写SQL的「分层病」现象一个Button的Click事件里写了三十行代码从new SqlConnection到ExecuteNonQuery再到MessageBox另一个按钮需要类似功能就复制粘贴一遍。原因没分层UI层直达数据库。平时能跑但答辩时老师问「改一下售价会影响哪些地方」可能答不上来因为逻辑全散在按钮事件里没有层次。解决按经典三层整理DAL层放SQL和参数封装BLL层放业务校验和事务控制UI层只处理事件和界面绑定。即使只是把SQL迁移到独立类里也比堆在按钮事件里强得多。这套项目本身就是三层结构按这个路径去读代码很快能找到对应关系。5.5 换台电脑运行就崩缺DLL或版本冲突现象到答辩机器运行程序启动即崩溃提示未能加载文件或程序集 System.Data.SqlClient或提示版本冲突。原因项目引用的程序集版本和本机全局程序集缓存不一致尤其是System.Data.SqlClient在.NET Framework和.NET Core下的版本差异很大。也可能是复制项目时漏了packages文件夹。解决发布时用Release配置确认引用的DLL都设置「复制本地 true」到演示机直接拷贝bin\Release整个文件夹运行不要只拷exe。如果你担心源码被顺手拿走答辩前可以对bin目录做混淆但混淆后的程序必须先在自己机器完整跑一遍再上演示机别为了防反编译做出一个启动不了的演示程序。6. 答辩前必做的三件事重置脚本、演示动线和代码走读6.1 一键重置脚本答辩前一天最该做的事是把开发期间的脏数据清空恢复成初始的干净状态。手工删数据容易漏自增主键不会自动归零销售单号从几百开始非常难看。下面这组脚本在SSMS里跑一遍数据就能回到起跑线。USE PharmacyDB; -- 先清带外键的子表 DELETE FROM Sales; DELETE FROM Drugs; DELETE FROM Users; -- 重置自增列保证演示数据从 1 开始 DBCC CHECKIDENT(Sales, RESEED, 0); DBCC CHECKIDENT(Drugs, RESEED, 0); DBCC CHECKIDENT(Users, RESEED, 0); -- 重新补基础角色 INSERT INTO Roles (RoleId, RoleName) VALUES (1, N管理员); INSERT INTO Roles (RoleId, RoleName) VALUES (2, N收银员); GORoles表不需要重置自增因为它的主键是手工指定的业务编号。Users清空后程序首次启动会自动重建admin账号密码仍是123456。重置完最好手动加两条演示药品库存设到预警值附近方便现场触发标红。6.2 演示动线演示顺序别按菜单从上到下点要按数据流设计。先登录接着新增一味感冒药并设置预警阈值再进销售开单把库存压到阈值以下触发预警最后用报表核对流水。全程大约十分钟逻辑闭环老师顺着思路走不容易被细节带偏。登录展示admin账号和权限菜单。新增药品录入一盒感冒药库存设为22。销售出库分两次卖出把库存压到预警线以下。回到药品列表红色预警行出现讲阈值逻辑。打开销售报表核对刚才两笔单据的流水。换收银员账号登录管理员和收银员的按钮差异一目了然。6.3 代码走读重点答辩讲代码时别从窗体的Paint事件开始讲抓三个方法就够ValidateLogin讲参数化查询和MD5加盐SubmitSale讲事务边界和防超卖条件更新GetStockWarningList讲预警的业务规则。这三个方法覆盖数据访问、事务一致性和业务规则三类高频问题讲完基本能掌握答辩主动权。我自己带课程设计时吃过一次大亏演示前一晚在库里导了一堆测试数据第二天打开系统销售单号从7428开始老师瞥了一眼就问销售单号怎么回事。从那以后每次交付前都强制先跑一遍重置脚本再手动造一两个预警数据走完全流程确认没问题才敢说完成。希望帮到你。本文还有配套的精品资源点击获取