1. 项目拆解与需求定位:医药管理系统到底在管什么
前100字引入: 基于Spring Boot的医药管理系统(编号11671),是我平时被问得最多的毕设类型。别一听“医药”两个字就觉得要懂药理知识,实际上它就是把药品当成商品,把药房当成仓库,做一套带库存、采购、销售、预警的管理后台。这篇文章我就从需求拆解开始讲,把整个系统的设计思路、表结构、核心代码、常见坑一次说清楚。
这类系统之所以在毕设选题里长盛不衰,是因为它兼顾了“业务复杂度”和“技术覆盖面”。你说它难吧,无非就是增删改查;你说它简单吧,又涉及到库存扣减、药品批次、有效期管理、处方关联、角色权限这些业务细节。折腾完这一套,市面上大多数中小型管理系统的套路基本都摸清了。
先说清楚这个系统到底要管什么。以我接触到的常见需求来看,核心其实就四块。
第一块是药品信息管理。不是简单存个药品名,而是要管通用名、商品名、剂型、规格、生产厂家、批准文号、存储条件、有效期这些字段。这里有个容易被忽视的点:药品的“规格”和“单位”在后续的库存和采购环节会反复用到,设计字段时宁可多拆几个也不要塞到一个字符串里。
第二块是库存管理。这是整个系统的业务核心,也是最容易出bug的地方。进货入库要增加库存,售出或发药要扣减库存,还要区分批号和有效期。药品不像普通商品,过期了就只能报废,所以很多系统还要做近效期预警——这个功能做好了,答辩时能加分不少。
第三块是采购与供应商管理。即你卖给患者的药,总得有上游渠道。供应商信息要单独建表,采购单要有状态流转,从创建、审核、入库到结算。有的需求还会要求做应付账款统计,但毕设阶段通常做到入库就差不多了。
第四块是销售与处方管理。如果是药店场景,就是POS收银加会员;如果是医院场景,就是处方录入、划价、发药。你拿到手的题目里写的是“医药管理系统”,往往两者都有点影子,这时候就看你自己怎么定义边界。我的建议是,做出一个清晰的“处方开立—划价—取药”链路,比面面俱到但每块都只做个列表要加分得多。
大多数同学拿到这种题目第一反应是“系统太大不知道从哪下手”。其实你只要把用户角色定清楚,整个系统的功能边界就自动收敛了。常见角色就三到四个:管理员管人和字典数据,库管员管出入库和库存预警,药师/收银员管处方和销售,再可选一个只读的领导和审计角色。
为什么Spring Boot是这类系统的最优解? 一句话:不是因为它技术最牛,而是因为它把“能跑起来”的成本压到了最低。内嵌Tomcat,不用额外配置服务器;spring-boot-starter一系列起步依赖,导包再也不用逐个找版本号;yml集中配置数据源和Redis;自带Actuator可以用来看健康状态;最关键的,社区资料多到令人发指,遇到问题一搜就有答案。这在毕设场景里太重要了——你有多少时间花在配置Tomcat版本冲突上,就有多少时间可以拿来做业务。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与项目初始化:别在该看文档的地方靠猜
2.1 技术栈清单:我用的是哪些组件
先说后端。Spring Boot版本建议直接用2.7.x,这是目前兼容性最稳的2系版本,网上绝大部分第三方整合教程都基于2.x写的。如果你用3.x,因为javax到jakarta的包名迁移,很多老教程会直接失效,排查起来非常痛苦。这里附上我常用的依赖清单,直接Spring Initializr生成项目后对照补充:
xml复制<dependencies>
<!-- Web 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- ORM:MyBatis-Plus,毕设效率神器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 不写getter/setter的快乐 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 接口文档:测试前端联调全靠它 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
</dependencies>
为什么用MyBatis-Plus而不是纯MyBatis或JPA? 这是很多人的纠结点。纯MyBatis要手写大量XML,很多同学精力都花在写基础CRUD上了;JPA在国内企业用的少,你毕业后写到简历上,面试官追问反而心虚。MyBatis-Plus相当于两者中间态:BaseMapper帮你把单表CRUD全部搞定,分页插件一行配置开启,复杂查询再用注解或XML补充。对医药管理系统这种“单表操作为主、多表关联有限”的业务,简直量身定做。
2.2 数据库设计:从零开始建表的六个核心表
数据库设计是这类系统里最考验功底的环节,也是答辩论十大追问区。很多同学一上来就建一张“药品大表”,所有字段堆在一起,看似一步到位,后期改一个需求就动一张表的DDL,越写越乱。
我的设计思路是遵循三范式但不过度拆分。药品主表(drug)放静态属性:药品编码、通用名、商品名、剂型、规格、厂家、批准文号、单位。批次表(drug_batch)放动态属性:批次号、生产日期、有效期至、入库价格、零售价格、库存数量。为什么要把批次单独拎出来?因为同一药品可能多次进货,批次不同,价格不同,有效期也不同,你要按批次先进先出。这两张表通过drug_id关联,是整套系统数据模型的地基。
然后是需要单独建表的:供应商表(supplier),采购单与明细(purchase_order + purchase_item),销售单与明细(sale_order + sale_item),登录用户与角色(sys_user、sys_role、sys_user_role关联表)。再加一张系统字典表(sys_dict),用来存剂型、单位、仓库位置这些可配置项。
光说空表结构没意思,我直接贴库存表、批次表的设计,这是我连续做了三个类似项目后打磨出的骨架:
sql复制CREATE TABLE drug_batch (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
drug_id BIGINT NOT NULL COMMENT '药品id,关联drug表',
batch_no VARCHAR(64) NOT NULL COMMENT '批次号',
production_date DATE COMMENT '生产日期',
expire_date DATE NOT NULL COMMENT '有效期至,近效期预警靠它',
inventory INT NOT NULL DEFAULT 0 COMMENT '当前库存数量',
purchase_price DECIMAL(10,2) COMMENT '入库单价(成本价)',
sale_price DECIMAL(10,2) NOT NULL COMMENT '零售单价',
supplier_id BIGINT COMMENT '供应商id',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_drug_id (drug_id),
KEY idx_expire_date (expire_date)
) COMMENT='药品批次库存表';
为什么一定要单独建一个drug_batch表而不是给药品表直接加库存字段? 你想一个场景:同一盒阿莫西林,三月份进了一批,有效期到2025年2月;九月份又进了一批,有效期到2026年1月。如果你只在药品表里存一个库存总数,那发药时到底先发哪批?系统没了追溯能力。拆出批次表之后,销售出库就可以先把有效期最近的那批扣完,扣不够再扣下一批——这在业务上叫“近效期优先出库”,是医药行业的标准操作,答辩时能说出这一条,老师对你的评价立刻不一样。
2.3 项目初始化:Spring Initializr之后的三件事
项目生成后,不少人直接开写代码,结果启动时报错一堆,半天发现是yml没配置或者包扫描不对。我建议按下面三步走,五分钟内就能把骨架跑起来。
第一步,写对application.yml。这是Spring Boot的入口配置,也是最常出问题的地方。贴一份可以直接复用的基础配置:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/pharmacy_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 你的密码
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
knife4j:
enable: true
这里有个细节我想多嘴一句:map-underscore-to-camel-case一定要开,否则你把数据库的expire_date映射到实体的expireDate时会发现全是null,查了半天才发现是驼峰命名没转换。StdOutImpl是SQL日志打印,开发阶段开着能肉眼排查SQL问题,上线前关掉。
第二步,设置统一的返回体和异常处理器。这不是必须的,但在答辩演示时,如果后端一报错就给前端抛一大段英文堆栈,观感极差。我会定义一个Result<T>类,包含code、message、data三个字段,再配一个@RestControllerAdvice全局异常拦截,把已知业务异常、参数校验异常、兜底异常分类返回。做这件事大概花半小时,但对系统规范性的提升是肉眼可见的。
第三步,创建BaseEntity。id、create_time、update_time、deleted这四个字段在每张表里都有,与其每张实体类都写一遍,不如抽个父类。MyBatis-Plus的@TableField(fill = FieldFill.INSERT)加一个MetaObjectHandler,就能实现create_time自动填充,不用每次手动set时间。
3. 核心模块开发:药品、库存、采购、销售四条业务线的落地代码
3.1 药品管理模块:别只做一个CRUD列表
药品管理是系统门面。我的建议是列表页必须支持分页查询 + 多条件组合筛选,筛选条件至少要覆盖药品名称、批准文号、厂家、剂型。如果你只写一个全表查询,做完自己都没底气演示。
用MyBatis-Plus的LambdaQueryWrapper可以优雅实现多条件动态拼接,核心代码如下:
java复制public PageResult<DrugVO> pageDrug(DrugQuery query) {
Page<Drug> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<Drug> wrapper = new LambdaQueryWrapper<>();
// 按药品名称模糊查询,非空才拼条件
wrapper.like(StringUtils.hasText(query.getDrugName()), Drug::getDrugName, query.getDrugName());
// 按批准文号精确匹配
wrapper.eq(StringUtils.hasText(query.getApprovalNumber()), Drug::getApprovalNumber, query.getApprovalNumber());
// 按厂家模糊查询
wrapper.like(StringUtils.hasText(query.getManufacturer()), Drug::getManufacturer, query.getManufacturer());
// 按剂型精确匹配(数据来自字典表,前端下拉框选择)
wrapper.eq(StringUtils.hasText(query.getDosageForm()), Drug::getDosageForm, query.getDosageForm());
wrapper.orderByDesc(Drug::getCreateTime);
Page<Drug> result = drugMapper.selectPage(page, wrapper);
// ...
}
写这段代码时有三个测试用例我建议你必测:第一,关键字包含空格时还能不能查出来(最好trim一下);第二,全部条件为空时走的是不是全量分页,SQL是否正确;第三,按药品名称模糊查询时,单测数据里同时有“阿莫西林胶囊”和“阿莫西林颗粒”,能不能都命中。这三点能验证你动态SQL拼接的健壮性,也基本覆盖了列表查询的所有边界情况。
新增药品时要做的校验也别只依赖前端。后端至少校验:药品编码唯一、批准文号必填、零售价必须大于0。虽然麻烦一点,但还算不上复杂系统,直接Controller层写校验逻辑,或者用JSR-303注解都可。我习惯用@Validated配合@NotBlank、@DecimalMin注解做声明式校验,代码少,可读性强。
3.2 库存与批次处理:扣库存不是简单的setInventory = inventory - 1
这是整套系统真正有含金量的地方,也是面试官和答辩老师最喜欢深挖的业务点。如果你只是写一句update drug set stock = stock - #{num} where id = #{id},那这系统只能说完成了一半。
入库和出库都要带着批次走。采购入库时,一种药品可能对应多个批次,前端传来的数据结构应该是“药品 + 批次列表”。后端处理逻辑按下面顺序走:校验采购单状态是否允许入库,逐条插入或更新drug_batch的批次库存,同时增加药品表的可用总量。这里我建议drug表只保留一个冗余的total_stock字段,方便列表展示,具体每个批次的明细以drug_batch表为准。两边的数据一致性在同一个事务方法里保证。
发药销售出库时的核心逻辑是先扣近效期批次。举个例子,某个药有三个批次:
- 501批次,有效期2025-01-01,库存30
- 502批次,有效期2025-06-01,库存50
- 503批次,有效期2026-03-01,库存40
患者要买60盒,那么系统应该先把501批次的30盒全部扣掉,再从502批次里扣30盒,扣完的批次自动从可用列表里移除(或者标记库存为0)。这样减少近效期药品因过期被报废的概率。这个逻辑放在service层,用SQL按expire_date asc排序批次,再循环扣减:
java复制@Transactional(rollbackFor = Exception.class)
public void deductStock(Long drugId, Integer quantity, Long orderId) {
// 查出该药品所有库存大于0且未过期的批次,按有效期升序
List<DrugBatch> batchList = drugBatchMapper.selectList(
new LambdaQueryWrapper<DrugBatch>()
.eq(DrugBatch::getDrugId, drugId)
.gt(DrugBatch::getInventory, 0)
.gt(DrugBatch::getExpireDate, LocalDate.now())
.orderByAsc(DrugBatch::getExpireDate)
);
int remain = quantity;
for (DrugBatch batch : batchList) {
if (remain <= 0) break;
int deduct = Math.min(remain, batch.getInventory());
batch.setInventory(batch.getInventory() - deduct);
drugBatchMapper.updateById(batch);
remain -= deduct;
// 写批次流水,便于后续追溯某个批次卖给谁了
batchFlowService.record(batch.getId(), orderId, -deduct, "SALE");
}
if (remain > 0) {
throw new BusinessException("当前可用库存不足,缺货:" + remain);
}
// 同步扣减药品表总的冗余库存
drugMapper.deductStock(drugId, quantity);
}
这段代码里我埋了个关键点,也是实际踩坑后才加上的:扣减前先做了批次过期判断。如果你不管有效期,先扣早批次,然后把过期批次的货卖出去了,这在医药场景里是事故级别的错误。加一个gt(expireDate, now())的条件,一句话的事,却能堵住一个大漏洞。
库存不足的判断也有讲究。如果扣到一半发现剩余量不够,事务已经对前面的批次做了updateById,如果你不抛异常回滚,库存就莫名其妙少了。所以我用了@Transactional,任何一步失败整个方法回滚到初始状态。库存模块的事务边界一定要想清楚,宁可多包一点也不要漏,这是血泪教训。
3.3 供应商与采购单:状态机设计让流程清晰可控
采购模块如果直接做成一张“采购单表”加一个“入库按钮”,管理起来会很乱。我推荐用状态机思维设计流程:待审核 → 已审核(待入库)→ 已入库 → 已作废。每个状态定义哪些操作是允许的:待审核可以编辑和作废,审核通过后不可以编辑,入库操作只在已审核状态下可执行。
为什么要用状态设计?最直接的原因是防止操作错乱。一个采购单还在“草稿”状态就被当成正式单据入了库,或者一个已经入库的单子被误删,在医药这种对数据准确性要求高的场景里,这种问题是不能接受的。状态配合update语句的where条件,能在并发操作时兜底:
java复制// 仅当采购单处于待审核状态时,才能执行审核操作
int rows = purchaseOrderMapper.update(null, new LambdaUpdateWrapper<PurchaseOrder>()
.eq(PurchaseOrder::getId, orderId)
.eq(PurchaseOrder::getStatus, "PENDING")
.set(PurchaseOrder::getStatus, "APPROVED")
.set(PurchaseOrder::getAuditTime, LocalDateTime.now()));
if (rows == 0) {
throw new BusinessException("采购单状态已变更,请刷新后再试");
}
这里为什么不用先select查状态、再update的方式?因为两步之间可能有其他人抢先改了状态,你看到的是旧数据,更新就会覆盖别人的操作。把状态判断放进update的where条件里,数据库的行锁机制会在更新那一刻帮你做并发校验,这比应用层的if判断可靠得多。这也算是并发场景的一个特例小技巧,能听懂的同学面试时聊到乐观锁就有素材了。
采购单细表也建议比普通商品订单多一些字段,比如采购单价、入库数量、已入库数量。药品允许分批入库,今天到20盒,下周到40盒,一张采购单挂两次入库记录,这样业务上更合理,也不用每批货来就新建一张单子。
3.4 销售与处方管理:从划价到发药的完整链路
销售模块最大的误区就是做一个孤立的收银页面。你要演示的是业务闭环,而不是单点功能。我给这个系统定义的主流程是:选择药品(自动带出价格)→ 生成销售单 → 扣减批次库存 → 写销售明细。有些场景还要支持会员折扣或结算金额统计,毕设阶段可以做成可选的“锦上添花”功能。
销售单创建时,前端传给后端的是一个商品列表,每项包括“药品ID、数量”。后端逐项校验库存,然后调用3.2里的deductStock方法,一次性生成销售主表和明细表。这里要注意嵌套事务的一个坑:MySQL默认REQUIRED传播级别,同一个事务里几个方法调用其实共享一个数据库事务,deductStock里抛了异常,整个销售单创建也会回滚,这是正确的行为。但如果你把deductStock类内部调用写成this.deductStock(),那就绕过了Spring的AOP代理,事务注解不生效,出问题难排查。所以自调用事务失效、事务传播机制、回滚条件,这些问题必须搞明白。
销售明细表我额外加了一个batch_id字段。这样做的好处是如果想查“患者张三买的三盒阿莫西林是哪一批生产的”,一条SQL能直接关联到。数据越往后积累越有价值,这个字段现在花两分钟加上,后面统计批次效期报表时能救命。
3.5 用户与权限:像模像样的RBAC,一劳永逸
毕设系统的权限不要求做到Spring Security + JWT那么重,但至少要能回答“不同角色登录看到的东西不一样”。用拦截器 + 用户角色表就够,只要在WebMvcConfig里注册一个HandlerInterceptor,在preHandle里判断当前请求路径是否允许匿名访问,不允许的校验Session或Token中的用户角色。
我现在的做法是登录成功后把用户信息和角色列表放进Redis,设计一个@RequireRole({"ADMIN","PHARMACIST"})注解配合拦截器做角色控制。涉及敏感操作的接口(比如作废采购单、删除药品、修改用户角色)必须加角色校验。这个设计看似简单,但能实打实防止“低权限用户找到接口路径后通过Postman直接调用”的问题,这也是安全方面的一个加分点。
4. 避坑指南与实战教训:这些坑我不希望你踩第二遍
4.1 分页查询的“幽灵数据”与COUNT歧义
用MyBatis-Plus分页,很多人第一步就忘了加分页插件。注意它和旧版配置方式不一样,新版是要这么配的:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
// 单页最大500条,防止一次性查太多拖垮数据库
pagination.setMaxLimit(500L);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
不加这个Bean,你会发现selectPage返回的记录数是全的,但数据一直是第一页的,翻页无效。这种问题不好排查,因为代码看似都对。
另一个典型问题是查询列表时COUNT语句和多表JOIN混在一起。假如列表页要显示药品名称、供应商名称、库存批次信息,你会写一个多表查询的Mapper方法,如果直接在这个查询上分页,MyBatis-Plus会尝试自动生成COUNT查询,但多表连接下自动生成的COUNT可能携带不必要的表关联,导致性能低下甚至SQL报错。
我的经验是分页查询优先在主表上单表过滤,先分页查出主表ID集合,再用IN查询补全关联信息。如果实在要一次join,就手写count查询或对分页结果做处理,别偷懒。数据量一大,这条原则就会帮你避开很多翻车时刻。
4.2 时间字段的时区陷阱与有效期比较
本地开发好好的,部署到服务器后时间整整差了8小时,这是典型的时区问题。我在yml里写serverTimezone=Asia/Shanghai就是为了规避这个。另外Java 8之后建议一律用LocalDateTime,不要用java.util.Date,前者带时区意识,JSON序列化也干净。
有效期比较有个容易被忽略的场景。用户查询“近效期药品”时,如果只按expire_date < 某个日期过滤,不排除已经过期且库存大于0的药品,就会把“过期没处理”的药也显示成“有效库存”。所以我给近效期查询加了一个范围条件:
java复制// 近效期:有效期在90天以内,且还未过期
wrapper.le(DrugBatch::getExpireDate, LocalDate.now().plusDays(90))
.gt(DrugBatch::getExpireDate, LocalDate.now())
.gt(DrugBatch::getInventory, 0);
这个查询逻辑虽然简单,但背后蕴含的业务含义是:系统要能识别哪些批次是“即将到期赶紧卖掉”,哪些批次是“已经过期等待报废处理”。这两个list在药房管理中是不同工作流。
4.3 库存记录的并发扣减与ABA问题
库存扣减在单机MySQL场景下,核心手段是乐观锁或条件更新。我在扣库存时不先查库存再判断,而是直接用带条件的UPDATE:
java复制int rows = drugBatchMapper.update(null, new LambdaUpdateWrapper<DrugBatch>()
.eq(DrugBatch::getId, batch.getId())
.ge(DrugBatch::getInventory, deduct) // 库存充足才更新成功
.setSql("inventory = inventory - " + deduct));
if (rows == 0) {
// 要么库存不足,要么批次被并发改过,抛出业务异常让上层回滚
throw new BusinessException("批次 " + batch.getBatchNo() + " 扣减失败,请刷新后重试");
}
注意这里用了setSql("inventory = inventory - " + deduct)。这是个关键点:不要在Java层先读出inventory再减完了update回去。为什么?假设两个请求同时读到库存是10,都准备扣到9,然后依次执行update,最后库存变成9而不是8——这就是典型的丢失更新。而用数据库层面的“inventory = inventory - deduct”,它是在行锁内部完成的原子操作,不会发生这种并发覆盖。同样的逻辑,我扣减药品表冗余库存时也用了这种方式。
4.4 接口文档与联调:Knife4j是毕设演示的隐形加分项
很多同学做完后端,自己拿Postman点一遍就算完事。但到了答辩,老师如果想看某个接口的数据结构,你手忙脚乱翻日志,体验很差。我强烈建议集成Knife4j(Swagger的增强UI),Controller上写好@Api、@ApiOperation、@ApiModelProperty注解,启动项目后访问/doc.html,所有接口的请求参数、返回示例都可视化呈现。这个工具对前端同学联调也友好,省去后端手动维护接口文档的时间。
写注解的时候顺便就把接口分类和参数说明理清了,等于倒逼你把Controller设计得结构清晰。项目演示时,直接在浏览器打开文档页,一个一个接口展示输入输出,比你临时敲Postman命令要专业得多。
5. 常见问题速查:从头到尾排一遍雷
我把这类系统开发中最高频的问题和排查方式整理成一张表。这些内容不是凭空想的,全是我在帮学生调项目时一个个排查过的真实场景。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
启动报Failed to configure a DataSource |
没引入数据库驱动依赖或yml配置错误 | 确认mysql-connector-java在依赖里,检查url、username、password三个配置 |
| 中文字段出现乱码 | 项目/数据库/表字符集不一致 | 建库用utf8mb4,url加characterEncoding=utf8 |
| MyBatis-Plus分页无效 | 没配置PaginationInnerInterceptor | 增加MybatisPlusInterceptor并装配分页插件 |
| insert后拿不到自增主键 | Java实体没设置@TableId(type = IdType.AUTO) |
主键字段加上该注解,数据库id列设为自增 |
| 药品列表查询字段大量为null | 没开启驼峰映射 | yml配置map-underscore-to-camel-case: true |
| 扣库存时数量变成负数 | 并发扣减未做条件保护 | 使用setSql("inventory = inventory - n") + where库存充足 |
| 近效期预警查出不准确 | 过期批次和零库存批次混入 | 查询条件加expire_date > now()和inventory > 0 |
| 前端传数组后端接收不到 | 未加@RequestBody或参数名不匹配 |
Controller参数写@RequestBody List<...>,前端用JSON发送 |
| 事务莫名其妙没生效 | 同类内部调用this方式绕过代理 | 通过注入自身代理对象,或把事务方法拆到另一个Service |
| 登录状态无法保持 | Session/Cookie跨域配置缺失 | 前后端分离项目配置CORS允许携带credentials,或改用Token方案 |
这里单独拎出一个最容易被忽略的问题说一下。自调用事务失效在学校项目里极其常见:你写了一个StockService.deductAndLog()方法,标了@Transactional,里面调用本类的deduct()方法,事务怎么都不生效,数据回滚不了。原因是Spring事务基于动态代理,this.xxx()调用走的是对象原始方法,不是代理对象,事务拦截器根本没机会介入。处理方法也很简单:把deduct()挪到另一个Service类里注入调用,或者从容器中获取代理对象再调用,再简单点干脆在同一个方法里把所有SQL写全。以下是错误写法和正确写法:
java复制// 错误:事务方法中通过this调用,事务注解失效
@Service
public class StockService {
@Transactional(rollbackFor = Exception.class)
public void deductAndLog(Long drugId, Integer quantity) {
this.deduct(drugId, quantity); // 走的是内部方法,事务环绕通知被跳过
logService.writeLog(drugId, quantity);
}
public void deduct(Long drugId, Integer quantity) {
// 扣库存SQL
}
}
// 正确:把扣库存逻辑放到另一个Bean,通过代理调用
@Service
public class StockService {
@Autowired
private StockOperateService stockOperateService;
@Transactional(rollbackFor = Exception.class)
public void deductAndLog(Long drugId, Integer quantity) {
stockOperateService.deduct(drugId, quantity); // 走代理,事务生效
logService.writeLog(drugId, quantity);
}
}
写这段示例的时候我顺便多说一句:排查事务问题还有一个硬办法,就是把MyBatis的SQL日志打开,看异常抛出前SQL是否真的执行了回滚。StdOutImpl打印的日志里如果能看到Rolling back字样,说明事务是生效的,只是回滚条件判断有误;如果压根没有回滚日志,那就要检查事务边界和传播级别了。
6. 后续扩展方向:从毕设到可用系统的三个升级点
如果答辩完你还有精力(或者想把这个项目继续完善放进简历),我建议优先做三个升级,投入产出比最高。
第一个是引入Redis做热点缓存。药品的字典数据、药品基础信息基本都是读多写少,缓存到Redis里能把每次查询的时间从几十毫秒降到几毫秒。最典型的场景是药品列表的下拉选择框,每次打开页面都要查一次全表,缓存后体验提升明显。做法不难,用spring-boot-starter-data-redis,在Service上先查缓存,不命中再查数据库并回填,注意设置合理的过期时间(比如30分钟)。
第二个是增加药品近效期报废流程。现在只是查出近效期,但还没有“报损出库”的操作。在库存模块加一个报损类型,走单出库扣减对应批次库存,并记录报损原因,这样系统就覆盖了完整药品生命周期,业务完整性再上一个台阶。
第三个是做一个简易的数据统计面板。统计当日销售额、当月采购金额、库存周转数、近效期药品数量。这里不需要引入额外的报表组件,后端写几个聚合SQL,前端用ECharts画柱状图和折线图就能出效果。毕设答辩时,能掏出这几张可视化图表,整体观感会加分不少,因为大多数人的项目还停在表格页面。
如果你打算用Docker部署,再补一句个人经验。写一个Dockerfile,基于openjdk:8-jre-alpine或eclipse-temurin:8-jre,把Spring Boot打好的jar包放进去,用EXPOSE 8080暴露端口,启动命令写成ENTRYPOINT ["java","-jar","/app.jar"]。如果连接了MySQL,不要把数据库也容器化,二开调试时连来连去特别麻烦,本地装个MySQL服务直接连宿主机地址就完事了。这也算是我折腾过几轮之后总结出的偷懒但稳的做法。
做这类管理系统,我个人最大的体会是:它不难,但非常考验你拆解需求、建模数据、编排流程的基本功。技术框架会过时,但“先理清角色和数据关系再动手写代码”这个思路,放在任何项目里都不会错。如果你正卡在某个报错上,或者对某个模块的设计拿不准,把上面这些内容对照着捋一遍,多半能找到答案。
