
简介这份药房药品管理系统源码基于Java后端语言整合SSM框架与Vue前端技术面向Web开发初学者、Java工程师及毕业设计学生可帮助解决药品信息管理、库存盘点、进货销售等业务场景的快速实现问题。压缩包为zip格式整体大小约22.7MB主要包含Spring、SpringMVC、MyBatis的Java后端源码Vue前端页面与交互逻辑以及MySQL数据库建表脚本和Maven配置使用IDEA或Eclipse导入即可查看运行。系统采用前后端分离设计后端由SSM和SpringBoot协作提供接口服务前端Vue负责数据展示与操作交互覆盖药品分类、供应商、库存预警等常见模块完整展示了企业级药品管理系统的代码组织方式。其中SQL脚本预置了药品表、订单表等核心数据表Maven配置帮助管理依赖Vue组件拆分清晰便于二次开发。目前该资源已有83人浏览学习对正在做课程设计或毕业设计的同学是一份值得参照的完整源码方案。1. 药房药品管理系统选型SSM 负责数据Vue 负责交互药房的日常业务看着就三件事进药、卖药、盘库存。但把批号、效期、供应商、处方来源这些字段引进来之后表结构会一下子变厚页面交互也从单表增删改查变成多条件搜索、批次选择、临期高亮。SSM Vue 在药房药品管理系统里是出现频率很高的组合SSM 三件套把接口、事务、SQL 分得清清楚楚Vue 把表格和表单交互做成单页体验。要读懂一套这种实现源码或者拿它改造成一个能上线的药房系统重点不在框架 API而在数据表怎么拆、库存怎么扣、效期怎么预警、权限怎么收敛这四个点正好对应后面四章的落点。这篇按源码落地路径来讲适合 java 后端初学者也适合要接手这种老项目做二次开发的人。2. SSM 后端的工程分层与药品数据表设计2.1 先看懂 SSM 工程的 maven 依赖与包结构拿到一份 SSM 药房系统源码第一件事不是启动而是先看工程长什么样。常见做法是单一 maven war 工程按 controller、service、mapper、entity 四个包分前后依赖。Spring 容器配置分成 applicationContext.xml 和 spring-mvc.xml前者扫 service、mapper、datasource后者只扫 controller。这样拆是因为 SpringMVC 有父子容器机制子容器里配置的 Controller 不能直接依赖父容器没有的 Bean乱扫会出现一堆“找不到 bean”的启动报错。properties spring.version5.2.22.RELEASE/spring.version mybatis.version3.5.10/mybatis.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.11/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency /dependencies依赖版本有一点要注意Spring 5 的 spring-webmvc 和 spring-context 版本必须一致否则运行期报 NoSuchMethodErrorMyBatis 3.5.x 必须配 mybatis-spring 2.x这个组合与 Spring 5 才兼容。Druid 数据源不是必须的但药房系统并发不大Druid 自带监控页排障很方便我一般会保留。提示如果源码还在用 Spring 4.x先跑通再升级不要一上来就改大版本否则一堆过时的 Spring XML 配置会先爆出兼容错误。2.2 拆成药品、批次、流水三张表效期才有地方放同一种药会按批进货各批次的进价、售价、有效期不同。若把库存直接放在药品表批次信息就无处存放效期预警也无从谈起。所以基本表结构至少拆成三张drug 表保存通用名、规格、厂家、库存上下限drug_batch 表保存批号、生产日期、失效日期、当前数量stock_record 表按时间顺序记录每一笔出入库。CREATE TABLE drug ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(32) NOT NULL COMMENT 药品编码, drug_name VARCHAR(64) NOT NULL COMMENT 通用名, spec VARCHAR(64) COMMENT 规格, unit VARCHAR(10) COMMENT 单位, form VARCHAR(20) COMMENT 剂型, manufacturer VARCHAR(128) COMMENT 生产厂家, min_stock INT DEFAULT 0 COMMENT 库存下限低于此值预警, max_stock INT DEFAULT 0 COMMENT 库存上限高于此值积压, status TINYINT DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_drug_code (drug_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(32) NOT NULL COMMENT 生产批号, product_date DATE COMMENT 生产日期, expire_date DATE NOT NULL COMMENT 失效日期, quantity INT NOT NULL DEFAULT 0 COMMENT 当前批次剩余数量, purchase_price DECIMAL(10,2) COMMENT 采购单价, sale_price DECIMAL(10,2) COMMENT 零售单价, UNIQUE KEY uk_drug_batch (drug_id, batch_no), KEY idx_expire (expire_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段命名全部用下划线MyBatis 开启 mapUnderscoreToCamelCase 后自动映射 drugCode 这类驼峰属性不用每个实体都写 resultMap。uk_drug_batch 唯一键保证同一药品同一批号只有一条记录idx_expire 索引是给效期预警查询用的后面第 4 章会专门讲。药品主数据、批次库存、流水记录三张表的职责边界如下表名用途关键字段drug药品基础档案drug_code, drug_name, min_stock, max_stockdrug_batch批次级库存与效期batch_no, expire_date, quantitystock_record出入库流水与审计change_qty, type, operator2.3 批次扣减把判断写进 UPDATE避免超卖出库是药房系统并发最高的操作。常见错误是先查再改两个请求同时读到 quantity10各自扣 5最后一次更新把结果覆盖错。解决方式是把判断条件写进 update 语句靠数据库行锁保证原子性。update iddeductBatchStock UPDATE drug_batch SET quantity quantity - #{qty} WHERE id #{batchId} AND quantity #{qty} AND expire_date #{today} /updateWHERE 里两个条件缺一不可quantity #{qty} 保证扣减后不会变成负数expire_date #{today} 保证过期批次不会被发出去。update 返回影响行数返回 0 就说明数量不够或批次已过期service 层直接抛业务异常不必再查一次。如果你读过 MyBatis 的 update 执行路径就能明白影响行数来自 JDBC 的 executeUpdate 返回值判断成本极低。提示药店场景单量不大条件更新足够用。如果要改成连锁药房多门店同时发药就要在 drug_batch 表加 version 字段做乐观锁或者直接引入分布式锁。2.4 Service 层事务边界与配置库存变更涉及两个写操作扣减批次数量、插入流水。如果只扣了库存、流水插入失败账面就乱了。SSM 里事务一般落在 service 实现类上。Service public class StockService { Transactional(rollbackFor Exception.class) public StockRecord outStock(StockOutDTO dto) { int rows drugBatchMapper.deductBatchStock( dto.getBatchId(), dto.getQty(), new Date()); if (rows 0) { throw new BizException(库存不足或药品已过期); } StockRecord record new StockRecord(); record.setDrugId(dto.getDrugId()); record.setBatchId(dto.getBatchId()); record.setChangeQty(-dto.getQty()); record.setType(2); // 2 表示出库正负号见 4.1 的约定 record.setOperator(SecurityUtils.getCurrentUserName()); stockRecordMapper.insert(record); return record; } }rollbackFor 必须显式写成 Exception.class。Spring 默认只对 RuntimeException 回滚如果抛的是自定义 checked 异常事务会正常提交流水和库存对不上月底盘点才暴雷。事务传播级别用默认 REQUIRED 就够这种单库场景不需要 NESTED 和 REQUIRES_NEW写多了反而容易把事务拆断。3. Vue 前端的接口对接与页面状态管理3.1 开发环境先解决跨域vite 代理与参数说明Vue 开发服务器默认跑在 3000 或 8080后端 SSM 打进 Tomcat 后跑在 9090两者不同源。药房系统登录态靠 token 传递为了避免每个请求都走浏览器 CORS 预检开发时用前端代理把 /api 前缀的请求转发到后端最省事。// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 3000, proxy: { /api: { target: http://localhost:9090, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })target 是后端 SSM 的根地址一般就是 Tomcat 的访问根路径changeOrigin 设为 true 会改写请求的 Host 头rewrite 把 /api 前缀剥掉后端 Controller 里就不需要额外加一层 context-path。如果拿到的是 vue-cli 工程对应配置写在 vue.config.js 的 devServer.proxy 里字段含义一致。参数作用本例子取值target后端接口地址http://localhost:9090changeOrigin改写请求 Host 头truerewrite去除路径前缀/^/api//另一个坑是后端如果已经配了 CorsFilter前端又开代理两个机制叠加会产生重复的 Access-Control-Allow-Origin 响应头。排查时直接看浏览器 Network 面板响应头里出现两个同名头就注释掉后端的过滤器。3.2 axios 统一封装token 注入、响应码处理、401 跳转大部分页面都要登录态最省事的方式是登录成功把 token 存 localStorageaxios 请求拦截器统一塞进 Header响应拦截器统一处理 401 和业务错误。import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器注入 token service.interceptors.request.use(config { const token localStorage.getItem(pharmacy_token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器处理业务码与登录态失效 service.interceptors.response.use( response { const res response.data if (res.code ! 200) { return Promise.reject(new Error(res.message || 请求失败)) } return res }, error { if (error.response error.response.status 401) { window.location.href /login } return Promise.reject(error) } ) export default service这里约定后端未登录时返回 HTTP 401前端靠 status 401 跳登录页。如果后端习惯返回 200 加业务码 401两边的逻辑就要统一改成业务码判断否则会出现“提示成功但页面没数据”的假象。超时时间建议设 10 秒药房系统的导出报表接口偶尔会超过这个值可以在具体调用里用service.get(url, { timeout: 30000 })单独覆盖。3.3 路由拆成登录、主框架、业务页面参数尽量走 query后台管理端的路由结构固定登录页独立主框架 Layout 负责侧边栏和内容区业务页面挂成 children。懒加载写法让首屏只下载必要的 chunk。const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layout/Layout.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/Dashboard.vue), meta: { title: 工作台 } }, { path: drug/list, name: DrugList, component: () import(/views/drug/List.vue), meta: { title: 药品管理 } }, { path: stock/in, name: StockIn, component: () import(/views/stock/In.vue), meta: { title: 入库 } }, { path: stock/warn, name: StockWarn, component: () import(/views/stock/Warn.vue), meta: { title: 效期预警 } } ] } ]meta.title 用来做侧边菜单的循环渲染加新页面时只需在 children 里加一项。vue-router 4Vue 3 默认跳转时传参有 query 和 params 两种query 会拼在 URL 后面刷新不会丢params 存在内存里刷新页面就空了。药房系统里从列表页跳到药品详情页传药品 id用 query 最稳别用 params否则用户一刷新就跳回列表。3.4 列表页分页搜索的传参技巧药品列表是药房系统的核心页面搜索条件一般有药品名称、编码、厂家、是否只看低库存。封装查询函数时最忌讳把整个表单对象原样传给后端。import service from /utils/request export function getDrugList(params) { return service({ url: /drug/list, method: get, params }) }async function loadList() { const params { pageNum: page.pageNum, pageSize: page.pageSize, drugName: searchForm.drugName || undefined, drugCode: searchForm.drugCode || undefined, manufacturer: searchForm.manufacturer || undefined, lowStockOnly: searchForm.lowStockOnly || false } const res await getDrugList(params) list.value res.data.records total.value res.data.total }空字符串转成 undefined 的原因axios 序列化参数时值为 undefined 的字段会直接跳过不进入 URL。后端 MyBatis 的动态 SQL 只需要判断字段是否为 null不用关心空字符串条件更干净。每次点击搜索都要把 pageNum 重置为 1否则可能出现停留在第 5 页搜索到空列表的体验问题。排查列表页数据时优先用 Vue DevTools 看组件的 list 和 total 是否赋值再看 Network 面板的请求参数两步能定位 90% 的问题。4. 药房核心业务库存流水、效期预警与操作权限4.1 出入库必须双写扣批次、记流水药房系统的价值不在“能增删改查”而在每一盒药都能追溯。入库、出库、退货、盘点调整这四类动作都要落在 stock_record 表type 字段约定如下type含义change_qty 方向1入库正数2出库负数3退货正数4盘点调整正数或负数前端提交时后端要校验 type 的取值不能直接存前端传的任意数字。业务单号可以单独建一个字段比如 RK20250101001、CK20250101003流水列表按 record_time 倒序分页展示。遇到报损、报废这类场景复用 type4 盘点调整在 remark 里写明原因就行不需要为每个动作单独建表。出库时由仓库人员指定批次更接近药房实际操作习惯。如果要做成自动分配批次一般是“近效期先出”也就是按失效日期升序取批次循环扣减SELECT id, quantity, expire_date FROM drug_batch WHERE drug_id #{drugId} AND quantity 0 AND expire_date CURDATE() ORDER BY expire_date ASC LIMIT 10;service 层拿到结果后循环调用 deductBatchStock一个批次不够就扣下一个批次直到满足出库数量。这里要格外注意循环中任意一次扣减失败整个事务都要回滚不能出现前一批扣了、后一批失败导致账目错乱。4.2 效期预警按剩余天数动态算别只靠定时任务很多源码用 quartz 每天凌晨扫一遍库把临期药品写预警表。这个方案能用但当天的入库药品要第二天的任务跑完才出现且阈值调整麻烦。更可靠的方式是查询时动态计算剩余天数。SELECT d.drug_code, d.drug_name, d.spec, b.batch_no, b.quantity, b.expire_date, DATEDIFF(b.expire_date, CURDATE()) AS remain_days FROM drug_batch b INNER JOIN drug d ON d.id b.drug_id WHERE b.quantity 0 AND b.expire_date DATE_ADD(CURDATE(), INTERVAL #{warnDays} DAY) ORDER BY b.expire_date ASC;warnDays 从系统参数表读取药房一般配置两级90 天以内标黄30 天以内标红。前端拿到 remain_days 后做样式判断不影响后端逻辑。这段 SQL 走的正是 drug_batch 表上的 idx_expire 索引数据量上来之后依然能保持稳定。预警页面实时查询的另一个好处是参数调整立即生效。把阈值塞进定时任务里的做法改一次配置要重启或手动触发运维成本高。定时任务可以保留一个用来每天把临期批次导出报表页面预警走实时查询两者职责分开。4.3 权限控制HandlerInterceptor 加会话不急着引 Shiro药房系统用户模型通常就管理员、库管员、收银员三种没有复杂的组织架构和细粒度权限。Spring Security 和 Shiro 在这个体量下都偏重。务实做法是用 SpringMVC 的 HandlerInterceptor 做 URL 级控制。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { User user (User) request.getSession().getAttribute(loginUser); if (user null) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或已过期\}); return false; } if (request.getRequestURI().startsWith(/admin/) !ADMIN.equals(user.getRole())) { response.setStatus(403); response.getWriter().write({\code\:403,\message\:\无权限访问\}); return false; } return true; } }拦截器注册时记得放行登录接口和静态资源mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/css/**/ mvc:exclude-mapping path/js/**/ mvc:exclude-mapping path/druid/**/ /mvc:interceptor /mvc:interceptors提示很多人把登录页也拦截了导致系统一访问就 401调试半天才发现是路径放行配置写错。所有静态资源和登录接口必须放在 exclude-mapping 里。药房这类系统操作留痕比复杂权限模型更重要。即使不做细粒度权限也要在每次增删改时写操作人在 service 里从会话中取不要用前端传的值。这既是审计要求也是出纠纷时追责的依据。5. 打包部署的验证顺序与后端排错技巧5.1 本地到服务器war 包和 dist 目录两条发布路径SSM 后端打 war 包部署到 Tomcat前端 Vue 打包成静态文件。上线前按顺序验证先执行构建命令确认打包能过再把 war 包丢进 Tomcat。mvn clean package -DskipTests cp target/pharmacy-web.war /opt/tomcat/webapps/ROOT.war /opt/tomcat/bin/startup.sh tail -f /opt/tomcat/logs/catalina.out启动后第一件事不是开页面而是看 catalina.out 里有没有成功的部署日志和数据库连接池初始化日志。Druid 连接失败往往不影响 Tomcat 启动而是第一个请求进来才报错这种延迟暴露的问题最迷惑人。数据库连接串里的时区参数要写成 serverTimezoneAsia/Shanghai连接 8.x MySQL 时不带时区会直接报错。Vue 前端构建产物放到 Nginx 静态目录接口请求反向代理到后端server { listen 80; server_name pharmacy.example.com; root /data/pharmacy/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:9090/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }proxy_pass 末尾的斜杠非常关键它会把 /api/login 重写成后端 /login与开发环境 vite 的 rewrite 保持一致后端 Controller 不用改任何路径。如果 Vue 路由用的是 history 模式直接访问 http://pharmacy.example.com/stock/warn 会 Nginx 404这不是打包后布局异常而是 Nginx 没有对应的静态文件兜底需要加一行try_files $uri $uri/ /index.html;。发布前还要确认服务器上java -version、mvn -v、node -v都可用老 vue-cli 工程对 Node 版本要求与新 vite 工程不一样版本太新反而可能装不上依赖。5.2 接口 404、MyBatis 绑定异常、跨域三类报错定位顺序后端接口 404先看 Tomcat 访问日志确认请求到了容器没有。如果是已经到了容器但 404检查 SpringMVC 的 RequestMapping 全路径和前端实际路径是否一致最常见的是 /api 前缀在前端代理里没剥干净。开发阶段可以把 spring-mvc.xml 的日志级别调到 DEBUG看 HandlerMapping 匹配到了哪个方法logger nameorg.springframework.web.servlet.mvc.method.annotation levelDEBUG/MyBatis 报 “Invalid bound statement (not found)”检 查三处Mapper 接口的包路径是否和 mybatis 扫描包一致XML 文件是否放在 resources/mapper 下且被 maven 当作资源打入 classpathMapper 接口方法名与 XML 里的 id 是否完全一致。这三个问题轮着查一遍绑定异常基本解决。跨域报错不要只在前端查。打开 F12 的 Network 面板响应头里没有 Access-Control-Allow-Origin 才是真正的后端问题如果出现了但请求还是失败通常是 OPTIONS 预检没有正确处理。确认前端开了代理、后端关了 CorsFilter两边不叠加是最省心的配置。5.3 上线前把慢 SQL 和 Druid 监控打开药房系统部署完毕后最容易被忽略的是数据库索引和 SQL 执行计划。drug_batch 表上的 idx_expire 和 uk_drug_batch 就是为预警和批次查询准备的上线前跑一遍 explain 确认 SQL 真实走了索引。如果 drug_batch.quantity 在 where 条件里参与判断时走了全表扫描优先优化索引不要急着加 Redis 缓存药房业务对数据一致性要求远高于对性能的要求。把 MySQL 慢查询日志打开阈值设 500ms观察一周里出现频率最高的 SQLSET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.5;同时打开 Druid 监控页默认地址是 /druid/index.html可以看到连接池活跃数、慢查询和每个接口的执行统计。药房这类小并发系统瓶颈很少在 Tomcat而在 SQL 上。排查时把 Druid 的 SQL 列表拉出来对照 explain 结果最容易翻车的写法是 where 条件里对日期字段做函数运算比如MONTH(expire_date) 5会让索引失效改成expire_date BETWEEN 2025-05-01 AND 2025-05-31才能命中 idx_expire这条经验在效期统计场景里百试百灵。本文还有配套的精品资源点击获取