简介:基于C#的药店管理系统项目,专为计算机相关专业学生毕业设计或期末作业而整理,覆盖药品信息维护、销售记录、库存查询等常见业务,同时涉及其中的数据库访问、界面设计与权限控制核心模块。整套资源共912个文件,打包体积约10.65MB,以cs源码文件为主,辅以resx/resources资源文件、dll动态库、rpt报表、sln/csproj工程文件及config配置等,便于在Visual Studio中直接打开、编译和调试。项目结合Windows Forms/WPF界面,通过ADO.NET或Entity Framework访问数据库,并引入MVC分层思路实现数据、视图、控制分离,同时内置管理员、销售人员等不同角色的权限管理机制。已有73人浏览学习,适合需要快速搭建完整药店管理系统的同学,可从源码结构、界面设计、数据库脚本和报表模板中获得清晰的开发参考,也能作为二次改造的起点。
1. C#药店管理系统:毕业设计能跑起来,也能答辩讲清楚
药店管理系统是C#方向最常见的毕业设计选题之一,因为它把数据库、WinForms、权限控制、多线程这些核心知识点全串起来了,而且业务逻辑直观——药品入库、销售出库、库存预警、员工权限,每一步都能对应到明确的代码实现。这套资源我拆过一遍,项目的包结构很干净:MShop.StorageFrm负责库存与出入库,MShop.SystemFrm负责系统设置与用户管理,ResolveAssemblyReference.cache这类文件说明源码在Visual Studio里直接编译过,拿过来改个连接字符串就能跑通。它的价值不在于功能多花哨,而在于每个模块都能在答辩时拆出三层结构讲清楚:界面层做了什么、业务层怎么校验、数据层怎么访问。适合两类人:一是要做毕业设计但还没定题目的,二是期末作业需要一套完整可运行代码的。下面按落地顺序把架构、数据库、权限、报表、部署逐层拆开,最后把最容易翻车的几个坑单独列出来。
2. 数据库设计与项目结构:先把MShop.StorageFrm和MShop.SystemFrm的关系理清
2.1 六个关键表的字段设计与业务含义
药店管理系统的数据库设计,第一原则是库存表与流水表分离。很多新手会把销售记录直接写在药品表里,字段越加越多,最后库存数都算不对。这套项目的做法是拆成药品表、库存表、入库流水、销售流水、用户表、操作日志表,各自职责单一。
药品表(Drug)核心字段包括:DrugID主键、DrugName药品名称、Specification规格、Unit单位、PurchasePrice进货价、SalePrice销售价、Manufacturer生产厂家、ExpiryDate有效期、StockCount库存数量。注意这里StockCount是按当前库存冗余存储的,而不是每次都SUM流水表——对药店这种中小数据量场景,冗余一个库存字段可以极大简化查询逻辑,销售时UPDATE减库存,盘点时直接看这个字段对比实物数量。
库存表(Inventory)和药品表可以合并,但分开更规范。Inventory表记录BatchNo批号、StockInDate入库日期、SupplierID供应商、Quantity数量。为什么要单独拆?因为同一药品可能有两批货,进货价不同、有效期不同,合在Drug表里就没法按批号追溯了。这个细节在答辩时是加分项。
销售流水表(SaleRecord)字段:SaleID、DrugID、Quantity、UnitPrice、TotalAmount、SaleTime、OperatorID。这里要特意设计一个SaleTime默认值GETDATE(),避免应用程序层漏传时间导致NULL。流水表只做插入,不做更新删除——退药另写退回记录,不要物理删除流水,这是审计的基本要求。
用户表(User)字段:UserID、UserName、PasswordHash、RoleID、RealName。PasswordHash存的是MD5加盐后的值,而不是明文密码。RoleID关联到权限表,这样改一个用户的角色就能控制整组权限,不用逐个功能去配置。
操作日志表(Log)是很多课程设计会漏掉的,但这套项目里有SystemFrm模块,说明有系统管理功能。Log表记录UserID、ActionType、Description、LogTime,最简单的做法是用触发器或者业务层统一拦截,把增删改操作写进日志。答辩时讲到数据安全,这个表能撑起至少两个提问。
2.2 连接字符串与项目分层:从App.config到三个项目文件夹
项目里MShop.StorageFrm和MShop.SystemFrm是两个独立的WinForms项目,前者管库存业务,后者管系统配置。实际编译时它们会生成两个EXE,用配置文件统一数据库连接。在App.config里的连接字符串一般是这样的:
<connectionStrings> <add name="MShopDB" connectionString="Data Source=localhost;Initial Catalog=PharmacyDB;User ID=sa;Password=123456;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" /> </connectionStrings>参数说明:Data Source是SQL Server实例地址,本地开发写localhost就够,连远程数据库要改成IP地址加端口,比如192.168.1.100,1433。Initial Catalog是数据库名,需要在SQL Server里提前建好PharmacyDB。User ID和Password是SQL Server登录账号,很多坑都出在Windows身份验证和SQL身份验证搞混上。MultipleActiveResultSets=True这句很关键,如果代码里同时有DataReader读取又执行其他Command,不加这个会有运行时异常。
数据库访问层建议用一个DbHelper类统一封装SqlConnection和SqlCommand,而不是每个窗体里都写一遍连接代码。常见做法是写一个静态类:
public static class DbHelper { private static string connStr = ConfigurationManager.ConnectionStrings["MShopDB"].ConnectionString; public static DataTable ExecuteQuery(string sql, SqlParameter[] parameters) { using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); SqlDataAdapter adapter = new SqlDataAdapter(cmd); DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } } } }逻辑说明:每次查询都新建连接并显式Open,用完using自动关闭,避免连接泄漏。参数数组传入时,调用方负责创建SqlParameter对象,这比字符串拼接SQL安全得多——传参方式能拦截掉90%以上的SQL注入。DataAdapter.Fill是断开式填充,查询结果返回DataTable后连接就可以关了,数据在内存里操作。
业务层再做一层封装,比如DrugManager类里定义GetAllDrugs、AddDrug、UpdateStock、ReduceStock等方法,每个方法内部调用DbHelper,同时做参数校验。比如ReduceStock要先检查库存是否充足,不足就直接抛异常,不让SQL语句硬执行。项目里的StorageFrm对应库存操作界面,SystemFrm对应用户和权限管理界面,各窗体只负责收集界面输入并调用业务层,不直接写SQL,这个分层在答辩时必须能说清楚。
3. 登录认证与权限控制:MD5加盐和用户会话状态
3.1 MD5加盐哈希:不存明文密码的Login实现
用户表里PasswordHash字段存的是加盐后的MD5值,登录验证时把用户输入的密码加上盐值再做一次哈希,比对数据库里的值。加盐是为了防止两个用户密码相同导致哈希值相同,或者被彩虹表直接破解。
public static string GenerateSalt() { byte[] bytes = new byte[8]; using (var rng = new RNGCryptoServiceProvider()) { rng.GetBytes(bytes); } return Convert.ToBase64String(bytes); } public static string HashPassword(string password, string salt) { string combined = password + salt; using (MD5 md5 = MD5.Create()) { byte[] hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(combined)); return Convert.ToBase64String(hashBytes); } }参数说明:GenerateSalt每次生成8字节随机数,Base64编码后得到约12字符的盐字符串,每个用户独立盐值。HashPassword把密码和盐拼接后再哈希,这样即使两个用户密码相同,存储的哈希值也不同。逻辑说明:注册时生成盐并同时保存盐值和哈希值到User表,登录时读取该用户的盐值,重新计算哈希再比对。这个做法比直接对密码做MD5要安全一个量级,课堂演示时能清晰解释加密的流程。
权限判断不放在SQL里做,而是在C#代码里用当前登录用户的RoleID控制按钮可见性。比如管理员登录后能看到"用户管理"按钮,普通销售员登录则隐藏。常见做法是在登录成功后把UserID和RoleID存入一个全局静态类:
public static class UserSession { public static int CurrentUserID { get; set; } public static string CurrentUserName { get; set; } public static int CurrentRoleID { get; set; } public static bool HasPermission(int requiredRole) { return CurrentRoleID <= requiredRole; } }逻辑说明:设计一个简单的角色等级规则,比如管理员RoleID=1,店长RoleID=2,销售员RoleID=3,数字越小权限越高。HasPermission判断当前用户角色数字是否小于等于所需角色,小于等于就放行。这个写法虽然简单,但足够毕业设计使用,而且比每个按钮写死判断条件好维护得多——权限调整只改配置不碰代码。
3.2 登录窗体重定向与密码错误锁定策略
登录窗体的逻辑并不只是比对用户名密码这么简单,还要处理重定向和防暴力破解。常见设计是LoginForm作为启动窗体,验证成功后打开MainForm并关闭自己,而不是把MainForm嵌套在LoginForm内部。
主窗体构造函数接收登录用户的信息,保证每个打开的业务窗体都知道当前操作人是谁。操作日志里记录的OperatorID就有了来源。密码连续错误三次锁定该账号5分钟,这种策略给答辩加分很多。
private int failedCount = 0; private DateTime lockUntil = DateTime.MinValue; private void btnLogin_Click(object sender, EventArgs e) { if (DateTime.Now < lockUntil) { MessageBox.Show("账号已锁定,请稍后再试"); return; } string username = txtUsername.Text.Trim(); string password = txtPassword.Text; DataTable dt = DbHelper.ExecuteQuery( "SELECT UserID, UserName, PasswordHash, Salt, RoleID FROM [User] WHERE UserName=@uname", new SqlParameter[] { new SqlParameter("@uname", username) }); if (dt.Rows.Count == 0) { failedCount++; if (failedCount >= 3) { lockUntil = DateTime.Now.AddMinutes(5); failedCount = 0; } MessageBox.Show("用户名或密码错误"); return; } string storedHash = dt.Rows[0]["PasswordHash"].ToString(); string salt = dt.Rows[0]["Salt"].ToString(); string inputHash = HashPassword(password, salt); if (storedHash == inputHash) { UserSession.CurrentUserID = Convert.ToInt32(dt.Rows[0]["UserID"]); UserSession.CurrentUserName = dt.Rows[0]["UserName"].ToString(); UserSession.CurrentRoleID = Convert.ToInt32(dt.Rows[0]["RoleID"]); MainForm main = new MainForm(); main.Show(); this.Hide(); } else { failedCount++; if (failedCount >= 3) { lockUntil = DateTime.Now.AddMinutes(5); failedCount = 0; } MessageBox.Show("用户名或密码错误"); } }逻辑说明:先校验锁定时间是否已到,锁定期内任何点击都直接返回。查询用户表时把盐值和哈希一起取出,计算完哈希再比对,避免用字符串拼接SQL。登录成功后把会话信息存入UserSession,再打开MainForm——这里用的是this.Hide而不是this.Close,因为MainForm关闭时要能回到登录窗体,直接Close会把整个进程退掉。
参数说明:锁定次数3和锁定时间5分钟是硬编码,实际生产环境会从配置表读取。毕业答辩时如果被问到参数化问题,这个点可以直接回答"可以扩展成从数据库读取配置"。还有一个细节:错误提示统一用"用户名或密码错误",不区分是用户不存在还是密码不对,防止攻击者通过提示信息猜测有效用户名。
4. WinForms界面与数据绑定:DataGridView的显示与库存操作联动
4.1 药品列表加载与库存预警高亮
WinForms项目里DataGridView是最常用的数据展示控件,药店管理系统的药品列表基本都用它。但很多人的写法是手动拼DataTable再逐个遍历设置行颜色,代码冗余且容易出错。更规范的做法是把查询和显示分开,先用DataTable接收结果,再绑定到DataGridView,最后用CellFormatting事件做条件样式。
private void LoadDrugList() { DataTable dt = DbHelper.ExecuteQuery( "SELECT DrugID, DrugName, Specification, Unit, StockCount, ExpiryDate, SalePrice FROM Drug", null); dgvDrugList.DataSource = dt; dgvDrugList.Columns["DrugID"].HeaderText = "药品编号"; dgvDrugList.Columns["DrugName"].HeaderText = "药品名称"; dgvDrugList.Columns["DrugName"].Width = 150; } private void dgvDrugList_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (dgvDrugList.Columns[e.ColumnIndex].Name == "StockCount") { int stock = Convert.ToInt32(e.Value); if (stock <= 10) { e.CellStyle.BackColor = Color.LightCoral; e.CellStyle.ForeColor = Color.White; } } }逻辑说明:LoadDrugList把查询结果直接绑定到DataGridView的DataSource,设置中文列头提升可读性,设置列宽避免药品名被截断。CellFormatting是DataGridView的实时格式化事件,每一行绘制时触发一次,判断当前列是库存列且值小于等于10就标红。这样不需要额外写循环遍历所有行,性能更好,而且库存变化重新绑定数据源后样式自动刷新。
参数说明:库存预警阈值10是写死的示例值,真实项目会做成全局参数或者从配置表读取,不同类别的药品阈值不一样——头孢类抗生素预警值肯定比创可贴高。如果要动态实现,可以在Load里加一个int warnThreshold = GetWarnThreshold(),把硬编码替换掉。
4.2 销售出库的库存联动与事务处理
销售出库是药店系统最核心的操作,牵扯到两张表更新:SaleRecord插入销售记录,Drug表扣减库存。这里必须用事务保证一致性——如果插入记录成功但扣库存失败,数据就不一致了。新手最容易犯的错是在两个独立的ExecuteQuery里执行这些操作,一旦第二步报错,第一步的数据已经落库了。
public static bool SellDrug(int drugId, int quantity, decimal unitPrice, int operatorId) { string checkSql = "SELECT StockCount FROM Drug WHERE DrugID=@drugId"; SqlParameter[] checkParams = { new SqlParameter("@drugId", drugId) }; DataTable dt = ExecuteQuery(checkSql, checkParams); if (dt.Rows.Count == 0) throw new Exception("药品不存在"); int stock = Convert.ToInt32(dt.Rows[0]["StockCount"]); if (stock < quantity) throw new Exception("库存不足,当前库存:" + stock); using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); SqlTransaction trans = conn.BeginTransaction(); try { string sql1 = "INSERT INTO SaleRecord(DrugID, Quantity, UnitPrice, TotalAmount, SaleTime, OperatorID) " + "VALUES(@drugId, @quantity, @unitPrice, @total, GETDATE(), @operatorId)"; SqlCommand cmd1 = new SqlCommand(sql1, conn, trans); cmd1.Parameters.AddWithValue("@drugId", drugId); cmd1.Parameters.AddWithValue("@quantity", quantity); cmd1.Parameters.AddWithValue("@unitPrice", unitPrice); cmd1.Parameters.AddWithValue("@total", quantity * unitPrice); cmd1.Parameters.AddWithValue("@operatorId", operatorId); cmd1.ExecuteNonQuery(); string sql2 = "UPDATE Drug SET StockCount = StockCount - @quantity WHERE DrugID=@drugId AND StockCount >= @quantity"; SqlCommand cmd2 = new SqlCommand(sql2, conn, trans); cmd2.Parameters.AddWithValue("@quantity", quantity); cmd2.Parameters.AddWithValue("@drugId", drugId); int rows = cmd2.ExecuteNonQuery(); if (rows == 0) throw new Exception("库存扣减失败,可能库存不足"); trans.Commit(); } catch { trans.Rollback(); throw; } } return true; }逻辑说明:先查询当前库存做一次预检,这里不做实际扣减。真正的事务里先插入销售流水,再执行库存扣减,UPDATE语句带AND StockCount >= @quantity条件,如果库存不够则影响行数为0,主动抛异常触发回滚。这样即使两个操作之间有其他线程抢了库存,数据库层面的条件判断也会拦截住,避免超卖。事务Commit之前任何一个步骤失败,Rollback会把两个操作全部撤销。
参数说明:TotalAmount在代码里计算成quantity * unitPrice再传入,不在SQL里做乘法,这样调试时能看到实际参数值。AddWithValue虽然方便但在某些情况下SqlParameter猜测类型可能不准,更严谨的做法是显式声明new SqlParameter("@quantity", SqlDbType.Int)再赋值。毕业设计用AddWithValue足够,但如果代码里出现死锁或者类型转换异常,优先排查这个位置。
4.3 药品入库批次管理与DataGridView编辑提交
入库和销售是对称操作,但入库有一个销售没有的麻烦:批号和有效期。同一款药品不同批次的进货价和有效期都不一样,如果入库时不做批次记录,后面效期预警就没法做。系统里可以设计成入库单界面,用户选择药品、填数量、填批号、填有效期,点保存后同时更新Inventory表和Drug表的StockCount。
批次信息也绑定到DataGridView的一个Tab页上,用户选中某一行可以查看该批次的余量。这里的坑在于DataGridView默认的编辑模式是CellEdit,用户改完单元格不离开编辑状态就点保存,Value可能还没提交。处理方法是保存前调用dgvBatch.EndEdit()把当前编辑单元格的值提交到数据源,或者在BindingSource上调用EndEdit。这个细节不处理,用户填了数量点保存却显示保存成功但库存没变,会让人误以为代码有bug。
5. 药店系统避坑:编译缓存文件、连接失败和数据库还原三大常见问题排查
5.1 ResolveAssemblyReference.cache报错的真实含义
项目文件列表里出现多个ResolveAssemblyReference.cache文件,这个文件是Visual Studio解析程序集引用时生成的缓存,理论上不该提交到源码管理器里,但它存在不报错。真正会让新手困惑的是这个文件损坏后,VS会提示"ResolveAssemblyReference"相关的编译错误。遇到这个情况,打开项目目录手动删除所有bin、obj文件夹和*.cache文件,回到VS里右键解决方案选择"重新生成",VS会自动重建这些缓存。这个清理操作也是打包提交代码前必做的一步——把obj和bin目录加进.gitignore,避免别人拉代码时产生一堆无意义的冲突。
5.2 数据库连接字符串引发的登录失败
最常见的翻车现场:代码在开发机跑得好好的,换一台电脑就报"用户'sa'登录失败"或者"无法连接到数据库"。原因基本是SQL Server实例名不一样——开发机装的是默认实例,新电脑是命名实例SQLEXPRESS,Data Source=localhost自然连不上。排查步骤是先打开SQL Server Management Studio确认实例名,再检查SQL Server服务里"SQL Server (MSSQLSERVER)"和"SQL Server (SQLEXPRESS)"哪个在运行。连接字符串里的Data Source改成localhost\SQLEXPRESS,或者干脆在SSMS里新建一个别名,把实例固定成本地默认实例。
另一个更隐蔽的坑是SQL Server只开了Windows身份验证模式,但程序里用的是SQL账号登录。解决方法是打开SSMS连接服务器,右键服务器属性→安全性→选择"SQL Server和Windows身份验证模式",然后重启SQL Server服务。这一步做完再测试连接,如果还报错那就是sa账户被禁用了,在安全→登录名→sa上右键启用,设置一个新密码。这三步都做完,99%的连接问题都解了。
5.3 从附加数据库到MDF文件的路径权限
很多课程设计会让学生用"附加数据库"的方式把PharmacyDB.mdf挂到SQL Server里,这种方式在实验室电脑上没问题,但回家自己电脑上MDF文件放在桌面,SQL Server服务账户可能没有桌面目录的读取权限,导致附加失败。解决方法是把MDF和LDF文件放到SQL Server默认数据目录下,通常路径是C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\DATA,然后右键属性→安全→给MSSQL服务账号赋完全控制权限。用SQL脚本附加比操作界面的成功率高:
CREATE DATABASE PharmacyDB ON (FILENAME = N'C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\DATA\PharmacyDB.mdf'), (FILENAME = N'C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\DATA\PharmacyDB_log.ldf') FOR ATTACH;参数说明:两个FILENAME分别指向数据文件和日志文件,路径里的盘符和目录名必须和实际一致,用反斜杠转义。FOR ATTACH表示把现有文件附加进来而不是新建库,MDF和LDF文件必须同时存在且能正常读取。这个脚本执行成功后再测试程序连接,如果程序里的连接字符串还是连不上,回到5.2的排查流程。
5.4 解决DataGridView刷新后滚动条跳到顶部的体验问题
用户每次销售后都要重新加载库存列表,数据一刷新,DataGridView从第一行开始显示,操作频繁时会让人很烦躁。优化方法是记录刷新前选中的DrugID,重新绑定数据后根据这个ID找到对应行,设置CurrentCell或者FirstDisplayedScrollingRowIndex。每次不要整表刷新,而是只更新受影响的行。这个改动代码量不大但体验提升明显,毕业设计答辩时拿这个点讲用户体验优化,比讲技术难度更打动老师。
6. 报表功能与部署发布:从DataGridView导出Excel到一键安装包
毕业设计做到能增删改查基本就算功能完成了,但想拿高分至少要加一个报表导出功能。这套系统里可以用C#调用Microsoft.Office.Interop.Excel把DataGridView的内容导出成Excel文件,或者用一个更轻量的方案——生成CSV文件,CSV用Excel打开完全兼容,但不依赖Office组件部署,避免目标机器没装Office直接抛异常。
public static void ExportToCsv(DataGridView dgv, string filePath) { StringBuilder sb = new StringBuilder(); // 写入列头 List<string> headers = new List<string>(); foreach (DataGridViewColumn col in dgv.Columns) { if (col.Visible) headers.Add(col.HeaderText); } sb.AppendLine(string.Join(",", headers)); // 逐行写入 foreach (DataGridViewRow row in dgv.Rows) { List<string> cells = new List<string>(); foreach (DataGridViewCell cell in row.Cells) { if (cell.Visible) { string val = cell.Value?.ToString() ?? ""; if (val.Contains(",")) val = "\"" + val + "\""; cells.Add(val); } } sb.AppendLine(string.Join(",", cells)); } File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8); }参数说明:第一个参数是数据源DataGridView,第二个参数是导出目标文件路径。列头用Column的HeaderText而不是DataPropertyName,保证导出文件里是中文字段名。单元格值如果包含逗号或引号,CSV规则要求用引号包裹,否则Excel打开会串列。File.WriteAllText用UTF8编码,避免Excel打开CSV时中文乱码——但注意UTF8带BOM的CSV在Excel里表现不同,如果乱码改用new UTF8Encoding(true)写入BOM头就能解决。
部署发布方面,Visual Studio的One Click发布可以直接生成安装包,但默认配置下每一台新电脑安装时都要检查.NET Framework版本。如果目标机器没装.NET或者版本过旧,安装过程会报错或者程序运行不起来。发布前在项目属性→发布→先决条件里勾选".NET Framework 4.x"并选择从与我的应用程序相同的位置下载,这样安装包体积变大但首次安装能自动拉起框架安装,不用手动去下载运行时。
发布后第一件事是检查配置文件是否有写权限——安装到C盘Program Files下的程序,默认对目录只有读权限,代码里如果尝试往exe所在目录写日志文件会报UnauthorizedAccessException。解决办法是把日志和临时数据写到Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)路径下,这个目录每个用户都有读写权限。这个坑几乎每个初用One Click发布的人都会踩一次,最常见的现象是程序在开发环境正常、发布后直接崩溃。
从那以后我发布前都会强制走一遍部署清单:连接字符串指向目标数据库、配置文件和应用放在同一目录、ApplicationData路径验证写权限、先决条件勾选完整框架。这套流程走完,不管答辩现场用的电脑是什么环境,双击都能跑起来。希望帮到你。
本文还有配套的精品资源,点击获取