实验室设备管理系统是高校实验室管理的重要工具,它通过信息化手段解决了传统人工管理模式下效率低下、数据易丢失、审批流程繁琐等问题。作为一名长期从事高校信息化建设的开发者,我设计并实现了这套基于Spring Boot和Vue.js的实验室设备管理系统,旨在为实验室管理人员提供一套完整的设备全生命周期管理解决方案。
系统采用前后端分离架构,后端基于Spring Boot框架构建,前端使用Vue.js实现响应式界面,数据库采用MySQL 8.0。系统主要功能包括设备信息管理、设备借用审批、设备报修处理、设备归还确认等核心业务流程,同时提供完善的用户权限管理和数据统计分析功能。
在技术选型阶段,我们主要考虑了以下几个关键因素:
基于这些考量,我们最终确定了以下技术栈:
后端技术栈:
前端技术栈:
系统采用典型的三层架构设计:
架构图如下:
code复制┌───────────────────────────────────────────────────┐
│ 客户端浏览器 │
└───────────────────────────────────────────────────┘
▲ │
│ HTTP/HTTPS │
│ ▼
┌───────────────────────────────────────────────────┐
│ Vue.js前端应用 │
└───────────────────────────────────────────────────┘
▲ │
│ RESTful API │
│ ▼
┌───────────────────────────────────────────────────┐
│ Spring Boot后端服务 │
├───────────────────┬───────────────────┬───────────┤
│ 用户管理模块 │ 设备管理模块 │ 审批模块 │
└───────────────────┴───────────────────┴───────────┘
▲ │
│ 数据访问 │
│ ▼
┌───────────────────────────────────────────────────┐
│ MySQL数据库 │
└───────────────────────────────────────────────────┘
数据库设计遵循第三范式,主要包含以下核心表:
用户表(sys_user):
设备表(equipment):
借用记录表(borrow_record):
报修记录表(repair_record):
数据库ER图如下:
code复制┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ sys_user │ │ equipment │ │ borrow_record│
├─────────────┤ ├───────────────┤ ├──────────────┤
│ PK user_id │───────│ PK equip_id │◄──────┤ FK equip_id │
│ username │ │ equip_name │ │ FK user_id │
│ password │ │ model │ │ borrow_time │
│ role_id │ │ status │ │ return_time │
│ dept_id │ │ location │ │ status │
└─────────────┘ └───────────────┘ └──────────────┘
▲ ▲
│ │
│ │
┌─────────────┐ ┌──────────────┐
│ sys_role │ │ repair_record│
├─────────────┤ ├──────────────┤
│ PK role_id │ │ PK repair_id │
│ role_name │ │ FK equip_id │
│ role_key │ │ reporter_id │
│ status │ │ fault_desc │
└─────────────┘ │ status │
└──────────────┘
设备借用是系统的核心功能之一,其业务流程如下:
关键技术实现:
java复制@RestController
@RequestMapping("/api/borrow")
public class BorrowController {
@Autowired
private BorrowService borrowService;
@PostMapping("/apply")
public Result applyBorrow(@RequestBody BorrowApplyDTO dto) {
// 验证用户身份
User currentUser = ShiroUtils.getCurrentUser();
if(currentUser == null) {
return Result.error("请先登录");
}
// 验证设备状态
Equipment equipment = equipmentService.getById(dto.getEquipId());
if(equipment == null || !"0".equals(equipment.getStatus())) {
return Result.error("设备不可借用");
}
// 创建借用记录
BorrowRecord record = new BorrowRecord();
record.setEquipId(dto.getEquipId());
record.setUserId(currentUser.getUserId());
record.setBorrowTime(dto.getBorrowTime());
record.setExpectedReturnTime(dto.getExpectedReturnTime());
record.setPurpose(dto.getPurpose());
record.setStatus("0"); // 待审批
if(borrowService.save(record)) {
// 更新设备状态为"待出借"
equipment.setStatus("1");
equipmentService.updateById(equipment);
return Result.success("借用申请已提交");
}
return Result.error("提交失败");
}
}
java复制@Service
public class BorrowApprovalService {
@Autowired
private BorrowRecordMapper borrowRecordMapper;
@Autowired
private EquipmentMapper equipmentMapper;
@Transactional
public Result approve(Long recordId, String approvalResult, String comment) {
BorrowRecord record = borrowRecordMapper.selectById(recordId);
if(record == null) {
return Result.error("记录不存在");
}
User approver = ShiroUtils.getCurrentUser();
if("1".equals(approvalResult)) { // 批准
record.setStatus("1"); // 已批准
record.setApproverId(approver.getUserId());
record.setApprovalTime(new Date());
borrowRecordMapper.updateById(record);
// 更新设备状态为"已借出"
Equipment equipment = equipmentMapper.selectById(record.getEquipId());
equipment.setStatus("2"); // 已借出
equipmentMapper.updateById(equipment);
// 发送通知给借用人
notifyUser(record.getUserId(), "您的借用申请已批准");
} else { // 拒绝
record.setStatus("2"); // 已拒绝
record.setApproverId(approver.getUserId());
record.setApprovalTime(new Date());
record.setRejectReason(comment);
borrowRecordMapper.updateById(record);
// 更新设备状态为"可借用"
Equipment equipment = equipmentMapper.selectById(record.getEquipId());
equipment.setStatus("0"); // 可借用
equipmentMapper.updateById(equipment);
// 发送通知给借用人
notifyUser(record.getUserId(), "您的借用申请被拒绝,原因:" + comment);
}
return Result.success("操作成功");
}
}
设备报修功能允许用户在线提交设备故障信息,管理员可查看并安排维修。主要流程包括:
关键技术点:
java复制@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
@Value("${file.upload-dir}")
private String uploadDir;
@PostMapping("/image")
public Result uploadImage(@RequestParam("file") MultipartFile file) {
if(file.isEmpty()) {
return Result.error("请选择文件");
}
try {
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String fileExt = originalFilename.substring(originalFilename.lastIndexOf("."));
String newFilename = UUID.randomUUID().toString() + fileExt;
// 创建目录
File dir = new File(uploadDir);
if(!dir.exists()) {
dir.mkdirs();
}
// 保存文件
File dest = new File(uploadDir + newFilename);
file.transferTo(dest);
// 返回访问路径
String fileUrl = "/uploads/" + newFilename;
return Result.success("上传成功").put("url", fileUrl);
} catch (IOException e) {
log.error("文件上传失败", e);
return Result.error("上传失败");
}
}
}
java复制@Service
public class RepairServiceImpl implements RepairService {
@Autowired
private RepairRecordMapper repairRecordMapper;
@Autowired
private EquipmentMapper equipmentMapper;
@Override
@Transactional
public Result submitRepair(RepairSubmitDTO dto) {
// 验证设备是否存在
Equipment equipment = equipmentMapper.selectById(dto.getEquipId());
if(equipment == null) {
return Result.error("设备不存在");
}
// 获取当前用户
User currentUser = ShiroUtils.getCurrentUser();
if(currentUser == null) {
return Result.error("请先登录");
}
// 创建报修记录
RepairRecord record = new RepairRecord();
record.setEquipId(dto.getEquipId());
record.setReporterId(currentUser.getUserId());
record.setFaultDesc(dto.getFaultDesc());
record.setFaultImages(dto.getFaultImages());
record.setReportTime(new Date());
record.setStatus("0"); // 待处理
if(repairRecordMapper.insert(record) > 0) {
// 更新设备状态为"维修中"
equipment.setStatus("3");
equipmentMapper.updateById(equipment);
return Result.success("报修成功");
}
return Result.error("报修失败");
}
}
系统采用Apache Shiro框架实现认证和授权功能,主要配置如下:
java复制@Configuration
public class ShiroConfig {
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
// 设置登录URL
shiroFilter.setLoginUrl("/login");
// 设置未授权跳转URL
shiroFilter.setUnauthorizedUrl("/unauthorized");
// 定义过滤器链
Map<String, String> filterMap = new LinkedHashMap<>();
// 静态资源不拦截
filterMap.put("/static/**", "anon");
filterMap.put("/uploads/**", "anon");
// 登录接口不拦截
filterMap.put("/api/login", "anon");
// 其他请求需要认证
filterMap.put("/**", "authc");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
@Bean
public Realm realm() {
UserRealm realm = new UserRealm();
realm.setCredentialsMatcher(hashedCredentialsMatcher());
return realm;
}
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
matcher.setHashAlgorithmName("SHA-256");
matcher.setHashIterations(1024);
matcher.setStoredCredentialsHexEncoded(true);
return matcher;
}
}
java复制public class UserRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private RoleService roleService;
@Autowired
private MenuService menuService;
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
User user = (User) principals.getPrimaryPrincipal();
// 查询用户角色
Set<String> roles = roleService.getRoleKeysByUserId(user.getUserId());
// 查询用户权限
Set<String> permissions = menuService.getPermsByUserId(user.getUserId());
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setRoles(roles);
info.setStringPermissions(permissions);
return info;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
String username = upToken.getUsername();
// 查询用户信息
User user = userService.getByUsername(username);
if(user == null) {
throw new UnknownAccountException("账号不存在");
}
if("1".equals(user.getStatus())) {
throw new LockedAccountException("账号已锁定");
}
return new SimpleAuthenticationInfo(user, user.getPassword(), getName());
}
}
java复制public class PasswordUtils {
private static final SecureRandom random = new SecureRandom();
private static final int SALT_LENGTH = 16;
public static String generateSalt() {
byte[] salt = new byte[SALT_LENGTH];
random.nextBytes(salt);
return Hex.encodeHexString(salt);
}
public static String encryptPassword(String password, String salt) {
String combined = salt + password;
return new Sha256Hash(combined, salt, 1024).toHex();
}
public static boolean validate(String inputPwd, String salt, String storedPwd) {
String encrypted = encryptPassword(inputPwd, salt);
return encrypted.equals(storedPwd);
}
}
X-XSS-Protection: 1; mode=block系统支持多种部署方式,推荐采用Docker容器化部署,便于管理和扩展。
yaml复制version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: lab-mysql
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: lab_equipment
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/conf:/etc/mysql/conf.d
ports:
- "3306:3306"
restart: always
redis:
image: redis:6.2
container_name: lab-redis
volumes:
- ./redis/data:/data
ports:
- "6379:6379"
restart: always
backend:
build: ./backend
container_name: lab-backend
depends_on:
- mysql
- redis
environment:
- SPRING_PROFILES_ACTIVE=prod
- DB_URL=jdbc:mysql://mysql:3306/lab_equipment
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
ports:
- "8080:8080"
restart: always
frontend:
build: ./frontend
container_name: lab-frontend
ports:
- "80:80"
restart: always
nginx复制server {
listen 80;
server_name lab.example.com;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend: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;
}
location /uploads/ {
alias /data/uploads/;
}
}
java复制@Service
@CacheConfig(cacheNames = "equipment")
public class EquipmentServiceImpl implements EquipmentService {
@Autowired
private EquipmentMapper equipmentMapper;
@Cacheable(key = "'list:' + #pageNum + '-' + #pageSize")
@Override
public PageInfo<Equipment> list(int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<Equipment> list = equipmentMapper.selectList(null);
return new PageInfo<>(list);
}
@CacheEvict(allEntries = true)
@Override
public void clearCache() {
// 清空缓存
}
}
系统采用分层测试策略,确保各组件和整体功能符合预期:
| 测试场景 | 测试步骤 | 预期结果 | 实际结果 | 状态 |
|---|---|---|---|---|
| 正常借用 | 1. 用户登录 2. 查询可用设备 3. 提交借用申请 4. 管理员审批 5. 用户领取设备 |
流程顺利完成,设备状态正确变更 | 符合预期 | 通过 |
| 重复借用 | 1. 对同一设备提交多个借用申请 | 后续申请被拒绝 | 符合预期 | 通过 |
| 超期未还 | 1. 借用设备超过归还时间 | 系统自动发送提醒,记录超期 | 符合预期 | 通过 |
| 测试类型 | 并发用户数 | 平均响应时间 | 错误率 | 吞吐量 |
|---|---|---|---|---|
| 登录 | 100 | 235ms | 0% | 420/s |
| 设备查询 | 200 | 180ms | 0% | 850/s |
| 借用申请 | 50 | 320ms | 0% | 150/s |
| 报表生成 | 10 | 1.2s | 0% | 8/s |
系统集成JaCoCo进行代码覆盖率测试,确保测试充分性:
xml复制<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
测试覆盖率结果:
本系统经过三个月的开发和测试,已成功在某高校实验室部署使用,取得了显著成效:
在项目开发过程中,我们积累了以下宝贵经验:
根据用户反馈和实际使用情况,计划在后续版本中增加以下功能:
在实际开发和部署过程中,我们遇到并解决了以下典型问题:
问题描述:前端访问后端API时出现CORS错误
解决方案:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.maxAge(3600);
}
}
javascript复制module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
}
}
}
问题描述:设备列表查询在数据量增大后响应变慢
解决方案:
sql复制ALTER TABLE equipment ADD INDEX idx_status_location (status, location);
java复制@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<Equipment> list = equipmentService.list();
return Result.success(new PageInfo<>(list));
}
java复制@Cacheable(value = "equipment", key = "'list:' + #pageNum + '-' + #pageSize")
public PageInfo<Equipment> list(int pageNum, int pageSize) {
// ...
}
问题描述:上传大文件时报错
解决方案:
properties复制# application.properties
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
javascript复制const beforeUpload = (file) => {
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('文件大小不能超过10MB');
return false;
}
return true;
}
后端开发:
前端开发:
数据库工具:
Spring Boot:
Vue.js:
系统设计:
需求分析阶段:
开发阶段:
测试阶段:
部署维护: