
简介面向初、中级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_infoid, drug_name, stock, sale_price, expire_date药品基础信息与库存sales_orderid, order_no, customer_id, total_price, order_time销售订单主表sales_order_itemid, order_id, drug_id, quantity, unit_price订单明细关联药品表药品列表页要展示分类和厂家名称不能只查drug_info单表。Mapper 层用接口加 XML 的方式组织多表关联时最关键的是 resultMap。2.2.1 多表关联查询的 resultMap 配置resultMap idDrugVO typecom.pharmacy.vo.DrugVO id columndrug_id propertyid/ result columndrug_name propertydrugName/ result columnstock propertystock/ result columncategory_name propertycategoryName/ result columnfactory_name propertyfactoryName/ result columnexpire_date propertyexpireDate/ /resultMap select idselectDrugPage resultMapDrugVO 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 的 WHEREUPDATE 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); MapString, 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无必须登录需要身份的页面和接口rolesadmin必须拥有指定角色管理后台入口permsdrug: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/loginusername, password登录并写入 SessionPOST/api/logout无退出登录GET/api/drug/listpage, 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 install2-run.bat启动 Spring Boot 应用mvn spring-boot:run 或 java -jar target/xxx.jar3-build.bat构建 Vue 前端静态资源npm install npm run build5.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?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/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 是否带 characterEncodingUTF-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() { ListDrugVO 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 方法返回ListPurchaseReportVOService 层拿到数据后可以先判断是否需要内存排序。数据量在几千行以内直接用 Java 8 Stream 按totalQuantity排序反而灵活数据量上去后再考虑把排序和分页都下推到 SQL避免一次加载全部数据。如果并发导出报表可以用线程池并行查询不同时间区间然后通过CountDownLatch等待所有查询线程完成后汇总这样 SQL 层承担的压力更小。本文还有配套的精品资源点击获取