做中药材店铺管理系统这个题目,其实比很多人想象的要有意思。乍一看就是个进销存系统,无非就是商品管理、订单管理、库存管理那一套常规操作。但真正动手做下来,你会发现中药材这个品类给系统设计埋了不少暗坑,而且这些坑恰恰是让这个项目区别于普通超市管理系统的价值所在。我这两年带过好几个做这个方向的同学,也自己完整重构过一版,今天把这套基于 SpringBoot 的中药材店铺管理系统从设计到部署的完整思路捋一遍,希望能帮到正在做同类项目的朋友。
1. 整体设计思路:为什么中药材店铺系统不能只做增删改查
先说个最关键的判断:如果你的毕设或者商业项目只是把一张商品表四个字段(名称、价格、库存、分类)做成 CRUD,那这个系统没有任何竞争力。中药材管理有一个天然的特性——批次与品质的强绑定。同一味黄芪,产自甘肃陇西和产自山西浑源,价格和药效完全不同;同一批党参,存放半年和存放两年的含水量、色泽、有效成分也差很多。这就意味着系统的核心不是“商品”,而是“批次库存”。
所以我搭这套系统时,数据模型的核心不是商品表,而是批次库存表。围绕这个核心,业务链路自然分成两条:一条是采购进来的批次入库、质检、养护、库存变动,另一条是销售出库时的批次扣减、溯源查询、临期预警。这种建模思路,才真正呼应了“中药材店铺管理系统”的“中药材”三个字,而不是随便换个商品名称就能套用的通用模板。
技术选型上,我最终确定的是 Spring Boot + MyBatis-Plus + MySQL 的组合,前端为了部署方便选了 Thymeleaf + Bootstrap,没有做前后端分离,而是用了服务端渲染。可能有同学会问,现在不都流行 Vue + SpringBoot 前后端分离吗?我的看法是——如果目标是快速交付一个完整可用、边学边改的项目,服务端渲染的维护成本低很多,部署也省事,直接打成 jar 包扔服务器就完事,不用再单独维护一套 Nginx 来托管前端静态资源。这套方案对个人学习和毕设场景非常友好,哪怕你是第一次接触 SpringBoot,也完全可以在两到三周内跑通全流程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:把中药材的“灵魂”塞进表结构
2.1 核心表结构:药材档案、批次和库存的关系
我在第一版设计里犯过一个错误,就是参照超市系统的库存表设计,用 product_id 作为唯一维度来记录库存数量,结果后面加了批次字段后,整张表的逻辑全乱套了。后来我重新拆分,把数据模型调整为三张核心表:
- drug_info(药材档案表):记录药材的名称、别名、性味归经、功效主治、产地、规格、计量单位、储存条件、图片路径、状态。这里要特别说明,药材档案并不是“库存”,它只是基础字典,类似于商超系统的商品主数据。
- drug_batch(批次表):每个入库批次对应一条记录,字段包括批次号(业务生成,比如采购单号加序号)、药材ID、供应商ID、采购单价、采购数量、入库日期、生产日期(采收日期)、有效期(保质期)、储存位置、养护说明、品质等级、当前剩余数量。
- inventory_stock(库存表):以 batch_id 为粒度维护当前库存余量,同时冗余一份 drug_id,方便按药材聚合查询总库存。
为什么要单独拆 inventory_stock 而不是直接在 batch 表上更新剩余数量?因为在大多数业务场景下,我们要区分“某批次的初始入库量”和“当前剩余量”,这两个数字对经营分析都很重要——前者体现采购规模,后者体现当前可售状态。如果只留一个字段,历史统计就会失真。
三张表之间的关系用一句话概括:drug_info 是静态字典,drug_batch 是一次一次垒起来的历史记录,inventory_stock 是实时状态。这样设计之后,后面做溯源查询就非常顺——用户在前台看到某味药材,点开详情,可以直接查到当前所有在售批次,每个批次的产地、入库时间、品质等级一目了然,这就是“中药材店铺”区别于“便利超市”的体验细节。
2.2 库存预警与养护记录:容易被忽略的两张表
除了上面三张主表,还有两张表如果你没做,系统会显得很“业余”。第一张是 stock_warning_log(库存预警记录表)。中药材行业对缺货和临期都非常敏感,缺货影响日常销售,临期则直接关系到用药安全。预警不能只在页面展示一条临时信息,最好每次触发都写入日志表,这样后期可以统计“哪几种药材频频临期”“哪个供应商的批次品质波动大”,这就是店铺经营层面的数据资产。
第二张是 drug_care_log(养护记录表)。懂行的人都清楚,中药材在库房里的养护是个持续过程,需要定期检查是否受潮、生虫、霉变,翻垛晾晒也要留记录。虽然在“毕设级别”的系统里这个模块往往做成简单的添加和列表,但表结构一定要提前留好——药材ID、批次ID、养护日期、养护方式、发现的问题、处理结果、操作人。这就让系统从单纯的销售工具变成了有一定业务深度的管理工具。
这两张表的设计思路,我给一个建议:不要等需求文档里写了才做,自己主动加上去,这在答辩时是一个非常亮眼的差异化亮点。同样的功能,你做出来了并且能讲清楚为什么做,效果完全不一样。
3. 后端模块实现:从登录到报表的完整链路
3.1 项目初始化和依赖如何选
项目我用的 Spring Boot 2.7.18,这是 2.x 系列的最后一个版本,稳定而且和大多数教程兼容。为什么不用 Spring Boot 3.x?核心原因在于 3.x 要求 JDK 17,而很多实训环境、服务器上跑的还是 JDK 1.8,如果为了赶新潮选了 3.x,在部署环节很容易被环境折腾到崩溃。Spring Boot 2.7 配合 JDK 1.8 和 MyBatis-Plus 3.5.x,是一套在毕业设计中经过千锤百炼的组合,稳定压倒一切。
pom.xml 里的核心依赖大概是这样:
xml复制<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
注意一个细节:这里的事务控制走的是 Spring 默认的注解驱动,在启动类上加上 @EnableTransactionManagement,然后在 Service 层入口方法加 @Transactional 即可。比如采购入库这个操作,要同时写 drug_batch、inventory_stock 和 stock_warning_log 三张表,任何一个环节失败都必须全部回滚,不加事务的话数据很容易脏。
3.2 登录鉴权:JWT 方案怎么落地
这套系统的用户角色我划分了三种:管理员、店员、店长,对应不同的菜单和数据权限。鉴权方式我用的是 JWT + 拦截器,没有引入 Spring Security,因为对于这个体量的项目,Spring Security 的学习成本和配置成本都偏高,而且默认的登录表单、CSRF 保护等机制反而会影响接口调试效率。
JWT 的实现核心就三步:
第一步,登录接口生成 token。 用户提交用户名密码后,用 BCryptPasswordEncoder 校验密码(密码字段在数据库里存的必须是 BCrypt 哈希,绝不能是明文),校验通过后用当前用户信息生成一个有效期为 24 小时的 token 返回前端。
第二步,拦截器校验 token。 写一个 AuthInterceptor 实现 HandlerInterceptor 接口,在 preHandle 里从请求头 Authorization 取 token,用相同的密钥解析,解析失败直接返回 401。注册拦截器时注意放行登录接口、静态资源和前端页面,其他接口一律拦截。
第三步,当前用户上下文。 解析成功后把 userId、role 这些信息放进 ThreadLocal 或者 HttpServletRequest 的 attribute 里,方便后续 Controller 获取当前操作人。
这块我踩过一个挺典型的坑:JWT 过期时间设太短,导致用户在写单子写到一半时被强制弹回登录页。后来我把 Token 有效期设成 24 小时,同时在权限拦截级别上把“查询”和“写操作”做了区分——查询类接口用较宽松的校验,写操作则严格校验角色,实际体验会好很多。
3.3 采购入库与库存流水:事务与批次更新的关键
采购入库是整个系统中业务流程最长、最容易出错的一个环节。我的实现逻辑是这样的:
code复制采购单保存后(采购单主表 + 采购明细表)
-> 确认入库时遍历每个明细
-> 根据药材ID和供应商ID生成唯一批次号(比如 B20250601001)
-> 写入 drug_batch 表
-> 更新 inventory_stock 表
(存在则累加剩余数量,不存在则插入新记录)
-> 记录库存流水 inventory_flow
-> 清除该药材的缺货预警
这里有一个值得展开的细节:批次号生成规则。我建议用“B + 年月日 + 四位序号”的规则,比如 B202506010023,既保证了唯一性,又方便人眼识别是哪个日期入库的批次。千万别用数据库自增 ID 当批次号,后期做导出和溯源时毫无可读性。
库存流水表(inventory_flow)也是一张容易被忽视的表。每一次入库、出库、报损、盘点调整,都要插入一条流水记录,包含批次ID、变动类型(IN/OUT/LOSS/CHECK)、变动数量、变动前余量、变动后余量、关联业务单号、操作人、操作时间。这张表的存在,让系统具备了完整的审计追踪能力,“数据是怎么变成当前这个状态的”这个问题随时可以回答。
3.4 销售出库与临期预警:批次扣减和时效提醒
销售出库的流程比采购简单一些,但核心逻辑同样要围绕批次来写。用户在前台下单,后台要自动选择扣减哪个批次的库存。这里我用了一个简单但实用的策略:先进先出(FIFO)。查询该药材所有在售批次,按入库日期升序排列,优先扣减最早入库的批次。
为什么不用“先到期先出(FEFO)”而是用 FIFO?考虑到中药材的特殊性,不同药材的保质期差异很大,有的能放三年,有的只能放一年。FIFO 的通用性更强,但针对具体药材可以做精细化优化——在 FIFO 排序基础上,叠加“临期优先”的权重:当某个批次距离有效期不足 3 个月时,自动跳升到扣减队列的前面。这个逻辑在代码里并不复杂,一个带优先级的排序比较器就搞定了,但业务价值的提升是肉眼可见的。
临期预警的实现我采用了查询时实时计算的方式:因为保质期信息存在 batch 表里,每次查询列表时用 SQL 的 DATEDIFF(expiry_date, CURDATE()) 计算剩余天数,小于 30 天的标记为“临期”,显示黄色标签;小于 7 天的标红。为了不拖慢列表查询,我没做全表扫描,而是给 expiry_date 字段加了索引,并且只对状态为在售的批次做计算。
4. 前端页面与交互:不用前后端分离也能做出好看的系统
4.1 主题和布局:用 AdminLTE 提升专业感
如果直接用 Bootstrap 写后台管理界面,默认样式比较平板,缺乏层次感。我选择基于 AdminLTE 3 的开源后台模板来改造页面。AdminLTE 是一套基于 Bootstrap 4 的后台管理框架,内置了侧边栏、导航条、卡片、表格、图表等组件,风格干净大气,而且和 Thymeleaf 配合非常友好——本质上它只是一套静态资源,你只要把 HTML 页面丢到 templates 目录,通过 Thymeleaf 语法把后端数据塞进页面即可。
首页仪表盘我放了三个核心指标卡片:今日销售额、今日订单数、库存预警数。下面再放一张近 7 天销售趋势的折线图,用的是 Chart.js,前端代码很简单:
javascript复制var ctx = document.getElementById('salesTrendChart').getContext('2d');
var myChart = new Chart(ctx, {
type: 'line',
data: {
labels: dates,
datasets: [{
label: '销售额(元)',
data: amounts,
backgroundColor: 'rgba(60, 141, 188, 0.2)',
borderColor: 'rgba(60, 141, 188, 1)',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true
}
});
这些数据从哪里来?后端提供一个 /api/dashboard/stats 接口,返回近 7 天的日期数组和销售额数组。这里每次切到首页时都实时查数据库,数据量不大,性能完全没问题,而且保证看到的永远是最新的。
4.2 药材档案与库存联动:列表页的搜索筛选体验
药材列表页是使用频率最高的页面,我特意优化了联合搜索条件:按药材名称(模糊查询)、按分类、按产地、按库存状态(有货/缺货/临期)、按品质等级。这里用的是 MyBatis-Plus 的 LambdaQueryWrapper 动态拼接查询条件,代码大概长这样:
java复制public PageResult<DrugInfoVO> pageQuery(DrugQueryDTO dto) {
LambdaQueryWrapper<DrugInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.hasText(dto.getName()), DrugInfo::getName, dto.getName())
.eq(dto.getCategoryId() != null, DrugInfo::getCategoryId, dto.getCategoryId())
.eq(StringUtils.hasText(dto.getOrigin()), DrugInfo::getOrigin, dto.getOrigin())
.orderByDesc(DrugInfo::getUpdateTime);
Page<DrugInfo> page = drugInfoMapper.selectPage(new Page<>(dto.getPageNum(), dto.getPageSize()), wrapper);
// 循环查询每个药材的总库存和预警状态
return convertToPageResult(page);
}
注意一个性能优化点:列表查询默认只查 drug_info 表单表,之后在循环里为每一行补充“总库存”和“预警状态”这两个字段。如果药材品类很多(比如几千种),这种 N+1 查询就会吃力。优化方案是用一条分组聚合 SQL 一次性查出所有药材的库存汇总,再以 Map 形式拼装到结果集里,代码复杂度不高,但响应速度提升明显。
4.3 订单流程的页面跳转逻辑
销售开单页面我设计成了三步式向导:第一步选择药材并填写数量,第二步确认收货人信息和支付方式,第三步提交生成销售单。每步之间通过 JavaScript 控制面板显隐,最后提交时把整个订单 JSON 数据 POST 到后端接口。下单成功后跳转到“销售单详情页”,同时把支付二维码(模拟)和订单编号展示出来。
这里有一个使用体验上的小技巧:在第一步选择药材时,输入框绑定了一个远程搜索事件,用户输入几个字就会向后端请求匹配的药材列表,显示名称、规格、产地和可售批次余量。这个交互比下拉选择器好用很多,尤其当药材名称包含生僻字、用户记不全名字时,模糊搜索能极大降低操作成本。
5. 部署上线:从本地到服务器的完整流程
5.1 版本选择:JDK 1.8 还是 17
虽然我建议项目基于 JDK 1.8 开发,但这里我要专门展开说一下版本选择的问题,因为这是我在实际部署中被折腾得最惨的一次。Spring Boot 2.7 官方支持 JDK 8 到 JDK 21,但如果你把项目跑在更高版本的 JDK 上(比如 JDK 17),Lombok 的版本就必须升级到 1.18.30 以上,否则编译直接报错。反过来,如果你的系统里安装了低版本 Maven(比如 3.5 系列),遇到 Spring Boot 2.7 的父 POM 也有一堆兼容性问题。
所以我给出一套经过验证的版本组合:
| 组件 | 版本 |
|---|---|
| JDK | 1.8.0_202 |
| Maven | 3.6.3 |
| Spring Boot | 2.7.18 |
| MyBatis-Plus | 3.5.3.1 |
| MySQL | 8.0.x / 5.7.x 均可 |
| Lombok | 1.18.30 |
这套组合在本地开发、IDEA 运行、服务器部署三个环境里都测试过,几乎不会出现环境层面的幺蛾子。
5.2 打包与部署:jar 包和前端静态资源的处理
部署方式我最终选择了单 jar 包部署,不搞 Docker。为什么?因为很多人的服务器内存就 2G,跑 Docker 要额外占用资源,而且 Docker 镜像的构建、仓库推送对新手来说又是一道坎。Maven 打包命令很简单:
bash复制mvn clean package -DskipTests
打包完会在 target 目录下生成一个可执行的 jar 包。注意,因为用了 Thymeleaf 和静态资源,jar 包会比较大(通常 60-100MB),这是正常的。上传到服务器后,用 nohup java -jar xxx.jar > app.log 2>&1 & 启动,Spring Boot 内置的 Tomcat 会自动监听 8080 端口。
有个小细节,spring boot 的项目配置文件 application.yml 里,数据库链接等环境相关的配置我做成了一份“部署专用”的版本,放在服务器上。为什么不用环境变量甚至直接用默认值?因为本地和服务器数据库地址、账号密码都不一样,如果配置文件写死,每次部署都要重新打包。用一份外部配置文件配合 --spring.config.additional-location 指定,就省去了重复打包的麻烦。
5.3 自动化部署的轻量方案:批处理脚本一键启动
如果你经常要重启项目,手敲 nohup 命令就太痛苦了。我在服务器上写了一个简单的 restart.sh 脚本,内容大致是:
bash复制#!/bin/bash
PID=$(ps -ef | grep java | grep app.jar | grep -v grep | awk '{print $2}')
if [ -n "$PID" ]; then
kill -9 $PID
fi
sleep 2
nohup java -jar app.jar --spring.config.additional-location=/opt/config/application-prod.yml > /opt/logs/app.log 2>&1 &
echo "Application started, pid: $!"
每次更新版本,只需要:上传新的 jar 包到 /opt/app/ 目录覆盖旧的,然后执行 sh restart.sh,几十秒内项目就恢复了。这套轻量方案虽然不如 Docker Compose 和 Jenkins 高级,但对个人项目和教学场景来说性价比极高——维护成本趋近于零,出问题还能容易排查。
6. 常见问题排查实录
6.1 接口能通但页面白屏:前端路由 history 模式
这个坑发生在前后端分离的版本里。如果用 Vue 写了前端,并且开了 history 模式路由,直接部署时访问 /home 这类二级路径会刷新白屏,因为服务器没有对应的处理机制。解决方法是配置一个转发规则,把所有非静态文件的请求都转回 index.html。
如果你和我一样用的是 Thymeleaf 服务端渲染,这个问题就不存在。这也是我强烈建议初学者优先选服务端渲染的根本原因——不用额外处理路由回退、不用配置 Nginx、不用解决跨域,部署和排错的复杂度至少降低一半。想做前后端分离的同学,先把服务端渲染的版本做扎实,理解了数据是怎么到页面上的,再去拆分 Vue 会顺畅很多。
6.2 分页失效:MyBatis-Plus 拦截器忘了配置
MyBatis-Plus 的分页功能不是默认开启的,你必须把分页拦截器注册成 Bean 才会生效。很多人代码写完,列表页数据能查出 10 条,但页码点击完全没反应,就是因为这行配置漏掉了。这是我见过出现频率最高的一个低级错误,放出来帮大家避坑:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
6.3 数据库连接超时:时区和小版本差异
Spring Boot 2.7 连接 MySQL 8.x 时,必须要在 JDBC URL 里加上 serverTimezone=Asia/Shanghai,否则启动时大概率报时区错误。另外,MySQL 5.7 和 8.0 的驱动类名是一致的,但驱动版本建议用 8.0.x,这样对两种 MySQL 版本都兼容。我的标准 URL 长这样:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/drug_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
6.4 日期时间格式化:JSON 返回变成时间戳
本地测试时接口返回的时间字段是一串数字,页面显示完全不正常。这个问题的根源是 Spring Boot 默认的 JSON 序列化器对 LocalDateTime 处理不够直观。解决方案有两种,一是全局统一格式化,在 yml 里配置:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
二是用 @JsonFormat 注解加到实体类的日期字段上。我建议两种方式结合,实体里比较重要的日期字段用注解显式声明格式,其他字段走全局配置,清晰可控。
6.5 缓存与并发:库存扣减如何避免超卖
最后一个要提醒的是并发场景下的库存扣减。如果两个用户同时买同一批次的最后一斤药材,普通的“先查库存再更新”逻辑必然导致超卖。正确做法是在更新 SQL 语句中直接做条件扣减:
sql复制UPDATE inventory_stock
SET remaining_quantity = remaining_quantity - #{quantity}
WHERE batch_id = #{batchId} AND remaining_quantity >= #{quantity}
通过受影响行数判断扣减是否成功,如果影响行数为 0,说明库存不够,直接返回“库存不足”。这种乐观锁思路简单有效,代码实现也不复杂,是保证库存数据准确的关键一招。
7. 一点补充:从项目到答辩汇报的经验
做完整套系统之后,还有一个很重要的环节容易被忽略——就是讲清楚“为什么这样设计”。我见过太多同学功能都实现了,但答辩或汇报时只会演示页面,讲不出背后的设计思考。这里分享两个我常用的讲解路线,帮你把项目的亮点讲明白。
第一,讲数据模型时会重点画三张表的关联图,强调“批次管理”在中药材场景中的意义,解释为什么不是普通的商品-库存二元关系,而是药材档案-批次-库存的三层结构。听众一下就明白你不是在机械复制教程代码,而是理解了业务本质。
第二,讲库存预警时展示两种预警类型(缺货预警和临期预警)的触发条件和处理流程,说明为什么用“实时查询+标记”的方案而不是“定时任务生成预警记录”。两种方案的取舍我是从数据一致性和系统复杂度两个维度对比考虑的:实时查询虽然每次请求都扫一遍数据,但这个量级下完全无压力,而且不需要额外的调度依赖,维护更简单。这种思维方式会让人明显感觉到你是真的在“做系统”,而不是在“套模板”。
8. 最后关于这个项目的可行扩展方向
这套系统的主干做完以后,后续可以往好几个方向扩展。第一个方向是引入会员积分和营销模块,比如会员等级、积分抵现、满减活动,这在商业场景里几乎是标配,也是从“管理系统”向“运营系统”迈进的一步。第二个方向是做一个小程序端或移动端 H5,让顾客能在线浏览药材、提交需求单,商家在后端审核接单。第三个方向是数据可视化大屏,如果项目需要展示给更多人看,大屏的效果非常炸裂,技术上无非是把现有报表数据通过 WebSocket 实时推送到前端图表组件。
这些扩展方向你不需要全部实现,但建议想清楚哪个最适合你的目标和场景,把它作为项目后续规划写进文档里。哪怕只实现其中一两个核心页面,整个项目的完整度和前瞻性都会有明显提升。我当时选择扩展了会员积分模块,虽然只要了三个页面,但答辩时我从积分规则设计、消费流水记录到积分抵扣订单的完整链路都讲清楚了,面试官和老师都很认可。
回归到最核心的一句话:SpringBoot 只是一个工具,真正让项目与众不同的是你如何理解这个领域、如何把业务复杂性抽象成清晰的数据结构和代码逻辑。中药材店铺管理系统这个题目,你如果只是写增删改查,它只能算及格;如果你把批次、养护、预警、溯源这些行业细节做进去,它就是一个有血有肉的完整业务系统。希望我的这些实践经验和踩坑记录,能让你在做的过程中少走些弯路。
