一个开药店的朋友找我帮忙说他店里还在用Excel管库存效期药品快到期的没人提醒处方药销售记录也不够规范每次应付检查都满头大汗。我一开始觉得就是个普通进销存但真正把需求聊透以后才发现药店管理的核心痛点根本不在卖药这个动作而在药品流转这条完整链路——从采购入库、批次库存、效期预警到收银开单和处方登记任何一环掉链子都会直接影响药店能不能合规经营。这篇文章要聊的这套基于Spring Boot Vue的药店管理系统就是围绕这条链路设计的前后端分离项目。后端用Spring Boot提供RESTful接口前端用Vue配合Element UI搭建管理后台源码、数据库脚本和项目文档都齐全适合正在做课程设计、毕设或者想给中小型药店做信息化改造的同学直接参考。下面我会把这个项目的需求拆解、数据库建模、库存扣减的并发处理、收银开单的交互设计这些核心细节全部展开实际调试中踩过的坑也会一并写出来。1. 药店管理项目从哪来先搞清楚药店每天在忙什么1.1 一天下来药店要处理哪些事药店不是简单的卖药。一家社区药店每天开门之后要做的事情大致有这么几类。第一类是采购和验收。店长要根据库存情况和销售节奏向供应商下采购单货到了以后要验数量、验批号、验效期然后录入系统。这个环节如果靠手写单据后期对账能对到怀疑人生。第二类是销售。顾客买药店员开单收款。如果是处方药按规定必须凭处方销售还要登记处方编号、患者信息、用药剂量这些内容。这里有个外行人容易忽略的点处方药销售记录不是你想记就记、想不记就不记的监管检查的时候必须能随时调出来。第三类是库存管理。药品有有效期不能像普通商品那样一直放着。效期管理做不好过期药还摆在货架上这是药店经营的红线问题。另外还要每天关注哪些品种库存偏少、哪些品种积压避免资金压在滞销品种上。第四类是会员和营销。很多药店有会员积分、满减活动会员购药记录要能查积分要能算不然顾客问一句我卡里多少分店员都答不上来。第五类是报表。日报、月报、毛利统计、供应商对账月底都要做得出来。把这五类事拆开看就是一个典型的进销存 合规管理系统。功能边界定清楚以后后面所有设计都围绕这几条主线展开就不会跑偏。1.2 为什么不能继续用Excel说实话很多小型药店至今还在用Excel管药勉强能用但越用越难受。库存账和实物账对不上是最常见的现象因为每个人录入习惯不一样有人填阿莫西林胶囊有人填阿莫灵同一个药品在表格里出现好几个称呼月末汇总的时候根本分不清到底哪个才是同一个品种。更麻烦的是效期管理。Excel里虽然有一列有效期但没人会每天翻一遍表格看哪批药快过期了。我接触过一家社区药店出过整盒过期药被顾客买到的情况最后只能赔礼道歉加退款还差点被投诉到监管机构。这不是Excel本身的问题而是流程上缺少一个主动提醒的机制——系统必须能在药品到期前自动告诉你而不是靠人肉死记。另外处方药登记在Excel里很容易漏项。手工登记很难保证每次都不漏。这套系统上线之后做的第一件事就是把处方药销售设成强制登记不填入处方编号就没有办法完成开单从机制上堵住漏洞。1.3 最终敲定的功能清单根据上面这些日常场景我把系统功能拆成了七个模块模块核心功能解决什么痛点系统管理用户管理、角色管理、菜单权限店员/店长/管理员分级操作药品管理药品档案、分类、厂家、批准文号统一药品主数据避免同药多名库存管理入库、出库、盘点、效期预警账实一致过期药提前拦截采购管理供应商维护、采购单、验收入库采购流程留痕方便对账销售管理收银开单、退货、处方登记日常收银与合规记录会员管理会员档案、积分、等级折扣留存回头客会员精准营销统计报表销售日报/月报、毛利分析、库存报表经营数据一目了然这个清单看起来多但实现上并不复杂。核心就是药品主数据、库存流水、销售流水这三块其他模块都是在它们之上做增删改查和统计。2. 技术选型分析Spring Boot Vue 这对组合为什么够用2.1 后端选 Spring Boot 的三个理由对于这类管理信息系统Spring Boot几乎是个不需要犹豫的选择。原因很直接。第一它把Spring MVC时代大量繁琐的配置自动化了。内嵌Tomcat、自动配置、起步依赖写一个Controller就能跑起来开发效率比传统SSH框架高太多。以前配一个Spring要用XML写满几页纸现在一个注解就解决。第二生态成熟。药店管理系统本质是CRUD加少量业务逻辑MyBatis-Plus可以把单表操作做到极致简洁分页、条件构造器都是开箱即用。不需要写大量XML映射文件实体类加几个注解就能自动生成基本的增删改查。第三招人容易、学习资料多。不管是接手项目的开发者还是后续维护的人Spring Boot的熟悉度在各种技术栈里都是最高的。出了问题随便一搜就是解决方案不会卡住。2.2 前端选 Vue 的理由前端用Vue加Element UI是管理后台项目里最稳妥的组合。Vue的组件化开发让页面复用变得简单比如药品选择器、库存表格、分页组件写一次到处用。Element UI提供了成熟的后台组件库表格、表单、弹窗、消息提示都有现成方案不需要自己从头写样式和交互。这套系统选的是Vue 2.6配合Element UI。现在Vue 3加Element Plus也完全成熟了如果是从零新建项目我更建议直接用Vue 3。当初保留Vue 2版本主要是历史原因——项目搭建时Vue 3生态还在过渡期而系统本身的功能并不依赖Vue 3的新特性升级动力不足就一直沿用了。2.3 配套组件选型一览技术组件选用版本说明Spring Boot2.7.x稳定、资料多、兼容性好MySQL8.0主流关系型数据库MyBatis-Plus3.5.x单表CRUD、分页插件Redis5.0缓存登录Token、热点数据JWT0.9.x前后端分离的认证方案Vue2.6.x渐进式前端框架Element UI2.15.x后台管理组件库Nginx1.20静态资源托管与反向代理这套组合对服务器要求很低一个2核4G的云主机跑起来毫无压力也符合中小药店的预算。别一上来就搞微服务、Redis集群、消息队列这套系统在这个量级的负载下根本用不上徒增运维成本。3. 数据库设计围绕药品-库存-销售的三条主线3.1 药品档案和批次库存为什么要分开存这是整个数据库设计里最重要的一个决定。一开始我也习惯把所有字段塞进一张药品表药品ID、名称、规格、厂家、数量、有效期。看起来简单但很快就发现问题了——同一个药品这次进的是2025年3月到期的一批下次进的是2025年9月到期的一批。如果只存一个数量和一个有效期入库时库存数量虽然可以累加但效期到底按哪一计算所以必须拆表。药品主表存固定不变的档案信息批次库存表存每次入库的批次信息CREATE TABLE drug_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(32) NOT NULL COMMENT 药品编码, drug_name VARCHAR(128) NOT NULL COMMENT 药品名称, spec VARCHAR(64) COMMENT 规格, unit VARCHAR(16) COMMENT 单位, manufacturer VARCHAR(128) COMMENT 生产厂家, category_id BIGINT COMMENT 分类ID, approval_number VARCHAR(64) COMMENT 批准文号, prescription_type TINYINT COMMENT 1处方药 0OTC, retail_price DECIMAL(10,2) COMMENT 零售价, purchase_price DECIMAL(10,2) COMMENT 进货价, status TINYINT DEFAULT 1 COMMENT 状态 1上架 0停用 ); CREATE TABLE drug_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(64) COMMENT 批号, expire_date DATE COMMENT 有效期, quantity INT DEFAULT 0 COMMENT 库存数量, purchase_price DECIMAL(10,2) COMMENT 该批次进货价, supplier_id BIGINT COMMENT 供应商ID, sort_order INT COMMENT 出库优先级值越小越先出库 );批号、有效期跟着库存批次走这样采购入库时新增一条批次记录销售出库时优先扣先到期的批次效期管理就落到了数据层面。药店如果要拿GSP认证批次记录都要有据可查这个设计的规范程度直接影响合规审计能不能通过。3.2 销售主表与明细表为什么必须拆卖药这个动作要记录的信息分两层这笔订单的整体信息和订单里每一件药品的信息。主表存订单整体明细表存每个药品。订单号、会员ID、收银员、总金额、实付金额、支付方式、销售时间放主表药品ID、批次ID、数量、单价、金额放明细表。CREATE TABLE sale_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号, member_id BIGINT COMMENT 会员ID, user_id BIGINT NOT NULL COMMENT 收银员ID, total_amount DECIMAL(10,2) COMMENT 总金额, discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT 优惠金额, pay_amount DECIMAL(10,2) COMMENT 实付金额, pay_type TINYINT COMMENT 1现金 2微信 3支付宝 4医保, sale_time DATETIME COMMENT 销售时间, require_prescription TINYINT DEFAULT 0 COMMENT 是否处方药订单, prescription_no VARCHAR(64) COMMENT 处方编号, status TINYINT DEFAULT 1 COMMENT 1正常 2已退货 ); CREATE TABLE sale_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, drug_id BIGINT NOT NULL, stock_id BIGINT NOT NULL COMMENT 批次库存ID, quantity INT NOT NULL, price DECIMAL(10,2) COMMENT 成交单价, amount DECIMAL(10,2) COMMENT 行金额 );拆开的好处首先是退货方便。顾客退一份药只需要在明细表里找到对应行做负向冲销主表状态改成已退货不用动其他数据。另外报表统计也简单按主表统计营业额按明细表统计药品销售排行两边互不干扰。3.3 效期预警的字段设计与查询写法效期预警依赖的是drug_stock表里的expire_date字段。核心查询就一个思路查出现在到90天以内到期的所有库存批次。SELECT s.drug_id, d.drug_name, s.batch_no, s.expire_date, s.quantity FROM drug_stock s LEFT JOIN drug_info d ON s.drug_id d.id WHERE s.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND s.quantity 0 ORDER BY s.expire_date ASC;这里有个写SQL的细节要特别注意很多人会写成DATEDIFF(s.expire_date, CURDATE()) 90这种写法在expire_date字段上没法走索引药品批次一多全表扫描会越来越慢。改成BETWEEN这种范围查询索引就能正常工作。别小看这个优化库存批次上万条之后查询速度的差别是非常明显的。预警提醒放在登录后的首页工作台。每天第一次登录系统自动拉取90天内到期的批次列表30天内到期的用红色标记。这套机制上线之后药店再也没出现过药快过期还没发现的窘境。4. 后端核心模块实现从登录到扣库存的完整链路4.1 JWT登录鉴权前后端分离下的轻量方案药店系统用户不多登录认证不需要复杂的OAuth流程JWT就够用。用户登录成功后后端生成一个带过期时间的Token返回给前端前端存起来每次请求在请求头带上Authorization字段。后端用一个拦截器校验Token通过就放行不通过就返回401。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } // 解析Token失败时由全局异常处理器统一返回 Claims claims JwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, claims.get(userId)); request.setAttribute(roleId, claims.get(roleId)); return true; } }Token里只放userId和角色信息不要放大段用户对象避免Token体积膨胀。过期时间一般设8小时药店营业时间基本都能覆盖店员中午换班重新登录也不会频繁。这里补一句实战经验JWT最大的坑是过期失效。店员可能8小时后还在收银Token过期突然被踢出去开了一半的单子就丢了很影响情绪。所以前端要加一个响应拦截器收到401时不要直接把用户踢回登录页而是先弹一个重新登录的对话框保留当前页面的开单数据用户重新认证后可以继续操作。4.2 收银下单的并发扣库存为什么不能先查后改收银开单是整套系统并发压力最集中的地方。两个收银员同时卖同一个药如果代码写成先查库存够的话再扣减那么两个请求可能同时查到库存还剩3盒然后都认为自己可以卖出2盒最后库存被扣成负数。这就是典型的超卖问题。解决思路不复杂用一条原子更新的SQL把检查和扣减合并成一个操作Transactional(rollbackFor Exception.class) public void createOrder(SaleOrderDTO dto) { // 1. 插入订单主表记录 // 2. 逐行扣减库存使用条件更新保证不超卖 for (SaleOrderItemDTO item : dto.getItems()) { int updated stockMapper.reduceStock(item.getStockId(), item.getQuantity()); if (updated 0) { throw new RuntimeException(库存不足或批次已被占用); } // 3. 写入订单明细 } }对应的Mapper方法Update(UPDATE drug_stock SET quantity quantity - #{num} WHERE id #{stockId} AND quantity #{num}) int reduceStock(Param(stockId) Long stockId, Param(num) Integer num);关键就在quantity #{num}这个条件数据库会先判断当前数量是否足够够才执行扣减。UPDATE的行锁保证了同一时刻只有一个请求能修改这条批次记录。执行结果返回0就说明扣减失败整个订单回滚前端提示库存不足。这个方案比乐观锁少一次版本号的复查逻辑比悲观锁少了SELECT FOR UPDATE的锁等待在药店这种并发量级下是最优解。这里还有一个细节销售下单时要按drug_stock表的sort_order字段排序优先扣先到期的批次。这样效期管理不只是能查还能落地执行——系统自动先卖快过期的药而不是每次都拿最新批次的药卖。这是很多管理系统的通病预警做出来了但业务动作没有闭环。4.3 采购入库与批号处理入库动作比想象中麻烦采购入库涉及两块数据采购单本身的状态变更、库存批次的新增。我把流程拆成三步下单、验收、入库。下单阶段只创建采购单不影响库存。验收阶段核对实物数量和批号如果供应商送来的批号和采购单上填的不一致以实际到货为准修改批号。入库阶段才真正插入drug_stock记录同时把采购单状态改成已入库。这样拆的好处是数据链路清晰每一步都有操作日志可查。监管检查时可以调出采购单、验收记录、入库批次三条数据形成完整追溯链。我给这一步加了校验入库数量大于验收数量时直接拦截防止人为录错造成账实不符。4.4 报表统计的SQL别在一开始就过度优化药店系统的报表无外乎销售日报、月报、毛利统计、品类销售占比。这类统计在数据量不大的阶段一条GROUP BY就解决了SELECT DATE_FORMAT(sale_time, %Y-%m-%d) AS sale_date, COUNT(*) AS order_count, SUM(pay_amount) AS total_pay, SUM(item_profit) AS gross_profit FROM sale_order WHERE sale_time #{startDate} GROUP BY DATE_FORMAT(sale_time, %Y-%m-%d) ORDER BY sale_date DESC;毛利这里要特别说明。药品的进货成本每个批次可能不一样毛利不能简单用零售价减最新进货价去算。我是在销售明细里冗余存了每一行当时的进货价下单时从对应库存批次里带出来。这个冗余字段虽然违反三范式但胜在查询快、历史准确——进货价后面涨了之前的销售毛利不该跟着变。数据量超过几十万行以后可以考虑按月分表或者定时汇总表但单体药店跑三五年都到不了那个量级。过早引入大数据组件和复杂的聚合方案只会让项目可维护性变差。先把业务跑通再谈优化。5. 前端开单体验与页面结构把复杂逻辑藏在操作后面5.1 页面整体布局与路由结构前端采用Element UI经典的左右布局左侧菜单导航右侧内容区域。菜单按功能模块划分成系统管理、药品管理、库存管理、采购管理、销售管理、会员管理、统计报表七组每个菜单项对应一个Vue路由。登录后默认进入工作台展示效期预警、库存预警、今日销售三个核心卡片让店长一打开系统就能看到最需要关注的信息。这三个卡片是高频需求放在首页就是让关键信息第一眼可见。有个设计值得说一下销售收银页我单独做了全屏路由不跟普通页面共用内容区域的固定头部。原因是收银员一天十几个小时盯着这个页面操作密度最高页面越简洁越好——只保留一个扫码输入框、一个商品列单区、一个结算区减少视觉干扰。5.2 收银开单页的交互设计扫码枪是怎么配合Vue的扫码枪在系统里就是一个USB输入设备本质上它是在扫描完成后模拟键盘一次性输入一串字符加一个回车。所以前端不需要任何特殊硬件对接只要有一个输入框拿着焦点把扫码内容当成普通的搜索关键字处理就行。el-input refscanInput v-modelscanCode placeholder扫码或输入药品名称/编码 keyup.enter.nativehandleScan / methods: { handleScan() { getDrugByCode(this.scanCode).then(res { if (res.data.stockQuantity 1) { this.$message.error(该批次已售罄); this.scanCode ; this.$refs.scanInput.focus(); return; } this.cartItems.push({ drugId: res.data.id, drugName: res.data.drugName, spec: res.data.spec, stockId: res.data.stockId, price: res.data.retailPrice, quantity: 1 }); this.scanCode ; this.$refs.scanInput.focus(); }); } }这里两个细节值得注意。第一每次扫码处理后必须清空输入框并重新聚焦因为扫码枪的机制就是输入加回车不重新聚焦的话下一单就失效了。第二每扫一个药品自动加一条购物车记录不需要手动点添加按钮这是收银效率的关键。实测下来熟练的收银员用这套流程完成一笔三五种药品的订单十秒以内能搞定。购物车列表里每行显示药品名、规格、批号、单价、数量、小计数量默认是1双击可以修改。修改数量时前端直接计算行小计和总金额不需要请求后端提交订单时再把整个购物车数据一次性发给后端。5.3 表单校验与关键操作保护药品新增和编辑的表单校验是最多的。批准文号必填、零售价和进货价不能为负、药品编码不能重复这些都要在前后端各校验一遍。前端做的目的是让用户不用提交才发现错误后端做的是防止有人绕过前端直接调接口。重点之一是处方药订单的强制登记。前端在提交订单前判断购物车里是否存在处方药存在则弹出处方登记弹窗必须填写处方编号和开方信息才能完成提交。这个强制逻辑不只做在前端后端createOrder接口里也加了同样的判断防止绕过前端直接调接口。退款操作也加了二次确认。弹出框里显示订单金额、购买人、药品明细确认后才执行退货。退货同时恢复对应批次库存并把订单状态改成已退货。这个逻辑说不上多难但很容易漏——只改订单状态忘了加库存账面就会对不上。我见过不少项目在这个环节出错退货的库存恢复一定要和状态变更放在同一个事务里。6. 权限控制与数据安全药店场景里容易忽视的两个点6.1 RBAC角色的简单落地这套系统用户少权限模型用最基础的RBAC就够用户属于角色角色拥有菜单权限。数据库设计三张核心表用户表、角色表、用户角色关联表。菜单权限我采用后端返回菜单列表前端根据返回结果动态生成侧边栏菜单的方式。有朋友问我为什么不把权限做到按钮级。我的看法是在药店这个场景里按钮级权限实际意义不大。店员能看到所有菜单但进不去某个页面意义不大真正需要控制的动作比如删除药品、退款、修改价格在后端接口里做权限校验就够了。菜单隐藏主要是让界面干净安全边界终究应该落在后端。后端权限校验我用的是自定义注解加拦截器的方式。在需要限制的接口上加一个RequirePermission注解标注需要的权限码拦截器里解析用户角色再去查角色权限列表做比对。这样权限逻辑集中管理Controller里的业务代码保持干净。6.2 密码加密与敏感信息保护密码存储必须用加密算法。我用的是BCrypt而不是MD5或SHA。理由很直接MD5和SHA是摘要算法计算速度快撞库也快彩虹表一砸就穿。BCrypt是慢哈希内部自带盐值即使数据库泄露想还原密码的代价也高得多。不引入全套Spring Security的情况下单独用spring-security-crypto依赖里的BCryptPasswordEncoder即可加密和校验都是一行代码的事。这个度的把握在于为一个小型管理系统引入全套Spring Security配置成本高、收益不大只引入需要的加密工具类刚刚好。日志里不能打明文密码。我写登录接口的初期踩过这个坑为了调试方便在日志里打印了登录参数结果密码也一起打出来了后来检查代码才发现赶紧改成只打印登录成功或失败这种不包含请求体的日志。这个习惯建议从一开始就养成。6.3 操作日志至少覆盖这三类不是所有操作都要记日志但至少三类必须记药品入库、删除或修改药品档案、退货退款。这三类操作直接关系库存数据和财务数据出了问题要能追踪到谁在什么时间做了什么。我用一个简单的AOP切面处理自定义一个Log注解标在需要记录日志的Controller方法上切面里把当前用户、请求参数、执行结果统一写入日志表。参数序列化时做字段过滤排除密码和Token这类敏感字段只保留指定的几个字段。这样做的好处是业务代码零侵入日志逻辑统一收敛。7. 部署上线与维护一台云主机怎么把系统跑起来7.1 前后端分离的部署方案药店服务器预算有限一台云主机就够。前端打包成静态文件交给Nginx后端打成一个jar包用systemd守护运行MySQL装在同一个台机器上。部署目录结构前端打包后放在 /var/www/pharmacy后端jar放在 /opt/pharmacy。Nginx配置同时做两件事托管前端静态资源、反向代理后端APIserver { listen 80; server_name pharmacy.example.com; root /var/www/pharmacy; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端配置里把context-path设为/api前端请求统一走/api前缀Nginx转发的规则和前端环境变量里的baseURL能对上。前后端同域部署之后生产环境的跨域问题完全不存在了。7.2 实际开发中踩过的两个典型坑跨域问题是前后端分离项目逃不掉的第一关。开发环境下我在Vue的vue.config.js里配置了devServer代理把/api开头的请求转发到本机8080端口避免前端直连后端接口产生跨域。生产环境用上面的Nginx方案整个系统只有80端口一个入口天然不存在跨域问题。时区问题也踩过一次。系统上线后店员反馈日报里的销售日期少了一天。排查下来是MySQL连接串里没有指定serverTimezone数据库默认用了服务器的UTC时区而服务器部署时区是北京时间凌晨0点到8点的订单被归档到了前一天。解决办法是把连接串改成带serverTimezone的格式问题立刻消失。这类问题在开发环境往往不出现因为本机的MySQL和Java默认时区一致一到服务器就露馅。7.3 关于金额精度和库存同步的提醒金额字段必须用DECIMAL不能用Double。这个教训我在测试阶段就发现了——用Double计算0.1加0.2得到的是0.30000000000000004虽然界面显示时四舍五入看不出来但累计到几千笔订单报表里就会差出几毛钱。后端所有金额计算用BigDecimal数据库用DECIMAL(10,2)前端展示用toFixed(2)三层统一精度问题就不会出现。库存显示不同步的问题也值得一说。前端药品列表里的库存数字是页面加载时查的收银员开完一单后切到库存页面看到的还是旧数字因为这个数字是静态展示不会自己刷新。后来我在关键页面加了activated生命周期钩子每次切回页面重新拉取一次库存数据。这个改动很轻但对使用体验的提升非常明显——不会再出现明明卖了一盒库存页面还显示原数量的困惑。后续这套系统如果要往更深的方向走大概会是这样几个方向对接医保接口这是药店信息化绕不开的需求点电子处方流转需要跟医院的处方系统打通多门店连锁时批次库存表加一个门店ID字段再引入总仓调拨流程。当初数据库设计时留好扩展位这些改动基本就是在现有骨架上添加功能而已。最后分享一点个人体会。这套系统上线后店长跟我说了一句话让我印象很深以前月底盘点要两天现在两个小时就能做完更重要的是效期提醒这件事终于被系统接住了再也不用靠人肉去翻表格。做管理系统的成就感其实就来自这种真实的效率提升。如果你正准备做类似的项目我的建议是先把需求边界和数据库表结构想清楚表结构稳了后面写代码会顺很多。尤其是药品这种效期和批次概念非常强的领域批次表的设计质量直接决定整个系统的上限。希望这篇记录能让你少走几步弯路。
阅读完成 · 觉得有帮助?