直接开工。这标题一看就是Java全栈方向最常见的“求职+练手”双需求项目,医药管理系统也是除商城、CRM之外,我最推荐新手完整走一遍的选题。原因后面细说。你拿着这套源码,应该关心的不只是怎么跑起来,而是:为什么要分前后端、权限模型怎么设计、库存和效期这种带状态流转的数据要怎么做才不出错、以及面试官追问时你怎么答得上话。
这篇文章我就照着这套SpringBoot+Vue3+MyBatis的医药管理系统,把从设计到落地的完整思路拆给你。没有源码我也能给你讲清楚每一行该写啥、每张表该建啥,有源码的可以直接对照着改。
1. 项目整体设计与技术选型解析
1.1 为什么是“前后端分离”,而不是传统的Thymeleaf模板
放在五年前,Java后端写管理系统基本都是Thymeleaf或者JSP,页面和后端代码揉在一个工程里。现在做医药管理系统这类企业内部系统,前后端分离已经是事实标准,原因很直接:
一是前后端并行开发效率高。后端定义好接口文档,前端就可以同时开工,不用等Java代码编译完再调页面。二是后端只管业务逻辑和数据,前端只管交互和渲染,职责边界清楚,排查问题不用来回翻代码。三是部署灵活,后端打一个jar包扔服务器,前端构建成静态文件扔Nginx,未来要加一个移动端管理页面,后端接口可以直接复用。
这套系统选型SpringBoot + Vue3 + MyBatis + MySQL,属于目前Java全栈最主流、招聘需求最大、学习资料最多的组合。SpringBoot负责把后端服务快速跑起来,Vue3负责做管理后台的交互界面,MyBatis负责把Java对象和数据库表映射起来,MySQL存数据。每一层的选型都不过时,每一层面试都有的聊。
1.2 核心需求拆解:医药管理系统到底管什么
做任何一个系统,先别急着写代码,先把业务边界画出来。医药管理系统听起来大,落到核心业务上,就是下面这几件事:
- 药品信息管理:药品的通用名、商品名、规格、生产厂家、批准文号、剂型、单位、零售价、进货价等信息维护。
- 药品分类管理:处方药/非处方药、中成药/西药/生物制品,或者按治疗领域分,分类层级一般是两级就够用。
- 库存管理:入库、出库、报损、盘点、库存上下限预警,最核心的是效期预警,药品过了有效期是绝对不能卖的,这是医药行业和普通商品最大的区别。
- 供应商管理:药品从哪个供应商采购的、联系方式、供货资质。
- 客户/销售管理:如果是零售场景,可能还涉及会员、处方登记;如果是批发场景,就是客户档案和销售单。
- 系统权限:管理员、库管员、销售员、采购员,不同角色看的菜单和操作按钮不一样。
你拿着这套源码,第一件事就是对照这个功能清单去看,缺了哪块,自己动手补上。面试时聊项目,能清楚说出“我做的系统包含X个核心模块,我主要负责Y和Z模块”,比背一百道八股文都有用。
1.3 技术栈选型背后的几个现实考量
SpringBoot版本选择上,如果你是跟着教程走,不要一上来就追最新版本。这套系统如果基于SpringBoot 2.x,那JDK 8就够了;如果用了SpringBoot 3.x,强制要求JDK 17+,很多人卡在启动报错上,大部分都是版本不匹配。建议稳妥方案:SpringBoot 2.7.x + JDK 8 + MyBatis 3.5.x,资料最多,踩坑最少。
Vue3这边,用Vue3 + Vite + Element Plus + Pinia + Vue Router,这是目前后台管理系统开发事实标准。Vite启动快、热更新快,Element Plus组件库自带表格、表单、弹窗、菜单这些现成组件,不用自己写CSS。
MyBatis和MyBatis-Plus的选择也聊两句。原生MyBatis的优势是SQL完全可控,适合复杂查询;MyBatis-Plus的优势是单表CRUD不用写SQL,内置分页插件、代码生成器。这套系统如果是你学习的,建议用原生MyBatis把基础CRUD、多表联查、动态SQL都手写一遍,理解透彻之后再换Plus,你会用得更明白。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与核心表结构
2.1 数据库建模:医药行业有哪些特殊字段
数据库设计是这套系统的地基。地基不牢,代码写得再花哨也是白搭。医药管理系统一张核心的药品表,字段设计上比普通的商品表要多几个行业专属字段。
我列一个可以直接照抄的药品信息表结构,字段含义和类型都给你标清楚:
sql复制CREATE TABLE `drug_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`drug_code` varchar(32) NOT NULL COMMENT '药品编码',
`generic_name` varchar(64) NOT NULL COMMENT '通用名',
`trade_name` varchar(64) DEFAULT NULL COMMENT '商品名',
`specification` varchar(64) DEFAULT NULL COMMENT '规格,如 0.25g*24片',
`dosage_form` varchar(32) DEFAULT NULL COMMENT '剂型,如片剂/胶囊/注射液',
`manufacturer` varchar(128) DEFAULT NULL COMMENT '生产厂家',
`approval_number` varchar(64) DEFAULT NULL COMMENT '批准文号,如国药准字Hxxxx',
`drug_category` varchar(32) DEFAULT NULL COMMENT '药品分类:处方药/OTC',
`unit` varchar(16) DEFAULT NULL COMMENT '基本单位,如盒/瓶/袋',
`purchase_price` decimal(10,2) DEFAULT NULL COMMENT '进货价',
`sale_price` decimal(10,2) DEFAULT NULL COMMENT '零售价',
`stock_quantity` int(11) DEFAULT '0' COMMENT '当前库存数量',
`stock_lower_limit` int(11) DEFAULT '10' COMMENT '库存下限',
`stock_upper_limit` int(11) DEFAULT '500' COMMENT '库存上限',
`production_date` date DEFAULT NULL COMMENT '生产日期',
`expiry_date` date DEFAULT NULL COMMENT '有效期至',
`status` tinyint(4) DEFAULT '1' COMMENT '状态:1启用 0停用',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_drug_code` (`drug_code`),
KEY `idx_expiry_date` (`expiry_date`),
KEY `idx_generic_name` (`generic_name`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='药品信息表';
需要注意的地方有几个:金额字段用decimal,绝不用float/double,这是Java开发的基本素养,浮点数算钱会出精度问题;药品编码要有唯一索引,这是业务主键,系统内部关联都用它,自增id只是物理主键;expiry_date上建索引,因为效期预警查询是高频操作,没有索引,数据量大了SQL直接慢查询。
2.2 库存流水表:库存系统不能只存一个数字
很多人做库存管理,就在药品表里加一个stock_quantity字段,每次入库就加、出库就减。这样做在单机小项目里能用,但一旦涉及多线程并发、多人同时操作,库存就很容易出错。更关键的是,你无法回答“这个库存数字是怎么变成这样的”。
正确做法是建一张库存流水表,记录每一次库存变动:
sql复制CREATE TABLE `stock_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`drug_code` varchar(32) NOT NULL COMMENT '药品编码',
`change_type` tinyint(4) NOT NULL COMMENT '变动类型:1采购入库 2销售出库 3报损 4盘点调整',
`change_quantity` int(11) NOT NULL COMMENT '变动数量,入库为正,出库为负',
`before_quantity` int(11) NOT NULL COMMENT '变动前库存',
`after_quantity` int(11) NOT NULL COMMENT '变动后库存',
`related_bill_no` varchar(64) DEFAULT NULL COMMENT '关联单号,如采购单号/销售单号',
`operator_id` bigint(20) DEFAULT NULL COMMENT '操作人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_drug_code` (`drug_code`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='库存变动流水表';
这个设计的核心逻辑是:库存表只存当前结果,流水表存变化过程。任何一次库存变动,都要在同一个数据库事务里同时更新药品表的库存数量和插入一条流水记录。这样既能追溯历史,也方便以后做统计报表。我见过很多刚工作的开发,库存系统做成了“只改数字”,上线不到一个月,账实不符,又查不出来哪里错了,最后只能盘点重置。这套源码如果没做流水表,建议你自己加上,这是加分项。
2.3 多表关系:药品、分类、供应商、用户、角色怎么关联
再梳理一下核心表之间的关系:
- 药品分类表:父子级结构,
parent_id为0表示顶级分类,药品表存category_id。 - 供应商表:
supplier_id关联采购入库单,记录每一批药从哪家供应商来。 - 用户表 + 角色表 + 菜单/权限表:经典RBAC模型,用户关联角色,角色关联菜单权限。管理员登录后根据角色动态渲染菜单,接口层用拦截器校验权限。
- 采购入库单表 + 入库明细表:一主多子的结构,主表存供应商、入库日期、经办人、总金额,明细表存每一行药品、数量、单价。
数据库设计这里多说一句:药品管理系统里,药品和批次的关系值得思考。实际业务中,同一种药不同批次的生产日期、效期不同,严格来说库存应该按批次管理。但作为学习项目,做到药品维度的库存就够用了,如果想把项目做得更深,可以再引入批次的概念,面试官会眼前一亮。
3. 后端核心模块与MyBatis实现
3.1 项目工程结构:包怎么分包才能又清晰又好维护
后端代码怎么组织,直接决定这个项目好不好维护,也是面试官看代码的第一印象。我推荐按模块分包,而不是按技术类型分包:
code复制com.hospital.medicine
├── controller // 控制层,接收前端请求
├── service // 业务接口
├── service.impl // 业务实现
├── mapper // MyBatis数据访问接口
├── entity // 数据库实体类
├── dto // 数据传输对象,接收前端请求参数
├── vo // 视图对象,返回给前端的数据
├── common // 通用类:统一返回结果、异常处理、工具类
├── config // 配置类:拦截器、跨域、分页插件等
└── MedicineApplication.java // 启动类
模块分包的好处是:一个业务模块(比如药品管理)的Controller、Service、Mapper、VO都在相近的包里,改需求时不用来回跳目录。entity是数据库表的映射,dto是接口的入参,vo是接口的返回值,三者分离,避免前端传参直接污染实体类。
3.2 RESTful接口设计规范:前后端联调不吵架的前提
前后端分离的项目,接口规范比代码本身更重要。一套统一的接口风格,能让前后端联调时少吵十次架。核心要点:
第一,接口路径用名词复数。比如:
GET /api/drugs- 分页查询药品列表GET /api/drugs/{id}- 查询药品详情POST /api/drugs- 新增药品PUT /api/drugs/{id}- 修改药品DELETE /api/drugs/{id}- 删除药品
第二,统一返回结构。后端不能一会儿返回JSON对象,一会儿返回字符串,一会儿又直接返回null。定义统一的结果类:
java复制public class Result<T> {
private Integer code; // 200成功,500失败
private String message; // 提示信息
private T data; // 业务数据
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
}
第三,分页参数统一。分页查询接口固定接收pageNum和pageSize,返回结果用PageResult对象封装,包含total总条数、list当前页数据。前端拿到这个结构,通用分页组件一套就完事了。
3.3 MyBatis动态SQL实战:药品列表的条件查询怎么写
药品管理列表页是管理系统的标配功能,按名称模糊搜索、按分类筛选、按状态筛选、按效期范围筛选,这些条件组合起来,就轮到MyBatis动态SQL上场了。我直接贴一段实际可用的Mapper代码:
xml复制<select id="selectDrugPage" parameterType="map" resultType="com.hospital.medicine.entity.DrugInfo">
SELECT * FROM drug_info
<where>
<if test="keyword != null and keyword != ''">
AND (generic_name LIKE CONCAT('%', #{keyword}, '%')
OR trade_name LIKE CONCAT('%', #{keyword}, '%')
OR drug_code LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="expiryStart != null">
AND expiry_date >= #{expiryStart}
</if>
<if test="expiryEnd != null">
AND expiry_date <= #{expiryEnd}
</if>
</where>
ORDER BY create_time DESC
LIMIT #{offset}, #{pageSize}
</select>
这里重点说两个容易踩坑的地方。
首先是<where>标签的作用:它会自动去掉SQL语句中第一个多余的AND关键字,避免出现WHERE AND generic_name LIKE xxx这种语法错误。如果你用<trim>标签,效果类似,但<where>更直观。
其次是模糊查询的写法。LIKE '%${keyword}%'这种直接拼字符串的写法绝对禁止,会导致SQL注入。要写LIKE CONCAT('%', #{keyword}, '%'),用#{}占位符传参,MyBatis会自动转义特殊字符。
分页这块,手写LIMIT #{offset}, #{pageSize}也没问题,但如果你不想每个Mapper都写分页逻辑,就引入PageHelper分页插件,一行代码搞定:
java复制PageHelper.startPage(pageNum, pageSize);
List<DrugInfo> drugList = drugMapper.selectDrugList(drugQueryDTO);
PageInfo<DrugInfo> pageInfo = new PageInfo<>(drugList);
3.4 库存扣减的并发问题:synchronized为什么不够用
系统里最高频也最容易出问题的操作,就是销售出库扣库存。先看一个典型的错误写法:
java复制DrugInfo drug = drugMapper.selectByDrugCode(drugCode);
if (drug.getStockQuantity() < quantity) {
throw new BusinessException("库存不足");
}
int result = drugMapper.reduceStock(drugCode, quantity);
这段代码的问题在于:先查询、再判断、再更新,三步操作不是原子的。当两个用户同时下单购买同一个药品时,可能同时查到库存是10,都判断库存充足,然后都去执行扣减,最终库存变成8而不是正确的6。这就是并发场景下经典的“超卖”问题。
解决方法有两个层次。初级做法是用数据库的原子更新:
java复制@Update("UPDATE drug_info SET stock_quantity = stock_quantity - #{quantity} " +
"WHERE drug_code = #{drugCode} AND stock_quantity >= #{quantity}")
int reduceStock(@Param("drugCode") String drugCode, @Param("quantity") Integer quantity);
// 判断返回值,如果有0条记录受影响,说明库存不足
if (reduceStockResult == 0) {
throw new BusinessException("库存不足");
}
把判断库存和下推库存合并成一条SQL,数据库的行级锁保证同一时刻只有一个事务能更新这行数据,从根上解决了超卖。同时注意,扣减库存和插入流水必须在同一个事务里:
java复制@Transactional(rollbackFor = Exception.class)
public void saleDrug(SaleOrderDTO saleOrderDTO) {
// 1. 扣减库存
int result = drugMapper.reduceStock(drugCode, quantity);
if (result == 0) {
throw new BusinessException("库存不足");
}
// 2. 插入库存流水
stockRecordMapper.insert(new StockRecord(...));
// 3. 生成销售单
saleOrderMapper.insert(saleOrder);
}
@Transactional保证这三步操作要么全部成功,要么全部回滚。如果第2步插入流水失败,第1步扣除的库存也会自动回滚,不会出现库存扣了但单据没生成的脏数据。
更进阶的做法是引入Redis分布式锁、乐观锁版本号控制,但对医药管理系统这个量级,数据库原子更新+事务已经足够,面试能讲到这一层,就已经超过大部分候选人了。
3.5 药品效期预警:定时任务怎么实现
效期预警是医药管理系统区别于普通商品系统的标志性功能。核心逻辑很简单:查询所有在有效期内、且expiry_date距离当前日期小于预警天数的药品。但关键点是预警天数不能写死,因为不同药品的效期敏感度不同。有的医院要求效期少于6个月就预警,有的要求3个月。所以需要在药品表或参数表里配置预警阈值。
后端用Spring自带的@Scheduled注解实现定时扫描:
java复制@Component
public class ExpiryDateJob {
@Resource
private DrugInfoMapper drugInfoMapper;
@Resource
private WarningRecordMapper warningRecordMapper;
// 每天凌晨2点执行一次
@Scheduled(cron = "0 0 2 * * ?")
public void checkExpiryDate() {
// 查询效期在预警阈值内的药品
List<DrugInfo> expiringDrugs = drugInfoMapper.selectExpiringDrugs(90);
for (DrugInfo drug : expiringDrugs) {
// 判断是否已存在未处理的预警记录,避免重复插入
WarningRecord record = warningRecordMapper.selectUnhandledByDrugCode(drug.getDrugCode());
if (record == null) {
warningRecordMapper.insert(new WarningRecord(drug.getDrugCode(), drug.getExpiryDate()));
}
}
}
}
实现的时候有两个细节要注意:避免重复预警,同一药品的预警记录只能有一条未处理的,库管员处理完(比如下架或退货)之后才能生成新的预警;预警状态要可流转,预警记录应该有“未处理/已处理”字段,已处理的记录要保留,方便后续审计。
4. 前端Vue3实现与核心页面拆解
4.1 Vue3工程结构:Vite脚手架初始化
前端工程用Vite创建,这是目前Vue3项目最推荐的构建工具。初始化命令:
bash复制npm create vite@latest medicine-web -- --template vue
创建完成后安装项目依赖:
bash复制npm install
npm install vue-router@4 pinia element-plus axios
npm install -D sass
实际开发中我习惯自己把目录结构整理一遍,而不是直接用脚手架生成的那套:
code复制medicine-web
├── src
│ ├── api // 接口请求封装
│ ├── assets // 静态资源
│ ├── components // 公共组件
│ ├── layout // 布局组件(侧边栏、顶部导航)
│ ├── router // 路由配置
│ ├── stores // Pinia状态管理
│ ├── utils // 工具类(axios封装、鉴权等)
│ ├── views // 页面组件
│ │ ├── login // 登录页面
│ │ ├── dashboard // 首页仪表盘
│ │ ├── drug // 药品管理
│ │ ├── stock // 库存管理
│ │ ├── supplier // 供应商管理
│ │ └── system // 系统管理(用户、角色、菜单)
│ ├── App.vue
│ └── main.js
└── vite.config.js
4.2 Axios请求封装:拦截器里干三件事
前后端分离项目,axios请求封装是基础设施。拦截器里主要干三件事:挂载Token、统一处理错误码、处理HTTP状态异常。直接看代码:
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const service = axios.create({
baseURL: '/api', // 通过Vite代理转发到后端
timeout: 10000
})
// 请求拦截器:让每个请求自动携带Token
service.interceptors.request.use(
config => {
const userStore = useUserStore()
if (userStore.token) {
config.headers['Authorization'] = 'Bearer ' + userStore.token
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器:统一处理后端返回的数据
service.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res
},
error => {
if (error.response && error.response.status === 401) {
ElMessage.error('登录已过期,请重新登录')
const userStore = useUserStore()
userStore.clearToken()
router.push('/login')
} else {
ElMessage.error(error.message || '网络异常')
}
return Promise.reject(error)
}
)
export default service
这里有一个实战中的经验:后端的code判断和HTTP状态码分开处理。业务错误(比如库存不足)通过HTTP 200 + code=500返回,由业务层统一处理;而登录过期、无权限这类需要前端跳转的场景,通过HTTP 401返回,由响应拦截器统一捕获。这样分工明确,不会出现前端把所有错误都弹一个提示框的尴尬局面。
4.3 动态路由与权限控制:不同角色看到不同菜单
管理系统权限控制的经典实现方案:用户登录后,后端返回该用户的角色信息和菜单权限列表,前端根据权限列表动态生成路由和侧边栏菜单。具体拆解一下:
后端登录接口返回数据结构大致是:
json复制{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9...",
"userInfo": {
"userId": 1,
"username": "admin",
"realName": "系统管理员",
"roles": ["admin"]
},
"permissions": [
"system:user:list",
"system:user:add",
"drug:list",
"drug:add",
"stock:list",
"stock:inbound"
]
}
}
前端拿到权限列表后,有两种处理方式:
一种是前端根据权限数组生成可访问的路由表,没有权限的路由不注册。这种方案安全性稍弱,因为前端路由本身并不安全,但控制菜单显示很灵活。
另一种是登录时只注册公共路由(如登录页、首页),其余路由用router.addRoute动态追加。菜单也根据权限动态渲染。为了安全,后端每个接口还要做权限校验,不能只靠前端隐藏按钮。
实际操作中,我会在侧边栏菜单的数据源里加permission字段,用v-if判断当前用户是否有权限显示:
vue复制<el-menu-item v-for="menu in menuList" :key="menu.path">
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</el-menu-item>
菜单列表从哪来?可以后端接口动态返回,也可以前端根据权限码自己过滤。小项目用前端过滤就行,省一次请求;大项目建议后端直接返回菜单树,灵活性更高。
4.4 Element Plus表格+表单+弹窗:药品管理页的标准写法
一个后台管理页面,80%的代码都是表格、搜索表单、新增/编辑弹窗这三件套。直接看药品管理页面的核心代码逻辑:
vue复制<template>
<div>
<!-- 搜索区 -->
<el-form :model="queryParams" inline>
<el-form-item label="药品名称">
<el-input v-model="queryParams.keyword" placeholder="通用名/商品名/编码" clearable />
</el-form-item>
<el-form-item label="药品分类">
<el-select v-model="queryParams.categoryId" clearable placeholder="请选择分类">
<el-option v-for="item in categoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区 -->
<el-table :data="drugList" v-loading="loading" border>
<el-table-column prop="drugCode" label="药品编码" width="120" />
<el-table-column prop="genericName" label="通用名" min-width="140" />
<el-table-column prop="specification" label="规格" width="120" />
<el-table-column prop="salePrice" label="零售价" width="90" />
<el-table-column prop="stockQuantity" label="库存" width="80" />
<el-table-column prop="expiryDate" label="有效期至" width="110" />
<el-table-column label="操作" fixed="right" width="180">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区 -->
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getList"
@current-change="getList"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listDrug, deleteDrug } from '@/api/drug'
const loading = ref(false)
const drugList = ref([])
const total = ref(0)
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
keyword: '',
categoryId: null
})
async function getList() {
loading.value = true
try {
const res = await listDrug(queryParams)
drugList.value = res.data.list
total.value = res.data.total
} finally {
loading.value = false
}
}
function handleQuery() {
queryParams.pageNum = 1
getList()
}
function resetQuery() {
queryParams.keyword = ''
queryParams.categoryId = null
queryParams.pageNum = 1
getList()
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除药品"${row.genericName}"吗?`, '提示', { type: 'warning' })
await deleteDrug(row.id)
ElMessage.success('删除成功')
getList()
}
onMounted(() => {
getList()
})
</script>
两个小技巧值得说。<script setup>是Vue3推荐写法,代码更简洁,组合式API的ref、reactive、onMounted直接从vue导入即可。表格操作列用fixed="right",数据多的时候横向滚动,操作按钮始终可见;如果项目里每个页面都要写一遍分页和搜索逻辑,可以把分页区抽成PaginationComponent全局组件,减少大量重复代码。
5. 权限管理、JWT鉴权与后端安全实践
5.1 登录认证流程:JWT令牌怎么签发和校验
管理系统必须做认证,也就是“你是谁”的问题。目前最常用的方案是JWT(JSON Web Token),无状态、跨域友好、适合前后端分离。核心流程:
- 用户提交用户名密码,后端校验通过后生成JWT令牌返回。
- 前端把Token存到
localStorage或Pinia中,后续每个请求的请求头上带Authorization: Bearer <token>。 - 后端通过拦截器校验Token,解析出用户身份,放行请求。
生成Token的核心代码:
java复制public String generateToken(LoginUser loginUser) {
return Jwts.builder()
.setSubject(loginUser.getUsername())
.claim("userId", loginUser.getUserId())
.claim("roles", loginUser.getRoles())
.setIssuedAt(new Date())
// 有效期24小时 - 用毫秒数相乘,避免直接魔数
.setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
JWT本身只是一个Token生成工具,真正让系统安全的是拦截器。自定义Spring MVC拦截器实现Token校验:
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行登录接口
if (request.getRequestURI().contains("/login")) {
return true;
}
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
// 返回401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
try {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token.replace("Bearer ", ""))
.getBody();
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("roles", claims.get("roles"));
return true;
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
}
拦截器注册到Spring MVC:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
}
}
这里必须强调一个安全细节:不要把所有接口都放行,也不要全部拦截。放行的接口只有登录接口,其他/api/**路径下的请求全部需要认证。
5.2 接口权限校验:@RequiresPermission注解设计
登录认证只解决了“你是谁”的问题,接下来还要解决“你能干什么”的问题。后端接口做按钮级权限控制,我推荐自定义注解加AOP的方式,代码侵入性最低。
先定义一个权限注解:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String value(); // 权限标识,如 "drug:add"
}
然后定义切面拦截:
java复制@Aspect
@Component
public class PermissionAspect {
@Around("@annotation(requiresPermission)")
public Object checkPermission(ProceedingJoinPoint joinPoint, RequiresPermission requiresPermission) throws Throwable {
// 从请求上下文中获取当前用户的权限列表
List<String> permissions = UserContext.getCurrentUser().getPermissions();
if (!permissions.contains(requiresPermission.value())) {
throw new BusinessException("无操作权限");
}
return joinPoint.proceed();
}
}
Controller里,在需要权限的接口上直接加注解:
java复制@PostMapping
@RequiresPermission("drug:add")
public Result<Void> addDrug(@RequestBody @Validated DrugDTO drugDTO) {
drugService.addDrug(drugDTO);
return Result.success(null);
}
这样代码看起来非常清爽,业务逻辑里不用写任何权限判断代码,权限校验统一交给切面处理。之前有人问过我,为什么不用Spring Security,非要用AOP自己搞?学习项目完全够用,实现一遍之后你对权限控制的理解远胜过直接引入框架。如果以后做更大项目,再用Spring Security或Sa-Token都会有更深刻的理解。
5.3 密码安全存库:MD5为什么不能直接用
用户密码明文存库是非常低级的错误。密码存储的正确姿势是“加密存储”。这里有两个选择:
一个是加盐MD5。用户注册时生成随机盐值,密码 = MD5(明文密码 + 盐值),把盐值和密文一起存库。这样即使数据库泄露,攻击者拿到密文也无法反推出原始密码,彩虹表对加盐密文无效。
另一个是BCrypt加密,Spring Security内置的BCryptPasswordEncoder就是干这个的,加密强度比MD5高,而且每次加密结果都不一样,安全性更好。
code复制老项目里用 MD5 + 盐 实现成本低,学习项目可以用这个方案把原理讲清楚;
如果是真实项目,建议直接上 BCrypt,想省事的可以直接引入 Spring Security 的 crypto 模块。
需要注意的是:密码不能明文传输到前端,登录接口用HTTPS加密传输是基本要求;登录接口要有失败次数限制,防止暴力破解,最简单的办法是Redis里记录连续失败次数,超过5次账号锁定15分钟。
6. 项目部署与运行调试:从源码到能访问
6.1 本地环境准备:JDK、MySQL、Node版本注意事项
先把环境讲清楚,太多人项目跑不起来,不是代码问题,是环境问题。
后端需要:
- JDK 8(如果SpringBoot是3.x,则需要JDK 17+,打开
pom.xml看一眼<parent>的版本就知道该用哪个JDK) - Maven 3.6+(推荐用IDEA自带的Maven,省去配环境变量的麻烦)
- MySQL 5.7或8.0(两者语法基本兼容,
utf8mb4字符集在8.0下是默认值,不用额外配置)
前端需要:
- Node.js 16.20+(Vue3+Vite要求,推荐LTS版本,不要用最新大版本,有兼容问题)
- npm或者pnpm(用pnpm安装依赖更快,磁盘占用更小)
数据库初始化:把项目里的sql目录下的初始化脚本导入MySQL,主要包含建库语句、建表语句、初始化数据(管理员账号、药品分类、测试药品数据等)。操作方式可以用命令行,也可以用Navicat或MySQL Workbench。
6.2 后端启动步骤:修改配置、建库、启动
第一步,修改application.yml配置文件,核心内容如下:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/medicine_system?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai
username: root
password: your_password
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.hospital.medicine.entity
configuration:
map-underscore-to-camel-case: true
jwt:
secret: your-own-secret-key-change-me
expire-days: 1
map-underscore-to-camel-case: true这个配置很多人容易漏掉。它负责把数据库字段generic_name自动映射到Java实体类的genericName属性,不配置的话,查询出来的结果每个字段都是null,排查半天还找不到原因。
第二步,IDEA里打开项目,等待Maven依赖下载完成(第一次下载会比较慢,如果卡住,检查Maven镜像源是否配置了阿里云)。然后运行主启动类MedicineApplication,看到SpringBoot的启动日志Logo,就说明后端跑起来了。
6.3 前端启动步骤:安装依赖、代理配置、本地运行
后端启动成功之后,前端工程在medicine-web目录下执行:
bash复制npm install
npm run dev
Vite默认端口是5173,启动后访问http://localhost:5173就能看到登录页面。
这里的关键点是前端代理配置。前端跑在5173端口,后端跑在8080端口,跨域问题怎么解决?两个方案:方案一,前端配置Vite代理,让/api请求转发到后端,开发环境最省事:
javascript复制// vite.config.js
export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
方案二,后端配置跨域过滤器。如果前端不通过代理直接访问后端,后端要开放跨域:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
实际开发中,生产环境用的是Nginx反向代理,把前端静态文件和/api接口请求统一部署在同一个域名下,不存在跨域问题。开发环境用Vite代理是最省心的选择,优先推荐。
6.4 生产环境部署:jar包和前端静态文件怎么放
生产环境部署的核心逻辑:后端打jar包,前端构建成静态文件,用Nginx统一托管。
后端打包:
bash复制mvn clean package -DskipTests
执行完成后,target目录下会生成medicine-system-1.0.0.jar。服务器上直接执行:
bash复制nohup java -jar medicine-system-1.0.0.jar --spring.profiles.active=prod > app.log 2>&1 &
前端构建:
bash复制npm run build
构建完成后,dist目录就是所有静态文件。Nginx配置:
nginx复制server {
listen 80;
server_name your-domain.com;
# 前端静态文件
location / {
root /var/www/medicine-web;
index index.html;
try_files $uri $uri/ /index.html; # 解决Vue路由刷新404问题
}
# 后端接口反向代理
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
try_files $uri $uri/ /index.html;这行一定要配,Vue Router用history模式时,刷新页面路径会直接请求Nginx,不加这行配置,刷新就404。
7. 面试高频追问与项目亮点包装
7.1 项目介绍怎么说才能显得有分量
面试时被问“介绍一下你的项目”,最怕的回答是“我做了个医药管理系统,里面有药品管理、库存管理、供应商管理”。信息量几乎为零。推荐用这样的结构:背景+功能+技术方案+个人工作+亮点。
参考话术:
这个项目是一个医药管理系统,主要服务对象是小型连锁药房,核心解决药品信息管理、库存管理、效期预警和权限控制这几个业务问题。技术选型上,后端用SpringBoot + MyBatis + MySQL,前端用Vue3 + Element Plus,实现了前后端分离开发。我在项目里主要负责药品管理和库存管理两个模块。其中一个技术亮点是库存扣减的并发控制,我采用了数据库原子更新加事务的方式,在一条SQL里完成库存充足判断和扣减操作。另一个是效期预警功能,我设计了一个定时任务,基于预警阈值扫描即将过期的药品,并且通过去重逻辑避免重复预警。
这样一段话把项目的业务背景、技术栈、个人职责、技术亮点全部覆盖了,面试官追问任意一个点,你都能往下接。
7.2 被追问的8个高频问题及答题思路
我把这套项目在面试中被问得最多的问题整理成了一张表,顺便给了答题方向:
| 高频问题 | 答题思路 |
|---|---|
| 为什么用MyBatis不用MyBatis-Plus? | 原生MyBatis的SQL可控性强,写多表联查和动态SQL能更深入理解SQL执行过程;Plus适合单表CRUD,复杂查询最后还是得写XML |
| 库存扣减怎么保证不超卖? | UPDATE语句带库存判断条件原子更新,配合事务;如果有更大量级再考虑乐观锁、Redis分布式锁 |
| 后端怎么做权限控制? | JWT做登录认证,自定义注解+AOP做接口粒度权限校验,用户-角色-权限的RBAC模型管理权限 |
| 药品效期预警怎么实现? | Spring定时任务每天扫描,动态配置预警天数,预警记录状态去重,过期或处理完不重复预警 |
| 谈谈这个项目遇到的难点? | 库存扣减并发、动态菜单权限、Vue3组件的复用和封装,选一个说透,别说了三个又每个都说不深 |
| 为什么要做前后端分离? | 并行开发效率高、部署灵活、职责边界清晰、接口可复用(未来可扩展移动端) |
| 数据库有哪些索引? | 主键索引、唯一索引(drug_code)、效期字段普通索引、流水表时间索引;可以补充索引原理的设计思考 |
| 如果药品数据量上亿了怎么办? | 分库分表、按药品编码哈希、读写分离、ES加速搜索;不用真做,但要让面试官知道你懂扩展方向 |
7.3 项目还能怎么扩展:进阶方向建议
如果你有时间把这个项目再往深做一两个点,我建议优先考虑这三个方向:
方向一:引入Redis做缓存和分布式会话。 把药品分类列表、高频查询的热点药品缓存到Redis,提升查询性能;用Redis存储登录Token替代无状态JWT,方便服务端主动下线用户。
方向二:引入消息队列做异步处理。 比如销售下单成功之后,发送通知、生成对账单这类非核心操作,通过RabbitMQ或RocketMQ异步执行,提高接口响应速度。
方向三:进销存报表模块。 没有报表的管理系统不完整。做一个采购入库统计、销售出库统计、库存周转率分析,后端用聚合SQL查询,前端用ECharts画折线图和柱状图。这个功能做出来,项目立刻有了“数据驱动决策”的味道。
面试官最喜欢能看到成长性和深度思考的候选人,你主动提出项目的不足和下一步规划,比被动等提问要加分得多。
8. 常见问题排查与避坑指南
8.1 环境搭建阶段最容易翻车的5个问题
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| Maven依赖下载极慢或卡住 | 未配置国内镜像源 | 在Maven的settings.xml中配置阿里云镜像,配完后重新reimport |
| 前端npm install报错 | Node版本过低或过高 | 用nvm管理Node版本,切到16.20+;删除node_modules和package-lock.json重装 |
| 项目启动报“Failed to configure a DataSource” | 配置文件里的数据库连接不对 | 检查application.yml的url、username、password,确认MySQL已启动且数据库已创建 |
| 启动报“Port 8080 was already in use” | 端口被占用 | 修改端口,或找到占用进程杀掉;Windows下netstat -ano配合taskkill /pid <pid> /f |
| 前端页面打开后接口404 | 代理没配置或路径不对 | 确认vite.config.js中的proxy配置,看F12里的请求URL和后端Controller的路径是否对得上 |
8.2 运行期典型异常排查思路
问题一:登录接口报401。 先确认账号密码是否正确,再确认拦截器是否放行了登录接口。可以在JwtInterceptor里打断点,看请求是否进入了preHandle,如果直接返回了401,检查请求头里Token是否传对了。
问题二:药品列表查询返回数据正常,但单个字段是null。 九成是MyBatis的驼峰映射没开启。检查application.yml里的map-underscore-to-camel-case是否为true,如果已经是true,再检查实体类属性和数据库字段名是否对得上。
问题三:删除药品时报外键约束错误。 这是因为药品被其他表(库存流水、销售单明细)引用了。解决办法有两个:物理上先查引用再删除,或者逻辑上给药品表加status字段,把“删除”做成“停用”。医药管理系统建议用逻辑删除,因为历史数据有审计价值。
问题四:前端修改页面后热更新不生效。 Vite偶尔会缓存失效。先试Ctrl+F5强刷,不行就重启npm run dev,再不行删掉node_modules/.vite缓存目录。这个问题不是你的代码问题,是工具的固有毛病,不用纠结。
8.3 学这套源码的正确姿势
最后聊聊怎么学这套源码最有收获。
第一遍,按功能模块过,先跑起来再看代码。 把项目启动起来,登录进去,把药品管理、库存管理、权限管理这些功能都点一遍,对系统有个整体感知。这一遍不要纠结具体代码,重点是理解功能边界。
第二遍,跟一条完整业务链路。 比如“新增药品 → 采购入库 → 销售出库 → 库存减少”,从前端页面找到对应接口,从接口找到Controller,从Controller进Service,从Service进Mapper,把一条链路的代码全部读一遍。这一遍下来,你对前后端数据流转的理解会非常深刻。
第三遍,带着问题去改代码。 可以自己给自己出几个任务:去掉分页插件改成手写分页;给药品表加一个“是否处方药”字段并加到列表筛选里;把角色权限从硬编码改成数据库动态配置。这三个任务做完,这套代码就真正变成你自己的了。
我见过不少人存了一堆源码,但从来不打开看,面试时项目说得磕磕绊绊,一问细节就露馅。源码不是拿来囤的,是拿来改的。你在这套系统上花了多少时间,面试时就能讲出多少深度。
