简介:面向初、中级Java Web开发者、毕业设计学生及需要快速落地管理系统的中小团队,这套药店管理系统完整源码覆盖了用户登录、权限管理、药品信息、库存与订单等典型业务模块,并附带可直接运行的构建脚本。后端基于Spring Boot、Shiro、MyBatis与MySQL,前端采用Vue、Bootstrap、jQuery,既能帮助理解经典Web分层架构与权限控制流程,也可作为新项目的基础模板或MVP快速启动。资源包共389个文件,压缩后约15.72MB,包括93个Java后端类、46个Vue组件、161个SVG图标,以及数据库SQL脚本、3个构建/启动bat脚本、开题报告与论文文档,类型覆盖前后端代码、说明文档和演示素材,便于按目录检索与二次开发。已有69人学习下载。通过完整源码、数据库脚本和配套论文,读者可掌握药店进销存业务的表结构设计与接口实现,获得一套可直接部署运行的Web应用,对课程设计、毕业答辩或创业项目起步均有实用参考价值。
1. 药店管理系统源码不是跑通就行:先看 Spring Boot、Shiro 和 MyBatis 怎么分工
拿到这套基于 Java Web 的药店管理系统源码,先不要急着双击 2-run.bat。它属于典型的单体 Java Web 项目:Spring Boot 负责接收请求和控制事务边界,Shiro 负责登录认证与接口权限过滤,MyBatis 负责把药品、订单、库存这几张 MySQL 表映射成 Java 对象;前端源码是 Vue 组件里混用 Bootstrap 和 JQuery,打包后生成app.bb5659c3.css、chunk-vendors.1f0a25b2.css这类带 hash 的静态资源。真正有业务含量的规则,比如库存不能扣成负数、过期药品不允许出库,都在 Service 层代码里,而不是数据库约束。项目自带论文和开题报告,对毕业生整理课程设计说明,或者团队想快速搭一个内部原型,都是合适的起点。需要的人大概有两类:一类是刚写完 Java 基础、想完整走一遍 Java Web 项目流程的初学者,另一类是带初级开发者的技术负责人,需要一套结构清晰的代码来做培训和评审。把源码当成“可以拆的样本”来看,比当成成品系统去部署更有价值。
2. Spring Boot + MyBatis:药品、订单与库存的领域模型怎么落地
后端代码按 controller、service、dao、entity、vo、config 六层拆开。Controller 做得非常薄,只负责参数绑定和结果包装;库存计算、价格校验、过期判断全部放在 Service;所有表操作收敛在 Mapper 接口和 XML 文件里。第一次读源码时,按“药品列表 → 创建订单 → 扣减库存”这条线走一遍,就能把整个后端主链路串起来。
2.1 Controller、Service 与 Mapper 的边界
以药品分页列表为例,Controller 里只看到三个元素:URL 映射、参数注入、调用 Service 并返回统一包装结果。
@RestController @RequestMapping("/api/drug") public class DrugController { private final DrugService drugService; public DrugController(DrugService drugService) { this.drugService = drugService; } @GetMapping("/list") public ResultVO list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String keyword) { return ResultVO.ok(drugService.page(page, size, keyword)); } }代码里用的是构造器注入,而不是直接@Autowired字段注入,原因很简单:构造器注入让DrugController在单元测试里可以直接new,不需要启动 Spring 容器。page和size由前端分页组件传进来,keyword是可选的模糊查询词。ResultVO是统一返回体,包含状态码、消息和数据,前端 axios 拦截器直接消费它的结构。
注意:不要把
@RequiresPermissions注解当成唯一的权限控制手段。它限制的是接口能不能被调用,但不会自动过滤列表数据里的敏感字段。常见做法是接口层用注解做操作拦截,页面层用路由守卫控制菜单显示。
2.2 药品主数据与库存扣减的 Mapper 设计
数据库里的核心表有三张,其它表大多围绕这三张扩展。
| 表名 | 核心字段 | 作用 |
|---|---|---|
| drug_info | id, drug_name, stock, sale_price, expire_date | 药品基础信息与库存 |
| sales_order | id, order_no, customer_id, total_price, order_time | 销售订单主表 |
| sales_order_item | id, order_id, drug_id, quantity, unit_price | 订单明细,关联药品表 |
药品列表页要展示分类和厂家名称,不能只查drug_info单表。Mapper 层用接口加 XML 的方式组织,多表关联时最关键的是 resultMap。
2.2.1 多表关联查询的 resultMap 配置
<resultMap id="DrugVO" type="com.pharmacy.vo.DrugVO"> <id column="drug_id" property="id"/> <result column="drug_name" property="drugName"/> <result column="stock" property="stock"/> <result column="category_name" property="categoryName"/> <result column="factory_name" property="factoryName"/> <result column="expire_date" property="expireDate"/> </resultMap> <select id="selectDrugPage" resultMap="DrugVO"> SELECT d.id AS drug_id, d.drug_name, d.stock, c.category_name, f.factory_name FROM drug_info d LEFT JOIN drug_category c ON d.category_id = c.id LEFT JOIN drug_factory f ON d.factory_id = f.id LIMIT #{offset}, #{size} </select>这段 XML 里的每个列都显式起了别名,因为drug_info和drug_category可能都有id或name字段。MyBatis 底层是通过列名去匹配 resultMap 的 property,列名重复时,后面查出来的值会把前面的覆盖掉。观察 MyBatis 源码里ResultSetHandler的处理逻辑就能看到,它本质上是一次列名到属性的循环赋值,并不自动处理重名。
2.2.2 避免库存超卖的 UPDATE 写法
库存扣减是并发问题的重灾区。如果 Service 里先执行 select 查库存,再在内存里判断够不够,最后执行 update,两个请求同时进来时,库存就可能变成负数。稳定的做法是把判断条件直接写进 UPDATE 的 WHERE:
UPDATE drug_info SET stock = stock - #{quantity} WHERE id = #{drugId} AND stock >= #{quantity}这段 SQL 的返回值是受影响行数,Mapper 方法返回 int。返回值大于 0,说明扣减成功;等于 0,说明库存不足或者药品被禁用。Service 根据返回值决定是否抛出业务异常,不用提前加锁,也不需要额外查一次数据库。
2.3 @Transactional 事务边界与回滚的坑
创建销售订单要同时写订单主表、订单明细表和库存表,这三步必须在一个事务里:
@Transactional(rollbackFor = Exception.class) public void createOrder(SalesOrderVO order) { // 校验药品状态与购买数量 // 插入订单主表 sales_order // 逐条插入订单明细 sales_order_item // 调用 drugMapper.reduceStock(order.getDrugId(), order.getQuantity()) }Spring 的@Transactional默认只对 RuntimeException 回滚。如果代码里自己 catch 了 Exception,或者方法抛出的是受检异常,事务不会回滚。这个坑最常见的表现是:订单明细插入失败,但主表和库存已经提交,最后数据库里出现一条没有明细的订单。处理办法是 Service 内部不要吞异常,异常交给统一异常处理器去转成 ResultVO,事务边界才能正常工作。
注意:事务方法里不要放远程调用或短信通知,比如创建订单后立刻发短信。这些操作会长时间占住数据库连接,一旦对方服务变慢,连接池会被耗尽。常见做法是事务提交后,通过事件发布或消息队列再去执行通知。
3. Shiro 认证与授权:登录校验、角色权限与 Vue 路由守卫
Shiro 在 Java Web 系统里是轻量级权限方案,和 Spring Security 相比配置量更少,但过滤链的坑也更隐蔽。这套系统用 Shiro 管理登录状态,核心流程是:前端提交用户名密码 → 后端Subject.login()触发自定义 Realm 查询用户 → 认证成功后写入 Session。登录之后,每个请求经过过滤器链时检查是否已登录,以及当前用户有没有对应权限。
3.1 Shiro 过滤器链与匿名访问配置
Shiro 的过滤链配置在 ShiroConfig 中,最需要关注的是路径顺序。anon开头的放行规则必须写在authc之前,否则/login会被匹配到后面的兜底拦截规则,形成死循环跳转。
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean(); filterFactoryBean.setSecurityManager(securityManager); Map<String, String> filterChainMap = new LinkedHashMap<>(); filterChainMap.put("/login", "anon"); filterChainMap.put("/css/**", "anon"); filterChainMap.put("/js/**", "anon"); filterChainMap.put("/images/**", "anon"); filterChainMap.put("/api/user/logout", "logout"); filterChainMap.put("/**", "authc"); filterFactoryBean.setFilterChainDefinitionMap(filterChainMap); filterFactoryBean.setLoginUrl("/login"); return filterFactoryBean; }filterChainMap的 key 是 Ant 风格路径,value 是过滤器名称。anon表示匿名可访问,authc表示必须登录,logout是 Shiro 内置的退出过滤器,会清除 Session。静态资源必须放行,不然登录页的 CSS 和图片全部被拦截,浏览器控制台里会看到一大堆 302 跳转到/login的请求。出现这个问题时,先查过滤链顺序,再查静态资源路径前缀。
注意:过滤链的 Map 要用 LinkedHashMap,因为需要保证遍历顺序。HashMap 的遍历顺序不稳定,放行规则可能失效,这是 Shiro 配置里比较容易忽略的细节。
Shiro 还提供按角色和权限过滤的规则,区别如下:
| 过滤器 | 右侧参数 | 作用 | 使用场景 |
|---|---|---|---|
| anon | 无 | 匿名访问 | 登录页、验证码、静态资源 |
| authc | 无 | 必须登录 | 需要身份的页面和接口 |
| roles | admin | 必须拥有指定角色 | 管理后台入口 |
| perms | drug:add | 必须拥有指定权限码 | 药品新增、删除等操作 |
3.2 密码加盐与 MD5 的配合方式
这套系统里用户密码用的是 MD5 加盐,散列次数设置为 2。单纯的 MD5 在彩虹表面前几乎没有防护力,加上随机盐之后,即使两个用户密码相同,数据库里存的也是不同的密文。Shiro 的密码比对通过HashedCredentialsMatcher完成:
@Bean public HashedCredentialsMatcher hashedCredentialsMatcher() { HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("MD5"); matcher.setHashIterations(2); return matcher; }自定义 Realm 里返回SimpleAuthenticationInfo时,把注册时生成的那个盐值放到第三个参数,Shiro 在比对时就会用同一个盐去重算哈希。如果登录报IncorrectCredentialsException,优先检查两处:第一,盐值是否完整传到SimpleAuthenticationInfo;第二,setHashIterations的数值是否和用户注册时使用的次数一致。这两处只要有一个对不上,密码验证就必失败。
3.3 会话超时与前端 401 拦截
Shiro 默认 Session 有效期是 30 分钟,后端会话过期后接口返回 401。如果前端不处理,用户会停留在当前页面,点击任何按钮都没有反应。项目里的 axios 封装通常在响应拦截器里统一处理:
axios.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { window.location.href = '/#/login'; } return Promise.reject(error); } );这段代码让所有接口共用同一套未登录跳转逻辑。注意跳转地址是/#/login,因为前端项目是 hash 路由,少了#号会跳到错误路径。同时要把 401 和 403 区分开,未登录是 401,跳转登录页;已登录但权限不足是 403,应该提示“当前账号无权限”,而不是让用户重新登录。
4. Vue + Bootstrap + JQuery:药店管理系统的前端与后端接口对接
前端源码经过 3-build.bat 构建后,产物是app.bb5659c3.css、chunk-vendors.1f0a25b2.css这类带 hash 的文件。chunk-vendors是第三方依赖的集合体,体积大但更新频率低,适合长期缓存;app是业务代码,每次改动后文件名里的 hash 会变化,浏览器会自动加载新版本。这也是部署完前端后用户反馈“页面没变”的原因,本质是 hash 没变或者强缓存。
4.1 index.html 与静态资源的关系
index.html是 Vue 构建的入口模板,打包后里面按顺序加载上述 JS 和 CSS 文件。Spring Boot 会把resources/static目录映射到根路径,把构建产物复制进去后,访问/index.html就能看到整个系统。如果前后端分开部署,需要在 nginx 里把/api路径反向代理到后端的 8080 端口,前端静态文件由 nginx 直接返回。区分这两条路径,比改代码更能解决部署时的 404 问题。
4.2 axios 请求封装与登录状态维护
前端所有请求共用同一个 axios 实例,核心参数是这几个:
const http = axios.create({ baseURL: '/api', timeout: 10000, withCredentials: true });withCredentials: true非常关键。Shiro 的登录状态依赖 Session Cookie,前端浏览器如果不携带 Cookie,后端永远认为用户未登录。如果本地开发时前后端端口不同,要在后端 CORS 配置里同时设置allowCredentials(true)和具体的allowedOrigin,不能只用*通配符,否则浏览器会拦截携带凭证的响应。
页面和后端接口的映射关系可以参考下表,新增模块时照着加一行:
| 方法 | 路径 | 主要参数 | 说明 |
|---|---|---|---|
| POST | /api/login | username, password | 登录并写入 Session |
| POST | /api/logout | 无 | 退出登录 |
| GET | /api/drug/list | page, size, keyword | 药品分页查询 |
| POST | /api/order/save | 订单 JSON | 创建销售订单 |
| PUT | /api/drug/update | 药品对象 | 更新药品信息 |
4.3 JQuery 插件在 Vue 组件里的迁移套路
这套项目里还存在部分 JQuery 页面,比如表格排序、日期选择器、分页按钮。直接把 JQuery 插件初始化代码写在 Vue 模板里,组件重新渲染时会失效,因为 Vue 的v-if会销毁重建 DOM 节点,插件绑定的事件也随之丢失。常见做法是把初始化放到 Vue 的mounted钩子里,在beforeDestroy中销毁插件实例:
export default { mounted() { $('#datePicker').datepicker({ format: 'yyyy-mm-dd' }); }, beforeDestroy() { $('#datePicker').datepicker('destroy'); } };如果 JQuery 操作的是循环渲染出来的表格行,尽量使用事件委托,在父级容器上监听事件,再通过data属性找到记录 id。避免给每行单独绑定事件,几千条药品数据量下,事件绑定数量会直接影响页面卡顿程度。
5. Windows 环境部署:1-install.bat、2-run.bat、3-build.bat 的执行顺序与排错
三个脚本的文件名已经标明了执行顺序:先1-install.bat,再2-run.bat,最后3-build.bat。1-install.bat解决的是依赖从哪来的问题,2-run.bat解决后端怎么启动的问题,3-build.bat解决前端页面怎么生成的问题。只有把前端静态资源复制到后端 static 目录之后再启动 2-run.bat,页面才能正常展示,否则后端起来了,浏览器也访问不到页面。
| 脚本 | 职责 | 常见内容 |
|---|---|---|
| 1-install.bat | 安装并构建后端依赖 | mvn clean install |
| 2-run.bat | 启动 Spring Boot 应用 | mvn spring-boot:run 或 java -jar target/xxx.jar |
| 3-build.bat | 构建 Vue 前端静态资源 | npm install && npm run build |
5.1 先检查环境还是先执行脚本
我一般先执行四个基础命令,确认环境可用,再决定脚本要不要继续跑:
java -version mvn -v npm -v mysql --version这四个命令分别确认 JDK、Maven、Node、MySQL 是否已经出现在 PATH 里。其中java -version和mvn -v都依赖 JAVA_HOME 环境变量,JAVA_HOME 一旦配错,mvn -v可能正常显示,但执行打包时找不到编译器,报错信息还会引导你去看 pom.xml,非常容易误判。配置 JAVA_HOME 时,路径必须写到 JDK 安装目录本身,不能带上bin子目录,配置完成后重新打开命令行窗口再验证。
5.2 MySQL 初始化与连接串参数
项目默认连接本机 3306 端口的 MySQL 库,application.yml里要注意驱动名和时区参数:
spring: datasource: url: jdbc:mysql://localhost:3306/pharmacy_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.DriverMySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,而且连接串必须带serverTimezone,否则启动阶段直接抛时区异常。MySQL 5.7 可以不带,但建议带上,这样在两个 MySQL 大版本之间切换部署时,少踩一次坑。
5.3 常见错误的定位顺序
命令行跑 2-run.bat 启动失败时,先按现象定位,不急着看日志。
| 现象 | 原因 | 排查方式 |
|---|---|---|
| Port 8080 was already in use | 端口被占用 | 执行 netstat -ano | findstr 8080,结束对应进程 |
| Unknown database | 数据库没有创建 | 执行项目中自带的 sql 脚本,确认库名和配置一致 |
| Access denied for user | 账号或密码错误 | 在 MySQL 命令行重设 root 密码 |
| 页面中文乱码 | 控制台或 MySQL 编码不一致 | bat 文件顶部加 chcp 65001,并检查 URL 是否带 characterEncoding=UTF-8 |
踩得最多的是 8080 端口冲突,本地装了其它微服务组件时,8080 会被占用。最简单的验证方式是把server.port改到 8090,同时修改前端 axios 的 baseURL,如果页面能打开,说明问题只在端口,跟代码无关。本地脚本跑通后,如果团队要持续集成,可以把这几个命令串联到 CI 工具里,用mvn clean package替换mvn spring-boot:run,让 Jenkins 去执行构建和部署,本地只保留最小验证命令。
6. 在源码上做两个增量:药品过期提醒与采购报表
往一套源码里加功能,是最快检验代码结构的办法。两个增量改动都用现有表和 Mapper 就能完成,不需要调整整体架构。
6.1 药品过期提醒
药品表如果有expire_date字段,过期提醒就只需要一条 SQL 加一个定时任务。查询条件是未来 30 天内到期且还有库存:
SELECT d.id, d.drug_name, d.expire_date FROM drug_info d WHERE d.expire_date BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY) AND d.stock > 0定时任务直接加在 Service 层:
@Scheduled(cron = "0 0 8 * * ?") public void remindExpireDrugs() { List<DrugVO> list = drugMapper.selectNearlyExpired(); list.forEach(item -> log.warn("药品 {} 将于 {} 过期", item.getDrugName(), item.getExpireDate())); }每天 8 点扫描一次,输出到日志。对药店系统来说,预警要留出足够时间,INTERVAL间隔可以抽成配置项,前后端共用同一个数值,避免页面显示的时间范围和实际 DB 查询不一致。
6.2 采购报表的聚合查询
采购报表要按药品分组统计数量和金额,标准的写法是把purchase_order_item和drug_info关联后聚合:
SELECT item.drug_id, d.drug_name, SUM(item.quantity) AS total_quantity, SUM(item.quantity * item.unit_price) AS total_amount FROM purchase_order_item item LEFT JOIN drug_info d ON item.drug_id = d.id WHERE item.create_time BETWEEN #{startTime} AND #{endTime} GROUP BY item.drug_id, d.drug_name ORDER BY total_quantity DESCMapper 方法返回List<PurchaseReportVO>,Service 层拿到数据后,可以先判断是否需要内存排序。数据量在几千行以内,直接用 Java 8 Stream 按totalQuantity排序反而灵活;数据量上去后,再考虑把排序和分页都下推到 SQL,避免一次加载全部数据。如果并发导出报表,可以用线程池并行查询不同时间区间,然后通过CountDownLatch等待所有查询线程完成后汇总,这样 SQL 层承担的压力更小。
本文还有配套的精品资源,点击获取