First commit

This commit is contained in:
2026-09-21 12:51:00 +08:00
commit 4fad31e438
145 changed files with 17216 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
package com.example.lab;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 实验室预约与资源管理平台启动类。
*
* <p>扫描 Mapper 接口、配置属性类,并开启定时任务(用于预约状态自动流转)。</p>
*/
@SpringBootApplication
@MapperScan("com.example.lab.mapper")
@ConfigurationPropertiesScan
@EnableScheduling
public class LabApplication {
/**
* 应用入口。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
SpringApplication.run(LabApplication.class, args);
}
}

View File

@@ -0,0 +1,28 @@
package com.example.lab.common.annotation;
import com.example.lab.domain.enums.Role;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 角色访问控制注解。
*
* <p>标注在 Controller 类或方法上,由 {@code AuthInterceptor} 在请求进入时校验;
* 方法级注解优先于类级注解。缺省表示「登录即可访问」。</p>
*/
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
/**
* 允许访问的角色列表,满足任意一个即放行。
*
* @return 角色数组
*/
Role[] value();
}

View File

@@ -0,0 +1,58 @@
package com.example.lab.common.api;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一响应包装。
*
* @param <T> 业务数据类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
/** 业务状态码,0 表示成功 */
private int code;
/** 提示信息 */
private String message;
/** 业务数据 */
private T data;
/**
* 构造成功响应(无数据)。
*
* @param <T> 数据类型
* @return 成功响应
*/
public static <T> ApiResponse<T> ok() {
return new ApiResponse<>(0, "success", null);
}
/**
* 构造成功响应。
*
* @param data 业务数据
* @param <T> 数据类型
* @return 成功响应
*/
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "success", data);
}
/**
* 构造失败响应。
*
* @param code 业务状态码
* @param message 错误信息
* @param <T> 数据类型
* @return 失败响应
*/
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -0,0 +1,42 @@
package com.example.lab.common.api;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分页结果包装。
*
* @param <T> 列表元素类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> {
/** 当前页数据 */
private List<T> records;
/** 总记录数 */
private long total;
/** 页码,从 1 开始 */
private long page;
/** 每页大小 */
private long size;
/**
* 由 MyBatis-Plus 分页对象构造分页结果。
*
* @param page 分页对象
* @param <T> 元素类型
* @return 分页结果
*/
public static <T> PageResult<T> of(IPage<T> page) {
return new PageResult<>(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize());
}
}

View File

@@ -0,0 +1,32 @@
package com.example.lab.common.context;
import com.example.lab.domain.enums.Role;
/**
* 登录用户上下文。
*
* @param id 用户 ID
* @param username 登录名
* @param realName 真实姓名
* @param role 角色
*/
public record LoginUser(Long id, String username, String realName, Role role) {
/**
* 是否为管理员。
*
* @return true 表示管理员
*/
public boolean isAdmin() {
return role != null && role.isAdmin();
}
/**
* 是否具备审批资格。
*
* @return true 表示可审批
*/
public boolean canApprove() {
return role != null && role.canApprove();
}
}

View File

@@ -0,0 +1,65 @@
package com.example.lab.common.context;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
/**
* 基于 {@link ThreadLocal} 的当前登录用户上下文。
*
* <p>由认证拦截器写入、请求结束后清理,业务层通过
* {@link #requireUser()} / {@link #requireUserId()} 获取当前操作人。</p>
*/
public final class UserContext {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
private UserContext() {
}
/**
* 写入当前登录用户。
*
* @param user 登录用户
*/
public static void set(LoginUser user) {
HOLDER.set(user);
}
/**
* 获取当前登录用户,可能为 null。
*
* @return 登录用户或 null
*/
public static LoginUser get() {
return HOLDER.get();
}
/**
* 获取当前登录用户,未登录时抛出 401 业务异常。
*
* @return 登录用户
*/
public static LoginUser requireUser() {
LoginUser user = HOLDER.get();
if (user == null) {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
return user;
}
/**
* 获取当前登录用户 ID。
*
* @return 用户 ID
*/
public static Long requireUserId() {
return requireUser().id();
}
/**
* 清理上下文,防止线程复用导致的数据串扰。
*/
public static void clear() {
HOLDER.remove();
}
}

View File

@@ -0,0 +1,44 @@
package com.example.lab.common.exception;
import lombok.Getter;
/**
* 业务异常,携带 {@link ErrorCode} 便于全局异常处理器映射为统一响应。
*/
@Getter
public class BizException extends RuntimeException {
/** 业务错误码 */
private final ErrorCode errorCode;
/**
* 使用错误码默认信息构造异常。
*
* @param errorCode 业务错误码
*/
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
/**
* 使用自定义信息构造异常。
*
* @param errorCode 业务错误码
* @param message 自定义错误信息
*/
public BizException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
/**
* 判断是否为指定错误码。
*
* @param code 错误码
* @return true 表示一致
*/
public boolean is(ErrorCode code) {
return this.errorCode == code;
}
}

View File

@@ -0,0 +1,75 @@
package com.example.lab.common.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 业务错误码字典。
*
* <p>1xxx 用户与认证,2xxx 资源管理,3xxx 预约与冲突,9xxx 系统级错误。</p>
*/
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
/** 请求参数不合法 */
BAD_REQUEST(400, "请求参数不合法"),
/** 未登录或登录已失效 */
UNAUTHORIZED(401, "未登录或登录已失效"),
/** 无权限执行该操作 */
FORBIDDEN(403, "无权限执行该操作"),
/** 资源不存在 */
NOT_FOUND(404, "资源不存在"),
/** 请求冲突 */
CONFLICT(409, "请求冲突"),
/** 服务器内部错误 */
INTERNAL_ERROR(500, "服务器内部错误"),
/** 用户名或密码错误 */
LOGIN_FAILED(1001, "用户名或密码错误"),
/** 账号被禁用 */
USER_DISABLED(1002, "账号已被禁用"),
/** 用户名已存在 */
USERNAME_EXISTS(1003, "用户名已存在"),
/** 用户不存在 */
USER_NOT_FOUND(1004, "用户不存在"),
/** 实验室不存在 */
LAB_NOT_FOUND(2001, "实验室不存在"),
/** 实验室编号重复 */
LAB_CODE_EXISTS(2002, "实验室编号已存在"),
/** 实验室当前状态不可预约 */
LAB_NOT_RESERVABLE(2003, "实验室当前状态不可预约"),
/** 设备不存在 */
DEVICE_NOT_FOUND(2101, "设备不存在"),
/** 设备编号重复 */
DEVICE_CODE_EXISTS(2102, "设备编号已存在"),
/** 设备不属于指定实验室 */
DEVICE_LAB_MISMATCH(2103, "设备不属于该实验室"),
/** 设备当前状态不可预约 */
DEVICE_NOT_RESERVABLE(2104, "设备当前状态不可预约"),
/** 课程不存在 */
COURSE_NOT_FOUND(2201, "课程不存在"),
/** 课程编号重复 */
COURSE_CODE_EXISTS(2202, "课程编号已存在"),
/** 课程已结束 */
COURSE_NOT_ACTIVE(2203, "课程已结束,无法生成预约"),
/** 预约不存在 */
RESERVATION_NOT_FOUND(3001, "预约不存在"),
/** 存在时间冲突 */
RESERVATION_CONFLICT(3002, "存在时间冲突"),
/** 预约状态不允许该操作 */
INVALID_STATE_TRANSITION(3003, "当前状态不允许该操作"),
/** 预约时间不合法 */
INVALID_TIME_RANGE(3004, "预约时间不合法"),
/** 重复规则不合法 */
INVALID_RECURRENCE_RULE(3005, "重复规则不合法"),
/** 超出容量限制 */
CAPACITY_EXCEEDED(3006, "参与人数超出实验室容量"),
/** 只能操作自己的数据 */
NOT_RESERVATION_OWNER(3007, "只能操作自己的预约");
private final int code;
private final String message;
}

View File

@@ -0,0 +1,107 @@
package com.example.lab.common.exception;
import com.example.lab.common.api.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* 全局异常处理器:把各种异常统一转换为 {@link ApiResponse},避免堆栈信息泄漏给前端。
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理业务异常。
*
* @param ex 业务异常
* @return 统一错误响应
*/
@ExceptionHandler(BizException.class)
public ApiResponse<Void> handleBizException(BizException ex) {
log.warn("业务异常: code={}, message={}", ex.getErrorCode().getCode(), ex.getMessage());
return ApiResponse.error(ex.getErrorCode().getCode(), ex.getMessage());
}
/**
* 处理请求体参数校验失败。
*
* @param ex 校验异常
* @return 统一错误响应,message 中包含每个字段的校验信息
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
return ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), message);
}
/**
* 处理表单/查询参数绑定校验失败。
*
* @param ex 绑定异常
* @return 统一错误响应
*/
@ExceptionHandler(BindException.class)
public ApiResponse<Void> handleBindException(BindException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
return ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), message);
}
/**
* 处理请求体无法解析(如日期格式错误、JSON 语法错误)。
*
* @param ex 解析异常
* @return 统一错误响应
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Void> handleNotReadable(HttpMessageNotReadableException ex) {
log.warn("请求体解析失败: {}", ex.getMessage());
return ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误");
}
/**
* 处理数据库唯一键冲突。
*
* @param ex 唯一键冲突异常
* @return 统一错误响应
*/
@ExceptionHandler(DuplicateKeyException.class)
public ApiResponse<Void> handleDuplicateKey(DuplicateKeyException ex) {
log.warn("唯一键冲突: {}", ex.getMessage());
return ApiResponse.error(ErrorCode.CONFLICT.getCode(), "数据已存在,请检查唯一字段");
}
/**
* 兜底异常处理。
*
* @param ex 未知异常
* @return 统一错误响应
*/
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) {
log.error("系统异常", ex);
return ApiResponse.error(ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getMessage());
}
/**
* 格式化字段错误信息。
*
* @param error 字段错误
* @return "字段: 信息" 形式的文本
*/
private String formatFieldError(FieldError error) {
return error.getField() + ": " + error.getDefaultMessage();
}
}

View File

@@ -0,0 +1,93 @@
package com.example.lab.common.util;
import com.example.lab.common.context.LoginUser;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.config.LabProperties;
import com.example.lab.domain.enums.Role;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 工具:负责登录令牌的签发与解析。
*/
@Component
public class JwtUtils {
private final SecretKey key;
private final long expireMinutes;
/**
* 构造 JWT 工具。
*
* @param properties 业务配置,提供密钥与有效期
*/
public JwtUtils(LabProperties properties) {
byte[] secretBytes = properties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
if (secretBytes.length < 32) {
throw new IllegalStateException("lab.jwt.secret 长度必须不少于 32 字节");
}
this.key = Keys.hmacShaKeyFor(secretBytes);
this.expireMinutes = properties.getJwt().getExpireMinutes();
}
/**
* 为登录用户签发令牌。
*
* @param user 登录用户
* @return JWT 字符串
*/
public String generateToken(LoginUser user) {
Date now = new Date();
Date expiration = new Date(now.getTime() + expireMinutes * 60_000L);
return Jwts.builder()
.subject(String.valueOf(user.id()))
.claim("username", user.username())
.claim("realName", user.realName())
.claim("role", user.role().name())
.issuedAt(now)
.expiration(expiration)
.signWith(key)
.compact();
}
/**
* 解析令牌并还原登录用户。
*
* @param token JWT 字符串
* @return 登录用户
* @throws BizException 令牌非法或已过期时抛出 401
*/
public LoginUser parseToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
return new LoginUser(
Long.valueOf(claims.getSubject()),
claims.get("username", String.class),
claims.get("realName", String.class),
Role.valueOf(claims.get("role", String.class)));
} catch (JwtException | IllegalArgumentException | NullPointerException ex) {
throw new BizException(ErrorCode.UNAUTHORIZED, "登录令牌无效或已过期");
}
}
/**
* 获取令牌有效期(分钟)。
*
* @return 有效期分钟数
*/
public long getExpireMinutes() {
return expireMinutes;
}
}

View File

@@ -0,0 +1,70 @@
package com.example.lab.config;
import com.example.lab.domain.enums.ReservationStatus;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* 预约业务可调参数,对应 application.yaml 中 {@code lab.*} 配置。
*/
@Data
@ConfigurationProperties(prefix = "lab")
public class LabProperties {
/** JWT 相关配置 */
private Jwt jwt = new Jwt();
/** 预约规则配置 */
private Reservation reservation = new Reservation();
/**
* JWT 配置项。
*/
@Data
public static class Jwt {
/** 签名密钥,长度需 >= 32 字节 */
private String secret;
/** 令牌有效期(分钟) */
private long expireMinutes = 720;
}
/**
* 预约规则配置项。
*/
@Data
public static class Reservation {
/** 单次预约最大时长(小时) */
private int maxDurationHours = 8;
/** 最多提前预约天数 */
private int maxAdvanceDays = 60;
/** 允许的过去时间容差(分钟),用于「现在立刻开始」的场景 */
private int backwardToleranceMinutes = 30;
/** 参与冲突检测的预约状态 */
private List<ReservationStatus> blockingStatuses =
List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE);
/** 重复预约系列允许生成的最大场次数 */
private int maxSeriesOccurrences = 60;
/**
* 获取参与冲突检测的状态集合。
*
* @return 状态集合
*/
public Set<ReservationStatus> blockingStatusSet() {
return blockingStatuses == null || blockingStatuses.isEmpty()
? ReservationStatus.DEFAULT_BLOCKING
: EnumSet.copyOf(blockingStatuses);
}
}
}

View File

@@ -0,0 +1,63 @@
package com.example.lab.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 配置:分页插件、乐观锁插件、公共字段自动填充。
*/
@Configuration
public class MybatisPlusConfig {
/**
* 注册 MyBatis-Plus 拦截器链。
*
* @return 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
/**
* 注册公共字段填充器:createdAt / updatedAt 自动写入当前时间。
*
* @return 填充器
*/
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
/**
* 插入时填充创建时间与更新时间。
*
* @param metaObject 目标对象
*/
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
}
/**
* 更新时填充更新时间。
*
* @param metaObject 目标对象
*/
@Override
public void updateFill(MetaObject metaObject) {
strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
};
}
}

View File

@@ -0,0 +1,23 @@
package com.example.lab.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 安全相关 Bean 配置。
*/
@Configuration
public class SecurityConfig {
/**
* 密码编码器:使用 BCrypt(自带盐值,抗彩虹表)。
*
* @return 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,45 @@
package com.example.lab.config;
import com.example.lab.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置:注册认证拦截器与跨域规则。
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
/**
* 注册认证拦截器,覆盖所有 {@code /api/**} 接口,登录接口除外。
*
* @param registry 拦截器注册表
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
}
/**
* 配置跨域,方便前端开发服务器(Vite)直接联调。
*
* @param registry 跨域注册表
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

View File

@@ -0,0 +1,56 @@
package com.example.lab.controller;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.dto.LoginRequest;
import com.example.lab.service.AuthService;
import com.example.lab.vo.LoginResponse;
import com.example.lab.vo.UserVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 认证接口:登录、当前用户、登出。
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 用户登录。
*
* @param request 登录请求
* @return 令牌与用户信息
*/
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.ok(authService.login(request));
}
/**
* 查询当前登录用户。
*
* @return 用户信息
*/
@GetMapping("/me")
public ApiResponse<UserVO> me() {
return ApiResponse.ok(authService.currentUser());
}
/**
* 登出(JWT 无状态,前端丢弃令牌即可,此接口用于审计埋点)。
*
* @return 空响应
*/
@PostMapping("/logout")
public ApiResponse<Void> logout() {
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,136 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.entity.Course;
import com.example.lab.domain.enums.CourseStatus;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.CourseRequest;
import com.example.lab.service.CourseService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 实验课程接口:查询对所有登录用户开放,写操作仅管理员与教师可用。
*/
@RestController
@RequestMapping("/api/courses")
@RequiredArgsConstructor
public class CourseController {
private final CourseService courseService;
/**
* 分页查询课程。
*
* @param page 页码
* @param size 每页大小
* @param keyword 关键字
* @param labId 实验室
* @param status 状态
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<Course>> page(@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long labId,
@RequestParam(required = false) CourseStatus status) {
return ApiResponse.ok(courseService.page(page, size, keyword, labId, status));
}
/**
* 查询全部课程(下拉选项用)。
*
* @return 课程列表
*/
@GetMapping("/all")
public ApiResponse<List<Course>> all() {
return ApiResponse.ok(courseService.listAll());
}
/**
* 查询某教师开设的课程。
*
* @param teacherId 教师 ID
* @return 课程列表
*/
@GetMapping("/by-teacher/{teacherId}")
public ApiResponse<List<Course>> byTeacher(@PathVariable Long teacherId) {
return ApiResponse.ok(courseService.listByTeacher(teacherId));
}
/**
* 查询课程详情。
*
* @param id 课程 ID
* @return 课程
*/
@GetMapping("/{id}")
public ApiResponse<Course> get(@PathVariable Long id) {
return ApiResponse.ok(courseService.get(id));
}
/**
* 新增课程(管理员/教师)。
*
* @param request 新增请求
* @return 新建课程
*/
@PostMapping
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Course> create(@Valid @RequestBody CourseRequest request) {
return ApiResponse.ok(courseService.create(request));
}
/**
* 修改课程(管理员/授课教师)。
*
* @param id 课程 ID
* @param request 修改请求
* @return 更新后的课程
*/
@PutMapping("/{id}")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Course> update(@PathVariable Long id, @Valid @RequestBody CourseRequest request) {
return ApiResponse.ok(courseService.update(id, request));
}
/**
* 结束课程(管理员/授课教师)。
*
* @param id 课程 ID
* @return 更新后的课程
*/
@PatchMapping("/{id}/finish")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Course> finish(@PathVariable Long id) {
return ApiResponse.ok(courseService.finish(id));
}
/**
* 删除课程(管理员/授课教师)。
*
* @param id 课程 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Void> delete(@PathVariable Long id) {
courseService.delete(id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,128 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.entity.Device;
import com.example.lab.domain.enums.DeviceStatus;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.DeviceRequest;
import com.example.lab.dto.StatusChangeRequest;
import com.example.lab.service.DeviceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 设备接口:查询对所有登录用户开放,增删改与状态变更仅管理员可用。
*/
@RestController
@RequestMapping("/api/devices")
@RequiredArgsConstructor
public class DeviceController {
private final DeviceService deviceService;
/**
* 分页查询设备。
*
* @param page 页码
* @param size 每页大小
* @param keyword 关键字
* @param labId 所属实验室
* @param status 状态
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<Device>> page(@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long labId,
@RequestParam(required = false) DeviceStatus status) {
return ApiResponse.ok(deviceService.page(page, size, keyword, labId, status));
}
/**
* 查询实验室下的设备(预约表单联动用)。
*
* @param labId 实验室 ID
* @return 设备列表
*/
@GetMapping("/by-lab/{labId}")
public ApiResponse<List<Device>> byLab(@PathVariable Long labId) {
return ApiResponse.ok(deviceService.listByLab(labId));
}
/**
* 查询设备详情。
*
* @param id 设备 ID
* @return 设备
*/
@GetMapping("/{id}")
public ApiResponse<Device> get(@PathVariable Long id) {
return ApiResponse.ok(deviceService.get(id));
}
/**
* 新增设备(管理员)。
*
* @param request 新增请求
* @return 新建设备
*/
@PostMapping
@RequireRole(Role.ADMIN)
public ApiResponse<Device> create(@Valid @RequestBody DeviceRequest request) {
return ApiResponse.ok(deviceService.create(request));
}
/**
* 修改设备(管理员)。
*
* @param id 设备 ID
* @param request 修改请求
* @return 更新后的设备
*/
@PutMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Device> update(@PathVariable Long id, @Valid @RequestBody DeviceRequest request) {
return ApiResponse.ok(deviceService.update(id, request));
}
/**
* 删除设备(管理员)。
*
* @param id 设备 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Void> delete(@PathVariable Long id) {
deviceService.delete(id);
return ApiResponse.ok();
}
/**
* 变更设备状态(管理员)。
*
* @param id 设备 ID
* @param request 状态请求
* @return 更新后的设备
*/
@PatchMapping("/{id}/status")
@RequireRole(Role.ADMIN)
public ApiResponse<Device> changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) {
return ApiResponse.ok(deviceService.changeStatus(id, request.getDeviceStatus()));
}
}

View File

@@ -0,0 +1,143 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.enums.LabStatus;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.LabRequest;
import com.example.lab.dto.StatusChangeRequest;
import com.example.lab.service.LabService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.List;
/**
* 实验室接口:查询对所有登录用户开放,增删改与状态变更仅管理员可用。
*/
@RestController
@RequestMapping("/api/labs")
@RequiredArgsConstructor
public class LabController {
private final LabService labService;
/**
* 分页查询实验室。
*
* @param page 页码
* @param size 每页大小
* @param keyword 关键字
* @param status 状态
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<Lab>> page(@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) LabStatus status) {
return ApiResponse.ok(labService.page(page, size, keyword, status));
}
/**
* 查询全部实验室(下拉选项用)。
*
* @return 实验室列表
*/
@GetMapping("/all")
public ApiResponse<List<Lab>> all() {
return ApiResponse.ok(labService.listAll());
}
/**
* 查询实验室详情。
*
* @param id 实验室 ID
* @return 实验室
*/
@GetMapping("/{id}")
public ApiResponse<Lab> get(@PathVariable Long id) {
return ApiResponse.ok(labService.get(id));
}
/**
* 查询指定时间段内可用的实验室。
*
* @param start 开始时间
* @param end 结束时间
* @param capacity 最少容量
* @return 可用实验室列表
*/
@GetMapping("/available")
public ApiResponse<List<Lab>> available(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end,
@RequestParam(required = false) Integer capacity) {
return ApiResponse.ok(labService.listAvailable(start, end, capacity));
}
/**
* 新增实验室(管理员)。
*
* @param request 新增请求
* @return 新建实验室
*/
@PostMapping
@RequireRole(Role.ADMIN)
public ApiResponse<Lab> create(@Valid @RequestBody LabRequest request) {
return ApiResponse.ok(labService.create(request));
}
/**
* 修改实验室(管理员)。
*
* @param id 实验室 ID
* @param request 修改请求
* @return 更新后的实验室
*/
@PutMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Lab> update(@PathVariable Long id, @Valid @RequestBody LabRequest request) {
return ApiResponse.ok(labService.update(id, request));
}
/**
* 删除实验室(管理员)。
*
* @param id 实验室 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Void> delete(@PathVariable Long id) {
labService.delete(id);
return ApiResponse.ok();
}
/**
* 变更实验室状态(管理员),流转受状态机约束。
*
* @param id 实验室 ID
* @param request 状态请求
* @return 更新后的实验室
*/
@PatchMapping("/{id}/status")
@RequireRole(Role.ADMIN)
public ApiResponse<Lab> changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) {
return ApiResponse.ok(labService.changeStatus(id, request.getLabStatus()));
}
}

View File

@@ -0,0 +1,183 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.enums.Role;
import com.example.lab.domain.conflict.Conflict;
import com.example.lab.dto.ConflictCheckRequest;
import com.example.lab.dto.CourseReservationRequest;
import com.example.lab.dto.RecurringReservationRequest;
import com.example.lab.dto.RejectRequest;
import com.example.lab.dto.ReservationCreateRequest;
import com.example.lab.dto.ReservationQuery;
import com.example.lab.service.ReservationService;
import com.example.lab.vo.ConflictCheckVO;
import com.example.lab.vo.ReservationVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
/**
* 预约接口:单次/多次/课程绑定预约、冲突预检、审批与状态流转、日历视图。
*/
@RestController
@RequestMapping("/api/reservations")
@RequiredArgsConstructor
public class ReservationController {
private final ReservationService reservationService;
/**
* 创建单次预约。
*
* @param request 预约请求
* @return 新建预约
*/
@PostMapping
public ApiResponse<ReservationVO> create(@Valid @RequestBody ReservationCreateRequest request) {
return ApiResponse.ok(reservationService.create(request));
}
/**
* 创建多次预约。
*
* @param request 多次预约请求
* @return 生成的预约列表
*/
@PostMapping("/recurring")
public ApiResponse<List<ReservationVO>> createRecurring(@Valid @RequestBody RecurringReservationRequest request) {
return ApiResponse.ok(reservationService.createRecurring(request));
}
/**
* 创建课程绑定预约(管理员/授课教师)。
*
* @param request 课程预约请求
* @return 生成的预约列表
*/
@PostMapping("/course")
public ApiResponse<List<ReservationVO>> createForCourse(@Valid @RequestBody CourseReservationRequest request) {
return ApiResponse.ok(reservationService.createForCourse(request));
}
/**
* 冲突预检,不落库。
*
* @param request 预检请求
* @return 冲突检查结果
*/
@PostMapping("/check")
public ApiResponse<ConflictCheckVO> check(@Valid @RequestBody ConflictCheckRequest request) {
List<Conflict> conflicts = reservationService.check(request);
return ApiResponse.ok(new ConflictCheckVO(conflicts.isEmpty(), conflicts));
}
/**
* 分页查询预约。
*
* @param query 查询条件
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<ReservationVO>> page(ReservationQuery query) {
return ApiResponse.ok(reservationService.page(query));
}
/**
* 查询预约详情。
*
* @param id 预约 ID
* @return 预约详情
*/
@GetMapping("/{id}")
public ApiResponse<ReservationVO> get(@PathVariable Long id) {
return ApiResponse.ok(reservationService.get(id));
}
/**
* 查询实验室日历。
*
* @param labId 实验室 ID
* @param from 开始日期
* @param to 结束日期
* @return 预约列表
*/
@GetMapping("/calendar")
public ApiResponse<List<ReservationVO>> calendar(
@RequestParam Long labId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return ApiResponse.ok(reservationService.calendar(labId, from, to));
}
/**
* 审批通过(管理员/教师)。
*
* @param id 预约 ID
* @return 更新后的预约
*/
@PostMapping("/{id}/approve")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<ReservationVO> approve(@PathVariable Long id) {
return ApiResponse.ok(reservationService.approve(id));
}
/**
* 驳回预约(管理员/教师)。
*
* @param id 预约 ID
* @param request 驳回请求
* @return 更新后的预约
*/
@PostMapping("/{id}/reject")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<ReservationVO> reject(@PathVariable Long id, @Valid @RequestBody RejectRequest request) {
return ApiResponse.ok(reservationService.reject(id, request.getReason()));
}
/**
* 取消预约(本人或管理员)。
*
* @param id 预约 ID
* @param series 是否取消整个系列
* @return 受影响的预约列表
*/
@PostMapping("/{id}/cancel")
public ApiResponse<List<ReservationVO>> cancel(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean series) {
return ApiResponse.ok(reservationService.cancel(id, series));
}
/**
* 开始使用(本人或管理员)。
*
* @param id 预约 ID
* @return 更新后的预约
*/
@PostMapping("/{id}/start")
public ApiResponse<ReservationVO> start(@PathVariable Long id) {
return ApiResponse.ok(reservationService.start(id));
}
/**
* 完成使用(本人或管理员)。
*
* @param id 预约 ID
* @return 更新后的预约
*/
@PostMapping("/{id}/complete")
public ApiResponse<ReservationVO> complete(@PathVariable Long id) {
return ApiResponse.ok(reservationService.complete(id));
}
}

View File

@@ -0,0 +1,89 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.UserCreateRequest;
import com.example.lab.dto.UserUpdateRequest;
import com.example.lab.service.UserService;
import com.example.lab.vo.UserVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 用户管理接口:仅管理员可访问。
*/
@RestController
@RequestMapping("/api/users")
@RequireRole(Role.ADMIN)
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 查询全部用户。
*
* @return 用户列表
*/
@GetMapping
public ApiResponse<List<UserVO>> list() {
return ApiResponse.ok(userService.list());
}
/**
* 查询用户详情。
*
* @param id 用户 ID
* @return 用户信息
*/
@GetMapping("/{id}")
public ApiResponse<UserVO> get(@PathVariable Long id) {
return ApiResponse.ok(userService.get(id));
}
/**
* 新增用户。
*
* @param request 新增请求
* @return 新建用户
*/
@PostMapping
public ApiResponse<UserVO> create(@Valid @RequestBody UserCreateRequest request) {
return ApiResponse.ok(userService.create(request));
}
/**
* 修改用户。
*
* @param id 用户 ID
* @param request 修改请求
* @return 更新后的用户
*/
@PutMapping("/{id}")
public ApiResponse<UserVO> update(@PathVariable Long id, @RequestBody UserUpdateRequest request) {
return ApiResponse.ok(userService.update(id, request));
}
/**
* 删除用户。
*
* @param id 用户 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,46 @@
package com.example.lab.domain.conflict;
/**
* 一条冲突明细。
*
* @param type 冲突类型
* @param message 可读的冲突描述
* @param slotIndex 请求时间段序号,从 0 开始(与请求 slots 顺序一致)
* @param conflictReservationId 冲突的已有预约 ID,非资源冲突时为 null
*/
public record Conflict(ConflictType type, String message, Integer slotIndex, Long conflictReservationId) {
/**
* 构造不关联已有预约的冲突。
*
* @param type 冲突类型
* @param message 描述
* @param slotIndex 时间段序号
* @return 冲突明细
*/
public static Conflict of(ConflictType type, String message, Integer slotIndex) {
return new Conflict(type, message, slotIndex, null);
}
/**
* 构造关联已有预约的冲突。
*
* @param type 冲突类型
* @param message 描述
* @param slotIndex 时间段序号
* @param reservation 冲突的已有预约 ID
* @return 冲突明细
*/
public static Conflict ofReservation(ConflictType type, String message, Integer slotIndex, Long reservation) {
return new Conflict(type, message, slotIndex, reservation);
}
/**
* 是否与已有预约冲突。
*
* @return true 表示由其他预约引起
*/
public boolean causedByReservation() {
return conflictReservationId != null;
}
}

View File

@@ -0,0 +1,358 @@
package com.example.lab.domain.conflict;
import com.example.lab.config.LabProperties;
import com.example.lab.domain.entity.Course;
import com.example.lab.domain.entity.Device;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.CourseStatus;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* 预约冲突检测器:平台的核心算法组件。
*
* <p>检测分两层:</p>
* <ol>
* <li><b>规则校验</b>:时间区间合法性、是否过去、提前预约上限、单次时长、
* 开放时间、资源状态、容量、课程绑定关系;</li>
* <li><b>区间重叠检测</b>:对每个请求时间段,与已有「占用态」预约做两路归并扫描,
* 时间复杂度 O(n·log n + m·log m + k),其中 k 为实际重叠对数。</li>
* </ol>
*
* <p>本类不访问数据库,所有数据由 {@link ConflictQuery} 传入,便于单元测试与复用。</p>
*/
@Component
@RequiredArgsConstructor
public class ConflictDetector {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final LabProperties properties;
/**
* 执行完整的冲突检测。
*
* @param query 检测入参
* @return 冲突列表,空列表表示校验通过
*/
public List<Conflict> detect(ConflictQuery query) {
Objects.requireNonNull(query, "query 不能为 null");
List<Conflict> conflicts = new ArrayList<>();
List<TimeSlot> slots = query.getRequestedSlots();
if (slots == null || slots.isEmpty()) {
conflicts.add(Conflict.of(ConflictType.INVALID_TIME_RANGE, "预约时间段不能为空", null));
return conflicts;
}
LocalDateTime now = query.getNow() == null ? LocalDateTime.now() : query.getNow();
validateTimeWindow(query, slots, now, conflicts);
validateOpeningHours(query, slots, conflicts);
validateResourceAvailability(query, conflicts);
validateCapacity(query, conflicts);
validateCourseBinding(query, conflicts);
validateInternalOverlap(slots, conflicts);
conflicts.addAll(detectReservationConflicts(query));
return new ArrayList<>(new LinkedHashSet<>(conflicts));
}
/**
* 在请求时间段与已有预约之间执行重叠检测。
*
* <p>算法:两侧分别按开始时间升序排序,使用单调右移的指针 i 跳过
* 「结束时间不晚于当前已有预约开始时间」的请求段,再从 i 向后扫描
* 所有开始时间早于当前已有预约结束时间的请求段并逐一判断是否真正重叠。
* 由于已有预约也按开始时间有序,指针无需回退,整体保持近线性复杂度。</p>
*
* @param requested 请求时间段(可乱序)
* @param existing 已有预约(可乱序,允许包含无关记录)
* @param ignoreId 需要忽略的预约 ID(更新自身时使用)
* @return 重叠匹配列表
*/
public List<Overlap> findOverlaps(List<TimeSlot> requested, List<Reservation> existing, Long ignoreId) {
List<Overlap> overlaps = new ArrayList<>();
if (requested == null || existing == null) {
return overlaps;
}
List<IndexedSlot> slots = new ArrayList<>();
for (int index = 0; index < requested.size(); index++) {
TimeSlot slot = requested.get(index);
if (slot != null && slot.isValid()) {
slots.add(new IndexedSlot(index, slot));
}
}
slots.sort(Comparator.comparing(s -> s.slot().start()));
Set<ReservationStatus> blockingStatuses = properties.getReservation().blockingStatusSet();
List<Reservation> reservations = existing.stream()
.filter(Objects::nonNull)
.filter(r -> r.getId() == null || !r.getId().equals(ignoreId))
.filter(r -> r.getStatus() == null || blockingStatuses.contains(r.getStatus()))
.filter(r -> r.getStartTime() != null && r.getEndTime() != null
&& r.getStartTime().isBefore(r.getEndTime()))
.sorted(Comparator.comparing(Reservation::getStartTime))
.toList();
int pointer = 0;
for (Reservation reservation : reservations) {
TimeSlot reservationSlot = TimeSlot.of(reservation.getStartTime(), reservation.getEndTime());
while (pointer < slots.size() && !slots.get(pointer).slot().end().isAfter(reservationSlot.start())) {
pointer++;
}
for (int j = pointer; j < slots.size() && slots.get(j).slot().start().isBefore(reservationSlot.end()); j++) {
if (reservationSlot.overlapsWith(slots.get(j).slot())) {
overlaps.add(new Overlap(slots.get(j).index(), reservation));
}
}
}
return overlaps;
}
/**
* 校验每个时间段的合法性、过去时间、提前预约上限与单次时长。
*
* @param query 检测入参
* @param slots 请求时间段
* @param now 当前时间
* @param conflicts 冲突收集器
*/
private void validateTimeWindow(ConflictQuery query, List<TimeSlot> slots, LocalDateTime now,
List<Conflict> conflicts) {
int maxDurationHours = properties.getReservation().getMaxDurationHours();
int maxAdvanceDays = properties.getReservation().getMaxAdvanceDays();
int tolerance = properties.getReservation().getBackwardToleranceMinutes();
LocalDateTime earliest = now.minusMinutes(Math.max(tolerance, 0));
LocalDateTime latest = now.plusDays(Math.max(maxAdvanceDays, 0));
for (int index = 0; index < slots.size(); index++) {
TimeSlot slot = slots.get(index);
if (slot == null || !slot.isValid()) {
conflicts.add(Conflict.of(ConflictType.INVALID_TIME_RANGE, "第 " + (index + 1) + " 个时间段不合法", index));
continue;
}
if (slot.start().isBefore(earliest)) {
conflicts.add(Conflict.of(ConflictType.PAST_TIME,
"第 " + (index + 1) + " 个时间段早于当前时间", index));
}
if (slot.start().isAfter(latest)) {
conflicts.add(Conflict.of(ConflictType.ADVANCE_LIMIT_EXCEEDED,
"最多只能提前 " + maxAdvanceDays + " 天预约", index));
}
long minutes = slot.durationMinutes();
if (minutes > Duration.ofHours(Math.max(maxDurationHours, 0)).toMinutes()) {
conflicts.add(Conflict.of(ConflictType.DURATION_EXCEEDED,
"单次预约不能超过 " + maxDurationHours + " 小时", index));
}
}
}
/**
* 校验时间段是否落在实验室开放时间之内。
*
* @param query 检测入参
* @param slots 请求时间段
* @param conflicts 冲突收集器
*/
private void validateOpeningHours(ConflictQuery query, List<TimeSlot> slots, List<Conflict> conflicts) {
Lab lab = query.getLab();
if (lab == null || lab.getOpenTime() == null || lab.getCloseTime() == null) {
return;
}
LocalTime open = lab.getOpenTime();
LocalTime close = lab.getCloseTime();
for (int index = 0; index < slots.size(); index++) {
TimeSlot slot = slots.get(index);
if (slot == null || !slot.isValid()) {
continue;
}
boolean sameDay = slot.isSameDay();
LocalTime start = slot.start().toLocalTime();
LocalTime end = slot.end().toLocalTime();
boolean endAtMidnight = end.equals(LocalTime.MIDNIGHT);
boolean insideOpen = !start.isBefore(open);
boolean insideClose = endAtMidnight || !end.isAfter(close);
if (!sameDay && !endAtMidnight) {
conflicts.add(Conflict.of(ConflictType.OUTSIDE_OPENING_HOURS,
"暂不支持跨天预约,第 " + (index + 1) + " 个时间段跨天", index));
} else if (!insideOpen || !insideClose) {
conflicts.add(Conflict.of(ConflictType.OUTSIDE_OPENING_HOURS,
"第 " + (index + 1) + " 个时间段不在开放时间 " + open + "~" + close + " 内", index));
}
}
}
/**
* 校验实验室与设备当前状态是否允许预约。
*
* @param query 检测入参
* @param conflicts 冲突收集器
*/
private void validateResourceAvailability(ConflictQuery query, List<Conflict> conflicts) {
Lab lab = query.getLab();
if (lab != null && lab.getStatus() != null && !lab.getStatus().isReservable()) {
conflicts.add(Conflict.of(ConflictType.RESOURCE_UNAVAILABLE,
"实验室「" + lab.getName() + "」当前状态为" + lab.getStatus().getLabel(), null));
}
Device device = query.getDevice();
if (device != null && device.getStatus() != null && !device.getStatus().isReservable()) {
conflicts.add(Conflict.of(ConflictType.RESOURCE_UNAVAILABLE,
"设备「" + device.getName() + "」当前状态为" + device.getStatus().getLabel(), null));
}
}
/**
* 校验参与人数是否超过实验室容量。
*
* @param query 检测入参
* @param conflicts 冲突收集器
*/
private void validateCapacity(ConflictQuery query, List<Conflict> conflicts) {
Lab lab = query.getLab();
if (lab == null) {
return;
}
Integer capacity = lab.getCapacity();
Integer participants = query.getParticipants();
if (capacity != null && participants != null && participants > capacity) {
conflicts.add(Conflict.of(ConflictType.CAPACITY_EXCEEDED,
"参与人数 " + participants + " 超过实验室容量 " + capacity, null));
}
}
/**
* 校验课程绑定预约的关联关系:必须有课程、课程实验室一致、课程未结束。
*
* @param query 检测入参
* @param conflicts 冲突收集器
*/
private void validateCourseBinding(ConflictQuery query, List<Conflict> conflicts) {
if (query.getType() != ReservationType.COURSE) {
return;
}
Course course = query.getCourse();
if (course == null) {
conflicts.add(Conflict.of(ConflictType.COURSE_REQUIRED, "课程绑定预约必须指定课程", null));
return;
}
Lab lab = query.getLab();
if (lab != null && course.getLabId() != null && !course.getLabId().equals(lab.getId())) {
conflicts.add(Conflict.of(ConflictType.COURSE_LAB_MISMATCH,
"课程「" + course.getName() + "」绑定的是其他实验室", null));
}
if (course.getStatus() == CourseStatus.FINISHED) {
conflicts.add(Conflict.of(ConflictType.COURSE_NOT_ACTIVE,
"课程「" + course.getName() + "」已结束", null));
}
if (lab != null && lab.getCapacity() != null && course.getStudentCount() != null
&& course.getStudentCount() > lab.getCapacity()) {
conflicts.add(Conflict.of(ConflictType.CAPACITY_EXCEEDED,
"课程选课人数 " + course.getStudentCount() + " 超过实验室容量 " + lab.getCapacity(), null));
}
}
/**
* 校验请求内部多个时间段是否互相重叠(例如重复规则中同一天出现两次)。
*
* @param slots 请求时间段
* @param conflicts 冲突收集器
*/
private void validateInternalOverlap(List<TimeSlot> slots, List<Conflict> conflicts) {
List<IndexedSlot> valid = new ArrayList<>();
for (int index = 0; index < slots.size(); index++) {
TimeSlot slot = slots.get(index);
if (slot != null && slot.isValid()) {
valid.add(new IndexedSlot(index, slot));
}
}
valid.sort(Comparator.comparing(s -> s.slot().start()));
for (int i = 1; i < valid.size(); i++) {
IndexedSlot previous = valid.get(i - 1);
IndexedSlot current = valid.get(i);
if (previous.slot().overlapsWith(current.slot())) {
conflicts.add(Conflict.of(ConflictType.INTERNAL_OVERLAP,
"第 " + (previous.index() + 1) + " 与第 " + (current.index() + 1) + " 个时间段相互重叠",
current.index()));
}
}
}
/**
* 把与已有预约的重叠匹配转换成带类型的冲突明细。
*
* <p>分类优先级:同实验室 &gt; 同设备 &gt; 同用户,保证一条已有预约最多产生一条冲突。</p>
*
* @param query 检测入参
* @return 冲突列表
*/
private List<Conflict> detectReservationConflicts(ConflictQuery query) {
List<Conflict> conflicts = new ArrayList<>();
for (Overlap overlap : findOverlaps(query.getRequestedSlots(), query.getExistingReservations(),
query.getIgnoreReservationId())) {
Reservation reservation = overlap.reservation();
ConflictType type;
String resource;
if (sameId(query.getLab() == null ? null : query.getLab().getId(), reservation.getLabId())) {
type = ConflictType.LAB_CONFLICT;
resource = "实验室";
} else if (query.getDevice() != null
&& sameId(query.getDevice().getId(), reservation.getDeviceId())) {
type = ConflictType.DEVICE_CONFLICT;
resource = "设备";
} else if (sameId(query.getUserId(), reservation.getUserId())) {
type = ConflictType.USER_CONFLICT;
resource = "本人";
} else {
continue;
}
conflicts.add(Conflict.ofReservation(type,
resource + "在 " + format(reservation) + " 已被预约 #" + reservation.getId(),
overlap.slotIndex(), reservation.getId()));
}
return conflicts;
}
/**
* 判断两个 ID 是否相同且非空。
*
* @param left 左 ID
* @param right 右 ID
* @return true 表示相同
*/
private boolean sameId(Long left, Long right) {
return left != null && left.equals(right);
}
/**
* 格式化已有预约时间用于提示。
*
* @param reservation 预约
* @return 时间文本
*/
private String format(Reservation reservation) {
return TIME_FORMAT.format(reservation.getStartTime()) + "~" + reservation.getEndTime().toLocalTime();
}
/**
* 带原始下标的时间段,排序后仍能定位到请求顺序。
*
* @param index 原始下标
* @param slot 时间段
*/
private record IndexedSlot(int index, TimeSlot slot) {
}
}

View File

@@ -0,0 +1,54 @@
package com.example.lab.domain.conflict;
import com.example.lab.domain.entity.Course;
import com.example.lab.domain.entity.Device;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.ReservationType;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 冲突检测输入参数。
*
* <p>该类是纯粹的入参载体:调用方负责把数据库中的实验室、设备、课程、
* 候选时间段以及可能冲突的已有预约一次性查出来,检测器本身不访问数据库,
* 因此可以非常方便地进行单元测试。</p>
*/
@Data
@Builder
public class ConflictQuery {
/** 请求占用的时间段列表(单次预约只有 1 个,多次/课程预约有 N 个) */
private List<TimeSlot> requestedSlots;
/** 可能参与冲突的已有预约(建议只查候选时间范围内的记录) */
private List<Reservation> existingReservations;
/** 校验时忽略的预约 ID(更新或审批已有预约时排除自身) */
private Long ignoreReservationId;
/** 目标实验室 */
private Lab lab;
/** 目标设备,可为空 */
private Device device;
/** 关联课程,仅课程绑定预约需要 */
private Course course;
/** 参与人数 */
private Integer participants;
/** 发起预约的用户 ID,用于检测「本人时间冲突」 */
private Long userId;
/** 预约类型 */
private ReservationType type;
/** 当前时间,允许注入以便测试 */
private LocalDateTime now;
}

View File

@@ -0,0 +1,43 @@
package com.example.lab.domain.conflict;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 冲突类型字典,每种类型对应一条明确的预约规则。
*/
@Getter
@RequiredArgsConstructor
public enum ConflictType {
/** 时间段为空或 end <= start */
INVALID_TIME_RANGE("时间区间不合法"),
/** 预约时间早于当前时间 */
PAST_TIME("不能预约过去的时间"),
/** 超出最大提前预约天数 */
ADVANCE_LIMIT_EXCEEDED("超出可提前预约的天数上限"),
/** 单次时长超过上限 */
DURATION_EXCEEDED("单次预约时长超过上限"),
/** 落在实验室开放时间之外或跨天 */
OUTSIDE_OPENING_HOURS("不在实验室开放时间内"),
/** 实验室/设备处于维护中等不可预约状态 */
RESOURCE_UNAVAILABLE("资源当前不可预约"),
/** 参与人数超过实验室容量 */
CAPACITY_EXCEEDED("参与人数超过实验室容量"),
/** 课程绑定预约缺少课程 */
COURSE_REQUIRED("课程绑定预约必须关联课程"),
/** 课程与实验室不匹配 */
COURSE_LAB_MISMATCH("课程绑定的实验室与预约实验室不一致"),
/** 课程已结束 */
COURSE_NOT_ACTIVE("课程已结束,不能生成预约"),
/** 请求内部多个时间段彼此重叠 */
INTERNAL_OVERLAP("提交的多个时间段相互重叠"),
/** 与同实验室已有预约冲突 */
LAB_CONFLICT("实验室在该时间段已被占用"),
/** 与同设备已有预约冲突 */
DEVICE_CONFLICT("设备在该时间段已被占用"),
/** 与本人的其他预约冲突 */
USER_CONFLICT("该时间段与本人的其他预约冲突");
private final String label;
}

View File

@@ -0,0 +1,12 @@
package com.example.lab.domain.conflict;
import com.example.lab.domain.entity.Reservation;
/**
* 单次预约请求与已有预约的重叠匹配结果。
*
* @param slotIndex 请求时间段序号,从 0 开始
* @param reservation 与之重叠的已有预约
*/
public record Overlap(int slotIndex, Reservation reservation) {
}

View File

@@ -0,0 +1,154 @@
package com.example.lab.domain.conflict;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.config.LabProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 重复规则展开器:把「每周几 + 起止日期 + 每日时间段」展开成具体的时间段列表。
*
* <p>支持两种星期写法:英文缩写({@code MON,WED,FRI})与 ISO 数字({@code 1,3,5},1 表示周一)。</p>
*/
@Component
@RequiredArgsConstructor
public class RecurrenceExpander {
/** 英文缩写到 DayOfWeek 的映射 */
private static final Set<String> ENGLISH_ALIASES = Set.of(
"MON", "TUES", "TUE", "WED", "THUR", "THU", "FRI", "SAT", "SUN");
private final LabProperties properties;
/**
* 展开重复规则。
*
* @param rule 重复规则
* @return 按时间升序排列的时间段列表
* @throws BizException 规则非法、无有效场次或超过最大场次数时抛出
*/
public List<TimeSlot> expand(RecurrenceRule rule) {
validate(rule);
int maxOccurrences = Math.max(properties.getReservation().getMaxSeriesOccurrences(), 1);
List<TimeSlot> slots = new ArrayList<>();
LocalDate date = rule.startDate();
while (!date.isAfter(rule.endDate())) {
if (rule.weekdays().contains(date.getDayOfWeek())) {
if (slots.size() >= maxOccurrences) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE,
"重复预约场次超过上限 " + maxOccurrences + " 次");
}
slots.add(TimeSlot.of(date.atTime(rule.startTime()), date.atTime(rule.endTime())));
}
date = date.plusDays(1);
}
if (slots.isEmpty()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复规则没有生成任何有效场次");
}
return slots;
}
/**
* 解析星期字符串。
*
* @param text 形如 "MON,WED,FRI"、"1,3,5" 的文本
* @return 星期集合
* @throws BizException 文本为空或包含非法记号时抛出
*/
public Set<DayOfWeek> parseWeekdays(String text) {
if (text == null || text.isBlank()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复星期不能为空");
}
Set<DayOfWeek> weekdays = EnumSet.noneOf(DayOfWeek.class);
for (String token : text.split(",")) {
String normalized = token.trim().toUpperCase(Locale.ROOT);
if (normalized.isEmpty()) {
continue;
}
weekdays.add(parseWeekday(normalized));
}
if (weekdays.isEmpty()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复星期不能为空");
}
return weekdays;
}
/**
* 解析单个星期记号。
*
* @param token 星期记号,如 MON / 1
* @return 星期
* @throws BizException 记号非法时抛出
*/
private DayOfWeek parseWeekday(String token) {
if (token.length() == 1 && Character.isDigit(token.charAt(0))) {
int value = Integer.parseInt(token);
if (value >= 1 && value <= 7) {
return DayOfWeek.of(value);
}
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "非法星期记号: " + token);
}
if (ENGLISH_ALIASES.contains(token)) {
return switch (token) {
case "MON" -> DayOfWeek.MONDAY;
case "TUE", "TUES" -> DayOfWeek.TUESDAY;
case "WED" -> DayOfWeek.WEDNESDAY;
case "THU", "THUR" -> DayOfWeek.THURSDAY;
case "FRI" -> DayOfWeek.FRIDAY;
case "SAT" -> DayOfWeek.SATURDAY;
default -> DayOfWeek.SUNDAY;
};
}
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "非法星期记号: " + token);
}
/**
* 校验重复规则的完整性。
*
* @param rule 重复规则
* @throws BizException 规则不完整或时间区间非法时抛出
*/
private void validate(RecurrenceRule rule) {
if (rule == null || !rule.isComplete()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复规则参数不完整");
}
if (rule.endDate().isBefore(rule.startDate())) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "结束日期不能早于开始日期");
}
LocalTime start = rule.startTime();
LocalTime end = rule.endTime();
if (!start.isBefore(end)) {
throw new BizException(ErrorCode.INVALID_TIME_RANGE, "每日开始时间必须早于结束时间");
}
}
/**
* 按天返回原始日期集合(供调试与统计使用)。
*
* @param rule 重复规则
* @return 命中的日期集合,保持插入顺序
*/
public Set<LocalDate> resolveDates(RecurrenceRule rule) {
validate(rule);
Set<LocalDate> dates = new LinkedHashSet<>();
LocalDate date = rule.startDate();
while (!date.isAfter(rule.endDate())) {
if (rule.weekdays().contains(date.getDayOfWeek())) {
dates.add(date);
}
date = date.plusDays(1);
}
return dates;
}
}

View File

@@ -0,0 +1,33 @@
package com.example.lab.domain.conflict;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Set;
/**
* 重复预约规则:在 {@code [startDate, endDate]} 日期区间内,
* 每个命中的星期产生一个 {@code [startTime, endTime)} 时间段。
*
* @param startDate 重复起始日期(含)
* @param endDate 重复结束日期(含)
* @param weekdays 命中的星期集合
* @param startTime 每次开始时间
* @param endTime 每次结束时间
*/
public record RecurrenceRule(LocalDate startDate,
LocalDate endDate,
Set<DayOfWeek> weekdays,
LocalTime startTime,
LocalTime endTime) {
/**
* 规则是否基本完整(非空字段)。
*
* @return true 表示字段齐全
*/
public boolean isComplete() {
return startDate != null && endDate != null && weekdays != null && !weekdays.isEmpty()
&& startTime != null && endTime != null;
}
}

View File

@@ -0,0 +1,86 @@
package com.example.lab.domain.conflict;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* 左闭右开时间区间 {@code [start, end)},是冲突检测的最小计算单元。
*
* <p>约定:区间长度为 0 或为负均视为非法;相邻区间(前者的 end 等于后者的 start)
* <b>不</b>算冲突,这样可以支持「上一场刚结束、下一场立即开始」的连续排课。</p>
*
* @param start 开始时间(含)
* @param end 结束时间(不含)
*/
public record TimeSlot(LocalDateTime start, LocalDateTime end) {
/**
* 构造时间区间。
*
* @param start 开始时间
* @param end 结束时间
* @return 时间区间
*/
public static TimeSlot of(LocalDateTime start, LocalDateTime end) {
return new TimeSlot(start, end);
}
/**
* 判断区间是否合法。
*
* @return true 表示 start、end 均非空且 start 早于 end
*/
public boolean isValid() {
return start != null && end != null && start.isBefore(end);
}
/**
* 区间时长。
*
* @return 时长
*/
public Duration duration() {
return Duration.between(start, end);
}
/**
* 区间时长(分钟)。
*
* @return 分钟数
*/
public long durationMinutes() {
return duration().toMinutes();
}
/**
* 判断与另一区间是否重叠(左闭右开语义,边界相接不算重叠)。
*
* @param other 另一区间
* @return true 表示存在实际重叠
*/
public boolean overlapsWith(TimeSlot other) {
if (other == null || !isValid() || !other.isValid()) {
return false;
}
return start.isBefore(other.end) && other.start.isBefore(end);
}
/**
* 判断区间是否包含某时刻,采用 {@code [start, end)} 语义。
*
* @param time 时刻
* @return true 表示包含
*/
public boolean contains(LocalDateTime time) {
return time != null && !time.isBefore(start) && time.isBefore(end);
}
/**
* 是否落在同一天内(跨天预约需要单独审批,平台默认不允许)。
*
* @return true 表示开始与结束在同一天
*/
public boolean isSameDay() {
return isValid() && start.toLocalDate().equals(end.toLocalDate());
}
}

View File

@@ -0,0 +1,75 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.CourseStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 实验课程实体。
*
* <p>课程自带周期性排课规则(起止日期 + 星期 + 每日时间段),
* 教师可基于课程一键生成整学期的课程绑定预约。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("course")
public class Course {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 课程编号,全局唯一 */
private String code;
/** 课程名称 */
private String name;
/** 授课教师 ID */
private Long teacherId;
/** 绑定实验室 ID */
private Long labId;
/** 选课人数 */
private Integer studentCount;
/** 课程开始日期 */
private LocalDate startDate;
/** 课程结束日期 */
private LocalDate endDate;
/** 上课星期,如 "MON,WED" */
private String weekdays;
/** 每次上课开始时间 */
private LocalTime startTime;
/** 每次上课结束时间 */
private LocalTime endTime;
/** 课程状态 */
private CourseStatus status;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,55 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.DeviceStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 设备实体,设备必须隶属于某个实验室。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("device")
public class Device {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属实验室 ID */
private Long labId;
/** 设备编号,全局唯一 */
private String code;
/** 设备名称 */
private String name;
/** 设备型号 */
private String model;
/** 描述 */
private String description;
/** 状态:空闲 / 已预约 / 使用中 / 维护中 */
private DeviceStatus status;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,62 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.LabStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 实验室实体。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("lab")
public class Lab {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 实验室编号,全局唯一 */
private String code;
/** 实验室名称 */
private String name;
/** 所在位置 */
private String location;
/** 容纳人数 */
private Integer capacity;
/** 描述 */
private String description;
/** 状态:空闲 / 已预约 / 使用中 / 维护中 */
private LabStatus status;
/** 开放开始时间,如 08:00 */
private LocalTime openTime;
/** 开放结束时间,如 22:00 */
private LocalTime closeTime;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,91 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 预约场次实体,是整个系统的核心业务对象。
*
* <p>冲突检测的最小单位就是一条 {@code Reservation} 的
* [{@code startTime}, {@code endTime}) 左闭右开区间。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("reservation")
public class Reservation {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属系列 ID,单次预约为空 */
private Long seriesId;
/** 预约人 ID */
private Long userId;
/** 实验室 ID */
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 课程 ID,课程绑定预约必填 */
private Long courseId;
/** 预约类型 */
private ReservationType type;
/** 标题 */
private String title;
/** 用途说明 */
private String purpose;
/** 开始时间(含) */
private LocalDateTime startTime;
/** 结束时间(不含) */
private LocalDateTime endTime;
/** 参与人数 */
private Integer participants;
/** 状态 */
private ReservationStatus status;
/** 驳回原因 */
private String rejectReason;
/** 审批人 ID */
private Long approverId;
/** 审批时间 */
private LocalDateTime approvedAt;
/** 乐观锁版本号,防止并发审批覆盖 */
@Version
private Integer version;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,78 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.ReservationType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 重复预约系列实体。
*
* <p>「多次预约」在数据库中拆分为:一条系列记录 + N 条 {@link Reservation} 场次记录,
* 这样每一次占用都是独立的行,冲突检测与审批都可以按统一的时间区间模型处理。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("reservation_series")
public class ReservationSeries {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 发起人 ID */
private Long userId;
/** 实验室 ID */
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 系列类型:多次预约 / 课程绑定预约 */
private ReservationType type;
/** 标题 */
private String title;
/** 用途说明 */
private String purpose;
/** 重复开始日期 */
private LocalDate startDate;
/** 重复结束日期 */
private LocalDate endDate;
/** 重复星期,如 "MON,WED" */
private String weekdays;
/** 每次占用开始时间 */
private LocalTime startTime;
/** 每次占用结束时间 */
private LocalTime endTime;
/** 参与人数 */
private Integer participants;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,58 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.Role;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 系统用户实体(管理员 / 教师 / 学生)。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user")
public class User {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 登录名,全局唯一 */
private String username;
/** BCrypt 加密后的密码 */
private String password;
/** 真实姓名 */
private String realName;
/** 角色 */
private Role role;
/** 邮箱 */
private String email;
/** 联系电话 */
private String phone;
/** 是否启用 */
private Boolean enabled;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,19 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 实验课程状态。
*/
@Getter
@RequiredArgsConstructor
public enum CourseStatus {
/** 进行中,可生成课程绑定预约 */
ACTIVE("进行中"),
/** 已结束 */
FINISHED("已结束");
private final String label;
}

View File

@@ -0,0 +1,32 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 设备状态机中的状态定义,流转规则与 {@link LabStatus} 一致。
*/
@Getter
@RequiredArgsConstructor
public enum DeviceStatus {
/** 空闲,可被预约 */
IDLE("空闲"),
/** 已被预约 */
RESERVED("已预约"),
/** 使用中 */
IN_USE("使用中"),
/** 维护中,不可预约 */
MAINTENANCE("维护中");
private final String label;
/**
* 是否允许被预约。
*
* @return true 表示可预约
*/
public boolean isReservable() {
return this == IDLE || this == RESERVED;
}
}

View File

@@ -0,0 +1,39 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 实验室状态机中的状态定义。
*
* <p>合法流转路径(详见 {@code LabStateMachine}):</p>
* <pre>
* IDLE(空闲) --预约--> RESERVED(已预约) --开始使用--> IN_USE(使用中) --结束--> IDLE
* IDLE --报修--> MAINTENANCE(维护中) --完成--> IDLE
* RESERVED --取消/驳回--> IDLE
* </pre>
*/
@Getter
@RequiredArgsConstructor
public enum LabStatus {
/** 空闲,可被预约 */
IDLE("空闲"),
/** 已被预约(当前时段存在有效预约) */
RESERVED("已预约"),
/** 使用中 */
IN_USE("使用中"),
/** 维护中,不可预约 */
MAINTENANCE("维护中");
private final String label;
/**
* 是否允许被预约。
*
* @return true 表示可预约
*/
public boolean isReservable() {
return this == IDLE || this == RESERVED;
}
}

View File

@@ -0,0 +1,58 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.EnumSet;
import java.util.Set;
/**
* 预约状态机中的状态定义。
*
* <pre>
* PENDING(待审批) --通过--> APPROVED(已通过) --开始--> IN_USE(使用中) --结束--> COMPLETED(已完成)
* PENDING --驳回--> REJECTED(已驳回)
* PENDING/APPROVED --取消--> CANCELLED(已取消)
* </pre>
*/
@Getter
@RequiredArgsConstructor
public enum ReservationStatus {
/** 待审批 */
PENDING("待审批"),
/** 已通过 */
APPROVED("已通过"),
/** 已驳回 */
REJECTED("已驳回"),
/** 已取消 */
CANCELLED("已取消"),
/** 使用中 */
IN_USE("使用中"),
/** 已完成 */
COMPLETED("已完成");
private final String label;
/** 参与时间冲突检测的状态集合:只有这些状态代表资源被实际占用。 */
public static final Set<ReservationStatus> DEFAULT_BLOCKING =
EnumSet.of(PENDING, APPROVED, IN_USE);
/**
* 默认是否占用资源(参与冲突检测)。
*
* @return true 表示占用资源
*/
public boolean isBlocking() {
return DEFAULT_BLOCKING.contains(this);
}
/**
* 是否为终态(不可再流转)。
*
* @return true 表示终态
*/
public boolean isTerminal() {
return this == REJECTED || this == CANCELLED || this == COMPLETED;
}
}

View File

@@ -0,0 +1,21 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 预约类型。
*/
@Getter
@RequiredArgsConstructor
public enum ReservationType {
/** 单次预约:一次提交仅占用一个时间段 */
SINGLE("单次预约"),
/** 多次预约:按周规则批量生成多个时间段(同一系列) */
RECURRING("多次预约"),
/** 课程绑定预约:由实验课程自动展开全部上课时间 */
COURSE("课程绑定预约");
private final String label;
}

View File

@@ -0,0 +1,54 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 系统角色枚举,用于 RBAC 权限控制。
*
* <ul>
* <li>{@link #ADMIN} 管理员:资源、用户、预约的全量管理权限</li>
* <li>{@link #TEACHER} 教师:创建课程/课程绑定预约,审批本课程预约</li>
* <li>{@link #STUDENT} 学生:创建个人预约、查看自己的预约</li>
* </ul>
*/
@Getter
@RequiredArgsConstructor
public enum Role {
/** 管理员 */
ADMIN("管理员"),
/** 教师 */
TEACHER("教师"),
/** 学生 */
STUDENT("学生");
private final String label;
/**
* 是否为管理员。
*
* @return true 表示管理员角色
*/
public boolean isAdmin() {
return this == ADMIN;
}
/**
* 是否为教师。
*
* @return true 表示教师角色
*/
public boolean isTeacher() {
return this == TEACHER;
}
/**
* 是否具备审批资格(管理员或教师)。
*
* @return true 表示可以审批预约
*/
public boolean canApprove() {
return this == ADMIN || this == TEACHER;
}
}

View File

@@ -0,0 +1,31 @@
package com.example.lab.domain.statemachine;
import com.example.lab.domain.enums.DeviceStatus;
import org.springframework.stereotype.Component;
/**
* 设备状态机,迁移规则与 {@link LabStateMachine} 保持一致。
*/
@Component
public class DeviceStateMachine extends EnumStateMachine<DeviceStatus> {
/**
* 构造状态机并注册合法迁移。
*/
public DeviceStateMachine() {
register(DeviceStatus.IDLE, DeviceStatus.RESERVED, DeviceStatus.MAINTENANCE);
register(DeviceStatus.RESERVED, DeviceStatus.IDLE, DeviceStatus.IN_USE);
register(DeviceStatus.IN_USE, DeviceStatus.IDLE);
register(DeviceStatus.MAINTENANCE, DeviceStatus.IDLE);
}
/**
* 提供状态枚举类型。
*
* @return DeviceStatus.class
*/
@Override
protected Class<DeviceStatus> getStateType() {
return DeviceStatus.class;
}
}

View File

@@ -0,0 +1,80 @@
package com.example.lab.domain.statemachine;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
/**
* 通用枚举状态机。
*
* <p>子类在构造时通过 {@link #register(Enum, Enum...)} 声明所有合法迁移,
* 未声明的迁移一律视为非法。</p>
*
* @param <S> 状态枚举类型
*/
public abstract class EnumStateMachine<S extends Enum<S>> {
/** 状态迁移表:from -> 允许到达的 to 集合 */
private final Map<S, Set<S>> transitions = new EnumMap<>(getStateType());
/**
* 返回状态枚举类型,供子类提供。
*
* @return 状态枚举 Class
*/
protected abstract Class<S> getStateType();
/**
* 注册合法迁移。
*
* @param from 起始状态
* @param to 允许到达的一个或多个状态
*/
protected void register(S from, S... to) {
Set<S> targets = transitions.computeIfAbsent(from, key -> EnumSet.noneOf(getStateType()));
Collections.addAll(targets, to);
}
/**
* 判断迁移是否合法。
*
* @param from 起始状态
* @param to 目标状态
* @return true 表示合法
*/
public boolean canTransit(S from, S to) {
if (from == null || to == null) {
return false;
}
return transitions.getOrDefault(from, Set.of()).contains(to);
}
/**
* 断言迁移合法,否则抛出业务异常。
*
* @param from 起始状态
* @param to 目标状态
* @throws BizException 迁移非法时抛出
*/
public void assertTransit(S from, S to) {
if (!canTransit(from, to)) {
throw new BizException(ErrorCode.INVALID_STATE_TRANSITION,
"状态不允许从 " + from + " 流转到 " + to);
}
}
/**
* 查询某状态的所有可达状态。
*
* @param from 起始状态
* @return 可达状态集合(不可变)
*/
public Set<S> nextStates(S from) {
return Collections.unmodifiableSet(transitions.getOrDefault(from, EnumSet.noneOf(getStateType())));
}
}

View File

@@ -0,0 +1,41 @@
package com.example.lab.domain.statemachine;
import com.example.lab.domain.enums.LabStatus;
import org.springframework.stereotype.Component;
/**
* 实验室状态机。
*
* <pre>
* IDLE -> RESERVED, MAINTENANCE
* RESERVED -> IDLE, IN_USE
* IN_USE -> IDLE
* MAINTENANCE -> IDLE
* </pre>
*
* <p>说明:维护操作必须先释放预约(RESERVED -> IDLE),
* 避免出现「有预约却被维护」的脏状态。</p>
*/
@Component
public class LabStateMachine extends EnumStateMachine<LabStatus> {
/**
* 构造状态机并注册合法迁移。
*/
public LabStateMachine() {
register(LabStatus.IDLE, LabStatus.RESERVED, LabStatus.MAINTENANCE);
register(LabStatus.RESERVED, LabStatus.IDLE, LabStatus.IN_USE);
register(LabStatus.IN_USE, LabStatus.IDLE);
register(LabStatus.MAINTENANCE, LabStatus.IDLE);
}
/**
* 提供状态枚举类型。
*
* @return LabStatus.class
*/
@Override
protected Class<LabStatus> getStateType() {
return LabStatus.class;
}
}

View File

@@ -0,0 +1,39 @@
package com.example.lab.domain.statemachine;
import com.example.lab.domain.enums.ReservationStatus;
import org.springframework.stereotype.Component;
/**
* 预约状态机。
*
* <pre>
* PENDING -> APPROVED, REJECTED, CANCELLED
* APPROVED -> IN_USE, CANCELLED, COMPLETED(时间已过而未签到,由定时任务自动完成)
* IN_USE -> COMPLETED
* REJECTED / CANCELLED / COMPLETED 为终态
* </pre>
*/
@Component
public class ReservationStateMachine extends EnumStateMachine<ReservationStatus> {
/**
* 构造状态机并注册合法迁移。
*/
public ReservationStateMachine() {
register(ReservationStatus.PENDING,
ReservationStatus.APPROVED, ReservationStatus.REJECTED, ReservationStatus.CANCELLED);
register(ReservationStatus.APPROVED,
ReservationStatus.IN_USE, ReservationStatus.CANCELLED, ReservationStatus.COMPLETED);
register(ReservationStatus.IN_USE, ReservationStatus.COMPLETED);
}
/**
* 提供状态枚举类型。
*
* @return ReservationStatus.class
*/
@Override
protected Class<ReservationStatus> getStateType() {
return ReservationStatus.class;
}
}

View File

@@ -0,0 +1,56 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.ReservationType;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 预约冲突预检请求:不落库,仅返回冲突明细,供前端表单实时校验。
*/
@Data
public class ConflictCheckRequest {
/** 实验室 ID */
@NotNull(message = "实验室不能为空")
private Long labId;
/** 设备 ID */
private Long deviceId;
/** 课程 ID,仅课程绑定预约需要 */
private Long courseId;
/** 预约类型 */
@NotNull(message = "预约类型不能为空")
private ReservationType type = ReservationType.SINGLE;
/** 参与人数 */
@Min(value = 1, message = "参与人数至少为 1")
private Integer participants;
/** 单次模式:开始时间 */
private LocalDateTime startTime;
/** 单次模式:结束时间 */
private LocalDateTime endTime;
/** 多次模式:开始日期 */
private LocalDate startDate;
/** 多次模式:结束日期 */
private LocalDate endDate;
/** 多次模式:星期 */
private String weekdays;
/** 多次模式:每日开始时间 */
private LocalTime startTimeOfDay;
/** 多次模式:每日结束时间 */
private LocalTime endTimeOfDay;
}

View File

@@ -0,0 +1,56 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalTime;
/**
* 实验课程新增/修改请求。
*/
@Data
public class CourseRequest {
/** 课程编号 */
@NotBlank(message = "课程编号不能为空")
private String code;
/** 课程名称 */
@NotBlank(message = "课程名称不能为空")
private String name;
/** 授课教师 ID,缺省为当前教师 */
private Long teacherId;
/** 绑定实验室 ID */
@NotNull(message = "绑定实验室不能为空")
private Long labId;
/** 选课人数 */
@NotNull(message = "选课人数不能为空")
@Min(value = 1, message = "选课人数至少为 1")
private Integer studentCount;
/** 课程开始日期 */
@NotNull(message = "开始日期不能为空")
private LocalDate startDate;
/** 课程结束日期 */
@NotNull(message = "结束日期不能为空")
private LocalDate endDate;
/** 上课星期,如 "MON,WED" */
@NotBlank(message = "上课星期不能为空")
private String weekdays;
/** 每次上课开始时间 */
@NotNull(message = "上课开始时间不能为空")
private LocalTime startTime;
/** 每次上课结束时间 */
@NotNull(message = "上课结束时间不能为空")
private LocalTime endTime;
}

View File

@@ -0,0 +1,24 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 课程绑定预约请求:以课程自带排课规则生成整期预约。
*/
@Data
public class CourseReservationRequest {
/** 课程 ID */
@NotNull(message = "课程不能为空")
private Long courseId;
/** 指定设备 ID,可为空 */
private Long deviceId;
/** 标题,为空时使用课程名 */
private String title;
/** 用途说明 */
private String purpose;
}

View File

@@ -0,0 +1,30 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 设备新增/修改请求。
*/
@Data
public class DeviceRequest {
/** 所属实验室 ID */
@NotNull(message = "所属实验室不能为空")
private Long labId;
/** 设备编号 */
@NotBlank(message = "设备编号不能为空")
private String code;
/** 设备名称 */
@NotBlank(message = "设备名称不能为空")
private String name;
/** 设备型号 */
private String model;
/** 描述 */
private String description;
}

View File

@@ -0,0 +1,41 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalTime;
/**
* 实验室新增/修改请求。
*/
@Data
public class LabRequest {
/** 实验室编号 */
@NotBlank(message = "实验室编号不能为空")
private String code;
/** 实验室名称 */
@NotBlank(message = "实验室名称不能为空")
private String name;
/** 位置 */
@NotBlank(message = "位置不能为空")
private String location;
/** 容量 */
@NotNull(message = "容量不能为空")
@Min(value = 1, message = "容量至少为 1")
private Integer capacity;
/** 描述 */
private String description;
/** 开放开始时间 */
private LocalTime openTime;
/** 开放结束时间 */
private LocalTime closeTime;
}

View File

@@ -0,0 +1,19 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录请求。
*/
@Data
public class LoginRequest {
/** 登录名 */
@NotBlank(message = "用户名不能为空")
private String username;
/** 密码(明文,传输层由 HTTPS 保障) */
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@@ -0,0 +1,54 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalTime;
/**
* 多次预约请求:按周重复批量生成场次。
*/
@Data
public class RecurringReservationRequest {
/** 实验室 ID */
@NotNull(message = "实验室不能为空")
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 标题 */
@NotBlank(message = "标题不能为空")
private String title;
/** 用途说明 */
private String purpose;
/** 参与人数 */
@Min(value = 1, message = "参与人数至少为 1")
private Integer participants;
/** 重复起始日期 */
@NotNull(message = "重复开始日期不能为空")
private LocalDate startDate;
/** 重复结束日期 */
@NotNull(message = "重复结束日期不能为空")
private LocalDate endDate;
/** 重复星期,如 "MON,WED" */
@NotBlank(message = "重复星期不能为空")
private String weekdays;
/** 每次开始时间 */
@NotNull(message = "开始时间不能为空")
private LocalTime startTime;
/** 每次结束时间 */
@NotNull(message = "结束时间不能为空")
private LocalTime endTime;
}

View File

@@ -0,0 +1,15 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 驳回预约请求。
*/
@Data
public class RejectRequest {
/** 驳回原因 */
@NotBlank(message = "驳回原因不能为空")
private String reason;
}

View File

@@ -0,0 +1,41 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 单次预约请求。
*/
@Data
public class ReservationCreateRequest {
/** 实验室 ID */
@NotNull(message = "实验室不能为空")
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 标题 */
@NotBlank(message = "标题不能为空")
private String title;
/** 用途说明 */
private String purpose;
/** 开始时间 */
@NotNull(message = "开始时间不能为空")
private LocalDateTime startTime;
/** 结束时间 */
@NotNull(message = "结束时间不能为空")
private LocalDateTime endTime;
/** 参与人数 */
@Min(value = 1, message = "参与人数至少为 1")
private Integer participants;
}

View File

@@ -0,0 +1,44 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import lombok.Data;
import java.time.LocalDate;
/**
* 预约分页查询条件。
*/
@Data
public class ReservationQuery {
/** 页码,从 1 开始 */
private long page = 1;
/** 每页大小 */
private long size = 10;
/** 状态过滤 */
private ReservationStatus status;
/** 类型过滤 */
private ReservationType type;
/** 实验室过滤 */
private Long labId;
/** 用户过滤(管理员可用) */
private Long userId;
/** 标题关键字 */
private String keyword;
/** 时间范围-开始日期 */
private LocalDate from;
/** 时间范围-结束日期 */
private LocalDate to;
/** 是否只看自己的预约 */
private boolean mine;
}

View File

@@ -0,0 +1,19 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.DeviceStatus;
import com.example.lab.domain.enums.LabStatus;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 资源状态变更请求(实验室 / 设备共用)。
*/
@Data
public class StatusChangeRequest {
/** 实验室目标状态,设备状态变更时可为空 */
private LabStatus labStatus;
/** 设备目标状态,实验室状态变更时可为空 */
private DeviceStatus deviceStatus;
}

View File

@@ -0,0 +1,35 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.Role;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 新增用户请求(仅管理员)。
*/
@Data
public class UserCreateRequest {
/** 登录名 */
@NotBlank(message = "用户名不能为空")
private String username;
/** 初始密码 */
@NotBlank(message = "密码不能为空")
private String password;
/** 真实姓名 */
@NotBlank(message = "姓名不能为空")
private String realName;
/** 角色 */
@NotNull(message = "角色不能为空")
private Role role;
/** 邮箱 */
private String email;
/** 联系电话 */
private String phone;
}

View File

@@ -0,0 +1,29 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.Role;
import lombok.Data;
/**
* 更新用户请求(仅管理员),字段为 {@code null} 表示不修改。
*/
@Data
public class UserUpdateRequest {
/** 真实姓名 */
private String realName;
/** 角色 */
private Role role;
/** 邮箱 */
private String email;
/** 联系电话 */
private String phone;
/** 是否启用 */
private Boolean enabled;
/** 新密码,为空表示不重置 */
private String password;
}

View File

@@ -0,0 +1,10 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.Course;
/**
* 实验课程表 Mapper:全部查询均可用 MyBatis-Plus 的 QueryWrapper 完成。
*/
public interface CourseMapper extends BaseMapper<Course> {
}

View File

@@ -0,0 +1,10 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.Device;
/**
* 设备表 Mapper:全部查询均可用 MyBatis-Plus 的 QueryWrapper 完成。
*/
public interface DeviceMapper extends BaseMapper<Device> {
}

View File

@@ -0,0 +1,10 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.Lab;
/**
* 实验室表 Mapper:全部查询均可用 MyBatis-Plus 的 QueryWrapper 完成。
*/
public interface LabMapper extends BaseMapper<Lab> {
}

View File

@@ -0,0 +1,44 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.ReservationStatus;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 预约表 Mapper。
*
* <p>约定:基础 CRUD 一律使用 MyBatis-Plus 的 {@link BaseMapper} 与
* {@code LambdaQueryWrapper};只有「带 FOR UPDATE 的动态冲突粗筛」这一条复杂 SQL
* 放在 XML({@code resources/mapper/ReservationMapper.xml})中维护。</p>
*/
public interface ReservationMapper extends BaseMapper<Reservation> {
/**
* 查询可能与目标时间段冲突的预约。
*
* <p>SQL 使用左闭右开区间语义:{@code start_time < end AND end_time > start},
* 因此首尾相接(前一场 end 等于后一场 start)不会被查出。</p>
*
* @param labId 实验室 ID
* @param deviceId 设备 ID,可为空
* @param userId 用户 ID,可为空
* @param start 目标开始时间
* @param end 目标结束时间
* @param ignoreId 需要排除的预约 ID,可为空
* @param statuses 视为占用的状态集合
* @param lock true 时追加 FOR UPDATE 行锁,用于并发创建场景
* @return 候选冲突预约列表,按开始时间升序
*/
List<Reservation> selectConflicting(@Param("labId") Long labId,
@Param("deviceId") Long deviceId,
@Param("userId") Long userId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("ignoreId") Long ignoreId,
@Param("statuses") List<ReservationStatus> statuses,
@Param("lock") boolean lock);
}

View File

@@ -0,0 +1,10 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.ReservationSeries;
/**
* 重复预约系列表 Mapper:全部查询均可用 MyBatis-Plus 的 QueryWrapper 完成。
*/
public interface ReservationSeriesMapper extends BaseMapper<ReservationSeries> {
}

View File

@@ -0,0 +1,10 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.User;
/**
* 用户表 Mapper:全部查询均可用 MyBatis-Plus 的 QueryWrapper 完成。
*/
public interface UserMapper extends BaseMapper<User> {
}

View File

@@ -0,0 +1,122 @@
package com.example.lab.security;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.context.LoginUser;
import com.example.lab.common.context.UserContext;
import com.example.lab.common.util.JwtUtils;
import com.example.lab.domain.enums.Role;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 认证与授权拦截器。
*
* <p>流程:解析 {@code Authorization: Bearer <token>} → 校验签名与有效期 →
* 写入 {@link UserContext} → 根据 {@link RequireRole} 做角色校验。</p>
*/
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
/** 令牌请求头名 */
private static final String AUTH_HEADER = "Authorization";
/** 令牌前缀 */
private static final String BEARER_PREFIX = "Bearer ";
private final JwtUtils jwtUtils;
/**
* 请求进入前完成认证与角色校验。
*
* @param request 请求
* @param response 响应
* @param handler 处理器
* @return true 表示放行
* @throws Exception IO 异常
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String token = resolveToken(request);
if (token == null) {
writeError(response, 401, "未登录或登录已失效");
return false;
}
try {
UserContext.set(jwtUtils.parseToken(token));
} catch (RuntimeException ex) {
writeError(response, 401, "未登录或登录已失效");
return false;
}
RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class);
if (requireRole == null) {
requireRole = handlerMethod.getBeanType().getAnnotation(RequireRole.class);
}
if (requireRole != null) {
Set<Role> allowed = Arrays.stream(requireRole.value()).collect(Collectors.toSet());
LoginUser user = UserContext.requireUser();
if (!allowed.contains(user.role())) {
writeError(response, 403, "无权限执行该操作");
return false;
}
}
return true;
}
/**
* 请求完成后清理线程上下文。
*
* @param request 请求
* @param response 响应
* @param handler 处理器
* @param ex 异常
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContext.clear();
}
/**
* 从请求头中提取 Bearer 令牌。
*
* @param request 请求
* @return 令牌,缺失或格式错误时返回 null
*/
private String resolveToken(HttpServletRequest request) {
String header = request.getHeader(AUTH_HEADER);
if (header == null || !header.startsWith(BEARER_PREFIX)) {
return null;
}
String token = header.substring(BEARER_PREFIX.length()).trim();
return token.isEmpty() ? null : token;
}
/**
* 输出统一格式的错误响应。
*
* @param response 响应
* @param code 业务状态码
* @param message 错误信息
* @throws Exception 写响应异常
*/
private void writeError(HttpServletResponse response, int code, String message) throws Exception {
response.setStatus(code);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":" + code + ",\"message\":\"" + message + "\",\"data\":null}");
}
}

View File

@@ -0,0 +1,64 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.lab.common.context.LoginUser;
import com.example.lab.common.context.UserContext;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.common.util.JwtUtils;
import com.example.lab.domain.entity.User;
import com.example.lab.dto.LoginRequest;
import com.example.lab.mapper.UserMapper;
import com.example.lab.vo.LoginResponse;
import com.example.lab.vo.UserVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
/**
* 认证服务:登录、令牌签发、当前用户查询。
*/
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils;
/**
* 校验账号密码并签发 JWT。
*
* @param request 登录请求
* @return 登录响应(令牌 + 用户信息)
* @throws BizException 用户名不存在、密码错误或账号被禁用时抛出
*/
public LoginResponse login(LoginRequest request) {
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, request.getUsername()));
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new BizException(ErrorCode.LOGIN_FAILED);
}
if (Boolean.FALSE.equals(user.getEnabled())) {
throw new BizException(ErrorCode.USER_DISABLED);
}
LoginUser loginUser = new LoginUser(user.getId(), user.getUsername(), user.getRealName(), user.getRole());
String token = jwtUtils.generateToken(loginUser);
return new LoginResponse(token, jwtUtils.getExpireMinutes(), UserVO.from(user));
}
/**
* 查询当前登录用户的最新信息。
*
* @return 用户信息
* @throws BizException 用户不存在时抛出
*/
public UserVO currentUser() {
Long userId = UserContext.requireUserId();
User user = userMapper.selectById(userId);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return UserVO.from(user);
}
}

View File

@@ -0,0 +1,303 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.lab.common.api.PageResult;
import com.example.lab.common.context.LoginUser;
import com.example.lab.common.context.UserContext;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.domain.conflict.RecurrenceExpander;
import com.example.lab.domain.entity.Course;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.entity.User;
import com.example.lab.domain.enums.CourseStatus;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.CourseRequest;
import com.example.lab.mapper.CourseMapper;
import com.example.lab.mapper.LabMapper;
import com.example.lab.mapper.ReservationMapper;
import com.example.lab.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 实验课程服务:课程 CRUD 与课程绑定预约的前置校验。
*/
@Service
@RequiredArgsConstructor
public class CourseService {
private final CourseMapper courseMapper;
private final LabMapper labMapper;
private final UserMapper userMapper;
private final ReservationMapper reservationMapper;
private final RecurrenceExpander recurrenceExpander;
/**
* 分页查询课程。
*
* @param page 页码
* @param size 每页大小
* @param keyword 名称/编号关键字,可为空
* @param labId 实验室过滤,可为空
* @param status 状态过滤,可为空
* @return 分页结果
*/
public PageResult<Course> page(long page, long size, String keyword, Long labId, CourseStatus status) {
LambdaQueryWrapper<Course> wrapper = new LambdaQueryWrapper<Course>().orderByAsc(Course::getId);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w.like(Course::getName, keyword).or().like(Course::getCode, keyword));
}
if (labId != null) {
wrapper.eq(Course::getLabId, labId);
}
if (status != null) {
wrapper.eq(Course::getStatus, status);
}
return PageResult.of(courseMapper.selectPage(new Page<>(page, size), wrapper));
}
/**
* 查询全部课程。
*
* @return 课程列表
*/
public List<Course> listAll() {
return courseMapper.selectList(new LambdaQueryWrapper<Course>().orderByAsc(Course::getId));
}
/**
* 查询某教师开设的课程。
*
* @param teacherId 教师 ID
* @return 课程列表
*/
public List<Course> listByTeacher(Long teacherId) {
return courseMapper.selectList(new LambdaQueryWrapper<Course>()
.eq(Course::getTeacherId, teacherId)
.orderByAsc(Course::getId));
}
/**
* 查询课程详情。
*
* @param id 课程 ID
* @return 课程
* @throws BizException 课程不存在时抛出
*/
public Course get(Long id) {
return requireCourse(id);
}
/**
* 新增课程。
*
* <p>权限规则:教师只能给自己开课;管理员可以指定任意教师。</p>
*
* @param request 新增请求
* @return 新建课程
* @throws BizException 权限不足、编号重复、教师/实验室不存在或参数非法时抛出
*/
@Transactional
public Course create(CourseRequest request) {
LoginUser me = UserContext.requireUser();
Long teacherId = resolveTeacherId(request.getTeacherId(), me);
requireTeacher(teacherId);
Lab lab = requireLab(request.getLabId());
ensureCodeUnique(request.getCode(), null);
validateSchedule(request, lab);
Course course = Course.builder()
.code(request.getCode())
.name(request.getName())
.teacherId(teacherId)
.labId(request.getLabId())
.studentCount(request.getStudentCount())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.weekdays(request.getWeekdays())
.startTime(request.getStartTime())
.endTime(request.getEndTime())
.status(CourseStatus.ACTIVE)
.build();
courseMapper.insert(course);
return course;
}
/**
* 更新课程。
*
* @param id 课程 ID
* @param request 更新请求
* @return 更新后的课程
* @throws BizException 课程不存在、权限不足或参数非法时抛出
*/
@Transactional
public Course update(Long id, CourseRequest request) {
Course course = requireCourse(id);
LoginUser me = UserContext.requireUser();
if (!me.isAdmin() && !me.id().equals(course.getTeacherId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能修改自己开设的课程");
}
Long teacherId = resolveTeacherId(request.getTeacherId(), me);
requireTeacher(teacherId);
Lab lab = requireLab(request.getLabId());
ensureCodeUnique(request.getCode(), id);
validateSchedule(request, lab);
course.setCode(request.getCode());
course.setName(request.getName());
course.setTeacherId(teacherId);
course.setLabId(request.getLabId());
course.setStudentCount(request.getStudentCount());
course.setStartDate(request.getStartDate());
course.setEndDate(request.getEndDate());
course.setWeekdays(request.getWeekdays());
course.setStartTime(request.getStartTime());
course.setEndTime(request.getEndTime());
courseMapper.updateById(course);
return course;
}
/**
* 结束课程(状态流转为 FINISHED)。
*
* @param id 课程 ID
* @return 更新后的课程
* @throws BizException 课程不存在或权限不足时抛出
*/
@Transactional
public Course finish(Long id) {
Course course = requireCourse(id);
LoginUser me = UserContext.requireUser();
if (!me.isAdmin() && !me.id().equals(course.getTeacherId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能结束自己开设的课程");
}
course.setStatus(CourseStatus.FINISHED);
courseMapper.updateById(course);
return course;
}
/**
* 删除课程。
*
* @param id 课程 ID
* @throws BizException 课程不存在或已生成预约时抛出
*/
@Transactional
public void delete(Long id) {
Course course = requireCourse(id);
LoginUser me = UserContext.requireUser();
if (!me.isAdmin() && !me.id().equals(course.getTeacherId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己开设的课程");
}
Long reservations = reservationMapper.selectCount(new LambdaQueryWrapper<Reservation>()
.eq(Reservation::getCourseId, id));
if (reservations != null && reservations > 0) {
throw new BizException(ErrorCode.CONFLICT, "课程已生成预约,无法删除");
}
courseMapper.deleteById(id);
}
/**
* 解析并校验授课教师 ID。
*
* @param requested 请求中的教师 ID,可为空
* @param me 当前用户
* @return 最终教师 ID
* @throws BizException 权限不足时抛出
*/
private Long resolveTeacherId(Long requested, LoginUser me) {
if (me.isAdmin()) {
return requested == null ? me.id() : requested;
}
if (requested != null && !requested.equals(me.id())) {
throw new BizException(ErrorCode.FORBIDDEN, "教师只能创建/修改自己的课程");
}
return me.id();
}
/**
* 校验教师存在且角色合法。
*
* @param teacherId 教师 ID
* @throws BizException 用户不存在或角色不是教师/管理员时抛出
*/
private void requireTeacher(Long teacherId) {
User teacher = userMapper.selectById(teacherId);
if (teacher == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
if (teacher.getRole() != Role.TEACHER && teacher.getRole() != Role.ADMIN) {
throw new BizException(ErrorCode.BAD_REQUEST, "授课人必须是教师或管理员");
}
}
/**
* 校验课程排课参数:日期区间、星期、每日时间段、容量。
*
* @param request 请求
* @param lab 绑定实验室
* @throws BizException 参数非法时抛出
*/
private void validateSchedule(CourseRequest request, Lab lab) {
if (request.getEndDate().isBefore(request.getStartDate())) {
throw new BizException(ErrorCode.BAD_REQUEST, "结束日期不能早于开始日期");
}
if (!request.getStartTime().isBefore(request.getEndTime())) {
throw new BizException(ErrorCode.INVALID_TIME_RANGE, "上课开始时间必须早于结束时间");
}
if (lab.getCapacity() != null && request.getStudentCount() > lab.getCapacity()) {
throw new BizException(ErrorCode.CAPACITY_EXCEEDED,
"选课人数超过实验室容量 " + lab.getCapacity());
}
recurrenceExpander.parseWeekdays(request.getWeekdays());
}
/**
* 校验课程编号唯一。
*
* @param code 编号
* @param excludeId 需要排除的课程 ID
* @throws BizException 编号已存在时抛出
*/
private void ensureCodeUnique(String code, Long excludeId) {
Long count = courseMapper.selectCount(new LambdaQueryWrapper<Course>()
.eq(Course::getCode, code)
.ne(excludeId != null, Course::getId, excludeId));
if (count != null && count > 0) {
throw new BizException(ErrorCode.COURSE_CODE_EXISTS);
}
}
/**
* 加载课程,不存在则抛出 404。
*
* @param id 课程 ID
* @return 课程
*/
private Course requireCourse(Long id) {
Course course = courseMapper.selectById(id);
if (course == null) {
throw new BizException(ErrorCode.COURSE_NOT_FOUND);
}
return course;
}
/**
* 加载实验室,不存在则抛出 404。
*
* @param id 实验室 ID
* @return 实验室
*/
private Lab requireLab(Long id) {
Lab lab = labMapper.selectById(id);
if (lab == null) {
throw new BizException(ErrorCode.LAB_NOT_FOUND);
}
return lab;
}
}

View File

@@ -0,0 +1,213 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.lab.common.api.PageResult;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.domain.entity.Device;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.DeviceStatus;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.statemachine.DeviceStateMachine;
import com.example.lab.dto.DeviceRequest;
import com.example.lab.mapper.DeviceMapper;
import com.example.lab.mapper.LabMapper;
import com.example.lab.mapper.ReservationMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 设备管理服务:增删改查 + 状态机流转 + 按实验室查询。
*/
@Service
@RequiredArgsConstructor
public class DeviceService {
/** 代表资源被占用的预约状态 */
private static final List<ReservationStatus> ACTIVE_STATUSES =
List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE);
private final DeviceMapper deviceMapper;
private final LabMapper labMapper;
private final ReservationMapper reservationMapper;
private final DeviceStateMachine deviceStateMachine;
/**
* 分页查询设备。
*
* @param page 页码
* @param size 每页大小
* @param keyword 名称/编号关键字,可为空
* @param labId 实验室过滤,可为空
* @param status 状态过滤,可为空
* @return 分页结果
*/
public PageResult<Device> page(long page, long size, String keyword, Long labId, DeviceStatus status) {
LambdaQueryWrapper<Device> wrapper = new LambdaQueryWrapper<Device>().orderByAsc(Device::getId);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w.like(Device::getName, keyword).or().like(Device::getCode, keyword));
}
if (labId != null) {
wrapper.eq(Device::getLabId, labId);
}
if (status != null) {
wrapper.eq(Device::getStatus, status);
}
return PageResult.of(deviceMapper.selectPage(new Page<>(page, size), wrapper));
}
/**
* 查询实验室下的全部设备。
*
* @param labId 实验室 ID
* @return 设备列表
*/
public List<Device> listByLab(Long labId) {
return deviceMapper.selectList(new LambdaQueryWrapper<Device>()
.eq(Device::getLabId, labId)
.orderByAsc(Device::getId));
}
/**
* 查询设备详情。
*
* @param id 设备 ID
* @return 设备
* @throws BizException 设备不存在时抛出
*/
public Device get(Long id) {
return requireDevice(id);
}
/**
* 新增设备。
*
* @param request 新增请求
* @return 新建设备
* @throws BizException 编号重复或所属实验室不存在时抛出
*/
@Transactional
public Device create(DeviceRequest request) {
requireLab(request.getLabId());
ensureCodeUnique(request.getCode(), null);
Device device = Device.builder()
.labId(request.getLabId())
.code(request.getCode())
.name(request.getName())
.model(request.getModel())
.description(request.getDescription())
.status(DeviceStatus.IDLE)
.build();
deviceMapper.insert(device);
return device;
}
/**
* 更新设备。
*
* @param id 设备 ID
* @param request 更新请求
* @return 更新后的设备
* @throws BizException 设备/实验室不存在或编号重复时抛出
*/
@Transactional
public Device update(Long id, DeviceRequest request) {
Device device = requireDevice(id);
requireLab(request.getLabId());
ensureCodeUnique(request.getCode(), id);
device.setLabId(request.getLabId());
device.setCode(request.getCode());
device.setName(request.getName());
device.setModel(request.getModel());
device.setDescription(request.getDescription());
deviceMapper.updateById(device);
return device;
}
/**
* 删除设备。
*
* @param id 设备 ID
* @throws BizException 设备不存在或存在占用中的预约时抛出
*/
@Transactional
public void delete(Long id) {
requireDevice(id);
Long active = reservationMapper.selectCount(new LambdaQueryWrapper<Reservation>()
.eq(Reservation::getDeviceId, id)
.in(Reservation::getStatus, ACTIVE_STATUSES));
if (active != null && active > 0) {
throw new BizException(ErrorCode.CONFLICT, "设备存在未结束的预约,无法删除");
}
deviceMapper.deleteById(id);
}
/**
* 变更设备状态,流转必须符合状态机。
*
* @param id 设备 ID
* @param target 目标状态
* @return 更新后的设备
* @throws BizException 设备不存在或状态流转非法时抛出
*/
@Transactional
public Device changeStatus(Long id, DeviceStatus target) {
if (target == null) {
throw new BizException(ErrorCode.BAD_REQUEST, "目标状态不能为空");
}
Device device = requireDevice(id);
deviceStateMachine.assertTransit(device.getStatus(), target);
device.setStatus(target);
deviceMapper.updateById(device);
return device;
}
/**
* 校验设备编号唯一。
*
* @param code 编号
* @param excludeId 需要排除的设备 ID
* @throws BizException 编号已存在时抛出
*/
private void ensureCodeUnique(String code, Long excludeId) {
Long count = deviceMapper.selectCount(new LambdaQueryWrapper<Device>()
.eq(Device::getCode, code)
.ne(excludeId != null, Device::getId, excludeId));
if (count != null && count > 0) {
throw new BizException(ErrorCode.DEVICE_CODE_EXISTS);
}
}
/**
* 加载设备,不存在则抛出 404。
*
* @param id 设备 ID
* @return 设备
*/
private Device requireDevice(Long id) {
Device device = deviceMapper.selectById(id);
if (device == null) {
throw new BizException(ErrorCode.DEVICE_NOT_FOUND);
}
return device;
}
/**
* 加载实验室,不存在则抛出 404。
*
* @param id 实验室 ID
* @return 实验室
*/
private Lab requireLab(Long id) {
Lab lab = labMapper.selectById(id);
if (lab == null) {
throw new BizException(ErrorCode.LAB_NOT_FOUND);
}
return lab;
}
}

View File

@@ -0,0 +1,239 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.lab.common.api.PageResult;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.LabStatus;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.statemachine.LabStateMachine;
import com.example.lab.dto.LabRequest;
import com.example.lab.mapper.LabMapper;
import com.example.lab.mapper.ReservationMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 实验室管理服务:增删改查 + 状态机流转 + 可预约实验室推荐。
*/
@Service
@RequiredArgsConstructor
public class LabService {
/** 代表资源被占用的预约状态 */
private static final List<ReservationStatus> ACTIVE_STATUSES =
List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE);
private final LabMapper labMapper;
private final ReservationMapper reservationMapper;
private final LabStateMachine labStateMachine;
/**
* 分页查询实验室。
*
* @param page 页码
* @param size 每页大小
* @param keyword 名称/编号关键字,可为空
* @param status 状态过滤,可为空
* @return 分页结果
*/
public PageResult<Lab> page(long page, long size, String keyword, LabStatus status) {
LambdaQueryWrapper<Lab> wrapper = new LambdaQueryWrapper<Lab>()
.orderByAsc(Lab::getId);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w.like(Lab::getName, keyword).or().like(Lab::getCode, keyword));
}
if (status != null) {
wrapper.eq(Lab::getStatus, status);
}
return PageResult.of(labMapper.selectPage(new Page<>(page, size), wrapper));
}
/**
* 查询全部实验室。
*
* @return 实验室列表
*/
public List<Lab> listAll() {
return labMapper.selectList(new LambdaQueryWrapper<Lab>().orderByAsc(Lab::getId));
}
/**
* 查询实验室详情。
*
* @param id 实验室 ID
* @return 实验室
* @throws BizException 实验室不存在时抛出
*/
public Lab get(Long id) {
return requireLab(id);
}
/**
* 新增实验室。
*
* @param request 新增请求
* @return 新建实验室
* @throws BizException 编号重复或开放时间非法时抛出
*/
@Transactional
public Lab create(LabRequest request) {
ensureCodeUnique(request.getCode(), null);
validateOpeningHours(request);
Lab lab = Lab.builder()
.code(request.getCode())
.name(request.getName())
.location(request.getLocation())
.capacity(request.getCapacity())
.description(request.getDescription())
.status(LabStatus.IDLE)
.openTime(request.getOpenTime())
.closeTime(request.getCloseTime())
.build();
labMapper.insert(lab);
return lab;
}
/**
* 更新实验室。
*
* @param id 实验室 ID
* @param request 更新请求
* @return 更新后的实验室
* @throws BizException 实验室不存在、编号重复或开放时间非法时抛出
*/
@Transactional
public Lab update(Long id, LabRequest request) {
Lab lab = requireLab(id);
ensureCodeUnique(request.getCode(), id);
validateOpeningHours(request);
lab.setCode(request.getCode());
lab.setName(request.getName());
lab.setLocation(request.getLocation());
lab.setCapacity(request.getCapacity());
lab.setDescription(request.getDescription());
if (request.getOpenTime() != null) {
lab.setOpenTime(request.getOpenTime());
}
if (request.getCloseTime() != null) {
lab.setCloseTime(request.getCloseTime());
}
labMapper.updateById(lab);
return lab;
}
/**
* 删除实验室。
*
* @param id 实验室 ID
* @throws BizException 实验室不存在或存在占用中的预约时抛出
*/
@Transactional
public void delete(Long id) {
requireLab(id);
Long active = reservationMapper.selectCount(new LambdaQueryWrapper<Reservation>()
.eq(Reservation::getLabId, id)
.in(Reservation::getStatus, ACTIVE_STATUSES));
if (active != null && active > 0) {
throw new BizException(ErrorCode.CONFLICT, "实验室存在未结束的预约,无法删除");
}
labMapper.deleteById(id);
}
/**
* 变更实验室状态,流转必须符合状态机。
*
* @param id 实验室 ID
* @param target 目标状态
* @return 更新后的实验室
* @throws BizException 实验室不存在或状态流转非法时抛出
*/
@Transactional
public Lab changeStatus(Long id, LabStatus target) {
if (target == null) {
throw new BizException(ErrorCode.BAD_REQUEST, "目标状态不能为空");
}
Lab lab = requireLab(id);
labStateMachine.assertTransit(lab.getStatus(), target);
lab.setStatus(target);
labMapper.updateById(lab);
return lab;
}
/**
* 查询指定时间段内可用的实验室。
*
* <p>先按状态与容量过滤,再逐个做区间重叠查询;实验室数量有限,
* 该策略实现简单且语义清晰。</p>
*
* @param start 开始时间
* @param end 结束时间
* @param capacity 最少容量,可为空
* @return 可用实验室列表
* @throws BizException 时间区间非法时抛出
*/
public List<Lab> listAvailable(LocalDateTime start, LocalDateTime end, Integer capacity) {
if (start == null || end == null || !start.isBefore(end)) {
throw new BizException(ErrorCode.INVALID_TIME_RANGE);
}
List<Lab> candidates = labMapper.selectList(new LambdaQueryWrapper<Lab>()
.in(Lab::getStatus, LabStatus.IDLE, LabStatus.RESERVED)
.ge(capacity != null, Lab::getCapacity, capacity)
.orderByAsc(Lab::getId));
return candidates.stream()
.filter(lab -> reservationMapper
.selectConflicting(lab.getId(), null, null, start, end, null, ACTIVE_STATUSES, false)
.isEmpty())
.toList();
}
/**
* 校验实验室编号唯一。
*
* @param code 编号
* @param excludeId 需要排除的实验室 ID(更新场景)
* @throws BizException 编号已存在时抛出
*/
private void ensureCodeUnique(String code, Long excludeId) {
Long count = labMapper.selectCount(new LambdaQueryWrapper<Lab>()
.eq(Lab::getCode, code)
.ne(excludeId != null, Lab::getId, excludeId));
if (count != null && count > 0) {
throw new BizException(ErrorCode.LAB_CODE_EXISTS);
}
}
/**
* 校验开放时间合法性。
*
* @param request 请求
* @throws BizException 开始时间不早于结束时间时抛出
*/
private void validateOpeningHours(LabRequest request) {
if (request.getOpenTime() != null && request.getCloseTime() != null
&& !request.getOpenTime().isBefore(request.getCloseTime())) {
throw new BizException(ErrorCode.BAD_REQUEST, "开放开始时间必须早于结束时间");
}
}
/**
* 加载实验室,不存在则抛出 404。
*
* @param id 实验室 ID
* @return 实验室
*/
private Lab requireLab(Long id) {
Lab lab = labMapper.selectById(id);
if (lab == null) {
throw new BizException(ErrorCode.LAB_NOT_FOUND);
}
return lab;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.lab.common.context.UserContext;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.domain.entity.User;
import com.example.lab.dto.UserCreateRequest;
import com.example.lab.dto.UserUpdateRequest;
import com.example.lab.mapper.UserMapper;
import com.example.lab.vo.UserVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 用户管理服务(仅管理员可用,权限由 Controller 的 {@code @RequireRole} 控制)。
*/
@Service
@RequiredArgsConstructor
public class UserService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
/**
* 查询全部用户。
*
* @return 用户列表(按 ID 升序)
*/
public List<UserVO> list() {
return userMapper.selectList(new LambdaQueryWrapper<User>().orderByAsc(User::getId))
.stream()
.map(UserVO::from)
.toList();
}
/**
* 查询用户详情。
*
* @param id 用户 ID
* @return 用户信息
* @throws BizException 用户不存在时抛出
*/
public UserVO get(Long id) {
return UserVO.from(requireUser(id));
}
/**
* 新增用户。
*
* @param request 新增请求
* @return 新建用户信息
* @throws BizException 用户名重复时抛出
*/
@Transactional
public UserVO create(UserCreateRequest request) {
Long exists = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getUsername, request.getUsername()));
if (exists != null && exists > 0) {
throw new BizException(ErrorCode.USERNAME_EXISTS);
}
User user = User.builder()
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.realName(request.getRealName())
.role(request.getRole())
.email(request.getEmail())
.phone(request.getPhone())
.enabled(true)
.build();
userMapper.insert(user);
return UserVO.from(user);
}
/**
* 更新用户信息。
*
* @param id 用户 ID
* @param request 更新请求,null 字段不修改
* @return 更新后的用户信息
* @throws BizException 用户不存在或试图禁用自己时抛出
*/
@Transactional
public UserVO update(Long id, UserUpdateRequest request) {
User user = requireUser(id);
if (request.getRealName() != null) {
user.setRealName(request.getRealName());
}
if (request.getRole() != null) {
user.setRole(request.getRole());
}
if (request.getEmail() != null) {
user.setEmail(request.getEmail());
}
if (request.getPhone() != null) {
user.setPhone(request.getPhone());
}
if (request.getEnabled() != null) {
if (Boolean.FALSE.equals(request.getEnabled()) && id.equals(UserContext.requireUserId())) {
throw new BizException(ErrorCode.FORBIDDEN, "不能禁用当前登录账号");
}
user.setEnabled(request.getEnabled());
}
if (request.getPassword() != null && !request.getPassword().isBlank()) {
user.setPassword(passwordEncoder.encode(request.getPassword()));
}
userMapper.updateById(user);
return UserVO.from(user);
}
/**
* 删除用户。
*
* @param id 用户 ID
* @throws BizException 用户不存在或删除自己时抛出
*/
@Transactional
public void delete(Long id) {
requireUser(id);
if (id.equals(UserContext.requireUserId())) {
throw new BizException(ErrorCode.FORBIDDEN, "不能删除当前登录账号");
}
userMapper.deleteById(id);
}
/**
* 加载用户,不存在则抛出 404。
*
* @param id 用户 ID
* @return 用户实体
*/
private User requireUser(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return user;
}
}

View File

@@ -0,0 +1,21 @@
package com.example.lab.vo;
import com.example.lab.domain.conflict.Conflict;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/**
* 冲突预检响应。
*/
@Data
@AllArgsConstructor
public class ConflictCheckVO {
/** 是否无冲突 */
private boolean passed;
/** 冲突明细 */
private List<Conflict> conflicts;
}

View File

@@ -0,0 +1,21 @@
package com.example.lab.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 登录响应。
*/
@Data
@AllArgsConstructor
public class LoginResponse {
/** JWT 令牌 */
private String token;
/** 令牌有效期(分钟) */
private long expiresIn;
/** 当前用户信息 */
private UserVO user;
}

View File

@@ -0,0 +1,117 @@
package com.example.lab.vo;
import com.example.lab.domain.entity.Reservation;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 预约视图对象:在实体基础上补充可读的名称与状态中文,供前端直接展示。
*/
@Data
@Builder
public class ReservationVO {
/** 预约 ID */
private Long id;
/** 系列 ID */
private Long seriesId;
/** 预约人 ID */
private Long userId;
/** 预约人姓名 */
private String userName;
/** 实验室 ID */
private Long labId;
/** 实验室名称 */
private String labName;
/** 设备 ID */
private Long deviceId;
/** 设备名称 */
private String deviceName;
/** 课程 ID */
private Long courseId;
/** 课程名称 */
private String courseName;
/** 类型 */
private String type;
/** 类型中文 */
private String typeLabel;
/** 标题 */
private String title;
/** 用途 */
private String purpose;
/** 开始时间 */
private LocalDateTime startTime;
/** 结束时间 */
private LocalDateTime endTime;
/** 参与人数 */
private Integer participants;
/** 状态 */
private String status;
/** 状态中文 */
private String statusLabel;
/** 驳回原因 */
private String rejectReason;
/** 审批人 ID */
private Long approverId;
/** 审批人姓名 */
private String approverName;
/** 审批时间 */
private LocalDateTime approvedAt;
/** 创建时间 */
private LocalDateTime createdAt;
/**
* 复制预约实体的基础字段。
*
* @param reservation 预约实体
* @return 视图对象(名称字段待填充)
*/
public static ReservationVO from(Reservation reservation) {
return ReservationVO.builder()
.id(reservation.getId())
.seriesId(reservation.getSeriesId())
.userId(reservation.getUserId())
.labId(reservation.getLabId())
.deviceId(reservation.getDeviceId())
.courseId(reservation.getCourseId())
.type(reservation.getType() == null ? null : reservation.getType().name())
.typeLabel(reservation.getType() == null ? null : reservation.getType().getLabel())
.title(reservation.getTitle())
.purpose(reservation.getPurpose())
.startTime(reservation.getStartTime())
.endTime(reservation.getEndTime())
.participants(reservation.getParticipants())
.status(reservation.getStatus() == null ? null : reservation.getStatus().name())
.statusLabel(reservation.getStatus() == null ? null : reservation.getStatus().getLabel())
.rejectReason(reservation.getRejectReason())
.approverId(reservation.getApproverId())
.approvedAt(reservation.getApprovedAt())
.createdAt(reservation.getCreatedAt())
.build();
}
}

View File

@@ -0,0 +1,57 @@
package com.example.lab.vo;
import com.example.lab.domain.enums.Role;
import com.example.lab.domain.entity.User;
import lombok.Builder;
import lombok.Data;
/**
* 用户信息视图对象(不含密码)。
*/
@Data
@Builder
public class UserVO {
/** 用户 ID */
private Long id;
/** 登录名 */
private String username;
/** 真实姓名 */
private String realName;
/** 角色 */
private Role role;
/** 角色中文名 */
private String roleLabel;
/** 邮箱 */
private String email;
/** 联系电话 */
private String phone;
/** 是否启用 */
private Boolean enabled;
/**
* 由实体转换视图对象。
*
* @param user 用户实体
* @return 视图对象
*/
public static UserVO from(User user) {
return UserVO.builder()
.id(user.getId())
.username(user.getUsername())
.realName(user.getRealName())
.role(user.getRole())
.roleLabel(user.getRole() == null ? null : user.getRole().getLabel())
.email(user.getEmail())
.phone(user.getPhone())
.enabled(user.getEnabled())
.build();
}
}

View File

@@ -0,0 +1,16 @@
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:file:./data/labdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;AUTO_SERVER=TRUE
username: sa
password: ""
sql:
init:
mode: always
schema-locations: classpath:db/schema.sql
data-locations: classpath:db/data.sql
continue-on-error: false
h2:
console:
enabled: true
path: /h2-console

View File

@@ -0,0 +1,12 @@
spring:
datasource:
driver-class-name: org.mariadb.jdbc.Driver
url: jdbc:mariadb://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:lab}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${DB_USER:root}
password: ${DB_PASSWORD:root}
sql:
init:
mode: always
schema-locations: classpath:db/schema-mariadb.sql
data-locations: classpath:db/data-mariadb.sql
continue-on-error: false

View File

@@ -0,0 +1,47 @@
spring:
application:
name: lab
profiles:
active: dev
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mvc:
problemdetails:
enabled: false
server:
port: 8080
mybatis-plus:
# 自定义 SQL 统一放在 XML 中维护
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
global-config:
banner: false
db-config:
id-type: auto
# 业务可调参数
lab:
jwt:
# 生产环境务必通过环境变量 LAB_JWT_SECRET 覆盖
secret: ${LAB_JWT_SECRET:lab-reservation-system-dev-secret-key-please-change-me-2026}
expire-minutes: 720
reservation:
# 单次预约最长时长(小时)
max-duration-hours: 8
# 最多可提前预约天数
max-advance-days: 60
# 最多可预约的历史天数(不允许预约过去;允许少量容差用于「立即开始」场景)
backward-tolerance-minutes: 30
# 视为「占用资源」的预约状态,冲突检测时参与比较
blocking-statuses: PENDING,APPROVED,IN_USE
# 单个重复预约系列生成的最大场次数
max-series-occurrences: 60
logging:
level:
root: info
com.example.lab: info

View File

@@ -0,0 +1,33 @@
-- =====================================================================
-- 演示数据(MariaDB / INSERT IGNORE 保证可重复执行,日期相对今天生成)
-- 账号密码:admin/admin123,teacher1/123456,student1/123456(BCrypt)
-- =====================================================================
INSERT IGNORE INTO sys_user (id, username, password, real_name, role, email, phone, enabled, created_at, updated_at) VALUES
(1, 'admin', '$2y$10$QcMTlPkaXS8uYB4eQpkUSOCVvdpq5YqGy1oVx4lgvsyGxKtEfIA36', '系统管理员', 'ADMIN', 'admin@example.edu', '13800000001', 1, NOW(), NOW()),
(2, 'teacher1', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '张老师', 'TEACHER', 'zhang@example.edu', '13800000002', 1, NOW(), NOW()),
(3, 'teacher2', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '王老师', 'TEACHER', 'wang@example.edu', '13800000003', 1, NOW(), NOW()),
(4, 'student1', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '李同学', 'STUDENT', 'lisi@example.edu', '13800000004', 1, NOW(), NOW()),
(5, 'student2', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '陈同学', 'STUDENT', 'chen@example.edu', '13800000005', 1, NOW(), NOW());
INSERT IGNORE INTO lab (id, code, name, location, capacity, description, status, open_time, close_time, created_at, updated_at) VALUES
(1, 'LAB-A101', '计算机基础实验室', '实验楼 A101', 40, '配备 40 台台式机,适合编程与基础实验', 'IDLE', '08:00', '22:00', NOW(), NOW()),
(2, 'LAB-A102', '网络工程实验室', '实验楼 A102', 24, '配备交换机、路由器与网络测试仪', 'IDLE', '08:00', '22:00', NOW(), NOW()),
(3, 'LAB-B201', '嵌入式与机器人实验室', '实验楼 B201', 16, '配备嵌入式开发板与机械臂', 'MAINTENANCE', '09:00', '18:00', NOW(), NOW());
INSERT IGNORE INTO device (id, lab_id, code, name, model, description, status, created_at, updated_at) VALUES
(1, 1, 'DEV-PC-001', '高性能工作站', 'Dell Precision 3660', '用于深度学习实验', 'IDLE', NOW(), NOW()),
(2, 1, 'DEV-PC-002', '高性能工作站', 'Dell Precision 3660', '用于深度学习实验', 'IDLE', NOW(), NOW()),
(3, 2, 'DEV-SW-001', '三层交换机', 'Cisco C3560', '网络实验专用', 'IDLE', NOW(), NOW()),
(4, 3, 'DEV-ARM-001', '机械臂', 'Dobot MG400', '嵌入式课程使用', 'MAINTENANCE', NOW(), NOW());
INSERT IGNORE INTO course (id, code, name, teacher_id, lab_id, student_count, start_date, end_date, weekdays, start_time, end_time, status, created_at, updated_at) VALUES
(1, 'CS101', '程序设计基础实验', 2, 1, 40, DATE_ADD(CURDATE(), INTERVAL 1 DAY), DATE_ADD(CURDATE(), INTERVAL 45 DAY), 'MON', '14:00', '16:00', 'ACTIVE', NOW(), NOW()),
(2, 'NET201', '计算机网络实验', 3, 2, 24, DATE_ADD(CURDATE(), INTERVAL 1 DAY), DATE_ADD(CURDATE(), INTERVAL 45 DAY), 'TUE', '09:00', '11:00', 'ACTIVE', NOW(), NOW());
INSERT IGNORE INTO reservation (id, series_id, user_id, lab_id, device_id, course_id, type, title, purpose, start_time, end_time, participants, status, reject_reason, approver_id, approved_at, version, created_at, updated_at) VALUES
(1, NULL, 4, 1, NULL, NULL, 'SINGLE', '毕业设计调试', '毕业设计系统联调', DATE_ADD(CURDATE(), INTERVAL 9 HOUR), DATE_ADD(CURDATE(), INTERVAL 11 HOUR), 1, 'APPROVED', NULL, 1, NOW(), 0, NOW(), NOW()),
(2, NULL, 3, 2, 3, NULL, 'SINGLE', '网络设备测试', '交换机配置验证', DATE_ADD(CURDATE(), INTERVAL 14 HOUR), DATE_ADD(CURDATE(), INTERVAL 16 HOUR), 4, 'PENDING', NULL, NULL, NULL, 0, NOW(), NOW()),
(3, NULL, 4, 1, NULL, NULL, 'SINGLE', '算法竞赛训练', '蓝桥杯赛前训练', DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 1 DAY), INTERVAL 9 HOUR), DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 1 DAY), INTERVAL 12 HOUR), 6, 'APPROVED', NULL, 1, NOW(), 0, NOW(), NOW()),
(4, NULL, 2, 1, NULL, NULL, 'SINGLE', '课程补课', '程序设计基础实验补课', DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 2 DAY), INTERVAL 14 HOUR), DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 2 DAY), INTERVAL 16 HOUR), 40, 'APPROVED', NULL, 1, NOW(), 0, NOW(), NOW()),
(5, NULL, 3, 2, NULL, NULL, 'SINGLE', '网络实验答疑', '课后答疑', DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 3 DAY), INTERVAL 10 HOUR), DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 3 DAY), INTERVAL 12 HOUR), 10, 'PENDING', NULL, NULL, NULL, 0, NOW(), NOW());

View File

@@ -0,0 +1,34 @@
-- =====================================================================
-- 演示数据(H2 / 使用 MERGE 保证可重复执行,日期相对今天生成)
-- 账号密码:admin/admin123,teacher1/123456,student1/123456(BCrypt)
-- =====================================================================
MERGE INTO sys_user (id, username, password, real_name, role, email, phone, enabled, created_at, updated_at) KEY (id) VALUES
(1, 'admin', '$2y$10$QcMTlPkaXS8uYB4eQpkUSOCVvdpq5YqGy1oVx4lgvsyGxKtEfIA36', '系统管理员', 'ADMIN', 'admin@example.edu', '13800000001', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, 'teacher1', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '张老师', 'TEACHER', 'zhang@example.edu', '13800000002', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(3, 'teacher2', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '王老师', 'TEACHER', 'wang@example.edu', '13800000003', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(4, 'student1', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '李同学', 'STUDENT', 'lisi@example.edu', '13800000004', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(5, 'student2', '$2y$10$LIftYKsGYtH4Hlx9JhCuqOXigADH9J5NIWdjLGviMre1BCpsPKqT6', '陈同学', 'STUDENT', 'chen@example.edu', '13800000005', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
MERGE INTO lab (id, code, name, location, capacity, description, status, open_time, close_time, created_at, updated_at) KEY (id) VALUES
(1, 'LAB-A101', '计算机基础实验室', '实验楼 A101', 40, '配备 40 台台式机,适合编程与基础实验', 'IDLE', '08:00', '22:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, 'LAB-A102', '网络工程实验室', '实验楼 A102', 24, '配备交换机、路由器与网络测试仪', 'IDLE', '08:00', '22:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(3, 'LAB-B201', '嵌入式与机器人实验室', '实验楼 B201', 16, '配备嵌入式开发板与机械臂', 'MAINTENANCE', '09:00', '18:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
MERGE INTO device (id, lab_id, code, name, model, description, status, created_at, updated_at) KEY (id) VALUES
(1, 1, 'DEV-PC-001', '高性能工作站', 'Dell Precision 3660', '用于深度学习实验', 'IDLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, 1, 'DEV-PC-002', '高性能工作站', 'Dell Precision 3660', '用于深度学习实验', 'IDLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(3, 2, 'DEV-SW-001', '三层交换机', 'Cisco C3560', '网络实验专用', 'IDLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(4, 3, 'DEV-ARM-001', '机械臂', 'Dobot MG400', '嵌入式课程使用', 'MAINTENANCE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
MERGE INTO course (id, code, name, teacher_id, lab_id, student_count, start_date, end_date, weekdays, start_time, end_time, status, created_at, updated_at) KEY (id) VALUES
(1, 'CS101', '程序设计基础实验', 2, 1, 40, DATEADD('DAY', 1, CURRENT_DATE), DATEADD('DAY', 45, CURRENT_DATE), 'MON', '14:00', '16:00', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, 'NET201', '计算机网络实验', 3, 2, 24, DATEADD('DAY', 1, CURRENT_DATE), DATEADD('DAY', 45, CURRENT_DATE), 'TUE', '09:00', '11:00', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- 今天的预约(便于日历视图直接看到效果)
MERGE INTO reservation (id, series_id, user_id, lab_id, device_id, course_id, type, title, purpose, start_time, end_time, participants, status, reject_reason, approver_id, approved_at, version, created_at, updated_at) KEY (id) VALUES
(1, NULL, 4, 1, NULL, NULL, 'SINGLE', '毕业设计调试', '毕业设计系统联调', DATEADD('HOUR', 9, CAST(CURRENT_DATE AS TIMESTAMP)), DATEADD('HOUR', 11, CAST(CURRENT_DATE AS TIMESTAMP)), 1, 'APPROVED', NULL, 1, CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, NULL, 3, 2, 3, NULL, 'SINGLE', '网络设备测试', '交换机配置验证', DATEADD('HOUR', 14, CAST(CURRENT_DATE AS TIMESTAMP)), DATEADD('HOUR', 16, CAST(CURRENT_DATE AS TIMESTAMP)), 4, 'PENDING', NULL, NULL, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(3, NULL, 4, 1, NULL, NULL, 'SINGLE', '算法竞赛训练', '蓝桥杯赛前训练', DATEADD('HOUR', 33, CAST(CURRENT_DATE AS TIMESTAMP)), DATEADD('HOUR', 36, CAST(CURRENT_DATE AS TIMESTAMP)), 6, 'APPROVED', NULL, 1, CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(4, NULL, 2, 1, NULL, NULL, 'SINGLE', '课程补课', '程序设计基础实验补课', DATEADD('HOUR', 62, CAST(CURRENT_DATE AS TIMESTAMP)), DATEADD('HOUR', 64, CAST(CURRENT_DATE AS TIMESTAMP)), 40, 'APPROVED', NULL, 1, CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(5, NULL, 3, 2, NULL, NULL, 'SINGLE', '网络实验答疑', '课后答疑', DATEADD('HOUR', 82, CAST(CURRENT_DATE AS TIMESTAMP)), DATEADD('HOUR', 84, CAST(CURRENT_DATE AS TIMESTAMP)), 10, 'PENDING', NULL, NULL, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

View File

@@ -0,0 +1,115 @@
-- =====================================================================
-- 实验室预约与资源管理平台 - MariaDB/MySQL 建表脚本
-- 用法:mvn spring-boot:run -Dspring-boot.run.profiles=mariadb
-- =====================================================================
CREATE TABLE IF NOT EXISTS sys_user (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
password VARCHAR(100) NOT NULL,
real_name VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
email VARCHAR(128) DEFAULT NULL,
phone VARCHAR(32) DEFAULT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_sys_user_username (username)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS lab (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(64) NOT NULL,
location VARCHAR(128) NOT NULL,
capacity INT NOT NULL DEFAULT 30,
description VARCHAR(512) DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'IDLE',
open_time VARCHAR(8) NOT NULL DEFAULT '08:00',
close_time VARCHAR(8) NOT NULL DEFAULT '22:00',
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_lab_code (code)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS device (
id BIGINT NOT NULL AUTO_INCREMENT,
lab_id BIGINT NOT NULL,
code VARCHAR(32) NOT NULL,
name VARCHAR(64) NOT NULL,
model VARCHAR(64) DEFAULT NULL,
description VARCHAR(512) DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'IDLE',
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_device_code (code),
KEY idx_device_lab (lab_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS course (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
teacher_id BIGINT NOT NULL,
lab_id BIGINT NOT NULL,
student_count INT NOT NULL DEFAULT 0,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
weekdays VARCHAR(32) NOT NULL,
start_time VARCHAR(8) NOT NULL,
end_time VARCHAR(8) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_course_code (code)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS reservation_series (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
lab_id BIGINT NOT NULL,
device_id BIGINT DEFAULT NULL,
type VARCHAR(16) NOT NULL,
title VARCHAR(128) NOT NULL,
purpose VARCHAR(512) DEFAULT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
weekdays VARCHAR(32) NOT NULL,
start_time VARCHAR(8) NOT NULL,
end_time VARCHAR(8) NOT NULL,
participants INT NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS reservation (
id BIGINT NOT NULL AUTO_INCREMENT,
series_id BIGINT DEFAULT NULL,
user_id BIGINT NOT NULL,
lab_id BIGINT NOT NULL,
device_id BIGINT DEFAULT NULL,
course_id BIGINT DEFAULT NULL,
type VARCHAR(16) NOT NULL,
title VARCHAR(128) NOT NULL,
purpose VARCHAR(512) DEFAULT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
participants INT NOT NULL DEFAULT 1,
status VARCHAR(16) NOT NULL,
reject_reason VARCHAR(255) DEFAULT NULL,
approver_id BIGINT DEFAULT NULL,
approved_at DATETIME DEFAULT NULL,
version INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_reservation_lab_time (lab_id, start_time, end_time),
KEY idx_reservation_device_time (device_id, start_time, end_time),
KEY idx_reservation_user_time (user_id, start_time, end_time),
KEY idx_reservation_status (status)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

View File

@@ -0,0 +1,110 @@
-- =====================================================================
-- 实验室预约与资源管理平台 - H2(MySQL 模式) 建表脚本
-- 说明:所有表使用逻辑外键(应用层校验),便于测试与迁移
-- =====================================================================
CREATE TABLE IF NOT EXISTS sys_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password VARCHAR(100) NOT NULL,
real_name VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
email VARCHAR(128),
phone VARCHAR(32),
enabled TINYINT NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME,
CONSTRAINT uk_sys_user_username UNIQUE (username)
);
CREATE TABLE IF NOT EXISTS lab (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL,
name VARCHAR(64) NOT NULL,
location VARCHAR(128) NOT NULL,
capacity INT NOT NULL DEFAULT 30,
description VARCHAR(512),
status VARCHAR(16) NOT NULL DEFAULT 'IDLE',
open_time VARCHAR(8) NOT NULL DEFAULT '08:00',
close_time VARCHAR(8) NOT NULL DEFAULT '22:00',
created_at DATETIME,
updated_at DATETIME,
CONSTRAINT uk_lab_code UNIQUE (code)
);
CREATE TABLE IF NOT EXISTS device (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
lab_id BIGINT NOT NULL,
code VARCHAR(32) NOT NULL,
name VARCHAR(64) NOT NULL,
model VARCHAR(64),
description VARCHAR(512),
status VARCHAR(16) NOT NULL DEFAULT 'IDLE',
created_at DATETIME,
updated_at DATETIME,
CONSTRAINT uk_device_code UNIQUE (code)
);
CREATE TABLE IF NOT EXISTS course (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
teacher_id BIGINT NOT NULL,
lab_id BIGINT NOT NULL,
student_count INT NOT NULL DEFAULT 0,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
weekdays VARCHAR(32) NOT NULL,
start_time VARCHAR(8) NOT NULL,
end_time VARCHAR(8) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME,
updated_at DATETIME,
CONSTRAINT uk_course_code UNIQUE (code)
);
CREATE TABLE IF NOT EXISTS reservation_series (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
lab_id BIGINT NOT NULL,
device_id BIGINT,
type VARCHAR(16) NOT NULL,
title VARCHAR(128) NOT NULL,
purpose VARCHAR(512),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
weekdays VARCHAR(32) NOT NULL,
start_time VARCHAR(8) NOT NULL,
end_time VARCHAR(8) NOT NULL,
participants INT NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS reservation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
series_id BIGINT,
user_id BIGINT NOT NULL,
lab_id BIGINT NOT NULL,
device_id BIGINT,
course_id BIGINT,
type VARCHAR(16) NOT NULL,
title VARCHAR(128) NOT NULL,
purpose VARCHAR(512),
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
participants INT NOT NULL DEFAULT 1,
status VARCHAR(16) NOT NULL,
reject_reason VARCHAR(255),
approver_id BIGINT,
approved_at DATETIME,
version INT NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_reservation_lab_time ON reservation (lab_id, start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_reservation_device_time ON reservation (device_id, start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_reservation_user_time ON reservation (user_id, start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_reservation_status ON reservation (status);
CREATE INDEX IF NOT EXISTS idx_device_lab ON device (lab_id);

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.lab.mapper.ReservationMapper">
<!-- 预约实体字段映射,枚举按名称(VARCHAR)读写 -->
<resultMap id="ReservationResultMap" type="com.example.lab.domain.entity.Reservation">
<id column="id" property="id"/>
<result column="series_id" property="seriesId"/>
<result column="user_id" property="userId"/>
<result column="lab_id" property="labId"/>
<result column="device_id" property="deviceId"/>
<result column="course_id" property="courseId"/>
<result column="type" property="type"/>
<result column="title" property="title"/>
<result column="purpose" property="purpose"/>
<result column="start_time" property="startTime"/>
<result column="end_time" property="endTime"/>
<result column="participants" property="participants"/>
<result column="status" property="status"/>
<result column="reject_reason" property="rejectReason"/>
<result column="approver_id" property="approverId"/>
<result column="approved_at" property="approvedAt"/>
<result column="version" property="version"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<!--
冲突粗筛:利用 (lab_id/device_id/user_id, start_time, end_time) 索引,
只取「实验室或设备或本人」在目标区间内的占用记录,精确重叠判断交给应用层
ConflictDetector 完成。lock=true 时追加 FOR UPDATE 行锁,防止并发双写。
-->
<select id="selectConflicting" resultMap="ReservationResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM reservation
WHERE start_time &lt; #{end}
AND end_time &gt; #{start}
AND status IN
<foreach collection="statuses" item="status" open="(" separator="," close=")">
#{status}
</foreach>
AND (
lab_id = #{labId}
<if test="deviceId != null">
OR device_id = #{deviceId}
</if>
<if test="userId != null">
OR user_id = #{userId}
</if>
)
<if test="ignoreId != null">
AND id &lt;&gt; #{ignoreId}
</if>
<if test="lock">
FOR UPDATE
</if>
</select>
<sql id="Base_Column_List">
id, series_id, user_id, lab_id, device_id, course_id, type, title, purpose,
start_time, end_time, participants, status, reject_reason, approver_id, approved_at,
version, created_at, updated_at
</sql>
</mapper>