1. 项目拆解:药店管理系统到底在解决什么问题
很多人第一次看到“前后端分离web药店管理系统”这个标题,第一反应是“又一个课设项目”。说实话,这类管理系统在市场上确实很多,但药店管理系统和普通的图书管理、学生管理系统有个本质区别——它背后是一套完整的进销存业务逻辑,涉及到药品批次、有效期、库存上下限、供应商往来、GSP管理规范等一系列实际问题。
我当初拿到这个模拟项目X的时候,核心诉求是三个:第一,技术栈要足够典型,SpringBoot+Vue这对组合是当前中小型web项目最主流的选择;第二,业务流程要完整,不是那种随便写个增删改查就交差的Demo,而是要能体现药店经营的场景;第三,部署要能落地,打包、上线、跑起来,这个过程必须顺畅。
先说这个项目解决的核心问题。药店在日常经营中,最头疼的不是“记个流水账”,而是库存和效期的管理。一个感冒药可能同时有三四个批次的库存,哪个先到期就得先卖哪个;某种处方药如果库存低于警戒线,需要及时补货;供应商的账期、销售人员的提成、会员的积分,这些都是靠系统来支撑的。所以,这个项目的核心价值在于,它把药店的“进—存—销—统计”四个环节用一套数字化的流程串了起来。
从技术角度看,这个项目覆盖的知识面也相当全面。后端用的是SpringBoot 2.x,持久层框架选了MyBatis而不是JPA,原因一会儿我会细说;前端是Vue 2 + Element UI,搭建了完整的工程化结构,有路由、有状态管理、有axios请求封装。前后端的交互走的是RESTful API,数据格式统一用JSON。整个项目对正在学SpringBoot和Vue、想找一个完整项目练手的人来说,是很好的参照物;对需要快速搭建一个管理系统、用来做课程设计或内部工具的开发者来说,也是一份可以直接上手的代码基础。
下面我从系统设计到部署上线,把整个项目完整拆开讲一遍。包括数据库是怎么设计的、后端接口是怎么组织的、前端页面对接的逻辑、以及部署过程中我踩过的那些坑,全部摊开来讲。
2. 架构设计和技术选型的思路
2.1 前后端分离的架构到底“分”在哪
前后端分离,字面意思很好理解:前端负责页面渲染和用户交互,后端负责业务逻辑和数据处理,两者通过HTTP接口通信,互不干扰。但真正落地的时候你会发现,分离的不只是代码,还包含团队协作方式、项目目录结构、部署方式、接口契约管理。
在这个项目中,我做了两个独立的工程:一个是后端的SpringBoot工程,一个是前端用Vue CLI创建的工程。后端运行在8080端口,前端开发环境运行在8081端口,通过代理转发解决开发环境的跨域问题。生产环境就更简单,前端打包成静态文件丢到Nginx里,Nginx把/api路径的请求反向代理到后端的8080端口。
这套架构的好处很明确:前端页面和后端接口都可以独立开发、独立测试、独立部署。前端改了页面样式,后端不用重启;后端调整了接口逻辑,前端只要契约不变就能无缝对接。我见过很多“半吊子”的前后端分离项目,所谓的分离只是把Vue页面扔到SpringBoot的static目录下,这本质上还是单体应用,只不过换了一层皮。这个项目做的是彻底的分离,最终产物是“一套静态前端文件+一个可执行jar包”。
2.2 技术选型背后的取舍
技术选型这块,我最初也纠结过一阵子。后端ORM框架,MyBatis和MyBatis-Plus之间,我最终选的是MyBatis配合XML映射文件的方式。原因很简单,药店的查询需求非常多变,比如“按供应商查近三个月的采购金额”“查某药品的批次库存分布”,这类SQL直接手写在XML里,可控性比操作起来更强。JPA虽然开发效率高,但复杂查询的调优空间小,一旦表结构复杂起来,自动生成的SQL会让你摸不着头脑。MyBatis让你自己控制SQL,学习成本稍微高一点,但用顺手之后,排查问题会直观很多。
数据库选MySQL,没有悬念。免费、稳定、文档多,这个体量的系统MySQL完全够用,MySQL 5.7以上版本对JSON类型的支持也不错,后续如果有扩展需求也方便。
前端框架选了Vue 2而不是Vue 3,主要是考虑到稳定性和生态兼容。Element UI这套组件库在Vue 2下最成熟,网上资料也多,遇到问题几乎都能搜到答案。如果你自己动手做,直接上Vue 3 + Element Plus也没问题,技术思路完全一样,只是个别写法有调整。
权限认证这块,用的是JWT而不是传统的Session。前后端分离架构下,Session的跨域处理相对麻烦,要配置CORS、要处理Cookie跨域携带,而JWT是无状态的设计,服务器不用保存会话状态,客户端每次请求时把Token放在请求头里就行。这套机制实现起来也不复杂,登录接口签发Token,前端拦截器统一携带Token,后端写一个拦截器校验Token。
2.3 系统模块的功能地图
整个系统我按真实药店的业务场景划分了八个功能模块:
- 用户管理:管理员和店员两种角色,不同的角色看到的菜单、能操作的按钮不一样
- 供应商管理:记录供应商名称、联系人、电话、地址、资质信息
- 药品管理:药品的基础信息,包括通用名、商品名、规格、生产厂家、批准文号、零售价、进货价、库存上下限
- 批次库存管理:同一个药品按批次分开管理,记录每一批的库存数量、生产日期、有效期
- 采购入库:选择供应商和药品,录入采购单,入库时自动生成批次记录
- 销售出库:选择药品,支持多行明细,自动计算总金额,扣减对应批次的库存
- 统计报表:按日、按月统计销售额、利润,查看库存预警列表
- 系统管理:操作日志、数据备份入口
每个模块都不是孤立存在的。比如采购入库会更新药品的库存和批次信息,销售出库会触发库存校验和批次扣减,统计报表的数据来源于采购和销售订单。数据流转的闭环是这套系统的核心。
3. 数据库设计与后端核心实现
3.1 建表思路:商业逻辑到最后都落在表结构上
后端代码写得再花哨,业务逻辑最终都要落到数据库表的设计上。我的建表原则是:宁可多拆几张表,也不要搞一张大而全的表硬扛。
基础表有用户表、供应商表、药品信息表、药品批次表、采购单主表、采购单明细表、销售单主表、销售单明细表,加上辅助的操作日志表。主表和明细表的拆分是进销存系统的基本功:采购单主表记录单号、供应商、总金额、入库日期等汇总信息,明细表记录本次采购具体包含哪些药品、数量、单价。这样设计的直接好处是数据冗余度低,统计报表时直接对明细表聚合就行;还有一个隐形的好处,就是做数据订正的时候,改主表不会影响明细,审计链路非常清晰。
药品和批次为什么要分开?药店经营里“同药不同批”是非常普遍的现象。比如一盒阿莫西林,仓库里可能躺着三个批次的货,对应的有效期各不相同。如果不拆分批次表,那你根本无法准确回答“哪一批先过期”这个灵魂拷问。批次表关联药品ID,同时记录了批次号、生产日期、有效期、入库数量和剩余数量,销售订单扣库存时按批次先进先出(FEFO)的规则逐批次扣减。
字段类型这块有一个我特别想提醒的细节:价格字段不要用FLOAT或者DOUBLE。二进制浮点数在计算金额的时候会出现精度误差,在账务系统里这是不可接受的。我全部用DECIMAL(10,2)来存金额,数量字段用INT或者是DECIMAL(10,3),确保进货单价、零售单价、总金额这些数据分毫不差。
3.2 后端分层结构和接口设计
后端工程我按SpringBoot最经典的分层方式组织:Controller、Service、Mapper(DAO)、Entity、DTO、Config、Common。
Controller层只做参数接收和结果返回,不写业务逻辑。所有的业务判断都放在Service层。比如销售出库这个操作,Controller收到请求后调到SaleService,SaleService先校验药品是否存在、批次库存是否够扣,再计算金额,再开启事务去扣减多张库存表,最后返回销售单号。这个流程如果Service不控制事务,扣库存和生成销售单这两步操作中间只要崩了,就会出现库存扣了但是单子没生成的数据问题。
在接口设计上,这次没有使用比较重的统一返回体工具类去封装,而是定义了一个轻量的Result对象,包含code、message、data三个字段。code统一用20000表示成功,其他值表示各种异常。这个设计参考了主流前后端分离项目的做法,好处是前端可以统一在axios拦截器里根据code做判断,比如code等于401时,自动清除本地Token并跳到登录页。
权限这块,JWT拦截器里检查请求头中是否有合法Token,顺便把当前登录用户的信息解析出来放到请求上下文里,后续Service层需要记录操作人时直接取。对于菜单权限,我用的是最简单的前端路由控制方案:登录接口返回用户角色,前端拿到角色后动态拼接可访问的路由列表。这个方案只适合内部管理系统,真正高安全要求的系统肯定要上细粒度的后端权限校验,但在这里够用了。
3.3 Mapper层实战:繁琐、复用与SQL的可控性
MyBatis在项目里的使用,最核心的是编写Mapper接口和XML映射文件。一个典型药品列表的分页查询,XML里就是一个带动态条件的select语句,用if标签判断有没有传入药品分类、关键字、效期状态,动态拼SQL条件。做了这么多年开发,我最大的感触是,MyBatis的XML文件一定要按表或者按业务模块拆开来放,比如DrugMapper.xml只管药品相关,SaleOrderMapper.xml只管销售相关。如果贪图省事把全部SQL堆在一个文件里,后期维护时会非常绝望。
复杂一点的业务SQL,比如说统计报表里的“近30天销售趋势”,核心是按照日期分组统计销售额:
SELECT DATE(create_time) AS day, SUM(total_amount) AS total FROM sale_order WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE(create_time) ORDER BY day ASC这种SQL放JPA里写起来很别扭,但在MyBatis里就是手到擒来的事。所以我坚持用MyBatis,本质上是想保留对SQL的绝对控制权。
还有一个需要留心的地方,就是批量插入。采购单明细可能一条采购单有好几十个商品,如果循环一条条insert,效率不忍直视。我使用MyBatis的foreach标签做批量insert,只需要构造好List传给Mapper,一个insert语句可以同时写入所有明细。
4. Vue前端的设计与实现
4.1 前端工程的搭建和目录划分
前端的工程结构我用Vue CLI搭建,按功能维度拆模块,而不是按页面维度拆。具体来说,目录结构是这样的:
- api目录:按模块存放接口请求函数,每个模块一个JS文件,里面按函数封装axios调用
- views目录:存放页面级组件
- components目录:存放可复用的业务组件,比如药品选择器
- store目录:Vuex的模块化管理,分别管理用户状态、系统配置等
- router目录:路由配置,区分静态路由和动态路由
- utils目录:请求封装、Token存储、日期格式化等纯函数方法
axios请求封装这个环节很关键。我在utils里创建了一个service实例,设置了baseURL为“/api”,设置了超时时间为10秒,然后在请求拦截器里从localStorage里取出Token放进Authorization头,在响应拦截器里统一解包Result对象的code。这样做的好处是业务代码里只需要写接口对应的函数调用,不用担心Token怎么传、结果怎么解包这种琐碎的事。
4.2 关键业务模块的页面设计
药品管理页面是整个系统使用频率最高的页面。我做成了典型的列表+弹窗编辑形态:左侧是药品分类的树形导航,右侧是表格,表格上方是筛选区,可以按名称关键字、分类、库存状态筛选。表格里的“库存状态”列通过计算属性动态渲染成绿色标签(正常)或红色标签(库存预警)。点击“新增”或“编辑”会弹出表单弹窗,表单里药品信息的校验规则是必填项校验加格式校验,比如药品名称不能为空、零售价必须大于0。
销售下单页面是整个系统里交互最复杂的页面。设计成两步:第一步选择商品,通过商品选择器弹窗,支持关键字搜索和分页加载;第二步填写客户和结算信息。选中的商品以表格行的形式展示在页面上,每一行可以修改购买数量,页面实时重算总金额。在这个页面里,我用了一个watch函数监听购物车列表的数据变化,数量一变就触发总价重算,还额外判断了一把如果数量减到0就自动移除该行。
路由拦截这块,我在router.beforeEach里面判断了用户是否登录。如果访问的页面需要认证而本地没有Token,就强制跳转到登录页;如果已经登录但又去访问登录页,就自动跳转回首页。Vuex里存储了用户信息和动态菜单,页面刷新时再从本地恢复,保证刷新页面后不会跳到登录页。
4.3 和前端“状态管理”有关的实战细节
对于这个体量的项目,Vuex的作用没你想的那么大,但它仍然是有必要的。我主要用它管理两类状态:一个是用户信息,包括用户名、角色、Token,二是系统级别的全局状态,比如侧边栏折叠状态。
最容易踩坑的场景是动态路由。我的做法是,路由表里先定义一个固定的基础路由,也就是登录页和404页,然后在登录成功的回调里按照角色动态拼接剩余路由,用router.addRoutes方法加进去。如果你是直接写死在静态路由表里,那Vuex里存角色信息的意义就只剩显示控制了,所有页面都能被直接访问,这显然是不合理的。
还有一个细节是关于权限指令。我在项目里有几个按钮是需要管理员角色才能看到的,比如“删除供应商”。我封装了一个全局自定义指令v-permission,指令的值是要校验的角色数组。在指令的inserted钩子里检查当前用户的角色,如果不匹配就直接移除这个按钮的DOM。这种方式比在模板里写v-if判断更优雅,也不容易遗漏。
5. 完整部署流程:从本地到服务器
5.1 环境准备和后端打包
部署这一步,我踩过的坑最多。先说说本机环境,需要JDK 1.8+、Maven 3.6+、Node.js 14+、MySQL 5.7+。开发阶段用的是这些版本,生产环境我建议保持一致,不必要的版本升级反而容易引入新问题。
后端打包的步骤很简单,在项目根目录执行:
mvn clean package -DskipTests执行完之后target目录下会生成一个jar包,比如pharmacy-admin.jar。这个包是SpringBoot内嵌Tomcat直接可运行的。本地验证时,我习惯用命令行跑起来看看日志,确认没有明显的启动报错,再用curl测几个核心接口,比如登录接口和药品列表接口,测通了才敢放到服务器上。
这里要注意的是,jar包里的数据库连接配置,我通过SpringBoot的多环境配置来解决。application.yml里默认用的dev配置连本地的MySQL,部署时通过启动参数来指定生产环境的配置文件:
java -jar pharmacy-admin.jar --spring.profiles.active=prod生产环境的配置里,数据库地址、账号密码、日志级别都不应该混在代码里。我见过很多人在代码里直接写生产库的密码,后期维护很被动。
5.2 前端构建和Nginx配置
前端构建相对简单,在Vue工程目录里执行:
npm install npm run build构建完成以后,dist目录下就是一堆压缩好的静态文件。把这堆文件上传到服务器的某个目录,比如/data/www/pharmacy-web,然后配置Nginx。
我的Nginx配置核心分两块:一是把根路径指向dist目录,让访问域名时直接加载前端资源;二是把/api前缀的请求反向代理到后端服务。这样前后端就以同一个域名对外提供服务,浏览器不会遇到跨域问题。
server { listen 80; server_name your-domain.com; location / { root /data/www/pharmacy-web; index index.html; 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }差不多是慢工出细活,这个配置最关键的其实是try_files $uri $uri/ /index.html;这一行。Vue是单页应用,路由切换都是前端在控制,如果用户直接刷新一个子页面的URL,比如/admin/drug,Nginx会去服务器上找这个真实路径的资源,找不到就会报404。try_files的作用就是把所有找不到的路径都回退到index.html,让Vue接管路由处理。
5.3 服务器部署的整体流程和脚本化
真正部署到服务器时,我建议你把整个流程写成一个shell脚本,自动完成备份、拉取最新代码、打包后端、构建前端、重启服务。不要每次更新版本都手动敲几行命令,既容易出错又浪费时间。
我这里提供一个简单的部署脚本骨架,你根据自己服务器的目录结构改改就能用:
#!/bin/bash # 后端编译打包 cd /opt/projects/pharmacy-server mvn clean package -DskipTests # 前端编译 cd /opt/projects/pharmacy-web npm run build # 停掉旧服务 systemctl stop pharmacy-admin # 部署后端jar cp /opt/projects/pharmacy-server/target/pharmacy-admin.jar /data/apps/pharmacy/ # 部署前端静态文件 rm -rf /data/www/pharmacy-web/* cp -r /opt/projects/pharmacy-web/dist/* /data/www/pharmacy-web/ # 启动新服务 systemctl start pharmacy-admin # 检查状态 systemctl status pharmacy-admin关于使用systemd管理Java进程,有一点要强调:不要用nohup java -jar这种方式,进程断了没人管,重启服务全靠手工。用systemd提供一个service文件,开机自启、崩溃自动重启、日志统一管理,这些能力就都有了。service文件的写法也很简单,核心就是指定ExecStart和User。
数据库部署也有讲究,上线前需要执行初始化SQL,把所有的建表语句和基础数据准备好。我第一次部署的时候,因为初始化SQL的顺序写错了,出现外键关联表的插入失败提示,来回折腾了好一会儿。你在整理SQL脚本时,一定要先建主表、再建从表,先插基础数据、再插业务演示数据,顺序颠倒会出很多不必要的麻烦。
6. 常见问题排查与一系列实战心得
6.1 本地开发阶段的高频问题
跨域问题是前后端分离项目绕不开的第一道坎。开发环境下前端的地址是localhost:8081,后端接口地址是localhost:8080,浏览器的同源策略直接拦住了所有请求。解决方法是利用Vue CLI的devServer配置代理,把请求转发出去,我在vue.config.js里做了这样一段配置:
devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这个方案在开发阶段非常方便,请求在浏览器里看起来是同源的,不需要后端额外处理CORS。但如果你想让其他设备直接访问前端页面联调,那就得在后端代码里加上CORS配置了。两种方案各有适用场景,项目里到底选哪个,取决于你开发的协作模式。
还有一个高频问题,是前后端字段名不一致导致的“页面显示undefined”。比如后端返回的字段是userId,前端写成了userid,Eloquent是看不出问题的,但页面就是显示不出来。我处理这个问题的方法很土但很有效:在开发阶段把axios的响应数据用console.log打印到控制台,肉眼对一遍字段名。联调阶段对大数据结构的字段校对,永远不要嫌麻烦。
6.2 部署上线后的排查经验
上线之后最经典的问题,就是前端能打开但登录接口一直超时或者返回502。排查顺序应该这样展开:先看Nginx的错误日志,找到代理转发的报错信息;然后确认后端服务还活着;再确认Nginx和SpringBoot在同一台机器上使用的是127.0.0.1而不是公网IP。“还有一次我排查了一下午的502”,最后发现是SpringBoot的端口被防火墙挡住了,把端口放开就好了。部署之后,一定先用浏览器直接访问后端ip加端口测一次,如果这个都通不了,就别指望Nginx代理能通。
另一个需要重点留意的,是生产环境的配置和开发环境不能共用一套。比如生产环境必须关闭SpringBoot的调试日志,不然每天存储够受的。还有数据库连接串区分开来,防止开发时误操作生产库。这块我吃过大亏,现在所有项目的数据库连接和密码统一放到生产服务器环境变量里,代码仓库只保留占位符,从源头上避免误操作。
再做一次友情提醒:如果你把项目部署到服务器上给别人演示,一定要留意服务器的时区和MySQL时区问题。MySQL时区如果不设置成中国时区,写入的时间字段会比当前时间晚了8个小时。在连接串里加上serverTimezone=Asia/Shanghai,基本就不会出问题。
6.3 想把这个项目二开的几点建议
如果你不只是想跑通Demo,而是想在这个项目基础上继续扩展,我有几个优先级建议。最容易做也最实用的,是加一个药品批号的二维码生成功能,把药品信息和批次信息编码成二维码,入库时打印贴在货架上,出库时用扫码枪直接录入,效率会有质的提升。这个功能技术上不难,后端加一个生成二维码图片的接口,前端在批次管理页面上加一个按钮就行。
第二个值得扩展的点是采购单审核流。现在的实现是直接入库生成批次,没有审核环节。在实际业务里,采购单应该至少有一个主管审核的节点,审核通过之后才允许入库。这个扩展需要加一张审核记录表,再调整采购单的状态字段,逻辑不复杂,但能大大提升系统的真实可用度。
第三个提一下报表模块。目前的统计功能是基础版本,如果你想做得更深入,可以用定时任务在夜间生成前一天的销售汇总,存入一张统计报表表中,这样白天查看报表时速度会快很多,也不用担心复杂聚合查询把主库拖慢了。
6.4 这个系统里让我印象最深的一个细节
最后分享一个具体的技术细节,也是我踩坑之后反复琢磨才想明白的点。销售出库扣减批次库存时,如果同时存在多个批次的库存,必须按有效期从近到远的顺序扣减,也就是先扣先到期的批次,这就是所谓的FEFO(First Expire First Out)原则。这个逻辑直接写在Service里,用一个SQL按有效期排序查出来库存大于0的批次,然后循环计算每个批次能扣多少数量。
这个逻辑在单线程情况下跑得没问题,但如果两个人同时提交销售订单,就存在并发扣减库存的危险,后一个事务读到的是扣减前的库存,导致超卖。解决的方法是在批次表上加了乐观锁版本号字段,更新时带上版本号条件。如果更新影响行数为0,就说明库存已经被别人改过了,重新读取并计算。这种场景面试官还挺喜欢问的,背后考察的就是并发控制思维。
我在最初编写这个功能的时候,其实根本没有考虑并发问题,直到测试时用两个浏览器窗口同时下单才发现了这坑。后来我给所有涉及到库存变动的操作都补上了行锁或乐观锁,这个问题才算彻底解决。希望看到这篇的朋友,在做类似业务的时候一开始就重视并发问题,不要等到生产环境出问题再补。
说到底,药店管理系统看起来只是一个常见的web管理项目,但它内部涉及的业务逻辑、数据一致性、部署运维这些问题,和真实企业级项目的思考路径是一致的。把这个项目彻底吃透,你收获的绝对不止一套能跑通的代码。