手里的毕设课题是SpringBoot药店管理系统这类题,先别急着敲代码。我见过太多人拿到题目就往Controller里堆接口,结果业务逻辑和页面全缠在一起,最后连自己都理不清。这篇就用一个完整的药店管理系统作为例子,从需求拆解、技术选型、数据库设计到核心业务实现、前后端联调,每一步都讲清楚为什么这么做,以及我实际开发中踩过的坑。不管你是准备答辩的学生,还是想快速上手全栈项目拿去做二次开发的开发者,这篇文章都值得你花二十分钟看完。
1. 先搞清楚药店管理系统真正要管什么:业务边界与需求拆解
很多人一看到"管理系统"三个字,脑子里立刻蹦出"增删改查"。没错,底层确实是增删改查,但药店管理系统和其他进销存系统有本质区别——它管的是药品,药品有批次、有有效期、有严格的进货销存关联规则,这些约束直接决定了系统的数据模型和业务流程。
1.1 角色权限:一个药品管理系统最少要有三类人
药店的操作场景通常分前台和后台。前台收银员负责接待顾客、开销售单、查库存;后台管理员负责药品信息维护、进货入库、供应商管理;如果再细分,还可能有专门的仓库管理员和店长看统计报表。
所以系统的用户表不能只存一个"admin"了事。我建议直接设计用户表 + 角色表 + 用户角色关联表的三表结构。原因很简单:SpringBoot加上Spring Security或拦截器,后续扩展权限会非常方便。比如"药品新增"这个按钮,管理员能点、店长能看、收银员根本看不到,这种细粒度控制靠一张表根本做不了。
1.2 药品主数据:不要只存一个"药品名称"
药品商品名和通用名往往不一样,比如"泰诺"和"对乙酰氨基酚片"是同一个东西。药店管理系统里,药品表至少要包含:
- 药品基本信息:名称、通用名、规格、剂型、生产厂家、批准文号
- 采购信息:进货价、零售价、会员价、供应商ID
- 库存信息:当前库存数量、预警阈值、存放货架号
- 状态信息:是否下架、是否为处方药、是否为医保药品
特别注意批准文号这个字段。如果系统要严格按照医药流通规范进行管理,批准文号应该是唯一索引级别的重要字段。它就像药品的身份证,同一厂家同一规格的同一种药,批准文号是唯一的。
1.3 进货、销售、库存三者的联动:系统最核心的数据流
药店业务的本质是一条链:供应商 → 进货入库 → 库存增加 → 销售出库 → 库存减少 → 库存不足再进货。系统中必须有三张核心业务表承接这个过程:
- 进货单 / 进货单明细:记录"什么时候从哪个供应商进了什么药、进价多少、数量多少、批次号是什么"
- 销售单 / 销售单明细:记录"什么时候卖出了什么药、售价多少、数量多少、收银员是谁"
- 批次库存表:记录"每个批次的药品还剩多少"
我特别要强调批次库存表。很多初学者把库存数量直接怼在药品表上,进货加、销售减,完事。这样做系统确实能跑,但遇到一个现实问题就崩了——药品过期。不同批次的药品有效期不同,零售的时候应该遵从"近效期先出"原则,也就是先卖快过期的批次。如果你没有批次库存表,这个需求根本没法实现,最后只能糊弄答辩说"系统暂不支持批次管理"。实际做的时候,批次表是必须有的,下文会详细讲实现。
2. 技术栈选型:为什么SpringBoot + Vue是毕设的最优解
说句实在话,药店管理系统在业务上并不复杂,技术上也不需要上微服务、Redis缓存、消息队列那一套。毕业设计和技术选型的核心原则是:在企业级主流技术栈上做减法,既体现你掌握了主流开发方式,又不把项目搞得难以完成。
2.1 后端:SpringBoot 2.x + MyBatis-Plus是稳妥组合
SpringBoot 2.7.x是我个人推荐的版本。SpringBoot 3.x需要JDK17起步,如果你的电脑装的是JDK8,直接选3.x会遇到一堆环境问题,纯属给自己找不痛快。SpringBoot 2.7配合JDK8,稳定、资料多、遇到问题随便一搜就有解决方案。
持久层框架我强烈建议用MyBatis-Plus而不是纯MyBatis。原因有三:
- 单表CRUD不用写SQL,BaseMapper自带insert/update/delete/selectById,开发效率翻倍
- 自带分页插件,写一个配置类就能用PageHelper式分页查询
- LambdaQueryWrapper写条件查询非常优雅,比如"查询库存低于100的药品",一行代码搞定
如果你是老手,当然可以手写各种XML映射文件做个复杂联表查询展示水平。但对毕设来说,项目能按期完成、代码清晰可维护,比炫技更有价值。
2.2 前端选择:Vue2 + Element UI仍然是主流兼容性最好的组合
Vue3确实已经普及好几年了,但Vue2 + Element UI的组合在大量教学资源和现成模板的加持下,仍然是毕设项目的高成功率选择。如果你对Vue3的Composition API足够熟悉,用Vue3 + Element Plus也可以,但如果你只是想快速把页面跑起来,Vue2的Options API确实更适合入门。
前端模块我建议按这个结构组织:
- 登录页(记住密码 + 登录角色区分跳转)
- 首页工作台(库存预警提醒、今日销售额、待处理进货单)
- 药品管理(药品列表、新增药品、编辑、下架、模糊搜索)
- 进货管理(进货单创建、入库、进货历史)
- 销售管理(收银台开单、销售历史、退货功能)
- 库存管理(批次库存查询、入库批次详情)
- 供应商管理(供应商列表、新增编辑、关联药品)
- 系统管理(用户管理、角色权限、修改密码)
页面数量不算少,但每个页面基本都是"表格 + 弹窗表单 + 搜索栏"的结构,Element UI的Table、Dialog、Form组件可以直接复用,工作量没有想象中那么大。
2.3 环境与数据库:MySQL 8.0 + 可视化工具
数据库没什么好纠结的,MySQL 8.0是主流版本,Navicat或者DataGrip选一个顺手的。要注意MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver,和5.x的com.mysql.jdbc.Driver不一样,配置连接串的时候记得带上serverTimezone=Asia/Shanghai,否则时间字段会差8个小时。
3. 数据库设计的核心细节:表结构、字段类型与关联关系
数据库设计是系统的地基,地基歪了,后面写多少代码都难受。这一节我直接给出设计思路和关键字段,你可以根据自己的需求扩充。
3.1 核心表清单与字段设计
用户相关
-- 系统用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL COMMENT '登录账号', password VARCHAR(100) NOT NULL COMMENT '密码,BCrypt加密', real_name VARCHAR(50) COMMENT '真实姓名', phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT '1启用 0禁用', create_time DATETIME );角色和权限表按简单方案做:一张角色表存储角色编码和名称,用户表通过role_id直接关联即可。毕设级别的系统没必要做完整的RBAC五表模型,用户+角色一对一关联已经能说明你理解权限控制。
药品与批次相关
-- 药品信息表 CREATE TABLE drug ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(50) UNIQUE COMMENT '药品编码,自定义生成规则', drug_name VARCHAR(100) NOT NULL COMMENT '药品名称', generic_name VARCHAR(100) COMMENT '通用名', specification VARCHAR(50) COMMENT '规格,如0.25g*24片', dosage_form VARCHAR(20) COMMENT '剂型', manufacturer VARCHAR(100) COMMENT '生产厂家', approval_number VARCHAR(50) COMMENT '批准文号', unit VARCHAR(10) COMMENT '单位:盒/瓶/袋', purchase_price DECIMAL(10,2) COMMENT '进货价', sale_price DECIMAL(10,2) COMMENT '零售价', member_price DECIMAL(10,2) COMMENT '会员价', stock_threshold INT DEFAULT 50 COMMENT '库存预警阈值', is_prescription TINYINT DEFAULT 0 COMMENT '是否处方药', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME ); -- 药品批次库存表 CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(50) NOT NULL COMMENT '批次号', production_date DATE COMMENT '生产日期', expire_date DATE NOT NULL COMMENT '有效期至', stock_quantity INT NOT NULL DEFAULT 0 COMMENT '当前批次剩余数量', supplier_id BIGINT, create_time DATETIME );业务单据表
-- 进货单主表 CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(30) UNIQUE COMMENT '进货单号,如PO202501151001', supplier_id BIGINT NOT NULL, total_amount DECIMAL(10,2) COMMENT '进货总额', status TINYINT COMMENT '0待入库 1已入库 2已取消', operator_id BIGINT COMMENT '操作人', create_time DATETIME ); -- 进货单明细表 CREATE TABLE purchase_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, drug_id BIGINT NOT NULL, batch_no VARCHAR(50), quantity INT, purchase_price DECIMAL(10,2), expire_date DATE ); -- 销售单主表 CREATE TABLE sale_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(30) UNIQUE COMMENT '销售单号,如SO202501151001', total_amount DECIMAL(10,2), payment_type TINYINT COMMENT '1现金 2微信 3支付宝 4医保', cashier_id BIGINT COMMENT '收银员ID', member_id BIGINT, create_time DATETIME ); -- 销售单明细表 CREATE TABLE sale_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, drug_id BIGINT NOT NULL, batch_id BIGINT COMMENT '销售时扣减的批次ID', quantity INT, sale_price DECIMAL(10,2) );3.2 大字段类型选择的两个重要提示
金额字段一律用DECIMAL(10,2),千万别用FLOAT或DOUBLE。浮点数在二进制存储下无法精确表示0.1这种值,累计计算会出现0.30000000000000004这类恶心结果。药店系统天天和钱打交道,精确性必须是第一位。
时间字段用DATE表示生产日期和有效期,用DATETIME记录订单创建时间。不要偷懒全部用VARCHAR存时间,否则后续做"查询近效期药品"时要写字符串比较的SQL,逻辑别扭且容易出错。
3.3 表关联不要滥用外键约束
我建议在逻辑上保留关联关系,但不一定在数据库层面建立物理外键。理由很实际:
- 销售单明细被删除时,MySQL外键约束会阻止删除,导致"删除一条销售记录"这种常规操作报错
- 批量导入测试数据时,外键约束会使导入顺序必须严格遵循父子表先后关系
- 你开发调试时手动改库,外键会给你使绊子
正确做法是:在表设计文档中说明逻辑外键关系,代码中通过Service层保证业务完整性,数据库层面只保留必要的唯一索引和普通索引。
4. 核心业务代码实现:进货入库、销售出库、库存预警三大硬骨头
系统好不好用,不看页面多漂亮,看核心业务函数能不能扛住真实场景。这一节我挑最有含量的三段代码逻辑展开讲。
4.1 进货入库:事务保证主单、明细、批次库存同时更新
进货入库是一个跨多表操作,必须加@Transactional注解保证原子性。伪代码思路如下:
@Transactional public void purchaseStock(PurchaseOrderDTO dto) { // 1. 生成进货单号 String orderNo = "PO" + DateUtil.format(new Date(), "yyyyMMddHHmmss"); // 2. 保存进货单主表 PurchaseOrder order = new PurchaseOrder(); order.setOrderNo(orderNo); order.setSupplierId(dto.getSupplierId()); order.setStatus(0); // 待入库 purchaseOrderMapper.insert(order); BigDecimal totalAmount = BigDecimal.ZERO; for (PurchaseItemDTO item : dto.getItems()) { // 3. 保存进货明细 PurchaseOrderItem orderItem = new PurchaseOrderItem(); orderItem.setOrderId(order.getId()); orderItem.setDrugId(item.getDrugId()); // 4. 判断批次信息:如果传了批次号,就落到批次表;否则自动创建新批次 DrugBatch batch = new DrugBatch(); batch.setDrugId(item.getDrugId()); batch.setBatchNo(item.getBatchNo()); batch.setExpireDate(item.getExpireDate()); batch.setStockQuantity(item.getQuantity()); batch.setSupplierId(dto.getSupplierId()); drugBatchMapper.insert(batch); orderItem.setBatchNo(item.getBatchNo()); orderItem.setQuantity(item.getQuantity()); orderItem.setPurchasePrice(item.getPurchasePrice()); purchaseOrderItemMapper.insert(orderItem); // 5. 累加总额 totalAmount = totalAmount.add( item.getPurchasePrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 6. 更新主表总额和状态 order.setTotalAmount(totalAmount); order.setStatus(1); // 已入库 purchaseOrderMapper.updateById(order); }注意第4步,药品批次入库时机有讲究。有的系统是"先建进货单,再单独做审核入库",这样设计更符合门店管理的审批流。如果你的毕设也要体现这个流程,可以把状态改为"0待入库 → 1已入库",在状态变为1时再去插入批次表。这样代码结构更清晰,答辩时还能多讲一个业务状态机的概念。
4.2 销售出库:先锁库存、再扣批次、后写流水
销售出库比进货更繁琐,因为它要解决**"从哪个批次扣"**的问题。正确逻辑是:
- 遍历销售购物车明细,对每个药品查出所有未过期批次,按有效期升序排序
- 从最早过期的批次开始扣减,扣完一个批次再扣下一个批次
- 扣减同时写销售明细,记录本次销售来自哪个批次
- 计算销售总额,生成销售单
@Transactional public SaleOrder createSaleOrder(SaleOrderDTO dto) { SaleOrder order = new SaleOrder(); order.setOrderNo("SO" + DateUtil.format(new Date(), "yyyyMMddHHmmss")); order.setCashierId(dto.getCashierId()); order.setPaymentType(dto.getPaymentType()); BigDecimal totalAmount = BigDecimal.ZERO; List<SaleOrderItem> saleItems = new ArrayList<>(); for (SaleItemDTO item : dto.getItems()) { // 1. 查询该药品的所有有效批次:过期时间大于今天,剩余数量大于0,按过期时间升序 List<DrugBatch> batches = drugBatchMapper.selectValidBatches( item.getDrugId(), new Date()); int remainingQty = item.getQuantity(); for (DrugBatch batch : batches) { if (remainingQty <= 0) { break; } int deductQty = Math.min(remainingQty, batch.getStockQuantity()); // 2. 扣减当前批次数量(用乐观锁更新,防止并发超卖) int rows = drugBatchMapper.deductStock( batch.getId(), deductQty, batch.getStockQuantity()); if (rows == 0) { throw new RuntimeException("批次库存不足或已被他人修改,请刷新后重试"); } // 3. 记录销售明细 SaleOrderItem saleItem = new SaleOrderItem(); saleItem.setOrderId(order.getId()); saleItem.setDrugId(item.getDrugId()); saleItem.setBatchId(batch.getId()); saleItem.setQuantity(deductQty); saleItem.setSalePrice(item.getSalePrice()); saleItems.add(saleItem); remainingQty -= deductQty; totalAmount = totalAmount.add(item.getSalePrice() .multiply(BigDecimal.valueOf(deductQty))); } if (remainingQty > 0) { throw new RuntimeException("药品【" + item.getDrugName() + "】库存不足"); } } order.setTotalAmount(totalAmount); saleOrderMapper.insert(order); for (SaleOrderItem saleItem : saleItems) { saleItem.setOrderId(order.getId()); saleOrderItemMapper.insert(saleItem); } return order; }这段代码里有两个非常值得你写进论文的设计点:
并发控制。deductStock方法用了乐观锁,SQL大致是UPDATE drug_batch SET stock_quantity = stock_quantity - #{qty} WHERE id = #{id} AND stock_quantity >= #{qty}。这样即使两个收银员同时卖最后一个批次,也不会出现库存扣成负数的问题。不用悲观锁的原因是药店系统并发量不高,乐观锁的实现更简单,不用在事务里SELECT FOR UPDATE。
近效期先出。通过ORDER BY expire_date ASC选择批次,保证了系统自动遵循医药零售的"近效期先出"原则。就这一个细节,就能在答辩时讲出两分钟的干货。
4.3 库存预警:定时任务只统计变化,不用实时计算
库存预警有两种实现思路:
- 实时计算:每次查询药品列表时,LEFT JOIN批次表聚合出总库存,与阈值比较。实现简单,但如果数据量大,列表页多条聚合查询会拖慢速度。
- 定时任务 + 预警表:写一个
@Scheduled方法,每天凌晨统计库存低于阈值的药品存入预警表,前端查询预警表展示。适合需要"已读/未读"状态管理的场景。
我建议结合使用。列表页用实时计算方式显示每行药品的库存状态,首页工作台用定时任务生成每日预警简报。定时任务用SpringBoot自带的@Scheduled就够了,不需要引入Quartz这种重量级框架。
@Component public class StockWarnTask { @Scheduled(cron = "0 0 8 * * ?") // 每天早上8点执行 public void generateDailyWarn() { List<Drug> lowStockDrugs = drugMapper.selectLowStockDrugs(); // 存入预警记录表,或推送到消息通知表 } }别忘了在启动类加@EnableScheduling开启定时任务,这个细节漏了,任务永远不会执行,排查半天才发现。
4.4 前后端交互:统一返回结构 + 登录状态校验
前端所有请求,后端统一返回一个JSON结构:
{ "code": 200, "message": "操作成功", "data": { } }这个结构用一个泛型类实现,配合全局异常处理器,前端axios拦截器里就能统一判断code、弹出错误信息,不用每个请求都写一遍错误处理。登录状态用Token方式,前端登录成功后把Token存到localStorage,每次请求带在Header里,后端用拦截器统一校验。毕设级别用JWT或简单的UUID Token都行,我推荐JWT,因为它的无状态特点可以顺带在论文里写一段"基于Token的无状态认证机制",比老旧的Session机制更能跟上企业主流技术。
5. 开发过程中会真实踩到的坑:一次排查完整的"销售出库库存变负数"经历
我不可能把系统里所有代码贴出来,但调试过程值得单独写一段。做这个系统的朋友百分之八十都会遇到库存相关的问题,我讲一个最典型的排查过程,你遇到类似问题可以直接套用排查思路。
5.1 问题现象
模拟项目X测试阶段,测试人员在销售端开了一张销售单,买了两盒阿莫西林胶囊。订单创建成功后,打开药品列表发现阿莫西林库存变成了-2。复现这个异常,第一反应是"减库存的代码写错了",但检查Service层发现逻辑没有明显bug,于是怀疑到并发上——会不会是同一个账号同时开了两个窗口提交订单?
5.2 排查链路
先在日志中搜索那条销售单的完整操作记录,发现创建时间精确到秒。因为生产库和测试库的日志会分开,所以我先在测试库复现,开了两个浏览器窗口模拟同一账号同时下单。复现成功后,我远程到方法栈观察实际执行顺序,发现两个线程几乎同时执行到了deductStock,但两个事务提交后库存变成-2。
问题根源在SQL写得太随意:
-- 错误写法:先查后更,查询和更新之间存在时间差 UPDATE drug_batch SET stock_quantity = stock_quantity - 2 WHERE id = 123; -- 正确写法:条件判断加在SQL里,一条语句原子完成 UPDATE drug_batch SET stock_quantity = stock_quantity - 2 WHERE id = 123 AND stock_quantity >= 2;"先查再减"的写法在并发下常见问题:两个请求都查到库存是2,都认为"我这次扣减没问题",然后先后执行了减2的操作,最终库存就是-2。把条件stock_quantity >= 2写进SQL后,第二次请求的UPDATE会返回0行受影响,代码里判断影响行数就能阻止超卖。
5.3 修复后还要做的验证
修复后可以做一个简单压测:用测试脚本同时发起10个销售请求,每个请求卖两盒同一种药品,药品初始库存设置为20。跑完断言总卖出数量不超过20、数据库库存不为负数。这一步不是可选的,而是必须做的,否则你根本不确定并发问题是否真正解决。
5.4 其他高频问题速查表
| 问题 | 根因 | 解决方案 |
|---|---|---|
| 时间字段查询差8小时 | MySQL连接串缺少serverTimezone配置 | 连接串加上serverTimezone=Asia/Shanghai |
| 前端传JSON日期格式解析失败 | Jackson默认格式不匹配 | 配置spring.jackson.date-format=yyyy-MM-dd HH:mm:ss |
| 修改密码后旧Token仍然有效 | 拦截器未校验密码变更时间 | Token中携带密码版本号,变更即失效 |
| 删除供应商提示外键冲突 | 逻辑外键没有做"是否被引用"查询 | 删除前先查进货单表是否存在该供应商记录 |
| 分页插件不生效,Page对象里total为0 | MyBatis-Plus分页插件未注册为Bean | 配置类中@Bean注册PaginationInnerInterceptor |
6. 交付物怎么整理:说明文档、部署步骤和个人心得
毕设除了代码能跑,文档同样重要。药店管理系统这类项目,最终交付物通常包括六样东西,我按优先级给你列出来。
6.1 完整代码与SQL脚本
代码要分目录管理:backend放SpringBoot工程,frontend放Vue工程,doc放数据库脚本和说明文档。数据库脚本要包含建库建表语句和初始测试数据。很多同学图省事,数据库脚本里只建表没有数据,导致答辩演示时登录都登不上去。至少给三个用户、十个药品、两个供应商、几条销售记录的测试数据。
6.2 说明文档的写作思路
说明文档最忌讳写成"安装步骤"三个字敷衍了事。一份合格说明文档至少包含:
- 系统概述与功能模块清单
- 数据库设计说明书(E-R图 + 表结构说明 + 核心表关系描述)
- 核心业务流程图文说明(进货流程、销售流程、库存预警流程)
- 系统部署文档(JDK配置、MySQL导入、后端启动、前端启动)
- 测试用例表(功能点、操作步骤、预期结果、是否通过)
写部署文档的时候有个细节:要把后端启动命令、前端启动命令都真实执行一遍,把实际窗口上显示的端口号和启动成功日志截图放进文档。不要代码写完了再凭记忆补文档,那必然漏步骤。
6.3 答辩演示的准备要点
答辩现场最容易翻车的地方不是功能bug,而是演示数据不连贯。比如你想演示"库存不足预警",结果系统里所有库存都是几千盒,摸不着头脑。
我的建议是:提前准备一套完整的演示剧本。比如先在50盒阿莫西林中卖出去48盒,再演示库存预警列表出现这条记录;先进两批不同有效期的同一药品,再演示销售时自动优先扣减近效期批次。每一步都有对应的数据和预期页面,答辩才能游刃有余。
6.4 我个人的几点体会
做这个项目的过程中,我最大的体会是最好的毕设不是"功能越多越好",而是"每个功能都能说清楚为什么这么做"。比如"近效期先出"这个业务规则,写代码前我以为是"药品卖得快就行",后来请教了一位在连锁药房做过系统的朋友才明白,药品不能简单当作普通商品管,老百姓从药房买到过期药是严重的质量事故,所以批次和有效期管理才是药品管理系统的灵魂。
另外,如果你打算把项目放进简历里,建议把下面几个点写清楚:系统中如何保证库存不超卖(乐观锁)、如何获得待入库状态机转换、如何实现近效期批次优先出库。这三个点虽然是业务细节,但能证明你考虑问题不只停留在CRUD层面,而是理解了真实业务场景里的痛点。
做完整套系统之后,我的建议是:先跑通最小闭环,再逐步加功能。很多同学喜欢一开始就把所有页面和功能全部铺开,结果中途发现数据库设计有问题,推倒重来。先实现"药品管理 + 进货 + 销售 + 库存"这条主线,让数据完整流转起来,再加权限、供应商、报表和统计。这样每天能看见进度,也有安全感。希望这篇东西能帮你在做药店管理系统时少走点弯路,把力气花在真正重要的地方。