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>

View File

@@ -0,0 +1,28 @@
package com.example.lab;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Spring 上下文加载测试:验证数据源、SQL 初始化脚本与容器装配。
*/
@SpringBootTest
class LabApplicationTests {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
@DisplayName("上下文加载成功且演示数据已初始化")
void contextLoads() {
Integer users = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_user", Integer.class);
Integer labs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM lab", Integer.class);
assertThat(users).isPositive();
assertThat(labs).isPositive();
}
}

View File

@@ -0,0 +1,62 @@
package com.example.lab.common.api;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link ApiResponse} 与 {@link PageResult} 单元测试。
*/
class ApiResponseTest {
@Test
@DisplayName("成功响应带数据")
void okWithData() {
ApiResponse<String> response = ApiResponse.ok("data");
assertThat(response.getCode()).isZero();
assertThat(response.getMessage()).isEqualTo("success");
assertThat(response.getData()).isEqualTo("data");
}
@Test
@DisplayName("成功响应无数据")
void okWithoutData() {
ApiResponse<Void> response = ApiResponse.ok();
assertThat(response.getCode()).isZero();
assertThat(response.getData()).isNull();
}
@Test
@DisplayName("错误响应")
void error() {
ApiResponse<Void> response = ApiResponse.error(3002, "冲突");
assertThat(response.getCode()).isEqualTo(3002);
assertThat(response.getMessage()).isEqualTo("冲突");
assertThat(response.getData()).isNull();
}
@Test
@DisplayName("分页结果由 IPage 转换")
void pageResult() {
Page<String> page = new Page<>(2, 5);
page.setRecords(List.of("a", "b"));
page.setTotal(12);
PageResult<String> result = PageResult.of(page);
assertThat(result.getRecords()).containsExactly("a", "b");
assertThat(result.getTotal()).isEqualTo(12);
assertThat(result.getPage()).isEqualTo(2);
assertThat(result.getSize()).isEqualTo(5);
}
@Test
@DisplayName("分页结果无参构造")
void pageResultConstructor() {
PageResult<Integer> result = new PageResult<>(List.of(1), 1, 1, 10);
assertThat(result.getRecords()).containsExactly(1);
assertThat(result.getTotal()).isEqualTo(1);
}
}

View File

@@ -0,0 +1,67 @@
package com.example.lab.common.context;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.domain.enums.Role;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link UserContext} 与 {@link LoginUser} 单元测试。
*/
class UserContextTest {
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("未登录时 get 返回 null,requireUser 抛 401")
void requireUserWithoutLogin() {
assertThat(UserContext.get()).isNull();
assertThatThrownBy(UserContext::requireUser)
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.UNAUTHORIZED);
assertThatThrownBy(UserContext::requireUserId).isInstanceOf(BizException.class);
}
@Test
@DisplayName("登录后可读取用户与 ID,clear 后失效")
void setAndClear() {
LoginUser user = new LoginUser(1L, "admin", "管理员", Role.ADMIN);
UserContext.set(user);
assertThat(UserContext.get()).isEqualTo(user);
assertThat(UserContext.requireUserId()).isEqualTo(1L);
UserContext.clear();
assertThat(UserContext.get()).isNull();
}
@Test
@DisplayName("LoginUser 角色辅助方法")
void loginUserHelpers() {
assertThat(new LoginUser(1L, "a", "a", Role.ADMIN).isAdmin()).isTrue();
assertThat(new LoginUser(1L, "a", "a", Role.TEACHER).isAdmin()).isFalse();
assertThat(new LoginUser(1L, "a", "a", Role.TEACHER).canApprove()).isTrue();
assertThat(new LoginUser(1L, "a", "a", Role.ADMIN).canApprove()).isTrue();
assertThat(new LoginUser(1L, "a", "a", Role.STUDENT).canApprove()).isFalse();
assertThat(new LoginUser(1L, "a", "a", null).canApprove()).isFalse();
assertThat(new LoginUser(1L, "a", "a", null).isAdmin()).isFalse();
}
@Test
@DisplayName("角色枚举辅助方法")
void roleHelpers() {
assertThat(Role.ADMIN.isAdmin()).isTrue();
assertThat(Role.TEACHER.isTeacher()).isTrue();
assertThat(Role.STUDENT.isTeacher()).isFalse();
assertThat(Role.TEACHER.canApprove()).isTrue();
assertThat(Role.STUDENT.canApprove()).isFalse();
assertThat(Role.ADMIN.getLabel()).isEqualTo("管理员");
}
}

View File

@@ -0,0 +1,104 @@
package com.example.lab.common.exception;
import com.example.lab.common.api.ApiResponse;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link GlobalExceptionHandler} 单元测试。
*/
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
@DisplayName("业务异常映射为对应错误码")
void bizException() {
ApiResponse<Void> response = handler.handleBizException(new BizException(ErrorCode.RESERVATION_CONFLICT, "冲突了"));
assertThat(response.getCode()).isEqualTo(3002);
assertThat(response.getMessage()).isEqualTo("冲突了");
}
@Test
@DisplayName("业务异常默认信息")
void bizExceptionDefaultMessage() {
BizException ex = new BizException(ErrorCode.LAB_NOT_FOUND);
assertThat(ex.getMessage()).isEqualTo(ErrorCode.LAB_NOT_FOUND.getMessage());
assertThat(ex.is(ErrorCode.LAB_NOT_FOUND)).isTrue();
assertThat(ex.is(ErrorCode.NOT_FOUND)).isFalse();
}
@Test
@DisplayName("参数校验异常拼接字段错误")
void validationException() throws Exception {
Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("dummy", String.class);
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(
new org.springframework.core.MethodParameter(method, 0),
bindingResult());
ApiResponse<Void> response = handler.handleValidationException(ex);
assertThat(response.getCode()).isEqualTo(400);
assertThat(response.getMessage()).contains("title: 标题不能为空");
}
@Test
@DisplayName("绑定异常拼接字段错误")
void bindException() {
ApiResponse<Void> response = handler.handleBindException(new BindException(bindingResult()));
assertThat(response.getCode()).isEqualTo(400);
assertThat(response.getMessage()).contains("title");
}
@Test
@DisplayName("请求体解析失败返回 400")
void notReadable() {
ApiResponse<Void> response = handler.handleNotReadable(new HttpMessageNotReadableException(
"bad", new org.springframework.mock.http.MockHttpInputMessage(new byte[0])));
assertThat(response.getCode()).isEqualTo(400);
assertThat(response.getMessage()).isEqualTo("请求体格式错误");
}
@Test
@DisplayName("唯一键冲突返回 409")
void duplicateKey() {
ApiResponse<Void> response = handler.handleDuplicateKey(new DuplicateKeyException("dup"));
assertThat(response.getCode()).isEqualTo(409);
}
@Test
@DisplayName("未知异常返回 500 且不泄漏堆栈")
void unknownException() {
ApiResponse<Void> response = handler.handleException(new IllegalStateException("boom"));
assertThat(response.getCode()).isEqualTo(500);
assertThat(response.getMessage()).isEqualTo("服务器内部错误");
}
/**
* 构造带字段错误的绑定结果。
*
* @return 绑定结果
*/
private BeanPropertyBindingResult bindingResult() {
BeanPropertyBindingResult result = new BeanPropertyBindingResult(new Object(), "request");
result.addError(new FieldError("request", "title", "标题不能为空"));
return result;
}
/**
* 占位方法,用于构造 MethodParameter。
*
* @param value 参数
*/
private void dummy(String value) {
// 仅用于反射获取方法签名
}
}

View File

@@ -0,0 +1,80 @@
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link JwtUtils} 单元测试。
*/
class JwtUtilsTest {
private JwtUtils jwtUtils;
@BeforeEach
void setUp() {
LabProperties properties = new LabProperties();
properties.getJwt().setSecret("unit-test-secret-key-0123456789-0123456789");
properties.getJwt().setExpireMinutes(30);
jwtUtils = new JwtUtils(properties);
}
@Test
@DisplayName("签发后可解析出同样的登录用户")
void generateAndParse() {
LoginUser user = new LoginUser(7L, "teacher1", "张老师", Role.TEACHER);
String token = jwtUtils.generateToken(user);
assertThat(token).isNotBlank();
LoginUser parsed = jwtUtils.parseToken(token);
assertThat(parsed).isEqualTo(user);
assertThat(jwtUtils.getExpireMinutes()).isEqualTo(30);
}
@Test
@DisplayName("被篡改的令牌解析失败")
void tamperedToken() {
LoginUser user = new LoginUser(1L, "admin", "管理员", Role.ADMIN);
String token = jwtUtils.generateToken(user);
assertThatThrownBy(() -> jwtUtils.parseToken(token + "x"))
.isInstanceOf(BizException.class)
.hasMessageContaining("无效");
}
@Test
@DisplayName("使用其他密钥签发的令牌解析失败")
void wrongKeyToken() {
LabProperties other = new LabProperties();
other.getJwt().setSecret("another-secret-key-0123456789-0123456789-xx");
String foreign = new JwtUtils(other).generateToken(new LoginUser(1L, "a", "b", Role.STUDENT));
assertThatThrownBy(() -> jwtUtils.parseToken(foreign)).isInstanceOf(BizException.class);
}
@Test
@DisplayName("空令牌与格式错误令牌解析失败")
void invalidToken() {
assertThatThrownBy(() -> jwtUtils.parseToken(null)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> jwtUtils.parseToken("not-a-jwt")).isInstanceOf(BizException.class);
BizException ex = org.junit.jupiter.api.Assertions.assertThrows(BizException.class,
() -> jwtUtils.parseToken("a.b.c"));
assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED);
}
@Test
@DisplayName("密钥过短时拒绝启动")
void shortSecret() {
LabProperties properties = new LabProperties();
properties.getJwt().setSecret("too-short");
assertThatThrownBy(() -> new JwtUtils(properties))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("32");
}
}

View File

@@ -0,0 +1,650 @@
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.DeviceStatus;
import com.example.lab.domain.enums.LabStatus;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link ConflictDetector} 单元测试:覆盖全部规则与区间边界情况。
*/
class ConflictDetectorTest {
/** 固定「当前时间」,保证测试不依赖真实时钟 */
private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 21, 10, 0);
private LabProperties properties;
private ConflictDetector detector;
@BeforeEach
void setUp() {
properties = new LabProperties();
properties.getReservation().setMaxDurationHours(8);
properties.getReservation().setMaxAdvanceDays(60);
properties.getReservation().setBackwardToleranceMinutes(30);
detector = new ConflictDetector(properties);
}
@Nested
@DisplayName("基础规则")
class BaseRules {
@Test
@DisplayName("合法单次预约通过全部校验")
void validRequestPasses() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("时间段为空或为 null 时报非法区间")
void emptySlots() {
assertThat(types(ConflictQuery.builder().requestedSlots(List.of()).build()))
.containsExactly(ConflictType.INVALID_TIME_RANGE);
assertThat(types(ConflictQuery.builder().build()))
.containsExactly(ConflictType.INVALID_TIME_RANGE);
}
@Test
@DisplayName("查询对象为 null 时抛 NPE")
void nullQuery() {
assertThatThrownBy(() -> detector.detect(null)).isInstanceOf(NullPointerException.class);
}
@Test
@DisplayName("end <= start 报非法区间")
void invalidSlotRange() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1), NOW.plusDays(1))).build();
assertThat(types(query)).contains(ConflictType.INVALID_TIME_RANGE);
}
@Test
@DisplayName("包含 null 元素的时间段列表只报该元素的非法区间")
void nullSlotElement() {
List<TimeSlot> slots = new ArrayList<>();
slots.add(null);
slots.add(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)));
ConflictQuery query = baseQuery(slots).build();
assertThat(types(query)).containsExactly(ConflictType.INVALID_TIME_RANGE);
}
@Test
@DisplayName("早于当前时间报 PAST_TIME,容差范围内允许")
void pastTimeAndTolerance() {
ConflictQuery tooEarly = baseQuery(slot(NOW.minusHours(1), NOW.plusHours(1))).build();
assertThat(types(tooEarly)).contains(ConflictType.PAST_TIME);
ConflictQuery tolerated = baseQuery(slot(NOW.minusMinutes(10), NOW.plusHours(1))).build();
assertThat(detector.detect(tolerated)).isEmpty();
}
@Test
@DisplayName("超过提前预约上限报 ADVANCE_LIMIT_EXCEEDED,边界日期允许")
void advanceLimit() {
ConflictQuery tooLate = baseQuery(slot(NOW.plusDays(61).withHour(10), NOW.plusDays(61).withHour(12)))
.build();
assertThat(types(tooLate)).contains(ConflictType.ADVANCE_LIMIT_EXCEEDED);
ConflictQuery boundary = baseQuery(slot(NOW.plusDays(60), NOW.plusDays(60).plusHours(2))).build();
assertThat(detector.detect(boundary)).isEmpty();
}
@Test
@DisplayName("时长超过上限报 DURATION_EXCEEDED,恰好等于上限允许")
void durationLimit() {
ConflictQuery tooLong = baseQuery(slot(NOW.plusDays(1).withHour(8), NOW.plusDays(1).withHour(17))).build();
assertThat(types(tooLong)).contains(ConflictType.DURATION_EXCEEDED);
ConflictQuery boundary = baseQuery(slot(NOW.plusDays(1).withHour(8), NOW.plusDays(1).withHour(16))).build();
assertThat(detector.detect(boundary)).isEmpty();
}
@Test
@DisplayName("开放时间之外报 OUTSIDE_OPENING_HOURS")
void outsideOpeningHours() {
ConflictQuery beforeOpen = queryWithLab(slot(NOW.plusDays(1).withHour(7), NOW.plusDays(1).withHour(9)));
assertThat(types(beforeOpen)).contains(ConflictType.OUTSIDE_OPENING_HOURS);
ConflictQuery afterClose = queryWithLab(slot(NOW.plusDays(1).withHour(21), NOW.plusDays(1).withHour(23)));
assertThat(types(afterClose)).contains(ConflictType.OUTSIDE_OPENING_HOURS);
}
@Test
@DisplayName("跨天预约报 OUTSIDE_OPENING_HOURS")
void crossDay() {
ConflictQuery crossDay = queryWithLab(slot(NOW.plusDays(1).withHour(21), NOW.plusDays(2).withHour(1)));
assertThat(types(crossDay)).contains(ConflictType.OUTSIDE_OPENING_HOURS);
}
@Test
@DisplayName("跨天但结束于零点视为开放时间内")
void crossDayEndingAtMidnight() {
Lab lab = lab(LabStatus.IDLE);
lab.setOpenTime(LocalTime.of(8, 0));
lab.setCloseTime(LocalTime.of(22, 0));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(20), NOW.plusDays(2).withHour(0)))
.lab(lab).build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("实验室缺少开放时间时不校验开放时段")
void labWithoutOpeningHours() {
Lab lab = lab(LabStatus.IDLE);
lab.setOpenTime(null);
lab.setCloseTime(null);
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(5), NOW.plusDays(1).withHour(6)))
.lab(lab).build();
assertThat(detector.detect(query)).isEmpty();
}
}
@Nested
@DisplayName("资源状态与容量")
class ResourceRules {
@Test
@DisplayName("实验室维护中不可预约,RESERVED 状态可继续预约")
void labStatus() {
ConflictQuery maintenance = queryWithLab(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)));
maintenance.setLab(lab(LabStatus.MAINTENANCE));
assertThat(types(maintenance)).contains(ConflictType.RESOURCE_UNAVAILABLE);
ConflictQuery reserved = queryWithLab(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)));
reserved.setLab(lab(LabStatus.RESERVED));
assertThat(detector.detect(reserved)).isEmpty();
}
@Test
@DisplayName("实验室状态为 null 时不拦截")
void labStatusNull() {
ConflictQuery query = queryWithLab(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)));
query.setLab(lab(null));
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("设备维护中不可预约")
void deviceStatus() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)))
.device(device(DeviceStatus.MAINTENANCE))
.build();
assertThat(types(query)).contains(ConflictType.RESOURCE_UNAVAILABLE);
}
@Test
@DisplayName("设备状态为 null 时不拦截")
void deviceStatusNull() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)))
.device(device(null))
.build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("人数超过容量报 CAPACITY_EXCEEDED,人数为空不校验")
void capacity() {
ConflictQuery exceeded = queryWithLab(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)));
exceeded.setParticipants(41);
assertThat(types(exceeded)).contains(ConflictType.CAPACITY_EXCEEDED);
ConflictQuery noParticipants = queryWithLab(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)));
noParticipants.setParticipants(null);
assertThat(detector.detect(noParticipants)).isEmpty();
ConflictQuery noLab = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)))
.lab(null).participants(999).build();
assertThat(detector.detect(noLab)).isEmpty();
}
}
@Nested
@DisplayName("课程绑定校验")
class CourseRules {
@Test
@DisplayName("课程绑定预约缺少课程报 COURSE_REQUIRED")
void courseRequired() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.type(ReservationType.COURSE)
.build();
assertThat(types(query)).contains(ConflictType.COURSE_REQUIRED);
}
@Test
@DisplayName("课程与实验室不一致报 COURSE_LAB_MISMATCH")
void courseLabMismatch() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.type(ReservationType.COURSE)
.course(course(CourseStatus.ACTIVE, 99L, 10))
.build();
assertThat(types(query)).contains(ConflictType.COURSE_LAB_MISMATCH);
}
@Test
@DisplayName("课程已结束报 COURSE_NOT_ACTIVE")
void courseFinished() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.type(ReservationType.COURSE)
.course(course(CourseStatus.FINISHED, 1L, 10))
.build();
assertThat(types(query)).contains(ConflictType.COURSE_NOT_ACTIVE);
}
@Test
@DisplayName("课程人数超容量报 CAPACITY_EXCEEDED")
void courseCapacity() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.type(ReservationType.COURSE)
.course(course(CourseStatus.ACTIVE, 1L, 100))
.build();
assertThat(types(query)).contains(ConflictType.CAPACITY_EXCEEDED);
}
@Test
@DisplayName("课程绑定信息完整时通过")
void courseValid() {
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.type(ReservationType.COURSE)
.course(course(CourseStatus.ACTIVE, 1L, 20))
.build();
assertThat(detector.detect(query)).isEmpty();
}
}
@Nested
@DisplayName("区间重叠检测")
class OverlapRules {
@Test
@DisplayName("请求内部时间段互相重叠报 INTERNAL_OVERLAP")
void internalOverlap() {
List<TimeSlot> slots = List.of(
slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)),
slot(NOW.plusDays(1).withHour(11), NOW.plusDays(1).withHour(13)));
assertThat(types(baseQuery(slots).build())).contains(ConflictType.INTERNAL_OVERLAP);
}
@Test
@DisplayName("内部时间段首尾相接不报重叠")
void internalAdjacent() {
List<TimeSlot> slots = List.of(
slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)),
slot(NOW.plusDays(1).withHour(12), NOW.plusDays(1).withHour(14)));
assertThat(detector.detect(baseQuery(slots).build())).isEmpty();
}
@Test
@DisplayName("同实验室已有预约报 LAB_CONFLICT")
void labConflict() {
Reservation existing = reservation(1L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(11), NOW.plusDays(1).withHour(13));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing))
.build();
List<Conflict> conflicts = detector.detect(query);
assertThat(types(query)).containsExactly(ConflictType.LAB_CONFLICT);
assertThat(conflicts.get(0).conflictReservationId()).isEqualTo(1L);
assertThat(conflicts.get(0).causedByReservation()).isTrue();
}
@Test
@DisplayName("实验室不同但设备相同报 DEVICE_CONFLICT")
void deviceConflict() {
Reservation existing = reservation(1L, 99L, 5L, null, ReservationStatus.PENDING,
NOW.plusDays(1).withHour(11), NOW.plusDays(1).withHour(13));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.device(device(DeviceStatus.IDLE))
.existingReservations(List.of(existing))
.build();
assertThat(types(query)).containsExactly(ConflictType.DEVICE_CONFLICT);
}
@Test
@DisplayName("实验室与设备都不同但属于本人报 USER_CONFLICT")
void userConflict() {
Reservation existing = reservation(1L, 99L, 98L, 4L, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(11), NOW.plusDays(1).withHour(13));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing))
.build();
assertThat(types(query)).containsExactly(ConflictType.USER_CONFLICT);
}
@Test
@DisplayName("完全无关的已有预约被忽略")
void unrelatedIgnored() {
Reservation existing = reservation(1L, 99L, 98L, 77L, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(11), NOW.plusDays(1).withHour(13));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing))
.build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("边界:已有预约结束时间等于请求开始时间不算冲突")
void touchingBefore() {
Reservation existing = reservation(1L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(8), NOW.plusDays(1).withHour(10));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing))
.build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("边界:已有预约开始时间等于请求结束时间不算冲突")
void touchingAfter() {
Reservation existing = reservation(1L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(12), NOW.plusDays(1).withHour(14));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing))
.build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("忽略指定预约 ID(更新场景)")
void ignoreReservation() {
Reservation existing = reservation(7L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing))
.ignoreReservationId(7L)
.build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("已取消/已驳回等非占用状态不参与冲突")
void nonBlockingStatusIgnored() {
for (ReservationStatus status : List.of(ReservationStatus.CANCELLED, ReservationStatus.REJECTED,
ReservationStatus.COMPLETED)) {
Reservation existing = reservation(1L, 1L, null, null, status,
NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing))
.build();
assertThat(detector.detect(query)).as("状态 %s 应被忽略", status).isEmpty();
}
}
@Test
@DisplayName("多条请求时间段时冲突定位到具体序号")
void conflictReportsSlotIndex() {
Reservation existing = reservation(1L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(8).withHour(10), NOW.plusDays(8).withHour(12));
List<TimeSlot> slots = List.of(
slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)),
slot(NOW.plusDays(8).withHour(10), NOW.plusDays(8).withHour(12)));
ConflictQuery query = baseQuery(slots).existingReservations(List.of(existing)).build();
List<Conflict> conflicts = detector.detect(query);
assertThat(conflicts).hasSize(1);
assertThat(conflicts.get(0).slotIndex()).isEqualTo(1);
}
@Test
@DisplayName("重复出现的同一条已有预约只产生一条冲突")
void duplicateExistingDeduplicated() {
Reservation existing = reservation(1L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.existingReservations(List.of(existing, existing))
.build();
assertThat(detector.detect(query)).hasSize(1);
}
@Test
@DisplayName("findOverlaps 容忍 null、非法区间与乱序输入")
void findOverlapsRobust() {
List<TimeSlot> requested = Arrays.asList(
slot(NOW.plusDays(2).withHour(10), NOW.plusDays(2).withHour(12)),
null,
slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)));
Reservation invalidRange = reservation(1L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(12), NOW.plusDays(1).withHour(10));
Reservation valid = reservation(2L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(11), NOW.plusDays(1).withHour(13));
List<Overlap> overlaps = detector.findOverlaps(requested, Arrays.asList(null, invalidRange, valid), null);
assertThat(overlaps).hasSize(1);
assertThat(overlaps.get(0).slotIndex()).isEqualTo(2);
assertThat(overlaps.get(0).reservation().getId()).isEqualTo(2L);
assertThat(detector.findOverlaps(null, null, null)).isEmpty();
assertThat(detector.findOverlaps(requested, List.of(), null)).isEmpty();
}
}
@Nested
@DisplayName("更多边界")
class MoreEdges {
@Test
@DisplayName("实验室缺少结束时间或容量时不校验对应规则")
void labMissingCloseTimeOrCapacity() {
Lab lab = lab(LabStatus.IDLE);
lab.setCloseTime(null);
ConflictQuery lateNight = baseQuery(slot(NOW.plusDays(1).withHour(23), NOW.plusDays(2).withHour(0)))
.lab(lab).build();
assertThat(detector.detect(lateNight)).isEmpty();
Lab noCapacity = lab(LabStatus.IDLE);
noCapacity.setCapacity(null);
ConflictQuery tooMany = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(11)))
.lab(noCapacity).participants(999).build();
assertThat(detector.detect(tooMany)).isEmpty();
}
@Test
@DisplayName("课程未绑定实验室、未设置人数时不校验对应规则")
void courseWithoutLabOrStudents() {
Course course = Course.builder().id(1L).name("课程").labId(null)
.studentCount(null).status(CourseStatus.ACTIVE).build();
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.type(ReservationType.COURSE).course(course).build();
assertThat(detector.detect(query)).isEmpty();
}
@Test
@DisplayName("已有预约缺少 ID、状态或时间时安全跳过")
void existingReservationIncomplete() {
Reservation incomplete = Reservation.builder()
.labId(1L).userId(4L).status(null).build();
assertThat(detector.findOverlaps(
List.of(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12))),
List.of(incomplete), null)).isEmpty();
Reservation noTimes = Reservation.builder()
.id(5L).labId(1L).userId(4L).status(ReservationStatus.APPROVED)
.startTime(null).endTime(null).build();
assertThat(detector.findOverlaps(
List.of(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12))),
List.of(noTimes), null)).isEmpty();
}
@Test
@DisplayName("未指定实验室时仍可按设备维度发现冲突")
void deviceDimensionWithoutLab() {
Reservation existing = reservation(9L, 99L, 5L, 3L, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12));
ConflictQuery query = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.lab(null)
.device(device(DeviceStatus.IDLE))
.existingReservations(List.of(existing))
.build();
assertThat(types(query)).containsExactly(ConflictType.DEVICE_CONFLICT);
}
@Test
@DisplayName("预约与查询都缺少设备/实验室 ID 时安全忽略")
void nullIdsAreSafe() {
Reservation noDevice = reservation(9L, 99L, null, 77L, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12));
ConflictQuery deviceQuery = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.device(device(DeviceStatus.IDLE))
.existingReservations(List.of(noDevice))
.build();
assertThat(detector.detect(deviceQuery)).isEmpty();
Reservation emptyIds = reservation(10L, 1L, null, null, ReservationStatus.APPROVED,
NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12));
ConflictQuery nullQuery = baseQuery(slot(NOW.plusDays(1).withHour(10), NOW.plusDays(1).withHour(12)))
.lab(null).device(null).userId(null)
.existingReservations(List.of(emptyIds))
.build();
assertThat(detector.detect(nullQuery)).isEmpty();
}
}
/**
* 构造不校验数据库的基础查询。
*
* @param slots 请求时间段
* @return 查询构建器
*/
private ConflictQuery.ConflictQueryBuilder baseQuery(List<TimeSlot> slots) {
return ConflictQuery.builder()
.requestedSlots(slots)
.existingReservations(List.of())
.lab(lab(LabStatus.IDLE))
.participants(10)
.type(ReservationType.SINGLE)
.userId(4L)
.now(NOW);
}
/**
* 构造单个时间段的查询。
*
* @param start 开始
* @param end 结束
* @return 查询构建器
*/
private ConflictQuery.ConflictQueryBuilder baseQuery(LocalDateTime start, LocalDateTime end) {
return baseQuery(List.of(slot(start, end)));
}
/**
* 以单个时间段构造查询。
*
* @param slot 时间段
* @return 查询构建器
*/
private ConflictQuery.ConflictQueryBuilder baseQuery(TimeSlot slot) {
return baseQuery(List.of(slot));
}
/**
* 以默认实验室构造查询并立即构建。
*
* @param slot 时间段
* @return 查询
*/
private ConflictQuery queryWithLab(TimeSlot slot) {
return baseQuery(slot).build();
}
/**
* 构造时间段。
*
* @param start 开始
* @param end 结束
* @return 时间段
*/
private TimeSlot slot(LocalDateTime start, LocalDateTime end) {
return TimeSlot.of(start, end);
}
/**
* 构造实验室。
*
* @param status 状态
* @return 实验室
*/
private Lab lab(LabStatus status) {
return Lab.builder()
.id(1L)
.name("计算机基础实验室")
.capacity(40)
.status(status)
.openTime(LocalTime.of(8, 0))
.closeTime(LocalTime.of(22, 0))
.build();
}
/**
* 构造设备。
*
* @param status 状态
* @return 设备
*/
private Device device(DeviceStatus status) {
return Device.builder().id(5L).labId(1L).name("工作站").status(status).build();
}
/**
* 构造课程。
*
* @param status 状态
* @param labId 绑定实验室
* @param students 人数
* @return 课程
*/
private Course course(CourseStatus status, Long labId, int students) {
return Course.builder().id(1L).name("程序设计基础实验").labId(labId)
.studentCount(students).status(status).build();
}
/**
* 构造已有预约。
*
* @param id 预约 ID
* @param labId 实验室 ID
* @param deviceId 设备 ID
* @param userId 用户 ID
* @param status 状态
* @param start 开始
* @param end 结束
* @return 预约
*/
private Reservation reservation(Long id, Long labId, Long deviceId, Long userId,
ReservationStatus status, LocalDateTime start, LocalDateTime end) {
return Reservation.builder()
.id(id).labId(labId).deviceId(deviceId).userId(userId)
.status(status).startTime(start).endTime(end)
.title("已有预约").build();
}
/**
* 提取冲突类型集合。
*
* @param query 查询
* @return 冲突类型集合
*/
private Set<ConflictType> types(ConflictQuery query) {
return detector.detect(query).stream().map(Conflict::type).collect(Collectors.toSet());
}
}

View File

@@ -0,0 +1,157 @@
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link RecurrenceExpander} 单元测试。
*/
class RecurrenceExpanderTest {
private LabProperties properties;
private RecurrenceExpander expander;
@BeforeEach
void setUp() {
properties = new LabProperties();
properties.getReservation().setMaxSeriesOccurrences(10);
expander = new RecurrenceExpander(properties);
}
@Test
@DisplayName("解析英文缩写与 ISO 数字星期,忽略大小写与空格")
void parseWeekdays() {
assertThat(expander.parseWeekdays("MON,WED,FRI"))
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY);
assertThat(expander.parseWeekdays("1,3,5"))
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY);
assertThat(expander.parseWeekdays(" tue , thur , sun "))
.containsExactlyInAnyOrder(DayOfWeek.TUESDAY, DayOfWeek.THURSDAY, DayOfWeek.SUNDAY);
assertThat(expander.parseWeekdays("TUES")).containsExactly(DayOfWeek.TUESDAY);
assertThat(expander.parseWeekdays("7")).containsExactly(DayOfWeek.SUNDAY);
}
@Test
@DisplayName("非法星期记号抛业务异常")
void parseWeekdaysInvalid() {
assertThatThrownBy(() -> expander.parseWeekdays(null))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能为空");
assertThatThrownBy(() -> expander.parseWeekdays(" "))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> expander.parseWeekdays("MON,X"))
.isInstanceOf(BizException.class)
.hasMessageContaining("非法星期记号");
assertThatThrownBy(() -> expander.parseWeekdays("0"))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> expander.parseWeekdays("8"))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> expander.parseWeekdays(",,"))
.isInstanceOf(BizException.class);
}
@Test
@DisplayName("解析星期时忽略空记号")
void parseWeekdaysWithBlankToken() {
assertThat(expander.parseWeekdays("MON,")).containsExactly(DayOfWeek.MONDAY);
assertThat(expander.parseWeekdays(" ,MON , WED")).containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY);
}
@Test
@DisplayName("按周展开:只命中指定星期")
void expandWeekly() {
// 2026-09-21 是周一
RecurrenceRule rule = new RecurrenceRule(LocalDate.of(2026, 9, 21), LocalDate.of(2026, 10, 2),
Set.of(DayOfWeek.MONDAY, DayOfWeek.FRIDAY), LocalTime.of(14, 0), LocalTime.of(16, 0));
List<TimeSlot> slots = expander.expand(rule);
assertThat(slots).hasSize(4);
assertThat(slots.get(0).start()).isEqualTo(LocalDate.of(2026, 9, 21).atTime(14, 0));
assertThat(slots.get(1).start()).isEqualTo(LocalDate.of(2026, 9, 25).atTime(14, 0));
assertThat(slots.get(2).start()).isEqualTo(LocalDate.of(2026, 9, 28).atTime(14, 0));
assertThat(slots.get(3).end()).isEqualTo(LocalDate.of(2026, 10, 2).atTime(16, 0));
}
@Test
@DisplayName("起止日期同一天也能展开")
void expandSingleDay() {
RecurrenceRule rule = new RecurrenceRule(LocalDate.of(2026, 9, 21), LocalDate.of(2026, 9, 21),
Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0), LocalTime.of(10, 0));
assertThat(expander.expand(rule)).hasSize(1);
}
@Test
@DisplayName("超出最大场次数抛业务异常")
void exceedMaxOccurrences() {
RecurrenceRule rule = new RecurrenceRule(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31),
Set.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY),
LocalTime.of(9, 0), LocalTime.of(10, 0));
assertThatThrownBy(() -> expander.expand(rule))
.isInstanceOf(BizException.class)
.hasMessageContaining("超过上限");
}
@Test
@DisplayName("规则不完整或参数非法时抛业务异常")
void invalidRules() {
RecurrenceRule incomplete = new RecurrenceRule(null, LocalDate.of(2026, 9, 21),
Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0), LocalTime.of(10, 0));
assertThatThrownBy(() -> expander.expand(incomplete)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> expander.expand(null)).isInstanceOf(BizException.class);
RecurrenceRule reversed = new RecurrenceRule(LocalDate.of(2026, 9, 22), LocalDate.of(2026, 9, 21),
Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0), LocalTime.of(10, 0));
assertThatThrownBy(() -> expander.expand(reversed))
.isInstanceOf(BizException.class)
.hasMessageContaining("结束日期");
RecurrenceRule badTime = new RecurrenceRule(LocalDate.of(2026, 9, 21), LocalDate.of(2026, 9, 21),
Set.of(DayOfWeek.MONDAY), LocalTime.of(10, 0), LocalTime.of(10, 0));
assertThatThrownBy(() -> expander.expand(badTime))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.INVALID_TIME_RANGE);
}
@Test
@DisplayName("规则命不中任何日期时抛业务异常")
void noOccurrence() {
RecurrenceRule rule = new RecurrenceRule(LocalDate.of(2026, 9, 21), LocalDate.of(2026, 9, 25),
Set.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY), LocalTime.of(9, 0), LocalTime.of(10, 0));
assertThatThrownBy(() -> expander.expand(rule))
.isInstanceOf(BizException.class)
.hasMessageContaining("没有生成任何有效场次");
}
@Test
@DisplayName("resolveDates 返回命中日期集合")
void resolveDates() {
RecurrenceRule rule = new RecurrenceRule(LocalDate.of(2026, 9, 21), LocalDate.of(2026, 9, 28),
Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0), LocalTime.of(10, 0));
assertThat(expander.resolveDates(rule))
.containsExactly(LocalDate.of(2026, 9, 21), LocalDate.of(2026, 9, 28));
}
@Test
@DisplayName("RecurrenceRule.isComplete 判定")
void ruleCompleteness() {
RecurrenceRule complete = new RecurrenceRule(LocalDate.of(2026, 9, 21), LocalDate.of(2026, 9, 28),
Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0), LocalTime.of(10, 0));
assertThat(complete.isComplete()).isTrue();
assertThat(new RecurrenceRule(null, null, Set.of(), null, null).isComplete()).isFalse();
}
}

View File

@@ -0,0 +1,44 @@
package com.example.lab.domain.conflict;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link RecurrenceRule} 与 {@link Conflict} 的细粒度测试。
*/
class RecurrenceRuleTest {
@Test
@DisplayName("isComplete:逐字段缺失都判定为不完整")
void isCompleteBranches() {
LocalDate date = LocalDate.of(2026, 9, 21);
assertThat(new RecurrenceRule(date, date, Set.of(DayOfWeek.MONDAY),
LocalTime.of(9, 0), LocalTime.of(10, 0)).isComplete()).isTrue();
assertThat(new RecurrenceRule(null, date, Set.of(DayOfWeek.MONDAY),
LocalTime.of(9, 0), LocalTime.of(10, 0)).isComplete()).isFalse();
assertThat(new RecurrenceRule(date, null, Set.of(DayOfWeek.MONDAY),
LocalTime.of(9, 0), LocalTime.of(10, 0)).isComplete()).isFalse();
assertThat(new RecurrenceRule(date, date, Set.of(),
LocalTime.of(9, 0), LocalTime.of(10, 0)).isComplete()).isFalse();
assertThat(new RecurrenceRule(date, date, null,
LocalTime.of(9, 0), LocalTime.of(10, 0)).isComplete()).isFalse();
assertThat(new RecurrenceRule(date, date, Set.of(DayOfWeek.MONDAY),
null, LocalTime.of(10, 0)).isComplete()).isFalse();
assertThat(new RecurrenceRule(date, date, Set.of(DayOfWeek.MONDAY),
LocalTime.of(9, 0), null).isComplete()).isFalse();
}
@Test
@DisplayName("Conflict:是否由已有预约引起")
void conflictFactory() {
assertThat(Conflict.of(ConflictType.PAST_TIME, "过去时间", 0).causedByReservation()).isFalse();
assertThat(Conflict.ofReservation(ConflictType.LAB_CONFLICT, "冲突", 1, 9L).causedByReservation()).isTrue();
}
}

View File

@@ -0,0 +1,85 @@
package com.example.lab.domain.conflict;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link TimeSlot} 单元测试:重点覆盖左闭右开区间的边界语义。
*/
class TimeSlotTest {
private static final LocalDateTime T10 = LocalDateTime.of(2026, 9, 21, 10, 0);
private static final LocalDateTime T11 = LocalDateTime.of(2026, 9, 21, 11, 0);
private static final LocalDateTime T12 = LocalDateTime.of(2026, 9, 21, 12, 0);
@Test
@DisplayName("合法区间:start 早于 end")
void isValid() {
assertThat(TimeSlot.of(T10, T11).isValid()).isTrue();
assertThat(TimeSlot.of(T10, T10).isValid()).isFalse();
assertThat(TimeSlot.of(T11, T10).isValid()).isFalse();
assertThat(TimeSlot.of(null, T11).isValid()).isFalse();
assertThat(TimeSlot.of(T10, null).isValid()).isFalse();
}
@Test
@DisplayName("时长与分钟数")
void duration() {
TimeSlot slot = TimeSlot.of(T10, T12);
assertThat(slot.duration()).isEqualTo(Duration.ofHours(2));
assertThat(slot.durationMinutes()).isEqualTo(120);
}
@Test
@DisplayName("边界:首尾相接不算重叠")
void adjacentSlotsDoNotOverlap() {
TimeSlot first = TimeSlot.of(T10, T11);
TimeSlot second = TimeSlot.of(T11, T12);
assertThat(first.overlapsWith(second)).isFalse();
assertThat(second.overlapsWith(first)).isFalse();
}
@Test
@DisplayName("重叠:部分相交 / 完全包含 / 完全相同")
void overlaps() {
TimeSlot base = TimeSlot.of(T11, T12);
assertThat(TimeSlot.of(T10, T11.plusMinutes(30)).overlapsWith(base)).isTrue();
assertThat(base.overlapsWith(TimeSlot.of(T11.plusMinutes(30), T12.plusHours(1)))).isTrue();
assertThat(TimeSlot.of(T10, T12.plusHours(1)).overlapsWith(base)).isTrue();
assertThat(TimeSlot.of(T11, T12).overlapsWith(base)).isTrue();
assertThat(base.overlapsWith(TimeSlot.of(T11, T12))).isTrue();
}
@Test
@DisplayName("与 null 或非法区间比较不视为重叠")
void overlapsWithInvalid() {
TimeSlot base = TimeSlot.of(T11, T12);
assertThat(base.overlapsWith(null)).isFalse();
assertThat(base.overlapsWith(TimeSlot.of(T12, T11))).isFalse();
assertThat(TimeSlot.of(T12, T11).overlapsWith(base)).isFalse();
}
@Test
@DisplayName("contains:左闭右开")
void contains() {
TimeSlot slot = TimeSlot.of(T10, T11);
assertThat(slot.contains(T10)).isTrue();
assertThat(slot.contains(T10.plusMinutes(59))).isTrue();
assertThat(slot.contains(T11)).isFalse();
assertThat(slot.contains(T10.minusSeconds(1))).isFalse();
assertThat(slot.contains(null)).isFalse();
}
@Test
@DisplayName("isSameDay:跨天返回 false")
void sameDay() {
assertThat(TimeSlot.of(T10, T12).isSameDay()).isTrue();
assertThat(TimeSlot.of(T10, T10.plusDays(1)).isSameDay()).isFalse();
assertThat(TimeSlot.of(T12, T10).isSameDay()).isFalse();
}
}

View File

@@ -0,0 +1,116 @@
package com.example.lab.domain.statemachine;
import com.example.lab.common.exception.BizException;
import com.example.lab.domain.enums.DeviceStatus;
import com.example.lab.domain.enums.LabStatus;
import com.example.lab.domain.enums.ReservationStatus;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 三个状态机的迁移规则测试。
*/
class StateMachineTest {
private final LabStateMachine labStateMachine = new LabStateMachine();
private final DeviceStateMachine deviceStateMachine = new DeviceStateMachine();
private final ReservationStateMachine reservationStateMachine = new ReservationStateMachine();
@Test
@DisplayName("实验室状态机:合法迁移与非法迁移")
void labTransitions() {
assertThat(labStateMachine.canTransit(LabStatus.IDLE, LabStatus.RESERVED)).isTrue();
assertThat(labStateMachine.canTransit(LabStatus.IDLE, LabStatus.MAINTENANCE)).isTrue();
assertThat(labStateMachine.canTransit(LabStatus.RESERVED, LabStatus.IN_USE)).isTrue();
assertThat(labStateMachine.canTransit(LabStatus.RESERVED, LabStatus.IDLE)).isTrue();
assertThat(labStateMachine.canTransit(LabStatus.IN_USE, LabStatus.IDLE)).isTrue();
assertThat(labStateMachine.canTransit(LabStatus.MAINTENANCE, LabStatus.IDLE)).isTrue();
assertThat(labStateMachine.canTransit(LabStatus.MAINTENANCE, LabStatus.RESERVED)).isFalse();
assertThat(labStateMachine.canTransit(LabStatus.IN_USE, LabStatus.RESERVED)).isFalse();
assertThat(labStateMachine.canTransit(LabStatus.IDLE, LabStatus.IN_USE)).isFalse();
assertThat(labStateMachine.canTransit(null, LabStatus.IDLE)).isFalse();
assertThat(labStateMachine.canTransit(LabStatus.IDLE, null)).isFalse();
}
@Test
@DisplayName("实验室状态机:断言非法迁移抛业务异常")
void labAssertTransit() {
labStateMachine.assertTransit(LabStatus.IDLE, LabStatus.RESERVED);
assertThatThrownBy(() -> labStateMachine.assertTransit(LabStatus.MAINTENANCE, LabStatus.IN_USE))
.isInstanceOf(BizException.class)
.hasMessageContaining("状态不允许");
}
@Test
@DisplayName("实验室状态机:可达状态集合")
void labNextStates() {
assertThat(labStateMachine.nextStates(LabStatus.IDLE))
.containsExactlyInAnyOrder(LabStatus.RESERVED, LabStatus.MAINTENANCE);
assertThat(labStateMachine.nextStates(LabStatus.IN_USE)).containsExactly(LabStatus.IDLE);
assertThat(labStateMachine.nextStates(LabStatus.IN_USE)).isUnmodifiable();
}
@Test
@DisplayName("设备状态机:迁移规则与实验室一致")
void deviceTransitions() {
assertThat(deviceStateMachine.canTransit(DeviceStatus.IDLE, DeviceStatus.RESERVED)).isTrue();
assertThat(deviceStateMachine.canTransit(DeviceStatus.RESERVED, DeviceStatus.IN_USE)).isTrue();
assertThat(deviceStateMachine.canTransit(DeviceStatus.IN_USE, DeviceStatus.IDLE)).isTrue();
assertThat(deviceStateMachine.canTransit(DeviceStatus.MAINTENANCE, DeviceStatus.IDLE)).isTrue();
assertThat(deviceStateMachine.canTransit(DeviceStatus.IN_USE, DeviceStatus.RESERVED)).isFalse();
assertThat(deviceStateMachine.canTransit(DeviceStatus.MAINTENANCE, DeviceStatus.IN_USE)).isFalse();
assertThat(deviceStateMachine.nextStates(DeviceStatus.MAINTENANCE)).containsExactly(DeviceStatus.IDLE);
}
@Test
@DisplayName("预约状态机:审批、使用、取消路径")
void reservationTransitions() {
assertThat(reservationStateMachine.canTransit(ReservationStatus.PENDING, ReservationStatus.APPROVED)).isTrue();
assertThat(reservationStateMachine.canTransit(ReservationStatus.PENDING, ReservationStatus.REJECTED)).isTrue();
assertThat(reservationStateMachine.canTransit(ReservationStatus.PENDING, ReservationStatus.CANCELLED)).isTrue();
assertThat(reservationStateMachine.canTransit(ReservationStatus.APPROVED, ReservationStatus.IN_USE)).isTrue();
assertThat(reservationStateMachine.canTransit(ReservationStatus.APPROVED, ReservationStatus.CANCELLED)).isTrue();
assertThat(reservationStateMachine.canTransit(ReservationStatus.APPROVED, ReservationStatus.COMPLETED)).isTrue();
assertThat(reservationStateMachine.canTransit(ReservationStatus.IN_USE, ReservationStatus.COMPLETED)).isTrue();
assertThat(reservationStateMachine.canTransit(ReservationStatus.COMPLETED, ReservationStatus.IN_USE)).isFalse();
assertThat(reservationStateMachine.canTransit(ReservationStatus.REJECTED, ReservationStatus.APPROVED)).isFalse();
assertThat(reservationStateMachine.canTransit(ReservationStatus.CANCELLED, ReservationStatus.APPROVED)).isFalse();
assertThat(reservationStateMachine.canTransit(ReservationStatus.IN_USE, ReservationStatus.CANCELLED)).isFalse();
}
@Test
@DisplayName("预约状态机:终态无后继")
void reservationTerminalStates() {
assertThat(reservationStateMachine.nextStates(ReservationStatus.REJECTED)).isEmpty();
assertThat(reservationStateMachine.nextStates(ReservationStatus.CANCELLED)).isEmpty();
assertThat(reservationStateMachine.nextStates(ReservationStatus.COMPLETED)).isEmpty();
assertThat(ReservationStatus.COMPLETED.isTerminal()).isTrue();
assertThat(ReservationStatus.CANCELLED.isTerminal()).isTrue();
assertThat(ReservationStatus.REJECTED.isTerminal()).isTrue();
assertThat(ReservationStatus.PENDING.isTerminal()).isFalse();
}
@Test
@DisplayName("状态枚举的属性方法")
void enumHelpers() {
assertThat(LabStatus.IDLE.isReservable()).isTrue();
assertThat(LabStatus.RESERVED.isReservable()).isTrue();
assertThat(LabStatus.IN_USE.isReservable()).isFalse();
assertThat(LabStatus.MAINTENANCE.isReservable()).isFalse();
assertThat(DeviceStatus.IDLE.isReservable()).isTrue();
assertThat(DeviceStatus.MAINTENANCE.isReservable()).isFalse();
assertThat(ReservationStatus.PENDING.isBlocking()).isTrue();
assertThat(ReservationStatus.APPROVED.isBlocking()).isTrue();
assertThat(ReservationStatus.IN_USE.isBlocking()).isTrue();
assertThat(ReservationStatus.CANCELLED.isBlocking()).isFalse();
assertThat(ReservationStatus.REJECTED.isBlocking()).isFalse();
assertThat(ReservationStatus.COMPLETED.isBlocking()).isFalse();
assertThat(ReservationStatus.PENDING.getLabel()).isEqualTo("待审批");
assertThat(LabStatus.IDLE.getLabel()).isEqualTo("空闲");
}
}

View File

@@ -0,0 +1,541 @@
package com.example.lab.integration;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 端到端接口集成测试:认证、RBAC、资源 CRUD、预约全流程与冲突校验。
*/
@SpringBootTest
@AutoConfigureMockMvc
class ApiIntegrationTest {
private static final String ADMIN = "admin:admin123";
private static final String TEACHER = "teacher1:123456";
private static final String STUDENT = "student1:123456";
private static final String STUDENT2 = "student2:123456";
@Autowired
private MockMvc mockMvc;
@Test
@DisplayName("登录成功后可查询当前用户,密码错误返回业务错误码")
void loginAndCurrentUser() throws Exception {
String token = login(STUDENT);
mockMvc.perform(get("/api/auth/me").header("Authorization", bearer(token)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.username").value("student1"))
.andExpect(jsonPath("$.data.roleLabel").value("学生"));
mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"student1\",\"password\":\"wrong\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1001));
mockMvc.perform(post("/api/auth/logout").header("Authorization", bearer(token)))
.andExpect(jsonPath("$.code").value(0));
}
@Test
@DisplayName("缺失令牌返回 401,越权访问返回 403")
void authenticationAndAuthorization() throws Exception {
mockMvc.perform(get("/api/labs")).andExpect(status().isUnauthorized());
String studentToken = login(STUDENT);
mockMvc.perform(post("/api/labs").header("Authorization", bearer(studentToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"X1\",\"name\":\"x\",\"location\":\"x\",\"capacity\":1}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
String teacherToken = login(TEACHER);
mockMvc.perform(get("/api/users").header("Authorization", bearer(teacherToken)))
.andExpect(status().isForbidden());
String adminToken = login(ADMIN);
mockMvc.perform(get("/api/users").header("Authorization", bearer(adminToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray());
}
@Test
@DisplayName("实验室 CRUD 与状态机流转")
void labCrudAndStatus() throws Exception {
String adminToken = login(ADMIN);
String code = unique("IT-LAB");
long labId = createLab(adminToken, code);
mockMvc.perform(get("/api/labs/" + labId).header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.data.code").value(code))
.andExpect(jsonPath("$.data.status").value("IDLE"));
mockMvc.perform(put("/api/labs/" + labId).header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content(labBody(code, "改造后的实验室", 50)))
.andExpect(jsonPath("$.data.name").value("改造后的实验室"))
.andExpect(jsonPath("$.data.capacity").value(50));
mockMvc.perform(patch("/api/labs/" + labId + "/status").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"labStatus\":\"MAINTENANCE\"}"))
.andExpect(jsonPath("$.data.status").value("MAINTENANCE"));
// 维护中不允许直接进入使用中
mockMvc.perform(patch("/api/labs/" + labId + "/status").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"labStatus\":\"IN_USE\"}"))
.andExpect(jsonPath("$.code").value(3003));
mockMvc.perform(delete("/api/labs/" + labId).header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.code").value(0));
}
@Test
@DisplayName("设备 CRUD:编号重复返回业务错误")
void deviceCrud() throws Exception {
String adminToken = login(ADMIN);
long labId = createLab(adminToken, unique("IT-LAB"));
String body = deviceBody(labId, "IT-DEV-1");
MvcResult result = mockMvc.perform(post("/api/devices").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(jsonPath("$.code").value(0))
.andReturn();
long deviceId = readLong(result, "$.data.id");
mockMvc.perform(get("/api/devices/by-lab/" + labId).header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.data[0].id").value(deviceId));
mockMvc.perform(patch("/api/devices/" + deviceId + "/status").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"deviceStatus\":\"MAINTENANCE\"}"))
.andExpect(jsonPath("$.data.status").value("MAINTENANCE"));
mockMvc.perform(post("/api/devices").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON).content(deviceBody(labId, "IT-DEV-1")))
.andExpect(jsonPath("$.code").value(2102));
mockMvc.perform(delete("/api/devices/" + deviceId).header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.code").value(0));
}
@Test
@DisplayName("课程绑定预约:生成整期场次,已生成预约的课程不可删除")
void courseCrudAndCourseReservation() throws Exception {
String adminToken = login(ADMIN);
String teacherToken = login(TEACHER);
long labId = createLab(adminToken, unique("IT-LAB"));
LocalDate start = LocalDate.now().plusDays(14);
String courseBody = """
{"code":"%s","name":"集成测试课程","labId":%d,"studentCount":10,
"startDate":"%s","endDate":"%s","weekdays":"%s","startTime":"10:00","endTime":"12:00"}
""".formatted(unique("IT-CS"), labId, start, start.plusDays(7),
start.getDayOfWeek().getValue());
MvcResult courseResult = mockMvc.perform(post("/api/courses").header("Authorization", bearer(teacherToken))
.contentType(MediaType.APPLICATION_JSON).content(courseBody))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.teacherId").value(2))
.andReturn();
long courseId = readLong(courseResult, "$.data.id");
mockMvc.perform(post("/api/reservations/course").header("Authorization", bearer(teacherToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"courseId\":" + courseId + "}"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].type").value("COURSE"))
.andExpect(jsonPath("$.data[0].status").value("APPROVED"));
mockMvc.perform(delete("/api/courses/" + courseId).header("Authorization", bearer(teacherToken)))
.andExpect(jsonPath("$.code").value(409));
mockMvc.perform(patch("/api/courses/" + courseId + "/finish")
.header("Authorization", bearer(teacherToken)))
.andExpect(jsonPath("$.data.status").value("FINISHED"));
mockMvc.perform(post("/api/reservations/course").header("Authorization", bearer(teacherToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"courseId\":" + courseId + "}"))
.andExpect(jsonPath("$.code").value(2203));
}
@Test
@DisplayName("单次预约全流程:学生提交 -> 教师审批 -> 使用 -> 完成")
void reservationLifecycle() throws Exception {
String adminToken = login(ADMIN);
String teacherToken = login(TEACHER);
String studentToken = login(STUDENT);
long labId = createLab(adminToken, unique("IT-LAB"));
LocalDate day = LocalDate.now().plusDays(10);
LocalDate conflictDay = day.plusDays(1);
MvcResult created = mockMvc.perform(post("/api/reservations").header("Authorization", bearer(studentToken))
.contentType(MediaType.APPLICATION_JSON)
.content(reservationBody(labId, day, "10:00", "12:00")))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andReturn();
long reservationId = readLong(created, "$.data.id");
// 冲突预检与创建都应被拒绝
mockMvc.perform(post("/api/reservations").header("Authorization", bearer(studentToken))
.contentType(MediaType.APPLICATION_JSON)
.content(reservationBody(labId, day, "11:00", "13:00")))
.andExpect(jsonPath("$.code").value(3002));
mockMvc.perform(post("/api/reservations/check").header("Authorization", bearer(studentToken))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"labId":%d,"type":"SINGLE","startTime":"%sT11:00:00","endTime":"%sT13:00:00"}
""".formatted(labId, day, day)))
.andExpect(jsonPath("$.data.passed").value(false))
.andExpect(jsonPath("$.data.conflicts[0].type").value("LAB_CONFLICT"));
// 学生自己不能审批
mockMvc.perform(post("/api/reservations/" + reservationId + "/approve")
.header("Authorization", bearer(studentToken)))
.andExpect(status().isForbidden());
mockMvc.perform(post("/api/reservations/" + reservationId + "/approve")
.header("Authorization", bearer(teacherToken)))
.andExpect(jsonPath("$.data.status").value("APPROVED"));
mockMvc.perform(post("/api/reservations/" + reservationId + "/start")
.header("Authorization", bearer(studentToken)))
.andExpect(jsonPath("$.data.status").value("IN_USE"));
mockMvc.perform(get("/api/labs/" + labId).header("Authorization", bearer(studentToken)))
.andExpect(jsonPath("$.data.status").value("IN_USE"));
mockMvc.perform(post("/api/reservations/" + reservationId + "/complete")
.header("Authorization", bearer(studentToken)))
.andExpect(jsonPath("$.data.status").value("COMPLETED"));
mockMvc.perform(get("/api/reservations/" + reservationId).header("Authorization", bearer(studentToken)))
.andExpect(jsonPath("$.data.userName").value("李同学"));
// 已完成的预约释放资源,可在同一天再次预约相同时间
mockMvc.perform(post("/api/reservations").header("Authorization", bearer(studentToken))
.contentType(MediaType.APPLICATION_JSON)
.content(reservationBody(labId, day, "10:00", "12:00")))
.andExpect(jsonPath("$.code").value(0));
// 冲突日期留白,便于日历断言
assertThat(conflictDay).isAfter(day);
}
@Test
@DisplayName("日历视图:返回实验室在日期范围内的预约")
void calendar() throws Exception {
String adminToken = login(ADMIN);
String token = login(STUDENT);
long labId = createLab(adminToken, unique("IT-LAB"));
LocalDate day = LocalDate.now().plusDays(12);
mockMvc.perform(post("/api/reservations").header("Authorization", bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.content(reservationBody(labId, day, "14:00", "16:00")))
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/api/reservations/calendar")
.param("labId", String.valueOf(labId))
.param("from", day.toString())
.param("to", day.toString())
.header("Authorization", bearer(token)))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].labName").isNotEmpty())
.andExpect(jsonPath("$.data[0].startTime").value(day + "T14:00:00"));
mockMvc.perform(get("/api/reservations/calendar")
.param("labId", String.valueOf(labId))
.param("from", day.toString())
.param("to", day.minusDays(1).toString())
.header("Authorization", bearer(token)))
.andExpect(jsonPath("$.code").value(400));
}
@Test
@DisplayName("多次预约:批量生成并按系列整体取消")
void recurringAndCancelSeries() throws Exception {
String adminToken = login(ADMIN);
String token = login(STUDENT);
long labId = createLab(adminToken, unique("IT-LAB"));
LocalDate start = LocalDate.now().plusDays(20);
MvcResult result = mockMvc.perform(post("/api/reservations/recurring")
.header("Authorization", bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"labId":%d,"title":"多次预约测试","startDate":"%s","endDate":"%s",
"weekdays":"%s","startTime":"09:00","endTime":"11:00","participants":3}
""".formatted(labId, start, start.plusDays(7), start.getDayOfWeek().getValue())))
.andExpect(jsonPath("$.code").value(0))
.andReturn();
int size = JsonPath.read(result.getResponse().getContentAsString(), "$.data.length()");
assertThat(size).isEqualTo(2);
long firstId = readLong(result, "$.data[0].id");
long seriesId = readLong(result, "$.data[0].seriesId");
mockMvc.perform(post("/api/reservations/" + firstId + "/cancel").param("series", "true")
.header("Authorization", bearer(token)))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(2));
mockMvc.perform(get("/api/reservations").param("mine", "true")
.header("Authorization", bearer(token)))
.andExpect(jsonPath("$.code").value(0));
assertThat(seriesId).isPositive();
}
@Test
@DisplayName("预约权限:他人不可取消或查看,学生列表强制只看自己")
void reservationPermissions() throws Exception {
String adminToken = login(ADMIN);
String studentToken = login(STUDENT);
String otherToken = login(STUDENT2);
long labId = createLab(adminToken, unique("IT-LAB"));
LocalDate day = LocalDate.now().plusDays(15);
MvcResult created = mockMvc.perform(post("/api/reservations").header("Authorization", bearer(studentToken))
.contentType(MediaType.APPLICATION_JSON)
.content(reservationBody(labId, day, "10:00", "12:00")))
.andExpect(jsonPath("$.code").value(0))
.andReturn();
long reservationId = readLong(created, "$.data.id");
mockMvc.perform(post("/api/reservations/" + reservationId + "/cancel")
.header("Authorization", bearer(otherToken)))
.andExpect(jsonPath("$.code").value(3007));
mockMvc.perform(get("/api/reservations/" + reservationId).header("Authorization", bearer(otherToken)))
.andExpect(jsonPath("$.code").value(3007));
mockMvc.perform(post("/api/reservations/" + reservationId + "/start")
.header("Authorization", bearer(otherToken)))
.andExpect(jsonPath("$.code").value(3007));
mockMvc.perform(get("/api/reservations").header("Authorization", bearer(otherToken)))
.andExpect(jsonPath("$.code").value(0));
}
@Test
@DisplayName("用户管理:管理员可增改删,且不能删除自己")
void userManagement() throws Exception {
String adminToken = login(ADMIN);
String username = unique("it-user");
MvcResult created = mockMvc.perform(post("/api/users").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"%s","password":"123456","realName":"集成测试用户","role":"STUDENT"}
""".formatted(username)))
.andExpect(jsonPath("$.code").value(0))
.andReturn();
long userId = readLong(created, "$.data.id");
mockMvc.perform(post("/api/users").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"%s","password":"123456","realName":"重复用户","role":"STUDENT"}
""".formatted(username)))
.andExpect(jsonPath("$.code").value(1003));
mockMvc.perform(put("/api/users/" + userId).header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"realName\":\"改名后的用户\",\"role\":\"TEACHER\"}"))
.andExpect(jsonPath("$.data.realName").value("改名后的用户"))
.andExpect(jsonPath("$.data.roleLabel").value("教师"));
mockMvc.perform(delete("/api/users/1").header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.code").value(403));
mockMvc.perform(delete("/api/users/" + userId).header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.code").value(0));
}
@Test
@DisplayName("参数校验与异常兜底")
void validationAndErrors() throws Exception {
String adminToken = login(ADMIN);
mockMvc.perform(post("/api/labs").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"IT-BAD\",\"location\":\"x\",\"capacity\":1}"))
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("name")));
mockMvc.perform(post("/api/reservations").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{not-json"))
.andExpect(jsonPath("$.code").value(400));
mockMvc.perform(get("/api/labs/999999").header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.code").value(2001));
mockMvc.perform(delete("/api/devices/999999").header("Authorization", bearer(adminToken)))
.andExpect(jsonPath("$.code").value(2101));
}
@Test
@DisplayName("可用实验室查询:维护中的实验室被排除")
void availableLabs() throws Exception {
String adminToken = login(ADMIN);
String token = login(STUDENT);
String code = unique("IT-LAB-MAINT");
long labId = createLab(adminToken, code);
mockMvc.perform(patch("/api/labs/" + labId + "/status").header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"labStatus\":\"MAINTENANCE\"}"))
.andExpect(jsonPath("$.code").value(0));
LocalDate day = LocalDate.now().plusDays(9);
MvcResult result = mockMvc.perform(get("/api/labs/available")
.param("start", day + "T10:00:00")
.param("end", day + "T12:00:00")
.header("Authorization", bearer(token)))
.andExpect(jsonPath("$.code").value(0))
.andReturn();
java.util.List<String> codes = JsonPath.read(result.getResponse().getContentAsString(), "$.data[*].code");
assertThat(codes).doesNotContain(code);
mockMvc.perform(get("/api/labs/available")
.param("start", day + "T12:00:00")
.param("end", day + "T10:00:00")
.header("Authorization", bearer(token)))
.andExpect(jsonPath("$.code").value(3004));
}
// ------------------------------------------------------------------
// 辅助方法
// ------------------------------------------------------------------
/**
* 登录并返回令牌。
*
* @param credentials "用户名:密码"
* @return JWT
* @throws Exception 请求异常
*/
private String login(String credentials) throws Exception {
String[] parts = credentials.split(":");
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"" + parts[0] + "\",\"password\":\"" + parts[1] + "\"}"))
.andExpect(jsonPath("$.code").value(0))
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.token");
}
/**
* 创建实验室并返回 ID。
*
* @param token 管理员令牌
* @param code 实验室编号
* @return 实验室 ID
* @throws Exception 请求异常
*/
private long createLab(String token, String code) throws Exception {
MvcResult result = mockMvc.perform(post("/api/labs").header("Authorization", bearer(token))
.contentType(MediaType.APPLICATION_JSON).content(labBody(code, "集成测试实验室", 30)))
.andExpect(jsonPath("$.code").value(0))
.andReturn();
return readLong(result, "$.data.id");
}
/**
* 实验室请求体。
*
* @param code 编号
* @param name 名称
* @param capacity 容量
* @return JSON
*/
private String labBody(String code, String name, int capacity) {
return """
{"code":"%s","name":"%s","location":"测试楼","capacity":%d,
"openTime":"08:00","closeTime":"22:00"}
""".formatted(code, name, capacity);
}
/**
* 设备请求体。
*
* @param labId 实验室 ID
* @param code 设备编号
* @return JSON
*/
private String deviceBody(long labId, String code) {
return """
{"labId":%d,"code":"%s","name":"集成测试设备","model":"T-100"}
""".formatted(labId, code);
}
/**
* 单次预约请求体。
*
* @param labId 实验室 ID
* @param day 日期
* @param start 开始时间
* @param end 结束时间
* @return JSON
*/
private String reservationBody(long labId, LocalDate day, String start, String end) {
return """
{"labId":%d,"title":"集成测试预约","purpose":"自动化测试",
"startTime":"%sT%s:00","endTime":"%sT%s:00","participants":2}
""".formatted(labId, day, start, day, end);
}
/**
* 构造 Bearer 头。
*
* @param token 令牌
* @return 头值
*/
private String bearer(String token) {
return "Bearer " + token;
}
/**
* 生成唯一编号。
*
* @param prefix 前缀
* @return 唯一编号
*/
private String unique(String prefix) {
return prefix + "-" + System.nanoTime();
}
/**
* 从响应中读取 long 值。
*
* @param result 响应
* @param path JSON 路径
* @return 数值
* @throws Exception 读取异常
*/
private long readLong(MvcResult result, String path) throws Exception {
Number value = JsonPath.read(result.getResponse().getContentAsString(), path);
return value.longValue();
}
}

View File

@@ -0,0 +1,182 @@
package com.example.lab.mapper;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link ReservationMapper} 集成测试:验证 XML 自定义 SQL 的区间语义、状态过滤与行锁。
*/
@SpringBootTest
@Transactional
class ReservationMapperTest {
private static final LocalDateTime BASE = LocalDateTime.of(2027, 3, 1, 10, 0);
@Autowired
private ReservationMapper reservationMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
@DisplayName("区间语义:首尾相接不算冲突,真实重叠命中")
void boundarySemantics() {
Long labId = ensureLab("mapper-lab-1");
insert(labId, BASE, BASE.plusHours(2), ReservationStatus.APPROVED, null, null);
assertThat(selectConflicting(labId, BASE.plusHours(1), BASE.plusHours(3))).hasSize(1);
assertThat(selectConflicting(labId, BASE, BASE.plusSeconds(1))).hasSize(1);
assertThat(selectConflicting(labId, BASE.plusHours(2), BASE.plusHours(3))).isEmpty();
assertThat(selectConflicting(labId, BASE.minusHours(1), BASE)).isEmpty();
}
@Test
@DisplayName("状态过滤:已取消的预约不算占用")
void statusFilter() {
Long labId = ensureLab("mapper-lab-2");
insert(labId, BASE, BASE.plusHours(2), ReservationStatus.CANCELLED, null, null);
assertThat(selectConflicting(labId, BASE, BASE.plusHours(1))).isEmpty();
}
@Test
@DisplayName("按设备或用户维度也能命中")
void deviceAndUserDimension() {
Long labId = ensureLab("mapper-lab-3");
Long deviceId = ensureDevice("mapper-dev-x", labId);
Long id = insert(labId, BASE, BASE.plusHours(2), ReservationStatus.PENDING, deviceId, 777L);
// 实验室不同,仅设备相同
List<Reservation> byDevice = reservationMapper.selectConflicting(999L, deviceId, null,
BASE.plusMinutes(30), BASE.plusMinutes(90), null, blockingStatuses(), false);
assertThat(byDevice).extracting(Reservation::getId).contains(id);
// 实验室与设备都不同,仅用户相同
List<Reservation> byUser = reservationMapper.selectConflicting(998L, 887L, 777L,
BASE.plusMinutes(30), BASE.plusMinutes(90), null, blockingStatuses(), false);
assertThat(byUser).extracting(Reservation::getId).contains(id);
}
@Test
@DisplayName("ignoreId 排除自身,lock=true 在事务内可正常加锁")
void ignoreAndLock() {
Long labId = ensureLab("mapper-lab-4");
Long id = insert(labId, BASE, BASE.plusHours(2), ReservationStatus.APPROVED, null, null);
assertThat(selectConflicting(labId, BASE, BASE.plusHours(1))).hasSize(1);
List<Reservation> ignored = reservationMapper.selectConflicting(labId, null, null,
BASE, BASE.plusHours(1), id, blockingStatuses(), true);
assertThat(ignored).isEmpty();
List<Reservation> locked = reservationMapper.selectConflicting(labId, null, null,
BASE, BASE.plusHours(1), null, blockingStatuses(), true);
assertThat(locked).hasSize(1);
}
@Test
@DisplayName("lock=true 与 lock=false 返回一致结果")
void lockConsistency() {
Long labId = ensureLab("mapper-lab-5");
insert(labId, BASE, BASE.plusHours(2), ReservationStatus.IN_USE, null, null);
List<Reservation> locked = reservationMapper.selectConflicting(labId, null, null, BASE, BASE.plusHours(1),
null, blockingStatuses(), true);
List<Reservation> unlocked = reservationMapper.selectConflicting(labId, null, null, BASE, BASE.plusHours(1),
null, blockingStatuses(), false);
assertThat(locked).hasSize(1).isEqualTo(unlocked);
}
/**
* 查询冲突候选。
*
* @param labId 实验室 ID
* @param start 开始
* @param end 结束
* @return 候选列表
*/
private List<Reservation> selectConflicting(Long labId, LocalDateTime start, LocalDateTime end) {
return reservationMapper.selectConflicting(labId, null, null, start, end, null,
blockingStatuses(), false);
}
/**
* 阻塞状态集合。
*
* @return 状态列表
*/
private List<ReservationStatus> blockingStatuses() {
return List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE);
}
/**
* 确保实验室存在并返回 ID。
*
* @param code 实验室编号
* @return 实验室 ID
*/
private Long ensureLab(String code) {
List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM lab WHERE code = ?", Long.class, code);
if (!ids.isEmpty()) {
return ids.get(0);
}
jdbcTemplate.update("INSERT INTO lab (code, name, location, capacity, status, open_time, close_time, "
+ "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
code, "集成测试实验室", "测试楼", 30, "IDLE", "08:00", "22:00");
return jdbcTemplate.queryForObject("SELECT id FROM lab WHERE code = ?", Long.class, code);
}
/**
* 确保设备存在并返回 ID。
*
* @param code 设备编号
* @param labId 实验室 ID
* @return 设备 ID
*/
private Long ensureDevice(String code, Long labId) {
List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM device WHERE code = ?", Long.class, code);
if (!ids.isEmpty()) {
return ids.get(0);
}
jdbcTemplate.update("INSERT INTO device (lab_id, code, name, status, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", labId, code, "集成测试设备", "IDLE");
return jdbcTemplate.queryForObject("SELECT id FROM device WHERE code = ?", Long.class, code);
}
/**
* 插入一条测试预约。
*
* @param labId 实验室 ID
* @param start 开始时间
* @param end 结束时间
* @param status 状态
* @param deviceId 设备 ID,可为空
* @param userId 用户 ID,可为空
* @return 预约 ID
*/
private Long insert(Long labId, LocalDateTime start, LocalDateTime end, ReservationStatus status,
Long deviceId, Long userId) {
Reservation reservation = Reservation.builder()
.userId(userId == null ? 4L : userId)
.labId(labId)
.deviceId(deviceId)
.type(ReservationType.SINGLE)
.title("集成测试预约")
.startTime(start)
.endTime(end)
.participants(1)
.status(status)
.version(0)
.build();
reservationMapper.insert(reservation);
return reservation.getId();
}
}

View File

@@ -0,0 +1,244 @@
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.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.common.util.JwtUtils;
import com.example.lab.domain.enums.Role;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.method.HandlerMethod;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* {@link AuthInterceptor} 单元测试:认证、角色校验、上下文清理。
*/
@ExtendWith(MockitoExtension.class)
class AuthInterceptorTest {
private static final String TOKEN = "valid-token";
@Mock
private JwtUtils jwtUtils;
private AuthInterceptor interceptor;
@BeforeEach
void setUp() {
interceptor = new AuthInterceptor(jwtUtils);
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("缺少 Authorization 头返回 401")
void missingToken() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
boolean pass = interceptor.preHandle(new MockHttpServletRequest("GET", "/api/labs"),
response, handler("open"));
assertThat(pass).isFalse();
assertThat(response.getStatus()).isEqualTo(401);
assertThat(response.getContentAsString()).contains("未登录");
}
@Test
@DisplayName("Bearer 前缀为空时按未登录处理")
void emptyBearerToken() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/labs");
request.addHeader("Authorization", "Bearer ");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("open"))).isFalse();
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
@DisplayName("非 Bearer 格式按未登录处理")
void wrongScheme() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/labs");
request.addHeader("Authorization", "Basic abc");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("open"))).isFalse();
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
@DisplayName("令牌解析失败返回 401")
void invalidToken() throws Exception {
MockHttpServletRequest request = requestWithToken();
when(jwtUtils.parseToken(TOKEN)).thenThrow(new BizException(ErrorCode.UNAUTHORIZED));
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("open"))).isFalse();
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
@DisplayName("合法令牌且无角色限制时放行并写入上下文")
void validTokenWithoutRole() throws Exception {
MockHttpServletRequest request = requestWithToken();
when(jwtUtils.parseToken(TOKEN)).thenReturn(new LoginUser(4L, "student1", "李同学", Role.STUDENT));
MockHttpServletResponse response = new MockHttpServletResponse();
boolean pass = interceptor.preHandle(request, response, handler("open"));
assertThat(pass).isTrue();
assertThat(UserContext.requireUserId()).isEqualTo(4L);
}
@Test
@DisplayName("角色不匹配返回 403")
void forbiddenRole() throws Exception {
MockHttpServletRequest request = requestWithToken();
when(jwtUtils.parseToken(TOKEN)).thenReturn(new LoginUser(4L, "student1", "李同学", Role.STUDENT));
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("adminOnly"))).isFalse();
assertThat(response.getStatus()).isEqualTo(403);
assertThat(response.getContentAsString()).contains("无权限");
}
@Test
@DisplayName("角色匹配时放行,方法注解优先于类注解")
void allowedRole() throws Exception {
MockHttpServletRequest request = requestWithToken();
when(jwtUtils.parseToken(TOKEN)).thenReturn(new LoginUser(1L, "admin", "管理员", Role.ADMIN));
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("adminOnly"))).isTrue();
assertThat(interceptor.preHandle(request, response, classLevelHandler("classLevel"))).isTrue();
}
@Test
@DisplayName("类级 @RequireRole 对未标注方法生效")
void classLevelRole() throws Exception {
MockHttpServletRequest request = requestWithToken();
when(jwtUtils.parseToken(TOKEN)).thenReturn(new LoginUser(4L, "student1", "李同学", Role.STUDENT));
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, classLevelHandler("noAnnotation"))).isFalse();
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
@DisplayName("OPTIONS 预检请求直接放行")
void optionsRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/api/labs");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, handler("adminOnly"))).isTrue();
}
@Test
@DisplayName("非 HandlerMethod 处理器直接放行")
void nonHandlerMethod() throws Exception {
assertThat(interceptor.preHandle(new MockHttpServletRequest("GET", "/static"),
new MockHttpServletResponse(), new Object())).isTrue();
}
@Test
@DisplayName("请求完成后清理用户上下文")
void afterCompletionClearsContext() {
UserContext.set(new LoginUser(1L, "admin", "管理员", Role.ADMIN));
interceptor.afterCompletion(new MockHttpServletRequest(), new MockHttpServletResponse(),
new Object(), null);
assertThat(UserContext.get()).isNull();
}
/**
* 构造带 Bearer 令牌的请求。
*
* @return 请求
*/
private MockHttpServletRequest requestWithToken() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/labs");
request.addHeader("Authorization", "Bearer " + TOKEN);
return request;
}
/**
* 构造指定方法的 HandlerMethod。
*
* @param methodName 方法名
* @return HandlerMethod
* @throws NoSuchMethodException 方法不存在
*/
private HandlerMethod handler(String methodName) throws NoSuchMethodException {
DummyController controller = new PlainController();
return new HandlerMethod(controller, controller.getClass().getMethod(methodName));
}
/**
* 构造类级 @RequireRole 控制器的 HandlerMethod。
*
* @param methodName 方法名
* @return HandlerMethod
* @throws NoSuchMethodException 方法不存在
*/
private HandlerMethod classLevelHandler(String methodName) throws NoSuchMethodException {
DummyController controller = new ClassLevelController();
return new HandlerMethod(controller, controller.getClass().getMethod(methodName));
}
/**
* 无类级注解的控制器,方法上仅 adminOnly 带 @RequireRole。
*/
static class PlainController implements DummyController {
@Override
public void open() {
// 无注解
}
@Override
public void adminOnly() {
// 方法注解
}
}
/**
* 测试用控制器接口。
*/
interface DummyController {
/** 无注解方法 */
void open();
/** 仅管理员方法 */
@RequireRole(Role.ADMIN)
void adminOnly();
}
/**
* 类级 @RequireRole 的控制器。
*/
@RequireRole(Role.ADMIN)
static class ClassLevelController implements DummyController {
@Override
public void open() {
// 无注解
}
@Override
public void adminOnly() {
// 方法注解
}
/** 未标注方法,应继承类注解 */
public void noAnnotation() {
// 继承类级注解
}
/** 类级注解测试方法 */
public void classLevel() {
// 继承类级注解
}
}
}

View File

@@ -0,0 +1,149 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
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.domain.enums.Role;
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* {@link AuthService} 单元测试。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AuthServiceTest extends com.example.lab.support.MybatisUnitTest {
@Mock
private UserMapper userMapper;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private JwtUtils jwtUtils;
@InjectMocks
private AuthService authService;
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("登录成功返回令牌与用户信息")
void loginSuccess() {
User user = user(2L, "teacher1", Role.TEACHER);
when(userMapper.selectOne(any(Wrapper.class))).thenReturn(user);
when(passwordEncoder.matches("123456", user.getPassword())).thenReturn(true);
when(jwtUtils.generateToken(any(LoginUser.class))).thenReturn("jwt-token");
when(jwtUtils.getExpireMinutes()).thenReturn(60L);
LoginResponse response = authService.login(request("teacher1", "123456"));
assertThat(response.getToken()).isEqualTo("jwt-token");
assertThat(response.getExpiresIn()).isEqualTo(60L);
assertThat(response.getUser().getUsername()).isEqualTo("teacher1");
assertThat(response.getUser().getRoleLabel()).isEqualTo("教师");
}
@Test
@DisplayName("用户不存在或密码错误返回登录失败")
void loginFailed() {
when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
assertThatThrownBy(() -> authService.login(request("nobody", "x")))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.LOGIN_FAILED);
User user = user(2L, "teacher1", Role.TEACHER);
when(userMapper.selectOne(any(Wrapper.class))).thenReturn(user);
when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false);
assertThatThrownBy(() -> authService.login(request("teacher1", "bad")))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.LOGIN_FAILED);
}
@Test
@DisplayName("账号被禁用时拒绝登录")
void loginDisabled() {
User user = user(2L, "teacher1", Role.TEACHER);
user.setEnabled(false);
when(userMapper.selectOne(any(Wrapper.class))).thenReturn(user);
when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);
assertThatThrownBy(() -> authService.login(request("teacher1", "123456")))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.USER_DISABLED);
}
@Test
@DisplayName("当前用户查询成功")
void currentUser() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(userMapper.selectById(2L)).thenReturn(user(2L, "teacher1", Role.TEACHER));
UserVO vo = authService.currentUser();
assertThat(vo.getRealName()).isEqualTo("张老师");
}
@Test
@DisplayName("当前用户已被删除时报用户不存在")
void currentUserNotFound() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(userMapper.selectById(2L)).thenReturn(null);
assertThatThrownBy(() -> authService.currentUser())
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.USER_NOT_FOUND);
}
/**
* 构造登录请求。
*
* @param username 用户名
* @param password 密码
* @return 请求
*/
private LoginRequest request(String username, String password) {
LoginRequest request = new LoginRequest();
request.setUsername(username);
request.setPassword(password);
return request;
}
/**
* 构造用户实体。
*
* @param id 用户 ID
* @param username 用户名
* @param role 角色
* @return 用户
*/
private User user(Long id, String username, Role role) {
return User.builder().id(id).username(username).password("encoded").realName("张老师")
.role(role).enabled(true).build();
}
}

View File

@@ -0,0 +1,342 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
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.config.LabProperties;
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.User;
import com.example.lab.domain.enums.CourseStatus;
import com.example.lab.domain.enums.LabStatus;
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link CourseService} 单元测试。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CourseServiceTest extends com.example.lab.support.MybatisUnitTest {
@Mock
private CourseMapper courseMapper;
@Mock
private LabMapper labMapper;
@Mock
private UserMapper userMapper;
@Mock
private ReservationMapper reservationMapper;
@Spy
private RecurrenceExpander recurrenceExpander = new RecurrenceExpander(new LabProperties());
@InjectMocks
private CourseService courseService;
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("分页与全量查询")
@SuppressWarnings("unchecked")
void queries() {
Page<Course> page = new Page<>(1, 10);
page.setRecords(List.of(course()));
page.setTotal(1);
when(courseMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
PageResult<Course> result = courseService.page(1, 10, "程序", 1L, CourseStatus.ACTIVE);
assertThat(result.getRecords()).hasSize(1);
when(courseMapper.selectList(any(Wrapper.class))).thenReturn(List.of(course()));
assertThat(courseService.listAll()).hasSize(1);
assertThat(courseService.listByTeacher(2L)).hasSize(1);
}
@Test
@DisplayName("查询课程详情")
void get() {
when(courseMapper.selectById(1L)).thenReturn(course());
assertThat(courseService.get(1L).getCode()).isEqualTo("CS101");
when(courseMapper.selectById(9L)).thenReturn(null);
assertThatThrownBy(() -> courseService.get(9L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.COURSE_NOT_FOUND);
}
@Test
@DisplayName("新增课程:教师只能给自己开课")
void createForbiddenForOtherTeacher() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
CourseRequest request = request();
request.setTeacherId(3L);
assertThatThrownBy(() -> courseService.create(request))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.FORBIDDEN);
}
@Test
@DisplayName("新增课程:编号重复报错")
void createDuplicate() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(courseMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThatThrownBy(() -> courseService.create(request())).isInstanceOf(BizException.class);
verify(courseMapper, never()).insert(any(Course.class));
}
@Test
@DisplayName("新增课程:教师不存在或角色不合法")
void createInvalidTeacher() {
UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN));
when(userMapper.selectById(2L)).thenReturn(null);
assertThatThrownBy(() -> courseService.create(request()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.USER_NOT_FOUND);
when(userMapper.selectById(2L)).thenReturn(User.builder().id(2L).role(Role.STUDENT).build());
assertThatThrownBy(() -> courseService.create(request()))
.isInstanceOf(BizException.class)
.hasMessageContaining("授课人");
}
@Test
@DisplayName("新增课程:实验室不存在")
void createLabMissing() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(userMapper.selectById(2L)).thenReturn(User.builder().id(2L).role(Role.TEACHER).build());
when(labMapper.selectById(1L)).thenReturn(null);
assertThatThrownBy(() -> courseService.create(request()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.LAB_NOT_FOUND);
}
@Test
@DisplayName("新增课程:日期、时间、容量、星期校验")
void createInvalidSchedule() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(userMapper.selectById(2L)).thenReturn(User.builder().id(2L).role(Role.TEACHER).build());
when(labMapper.selectById(1L)).thenReturn(lab());
when(courseMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
CourseRequest reversed = request();
reversed.setEndDate(reversed.getStartDate().minusDays(1));
assertThatThrownBy(() -> courseService.create(reversed)).hasMessageContaining("结束日期");
CourseRequest badTime = request();
badTime.setEndTime(badTime.getStartTime());
assertThatThrownBy(() -> courseService.create(badTime))
.extracting("errorCode").isEqualTo(ErrorCode.INVALID_TIME_RANGE);
CourseRequest tooMany = request();
tooMany.setStudentCount(999);
assertThatThrownBy(() -> courseService.create(tooMany))
.extracting("errorCode").isEqualTo(ErrorCode.CAPACITY_EXCEEDED);
CourseRequest badWeekdays = request();
badWeekdays.setWeekdays("MOON");
assertThatThrownBy(() -> courseService.create(badWeekdays)).isInstanceOf(BizException.class);
}
@Test
@DisplayName("新增课程成功(教师自开课)")
void createSuccess() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(userMapper.selectById(2L)).thenReturn(User.builder().id(2L).role(Role.TEACHER).build());
when(labMapper.selectById(1L)).thenReturn(lab());
when(courseMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
Course course = courseService.create(request());
assertThat(course.getTeacherId()).isEqualTo(2L);
assertThat(course.getStatus()).isEqualTo(CourseStatus.ACTIVE);
verify(courseMapper).insert(any(Course.class));
}
@Test
@DisplayName("新增课程:管理员未指定教师时默认自己")
void createByAdminDefaultsToSelf() {
UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN));
when(userMapper.selectById(1L)).thenReturn(User.builder().id(1L).role(Role.ADMIN).build());
when(labMapper.selectById(1L)).thenReturn(lab());
when(courseMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
CourseRequest request = request();
request.setTeacherId(null);
assertThat(courseService.create(request).getTeacherId()).isEqualTo(1L);
}
@Test
@DisplayName("修改课程:非授课教师不可修改")
void updateForbidden() {
when(courseMapper.selectById(1L)).thenReturn(course());
UserContext.set(new LoginUser(3L, "teacher2", "王老师", Role.TEACHER));
assertThatThrownBy(() -> courseService.update(1L, request()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.FORBIDDEN);
}
@Test
@DisplayName("修改课程成功")
void updateSuccess() {
when(courseMapper.selectById(1L)).thenReturn(course());
when(userMapper.selectById(2L)).thenReturn(User.builder().id(2L).role(Role.TEACHER).build());
when(labMapper.selectById(1L)).thenReturn(lab());
when(courseMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
CourseRequest request = request();
request.setName("改名后的课程");
assertThat(courseService.update(1L, request).getName()).isEqualTo("改名后的课程");
}
@Test
@DisplayName("结束课程:非授课教师不可操作,授课教师可结束")
void finish() {
when(courseMapper.selectById(1L)).thenReturn(course());
UserContext.set(new LoginUser(3L, "teacher2", "王老师", Role.TEACHER));
assertThatThrownBy(() -> courseService.finish(1L)).isInstanceOf(BizException.class);
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
assertThat(courseService.finish(1L).getStatus()).isEqualTo(CourseStatus.FINISHED);
}
@Test
@DisplayName("删除课程:已生成预约时报错,未生成时成功")
void delete() {
when(courseMapper.selectById(1L)).thenReturn(course());
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(3L);
assertThatThrownBy(() -> courseService.delete(1L)).isInstanceOf(BizException.class);
verify(courseMapper, never()).deleteById(any(Long.class));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
courseService.delete(1L);
verify(courseMapper).deleteById(1L);
}
@Test
@DisplayName("分页查询:无关键字、实验室与状态过滤")
@SuppressWarnings("unchecked")
void pageWithoutFilters() {
Page<Course> page = new Page<>(1, 10);
page.setRecords(List.of());
page.setTotal(0);
when(courseMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
assertThat(courseService.page(1, 10, null, null, null).getTotal()).isZero();
}
@Test
@DisplayName("管理员可以修改、结束并删除他人开设的课程")
void adminManagesOtherTeacherCourse() {
when(courseMapper.selectById(1L)).thenReturn(course());
when(userMapper.selectById(2L)).thenReturn(User.builder().id(2L).role(Role.TEACHER).build());
when(labMapper.selectById(1L)).thenReturn(lab());
when(courseMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN));
assertThat(courseService.update(1L, request()).getId()).isEqualTo(1L);
assertThat(courseService.finish(1L).getStatus()).isEqualTo(CourseStatus.FINISHED);
courseService.delete(1L);
verify(courseMapper).deleteById(1L);
}
@Test
@DisplayName("新增课程:实验室未设置容量时不校验容量")
void createWithLabCapacityNull() {
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
when(userMapper.selectById(2L)).thenReturn(User.builder().id(2L).role(Role.TEACHER).build());
when(labMapper.selectById(1L)).thenReturn(Lab.builder().id(1L).capacity(null).status(LabStatus.IDLE).build());
when(courseMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
assertThat(courseService.create(request()).getStudentCount()).isEqualTo(40);
}
@Test
@DisplayName("删除课程:统计结果为 null 时视为未生成预约")
void deleteWhenCountNull() {
when(courseMapper.selectById(1L)).thenReturn(course());
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(null);
UserContext.set(new LoginUser(2L, "teacher1", "张老师", Role.TEACHER));
courseService.delete(1L);
verify(courseMapper).deleteById(1L);
}
/**
* 构造课程。
*
* @return 课程
*/
private Course course() {
return Course.builder().id(1L).code("CS101").name("程序设计基础实验").teacherId(2L)
.labId(1L).studentCount(40).startDate(LocalDate.now().plusDays(1))
.endDate(LocalDate.now().plusDays(90)).weekdays("MON")
.startTime(LocalTime.of(14, 0)).endTime(LocalTime.of(16, 0))
.status(CourseStatus.ACTIVE).build();
}
/**
* 构造实验室。
*
* @return 实验室
*/
private Lab lab() {
return Lab.builder().id(1L).name("计算机基础实验室").capacity(40).status(LabStatus.IDLE).build();
}
/**
* 构造课程请求。
*
* @return 请求
*/
private CourseRequest request() {
CourseRequest request = new CourseRequest();
request.setCode("CS101");
request.setName("程序设计基础实验");
request.setTeacherId(2L);
request.setLabId(1L);
request.setStudentCount(40);
request.setStartDate(LocalDate.now().plusDays(1));
request.setEndDate(LocalDate.now().plusDays(90));
request.setWeekdays("MON");
request.setStartTime(LocalTime.of(14, 0));
request.setEndTime(LocalTime.of(16, 0));
return request;
}
}

View File

@@ -0,0 +1,218 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
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.enums.DeviceStatus;
import com.example.lab.domain.enums.LabStatus;
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 org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link DeviceService} 单元测试。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeviceServiceTest extends com.example.lab.support.MybatisUnitTest {
@Mock
private DeviceMapper deviceMapper;
@Mock
private LabMapper labMapper;
@Mock
private ReservationMapper reservationMapper;
@Spy
private DeviceStateMachine deviceStateMachine = new DeviceStateMachine();
@InjectMocks
private DeviceService deviceService;
@Test
@DisplayName("分页查询")
@SuppressWarnings("unchecked")
void page() {
Page<Device> page = new Page<>(1, 10);
page.setRecords(List.of(device(1L, DeviceStatus.IDLE)));
page.setTotal(1);
when(deviceMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
PageResult<Device> result = deviceService.page(1, 10, null, 1L, DeviceStatus.IDLE);
assertThat(result.getRecords()).hasSize(1);
}
@Test
@DisplayName("按实验室查询设备")
void listByLab() {
when(deviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device(1L, DeviceStatus.IDLE)));
assertThat(deviceService.listByLab(1L)).hasSize(1);
}
@Test
@DisplayName("查询详情:存在与不存在")
void get() {
when(deviceMapper.selectById(1L)).thenReturn(device(1L, DeviceStatus.IDLE));
assertThat(deviceService.get(1L).getCode()).isEqualTo("DEV-PC-001");
when(deviceMapper.selectById(2L)).thenReturn(null);
assertThatThrownBy(() -> deviceService.get(2L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.DEVICE_NOT_FOUND);
}
@Test
@DisplayName("新增设备:实验室不存在报错")
void createLabMissing() {
when(labMapper.selectById(1L)).thenReturn(null);
assertThatThrownBy(() -> deviceService.create(request()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.LAB_NOT_FOUND);
}
@Test
@DisplayName("新增设备:编号重复报错")
void createDuplicate() {
when(labMapper.selectById(1L)).thenReturn(lab());
when(deviceMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThatThrownBy(() -> deviceService.create(request()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.DEVICE_CODE_EXISTS);
}
@Test
@DisplayName("新增设备成功")
void createSuccess() {
when(labMapper.selectById(1L)).thenReturn(lab());
when(deviceMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
Device device = deviceService.create(request());
assertThat(device.getStatus()).isEqualTo(DeviceStatus.IDLE);
verify(deviceMapper).insert(any(Device.class));
}
@Test
@DisplayName("修改设备")
void update() {
when(deviceMapper.selectById(1L)).thenReturn(device(1L, DeviceStatus.IDLE));
when(labMapper.selectById(1L)).thenReturn(lab());
when(deviceMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
DeviceRequest request = request();
request.setName("新设备名");
assertThat(deviceService.update(1L, request).getName()).isEqualTo("新设备名");
verify(deviceMapper).updateById(any(Device.class));
}
@Test
@DisplayName("删除设备:有占用预约时报错,无占用时成功")
void delete() {
when(deviceMapper.selectById(1L)).thenReturn(device(1L, DeviceStatus.IDLE));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThatThrownBy(() -> deviceService.delete(1L)).isInstanceOf(BizException.class);
verify(deviceMapper, never()).deleteById(any(Long.class));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
deviceService.delete(1L);
verify(deviceMapper).deleteById(1L);
}
@Test
@DisplayName("变更设备状态:合法与非法迁移")
void changeStatus() {
when(deviceMapper.selectById(1L)).thenReturn(device(1L, DeviceStatus.IDLE));
assertThat(deviceService.changeStatus(1L, DeviceStatus.MAINTENANCE).getStatus())
.isEqualTo(DeviceStatus.MAINTENANCE);
when(deviceMapper.selectById(1L)).thenReturn(device(1L, DeviceStatus.MAINTENANCE));
assertThatThrownBy(() -> deviceService.changeStatus(1L, DeviceStatus.IN_USE))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.INVALID_STATE_TRANSITION);
assertThatThrownBy(() -> deviceService.changeStatus(1L, null))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.BAD_REQUEST);
}
@Test
@DisplayName("分页查询:无关键字、实验室与状态过滤")
@SuppressWarnings("unchecked")
void pageWithoutFilters() {
Page<Device> page = new Page<>(1, 10);
page.setRecords(List.of());
page.setTotal(0);
when(deviceMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
assertThat(deviceService.page(1, 10, null, null, null).getTotal()).isZero();
}
@Test
@DisplayName("删除设备:统计结果为 null 时视为无占用")
void deleteWhenCountNull() {
when(deviceMapper.selectById(1L)).thenReturn(device(1L, DeviceStatus.IDLE));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(null);
deviceService.delete(1L);
verify(deviceMapper).deleteById(1L);
}
/**
* 构建设备。
*
* @param id 设备 ID
* @param status 状态
* @return 设备
*/
private Device device(Long id, DeviceStatus status) {
return Device.builder().id(id).labId(1L).code("DEV-PC-001").name("工作站").status(status).build();
}
/**
* 构造实验室。
*
* @return 实验室
*/
private Lab lab() {
return Lab.builder().id(1L).code("LAB-A101").name("计算机基础实验室")
.status(LabStatus.IDLE).capacity(40).build();
}
/**
* 构建设备请求。
*
* @return 请求
*/
private DeviceRequest request() {
DeviceRequest request = new DeviceRequest();
request.setLabId(1L);
request.setCode("DEV-PC-001");
request.setName("工作站");
return request;
}
}

View File

@@ -0,0 +1,270 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
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 org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link LabService} 单元测试。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class LabServiceTest extends com.example.lab.support.MybatisUnitTest {
@Mock
private LabMapper labMapper;
@Mock
private ReservationMapper reservationMapper;
@Spy
private LabStateMachine labStateMachine = new LabStateMachine();
@InjectMocks
private LabService labService;
@Test
@DisplayName("分页查询")
@SuppressWarnings("unchecked")
void page() {
Page<Lab> page = new Page<>(1, 10);
page.setRecords(List.of(lab(1L, LabStatus.IDLE)));
page.setTotal(1);
when(labMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
PageResult<Lab> result = labService.page(1, 10, "计算机", LabStatus.IDLE);
assertThat(result.getTotal()).isEqualTo(1);
assertThat(result.getRecords()).hasSize(1);
}
@Test
@DisplayName("查询全部实验室")
void listAll() {
when(labMapper.selectList(any(Wrapper.class))).thenReturn(List.of(lab(1L, LabStatus.IDLE)));
assertThat(labService.listAll()).hasSize(1);
}
@Test
@DisplayName("查询详情:存在与不存在")
void get() {
when(labMapper.selectById(1L)).thenReturn(lab(1L, LabStatus.IDLE));
assertThat(labService.get(1L).getCode()).isEqualTo("LAB-A101");
when(labMapper.selectById(2L)).thenReturn(null);
assertThatThrownBy(() -> labService.get(2L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.LAB_NOT_FOUND);
}
@Test
@DisplayName("新增实验室:编号唯一校验")
void createDuplicate() {
when(labMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThatThrownBy(() -> labService.create(request("LAB-A101"))).isInstanceOf(BizException.class);
verify(labMapper, never()).insert(any(Lab.class));
}
@Test
@DisplayName("新增实验室:成功且初始状态为空闲")
void createSuccess() {
when(labMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
Lab lab = labService.create(request("LAB-NEW"));
assertThat(lab.getStatus()).isEqualTo(LabStatus.IDLE);
verify(labMapper).insert(any(Lab.class));
}
@Test
@DisplayName("新增实验室:开放时间非法")
void createInvalidOpeningHours() {
when(labMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
LabRequest request = request("LAB-NEW");
request.setOpenTime(LocalTime.of(22, 0));
request.setCloseTime(LocalTime.of(8, 0));
assertThatThrownBy(() -> labService.create(request))
.isInstanceOf(BizException.class)
.hasMessageContaining("开放开始时间");
}
@Test
@DisplayName("修改实验室")
void update() {
when(labMapper.selectById(1L)).thenReturn(lab(1L, LabStatus.IDLE));
when(labMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
LabRequest request = request("LAB-A101");
request.setName("新名称");
Lab lab = labService.update(1L, request);
assertThat(lab.getName()).isEqualTo("新名称");
verify(labMapper).updateById(any(Lab.class));
}
@Test
@DisplayName("删除实验室:存在未结束预约时报错")
void deleteWithActiveReservation() {
when(labMapper.selectById(1L)).thenReturn(lab(1L, LabStatus.IDLE));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(2L);
assertThatThrownBy(() -> labService.delete(1L))
.isInstanceOf(BizException.class)
.hasMessageContaining("未结束");
verify(labMapper, never()).deleteById(any(Long.class));
}
@Test
@DisplayName("删除实验室:无占用时成功")
void deleteSuccess() {
when(labMapper.selectById(1L)).thenReturn(lab(1L, LabStatus.IDLE));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
labService.delete(1L);
verify(labMapper).deleteById(1L);
}
@Test
@DisplayName("变更状态:合法迁移成功,非法迁移报错")
void changeStatus() {
Lab lab = lab(1L, LabStatus.IDLE);
when(labMapper.selectById(1L)).thenReturn(lab);
assertThat(labService.changeStatus(1L, LabStatus.MAINTENANCE).getStatus())
.isEqualTo(LabStatus.MAINTENANCE);
when(labMapper.selectById(1L)).thenReturn(lab(1L, LabStatus.MAINTENANCE));
assertThatThrownBy(() -> labService.changeStatus(1L, LabStatus.IN_USE))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.INVALID_STATE_TRANSITION);
assertThatThrownBy(() -> labService.changeStatus(1L, null))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.BAD_REQUEST);
}
@Test
@DisplayName("可用实验室查询:过滤维护中与已被占用的实验室")
void listAvailable() {
when(labMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
lab(1L, LabStatus.IDLE), lab(2L, LabStatus.RESERVED)));
when(reservationMapper.selectConflicting(eq(1L), isNull(), isNull(), any(), any(), isNull(),
anyList(), anyBoolean())).thenReturn(List.of());
when(reservationMapper.selectConflicting(eq(2L), isNull(), isNull(), any(), any(), isNull(),
anyList(), anyBoolean())).thenReturn(List.of(Reservation.builder().id(9L).build()));
List<Lab> available = labService.listAvailable(LocalDateTime.now(), LocalDateTime.now().plusHours(2), 10);
assertThat(available).extracting(Lab::getId).containsExactly(1L);
}
@Test
@DisplayName("可用实验室查询:时间区间非法时报错")
void listAvailableInvalidRange() {
assertThatThrownBy(() -> labService.listAvailable(null, null, null))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.INVALID_TIME_RANGE);
assertThatThrownBy(() -> labService.listAvailable(LocalDateTime.now(), LocalDateTime.now().minusHours(1), null))
.isInstanceOf(BizException.class);
}
@Test
@DisplayName("分页查询:无关键字与状态过滤")
@SuppressWarnings("unchecked")
void pageWithoutFilters() {
Page<Lab> page = new Page<>(1, 10);
page.setRecords(List.of());
page.setTotal(0);
when(labMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
assertThat(labService.page(1, 10, null, null).getTotal()).isZero();
}
@Test
@DisplayName("新增实验室:未配置开放时间也可创建")
void createWithoutOpeningHours() {
when(labMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
LabRequest request = request("LAB-NO-TIME");
request.setOpenTime(null);
request.setCloseTime(null);
assertThat(labService.create(request).getOpenTime()).isNull();
}
@Test
@DisplayName("修改实验室:时间为空时保留原值")
void updateKeepsOriginalTimes() {
when(labMapper.selectById(1L)).thenReturn(lab(1L, LabStatus.IDLE));
when(labMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
LabRequest request = request("LAB-A101");
request.setOpenTime(null);
request.setCloseTime(null);
Lab updated = labService.update(1L, request);
assertThat(updated.getOpenTime()).isEqualTo(LocalTime.of(8, 0));
assertThat(updated.getCloseTime()).isEqualTo(LocalTime.of(22, 0));
}
@Test
@DisplayName("删除实验室:统计结果为 null 时视为无占用")
void deleteWhenCountNull() {
when(labMapper.selectById(1L)).thenReturn(lab(1L, LabStatus.IDLE));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(null);
labService.delete(1L);
verify(labMapper).deleteById(1L);
}
/**
* 构造实验室。
*
* @param id 实验室 ID
* @param status 状态
* @return 实验室
*/
private Lab lab(Long id, LabStatus status) {
return Lab.builder().id(id).code("LAB-A101").name("计算机基础实验室").location("A101")
.capacity(40).status(status).openTime(LocalTime.of(8, 0)).closeTime(LocalTime.of(22, 0)).build();
}
/**
* 构造请求。
*
* @param code 实验室编号
* @return 请求
*/
private LabRequest request(String code) {
LabRequest request = new LabRequest();
request.setCode(code);
request.setName("计算机基础实验室");
request.setLocation("实验楼 A101");
request.setCapacity(40);
request.setOpenTime(LocalTime.of(8, 0));
request.setCloseTime(LocalTime.of(22, 0));
return request;
}
}

View File

@@ -0,0 +1,957 @@
package com.example.lab.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
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.config.LabProperties;
import com.example.lab.domain.conflict.Conflict;
import com.example.lab.domain.conflict.ConflictDetector;
import com.example.lab.domain.conflict.ConflictType;
import com.example.lab.domain.conflict.RecurrenceExpander;
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.entity.User;
import com.example.lab.domain.enums.CourseStatus;
import com.example.lab.domain.enums.DeviceStatus;
import com.example.lab.domain.enums.LabStatus;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import com.example.lab.domain.enums.Role;
import com.example.lab.domain.statemachine.DeviceStateMachine;
import com.example.lab.domain.statemachine.LabStateMachine;
import com.example.lab.domain.statemachine.ReservationStateMachine;
import com.example.lab.dto.ConflictCheckRequest;
import com.example.lab.dto.CourseReservationRequest;
import com.example.lab.dto.RecurringReservationRequest;
import com.example.lab.dto.ReservationCreateRequest;
import com.example.lab.dto.ReservationQuery;
import com.example.lab.mapper.CourseMapper;
import com.example.lab.mapper.DeviceMapper;
import com.example.lab.mapper.LabMapper;
import com.example.lab.mapper.ReservationMapper;
import com.example.lab.mapper.ReservationSeriesMapper;
import com.example.lab.mapper.UserMapper;
import com.example.lab.vo.ReservationVO;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Collection;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ReservationService} 单元测试:覆盖三种预约场景、冲突校验、审批与状态流转、权限控制。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ReservationServiceTest extends com.example.lab.support.MybatisUnitTest {
private static final LoginUser STUDENT = new LoginUser(4L, "student1", "李同学", Role.STUDENT);
private static final LoginUser TEACHER = new LoginUser(2L, "teacher1", "张老师", Role.TEACHER);
private static final LoginUser ADMIN = new LoginUser(1L, "admin", "系统管理员", Role.ADMIN);
@Mock
private ReservationMapper reservationMapper;
@Mock
private ReservationSeriesMapper seriesMapper;
@Mock
private LabMapper labMapper;
@Mock
private DeviceMapper deviceMapper;
@Mock
private CourseMapper courseMapper;
@Mock
private UserMapper userMapper;
@Spy
private LabProperties properties = properties();
@Spy
private ConflictDetector conflictDetector = new ConflictDetector(properties);
@Spy
private RecurrenceExpander recurrenceExpander = new RecurrenceExpander(properties);
@Spy
private ReservationStateMachine reservationStateMachine = new ReservationStateMachine();
@Spy
private LabStateMachine labStateMachine = new LabStateMachine();
@Spy
private DeviceStateMachine deviceStateMachine = new DeviceStateMachine();
@InjectMocks
private ReservationService reservationService;
@BeforeEach
void setUp() {
when(labMapper.selectById(any())).thenReturn(lab(LabStatus.IDLE));
when(deviceMapper.selectById(any())).thenReturn(device(DeviceStatus.IDLE));
when(reservationMapper.selectConflicting(any(), any(), any(), any(), any(), any(), anyList(), anyBoolean()))
.thenReturn(List.of());
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L);
when(reservationMapper.insert(any(Reservation.class))).thenAnswer(invocation -> {
Reservation reservation = invocation.getArgument(0);
reservation.setId(100L);
return 1;
});
when(reservationMapper.updateById(any(Reservation.class))).thenReturn(1);
when(userMapper.selectByIds(anyCollection())).thenReturn(List.of(user(4L, "李同学", Role.STUDENT)));
when(labMapper.selectByIds(anyCollection())).thenReturn(List.of(lab(LabStatus.IDLE)));
when(deviceMapper.selectByIds(anyCollection())).thenReturn(List.of(device(DeviceStatus.IDLE)));
when(courseMapper.selectByIds(anyCollection())).thenReturn(List.of());
}
@AfterEach
void tearDown() {
UserContext.clear();
}
/**
* 提供任意集合匹配器,规避 selectByIds 的泛型歧义。
*
* @param <T> 集合元素类型
* @return 匹配器
*/
@SuppressWarnings("unchecked")
private static <T> Collection<T> anyCollection() {
return org.mockito.ArgumentMatchers.anyCollection();
}
// ------------------------------------------------------------------
// 单次预约
// ------------------------------------------------------------------
@Test
@DisplayName("学生创建单次预约:进入待审批并同步实验室状态")
void createSingleAsStudent() {
UserContext.set(STUDENT);
ReservationVO vo = reservationService.create(singleRequest());
assertThat(vo.getStatus()).isEqualTo(ReservationStatus.PENDING.name());
assertThat(vo.getUserName()).isEqualTo("李同学");
assertThat(vo.getLabName()).isEqualTo("计算机基础实验室");
assertThat(vo.getDeviceName()).isNull();
assertThat(vo.getCourseName()).isNull();
assertThat(vo.getApproverName()).isNull();
verify(reservationMapper).insert(any(Reservation.class));
verify(labMapper).updateById(any(Lab.class));
}
@Test
@DisplayName("教师/管理员创建单次预约自动通过")
void createSingleAutoApproved() {
UserContext.set(TEACHER);
assertThat(reservationService.create(singleRequest()).getStatus())
.isEqualTo(ReservationStatus.APPROVED.name());
UserContext.set(ADMIN);
assertThat(reservationService.create(singleRequest()).getStatus())
.isEqualTo(ReservationStatus.APPROVED.name());
}
@Test
@DisplayName("预约不存在的实验室")
void createLabNotFound() {
UserContext.set(STUDENT);
when(labMapper.selectById(9L)).thenReturn(null);
ReservationCreateRequest request = singleRequest();
request.setLabId(9L);
assertThatThrownBy(() -> reservationService.create(request))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.LAB_NOT_FOUND);
}
@Test
@DisplayName("预约不存在或不属于该实验室的设备")
void createDeviceMismatch() {
UserContext.set(STUDENT);
when(deviceMapper.selectById(5L)).thenReturn(null);
ReservationCreateRequest missing = singleRequest();
missing.setDeviceId(5L);
assertThatThrownBy(() -> reservationService.create(missing))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.DEVICE_NOT_FOUND);
when(deviceMapper.selectById(6L)).thenReturn(Device.builder().id(6L).labId(2L).build());
ReservationCreateRequest mismatch = singleRequest();
mismatch.setDeviceId(6L);
assertThatThrownBy(() -> reservationService.create(mismatch))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.DEVICE_LAB_MISMATCH);
}
@Test
@DisplayName("单次预约冲突时拒绝写入")
void createSingleConflict() {
UserContext.set(STUDENT);
when(reservationMapper.selectConflicting(any(), any(), any(), any(), any(), any(), anyList(), anyBoolean()))
.thenReturn(List.of(existing(9L, 1L, null, 3L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1).withHour(11),
LocalDateTime.now().plusDays(1).withHour(13))));
assertThatThrownBy(() -> reservationService.create(singleRequest()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.RESERVATION_CONFLICT);
verify(reservationMapper, never()).insert(any(Reservation.class));
}
@Test
@DisplayName("时间区间非法时直接拒绝")
void createSingleInvalidRange() {
UserContext.set(STUDENT);
ReservationCreateRequest request = singleRequest();
request.setEndTime(request.getStartTime().minusHours(1));
assertThatThrownBy(() -> reservationService.create(request))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.RESERVATION_CONFLICT);
}
// ------------------------------------------------------------------
// 多次预约
// ------------------------------------------------------------------
@Test
@DisplayName("多次预约:生成系列与全部场次")
void createRecurring() {
UserContext.set(STUDENT);
RecurringReservationRequest request = new RecurringReservationRequest();
request.setLabId(1L);
request.setTitle("每周训练");
request.setStartDate(LocalDate.now().plusDays(1));
request.setEndDate(LocalDate.now().plusDays(14));
request.setWeekdays("MON,WED");
request.setStartTime(LocalTime.of(9, 0));
request.setEndTime(LocalTime.of(11, 0));
request.setParticipants(3);
List<ReservationVO> created = reservationService.createRecurring(request);
assertThat(created).isNotEmpty();
assertThat(created).allMatch(vo -> ReservationType.RECURRING.name().equals(vo.getType()));
verify(seriesMapper).insert(any(com.example.lab.domain.entity.ReservationSeries.class));
verify(reservationMapper, times(created.size())).insert(any(Reservation.class));
}
@Test
@DisplayName("多次预约:任一场次冲突则整体拒绝")
void createRecurringConflict() {
UserContext.set(STUDENT);
LocalDate first = LocalDate.now().plusDays(1);
when(reservationMapper.selectConflicting(any(), any(), any(), any(), any(), any(), anyList(), anyBoolean()))
.thenReturn(List.of(existing(9L, 1L, null, 3L, ReservationStatus.APPROVED,
first.atTime(9, 0), first.atTime(10, 0))));
RecurringReservationRequest request = new RecurringReservationRequest();
request.setLabId(1L);
request.setTitle("每周训练");
request.setStartDate(first);
request.setEndDate(first.plusDays(7));
request.setWeekdays("MON,TUE,WED,THU,FRI,SAT,SUN");
request.setStartTime(LocalTime.of(9, 0));
request.setEndTime(LocalTime.of(11, 0));
assertThatThrownBy(() -> reservationService.createRecurring(request))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.RESERVATION_CONFLICT);
verify(seriesMapper, never()).insert(any(com.example.lab.domain.entity.ReservationSeries.class));
}
// ------------------------------------------------------------------
// 课程绑定预约
// ------------------------------------------------------------------
@Test
@DisplayName("课程预约:非授课教师被拒绝")
void createForCourseForbidden() {
UserContext.set(STUDENT);
when(courseMapper.selectById(1L)).thenReturn(course(CourseStatus.ACTIVE, 2L));
CourseReservationRequest request = new CourseReservationRequest();
request.setCourseId(1L);
assertThatThrownBy(() -> reservationService.createForCourse(request))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.FORBIDDEN);
}
@Test
@DisplayName("课程预约:课程已结束被拒绝")
void createForCourseFinished() {
UserContext.set(TEACHER);
when(courseMapper.selectById(1L)).thenReturn(course(CourseStatus.FINISHED, 2L));
CourseReservationRequest request = new CourseReservationRequest();
request.setCourseId(1L);
assertThatThrownBy(() -> reservationService.createForCourse(request))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.COURSE_NOT_ACTIVE);
}
@Test
@DisplayName("课程预约:授课教师生成整期场次")
void createForCourseSuccess() {
UserContext.set(TEACHER);
when(courseMapper.selectById(1L)).thenReturn(course(CourseStatus.ACTIVE, 2L));
when(courseMapper.selectByIds(anyCollection())).thenReturn(List.of(course(CourseStatus.ACTIVE, 2L)));
CourseReservationRequest request = new CourseReservationRequest();
request.setCourseId(1L);
request.setDeviceId(1L);
List<ReservationVO> created = reservationService.createForCourse(request);
assertThat(created).hasSize(2);
assertThat(created.get(0).getCourseName()).isEqualTo("程序设计基础实验");
assertThat(created.get(0).getType()).isEqualTo(ReservationType.COURSE.name());
assertThat(created.get(0).getStatus()).isEqualTo(ReservationStatus.APPROVED.name());
assertThat(created.get(0).getParticipants()).isEqualTo(40);
verify(seriesMapper).insert(any(com.example.lab.domain.entity.ReservationSeries.class));
}
// ------------------------------------------------------------------
// 冲突预检
// ------------------------------------------------------------------
@Test
@DisplayName("冲突预检:单次模式与重复/课程模式")
void check() {
UserContext.set(STUDENT);
ConflictCheckRequest single = new ConflictCheckRequest();
single.setLabId(1L);
single.setType(ReservationType.SINGLE);
single.setStartTime(LocalDateTime.now().plusDays(1).withHour(10));
single.setEndTime(LocalDateTime.now().plusDays(1).withHour(12));
assertThat(reservationService.check(single)).isEmpty();
ConflictCheckRequest recurring = new ConflictCheckRequest();
recurring.setLabId(1L);
recurring.setType(ReservationType.RECURRING);
recurring.setStartDate(LocalDate.now().plusDays(1));
recurring.setEndDate(LocalDate.now().plusDays(8));
recurring.setWeekdays("MON,WED");
recurring.setStartTimeOfDay(LocalTime.of(9, 0));
recurring.setEndTimeOfDay(LocalTime.of(10, 0));
assertThat(reservationService.check(recurring)).isEmpty();
when(courseMapper.selectById(1L)).thenReturn(course(CourseStatus.ACTIVE, 2L));
ConflictCheckRequest course = new ConflictCheckRequest();
course.setLabId(1L);
course.setCourseId(1L);
course.setType(ReservationType.COURSE);
assertThat(reservationService.check(course)).isEmpty();
}
@Test
@DisplayName("冲突预检:课程模式缺少课程报错")
void checkCourseMissing() {
UserContext.set(STUDENT);
ConflictCheckRequest request = new ConflictCheckRequest();
request.setLabId(1L);
request.setType(ReservationType.COURSE);
assertThatThrownBy(() -> reservationService.check(request))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.COURSE_NOT_FOUND);
}
@Test
@DisplayName("冲突预检:返回具体冲突明细")
void checkReturnsConflicts() {
UserContext.set(STUDENT);
when(reservationMapper.selectConflicting(any(), any(), any(), any(), any(), any(), anyList(), anyBoolean()))
.thenReturn(List.of(existing(9L, 1L, null, 3L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1).withHour(10),
LocalDateTime.now().plusDays(1).withHour(12))));
ConflictCheckRequest request = new ConflictCheckRequest();
request.setLabId(1L);
request.setType(ReservationType.SINGLE);
request.setStartTime(LocalDateTime.now().plusDays(1).withHour(11));
request.setEndTime(LocalDateTime.now().plusDays(1).withHour(13));
List<Conflict> conflicts = reservationService.check(request);
assertThat(conflicts).extracting(Conflict::type).contains(ConflictType.LAB_CONFLICT);
}
// ------------------------------------------------------------------
// 查询
// ------------------------------------------------------------------
@Test
@DisplayName("分页查询:学生强制只看自己,管理员可按用户过滤")
@SuppressWarnings("unchecked")
void page() {
Page<Reservation> page = new Page<>(1, 10);
page.setRecords(List.of(existing(1L, 1L, null, 4L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(1).plusHours(2))));
page.setTotal(1);
when(reservationMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
UserContext.set(STUDENT);
ReservationQuery studentQuery = new ReservationQuery();
studentQuery.setMine(true);
studentQuery.setStatus(ReservationStatus.APPROVED);
studentQuery.setType(ReservationType.SINGLE);
studentQuery.setLabId(1L);
studentQuery.setKeyword("训练");
studentQuery.setFrom(LocalDate.now());
studentQuery.setTo(LocalDate.now().plusDays(2));
PageResult<ReservationVO> result = reservationService.page(studentQuery);
assertThat(result.getRecords()).hasSize(1);
UserContext.set(ADMIN);
ReservationQuery adminQuery = new ReservationQuery();
adminQuery.setUserId(4L);
assertThat(reservationService.page(adminQuery).getTotal()).isEqualTo(1);
}
@Test
@DisplayName("查询详情:学生只能看自己的预约")
void getDetail() {
Reservation reservation = existing(1L, 1L, null, 4L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(1).plusHours(2));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
UserContext.set(new LoginUser(5L, "student2", "陈同学", Role.STUDENT));
assertThatThrownBy(() -> reservationService.get(1L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.NOT_RESERVATION_OWNER);
UserContext.set(STUDENT);
assertThat(reservationService.get(1L).getId()).isEqualTo(1L);
when(reservationMapper.selectById(99L)).thenReturn(null);
assertThatThrownBy(() -> reservationService.get(99L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.RESERVATION_NOT_FOUND);
}
// ------------------------------------------------------------------
// 审批
// ------------------------------------------------------------------
@Test
@DisplayName("审批通过:写入审批人与审批时间")
void approve() {
UserContext.set(TEACHER);
Reservation reservation = existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
ReservationVO vo = reservationService.approve(1L);
assertThat(vo.getStatus()).isEqualTo(ReservationStatus.APPROVED.name());
assertThat(reservation.getApproverId()).isEqualTo(2L);
assertThat(reservation.getApprovedAt()).isNotNull();
verify(reservationMapper).updateById(reservation);
}
@Test
@DisplayName("审批:自己不能审批自己的预约")
void approveOwn() {
UserContext.set(TEACHER);
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 2L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12)));
assertThatThrownBy(() -> reservationService.approve(1L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.FORBIDDEN);
}
@Test
@DisplayName("审批:学生没有审批权限")
void approveWithoutPermission() {
UserContext.set(STUDENT);
assertThatThrownBy(() -> reservationService.approve(1L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.FORBIDDEN);
}
@Test
@DisplayName("审批:状态非法时报错")
void approveInvalidState() {
UserContext.set(TEACHER);
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 4L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12)));
assertThatThrownBy(() -> reservationService.approve(1L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.INVALID_STATE_TRANSITION);
}
@Test
@DisplayName("审批:冲突复检发现重叠时拒绝")
void approveConflict() {
UserContext.set(TEACHER);
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12)));
when(reservationMapper.selectConflicting(any(), any(), any(), any(), any(), any(), anyList(), anyBoolean()))
.thenReturn(List.of(existing(9L, 1L, null, 3L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1).withHour(11),
LocalDateTime.now().plusDays(1).withHour(13))));
assertThatThrownBy(() -> reservationService.approve(1L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.RESERVATION_CONFLICT);
}
@Test
@DisplayName("驳回:写入原因;自己不能驳回自己的预约")
void reject() {
UserContext.set(TEACHER);
Reservation reservation = existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
ReservationVO vo = reservationService.reject(1L, "时间不合适");
assertThat(vo.getStatus()).isEqualTo(ReservationStatus.REJECTED.name());
assertThat(reservation.getRejectReason()).isEqualTo("时间不合适");
when(reservationMapper.selectById(2L)).thenReturn(existing(2L, 1L, null, 2L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12)));
assertThatThrownBy(() -> reservationService.reject(2L, "原因"))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.FORBIDDEN);
}
// ------------------------------------------------------------------
// 取消 / 开始 / 完成
// ------------------------------------------------------------------
@Test
@DisplayName("取消单条预约:本人可取消,他人不可")
void cancelSingle() {
UserContext.set(STUDENT);
Reservation reservation = existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
List<ReservationVO> canceled = reservationService.cancel(1L, false);
assertThat(canceled).hasSize(1);
assertThat(canceled.get(0).getStatus()).isEqualTo(ReservationStatus.CANCELLED.name());
when(reservationMapper.selectById(2L)).thenReturn(existing(2L, 1L, null, 5L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12)));
assertThatThrownBy(() -> reservationService.cancel(2L, false))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.NOT_RESERVATION_OWNER);
}
@Test
@DisplayName("取消系列预约:批量取消未结束场次")
void cancelSeries() {
UserContext.set(STUDENT);
Reservation first = existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12));
first.setSeriesId(88L);
first.setDeviceId(1L);
when(reservationMapper.selectById(1L)).thenReturn(first);
when(reservationMapper.selectList(any(Wrapper.class))).thenReturn(List.of(first,
existing(2L, 1L, null, 4L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(2).withHour(10),
LocalDateTime.now().plusDays(2).withHour(12))));
List<ReservationVO> canceled = reservationService.cancel(1L, true);
assertThat(canceled).hasSize(2);
verify(reservationMapper, times(2)).updateById(any(Reservation.class));
}
@Test
@DisplayName("取消:使用中的预约不可取消")
void cancelInUse() {
UserContext.set(STUDENT);
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 4L, ReservationStatus.IN_USE,
LocalDateTime.now().minusHours(1), LocalDateTime.now().plusHours(1)));
assertThatThrownBy(() -> reservationService.cancel(1L, false))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.INVALID_STATE_TRANSITION);
}
@Test
@DisplayName("开始使用:状态由已通过变为使用中")
void start() {
UserContext.set(STUDENT);
Reservation reservation = existing(1L, 1L, 1L, 4L, ReservationStatus.APPROVED,
LocalDateTime.now(), LocalDateTime.now().plusHours(2));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
ReservationVO vo = reservationService.start(1L);
assertThat(vo.getStatus()).isEqualTo(ReservationStatus.IN_USE.name());
// 资源状态从 IDLE 经 RESERVED 推进到 IN_USE,共两次更新
verify(labMapper, times(2)).updateById(any(Lab.class));
verify(deviceMapper, times(2)).updateById(any(Device.class));
}
@Test
@DisplayName("完成使用:状态由使用中变为已完成,资源回到空闲")
void complete() {
UserContext.set(STUDENT);
Reservation reservation = existing(1L, 1L, null, 4L, ReservationStatus.IN_USE,
LocalDateTime.now().minusHours(1), LocalDateTime.now().plusHours(1));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L);
ReservationVO vo = reservationService.complete(1L);
assertThat(vo.getStatus()).isEqualTo(ReservationStatus.COMPLETED.name());
verify(labMapper).updateById(any(Lab.class));
}
@Test
@DisplayName("开始使用:状态非法时报错")
void startInvalidState() {
UserContext.set(STUDENT);
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now(), LocalDateTime.now().plusHours(2)));
assertThatThrownBy(() -> reservationService.start(1L))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.INVALID_STATE_TRANSITION);
}
// ------------------------------------------------------------------
// 日历、自动完结、资源状态同步
// ------------------------------------------------------------------
@Test
@DisplayName("日历查询:非法日期报错,正常范围返回预约")
void calendar() {
when(reservationMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
existing(1L, 1L, null, 4L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(1).plusHours(2))));
List<ReservationVO> events = reservationService.calendar(1L, LocalDate.now(), LocalDate.now().plusDays(7));
assertThat(events).hasSize(1);
assertThatThrownBy(() -> reservationService.calendar(1L, LocalDate.now().plusDays(7), LocalDate.now()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.BAD_REQUEST);
when(labMapper.selectById(9L)).thenReturn(null);
assertThatThrownBy(() -> reservationService.calendar(9L, LocalDate.now(), LocalDate.now().plusDays(1)))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.LAB_NOT_FOUND);
}
@Test
@DisplayName("自动完结:过期预约转为已完成并同步资源")
void autoCompleteExpired() {
Reservation expiredApproved = existing(1L, 1L, 1L, 4L, ReservationStatus.APPROVED,
LocalDateTime.now().minusHours(3), LocalDateTime.now().minusHours(1));
Reservation expiredInUse = existing(2L, 1L, null, 4L, ReservationStatus.IN_USE,
LocalDateTime.now().minusHours(2), LocalDateTime.now().minusMinutes(30));
when(reservationMapper.selectList(any(Wrapper.class)))
.thenReturn(List.of(expiredApproved), List.of(expiredInUse));
int count = reservationService.autoCompleteExpired();
assertThat(count).isEqualTo(2);
assertThat(expiredApproved.getStatus()).isEqualTo(ReservationStatus.COMPLETED);
assertThat(expiredInUse.getStatus()).isEqualTo(ReservationStatus.COMPLETED);
verify(reservationMapper, times(2)).updateById(any(Reservation.class));
}
@Test
@DisplayName("自动完结:无过期预约时不更新")
void autoCompleteExpiredNothing() {
when(reservationMapper.selectList(any(Wrapper.class))).thenReturn(List.of(), List.of());
assertThat(reservationService.autoCompleteExpired()).isZero();
verify(reservationMapper, never()).updateById(any(Reservation.class));
}
@Test
@DisplayName("资源维护中时状态不参与同步")
void syncSkipsMaintenance() {
UserContext.set(STUDENT);
when(labMapper.selectById(any())).thenReturn(lab(LabStatus.MAINTENANCE));
when(deviceMapper.selectById(any())).thenReturn(device(DeviceStatus.MAINTENANCE));
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, 1L, 4L, ReservationStatus.IN_USE,
LocalDateTime.now().minusHours(1), LocalDateTime.now().plusHours(1)));
reservationService.complete(1L);
verify(labMapper, never()).updateById(any(Lab.class));
verify(deviceMapper, never()).updateById(any(Device.class));
}
// ------------------------------------------------------------------
// 补充边界
// ------------------------------------------------------------------
@Test
@DisplayName("单次预约:指定设备、缺省人数为 1,并同步设备状态")
void createWithDeviceAndDefaultParticipants() {
UserContext.set(STUDENT);
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L, 0L, 1L);
ReservationCreateRequest request = singleRequest();
request.setDeviceId(1L);
request.setParticipants(null);
ReservationVO vo = reservationService.create(request);
assertThat(vo.getParticipants()).isEqualTo(1);
assertThat(vo.getDeviceName()).isEqualTo("工作站");
verify(deviceMapper).updateById(any(Device.class));
}
@Test
@DisplayName("多次预约:指定设备时同样同步设备状态")
void createRecurringWithDevice() {
UserContext.set(TEACHER);
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L, 0L, 1L);
LocalDate start = LocalDate.now().plusDays(1);
RecurringReservationRequest request = new RecurringReservationRequest();
request.setLabId(1L);
request.setDeviceId(1L);
request.setTitle("设备重复预约");
request.setStartDate(start);
request.setEndDate(start);
request.setWeekdays(String.valueOf(start.getDayOfWeek().getValue()));
request.setStartTime(LocalTime.of(13, 0));
request.setEndTime(LocalTime.of(14, 0));
List<ReservationVO> created = reservationService.createRecurring(request);
assertThat(created).hasSize(1);
verify(deviceMapper).updateById(any(Device.class));
}
@Test
@DisplayName("课程预约:管理员发起、标题空白回退课程名、人数缺省为 1")
void createForCourseAsAdminWithFallbacks() {
UserContext.set(ADMIN);
Course course = course(CourseStatus.ACTIVE, 2L);
course.setStudentCount(null);
when(courseMapper.selectById(1L)).thenReturn(course);
when(courseMapper.selectByIds(anyCollection())).thenReturn(List.of(course));
CourseReservationRequest request = new CourseReservationRequest();
request.setCourseId(1L);
request.setTitle(" ");
List<ReservationVO> created = reservationService.createForCourse(request);
assertThat(created).hasSize(2);
assertThat(created.get(0).getTitle()).isEqualTo("程序设计基础实验");
assertThat(created.get(0).getParticipants()).isEqualTo(1);
}
@Test
@DisplayName("冲突预检:显式人数参与容量校验")
void checkWithExplicitParticipants() {
UserContext.set(STUDENT);
ConflictCheckRequest request = new ConflictCheckRequest();
request.setLabId(1L);
request.setType(ReservationType.SINGLE);
request.setParticipants(100);
request.setStartTime(LocalDateTime.now().plusDays(1).withHour(10));
request.setEndTime(LocalDateTime.now().plusDays(1).withHour(12));
assertThat(reservationService.check(request))
.extracting(Conflict::type)
.contains(ConflictType.CAPACITY_EXCEEDED);
}
@Test
@DisplayName("分页查询:管理员无过滤条件时不限制用户与关键字")
@SuppressWarnings("unchecked")
void pageAdminWithoutFilters() {
Page<Reservation> page = new Page<>(1, 10);
page.setRecords(List.of());
page.setTotal(0);
when(reservationMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
UserContext.set(ADMIN);
ReservationQuery query = new ReservationQuery();
assertThat(reservationService.page(query).getTotal()).isZero();
}
@Test
@DisplayName("审批:管理员可审批任意预约")
void approveAsAdmin() {
UserContext.set(ADMIN);
Reservation reservation = existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
assertThat(reservationService.approve(1L).getStatus()).isEqualTo(ReservationStatus.APPROVED.name());
}
@Test
@DisplayName("驳回:管理员可驳回任意预约(包括自己提交的)")
void rejectAsAdmin() {
UserContext.set(ADMIN);
Reservation reservation = existing(1L, 1L, null, 1L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12));
when(reservationMapper.selectById(1L)).thenReturn(reservation);
assertThat(reservationService.reject(1L, "管理员驳回").getStatus())
.isEqualTo(ReservationStatus.REJECTED.name());
}
@Test
@DisplayName("取消:单条预约即使指定系列参数也只取消一条")
void cancelSingleWithSeriesFlag() {
UserContext.set(STUDENT);
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 4L, ReservationStatus.PENDING,
LocalDateTime.now().plusDays(1).withHour(10), LocalDateTime.now().plusDays(1).withHour(12)));
assertThat(reservationService.cancel(1L, true)).hasSize(1);
}
@Test
@DisplayName("日历查询:缺少起始日期时报参数错误")
void calendarWithNullFrom() {
assertThatThrownBy(() -> reservationService.calendar(1L, null, LocalDate.now()))
.isInstanceOf(BizException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.BAD_REQUEST);
}
@Test
@DisplayName("日历查询:无预约时返回空列表")
void calendarEmpty() {
when(reservationMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
assertThat(reservationService.calendar(1L, LocalDate.now(), LocalDate.now().plusDays(1))).isEmpty();
}
@Test
@DisplayName("完成使用:实验室从使用中经空闲回到已预约")
void completeMovesLabFromInUseToReserved() {
UserContext.set(STUDENT);
when(labMapper.selectById(any())).thenReturn(lab(LabStatus.IN_USE));
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 4L, ReservationStatus.IN_USE,
LocalDateTime.now().minusHours(1), LocalDateTime.now().plusHours(1)));
when(reservationMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L);
reservationService.complete(1L);
// IN_USE -> IDLE -> RESERVED 需要两步
verify(labMapper, times(2)).updateById(any(Lab.class));
}
@Test
@DisplayName("详情查询:教师可查看他人预约")
void getAsTeacher() {
UserContext.set(TEACHER);
when(reservationMapper.selectById(1L)).thenReturn(existing(1L, 1L, null, 4L, ReservationStatus.APPROVED,
LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(1).plusHours(2)));
assertThat(reservationService.get(1L).getUserName()).isEqualTo("李同学");
}
// ------------------------------------------------------------------
// 辅助构造
// ------------------------------------------------------------------
/**
* 构造单次预约请求。
*
* @return 请求
*/
private ReservationCreateRequest singleRequest() {
ReservationCreateRequest request = new ReservationCreateRequest();
request.setLabId(1L);
request.setTitle("毕业设计调试");
request.setPurpose("联调");
request.setStartTime(LocalDateTime.now().plusDays(1).withHour(10));
request.setEndTime(LocalDateTime.now().plusDays(1).withHour(12));
request.setParticipants(5);
return request;
}
/**
* 构造实验室。
*
* @param status 状态
* @return 实验室
*/
private Lab lab(LabStatus status) {
return Lab.builder().id(1L).code("LAB-A101").name("计算机基础实验室").capacity(40)
.status(status).openTime(LocalTime.of(8, 0)).closeTime(LocalTime.of(22, 0)).build();
}
/**
* 构造设备。
*
* @param status 状态
* @return 设备
*/
private Device device(DeviceStatus status) {
return Device.builder().id(1L).labId(1L).code("DEV-PC-001").name("工作站").status(status).build();
}
/**
* 构造课程。
*
* @param status 状态
* @param teacherId 授课教师
* @return 课程
*/
private Course course(CourseStatus status, Long teacherId) {
LocalDate start = LocalDate.now().plusDays(1);
return Course.builder().id(1L).code("CS101").name("程序设计基础实验").teacherId(teacherId)
.labId(1L).studentCount(40).startDate(start).endDate(start.plusDays(1))
.weekdays("MON,TUE,WED,THU,FRI,SAT,SUN").startTime(LocalTime.of(10, 0))
.endTime(LocalTime.of(12, 0)).status(status).build();
}
/**
* 构造已有预约。
*
* @param id 预约 ID
* @param labId 实验室
* @param deviceId 设备
* @param userId 用户
* @param status 状态
* @param start 开始
* @param end 结束
* @return 预约
*/
private Reservation existing(Long id, Long labId, Long deviceId, Long userId,
ReservationStatus status, LocalDateTime start, LocalDateTime end) {
return Reservation.builder().id(id).labId(labId).deviceId(deviceId).userId(userId)
.type(ReservationType.SINGLE).title("已有预约").startTime(start).endTime(end)
.participants(1).status(status).version(0).build();
}
/**
* 构造用户。
*
* @param id 用户 ID
* @param realName 姓名
* @param role 角色
* @return 用户
*/
private User user(Long id, String realName, Role role) {
return User.builder().id(id).username("user" + id).realName(realName).role(role).build();
}
/**
* 构造默认业务参数。
*
* @return 参数
*/
private static LabProperties properties() {
LabProperties properties = new LabProperties();
properties.getReservation().setMaxDurationHours(8);
properties.getReservation().setMaxAdvanceDays(60);
properties.getReservation().setBackwardToleranceMinutes(30);
return properties;
}
}

Some files were not shown because too many files have changed in this diff Show More