commit 4fad31e43808453dc777be19124af6a705cc2575 Author: Mike Solar Date: Mon Sep 21 12:51:00 2026 +0800 First commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a277fc3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# 后端构建上下文忽略项:只保留 pom.xml 与 src +target/ +data/ +.idea/ +.vscode/ +.git/ +.gitignore +*.iml +docs/ +frontend/ +README.md +Dockerfile +docker-compose.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07ecd4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### 前端 ### +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +### H2 本地数据库文件 ### +data/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f7c9a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# ===================================================================== +# 后端镜像:多阶段构建(Maven 编译 -> JRE 运行) +# 构建:docker build -t lab-backend . +# 运行:docker run -p 8080:8080 -e SPRING_PROFILES_ACTIVE=mariadb \ +# -e DB_HOST=host.docker.internal -e DB_USER=lab -e DB_PASSWORD=lab123456 lab-backend +# ===================================================================== + +# ---------- 构建阶段 ---------- +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /build + +# 先只复制 pom,利用 Docker 层缓存预热依赖 +COPY pom.xml . +RUN mvn -B -q -Dmaven.test.skip=true dependency:go-offline + +# 再复制源码并打包(测试在 CI/本地执行,镜像构建跳过) +COPY src ./src +RUN mvn -B -q -Dmaven.test.skip=true package \ + && cp target/*.jar /build/app.jar + +# ---------- 运行阶段 ---------- +FROM eclipse-temurin:21-jre +WORKDIR /opt/app + +# 以非 root 用户运行 +RUN useradd -r -u 1001 appuser && chown -R appuser:appuser /opt/app +USER appuser + +COPY --from=build --chown=appuser:appuser /build/app.jar /opt/app/app.jar + +ENV TZ=Asia/Shanghai \ + JAVA_OPTS="-Xms256m -Xmx512m" \ + SPRING_PROFILES_ACTIVE=dev + +EXPOSE 8080 + +# 容器存活探针:用 bash 的 /dev/tcp 探测端口,避免安装额外工具 +HEALTHCHECK --interval=15s --timeout=5s --start-period=40s --retries=5 \ + CMD bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/8080' || exit 1 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /opt/app/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b1fcd1 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# 实验室预约与资源管理平台 + +面向学校实验室的场景化预约系统:统一管理**实验室**、**设备**与**实验课程**,支持 +**单次预约 / 多次预约 / 课程绑定预约**三种业务模式,内置可测试的**时间冲突检测算法**、 +**资源状态机**与**基于角色的访问控制(RBAC)**,并提供 Vue3 前端与日历视图。 + +## 技术栈 + +| 层次 | 技术 | +| --- | --- | +| 后端 | Spring Boot 4.1.1、MyBatis-Plus 3.5.17(XML 自定义 SQL + QueryWrapper)、JWT(jjwt)、BCrypt | +| 数据库 | H2(默认,免安装演示)/ MariaDB(`mariadb` Profile,schema 脚本已提供) | +| 测试 | JUnit 5、Mockito、MockMvc、AssertJ、JaCoCo(覆盖率门禁) | +| 前端 | Vue 3 + TypeScript + Vite + Element Plus + Pinia + Vue Router + Axios,包管理 pnpm | + +## 快速开始 + +### 1. 启动后端(默认 H2 文件库,无需安装数据库) + +```bash +./mvnw spring-boot:run +# 或者:mvn spring-boot:run +``` + +- 服务地址:http://localhost:8080 +- H2 控制台:http://localhost:8080/h2-console (JDBC URL:`jdbc:h2:file:./data/labdb`,用户 `sa`,密码留空) +- 首次启动会自动建表并写入演示数据(`src/main/resources/db/schema.sql`、`data.sql`) + +使用 MariaDB/MySQL 时: + +```bash +# 先创建数据库:CREATE DATABASE lab DEFAULT CHARSET utf8mb4; +DB_HOST=localhost DB_USER=root DB_PASSWORD=yourpass \ + mvn spring-boot:run -Dspring-boot.run.profiles=mariadb +``` + +### 2. 启动前端 + +```bash +cd frontend +pnpm install +pnpm dev # http://localhost:5173,已配置 /api 代理到 8080 +pnpm build # 生产构建(vue-tsc 类型检查 + vite build) +``` + +### 3. Docker 一键部署(推荐用于演示/验收) + +```bash +docker compose up -d --build # 构建并启动 mariadb + backend + frontend +# 前端:http://localhost (Nginx 托管静态资源并反代 /api) +# 后端:http://localhost:8080 +docker compose ps # 查看健康状态 +docker compose down # 停止容器,保留数据库数据卷 +docker compose down -v # 停止并清空数据库(下次启动重建演示数据) +``` + +- 编排:`mariadb:11.4`(数据持久化到命名卷)+ 后端多阶段构建镜像(JRE 21,非 root 运行)+ Nginx 前端镜像 +- 三个服务均配置健康检查,后端与前端会等待上游就绪后再启动 +- 可用环境变量覆盖:`WEB_PORT`(默认 80)、`BACKEND_PORT`(默认 8080)、 + `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DB_ROOT_PASSWORD`、`LAB_JWT_SECRET` + +### 4. 演示账号 + +| 账号 | 密码 | 角色 | 典型能力 | +| --- | --- | --- | --- | +| `admin` | `admin123` | 管理员 | 全部资源与用户管理、审批、状态流转 | +| `teacher1` | `123456` | 教师 | 课程管理、发起课程预约、审批 | +| `student1` | `123456` | 学生 | 发起预约、查看/取消自己的预约 | + +## 功能地图 + +- **认证与权限**:JWT 登录、`@RequireRole` 角色注解、拦截器统一鉴权,前端路由/菜单按角色过滤 +- **实验室管理**:增删改查、状态机流转(空闲 / 已预约 / 使用中 / 维护中)、可用实验室推荐 +- **设备管理**:增删改查、归属实验室校验、状态机流转 +- **实验课程**:教师排课(起止日期 + 星期 + 时间段)、一键生成整期课程绑定预约 +- **预约中心**: + - 单次预约(datetimerange) + - 多次预约(按周重复,自动拆分为系列场次) + - 课程绑定预约(按课程排课规则批量生成) + - 冲突预检、审批(通过/驳回)、取消(可整系列)、开始/完成使用 +- **日历视图**:按实验室 + 月份展示预约分布,点击查看详情 +- **自动任务**:每 10 分钟自动完结已过期的预约并同步资源状态 + +## 核心设计 + +### 冲突检测 + +分层设计:数据库层用索引粗筛(XML 自定义 SQL,可选 `FOR UPDATE` 行锁), +应用层 `ConflictDetector` 做精确判断。 + +- 区间语义:左闭右开 `[start, end)`,**首尾相接不算冲突** +- 规则校验:时间区间合法性、过去时间、提前预约上限、单次时长、开放时间/跨天、 + 资源维护状态、容量、课程绑定关系、请求内部重叠 +- 冲突分类:`LAB_CONFLICT` / `DEVICE_CONFLICT` / `USER_CONFLICT` 等,返回多冲突明细 +- 并发控制:同事务内 `SELECT ... FOR UPDATE` + 冲突复检,避免并发双写 + +详细说明见 [docs/design.md](docs/design.md)。 + +### 状态机 + +- 实验室/设备:`IDLE → RESERVED → IN_USE → IDLE`,`IDLE ↔ MAINTENANCE`,非法迁移抛 `3003` +- 预约:`PENDING → APPROVED → IN_USE → COMPLETED`,`PENDING → REJECTED`, + `PENDING/APPROVED → CANCELLED`,终态不可再流转 + +## 测试与覆盖率 + +```bash +mvn verify # 运行全部测试并执行 JaCoCo 覆盖率门禁 +``` + +- 测试规模:**219** 个用例(19 个测试类)(冲突算法边界、状态机、服务层 Mockito、Mapper/接口集成) +- 覆盖率(核心包,排除实体/DTO/VO/配置):**行 98.1%,分支 92.8%** +- 门禁:行 ≥ 90%,分支 ≥ 85%,不达标 `mvn verify` 直接失败 +- 报告:`target/site/jacoco/index.html` + +## 目录结构 + +``` +├── src/main/java/com/example/lab +│ ├── common/ # 统一响应、异常、注解、上下文、JWT +│ ├── config/ # MyBatis-Plus、Web、安全、业务参数 +│ ├── controller/ # REST 接口 +│ ├── domain/ +│ │ ├── conflict/ # 时间复杂度可控的冲突检测与重复规则展开 +│ │ ├── entity/ # 实体 +│ │ ├── enums/ # 角色/状态/类型枚举 +│ │ └── statemachine/# 通用枚举状态机 +│ ├── dto/ vo/ # 请求与视图对象 +│ ├── mapper/ # MyBatis-Plus Mapper +│ ├── security/ # 认证授权拦截器 +│ └── service/ # 业务服务 +├── src/main/resources +│ ├── db/ # H2 / MariaDB 建表与演示数据 +│ └── mapper/ # XML 自定义 SQL +├── src/test/java/... # 单元测试 + 集成测试 +├── frontend/ # Vue3 + Element Plus 前端 +│ ├── Dockerfile # pnpm 构建 + Nginx 托管(含 /api 反向代理) +│ └── nginx.conf +├── Dockerfile # 后端多阶段构建镜像 +├── docker-compose.yml # mariadb + backend + frontend 一键编排 +└── docs/design.md # 需求分析与详细设计 +``` + +## 主要接口 + +| 方法 | 路径 | 说明 | 权限 | +| --- | --- | --- | --- | +| POST | `/api/auth/login` | 登录 | 公开 | +| GET | `/api/auth/me` | 当前用户 | 登录 | +| GET | `/api/labs` / `/api/labs/available` | 实验室列表 / 可用推荐 | 登录 | +| POST/PUT/DELETE | `/api/labs/**` | 实验室管理 | 管理员 | +| GET | `/api/devices` / `/api/devices/by-lab/{id}` | 设备列表 | 登录 | +| POST/PUT/DELETE | `/api/devices/**` | 设备管理 | 管理员 | +| GET | `/api/courses` | 课程列表 | 登录 | +| POST/PUT/DELETE | `/api/courses/**` | 课程管理 | 管理员/教师 | +| POST | `/api/reservations` | 单次预约 | 登录 | +| POST | `/api/reservations/recurring` | 多次预约 | 登录 | +| POST | `/api/reservations/course` | 课程绑定预约 | 管理员/授课教师 | +| POST | `/api/reservations/check` | 冲突预检 | 登录 | +| GET | `/api/reservations` / `/{id}` / `/calendar` | 列表 / 详情 / 日历 | 登录(学生仅本人) | +| POST | `/api/reservations/{id}/approve\|reject` | 审批 | 管理员/教师 | +| POST | `/api/reservations/{id}/cancel\|start\|complete` | 取消/开始/完成 | 本人或管理员 | +| GET/POST/PUT/DELETE | `/api/users/**` | 用户管理 | 管理员 | + +统一响应格式: + +```json +{ "code": 0, "message": "success", "data": {} } +``` + +业务错误码:`0` 成功、`1xxx` 用户与认证、`2xxx` 资源管理、`3xxx` 预约与冲突、`4xx/5xx` 通用错误。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e81da32 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,97 @@ +# ===================================================================== +# 实验室预约与资源管理平台 - 一键部署编排 +# +# 启动:docker compose up -d --build +# 访问:前端 http://localhost:${WEB_PORT:-80} +# 后端 http://localhost:${BACKEND_PORT:-8080} +# 停止:docker compose down (保留数据库数据卷) +# docker compose down -v (同时删除数据库数据,首次初始化会重建演示数据) +# +# 可通过环境变量覆盖:DB_NAME / DB_USER / DB_PASSWORD / DB_ROOT_PASSWORD / +# LAB_JWT_SECRET / WEB_PORT / BACKEND_PORT +# ===================================================================== + +name: lab-reservation + +services: + # ---------- 数据库:MariaDB 11 ---------- + mariadb: + image: mariadb:11.4 + container_name: lab-mariadb + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root123456} + MARIADB_DATABASE: ${DB_NAME:-lab} + MARIADB_USER: ${DB_USER:-lab} + MARIADB_PASSWORD: ${DB_PASSWORD:-lab123456} + TZ: Asia/Shanghai + # 统一字符集,保证中文与 emoji 正常存取 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + # 数据库数据持久化;首次启动由后端自动建表并写入演示数据 + - mariadb-data:/var/lib/mysql + # 需要本地客户端连库调试时可打开端口映射 + # ports: + # - "3306:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + networks: + - lab-net + + # ---------- 后端:Spring Boot + MyBatis-Plus ---------- + backend: + build: + context: . + dockerfile: Dockerfile + image: lab-backend:latest + container_name: lab-backend + restart: unless-stopped + environment: + # 使用 MariaDB Profile(对应 application-mariadb.yaml) + SPRING_PROFILES_ACTIVE: mariadb + DB_HOST: mariadb + DB_PORT: 3306 + DB_NAME: ${DB_NAME:-lab} + DB_USER: ${DB_USER:-lab} + DB_PASSWORD: ${DB_PASSWORD:-lab123456} + # 生产部署请务必覆盖为随机密钥(长度 >= 32 字节) + LAB_JWT_SECRET: ${LAB_JWT_SECRET:-lab-reservation-docker-secret-key-change-me-2026} + TZ: Asia/Shanghai + JAVA_OPTS: "-Xms256m -Xmx512m" + depends_on: + mariadb: + condition: service_healthy + ports: + - "${BACKEND_PORT:-8080}:8080" + networks: + - lab-net + + # ---------- 前端:Nginx + Vue3 静态资源 ---------- + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + image: lab-frontend:latest + container_name: lab-frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${WEB_PORT:-80}:80" + networks: + - lab-net + +volumes: + # 命名卷保存 MariaDB 数据;docker compose down -v 可连同数据一起清理 + mariadb-data: + +networks: + lab-net: + driver: bridge diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..5235cc5 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,257 @@ +# 实验室预约与资源管理平台 - 需求分析与设计说明 + +## 1. 需求分析 + +### 1.1 用户角色与需求 + +| 角色 | 核心诉求 | 主要用例 | +| --- | --- | --- | +| 管理员 ADMIN | 保证资源台账准确、预约秩序可控 | 实验室/设备/课程的增删改查;资源状态流转(报修、启用);用户账号管理;审批任何预约;查看全局日历与统计 | +| 教师 TEACHER | 排课与科研实验,不想被冲突打乱 | 创建/维护自己的实验课程;按课程一键生成整期预约;审批学生预约;发起多次预约(如每周教研) | +| 学生 STUDENT | 快速找到可用资源并预约 | 查询实验室/设备可用性;发起单次/多次预约;查看与取消自己的预约;签到与完成 | + +### 1.2 预约场景定义 + +| 场景 | 输入 | 系统行为 | 对应类型 | +| --- | --- | --- | --- | +| 单次预约 | 实验室(可选设备)、一个开始/结束时间 | 校验冲突后生成 1 条预约;学生进入待审批,教师/管理员直接通过 | `SINGLE` | +| 多次预约 | 日期区间 + 星期集合 + 每日时段 | 按周展开为多个场次,写入 1 条系列 + N 条预约;任一场次冲突则整批拒绝 | `RECURRING` | +| 课程绑定预约 | 课程 ID(可指定设备) | 按课程排课规则展开整期场次,绑定课程 ID;仅授课教师或管理员可发起 | `COURSE` | + +**关键业务规则** + +1. 冲突判定必须覆盖:同实验室、同设备、同申请人三个维度。 +2. 学生预约需要审批;教师/管理员预约自动通过(仍要过冲突校验)。 +3. 被驳回/取消/完成的预约不再占用资源。 +4. 资源处于「维护中」不可预约;人数不得超过实验室容量。 +5. 单次预约 ≤ 8 小时、最多提前 60 天、不得预约过去时间,且需落在实验室开放时间内。 +6. 不允许跨天预约(结束时间为次日 00:00 除外)。 + +## 2. 领域模型 + +``` +┌────────┐ 1 n ┌────────┐ 1 n ┌────────┐ +│ Lab │─────────│ Device │ │ Course │ +└───┬────┘ └────┬───┘ └───┬────┘ + │ 1 │ n │ 1 + │ │ │ + │ n ┌───────┴─────────────────┘ +┌───┴───────────┴───┐ +│ Reservation │ n ────── 1 ┌──────────────────┐ +│ (场次/时间区间) │ │ ReservationSeries │ +└───┬───────────────┘ └──────────────────┘ + │ n + │ 1 +┌───┴────┐ +│ User │ +└────────┘ +``` + +**设计要点** + +- 「多次预约/课程预约」采用 **系列(Series)+ 场次(Reservation)** 两层结构: + 每次实际占用都是一条独立 `reservation` 行,冲突检测、审批、状态流转 + 都只需处理统一的时间区间模型,避免规则分叉。 +- 时间区间统一使用 **左闭右开** `[startTime, endTime)`。 +- `reservation.version` 为乐观锁字段,防止并发审批互相覆盖。 + +### 2.1 数据表 + +| 表 | 关键字段 | 说明 | +| --- | --- | --- | +| `sys_user` | username(UK), password(BCrypt), role, enabled | 三类角色 | +| `lab` | code(UK), capacity, status, open_time, close_time | 开放时间参与冲突校验 | +| `device` | code(UK), lab_id, status | 设备必须归属实验室 | +| `course` | code(UK), teacher_id, lab_id, student_count, start/end_date, weekdays, start/end_time | 自带排课规则 | +| `reservation_series` | user_id, lab_id, device_id, type, weekdays, 起止日期/时间 | 重复规则原始记录 | +| `reservation` | lab_id, device_id, course_id, user_id, start/end_time, status, version | 冲突检测主体,建立 `(lab_id, start_time, end_time)` 等索引 | + +## 3. 状态机模型 + +### 3.1 资源状态机(实验室 / 设备共用规则) + +```mermaid +stateDiagram-v2 + [*] --> IDLE + IDLE --> RESERVED: 预约通过 + IDLE --> MAINTENANCE: 报修/维护 + RESERVED --> IN_USE: 开始使用 + RESERVED --> IDLE: 取消/驳回/过期 + IN_USE --> IDLE: 完成使用 + MAINTENANCE --> IDLE: 维护完成 +``` + +| 起始 \ 目标 | IDLE | RESERVED | IN_USE | MAINTENANCE | +| --- | --- | --- | --- | --- | +| IDLE | — | ✅ | ❌(需经 RESERVED) | ✅ | +| RESERVED | ✅ | — | ✅ | ❌(需先释放) | +| IN_USE | ✅ | ❌(需先释放) | — | ❌ | +| MAINTENANCE | ✅ | ❌ | ❌ | — | + +- 实现:`EnumStateMachine` 通用基类 + `LabStateMachine` / `DeviceStateMachine` 注册迁移表; + 非法迁移抛出业务错误 `3003`。 +- 资源状态由预约生命周期驱动:`ReservationService.syncLabStatus/syncDeviceStatus` + 根据「是否存在 IN_USE / 占用态预约」推导目标状态,并沿状态机的合法路径推进 + (如 `IDLE → RESERVED → IN_USE`)。 +- 维护中的资源不会被状态同步覆盖,保证报修期间状态稳定。 + +### 3.2 预约状态机 + +```mermaid +stateDiagram-v2 + [*] --> PENDING: 学生提交 + [*] --> APPROVED: 教师/管理员提交 + PENDING --> APPROVED: 审批通过 + PENDING --> REJECTED: 驳回 + PENDING --> CANCELLED: 本人/管理员取消 + APPROVED --> IN_USE: 签到开始 + APPROVED --> CANCELLED: 取消 + APPROVED --> COMPLETED: 到期自动完结 + IN_USE --> COMPLETED: 完成使用 + REJECTED --> [*] + CANCELLED --> [*] + COMPLETED --> [*] +``` + +## 4. 预约冲突检测算法 + +### 4.1 规则清单 + +| 类型 | 触发条件 | 错误码 | +| --- | --- | --- | +| `INVALID_TIME_RANGE` | 时间段为空或 `end <= start` | 3002 | +| `PAST_TIME` | 开始时间早于 `now - 容差(30min)` | 3002 | +| `ADVANCE_LIMIT_EXCEEDED` | 开始时间晚于 `now + 60 天` | 3002 | +| `DURATION_EXCEEDED` | 单段时长 > 8 小时 | 3002 | +| `OUTSIDE_OPENING_HOURS` | 不在实验室开放时间内或跨天 | 3002 | +| `RESOURCE_UNAVAILABLE` | 实验室/设备处于维护中 | 3002 | +| `CAPACITY_EXCEEDED` | 人数 > 实验室容量 | 3002 | +| `COURSE_REQUIRED` / `COURSE_LAB_MISMATCH` / `COURSE_NOT_ACTIVE` | 课程绑定校验 | 3002 / 2203 | +| `INTERNAL_OVERLAP` | 请求内部多个时间段互相重叠 | 3002 | +| `LAB_CONFLICT` / `DEVICE_CONFLICT` / `USER_CONFLICT` | 与已有占用态预约重叠 | 3002 | + +分类优先级:同实验室 > 同设备 > 同用户,一条已有预约最多产生一条冲突。 + +### 4.2 区间重叠算法 + +采用 **排序 + 双指针扫描**,而不是两两比较: + +``` +输入:requested[0..n)(请求时间段,乱序),existing[0..m)(占用态已有预约) +1. 过滤非法/被忽略/非占用状态的 existing,两侧各按 start 升序排序 +2. pointer = 0 +3. for each existing r: +4. while pointer < n and requested[pointer].end <= r.start: pointer++ // 完全在 r 之前的请求段 +5. for j = pointer; j < n and requested[j].start < r.end; j++: +6. if r.overlaps(requested[j]): 记录冲突(j, r) +输出:冲突列表 +``` + +- 重叠判定:`a.start < b.end && b.start < a.end`,因此 `10:00-12:00` 与 + `12:00-14:00` **不算冲突**(首尾相接合法)。 +- 复杂度:排序 `O((n+m) log(n+m))`,扫描近似线性;单次预约时 n=1,实际为一次索引查询。 +- 由于 `existing` 按 start 单调递增,`pointer` 只前进不回退。 + +### 4.3 数据库层粗筛与并发控制 + +`resources/mapper/ReservationMapper.xml` 中的 `selectConflicting`: + +```sql +SELECT ... FROM reservation + WHERE start_time < #{end} AND end_time > #{start} -- 区间重叠 + AND status IN (...) -- 只取占用态 + AND (lab_id = ? [OR device_id = ?] [OR user_id = ?]) -- 三个维度 + [AND id <> #{ignoreId}] -- 更新/审批时排除自身 + [FOR UPDATE] -- 并发写场景行锁 +``` + +- 利用 `(lab_id, start_time, end_time)` 等索引把候选集缩小到极少量行, + 精确判断交给无副作用的 `ConflictDetector`。 +- 创建/审批在同一个 `@Transactional` 中先加锁查询、再检测、最后写入, + 避免「先查后写」竞态;审批时还会排除自身做二次冲突复检, + 防止多条待审批预约互相重叠。 + +### 4.4 边界情况(均有单元测试覆盖) + +| 场景 | 期望 | +| --- | --- | +| 已有 `[9:00,11:00)`,请求 `[11:00,13:00)` | 不冲突(相接) | +| 已有 `[9:00,11:00)`,请求 `[10:59,13:00)` | 冲突 | +| 已有区间完全包含请求区间 | 冲突 | +| 请求时间段的 end == start | `INVALID_TIME_RANGE` | +| 开始时间等于 `now - 30min` | 允许(容差) | +| 开始时间等于 `now + 60 天` | 允许(边界含) | +| 时长恰好 8 小时 | 允许 | +| 跨天但结束于 00:00 | 允许 | +| 状态为 CANCELLED/REJECTED/COMPLETED 的预约 | 不参与冲突 | +| 重复规则命不中任何日期 / 超过 60 场 | 抛 `INVALID_RECURRENCE_RULE` | +| 请求内部两段互相重叠 | `INTERNAL_OVERLAP` | + +## 5. RBAC 权限矩阵 + +| 操作 | 管理员 | 教师 | 学生 | +| --- | --- | --- | --- | +| 查看实验室/设备/课程/日历 | ✅ | ✅ | ✅ | +| 实验室/设备增删改、状态流转 | ✅ | ❌ | ❌ | +| 课程增删改(仅本人课程) | ✅(任意) | ✅ | ❌ | +| 发起单次/多次预约 | ✅(自动通过) | ✅(自动通过) | ✅(待审批) | +| 发起课程绑定预约 | ✅ | ✅(仅本人课程) | ❌ | +| 审批预约 | ✅(除自己) | ✅(除自己) | ❌ | +| 取消/开始/完成预约 | ✅(任意) | 仅本人 | 仅本人 | +| 查看预约列表 | 全部 | 全部 | 仅本人(强制过滤) | +| 用户管理 | ✅ | ❌ | ❌ | + +实现:`@RequireRole` 注解 + `AuthInterceptor`(JWT 解析、角色校验), +服务层再做**数据级权限**(所有者校验、授课教师校验)。 + +## 6. 接口设计 + +统一响应:`{ code, message, data }`,`code = 0` 表示成功。 + +| 分组 | 接口 | +| --- | --- | +| 认证 | `POST /api/auth/login`、`GET /api/auth/me`、`POST /api/auth/logout` | +| 实验室 | `GET /api/labs`、`GET /api/labs/all`、`GET /api/labs/{id}`、`GET /api/labs/available`、`POST/PUT/DELETE`、`PATCH /{id}/status` | +| 设备 | `GET /api/devices`、`GET /api/devices/by-lab/{labId}`、`POST/PUT/DELETE`、`PATCH /{id}/status` | +| 课程 | `GET /api/courses`、`GET /api/courses/all`、`POST/PUT/DELETE`、`PATCH /{id}/finish` | +| 预约 | `POST /api/reservations`、`/recurring`、`/course`、`/check`;`GET /api/reservations`、`/{id}`、`/calendar`;`POST /{id}/approve|reject|cancel|start|complete` | +| 用户 | `GET/POST/PUT/DELETE /api/users/**` | + +## 7. 测试设计 + +### 7.1 测试分层 + +| 层次 | 代表用例 | 目标 | +| --- | --- | --- | +| 算法单元测试 | `ConflictDetectorTest`(40 例)、`TimeSlotTest`、`RecurrenceExpanderTest` | 全规则 + 边界值,纯函数无数据库 | +| 状态机测试 | `StateMachineTest` | 全部合法/非法迁移与终态 | +| 服务层测试 | `ReservationServiceTest`(44 例)、`LabServiceTest` 等 | Mockito 隔离,覆盖权限、状态同步、异常分支 | +| Mapper 集成测试 | `ReservationMapperTest` | XML SQL 的区间语义、状态过滤、`FOR UPDATE` | +| 接口集成测试 | `ApiIntegrationTest`(12 例) | MockMvc + H2 全链路:登录、RBAC、CRUD、预约生命周期、日历 | + +### 7.2 覆盖率(JaCoCo 门禁) + +``` +mvn verify +``` + +- 核心包(排除实体/DTO/VO/配置):**行覆盖 98.1%,分支覆盖 92.8%** +- 门禁:行 ≥ 90%、分支 ≥ 85%,不达标构建失败 +- 报告位置:`target/site/jacoco/index.html` + +## 8. 扩展功能:日历视图 + +- 后端 `GET /api/reservations/calendar?labId&from&to` 返回区间内预约(一次查询整月)。 +- 前端「预约日历」页使用 Element Plus `el-calendar` 自定义日期单元格, + 按状态着色(待审批/已通过/使用中/已完成),点击事件查看详情。 +- 与冲突算法共用同一份数据模型,所见即校验依据。 + +## 9. 关键设计取舍 + +| 问题 | 方案 | 原因 | +| --- | --- | --- | +| 重复预约如何落库 | 系列 + 场次拆行 | 冲突检测/审批/日历统一为区间模型 | +| 冲突检测放数据库还是应用层 | 数据库索引粗筛 + 应用层精判 | 兼顾性能、可测试性与跨库兼容(MySQL 无排他约束) | +| 并发双写 | `FOR UPDATE` + 事务内复检 + 乐观锁 | 预约系统写冲突概率低但后果严重 | +| 权限控制 | JWT 无状态 + 注解拦截 + 服务层数据权限 | 部署简单,规则集中可测 | +| 状态一致性 | 显式状态机 + 资源状态推导 | 避免「有预约却空闲」的脏状态 | diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..75179eb --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +Dockerfile diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..07f6c95 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# ===================================================================== +# 前端镜像:pnpm 构建静态资源 -> Nginx 托管(同时反代 /api 到后端) +# 构建:docker build -t lab-frontend ./frontend +# ===================================================================== + +# ---------- 构建阶段 ---------- +FROM node:24-alpine AS build +WORKDIR /app + +# 使用与本地一致的 pnpm 主版本,锁定依赖保证可复现 +RUN npm install -g pnpm@11.3.0 + +# 先装依赖,充分利用层缓存 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build + +# ---------- 运行阶段 ---------- +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6fe9ce3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 实验室预约与资源管理平台 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..2341b7f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,35 @@ +# Nginx 站点配置:SPA 路由回退 + /api 反向代理到后端容器 +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # 静态资源压缩 + gzip on; + gzip_min_length 1k; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + # 前端单页应用:刷新子路由时回退到 index.html + location / { + try_files $uri $uri/ /index.html; + } + + # 后端接口:转发到 docker-compose 中的 backend 服务 + location /api/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + # 带哈希的静态资源长期缓存 + location /assets/ { + expires 7d; + add_header Cache-Control "public, max-age=604800"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2bdd30b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "lab-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "实验室预约与资源管理平台前端", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "element-plus": "^2.9.1", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } +} \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..91e9cff --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1432 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.1 + version: 2.3.2(vue@3.5.43(typescript@5.7.2)) + axios: + specifier: ^1.7.9 + version: 1.20.0 + dayjs: + specifier: ^1.11.13 + version: 1.11.23 + element-plus: + specifier: ^2.9.1 + version: 2.14.6(vue@3.5.43(typescript@5.7.2)) + pinia: + specifier: ^2.3.0 + version: 2.3.0(typescript@5.7.2)(vue@3.5.43(typescript@5.7.2)) + vue: + specifier: ^3.5.13 + version: 3.5.43(typescript@5.7.2) + vue-router: + specifier: ^4.5.0 + version: 4.6.4(vue@3.5.43(typescript@5.7.2)) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.4 + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.1(vite@6.4.3(@types/node@22.20.4))(vue@3.5.43(typescript@5.7.2)) + typescript: + specifier: ^5.7.2 + version: 5.7.2 + vite: + specifier: ^6.0.5 + version: 6.4.3(@types/node@22.20.4) + vue-tsc: + specifier: ^2.2.0 + version: 2.2.0(typescript@5.7.2) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/aix-ppc64@0.25.0': + resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.0': + resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.0': + resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.0': + resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.0': + resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.0': + resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.0': + resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.0': + resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.0': + resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.0': + resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.0': + resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.0': + resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.0': + resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.0': + resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.0': + resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.0': + resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.0': + resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.0': + resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.0': + resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.0': + resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.0': + resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.0': + resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.0': + resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.0': + resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.0': + resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.4': + resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} + cpu: [x64] + os: [win32] + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@5.2.1': + resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.3': + resolution: {integrity: sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.0': + resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@vueuse/core@15.0.0': + resolution: {integrity: sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@15.0.0': + resolution: {integrity: sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==} + engines: {node: '>=22'} + + '@vueuse/shared@15.0.0': + resolution: {integrity: sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + agent-base@6.0.0: + resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==} + engines: {node: '>= 6.0.0'} + + alien-signals@0.4.9: + resolution: {integrity: sha512-piRGlMgQ65uRiY06mGU7I432AwPwAGf64TK1RXtM1Px4pPfLMTGI9TmsHTfioW1GukZRsNzkVQ/uHjhhd231Ow==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + element-plus@2.14.6: + resolution: {integrity: sha512-GH0fMoMgVEKkHimoaKvg5B00NmfPbQ+qxX6/JrO7F+dLAC3atrZr2Y2MoccHMxS22M7QE12/s0DG0Zs49iwJ/A==} + peerDependencies: + vue: ^3.3.7 + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.0: + resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pinia@2.3.0: + resolution: {integrity: sha512-ohZj3jla0LL0OH5PlLTDMzqKiVw2XARmC1XYLdLWIPBMdhDW/123ZWr4zVAhtJm+aoSkFa13pYXskAvAscIkhQ==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-component-type-helpers@3.3.11: + resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.0: + resolution: {integrity: sha512-gtmM1sUuJ8aSb0KoAFmK9yMxb8TxjewmxqTJ1aKphD5Cbu0rULFY6+UQT51zW7SpUcenfPUuflKyVwyx9Qdnxg==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@4.2.1': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.43(typescript@5.7.2))': + dependencies: + vue: 3.5.43(typescript@5.7.2) + + '@esbuild/aix-ppc64@0.25.0': + optional: true + + '@esbuild/android-arm64@0.25.0': + optional: true + + '@esbuild/android-arm@0.25.0': + optional: true + + '@esbuild/android-x64@0.25.0': + optional: true + + '@esbuild/darwin-arm64@0.25.0': + optional: true + + '@esbuild/darwin-x64@0.25.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.0': + optional: true + + '@esbuild/freebsd-x64@0.25.0': + optional: true + + '@esbuild/linux-arm64@0.25.0': + optional: true + + '@esbuild/linux-arm@0.25.0': + optional: true + + '@esbuild/linux-ia32@0.25.0': + optional: true + + '@esbuild/linux-loong64@0.25.0': + optional: true + + '@esbuild/linux-mips64el@0.25.0': + optional: true + + '@esbuild/linux-ppc64@0.25.0': + optional: true + + '@esbuild/linux-riscv64@0.25.0': + optional: true + + '@esbuild/linux-s390x@0.25.0': + optional: true + + '@esbuild/linux-x64@0.25.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.0': + optional: true + + '@esbuild/netbsd-x64@0.25.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.0': + optional: true + + '@esbuild/openbsd-x64@0.25.0': + optional: true + + '@esbuild/sunos-x64@0.25.0': + optional: true + + '@esbuild/win32-arm64@0.25.0': + optional: true + + '@esbuild/win32-ia32@0.25.0': + optional: true + + '@esbuild/win32-x64@0.25.0': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.4': + optional: true + + '@rollup/rollup-android-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-x64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.4': + optional: true + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/estree@1.0.9': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@5.2.1(vite@6.4.3(@types/node@22.20.4))(vue@3.5.43(typescript@5.7.2))': + dependencies: + vite: 6.4.3(@types/node@22.20.4) + vue: 3.5.43(typescript@5.7.2) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.3': {} + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.0(typescript@5.7.2)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.43 + alien-signals: 0.4.9 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.7.2 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vueuse/core@15.0.0(vue@3.5.43(typescript@5.7.2))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 15.0.0 + '@vueuse/shared': 15.0.0(vue@3.5.43(typescript@5.7.2)) + vue: 3.5.43(typescript@5.7.2) + + '@vueuse/metadata@15.0.0': {} + + '@vueuse/shared@15.0.0(vue@3.5.43(typescript@5.7.2))': + dependencies: + vue: 3.5.43(typescript@5.7.2) + + agent-base@6.0.0: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + alien-signals@0.4.9: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.0: {} + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + element-plus@2.14.6(vue@3.5.43(typescript@5.7.2)): + dependencies: + '@ctrl/tinycolor': 4.2.1 + '@element-plus/icons-vue': 2.3.2(vue@3.5.43(typescript@5.7.2)) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 15.0.0(vue@3.5.43(typescript@5.7.2)) + async-validator: 4.2.5 + dayjs: 1.11.23 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.43(typescript@5.7.2) + vue-component-type-helpers: 3.3.11 + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.25.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.0 + '@esbuild/android-arm': 0.25.0 + '@esbuild/android-arm64': 0.25.0 + '@esbuild/android-x64': 0.25.0 + '@esbuild/darwin-arm64': 0.25.0 + '@esbuild/darwin-x64': 0.25.0 + '@esbuild/freebsd-arm64': 0.25.0 + '@esbuild/freebsd-x64': 0.25.0 + '@esbuild/linux-arm': 0.25.0 + '@esbuild/linux-arm64': 0.25.0 + '@esbuild/linux-ia32': 0.25.0 + '@esbuild/linux-loong64': 0.25.0 + '@esbuild/linux-mips64el': 0.25.0 + '@esbuild/linux-ppc64': 0.25.0 + '@esbuild/linux-riscv64': 0.25.0 + '@esbuild/linux-s390x': 0.25.0 + '@esbuild/linux-x64': 0.25.0 + '@esbuild/netbsd-arm64': 0.25.0 + '@esbuild/netbsd-x64': 0.25.0 + '@esbuild/openbsd-arm64': 0.25.0 + '@esbuild/openbsd-x64': 0.25.0 + '@esbuild/sunos-x64': 0.25.0 + '@esbuild/win32-arm64': 0.25.0 + '@esbuild/win32-ia32': 0.25.0 + '@esbuild/win32-x64': 0.25.0 + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + memoize-one@6.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: {} + + normalize-wheel-es@1.2.0: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pinia@2.3.0(typescript@5.7.2)(vue@3.5.43(typescript@5.7.2)): + dependencies: + '@vue/devtools-api': 6.6.3 + vue: 3.5.43(typescript@5.7.2) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.7.2)) + optionalDependencies: + typescript: 5.7.2 + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@2.1.0: {} + + rollup@4.63.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@5.7.2: {} + + undici-types@6.21.0: {} + + vite@6.4.3(@types/node@22.20.4): + dependencies: + esbuild: 0.25.0 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.4 + fsevents: 2.3.3 + + vscode-uri@3.2.0: {} + + vue-component-type-helpers@3.3.11: {} + + vue-demi@0.14.10(vue@3.5.43(typescript@5.7.2)): + dependencies: + vue: 3.5.43(typescript@5.7.2) + + vue-router@4.6.4(vue@3.5.43(typescript@5.7.2)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.43(typescript@5.7.2) + + vue-tsc@2.2.0(typescript@5.7.2): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.7.2) + typescript: 5.7.2 + + vue@3.5.43(typescript@5.7.2): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.7.2 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..364b6f3 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - '.' + +# pnpm 11 起,依赖构建脚本白名单统一配置在 workspace 文件中 +allowBuilds: + esbuild: true + vue-demi: true diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..746fa4d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,9 @@ + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..0f05178 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,351 @@ +import { del, get, patch, post, put } from '@/api/request' +import type { + ApiResponse, + ConflictCheckVO, + Course, + Device, + Lab, + LoginResponse, + PageResult, + ReservationQuery, + ReservationVO, + UserVO +} from '@/types' + +/** 认证接口 */ +export const authApi = { + /** + * 登录。 + * + * @param username 用户名 + * @param password 密码 + * @returns 登录响应 + */ + login: (username: string, password: string) => + post('/auth/login', { username, password }), + + /** + * 查询当前用户。 + * + * @returns 用户信息 + */ + me: () => get('/auth/me'), + + /** + * 登出(无状态令牌,前端自行清理)。 + */ + logout: () => post('/auth/logout') +} + +/** 实验室接口 */ +export const labApi = { + /** + * 分页查询实验室。 + * + * @param params 查询参数 + * @returns 分页结果 + */ + page: (params: Record) => + get>('/labs', params), + + /** + * 查询全部实验室。 + * + * @returns 实验室列表 + */ + all: () => get('/labs/all'), + + /** + * 查询单项时间段内可用实验室。 + * + * @param start 开始时间 + * @param end 结束时间 + * @param capacity 最少容量 + * @returns 可用实验室列表 + */ + available: (start: string, end: string, capacity?: number) => + get('/labs/available', { start, end, capacity }), + + /** + * 新增实验室。 + * + * @param body 请求体 + * @returns 新建实验室 + */ + create: (body: Partial) => post('/labs', body), + + /** + * 修改实验室。 + * + * @param id 实验室 ID + * @param body 请求体 + * @returns 更新后的实验室 + */ + update: (id: number, body: Partial) => put(`/labs/${id}`, body), + + /** + * 删除实验室。 + * + * @param id 实验室 ID + */ + remove: (id: number) => del(`/labs/${id}`), + + /** + * 变更实验室状态。 + * + * @param id 实验室 ID + * @param status 目标状态 + * @returns 更新后的实验室 + */ + changeStatus: (id: number, status: string) => + patch(`/labs/${id}/status`, { labStatus: status }) +} + +/** 设备接口 */ +export const deviceApi = { + /** + * 分页查询设备。 + * + * @param params 查询参数 + * @returns 分页结果 + */ + page: (params: Record) => get>('/devices', params), + + /** + * 查询实验室下的设备。 + * + * @param labId 实验室 ID + * @returns 设备列表 + */ + byLab: (labId: number) => get(`/devices/by-lab/${labId}`), + + /** + * 新增设备。 + * + * @param body 请求体 + * @returns 新建设备 + */ + create: (body: Partial) => post('/devices', body), + + /** + * 修改设备。 + * + * @param id 设备 ID + * @param body 请求体 + * @returns 更新后的设备 + */ + update: (id: number, body: Partial) => put(`/devices/${id}`, body), + + /** + * 删除设备。 + * + * @param id 设备 ID + */ + remove: (id: number) => del(`/devices/${id}`), + + /** + * 变更设备状态。 + * + * @param id 设备 ID + * @param status 目标状态 + * @returns 更新后的设备 + */ + changeStatus: (id: number, status: string) => + patch(`/devices/${id}/status`, { deviceStatus: status }) +} + +/** 课程接口 */ +export const courseApi = { + /** + * 分页查询课程。 + * + * @param params 查询参数 + * @returns 分页结果 + */ + page: (params: Record) => get>('/courses', params), + + /** + * 查询全部课程。 + * + * @returns 课程列表 + */ + all: () => get('/courses/all'), + + /** + * 新增课程。 + * + * @param body 请求体 + * @returns 新建课程 + */ + create: (body: Partial) => post('/courses', body), + + /** + * 修改课程。 + * + * @param id 课程 ID + * @param body 请求体 + * @returns 更新后的课程 + */ + update: (id: number, body: Partial) => put(`/courses/${id}`, body), + + /** + * 结束课程。 + * + * @param id 课程 ID + * @returns 更新后的课程 + */ + finish: (id: number) => patch(`/courses/${id}/finish`), + + /** + * 删除课程。 + * + * @param id 课程 ID + */ + remove: (id: number) => del(`/courses/${id}`) +} + +/** 预约接口 */ +export const reservationApi = { + /** + * 创建单次预约。 + * + * @param body 请求体 + * @returns 新建预约 + */ + create: (body: Record) => post('/reservations', body), + + /** + * 创建多次预约。 + * + * @param body 请求体 + * @returns 生成的预约列表 + */ + createRecurring: (body: Record) => + post('/reservations/recurring', body), + + /** + * 创建课程绑定预约。 + * + * @param body 请求体 + * @returns 生成的预约列表 + */ + createForCourse: (body: Record) => + post('/reservations/course', body), + + /** + * 冲突预检。 + * + * @param body 请求体 + * @returns 预检结果 + */ + check: (body: Record) => + post('/reservations/check', body), + + /** + * 分页查询预约。 + * + * @param params 查询条件 + * @returns 分页结果 + */ + page: (params: ReservationQuery) => + get>('/reservations', params as Record), + + /** + * 查询预约详情。 + * + * @param id 预约 ID + * @returns 预约详情 + */ + detail: (id: number) => get(`/reservations/${id}`), + + /** + * 查询实验室日历。 + * + * @param labId 实验室 ID + * @param from 开始日期 + * @param to 结束日期 + * @returns 预约列表 + */ + calendar: (labId: number, from: string, to: string) => + get('/reservations/calendar', { labId, from, to }), + + /** + * 审批通过。 + * + * @param id 预约 ID + * @returns 更新后的预约 + */ + approve: (id: number) => post(`/reservations/${id}/approve`), + + /** + * 驳回预约。 + * + * @param id 预约 ID + * @param reason 驳回原因 + * @returns 更新后的预约 + */ + reject: (id: number, reason: string) => + post(`/reservations/${id}/reject`, { reason }), + + /** + * 取消预约。 + * + * @param id 预约 ID + * @param series 是否取消整个系列 + * @returns 受影响的预约列表 + */ + cancel: (id: number, series: boolean) => + post(`/reservations/${id}/cancel?series=${series}`), + + /** + * 开始使用。 + * + * @param id 预约 ID + * @returns 更新后的预约 + */ + start: (id: number) => post(`/reservations/${id}/start`), + + /** + * 完成使用。 + * + * @param id 预约 ID + * @returns 更新后的预约 + */ + complete: (id: number) => post(`/reservations/${id}/complete`) +} + +/** 用户管理接口 */ +export const userApi = { + /** + * 查询全部用户。 + * + * @returns 用户列表 + */ + list: () => get('/users'), + + /** + * 新增用户。 + * + * @param body 请求体 + * @returns 新建用户 + */ + create: (body: Record) => post('/users', body), + + /** + * 修改用户。 + * + * @param id 用户 ID + * @param body 请求体 + * @returns 更新后的用户 + */ + update: (id: number, body: Record) => put(`/users/${id}`, body), + + /** + * 删除用户。 + * + * @param id 用户 ID + */ + remove: (id: number) => del(`/users/${id}`) +} + +export type { ApiResponse } diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..2c4e498 --- /dev/null +++ b/frontend/src/api/request.ts @@ -0,0 +1,136 @@ +import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' +import { ElMessage } from 'element-plus' + +/** 令牌在 localStorage 中的键名 */ +const TOKEN_KEY = 'lab-token' + +/** + * 读取本地令牌。 + * + * @returns JWT 字符串或空串 + */ +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? '' +} + +/** + * 写入本地令牌。 + * + * @param token JWT 字符串 + */ +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} + +/** 清除本地令牌 */ +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +/** axios 实例,统一以 /api 为前缀(开发环境由 Vite 代理) */ +const request: AxiosInstance = axios.create({ + baseURL: '/api', + timeout: 15000 +}) + +/** + * 请求拦截器:附加 Bearer 令牌。 + */ +request.interceptors.request.use((config) => { + const token = getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +/** + * 响应拦截器:统一解包业务码,401 时跳转登录页。 + */ +request.interceptors.response.use( + (response) => { + const body = response.data + if (body && typeof body.code === 'number' && body.code !== 0) { + ElMessage.error(body.message || '请求失败') + return Promise.reject(new Error(body.message)) + } + return response + }, + (error) => { + const status = error?.response?.status + if (status === 401) { + clearToken() + ElMessage.error('登录已失效,请重新登录') + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } + } else if (status === 403) { + ElMessage.error('无权限执行该操作') + } else { + ElMessage.error(error?.response?.data?.message || error.message || '网络异常') + } + return Promise.reject(error) + } +) + +/** + * 发起 GET 请求。 + * + * @param url 接口路径 + * @param params 查询参数 + * @returns 业务数据 + */ +export async function get(url: string, params?: Record): Promise { + const { data } = await request.get(url, { params }) + return data.data as T +} + +/** + * 发起 POST 请求。 + * + * @param url 接口路径 + * @param body 请求体 + * @param config 额外配置 + * @returns 业务数据 + */ +export async function post(url: string, body?: unknown, config?: AxiosRequestConfig): Promise { + const { data } = await request.post(url, body, config) + return data.data as T +} + +/** + * 发起 PUT 请求。 + * + * @param url 接口路径 + * @param body 请求体 + * @returns 业务数据 + */ +export async function put(url: string, body?: unknown): Promise { + const { data } = await request.put(url, body) + return data.data as T +} + +/** + * 发起 PATCH 请求。 + * + * @param url 接口路径 + * @param body 请求体 + * @returns 业务数据 + */ +export async function patch(url: string, body?: unknown): Promise { + const { data } = await request.patch(url, body) + return data.data as T +} + +/** + * 发起 DELETE 请求。 + * + * @param url 接口路径 + * @returns 业务数据 + */ +export async function del(url: string): Promise { + const { data } = await request.delete(url) + return data.data as T +} + +export default request diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..2c3ed98 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..6ea4bc1 --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..1160d11 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router from './router' +import './styles/index.css' + +const app = createApp(App) + +// 注册全部 Element Plus 图标,模板中可直接使用 +for (const [name, component] of Object.entries(ElementPlusIconsVue)) { + app.component(name, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..5edcf67 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,122 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { ElMessage } from 'element-plus' +import { useAuthStore } from '@/stores/auth' +import type { Role } from '@/types' + +/** + * 路由表:meta.roles 为空表示登录即可访问。 + */ +const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { public: true, title: '登录' } + }, + { + path: '/', + component: () => import('@/layouts/MainLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'dashboard', + component: () => import('@/views/DashboardView.vue'), + meta: { title: '工作台' } + }, + { + path: 'labs', + name: 'labs', + component: () => import('@/views/LabView.vue'), + meta: { title: '实验室管理' } + }, + { + path: 'devices', + name: 'devices', + component: () => import('@/views/DeviceView.vue'), + meta: { title: '设备管理' } + }, + { + path: 'courses', + name: 'courses', + component: () => import('@/views/CourseView.vue'), + meta: { title: '实验课程' } + }, + { + path: 'reservations/new', + name: 'reservation-create', + component: () => import('@/views/ReservationCreateView.vue'), + meta: { title: '发起预约' } + }, + { + path: 'reservations/mine', + name: 'reservation-mine', + component: () => import('@/views/MyReservationsView.vue'), + meta: { title: '我的预约' } + }, + { + path: 'approvals', + name: 'approvals', + component: () => import('@/views/ApprovalView.vue'), + meta: { title: '预约审批', roles: ['ADMIN', 'TEACHER'] as Role[] } + }, + { + path: 'calendar', + name: 'calendar', + component: () => import('@/views/CalendarView.vue'), + meta: { title: '预约日历' } + }, + { + path: 'users', + name: 'users', + component: () => import('@/views/UserView.vue'), + meta: { title: '用户管理', roles: ['ADMIN'] as Role[] } + } + ] + }, + { + path: '/:pathMatch(.*)*', + redirect: '/dashboard' + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +/** + * 全局前置守卫:登录校验 + 角色校验。 + */ +router.beforeEach(async (to) => { + const auth = useAuthStore() + if (to.meta.public) { + return true + } + if (!auth.loggedIn) { + return { path: '/login', query: { redirect: to.fullPath } } + } + if (!auth.user) { + try { + await auth.fetchMe() + } catch { + return { path: '/login' } + } + } + const roles = to.meta.roles as Role[] | undefined + if (roles && roles.length > 0 && !auth.hasRole(...roles)) { + ElMessage.error('无权限访问该页面') + return { path: '/dashboard' } + } + return true +}) + +/** + * 全局后置守卫:更新页面标题。 + */ +router.afterEach((to) => { + const title = to.meta.title as string | undefined + document.title = title ? `${title} - 实验室预约平台` : '实验室预约平台' +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..0e14f2a --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,68 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { authApi } from '@/api' +import { clearToken, getToken, setToken } from '@/api/request' +import type { Role, UserVO } from '@/types' + +/** + * 认证状态仓库:令牌与当前用户信息。 + */ +export const useAuthStore = defineStore('auth', () => { + const token = ref(getToken()) + const user = ref(null) + + /** 是否已登录 */ + const loggedIn = computed(() => Boolean(token.value)) + + /** 当前角色 */ + const role = computed(() => user.value?.role ?? '') + + /** + * 登录并保存令牌。 + * + * @param username 用户名 + * @param password 密码 + */ + async function login(username: string, password: string): Promise { + const result = await authApi.login(username, password) + token.value = result.token + user.value = result.user + setToken(result.token) + } + + /** + * 拉取当前用户信息(刷新页面后恢复状态)。 + */ + async function fetchMe(): Promise { + if (!token.value) { + return + } + user.value = await authApi.me() + } + + /** + * 判断当前用户是否属于给定角色集合。 + * + * @param roles 允许的角色 + * @returns 是否命中 + */ + function hasRole(...roles: Role[]): boolean { + return user.value != null && roles.includes(user.value.role) + } + + /** + * 退出登录并清理本地状态。 + */ + async function logout(): Promise { + try { + await authApi.logout() + } catch { + // 忽略登出接口异常,本地清理优先 + } + token.value = '' + user.value = null + clearToken() + } + + return { token, user, loggedIn, role, login, fetchMe, hasRole, logout } +}) diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..084319a --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,27 @@ +html, +body, +#app { + height: 100%; + margin: 0; +} + +body { + font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; + background-color: #f5f7fa; +} + +.page-card { + margin-bottom: 16px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-bottom: 16px; +} + +.toolbar .spacer { + flex: 1; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..478a46e --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,153 @@ +/** 后端统一响应包装 */ +export interface ApiResponse { + code: number + message: string + data: T +} + +/** 分页结果 */ +export interface PageResult { + records: T[] + total: number + page: number + size: number +} + +/** 用户角色 */ +export type Role = 'ADMIN' | 'TEACHER' | 'STUDENT' + +/** 用户信息 */ +export interface UserVO { + id: number + username: string + realName: string + role: Role + roleLabel: string + email?: string + phone?: string + enabled: boolean +} + +/** 实验室状态 */ +export type LabStatus = 'IDLE' | 'RESERVED' | 'IN_USE' | 'MAINTENANCE' + +/** 设备状态 */ +export type DeviceStatus = 'IDLE' | 'RESERVED' | 'IN_USE' | 'MAINTENANCE' + +/** 实验室 */ +export interface Lab { + id: number + code: string + name: string + location: string + capacity: number + description?: string + status: LabStatus + openTime: string + closeTime: string +} + +/** 设备 */ +export interface Device { + id: number + labId: number + code: string + name: string + model?: string + description?: string + status: DeviceStatus +} + +/** 课程状态 */ +export type CourseStatus = 'ACTIVE' | 'FINISHED' + +/** 实验课程 */ +export interface Course { + id: number + code: string + name: string + teacherId: number + labId: number + studentCount: number + startDate: string + endDate: string + weekdays: string + startTime: string + endTime: string + status: CourseStatus +} + +/** 预约类型 */ +export type ReservationType = 'SINGLE' | 'RECURRING' | 'COURSE' + +/** 预约状态 */ +export type ReservationStatus = + | 'PENDING' + | 'APPROVED' + | 'REJECTED' + | 'CANCELLED' + | 'IN_USE' + | 'COMPLETED' + +/** 预约视图对象 */ +export interface ReservationVO { + id: number + seriesId?: number + userId: number + userName?: string + labId: number + labName?: string + deviceId?: number + deviceName?: string + courseId?: number + courseName?: string + type: ReservationType + typeLabel: string + title: string + purpose?: string + startTime: string + endTime: string + participants: number + status: ReservationStatus + statusLabel: string + rejectReason?: string + approverId?: number + approverName?: string + approvedAt?: string + createdAt?: string +} + +/** 冲突明细 */ +export interface Conflict { + type: string + message: string + slotIndex?: number + conflictReservationId?: number +} + +/** 冲突预检结果 */ +export interface ConflictCheckVO { + passed: boolean + conflicts: Conflict[] +} + +/** 登录响应 */ +export interface LoginResponse { + token: string + expiresIn: number + user: UserVO +} + +/** 预约查询条件 */ +export interface ReservationQuery { + page?: number + size?: number + status?: ReservationStatus | '' + type?: ReservationType | '' + labId?: number | '' + userId?: number + keyword?: string + from?: string + to?: string + mine?: boolean +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..23eb245 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,107 @@ +import dayjs from 'dayjs' + +/** 状态 -> 中文标签 */ +export const LAB_STATUS_LABELS: Record = { + IDLE: '空闲', + RESERVED: '已预约', + IN_USE: '使用中', + MAINTENANCE: '维护中' +} + +/** 预约状态 -> 标签颜色 */ +export const RESERVATION_STATUS_TYPES: Record = { + PENDING: 'warning', + APPROVED: 'primary', + REJECTED: 'danger', + CANCELLED: 'info', + IN_USE: 'success', + COMPLETED: '' +} + +/** 资源状态 -> 标签颜色 */ +export const RESOURCE_STATUS_TYPES: Record = { + IDLE: 'success', + RESERVED: 'warning', + IN_USE: 'primary', + MAINTENANCE: 'danger' +} + +/** 星期数字 -> 中文 */ +const WEEKDAY_LABELS: Record = { + MON: '周一', + TUE: '周二', + WED: '周三', + THU: '周四', + FRI: '周五', + SAT: '周六', + SUN: '周日' +} + +/** + * 格式化日期时间。 + * + * @param value ISO 时间字符串 + * @returns yyyy-MM-dd HH:mm + */ +export function formatDateTime(value?: string): string { + return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-' +} + +/** + * 格式化时间(HH:mm),兼容 "HH:mm:ss" 与 "HH:mm"。 + * + * @param value 时间字符串 + * @returns HH:mm + */ +export function formatTime(value?: string): string { + if (!value) { + return '-' + } + const match = value.match(/(\d{2}):(\d{2})/) + return match ? `${match[1]}:${match[2]}` : value +} + +/** + * 格式化星期描述,如 "1,3" -> "周一 / 周三"。 + * + * @param value 星期字符串 + * @returns 可读文本 + */ +export function formatWeekdays(value?: string): string { + if (!value) { + return '-' + } + const numeric = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] + return value + .split(',') + .map((token) => token.trim()) + .filter(Boolean) + .map((token) => { + if (/^[1-7]$/.test(token)) { + return WEEKDAY_LABELS[numeric[Number(token) - 1]] + } + return WEEKDAY_LABELS[token.toUpperCase()] ?? token + }) + .join(' / ') +} + +/** + * 生成日期偏移字符串。 + * + * @param days 偏移天数 + * @returns yyyy-MM-dd + */ +export function dateOffset(days: number): string { + return dayjs().add(days, 'day').format('YYYY-MM-DD') +} + +/** + * 生成日期时间字符串。 + * + * @param days 偏移天数 + * @param time HH:mm + * @returns yyyy-MM-ddTHH:mm:ss + */ +export function dateTimeOffset(days: number, time: string): string { + return dayjs().add(days, 'day').format('YYYY-MM-DD') + 'T' + time + ':00' +} diff --git a/frontend/src/views/ApprovalView.vue b/frontend/src/views/ApprovalView.vue new file mode 100644 index 0000000..0a05714 --- /dev/null +++ b/frontend/src/views/ApprovalView.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue new file mode 100644 index 0000000..54914f6 --- /dev/null +++ b/frontend/src/views/CalendarView.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/frontend/src/views/CourseView.vue b/frontend/src/views/CourseView.vue new file mode 100644 index 0000000..bf601d9 --- /dev/null +++ b/frontend/src/views/CourseView.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..c3c7ef4 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/src/views/DeviceView.vue b/frontend/src/views/DeviceView.vue new file mode 100644 index 0000000..27a74ea --- /dev/null +++ b/frontend/src/views/DeviceView.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/views/LabView.vue b/frontend/src/views/LabView.vue new file mode 100644 index 0000000..41be566 --- /dev/null +++ b/frontend/src/views/LabView.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..ceecb22 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/frontend/src/views/MyReservationsView.vue b/frontend/src/views/MyReservationsView.vue new file mode 100644 index 0000000..c298748 --- /dev/null +++ b/frontend/src/views/MyReservationsView.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/frontend/src/views/ReservationCreateView.vue b/frontend/src/views/ReservationCreateView.vue new file mode 100644 index 0000000..5e0ad02 --- /dev/null +++ b/frontend/src/views/ReservationCreateView.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/frontend/src/views/UserView.vue b/frontend/src/views/UserView.vue new file mode 100644 index 0000000..6b69e60 --- /dev/null +++ b/frontend/src/views/UserView.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..03c3f61 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3bcdf30 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,24 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +/** + * Vite 配置:开发代理 /api 到后端 8080,避免跨域。 + */ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +}) diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..12f971b --- /dev/null +++ b/pom.xml @@ -0,0 +1,183 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + com.example + lab + 0.0.1-SNAPSHOT + lab + 实验室预约与资源管理平台 + + + 21 + 3.5.17 + 0.12.6 + 0.8.13 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + + + com.baomidou + mybatis-plus-spring-boot4-starter + ${mybatis-plus.version} + + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + + + org.springframework.security + spring-security-crypto + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + org.projectlombok + lombok + true + + + + + com.h2database + h2 + runtime + + + + org.mariadb.jdbc + mariadb-java-client + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + check + verify + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.90 + + + BRANCH + COVEREDRATIO + 0.85 + + + + + + com/example/lab/LabApplication.class + com/example/lab/domain/entity/** + com/example/lab/domain/enums/** + com/example/lab/dto/** + com/example/lab/vo/** + com/example/lab/common/api/** + com/example/lab/config/** + + + + + + + + + diff --git a/src/main/java/com/example/lab/LabApplication.java b/src/main/java/com/example/lab/LabApplication.java new file mode 100644 index 0000000..28a64b0 --- /dev/null +++ b/src/main/java/com/example/lab/LabApplication.java @@ -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; + +/** + * 实验室预约与资源管理平台启动类。 + * + *

扫描 Mapper 接口、配置属性类,并开启定时任务(用于预约状态自动流转)。

+ */ +@SpringBootApplication +@MapperScan("com.example.lab.mapper") +@ConfigurationPropertiesScan +@EnableScheduling +public class LabApplication { + + /** + * 应用入口。 + * + * @param args 命令行参数 + */ + public static void main(String[] args) { + SpringApplication.run(LabApplication.class, args); + } +} diff --git a/src/main/java/com/example/lab/common/annotation/RequireRole.java b/src/main/java/com/example/lab/common/annotation/RequireRole.java new file mode 100644 index 0000000..92e6b52 --- /dev/null +++ b/src/main/java/com/example/lab/common/annotation/RequireRole.java @@ -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; + +/** + * 角色访问控制注解。 + * + *

标注在 Controller 类或方法上,由 {@code AuthInterceptor} 在请求进入时校验; + * 方法级注解优先于类级注解。缺省表示「登录即可访问」。

+ */ +@Documented +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireRole { + + /** + * 允许访问的角色列表,满足任意一个即放行。 + * + * @return 角色数组 + */ + Role[] value(); +} diff --git a/src/main/java/com/example/lab/common/api/ApiResponse.java b/src/main/java/com/example/lab/common/api/ApiResponse.java new file mode 100644 index 0000000..7e014af --- /dev/null +++ b/src/main/java/com/example/lab/common/api/ApiResponse.java @@ -0,0 +1,58 @@ +package com.example.lab.common.api; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 统一响应包装。 + * + * @param 业务数据类型 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + /** 业务状态码,0 表示成功 */ + private int code; + + /** 提示信息 */ + private String message; + + /** 业务数据 */ + private T data; + + /** + * 构造成功响应(无数据)。 + * + * @param 数据类型 + * @return 成功响应 + */ + public static ApiResponse ok() { + return new ApiResponse<>(0, "success", null); + } + + /** + * 构造成功响应。 + * + * @param data 业务数据 + * @param 数据类型 + * @return 成功响应 + */ + public static ApiResponse ok(T data) { + return new ApiResponse<>(0, "success", data); + } + + /** + * 构造失败响应。 + * + * @param code 业务状态码 + * @param message 错误信息 + * @param 数据类型 + * @return 失败响应 + */ + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} diff --git a/src/main/java/com/example/lab/common/api/PageResult.java b/src/main/java/com/example/lab/common/api/PageResult.java new file mode 100644 index 0000000..16a3a54 --- /dev/null +++ b/src/main/java/com/example/lab/common/api/PageResult.java @@ -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 列表元素类型 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PageResult { + + /** 当前页数据 */ + private List records; + + /** 总记录数 */ + private long total; + + /** 页码,从 1 开始 */ + private long page; + + /** 每页大小 */ + private long size; + + /** + * 由 MyBatis-Plus 分页对象构造分页结果。 + * + * @param page 分页对象 + * @param 元素类型 + * @return 分页结果 + */ + public static PageResult of(IPage page) { + return new PageResult<>(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize()); + } +} diff --git a/src/main/java/com/example/lab/common/context/LoginUser.java b/src/main/java/com/example/lab/common/context/LoginUser.java new file mode 100644 index 0000000..412282c --- /dev/null +++ b/src/main/java/com/example/lab/common/context/LoginUser.java @@ -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(); + } +} diff --git a/src/main/java/com/example/lab/common/context/UserContext.java b/src/main/java/com/example/lab/common/context/UserContext.java new file mode 100644 index 0000000..94aeba0 --- /dev/null +++ b/src/main/java/com/example/lab/common/context/UserContext.java @@ -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} 的当前登录用户上下文。 + * + *

由认证拦截器写入、请求结束后清理,业务层通过 + * {@link #requireUser()} / {@link #requireUserId()} 获取当前操作人。

+ */ +public final class UserContext { + + private static final ThreadLocal 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(); + } +} diff --git a/src/main/java/com/example/lab/common/exception/BizException.java b/src/main/java/com/example/lab/common/exception/BizException.java new file mode 100644 index 0000000..6751fad --- /dev/null +++ b/src/main/java/com/example/lab/common/exception/BizException.java @@ -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; + } +} diff --git a/src/main/java/com/example/lab/common/exception/ErrorCode.java b/src/main/java/com/example/lab/common/exception/ErrorCode.java new file mode 100644 index 0000000..5fb6fbe --- /dev/null +++ b/src/main/java/com/example/lab/common/exception/ErrorCode.java @@ -0,0 +1,75 @@ +package com.example.lab.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 业务错误码字典。 + * + *

1xxx 用户与认证,2xxx 资源管理,3xxx 预约与冲突,9xxx 系统级错误。

+ */ +@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; +} diff --git a/src/main/java/com/example/lab/common/exception/GlobalExceptionHandler.java b/src/main/java/com/example/lab/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..3802adc --- /dev/null +++ b/src/main/java/com/example/lab/common/exception/GlobalExceptionHandler.java @@ -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 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 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 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 handleNotReadable(HttpMessageNotReadableException ex) { + log.warn("请求体解析失败: {}", ex.getMessage()); + return ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误"); + } + + /** + * 处理数据库唯一键冲突。 + * + * @param ex 唯一键冲突异常 + * @return 统一错误响应 + */ + @ExceptionHandler(DuplicateKeyException.class) + public ApiResponse handleDuplicateKey(DuplicateKeyException ex) { + log.warn("唯一键冲突: {}", ex.getMessage()); + return ApiResponse.error(ErrorCode.CONFLICT.getCode(), "数据已存在,请检查唯一字段"); + } + + /** + * 兜底异常处理。 + * + * @param ex 未知异常 + * @return 统一错误响应 + */ + @ExceptionHandler(Exception.class) + public ApiResponse 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(); + } +} diff --git a/src/main/java/com/example/lab/common/util/JwtUtils.java b/src/main/java/com/example/lab/common/util/JwtUtils.java new file mode 100644 index 0000000..999b7a9 --- /dev/null +++ b/src/main/java/com/example/lab/common/util/JwtUtils.java @@ -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; + } +} diff --git a/src/main/java/com/example/lab/config/LabProperties.java b/src/main/java/com/example/lab/config/LabProperties.java new file mode 100644 index 0000000..db2f6be --- /dev/null +++ b/src/main/java/com/example/lab/config/LabProperties.java @@ -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 blockingStatuses = + List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE); + + /** 重复预约系列允许生成的最大场次数 */ + private int maxSeriesOccurrences = 60; + + /** + * 获取参与冲突检测的状态集合。 + * + * @return 状态集合 + */ + public Set blockingStatusSet() { + return blockingStatuses == null || blockingStatuses.isEmpty() + ? ReservationStatus.DEFAULT_BLOCKING + : EnumSet.copyOf(blockingStatuses); + } + } +} diff --git a/src/main/java/com/example/lab/config/MybatisPlusConfig.java b/src/main/java/com/example/lab/config/MybatisPlusConfig.java new file mode 100644 index 0000000..cc113a1 --- /dev/null +++ b/src/main/java/com/example/lab/config/MybatisPlusConfig.java @@ -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()); + } + }; + } +} diff --git a/src/main/java/com/example/lab/config/SecurityConfig.java b/src/main/java/com/example/lab/config/SecurityConfig.java new file mode 100644 index 0000000..2122f28 --- /dev/null +++ b/src/main/java/com/example/lab/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/example/lab/config/WebConfig.java b/src/main/java/com/example/lab/config/WebConfig.java new file mode 100644 index 0000000..8aa4c17 --- /dev/null +++ b/src/main/java/com/example/lab/config/WebConfig.java @@ -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); + } +} diff --git a/src/main/java/com/example/lab/controller/AuthController.java b/src/main/java/com/example/lab/controller/AuthController.java new file mode 100644 index 0000000..7cda34c --- /dev/null +++ b/src/main/java/com/example/lab/controller/AuthController.java @@ -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 login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.ok(authService.login(request)); + } + + /** + * 查询当前登录用户。 + * + * @return 用户信息 + */ + @GetMapping("/me") + public ApiResponse me() { + return ApiResponse.ok(authService.currentUser()); + } + + /** + * 登出(JWT 无状态,前端丢弃令牌即可,此接口用于审计埋点)。 + * + * @return 空响应 + */ + @PostMapping("/logout") + public ApiResponse logout() { + return ApiResponse.ok(); + } +} diff --git a/src/main/java/com/example/lab/controller/CourseController.java b/src/main/java/com/example/lab/controller/CourseController.java new file mode 100644 index 0000000..9693284 --- /dev/null +++ b/src/main/java/com/example/lab/controller/CourseController.java @@ -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> 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> all() { + return ApiResponse.ok(courseService.listAll()); + } + + /** + * 查询某教师开设的课程。 + * + * @param teacherId 教师 ID + * @return 课程列表 + */ + @GetMapping("/by-teacher/{teacherId}") + public ApiResponse> byTeacher(@PathVariable Long teacherId) { + return ApiResponse.ok(courseService.listByTeacher(teacherId)); + } + + /** + * 查询课程详情。 + * + * @param id 课程 ID + * @return 课程 + */ + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(courseService.get(id)); + } + + /** + * 新增课程(管理员/教师)。 + * + * @param request 新增请求 + * @return 新建课程 + */ + @PostMapping + @RequireRole({Role.ADMIN, Role.TEACHER}) + public ApiResponse 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 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 finish(@PathVariable Long id) { + return ApiResponse.ok(courseService.finish(id)); + } + + /** + * 删除课程(管理员/授课教师)。 + * + * @param id 课程 ID + * @return 空响应 + */ + @DeleteMapping("/{id}") + @RequireRole({Role.ADMIN, Role.TEACHER}) + public ApiResponse delete(@PathVariable Long id) { + courseService.delete(id); + return ApiResponse.ok(); + } +} diff --git a/src/main/java/com/example/lab/controller/DeviceController.java b/src/main/java/com/example/lab/controller/DeviceController.java new file mode 100644 index 0000000..4831813 --- /dev/null +++ b/src/main/java/com/example/lab/controller/DeviceController.java @@ -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> 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> byLab(@PathVariable Long labId) { + return ApiResponse.ok(deviceService.listByLab(labId)); + } + + /** + * 查询设备详情。 + * + * @param id 设备 ID + * @return 设备 + */ + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(deviceService.get(id)); + } + + /** + * 新增设备(管理员)。 + * + * @param request 新增请求 + * @return 新建设备 + */ + @PostMapping + @RequireRole(Role.ADMIN) + public ApiResponse create(@Valid @RequestBody DeviceRequest request) { + return ApiResponse.ok(deviceService.create(request)); + } + + /** + * 修改设备(管理员)。 + * + * @param id 设备 ID + * @param request 修改请求 + * @return 更新后的设备 + */ + @PutMapping("/{id}") + @RequireRole(Role.ADMIN) + public ApiResponse 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 delete(@PathVariable Long id) { + deviceService.delete(id); + return ApiResponse.ok(); + } + + /** + * 变更设备状态(管理员)。 + * + * @param id 设备 ID + * @param request 状态请求 + * @return 更新后的设备 + */ + @PatchMapping("/{id}/status") + @RequireRole(Role.ADMIN) + public ApiResponse changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) { + return ApiResponse.ok(deviceService.changeStatus(id, request.getDeviceStatus())); + } +} diff --git a/src/main/java/com/example/lab/controller/LabController.java b/src/main/java/com/example/lab/controller/LabController.java new file mode 100644 index 0000000..0ee07a4 --- /dev/null +++ b/src/main/java/com/example/lab/controller/LabController.java @@ -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> 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> all() { + return ApiResponse.ok(labService.listAll()); + } + + /** + * 查询实验室详情。 + * + * @param id 实验室 ID + * @return 实验室 + */ + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(labService.get(id)); + } + + /** + * 查询指定时间段内可用的实验室。 + * + * @param start 开始时间 + * @param end 结束时间 + * @param capacity 最少容量 + * @return 可用实验室列表 + */ + @GetMapping("/available") + public ApiResponse> 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 create(@Valid @RequestBody LabRequest request) { + return ApiResponse.ok(labService.create(request)); + } + + /** + * 修改实验室(管理员)。 + * + * @param id 实验室 ID + * @param request 修改请求 + * @return 更新后的实验室 + */ + @PutMapping("/{id}") + @RequireRole(Role.ADMIN) + public ApiResponse 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 delete(@PathVariable Long id) { + labService.delete(id); + return ApiResponse.ok(); + } + + /** + * 变更实验室状态(管理员),流转受状态机约束。 + * + * @param id 实验室 ID + * @param request 状态请求 + * @return 更新后的实验室 + */ + @PatchMapping("/{id}/status") + @RequireRole(Role.ADMIN) + public ApiResponse changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) { + return ApiResponse.ok(labService.changeStatus(id, request.getLabStatus())); + } +} diff --git a/src/main/java/com/example/lab/controller/ReservationController.java b/src/main/java/com/example/lab/controller/ReservationController.java new file mode 100644 index 0000000..6f0b534 --- /dev/null +++ b/src/main/java/com/example/lab/controller/ReservationController.java @@ -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 create(@Valid @RequestBody ReservationCreateRequest request) { + return ApiResponse.ok(reservationService.create(request)); + } + + /** + * 创建多次预约。 + * + * @param request 多次预约请求 + * @return 生成的预约列表 + */ + @PostMapping("/recurring") + public ApiResponse> createRecurring(@Valid @RequestBody RecurringReservationRequest request) { + return ApiResponse.ok(reservationService.createRecurring(request)); + } + + /** + * 创建课程绑定预约(管理员/授课教师)。 + * + * @param request 课程预约请求 + * @return 生成的预约列表 + */ + @PostMapping("/course") + public ApiResponse> createForCourse(@Valid @RequestBody CourseReservationRequest request) { + return ApiResponse.ok(reservationService.createForCourse(request)); + } + + /** + * 冲突预检,不落库。 + * + * @param request 预检请求 + * @return 冲突检查结果 + */ + @PostMapping("/check") + public ApiResponse check(@Valid @RequestBody ConflictCheckRequest request) { + List conflicts = reservationService.check(request); + return ApiResponse.ok(new ConflictCheckVO(conflicts.isEmpty(), conflicts)); + } + + /** + * 分页查询预约。 + * + * @param query 查询条件 + * @return 分页结果 + */ + @GetMapping + public ApiResponse> page(ReservationQuery query) { + return ApiResponse.ok(reservationService.page(query)); + } + + /** + * 查询预约详情。 + * + * @param id 预约 ID + * @return 预约详情 + */ + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(reservationService.get(id)); + } + + /** + * 查询实验室日历。 + * + * @param labId 实验室 ID + * @param from 开始日期 + * @param to 结束日期 + * @return 预约列表 + */ + @GetMapping("/calendar") + public ApiResponse> 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 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 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> 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 start(@PathVariable Long id) { + return ApiResponse.ok(reservationService.start(id)); + } + + /** + * 完成使用(本人或管理员)。 + * + * @param id 预约 ID + * @return 更新后的预约 + */ + @PostMapping("/{id}/complete") + public ApiResponse complete(@PathVariable Long id) { + return ApiResponse.ok(reservationService.complete(id)); + } +} diff --git a/src/main/java/com/example/lab/controller/UserController.java b/src/main/java/com/example/lab/controller/UserController.java new file mode 100644 index 0000000..b7abdca --- /dev/null +++ b/src/main/java/com/example/lab/controller/UserController.java @@ -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() { + return ApiResponse.ok(userService.list()); + } + + /** + * 查询用户详情。 + * + * @param id 用户 ID + * @return 用户信息 + */ + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(userService.get(id)); + } + + /** + * 新增用户。 + * + * @param request 新增请求 + * @return 新建用户 + */ + @PostMapping + public ApiResponse create(@Valid @RequestBody UserCreateRequest request) { + return ApiResponse.ok(userService.create(request)); + } + + /** + * 修改用户。 + * + * @param id 用户 ID + * @param request 修改请求 + * @return 更新后的用户 + */ + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody UserUpdateRequest request) { + return ApiResponse.ok(userService.update(id, request)); + } + + /** + * 删除用户。 + * + * @param id 用户 ID + * @return 空响应 + */ + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + userService.delete(id); + return ApiResponse.ok(); + } +} diff --git a/src/main/java/com/example/lab/domain/conflict/Conflict.java b/src/main/java/com/example/lab/domain/conflict/Conflict.java new file mode 100644 index 0000000..ebce436 --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/Conflict.java @@ -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; + } +} diff --git a/src/main/java/com/example/lab/domain/conflict/ConflictDetector.java b/src/main/java/com/example/lab/domain/conflict/ConflictDetector.java new file mode 100644 index 0000000..48419b5 --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/ConflictDetector.java @@ -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; + +/** + * 预约冲突检测器:平台的核心算法组件。 + * + *

检测分两层:

+ *
    + *
  1. 规则校验:时间区间合法性、是否过去、提前预约上限、单次时长、 + * 开放时间、资源状态、容量、课程绑定关系;
  2. + *
  3. 区间重叠检测:对每个请求时间段,与已有「占用态」预约做两路归并扫描, + * 时间复杂度 O(n·log n + m·log m + k),其中 k 为实际重叠对数。
  4. + *
+ * + *

本类不访问数据库,所有数据由 {@link ConflictQuery} 传入,便于单元测试与复用。

+ */ +@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 detect(ConflictQuery query) { + Objects.requireNonNull(query, "query 不能为 null"); + List conflicts = new ArrayList<>(); + List 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)); + } + + /** + * 在请求时间段与已有预约之间执行重叠检测。 + * + *

算法:两侧分别按开始时间升序排序,使用单调右移的指针 i 跳过 + * 「结束时间不晚于当前已有预约开始时间」的请求段,再从 i 向后扫描 + * 所有开始时间早于当前已有预约结束时间的请求段并逐一判断是否真正重叠。 + * 由于已有预约也按开始时间有序,指针无需回退,整体保持近线性复杂度。

+ * + * @param requested 请求时间段(可乱序) + * @param existing 已有预约(可乱序,允许包含无关记录) + * @param ignoreId 需要忽略的预约 ID(更新自身时使用) + * @return 重叠匹配列表 + */ + public List findOverlaps(List requested, List existing, Long ignoreId) { + List overlaps = new ArrayList<>(); + if (requested == null || existing == null) { + return overlaps; + } + List 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 blockingStatuses = properties.getReservation().blockingStatusSet(); + List 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 slots, LocalDateTime now, + List 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 slots, List 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 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 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 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 slots, List conflicts) { + List 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())); + } + } + } + + /** + * 把与已有预约的重叠匹配转换成带类型的冲突明细。 + * + *

分类优先级:同实验室 > 同设备 > 同用户,保证一条已有预约最多产生一条冲突。

+ * + * @param query 检测入参 + * @return 冲突列表 + */ + private List detectReservationConflicts(ConflictQuery query) { + List 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) { + } +} diff --git a/src/main/java/com/example/lab/domain/conflict/ConflictQuery.java b/src/main/java/com/example/lab/domain/conflict/ConflictQuery.java new file mode 100644 index 0000000..d8aaf2c --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/ConflictQuery.java @@ -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; + +/** + * 冲突检测输入参数。 + * + *

该类是纯粹的入参载体:调用方负责把数据库中的实验室、设备、课程、 + * 候选时间段以及可能冲突的已有预约一次性查出来,检测器本身不访问数据库, + * 因此可以非常方便地进行单元测试。

+ */ +@Data +@Builder +public class ConflictQuery { + + /** 请求占用的时间段列表(单次预约只有 1 个,多次/课程预约有 N 个) */ + private List requestedSlots; + + /** 可能参与冲突的已有预约(建议只查候选时间范围内的记录) */ + private List 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; +} diff --git a/src/main/java/com/example/lab/domain/conflict/ConflictType.java b/src/main/java/com/example/lab/domain/conflict/ConflictType.java new file mode 100644 index 0000000..9bbd3ec --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/ConflictType.java @@ -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; +} diff --git a/src/main/java/com/example/lab/domain/conflict/Overlap.java b/src/main/java/com/example/lab/domain/conflict/Overlap.java new file mode 100644 index 0000000..ffbf4ed --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/Overlap.java @@ -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) { +} diff --git a/src/main/java/com/example/lab/domain/conflict/RecurrenceExpander.java b/src/main/java/com/example/lab/domain/conflict/RecurrenceExpander.java new file mode 100644 index 0000000..de73338 --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/RecurrenceExpander.java @@ -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; + +/** + * 重复规则展开器:把「每周几 + 起止日期 + 每日时间段」展开成具体的时间段列表。 + * + *

支持两种星期写法:英文缩写({@code MON,WED,FRI})与 ISO 数字({@code 1,3,5},1 表示周一)。

+ */ +@Component +@RequiredArgsConstructor +public class RecurrenceExpander { + + /** 英文缩写到 DayOfWeek 的映射 */ + private static final Set ENGLISH_ALIASES = Set.of( + "MON", "TUES", "TUE", "WED", "THUR", "THU", "FRI", "SAT", "SUN"); + + private final LabProperties properties; + + /** + * 展开重复规则。 + * + * @param rule 重复规则 + * @return 按时间升序排列的时间段列表 + * @throws BizException 规则非法、无有效场次或超过最大场次数时抛出 + */ + public List expand(RecurrenceRule rule) { + validate(rule); + int maxOccurrences = Math.max(properties.getReservation().getMaxSeriesOccurrences(), 1); + List 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 parseWeekdays(String text) { + if (text == null || text.isBlank()) { + throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复星期不能为空"); + } + Set 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 resolveDates(RecurrenceRule rule) { + validate(rule); + Set 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; + } +} diff --git a/src/main/java/com/example/lab/domain/conflict/RecurrenceRule.java b/src/main/java/com/example/lab/domain/conflict/RecurrenceRule.java new file mode 100644 index 0000000..4005860 --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/RecurrenceRule.java @@ -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 weekdays, + LocalTime startTime, + LocalTime endTime) { + + /** + * 规则是否基本完整(非空字段)。 + * + * @return true 表示字段齐全 + */ + public boolean isComplete() { + return startDate != null && endDate != null && weekdays != null && !weekdays.isEmpty() + && startTime != null && endTime != null; + } +} diff --git a/src/main/java/com/example/lab/domain/conflict/TimeSlot.java b/src/main/java/com/example/lab/domain/conflict/TimeSlot.java new file mode 100644 index 0000000..b4caee5 --- /dev/null +++ b/src/main/java/com/example/lab/domain/conflict/TimeSlot.java @@ -0,0 +1,86 @@ +package com.example.lab.domain.conflict; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * 左闭右开时间区间 {@code [start, end)},是冲突检测的最小计算单元。 + * + *

约定:区间长度为 0 或为负均视为非法;相邻区间(前者的 end 等于后者的 start) + * 不算冲突,这样可以支持「上一场刚结束、下一场立即开始」的连续排课。

+ * + * @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()); + } +} diff --git a/src/main/java/com/example/lab/domain/entity/Course.java b/src/main/java/com/example/lab/domain/entity/Course.java new file mode 100644 index 0000000..4583114 --- /dev/null +++ b/src/main/java/com/example/lab/domain/entity/Course.java @@ -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; + +/** + * 实验课程实体。 + * + *

课程自带周期性排课规则(起止日期 + 星期 + 每日时间段), + * 教师可基于课程一键生成整学期的课程绑定预约。

+ */ +@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; +} diff --git a/src/main/java/com/example/lab/domain/entity/Device.java b/src/main/java/com/example/lab/domain/entity/Device.java new file mode 100644 index 0000000..0618608 --- /dev/null +++ b/src/main/java/com/example/lab/domain/entity/Device.java @@ -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; +} diff --git a/src/main/java/com/example/lab/domain/entity/Lab.java b/src/main/java/com/example/lab/domain/entity/Lab.java new file mode 100644 index 0000000..f04228e --- /dev/null +++ b/src/main/java/com/example/lab/domain/entity/Lab.java @@ -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; +} diff --git a/src/main/java/com/example/lab/domain/entity/Reservation.java b/src/main/java/com/example/lab/domain/entity/Reservation.java new file mode 100644 index 0000000..e64ede8 --- /dev/null +++ b/src/main/java/com/example/lab/domain/entity/Reservation.java @@ -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; + +/** + * 预约场次实体,是整个系统的核心业务对象。 + * + *

冲突检测的最小单位就是一条 {@code Reservation} 的 + * [{@code startTime}, {@code endTime}) 左闭右开区间。

+ */ +@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; +} diff --git a/src/main/java/com/example/lab/domain/entity/ReservationSeries.java b/src/main/java/com/example/lab/domain/entity/ReservationSeries.java new file mode 100644 index 0000000..68852d3 --- /dev/null +++ b/src/main/java/com/example/lab/domain/entity/ReservationSeries.java @@ -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; + +/** + * 重复预约系列实体。 + * + *

「多次预约」在数据库中拆分为:一条系列记录 + N 条 {@link Reservation} 场次记录, + * 这样每一次占用都是独立的行,冲突检测与审批都可以按统一的时间区间模型处理。

+ */ +@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; +} diff --git a/src/main/java/com/example/lab/domain/entity/User.java b/src/main/java/com/example/lab/domain/entity/User.java new file mode 100644 index 0000000..594b01e --- /dev/null +++ b/src/main/java/com/example/lab/domain/entity/User.java @@ -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; +} diff --git a/src/main/java/com/example/lab/domain/enums/CourseStatus.java b/src/main/java/com/example/lab/domain/enums/CourseStatus.java new file mode 100644 index 0000000..e8de776 --- /dev/null +++ b/src/main/java/com/example/lab/domain/enums/CourseStatus.java @@ -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; +} diff --git a/src/main/java/com/example/lab/domain/enums/DeviceStatus.java b/src/main/java/com/example/lab/domain/enums/DeviceStatus.java new file mode 100644 index 0000000..146bddd --- /dev/null +++ b/src/main/java/com/example/lab/domain/enums/DeviceStatus.java @@ -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; + } +} diff --git a/src/main/java/com/example/lab/domain/enums/LabStatus.java b/src/main/java/com/example/lab/domain/enums/LabStatus.java new file mode 100644 index 0000000..c99ec06 --- /dev/null +++ b/src/main/java/com/example/lab/domain/enums/LabStatus.java @@ -0,0 +1,39 @@ +package com.example.lab.domain.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 实验室状态机中的状态定义。 + * + *

合法流转路径(详见 {@code LabStateMachine}):

+ *
+ * IDLE(空闲) --预约--> RESERVED(已预约) --开始使用--> IN_USE(使用中) --结束--> IDLE
+ * IDLE --报修--> MAINTENANCE(维护中) --完成--> IDLE
+ * RESERVED --取消/驳回--> IDLE
+ * 
+ */ +@Getter +@RequiredArgsConstructor +public enum LabStatus { + + /** 空闲,可被预约 */ + IDLE("空闲"), + /** 已被预约(当前时段存在有效预约) */ + RESERVED("已预约"), + /** 使用中 */ + IN_USE("使用中"), + /** 维护中,不可预约 */ + MAINTENANCE("维护中"); + + private final String label; + + /** + * 是否允许被预约。 + * + * @return true 表示可预约 + */ + public boolean isReservable() { + return this == IDLE || this == RESERVED; + } +} diff --git a/src/main/java/com/example/lab/domain/enums/ReservationStatus.java b/src/main/java/com/example/lab/domain/enums/ReservationStatus.java new file mode 100644 index 0000000..3f267ea --- /dev/null +++ b/src/main/java/com/example/lab/domain/enums/ReservationStatus.java @@ -0,0 +1,58 @@ +package com.example.lab.domain.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.EnumSet; +import java.util.Set; + +/** + * 预约状态机中的状态定义。 + * + *
+ * PENDING(待审批) --通过--> APPROVED(已通过) --开始--> IN_USE(使用中) --结束--> COMPLETED(已完成)
+ * PENDING --驳回--> REJECTED(已驳回)
+ * PENDING/APPROVED --取消--> CANCELLED(已取消)
+ * 
+ */ +@Getter +@RequiredArgsConstructor +public enum ReservationStatus { + + /** 待审批 */ + PENDING("待审批"), + /** 已通过 */ + APPROVED("已通过"), + /** 已驳回 */ + REJECTED("已驳回"), + /** 已取消 */ + CANCELLED("已取消"), + /** 使用中 */ + IN_USE("使用中"), + /** 已完成 */ + COMPLETED("已完成"); + + private final String label; + + /** 参与时间冲突检测的状态集合:只有这些状态代表资源被实际占用。 */ + public static final Set 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; + } +} diff --git a/src/main/java/com/example/lab/domain/enums/ReservationType.java b/src/main/java/com/example/lab/domain/enums/ReservationType.java new file mode 100644 index 0000000..3b917dd --- /dev/null +++ b/src/main/java/com/example/lab/domain/enums/ReservationType.java @@ -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; +} diff --git a/src/main/java/com/example/lab/domain/enums/Role.java b/src/main/java/com/example/lab/domain/enums/Role.java new file mode 100644 index 0000000..a1c9ced --- /dev/null +++ b/src/main/java/com/example/lab/domain/enums/Role.java @@ -0,0 +1,54 @@ +package com.example.lab.domain.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 系统角色枚举,用于 RBAC 权限控制。 + * + *
    + *
  • {@link #ADMIN} 管理员:资源、用户、预约的全量管理权限
  • + *
  • {@link #TEACHER} 教师:创建课程/课程绑定预约,审批本课程预约
  • + *
  • {@link #STUDENT} 学生:创建个人预约、查看自己的预约
  • + *
+ */ +@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; + } +} diff --git a/src/main/java/com/example/lab/domain/statemachine/DeviceStateMachine.java b/src/main/java/com/example/lab/domain/statemachine/DeviceStateMachine.java new file mode 100644 index 0000000..9449d2d --- /dev/null +++ b/src/main/java/com/example/lab/domain/statemachine/DeviceStateMachine.java @@ -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 { + + /** + * 构造状态机并注册合法迁移。 + */ + 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 getStateType() { + return DeviceStatus.class; + } +} diff --git a/src/main/java/com/example/lab/domain/statemachine/EnumStateMachine.java b/src/main/java/com/example/lab/domain/statemachine/EnumStateMachine.java new file mode 100644 index 0000000..c2a4d5a --- /dev/null +++ b/src/main/java/com/example/lab/domain/statemachine/EnumStateMachine.java @@ -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; + +/** + * 通用枚举状态机。 + * + *

子类在构造时通过 {@link #register(Enum, Enum...)} 声明所有合法迁移, + * 未声明的迁移一律视为非法。

+ * + * @param 状态枚举类型 + */ +public abstract class EnumStateMachine> { + + /** 状态迁移表:from -> 允许到达的 to 集合 */ + private final Map> transitions = new EnumMap<>(getStateType()); + + /** + * 返回状态枚举类型,供子类提供。 + * + * @return 状态枚举 Class + */ + protected abstract Class getStateType(); + + /** + * 注册合法迁移。 + * + * @param from 起始状态 + * @param to 允许到达的一个或多个状态 + */ + protected void register(S from, S... to) { + Set 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 nextStates(S from) { + return Collections.unmodifiableSet(transitions.getOrDefault(from, EnumSet.noneOf(getStateType()))); + } +} diff --git a/src/main/java/com/example/lab/domain/statemachine/LabStateMachine.java b/src/main/java/com/example/lab/domain/statemachine/LabStateMachine.java new file mode 100644 index 0000000..f430174 --- /dev/null +++ b/src/main/java/com/example/lab/domain/statemachine/LabStateMachine.java @@ -0,0 +1,41 @@ +package com.example.lab.domain.statemachine; + +import com.example.lab.domain.enums.LabStatus; +import org.springframework.stereotype.Component; + +/** + * 实验室状态机。 + * + *
+ * IDLE         -> RESERVED, MAINTENANCE
+ * RESERVED     -> IDLE, IN_USE
+ * IN_USE       -> IDLE
+ * MAINTENANCE  -> IDLE
+ * 
+ * + *

说明:维护操作必须先释放预约(RESERVED -> IDLE), + * 避免出现「有预约却被维护」的脏状态。

+ */ +@Component +public class LabStateMachine extends EnumStateMachine { + + /** + * 构造状态机并注册合法迁移。 + */ + 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 getStateType() { + return LabStatus.class; + } +} diff --git a/src/main/java/com/example/lab/domain/statemachine/ReservationStateMachine.java b/src/main/java/com/example/lab/domain/statemachine/ReservationStateMachine.java new file mode 100644 index 0000000..1d7207b --- /dev/null +++ b/src/main/java/com/example/lab/domain/statemachine/ReservationStateMachine.java @@ -0,0 +1,39 @@ +package com.example.lab.domain.statemachine; + +import com.example.lab.domain.enums.ReservationStatus; +import org.springframework.stereotype.Component; + +/** + * 预约状态机。 + * + *
+ * PENDING   -> APPROVED, REJECTED, CANCELLED
+ * APPROVED  -> IN_USE, CANCELLED, COMPLETED(时间已过而未签到,由定时任务自动完成)
+ * IN_USE    -> COMPLETED
+ * REJECTED / CANCELLED / COMPLETED 为终态
+ * 
+ */ +@Component +public class ReservationStateMachine extends EnumStateMachine { + + /** + * 构造状态机并注册合法迁移。 + */ + 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 getStateType() { + return ReservationStatus.class; + } +} diff --git a/src/main/java/com/example/lab/dto/ConflictCheckRequest.java b/src/main/java/com/example/lab/dto/ConflictCheckRequest.java new file mode 100644 index 0000000..1752bc0 --- /dev/null +++ b/src/main/java/com/example/lab/dto/ConflictCheckRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/CourseRequest.java b/src/main/java/com/example/lab/dto/CourseRequest.java new file mode 100644 index 0000000..6de7add --- /dev/null +++ b/src/main/java/com/example/lab/dto/CourseRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/CourseReservationRequest.java b/src/main/java/com/example/lab/dto/CourseReservationRequest.java new file mode 100644 index 0000000..d5ba254 --- /dev/null +++ b/src/main/java/com/example/lab/dto/CourseReservationRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/DeviceRequest.java b/src/main/java/com/example/lab/dto/DeviceRequest.java new file mode 100644 index 0000000..cf7828e --- /dev/null +++ b/src/main/java/com/example/lab/dto/DeviceRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/LabRequest.java b/src/main/java/com/example/lab/dto/LabRequest.java new file mode 100644 index 0000000..b1e5ac9 --- /dev/null +++ b/src/main/java/com/example/lab/dto/LabRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/LoginRequest.java b/src/main/java/com/example/lab/dto/LoginRequest.java new file mode 100644 index 0000000..bed5e39 --- /dev/null +++ b/src/main/java/com/example/lab/dto/LoginRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/RecurringReservationRequest.java b/src/main/java/com/example/lab/dto/RecurringReservationRequest.java new file mode 100644 index 0000000..774af30 --- /dev/null +++ b/src/main/java/com/example/lab/dto/RecurringReservationRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/RejectRequest.java b/src/main/java/com/example/lab/dto/RejectRequest.java new file mode 100644 index 0000000..b59f857 --- /dev/null +++ b/src/main/java/com/example/lab/dto/RejectRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/ReservationCreateRequest.java b/src/main/java/com/example/lab/dto/ReservationCreateRequest.java new file mode 100644 index 0000000..018f4aa --- /dev/null +++ b/src/main/java/com/example/lab/dto/ReservationCreateRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/ReservationQuery.java b/src/main/java/com/example/lab/dto/ReservationQuery.java new file mode 100644 index 0000000..9997aae --- /dev/null +++ b/src/main/java/com/example/lab/dto/ReservationQuery.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/StatusChangeRequest.java b/src/main/java/com/example/lab/dto/StatusChangeRequest.java new file mode 100644 index 0000000..1e85195 --- /dev/null +++ b/src/main/java/com/example/lab/dto/StatusChangeRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/UserCreateRequest.java b/src/main/java/com/example/lab/dto/UserCreateRequest.java new file mode 100644 index 0000000..57f783f --- /dev/null +++ b/src/main/java/com/example/lab/dto/UserCreateRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/dto/UserUpdateRequest.java b/src/main/java/com/example/lab/dto/UserUpdateRequest.java new file mode 100644 index 0000000..ec458b3 --- /dev/null +++ b/src/main/java/com/example/lab/dto/UserUpdateRequest.java @@ -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; +} diff --git a/src/main/java/com/example/lab/mapper/CourseMapper.java b/src/main/java/com/example/lab/mapper/CourseMapper.java new file mode 100644 index 0000000..3c4bc9a --- /dev/null +++ b/src/main/java/com/example/lab/mapper/CourseMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/lab/mapper/DeviceMapper.java b/src/main/java/com/example/lab/mapper/DeviceMapper.java new file mode 100644 index 0000000..5d148f4 --- /dev/null +++ b/src/main/java/com/example/lab/mapper/DeviceMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/lab/mapper/LabMapper.java b/src/main/java/com/example/lab/mapper/LabMapper.java new file mode 100644 index 0000000..ee742bd --- /dev/null +++ b/src/main/java/com/example/lab/mapper/LabMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/lab/mapper/ReservationMapper.java b/src/main/java/com/example/lab/mapper/ReservationMapper.java new file mode 100644 index 0000000..4d133e3 --- /dev/null +++ b/src/main/java/com/example/lab/mapper/ReservationMapper.java @@ -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。 + * + *

约定:基础 CRUD 一律使用 MyBatis-Plus 的 {@link BaseMapper} 与 + * {@code LambdaQueryWrapper};只有「带 FOR UPDATE 的动态冲突粗筛」这一条复杂 SQL + * 放在 XML({@code resources/mapper/ReservationMapper.xml})中维护。

+ */ +public interface ReservationMapper extends BaseMapper { + + /** + * 查询可能与目标时间段冲突的预约。 + * + *

SQL 使用左闭右开区间语义:{@code start_time < end AND end_time > start}, + * 因此首尾相接(前一场 end 等于后一场 start)不会被查出。

+ * + * @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 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 statuses, + @Param("lock") boolean lock); +} diff --git a/src/main/java/com/example/lab/mapper/ReservationSeriesMapper.java b/src/main/java/com/example/lab/mapper/ReservationSeriesMapper.java new file mode 100644 index 0000000..e944888 --- /dev/null +++ b/src/main/java/com/example/lab/mapper/ReservationSeriesMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/lab/mapper/UserMapper.java b/src/main/java/com/example/lab/mapper/UserMapper.java new file mode 100644 index 0000000..634e4a7 --- /dev/null +++ b/src/main/java/com/example/lab/mapper/UserMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/lab/security/AuthInterceptor.java b/src/main/java/com/example/lab/security/AuthInterceptor.java new file mode 100644 index 0000000..d94d1c2 --- /dev/null +++ b/src/main/java/com/example/lab/security/AuthInterceptor.java @@ -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; + +/** + * 认证与授权拦截器。 + * + *

流程:解析 {@code Authorization: Bearer } → 校验签名与有效期 → + * 写入 {@link UserContext} → 根据 {@link RequireRole} 做角色校验。

+ */ +@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 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}"); + } +} diff --git a/src/main/java/com/example/lab/service/AuthService.java b/src/main/java/com/example/lab/service/AuthService.java new file mode 100644 index 0000000..7589f0e --- /dev/null +++ b/src/main/java/com/example/lab/service/AuthService.java @@ -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() + .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); + } +} diff --git a/src/main/java/com/example/lab/service/CourseService.java b/src/main/java/com/example/lab/service/CourseService.java new file mode 100644 index 0000000..a7aac86 --- /dev/null +++ b/src/main/java/com/example/lab/service/CourseService.java @@ -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 page(long page, long size, String keyword, Long labId, CourseStatus status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().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 listAll() { + return courseMapper.selectList(new LambdaQueryWrapper().orderByAsc(Course::getId)); + } + + /** + * 查询某教师开设的课程。 + * + * @param teacherId 教师 ID + * @return 课程列表 + */ + public List listByTeacher(Long teacherId) { + return courseMapper.selectList(new LambdaQueryWrapper() + .eq(Course::getTeacherId, teacherId) + .orderByAsc(Course::getId)); + } + + /** + * 查询课程详情。 + * + * @param id 课程 ID + * @return 课程 + * @throws BizException 课程不存在时抛出 + */ + public Course get(Long id) { + return requireCourse(id); + } + + /** + * 新增课程。 + * + *

权限规则:教师只能给自己开课;管理员可以指定任意教师。

+ * + * @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() + .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() + .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; + } +} diff --git a/src/main/java/com/example/lab/service/DeviceService.java b/src/main/java/com/example/lab/service/DeviceService.java new file mode 100644 index 0000000..f7e5602 --- /dev/null +++ b/src/main/java/com/example/lab/service/DeviceService.java @@ -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 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 page(long page, long size, String keyword, Long labId, DeviceStatus status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().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 listByLab(Long labId) { + return deviceMapper.selectList(new LambdaQueryWrapper() + .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() + .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() + .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; + } +} diff --git a/src/main/java/com/example/lab/service/LabService.java b/src/main/java/com/example/lab/service/LabService.java new file mode 100644 index 0000000..03e1c2b --- /dev/null +++ b/src/main/java/com/example/lab/service/LabService.java @@ -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 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 page(long page, long size, String keyword, LabStatus status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 listAll() { + return labMapper.selectList(new LambdaQueryWrapper().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() + .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; + } + + /** + * 查询指定时间段内可用的实验室。 + * + *

先按状态与容量过滤,再逐个做区间重叠查询;实验室数量有限, + * 该策略实现简单且语义清晰。

+ * + * @param start 开始时间 + * @param end 结束时间 + * @param capacity 最少容量,可为空 + * @return 可用实验室列表 + * @throws BizException 时间区间非法时抛出 + */ + public List listAvailable(LocalDateTime start, LocalDateTime end, Integer capacity) { + if (start == null || end == null || !start.isBefore(end)) { + throw new BizException(ErrorCode.INVALID_TIME_RANGE); + } + List candidates = labMapper.selectList(new LambdaQueryWrapper() + .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() + .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; + } +} diff --git a/src/main/java/com/example/lab/service/ReservationService.java b/src/main/java/com/example/lab/service/ReservationService.java new file mode 100644 index 0000000..3bc6a18 --- /dev/null +++ b/src/main/java/com/example/lab/service/ReservationService.java @@ -0,0 +1,1036 @@ +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.config.LabProperties; +import com.example.lab.domain.conflict.Conflict; +import com.example.lab.domain.conflict.ConflictDetector; +import com.example.lab.domain.conflict.ConflictQuery; +import com.example.lab.domain.conflict.RecurrenceExpander; +import com.example.lab.domain.conflict.RecurrenceRule; +import com.example.lab.domain.conflict.TimeSlot; +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.ReservationSeries; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 预约服务:单次预约、多次预约、课程绑定预约的创建、审批、取消与状态流转。 + * + *

并发控制策略:创建/审批等写操作在同一个事务内先执行带 {@code FOR UPDATE} + * 的候选冲突查询,再运行应用层冲突检测,最后写入,从而避免「先查后写」的竞态。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReservationService { + + /** 允许取消的状态 */ + private static final List CANCELLABLE_STATUSES = + List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED); + + /** 代表资源被占用的状态集合,用于资源状态同步 */ + private static final List OCCUPYING_STATUSES = + List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE); + + private final ReservationMapper reservationMapper; + private final ReservationSeriesMapper seriesMapper; + private final LabMapper labMapper; + private final DeviceMapper deviceMapper; + private final CourseMapper courseMapper; + private final UserMapper userMapper; + private final ConflictDetector conflictDetector; + private final RecurrenceExpander recurrenceExpander; + private final ReservationStateMachine reservationStateMachine; + private final LabStateMachine labStateMachine; + private final DeviceStateMachine deviceStateMachine; + private final LabProperties properties; + + /** + * 创建单次预约。 + * + *

审批策略:管理员与教师提交后直接通过,学生提交后进入待审批。

+ * + * @param request 预约请求 + * @return 新建预约 + * @throws BizException 资源不存在、时间非法或存在冲突时抛出 + */ + @Transactional + public ReservationVO create(ReservationCreateRequest request) { + LoginUser me = UserContext.requireUser(); + Lab lab = requireLab(request.getLabId()); + Device device = resolveDevice(request.getDeviceId(), lab); + List slots = List.of(TimeSlot.of(request.getStartTime(), request.getEndTime())); + int participants = request.getParticipants() == null ? 1 : request.getParticipants(); + + ConflictQuery query = ConflictQuery.builder() + .requestedSlots(slots) + .lab(lab) + .device(device) + .participants(participants) + .type(ReservationType.SINGLE) + .build(); + checkOrThrow(query, me.id(), true); + + Reservation reservation = buildReservation(null, me, lab, device, null, ReservationType.SINGLE, + request.getTitle(), request.getPurpose(), slots.get(0), participants, initialStatus(me)); + reservationMapper.insert(reservation); + syncLabStatus(lab.getId()); + syncDeviceStatus(device == null ? null : device.getId()); + return toVO(reservation); + } + + /** + * 创建多次预约(按周重复)。 + * + * @param request 多次预约请求 + * @return 生成的预约列表,按时间升序 + * @throws BizException 资源不存在、规则非法或存在冲突时抛出 + */ + @Transactional + public List createRecurring(RecurringReservationRequest request) { + LoginUser me = UserContext.requireUser(); + Lab lab = requireLab(request.getLabId()); + Device device = resolveDevice(request.getDeviceId(), lab); + RecurrenceRule rule = new RecurrenceRule(request.getStartDate(), request.getEndDate(), + recurrenceExpander.parseWeekdays(request.getWeekdays()), + request.getStartTime(), request.getEndTime()); + List slots = recurrenceExpander.expand(rule); + int participants = request.getParticipants() == null ? 1 : request.getParticipants(); + + ConflictQuery query = ConflictQuery.builder() + .requestedSlots(slots) + .lab(lab) + .device(device) + .participants(participants) + .type(ReservationType.RECURRING) + .build(); + checkOrThrow(query, me.id(), true); + + ReservationSeries series = saveSeries(me, lab, device, ReservationType.RECURRING, request.getTitle(), + request.getPurpose(), request.getStartDate(), request.getEndDate(), request.getWeekdays(), + request.getStartTime(), request.getEndTime(), participants); + List reservations = slots.stream() + .map(slot -> buildReservation(series.getId(), me, lab, device, null, ReservationType.RECURRING, + request.getTitle(), request.getPurpose(), slot, participants, initialStatus(me))) + .toList(); + reservations.forEach(reservationMapper::insert); + syncLabStatus(lab.getId()); + syncDeviceStatus(device == null ? null : device.getId()); + return toVOs(reservations); + } + + /** + * 创建课程绑定预约:按课程排课规则生成整期场次。 + * + *

权限:仅管理员或该课程的授课教师可以发起。

+ * + * @param request 课程预约请求 + * @return 生成的预约列表,按时间升序 + * @throws BizException 课程不存在、权限不足、课程已结束或存在冲突时抛出 + */ + @Transactional + public List createForCourse(CourseReservationRequest request) { + LoginUser me = UserContext.requireUser(); + Course course = requireCourse(request.getCourseId()); + if (!me.isAdmin() && !me.id().equals(course.getTeacherId())) { + throw new BizException(ErrorCode.FORBIDDEN, "只有授课教师或管理员可以生成课程预约"); + } + if (course.getStatus() == CourseStatus.FINISHED) { + throw new BizException(ErrorCode.COURSE_NOT_ACTIVE); + } + Lab lab = requireLab(course.getLabId()); + Device device = resolveDevice(request.getDeviceId(), lab); + RecurrenceRule rule = new RecurrenceRule(course.getStartDate(), course.getEndDate(), + recurrenceExpander.parseWeekdays(course.getWeekdays()), + course.getStartTime(), course.getEndTime()); + List slots = recurrenceExpander.expand(rule); + int participants = course.getStudentCount() == null ? 1 : course.getStudentCount(); + String title = request.getTitle() == null || request.getTitle().isBlank() + ? course.getName() : request.getTitle(); + + ConflictQuery query = ConflictQuery.builder() + .requestedSlots(slots) + .lab(lab) + .device(device) + .course(course) + .participants(participants) + .type(ReservationType.COURSE) + .build(); + checkOrThrow(query, me.id(), true); + + ReservationSeries series = saveSeries(me, lab, device, ReservationType.COURSE, title, + request.getPurpose(), course.getStartDate(), course.getEndDate(), course.getWeekdays(), + course.getStartTime(), course.getEndTime(), participants); + List reservations = slots.stream() + .map(slot -> buildReservation(series.getId(), me, lab, device, course.getId(), + ReservationType.COURSE, title, request.getPurpose(), slot, participants, initialStatus(me))) + .toList(); + reservations.forEach(reservationMapper::insert); + syncLabStatus(lab.getId()); + syncDeviceStatus(device == null ? null : device.getId()); + return toVOs(reservations); + } + + /** + * 冲突预检(不落库),供前端表单实时校验。 + * + * @param request 预检请求 + * @return 冲突明细列表,空列表表示可预约 + * @throws BizException 资源或课程不存在、重复规则非法时抛出 + */ + public List check(ConflictCheckRequest request) { + LoginUser me = UserContext.requireUser(); + Lab lab = requireLab(request.getLabId()); + Device device = resolveDevice(request.getDeviceId(), lab); + Course course = request.getCourseId() == null ? null : requireCourse(request.getCourseId()); + List slots = resolveCheckSlots(request, course); + Integer participants = request.getParticipants() != null + ? request.getParticipants() + : (course == null ? 1 : course.getStudentCount()); + + ConflictQuery query = ConflictQuery.builder() + .requestedSlots(slots) + .lab(lab) + .device(device) + .course(course) + .participants(participants) + .type(request.getType()) + .build(); + return detect(query, me.id(), false); + } + + /** + * 分页查询预约。 + * + *

数据范围:学生只能看到自己的预约;管理员与教师可查看全部,也可通过 + * {@code userId} 精确过滤。

+ * + * @param query 查询条件 + * @return 分页结果 + */ + public PageResult page(ReservationQuery query) { + LoginUser me = UserContext.requireUser(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (query.isMine() || me.role() == Role.STUDENT) { + wrapper.eq(Reservation::getUserId, me.id()); + } else if (query.getUserId() != null) { + wrapper.eq(Reservation::getUserId, query.getUserId()); + } + if (query.getStatus() != null) { + wrapper.eq(Reservation::getStatus, query.getStatus()); + } + if (query.getType() != null) { + wrapper.eq(Reservation::getType, query.getType()); + } + if (query.getLabId() != null) { + wrapper.eq(Reservation::getLabId, query.getLabId()); + } + if (query.getKeyword() != null && !query.getKeyword().isBlank()) { + wrapper.like(Reservation::getTitle, query.getKeyword()); + } + if (query.getFrom() != null) { + wrapper.gt(Reservation::getEndTime, query.getFrom().atStartOfDay()); + } + if (query.getTo() != null) { + wrapper.lt(Reservation::getStartTime, query.getTo().plusDays(1).atStartOfDay()); + } + wrapper.orderByDesc(Reservation::getStartTime); + Page page = reservationMapper.selectPage(new Page<>(query.getPage(), query.getSize()), wrapper); + return new PageResult<>(toVOs(page.getRecords()), page.getTotal(), page.getCurrent(), page.getSize()); + } + + /** + * 查询预约详情。 + * + * @param id 预约 ID + * @return 预约详情 + * @throws BizException 预约不存在或无权查看时抛出 + */ + public ReservationVO get(Long id) { + Reservation reservation = requireReservation(id); + LoginUser me = UserContext.requireUser(); + requireVisible(reservation, me); + return toVO(reservation); + } + + /** + * 审批通过预约。 + * + *

审批前会做一次冲突复检(排除自身),避免多条待审批预约互相重叠。

+ * + * @param id 预约 ID + * @return 更新后的预约 + * @throws BizException 预约不存在、无审批权限、状态非法或存在冲突时抛出 + */ + @Transactional + public ReservationVO approve(Long id) { + LoginUser me = requireApprover(); + Reservation reservation = requireReservation(id); + if (!me.isAdmin() && me.id().equals(reservation.getUserId())) { + throw new BizException(ErrorCode.FORBIDDEN, "不能审批自己提交的预约"); + } + reservationStateMachine.assertTransit(reservation.getStatus(), ReservationStatus.APPROVED); + + ConflictQuery query = ConflictQuery.builder() + .requestedSlots(List.of(TimeSlot.of(reservation.getStartTime(), reservation.getEndTime()))) + .lab(findLab(reservation.getLabId())) + .device(findDevice(reservation.getDeviceId())) + .course(findCourse(reservation.getCourseId())) + .participants(reservation.getParticipants()) + .type(reservation.getType()) + .ignoreReservationId(reservation.getId()) + .build(); + checkOrThrow(query, reservation.getUserId(), true); + + reservation.setStatus(ReservationStatus.APPROVED); + reservation.setApproverId(me.id()); + reservation.setApprovedAt(LocalDateTime.now()); + reservation.setRejectReason(null); + reservationMapper.updateById(reservation); + syncLabStatus(reservation.getLabId()); + syncDeviceStatus(reservation.getDeviceId()); + return toVO(reservation); + } + + /** + * 驳回预约。 + * + * @param id 预约 ID + * @param reason 驳回原因 + * @return 更新后的预约 + * @throws BizException 预约不存在、无审批权限或状态非法时抛出 + */ + @Transactional + public ReservationVO reject(Long id, String reason) { + LoginUser me = requireApprover(); + Reservation reservation = requireReservation(id); + if (!me.isAdmin() && me.id().equals(reservation.getUserId())) { + throw new BizException(ErrorCode.FORBIDDEN, "不能审批自己提交的预约"); + } + reservationStateMachine.assertTransit(reservation.getStatus(), ReservationStatus.REJECTED); + reservation.setStatus(ReservationStatus.REJECTED); + reservation.setRejectReason(reason); + reservation.setApproverId(me.id()); + reservation.setApprovedAt(LocalDateTime.now()); + reservationMapper.updateById(reservation); + syncLabStatus(reservation.getLabId()); + syncDeviceStatus(reservation.getDeviceId()); + return toVO(reservation); + } + + /** + * 取消预约,可选择取消整个系列。 + * + * @param id 预约 ID + * @param wholeSeries true 表示取消同系列下所有未结束的场次 + * @return 受影响的预约列表 + * @throws BizException 预约不存在、无权操作或状态非法时抛出 + */ + @Transactional + public List cancel(Long id, boolean wholeSeries) { + LoginUser me = UserContext.requireUser(); + Reservation reservation = requireReservation(id); + requireOwnerOrAdmin(reservation, me); + + List affected = new ArrayList<>(); + if (wholeSeries && reservation.getSeriesId() != null) { + for (Reservation item : reservationMapper.selectList(new LambdaQueryWrapper() + .eq(Reservation::getSeriesId, reservation.getSeriesId()) + .in(Reservation::getStatus, CANCELLABLE_STATUSES) + .orderByAsc(Reservation::getStartTime))) { + item.setStatus(ReservationStatus.CANCELLED); + reservationMapper.updateById(item); + affected.add(item); + } + } else { + reservationStateMachine.assertTransit(reservation.getStatus(), ReservationStatus.CANCELLED); + reservation.setStatus(ReservationStatus.CANCELLED); + reservationMapper.updateById(reservation); + affected.add(reservation); + } + Set labIds = affected.stream().map(Reservation::getLabId).collect(Collectors.toCollection(LinkedHashSet::new)); + Set deviceIds = affected.stream().map(Reservation::getDeviceId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + labIds.forEach(this::syncLabStatus); + deviceIds.forEach(this::syncDeviceStatus); + return toVOs(affected); + } + + /** + * 开始使用预约(签到)。 + * + * @param id 预约 ID + * @return 更新后的预约 + * @throws BizException 预约不存在、无权操作或状态非法时抛出 + */ + @Transactional + public ReservationVO start(Long id) { + LoginUser me = UserContext.requireUser(); + Reservation reservation = requireReservation(id); + requireOwnerOrAdmin(reservation, me); + reservationStateMachine.assertTransit(reservation.getStatus(), ReservationStatus.IN_USE); + reservation.setStatus(ReservationStatus.IN_USE); + reservationMapper.updateById(reservation); + syncLabStatus(reservation.getLabId()); + syncDeviceStatus(reservation.getDeviceId()); + return toVO(reservation); + } + + /** + * 完成使用预约。 + * + * @param id 预约 ID + * @return 更新后的预约 + * @throws BizException 预约不存在、无权操作或状态非法时抛出 + */ + @Transactional + public ReservationVO complete(Long id) { + LoginUser me = UserContext.requireUser(); + Reservation reservation = requireReservation(id); + requireOwnerOrAdmin(reservation, me); + reservationStateMachine.assertTransit(reservation.getStatus(), ReservationStatus.COMPLETED); + reservation.setStatus(ReservationStatus.COMPLETED); + reservationMapper.updateById(reservation); + syncLabStatus(reservation.getLabId()); + syncDeviceStatus(reservation.getDeviceId()); + return toVO(reservation); + } + + /** + * 查询实验室日历(指定日期范围内的预约)。 + * + * @param labId 实验室 ID + * @param from 开始日期 + * @param to 结束日期 + * @return 预约列表,按开始时间升序 + * @throws BizException 实验室不存在或日期区间非法时抛出 + */ + public List calendar(Long labId, LocalDate from, LocalDate to) { + requireLab(labId); + if (from == null || to == null || to.isBefore(from)) { + throw new BizException(ErrorCode.BAD_REQUEST, "日期区间不合法"); + } + List reservations = reservationMapper.selectList(new LambdaQueryWrapper() + .eq(Reservation::getLabId, labId) + .lt(Reservation::getStartTime, to.plusDays(1).atStartOfDay()) + .gt(Reservation::getEndTime, from.atStartOfDay()) + .orderByAsc(Reservation::getStartTime)); + return toVOs(reservations); + } + + /** + * 定时任务:自动完结已过期的预约(每 10 分钟一次)。 + * + * @return 本次完结的预约数量 + */ + @Scheduled(fixedDelayString = "PT10M", initialDelayString = "PT1M") + @Transactional + public int autoCompleteExpired() { + LocalDateTime now = LocalDateTime.now(); + List expired = new ArrayList<>(); + expired.addAll(selectExpired(ReservationStatus.APPROVED, now)); + expired.addAll(selectExpired(ReservationStatus.IN_USE, now)); + for (Reservation reservation : expired) { + reservation.setStatus(ReservationStatus.COMPLETED); + reservationMapper.updateById(reservation); + } + expired.stream().map(Reservation::getLabId).distinct().forEach(this::syncLabStatus); + expired.stream().map(Reservation::getDeviceId).filter(Objects::nonNull) + .distinct().forEach(this::syncDeviceStatus); + if (!expired.isEmpty()) { + log.info("自动完结过期预约 {} 条", expired.size()); + } + return expired.size(); + } + + /** + * 查询已到期但仍处于指定状态的预约。 + * + * @param status 状态 + * @param now 当前时间 + * @return 过期预约列表 + */ + private List selectExpired(ReservationStatus status, LocalDateTime now) { + return reservationMapper.selectList(new LambdaQueryWrapper() + .eq(Reservation::getStatus, status) + .lt(Reservation::getEndTime, now) + .orderByAsc(Reservation::getEndTime)); + } + + /** + * 同步实验室状态:维护中不干预,其余按预约占用情况推导。 + * + * @param labId 实验室 ID + */ + private void syncLabStatus(Long labId) { + if (labId == null) { + return; + } + Lab lab = labMapper.selectById(labId); + if (lab == null || lab.getStatus() == LabStatus.MAINTENANCE) { + return; + } + boolean inUse = reservationMapper.selectCount(new LambdaQueryWrapper() + .eq(Reservation::getLabId, labId) + .eq(Reservation::getStatus, ReservationStatus.IN_USE)) > 0; + boolean occupied = reservationMapper.selectCount(new LambdaQueryWrapper() + .eq(Reservation::getLabId, labId) + .in(Reservation::getStatus, OCCUPYING_STATUSES)) > 0; + LabStatus target = inUse ? LabStatus.IN_USE : (occupied ? LabStatus.RESERVED : LabStatus.IDLE); + applyStatus(lab.getStatus(), target, + next -> { + lab.setStatus(next); + labMapper.updateById(lab); + }, + labStateMachine::canTransit); + } + + /** + * 同步设备状态,规则同实验室。 + * + * @param deviceId 设备 ID + */ + private void syncDeviceStatus(Long deviceId) { + if (deviceId == null) { + return; + } + Device device = deviceMapper.selectById(deviceId); + if (device == null || device.getStatus() == DeviceStatus.MAINTENANCE) { + return; + } + boolean inUse = reservationMapper.selectCount(new LambdaQueryWrapper() + .eq(Reservation::getDeviceId, deviceId) + .eq(Reservation::getStatus, ReservationStatus.IN_USE)) > 0; + boolean occupied = reservationMapper.selectCount(new LambdaQueryWrapper() + .eq(Reservation::getDeviceId, deviceId) + .in(Reservation::getStatus, OCCUPYING_STATUSES)) > 0; + DeviceStatus target = inUse ? DeviceStatus.IN_USE : (occupied ? DeviceStatus.RESERVED : DeviceStatus.IDLE); + applyStatus(device.getStatus(), target, + next -> { + device.setStatus(next); + deviceMapper.updateById(device); + }, + deviceStateMachine::canTransit); + } + + /** + * 通过状态机可达路径把资源从当前状态推进到目标状态(必要时经由中间状态)。 + * + * @param current 当前状态 + * @param target 目标状态 + * @param setter 状态写入回调 + * @param canTrans 状态机迁移判定 + * @param 状态枚举类型 + */ + private > void applyStatus(S current, S target, + Consumer setter, + BiPredicate canTrans) { + S state = current; + int guard = 0; + while (!state.equals(target) && guard++ < 4) { + S next = canTrans.test(state, target) ? target : intermediate(state, target); + if (next == null || next.equals(state)) { + log.warn("无法从状态 {} 流转到 {},跳过状态同步", state, target); + return; + } + setter.accept(next); + state = next; + } + } + + /** + * 计算中间状态:只有两种无法直达的情形需要绕行, + * {@code IDLE -> RESERVED -> IN_USE} 与 {@code IN_USE -> IDLE -> RESERVED}。 + * + * @param current 当前状态 + * @param target 目标状态 + * @param 状态枚举类型 + * @return 中间状态,可直达或无法推导时返回 null + */ + private > S intermediate(S current, S target) { + if ("IDLE".equals(current.name()) && "IN_USE".equals(target.name())) { + return enumConstant(current, "RESERVED"); + } + if ("IN_USE".equals(current.name()) && "RESERVED".equals(target.name())) { + return enumConstant(current, "IDLE"); + } + return null; + } + + /** + * 按名称取枚举常量。 + * + * @param sample 同类型枚举实例 + * @param name 常量名 + * @param 状态枚举类型 + * @return 常量,不存在时返回 null + */ + private > S enumConstant(S sample, String name) { + for (S candidate : sample.getDeclaringClass().getEnumConstants()) { + if (candidate.name().equals(name)) { + return candidate; + } + } + return null; + } + + /** + * 执行冲突检测并在存在冲突时抛出业务异常。 + * + * @param query 检测入参 + * @param ownerUserId 冲突检测中的「本人」用户 ID + * @param lock 是否加行锁 + * @throws BizException 存在冲突时抛出 + */ + private void checkOrThrow(ConflictQuery query, Long ownerUserId, boolean lock) { + List conflicts = detect(query, ownerUserId, lock); + if (!conflicts.isEmpty()) { + throw new BizException(ErrorCode.RESERVATION_CONFLICT, summarize(conflicts)); + } + } + + /** + * 冲突检测主流程:先查候选冲突集,再交给检测器精确判断。 + * + * @param query 检测入参 + * @param ownerUserId 本人用户 ID + * @param lock 是否加行锁 + * @return 冲突列表 + */ + private List detect(ConflictQuery query, Long ownerUserId, boolean lock) { + query.setUserId(ownerUserId); + List validSlots = query.getRequestedSlots() == null ? List.of() + : query.getRequestedSlots().stream().filter(Objects::nonNull).filter(TimeSlot::isValid).toList(); + if (!validSlots.isEmpty()) { + LocalDateTime start = validSlots.stream().map(TimeSlot::start).min(LocalDateTime::compareTo).orElseThrow(); + LocalDateTime end = validSlots.stream().map(TimeSlot::end).max(LocalDateTime::compareTo).orElseThrow(); + query.setExistingReservations(reservationMapper.selectConflicting( + query.getLab() == null ? null : query.getLab().getId(), + query.getDevice() == null ? null : query.getDevice().getId(), + ownerUserId, + start, end, + query.getIgnoreReservationId(), + properties.getReservation().getBlockingStatuses(), + lock)); + } + return conflictDetector.detect(query); + } + + /** + * 汇总冲突信息用于错误提示。 + * + * @param conflicts 冲突列表 + * @return 提示文本 + */ + private String summarize(List conflicts) { + return conflicts.stream() + .limit(3) + .map(Conflict::message) + .collect(Collectors.joining(";")); + } + + /** + * 解析预检请求中的时间段。 + * + * @param request 预检请求 + * @param course 课程,可为空 + * @return 时间段列表 + * @throws BizException 参数缺失或规则非法时抛出 + */ + private List resolveCheckSlots(ConflictCheckRequest request, Course course) { + return switch (request.getType()) { + case SINGLE -> List.of(TimeSlot.of(request.getStartTime(), request.getEndTime())); + case RECURRING -> recurrenceExpander.expand(new RecurrenceRule(request.getStartDate(), + request.getEndDate(), recurrenceExpander.parseWeekdays(request.getWeekdays()), + request.getStartTimeOfDay(), request.getEndTimeOfDay())); + case COURSE -> { + if (course == null) { + throw new BizException(ErrorCode.COURSE_NOT_FOUND); + } + yield recurrenceExpander.expand(new RecurrenceRule(course.getStartDate(), course.getEndDate(), + recurrenceExpander.parseWeekdays(course.getWeekdays()), + course.getStartTime(), course.getEndTime())); + } + }; + } + + /** + * 追加保存预约系列。 + * + * @param me 当前用户 + * @param lab 实验室 + * @param device 设备,可为空 + * @param type 类型 + * @param title 标题 + * @param purpose 用途 + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param weekdays 星期 + * @param startTime 每日开始时间 + * @param endTime 每日结束时间 + * @param participants 人数 + * @return 已保存的系列 + */ + private ReservationSeries saveSeries(LoginUser me, Lab lab, Device device, ReservationType type, + String title, String purpose, LocalDate startDate, LocalDate endDate, + String weekdays, LocalTime startTime, LocalTime endTime, + int participants) { + ReservationSeries series = ReservationSeries.builder() + .userId(me.id()) + .labId(lab.getId()) + .deviceId(device == null ? null : device.getId()) + .type(type) + .title(title) + .purpose(purpose) + .startDate(startDate) + .endDate(endDate) + .weekdays(weekdays) + .startTime(startTime) + .endTime(endTime) + .participants(participants) + .build(); + seriesMapper.insert(series); + return series; + } + + /** + * 构造预约实体。 + * + * @param seriesId 系列 ID,可为空 + * @param me 当前用户 + * @param lab 实验室 + * @param device 设备,可为空 + * @param courseId 课程 ID,可为空 + * @param type 类型 + * @param title 标题 + * @param purpose 用途 + * @param slot 时间段 + * @param participants 人数 + * @param status 初始状态 + * @return 预约实体 + */ + private Reservation buildReservation(Long seriesId, LoginUser me, Lab lab, Device device, Long courseId, + ReservationType type, String title, String purpose, TimeSlot slot, + int participants, ReservationStatus status) { + return Reservation.builder() + .seriesId(seriesId) + .userId(me.id()) + .labId(lab.getId()) + .deviceId(device == null ? null : device.getId()) + .courseId(courseId) + .type(type) + .title(title) + .purpose(purpose) + .startTime(slot.start()) + .endTime(slot.end()) + .participants(participants) + .status(status) + .version(0) + .build(); + } + + /** + * 计算初始状态:管理员与教师直接通过,学生待审批。 + * + * @param me 当前用户 + * @return 初始状态 + */ + private ReservationStatus initialStatus(LoginUser me) { + return me.isAdmin() || me.role() == Role.TEACHER + ? ReservationStatus.APPROVED : ReservationStatus.PENDING; + } + + /** + * 解析并校验设备归属。 + * + * @param deviceId 设备 ID,可为空 + * @param lab 目标实验室 + * @return 设备实体或 null + * @throws BizException 设备不存在或不属于该实验室时抛出 + */ + private Device resolveDevice(Long deviceId, Lab lab) { + if (deviceId == null) { + return null; + } + Device device = deviceMapper.selectById(deviceId); + if (device == null) { + throw new BizException(ErrorCode.DEVICE_NOT_FOUND); + } + if (lab != null && !lab.getId().equals(device.getLabId())) { + throw new BizException(ErrorCode.DEVICE_LAB_MISMATCH); + } + return device; + } + + /** + * 校验当前用户具备审批资格。 + * + * @return 当前用户 + * @throws BizException 无审批权限时抛出 + */ + private LoginUser requireApprover() { + LoginUser me = UserContext.requireUser(); + if (!me.canApprove()) { + throw new BizException(ErrorCode.FORBIDDEN, "只有管理员或教师可以审批预约"); + } + return me; + } + + /** + * 校验当前用户是预约所有者或管理员。 + * + * @param reservation 预约 + * @param me 当前用户 + * @throws BizException 无权操作时抛出 + */ + private void requireOwnerOrAdmin(Reservation reservation, LoginUser me) { + if (!me.isAdmin() && !me.id().equals(reservation.getUserId())) { + throw new BizException(ErrorCode.NOT_RESERVATION_OWNER); + } + } + + /** + * 校验当前用户有权查看预约。 + * + * @param reservation 预约 + * @param me 当前用户 + * @throws BizException 无权查看时抛出 + */ + private void requireVisible(Reservation reservation, LoginUser me) { + if (me.role() == Role.STUDENT && !me.id().equals(reservation.getUserId())) { + throw new BizException(ErrorCode.NOT_RESERVATION_OWNER, "只能查看自己的预约"); + } + } + + /** + * 加载预约,不存在则抛出 404。 + * + * @param id 预约 ID + * @return 预约 + */ + private Reservation requireReservation(Long id) { + Reservation reservation = reservationMapper.selectById(id); + if (reservation == null) { + throw new BizException(ErrorCode.RESERVATION_NOT_FOUND); + } + return reservation; + } + + /** + * 加载实验室,不存在则抛出 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; + } + + /** + * 加载课程,不存在则抛出 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; + } + + /** + * 宽容加载实验室(审批复检时资源可能已被删除)。 + * + * @param id 实验室 ID + * @return 实验室或 null + */ + private Lab findLab(Long id) { + return id == null ? null : labMapper.selectById(id); + } + + /** + * 宽容加载设备。 + * + * @param id 设备 ID + * @return 设备或 null + */ + private Device findDevice(Long id) { + return id == null ? null : deviceMapper.selectById(id); + } + + /** + * 宽容加载课程。 + * + * @param id 课程 ID + * @return 课程或 null + */ + private Course findCourse(Long id) { + return id == null ? null : courseMapper.selectById(id); + } + + /** + * 单个预约转视图对象。 + * + * @param reservation 预约 + * @return 视图对象 + */ + private ReservationVO toVO(Reservation reservation) { + return toVOs(List.of(reservation)).get(0); + } + + /** + * 批量转换视图对象并补齐名称字段。 + * + * @param reservations 预约列表 + * @return 视图对象列表 + */ + private List toVOs(List reservations) { + if (reservations == null || reservations.isEmpty()) { + return List.of(); + } + Map userNames = loadUserNames(userIds(reservations, Reservation::getUserId)); + Map labNames = loadNames(userIds(reservations, Reservation::getLabId), + labMapper::selectByIds, Lab::getId, Lab::getName); + Map deviceNames = loadNames(userIds(reservations, Reservation::getDeviceId), + deviceMapper::selectByIds, Device::getId, Device::getName); + Map courseNames = loadNames(userIds(reservations, Reservation::getCourseId), + courseMapper::selectByIds, Course::getId, Course::getName); + return reservations.stream().map(reservation -> { + ReservationVO vo = ReservationVO.from(reservation); + vo.setUserName(nameOrNull(userNames, reservation.getUserId())); + vo.setLabName(nameOrNull(labNames, reservation.getLabId())); + vo.setDeviceName(nameOrNull(deviceNames, reservation.getDeviceId())); + vo.setCourseName(nameOrNull(courseNames, reservation.getCourseId())); + vo.setApproverName(nameOrNull(userNames, reservation.getApproverId())); + return vo; + }).toList(); + } + + /** + * 从 id -> 名称 映射中安全取值,key 为空时返回 null。 + * + * @param names 映射 + * @param key ID + * @return 名称或 null + */ + private String nameOrNull(Map names, Long key) { + return key == null ? null : names.get(key); + } + + /** + * 提取列表中非空的 ID 集合。 + * + * @param reservations 预约列表 + * @param getter ID 提取函数 + * @return ID 集合 + */ + private Set userIds(List reservations, Function getter) { + return reservations.stream().map(getter).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + /** + * 按 ID 集合加载用户并转换为 id -> 姓名 映射,空集合直接返回空映射。 + * + * @param ids 用户 ID 集合 + * @return 映射 + */ + private Map loadUserNames(Set ids) { + return ids.isEmpty() ? Map.of() : nameMap(userMapper.selectByIds(ids)); + } + + /** + * 按 ID 集合加载实体并转换为 id -> 名称 映射,空集合直接返回空映射。 + * + * @param ids ID 集合 + * @param loader 批量加载函数 + * @param idGetter ID 提取函数 + * @param nameGetter 名称提取函数 + * @param 实体类型 + * @return 映射 + */ + private Map loadNames(Set ids, Function, List> loader, + Function idGetter, Function nameGetter) { + return ids.isEmpty() ? Map.of() : nameMap(loader.apply(ids), idGetter, nameGetter); + } + + /** + * 将用户实体列表转换为 id -> 姓名 映射。 + * + * @param users 用户列表 + * @return 映射 + */ + private Map nameMap(Collection users) { + return nameMap(users, User::getId, User::getRealName); + } + + /** + * 将实体列表转换为 id -> 名称 映射。 + * + * @param items 实体列表 + * @param idGetter ID 提取函数 + * @param nameGetter 名称提取函数 + * @param 实体类型 + * @return 映射 + */ + private Map nameMap(Collection items, Function idGetter, Function nameGetter) { + if (items == null || items.isEmpty()) { + return Map.of(); + } + return items.stream().collect(Collectors.toMap(idGetter, nameGetter, (a, b) -> a)); + } +} diff --git a/src/main/java/com/example/lab/service/UserService.java b/src/main/java/com/example/lab/service/UserService.java new file mode 100644 index 0000000..c992830 --- /dev/null +++ b/src/main/java/com/example/lab/service/UserService.java @@ -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 list() { + return userMapper.selectList(new LambdaQueryWrapper().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() + .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; + } +} diff --git a/src/main/java/com/example/lab/vo/ConflictCheckVO.java b/src/main/java/com/example/lab/vo/ConflictCheckVO.java new file mode 100644 index 0000000..c9408e5 --- /dev/null +++ b/src/main/java/com/example/lab/vo/ConflictCheckVO.java @@ -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 conflicts; +} diff --git a/src/main/java/com/example/lab/vo/LoginResponse.java b/src/main/java/com/example/lab/vo/LoginResponse.java new file mode 100644 index 0000000..f149a29 --- /dev/null +++ b/src/main/java/com/example/lab/vo/LoginResponse.java @@ -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; +} diff --git a/src/main/java/com/example/lab/vo/ReservationVO.java b/src/main/java/com/example/lab/vo/ReservationVO.java new file mode 100644 index 0000000..3c0119f --- /dev/null +++ b/src/main/java/com/example/lab/vo/ReservationVO.java @@ -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(); + } +} diff --git a/src/main/java/com/example/lab/vo/UserVO.java b/src/main/java/com/example/lab/vo/UserVO.java new file mode 100644 index 0000000..8ff8f71 --- /dev/null +++ b/src/main/java/com/example/lab/vo/UserVO.java @@ -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(); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..72e672f --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -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 diff --git a/src/main/resources/application-mariadb.yaml b/src/main/resources/application-mariadb.yaml new file mode 100644 index 0000000..b49fe4b --- /dev/null +++ b/src/main/resources/application-mariadb.yaml @@ -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 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..d28d42f --- /dev/null +++ b/src/main/resources/application.yaml @@ -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 diff --git a/src/main/resources/db/data-mariadb.sql b/src/main/resources/db/data-mariadb.sql new file mode 100644 index 0000000..63583fc --- /dev/null +++ b/src/main/resources/db/data-mariadb.sql @@ -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()); diff --git a/src/main/resources/db/data.sql b/src/main/resources/db/data.sql new file mode 100644 index 0000000..1661d25 --- /dev/null +++ b/src/main/resources/db/data.sql @@ -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); diff --git a/src/main/resources/db/schema-mariadb.sql b/src/main/resources/db/schema-mariadb.sql new file mode 100644 index 0000000..9d17c2a --- /dev/null +++ b/src/main/resources/db/schema-mariadb.sql @@ -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; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql new file mode 100644 index 0000000..1a4e410 --- /dev/null +++ b/src/main/resources/db/schema.sql @@ -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); diff --git a/src/main/resources/mapper/ReservationMapper.xml b/src/main/resources/mapper/ReservationMapper.xml new file mode 100644 index 0000000..0f906b5 --- /dev/null +++ b/src/main/resources/mapper/ReservationMapper.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + diff --git a/src/test/java/com/example/lab/LabApplicationTests.java b/src/test/java/com/example/lab/LabApplicationTests.java new file mode 100644 index 0000000..59c7b91 --- /dev/null +++ b/src/test/java/com/example/lab/LabApplicationTests.java @@ -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(); + } +} diff --git a/src/test/java/com/example/lab/common/api/ApiResponseTest.java b/src/test/java/com/example/lab/common/api/ApiResponseTest.java new file mode 100644 index 0000000..e6d2e51 --- /dev/null +++ b/src/test/java/com/example/lab/common/api/ApiResponseTest.java @@ -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 response = ApiResponse.ok("data"); + assertThat(response.getCode()).isZero(); + assertThat(response.getMessage()).isEqualTo("success"); + assertThat(response.getData()).isEqualTo("data"); + } + + @Test + @DisplayName("成功响应无数据") + void okWithoutData() { + ApiResponse response = ApiResponse.ok(); + assertThat(response.getCode()).isZero(); + assertThat(response.getData()).isNull(); + } + + @Test + @DisplayName("错误响应") + void error() { + ApiResponse response = ApiResponse.error(3002, "冲突"); + assertThat(response.getCode()).isEqualTo(3002); + assertThat(response.getMessage()).isEqualTo("冲突"); + assertThat(response.getData()).isNull(); + } + + @Test + @DisplayName("分页结果由 IPage 转换") + void pageResult() { + Page page = new Page<>(2, 5); + page.setRecords(List.of("a", "b")); + page.setTotal(12); + PageResult 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 result = new PageResult<>(List.of(1), 1, 1, 10); + assertThat(result.getRecords()).containsExactly(1); + assertThat(result.getTotal()).isEqualTo(1); + } +} diff --git a/src/test/java/com/example/lab/common/context/UserContextTest.java b/src/test/java/com/example/lab/common/context/UserContextTest.java new file mode 100644 index 0000000..2056b17 --- /dev/null +++ b/src/test/java/com/example/lab/common/context/UserContextTest.java @@ -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("管理员"); + } +} diff --git a/src/test/java/com/example/lab/common/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/example/lab/common/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..ed41636 --- /dev/null +++ b/src/test/java/com/example/lab/common/exception/GlobalExceptionHandlerTest.java @@ -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 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 response = handler.handleValidationException(ex); + assertThat(response.getCode()).isEqualTo(400); + assertThat(response.getMessage()).contains("title: 标题不能为空"); + } + + @Test + @DisplayName("绑定异常拼接字段错误") + void bindException() { + ApiResponse response = handler.handleBindException(new BindException(bindingResult())); + assertThat(response.getCode()).isEqualTo(400); + assertThat(response.getMessage()).contains("title"); + } + + @Test + @DisplayName("请求体解析失败返回 400") + void notReadable() { + ApiResponse 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 response = handler.handleDuplicateKey(new DuplicateKeyException("dup")); + assertThat(response.getCode()).isEqualTo(409); + } + + @Test + @DisplayName("未知异常返回 500 且不泄漏堆栈") + void unknownException() { + ApiResponse 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) { + // 仅用于反射获取方法签名 + } +} diff --git a/src/test/java/com/example/lab/common/util/JwtUtilsTest.java b/src/test/java/com/example/lab/common/util/JwtUtilsTest.java new file mode 100644 index 0000000..a6cfd9e --- /dev/null +++ b/src/test/java/com/example/lab/common/util/JwtUtilsTest.java @@ -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"); + } +} diff --git a/src/test/java/com/example/lab/domain/conflict/ConflictDetectorTest.java b/src/test/java/com/example/lab/domain/conflict/ConflictDetectorTest.java new file mode 100644 index 0000000..a053904 --- /dev/null +++ b/src/test/java/com/example/lab/domain/conflict/ConflictDetectorTest.java @@ -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 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 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 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 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 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 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 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 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 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 types(ConflictQuery query) { + return detector.detect(query).stream().map(Conflict::type).collect(Collectors.toSet()); + } +} diff --git a/src/test/java/com/example/lab/domain/conflict/RecurrenceExpanderTest.java b/src/test/java/com/example/lab/domain/conflict/RecurrenceExpanderTest.java new file mode 100644 index 0000000..daca275 --- /dev/null +++ b/src/test/java/com/example/lab/domain/conflict/RecurrenceExpanderTest.java @@ -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 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(); + } +} diff --git a/src/test/java/com/example/lab/domain/conflict/RecurrenceRuleTest.java b/src/test/java/com/example/lab/domain/conflict/RecurrenceRuleTest.java new file mode 100644 index 0000000..1e97c91 --- /dev/null +++ b/src/test/java/com/example/lab/domain/conflict/RecurrenceRuleTest.java @@ -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(); + } +} diff --git a/src/test/java/com/example/lab/domain/conflict/TimeSlotTest.java b/src/test/java/com/example/lab/domain/conflict/TimeSlotTest.java new file mode 100644 index 0000000..5fa77ba --- /dev/null +++ b/src/test/java/com/example/lab/domain/conflict/TimeSlotTest.java @@ -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(); + } +} diff --git a/src/test/java/com/example/lab/domain/statemachine/StateMachineTest.java b/src/test/java/com/example/lab/domain/statemachine/StateMachineTest.java new file mode 100644 index 0000000..f11427b --- /dev/null +++ b/src/test/java/com/example/lab/domain/statemachine/StateMachineTest.java @@ -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("空闲"); + } +} diff --git a/src/test/java/com/example/lab/integration/ApiIntegrationTest.java b/src/test/java/com/example/lab/integration/ApiIntegrationTest.java new file mode 100644 index 0000000..a1e9ef5 --- /dev/null +++ b/src/test/java/com/example/lab/integration/ApiIntegrationTest.java @@ -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 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(); + } + +} diff --git a/src/test/java/com/example/lab/mapper/ReservationMapperTest.java b/src/test/java/com/example/lab/mapper/ReservationMapperTest.java new file mode 100644 index 0000000..aa434d1 --- /dev/null +++ b/src/test/java/com/example/lab/mapper/ReservationMapperTest.java @@ -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 byDevice = reservationMapper.selectConflicting(999L, deviceId, null, + BASE.plusMinutes(30), BASE.plusMinutes(90), null, blockingStatuses(), false); + assertThat(byDevice).extracting(Reservation::getId).contains(id); + + // 实验室与设备都不同,仅用户相同 + List 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 ignored = reservationMapper.selectConflicting(labId, null, null, + BASE, BASE.plusHours(1), id, blockingStatuses(), true); + assertThat(ignored).isEmpty(); + List 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 locked = reservationMapper.selectConflicting(labId, null, null, BASE, BASE.plusHours(1), + null, blockingStatuses(), true); + List 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 selectConflicting(Long labId, LocalDateTime start, LocalDateTime end) { + return reservationMapper.selectConflicting(labId, null, null, start, end, null, + blockingStatuses(), false); + } + + /** + * 阻塞状态集合。 + * + * @return 状态列表 + */ + private List blockingStatuses() { + return List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE); + } + + /** + * 确保实验室存在并返回 ID。 + * + * @param code 实验室编号 + * @return 实验室 ID + */ + private Long ensureLab(String code) { + List 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 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(); + } +} diff --git a/src/test/java/com/example/lab/security/AuthInterceptorTest.java b/src/test/java/com/example/lab/security/AuthInterceptorTest.java new file mode 100644 index 0000000..53c907f --- /dev/null +++ b/src/test/java/com/example/lab/security/AuthInterceptorTest.java @@ -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() { + // 继承类级注解 + } + } +} diff --git a/src/test/java/com/example/lab/service/AuthServiceTest.java b/src/test/java/com/example/lab/service/AuthServiceTest.java new file mode 100644 index 0000000..d840292 --- /dev/null +++ b/src/test/java/com/example/lab/service/AuthServiceTest.java @@ -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(); + } +} diff --git a/src/test/java/com/example/lab/service/CourseServiceTest.java b/src/test/java/com/example/lab/service/CourseServiceTest.java new file mode 100644 index 0000000..c21f49a --- /dev/null +++ b/src/test/java/com/example/lab/service/CourseServiceTest.java @@ -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 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 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 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; + } +} diff --git a/src/test/java/com/example/lab/service/DeviceServiceTest.java b/src/test/java/com/example/lab/service/DeviceServiceTest.java new file mode 100644 index 0000000..13126fc --- /dev/null +++ b/src/test/java/com/example/lab/service/DeviceServiceTest.java @@ -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 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 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 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; + } +} diff --git a/src/test/java/com/example/lab/service/LabServiceTest.java b/src/test/java/com/example/lab/service/LabServiceTest.java new file mode 100644 index 0000000..aa72d68 --- /dev/null +++ b/src/test/java/com/example/lab/service/LabServiceTest.java @@ -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 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 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 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 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; + } +} diff --git a/src/test/java/com/example/lab/service/ReservationServiceTest.java b/src/test/java/com/example/lab/service/ReservationServiceTest.java new file mode 100644 index 0000000..d3c210f --- /dev/null +++ b/src/test/java/com/example/lab/service/ReservationServiceTest.java @@ -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 集合元素类型 + * @return 匹配器 + */ + @SuppressWarnings("unchecked") + private static Collection 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 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 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 conflicts = reservationService.check(request); + assertThat(conflicts).extracting(Conflict::type).contains(ConflictType.LAB_CONFLICT); + } + + // ------------------------------------------------------------------ + // 查询 + // ------------------------------------------------------------------ + + @Test + @DisplayName("分页查询:学生强制只看自己,管理员可按用户过滤") + @SuppressWarnings("unchecked") + void page() { + Page 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 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 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 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 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 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 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 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; + } +} diff --git a/src/test/java/com/example/lab/service/UserServiceTest.java b/src/test/java/com/example/lab/service/UserServiceTest.java new file mode 100644 index 0000000..e0f4ba2 --- /dev/null +++ b/src/test/java/com/example/lab/service/UserServiceTest.java @@ -0,0 +1,203 @@ +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.domain.entity.User; +import com.example.lab.domain.enums.Role; +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 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 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.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link UserService} 单元测试。 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class UserServiceTest extends com.example.lab.support.MybatisUnitTest { + + @Mock + private UserMapper userMapper; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + @DisplayName("查询全部用户") + void list() { + when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(user(1L, "admin", Role.ADMIN))); + List users = userService.list(); + assertThat(users).hasSize(1); + assertThat(users.get(0).getUsername()).isEqualTo("admin"); + } + + @Test + @DisplayName("查询用户详情与不存在场景") + void get() { + when(userMapper.selectById(1L)).thenReturn(user(1L, "admin", Role.ADMIN)); + assertThat(userService.get(1L).getUsername()).isEqualTo("admin"); + + when(userMapper.selectById(99L)).thenReturn(null); + assertThatThrownBy(() -> userService.get(99L)) + .isInstanceOf(BizException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("新增用户:用户名重复报错") + void createDuplicate() { + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + assertThatThrownBy(() -> userService.create(createRequest("admin"))) + .isInstanceOf(BizException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.USERNAME_EXISTS); + verify(userMapper, never()).insert(any(User.class)); + } + + @Test + @DisplayName("新增用户:密码加密后入库") + void createSuccess() { + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(passwordEncoder.encode("123456")).thenReturn("encoded"); + UserCreateRequest request = createRequest("newbie"); + UserVO vo = userService.create(request); + assertThat(vo.getUsername()).isEqualTo("newbie"); + assertThat(vo.getRole()).isEqualTo(Role.STUDENT); + verify(userMapper).insert(any(User.class)); + } + + @Test + @DisplayName("更新用户:逐字段更新,空值跳过") + void update() { + UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN)); + when(userMapper.selectById(2L)).thenReturn(user(2L, "teacher1", Role.TEACHER)); + when(passwordEncoder.encode("new-pass")).thenReturn("encoded-new"); + + UserUpdateRequest request = new UserUpdateRequest(); + request.setRealName("新名字"); + request.setEmail("new@example.edu"); + request.setPhone("13900000000"); + request.setRole(Role.ADMIN); + request.setEnabled(true); + request.setPassword("new-pass"); + + UserVO vo = userService.update(2L, request); + assertThat(vo.getRealName()).isEqualTo("新名字"); + assertThat(vo.getEmail()).isEqualTo("new@example.edu"); + assertThat(vo.getRole()).isEqualTo(Role.ADMIN); + verify(userMapper).updateById(any(User.class)); + } + + @Test + @DisplayName("更新用户:不能禁用当前登录账号") + void updateDisableSelf() { + UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN)); + when(userMapper.selectById(1L)).thenReturn(user(1L, "admin", Role.ADMIN)); + UserUpdateRequest request = new UserUpdateRequest(); + request.setEnabled(false); + assertThatThrownBy(() -> userService.update(1L, request)) + .isInstanceOf(BizException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.FORBIDDEN); + } + + @Test + @DisplayName("删除用户:不能删除自己") + void deleteSelf() { + UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN)); + when(userMapper.selectById(1L)).thenReturn(user(1L, "admin", Role.ADMIN)); + assertThatThrownBy(() -> userService.delete(1L)) + .isInstanceOf(BizException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.FORBIDDEN); + verify(userMapper, never()).deleteById(any(Long.class)); + } + + @Test + @DisplayName("删除用户成功") + void deleteSuccess() { + UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN)); + when(userMapper.selectById(5L)).thenReturn(user(5L, "student1", Role.STUDENT)); + userService.delete(5L); + verify(userMapper).deleteById(5L); + } + + @Test + @DisplayName("新增用户:统计结果为 null 时视为用户名可用") + void createWhenCountNull() { + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(null); + when(passwordEncoder.encode(anyString())).thenReturn("encoded"); + assertThat(userService.create(createRequest("fresh")).getUsername()).isEqualTo("fresh"); + } + + @Test + @DisplayName("更新用户:空白密码不重置,启用自己不报错") + void updateBlankPasswordAndEnableSelf() { + UserContext.set(new LoginUser(1L, "admin", "系统管理员", Role.ADMIN)); + when(userMapper.selectById(1L)).thenReturn(user(1L, "admin", Role.ADMIN)); + UserUpdateRequest request = new UserUpdateRequest(); + request.setPassword(" "); + request.setEnabled(true); + assertThat(userService.update(1L, request).getEnabled()).isTrue(); + } + + /** + * 构造新增请求。 + * + * @param username 用户名 + * @return 请求 + */ + private UserCreateRequest createRequest(String username) { + UserCreateRequest request = new UserCreateRequest(); + request.setUsername(username); + request.setPassword("123456"); + request.setRealName("新用户"); + request.setRole(Role.STUDENT); + 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(); + } +} diff --git a/src/test/java/com/example/lab/support/MybatisUnitTest.java b/src/test/java/com/example/lab/support/MybatisUnitTest.java new file mode 100644 index 0000000..4f3db56 --- /dev/null +++ b/src/test/java/com/example/lab/support/MybatisUnitTest.java @@ -0,0 +1,17 @@ +package com.example.lab.support; + +import org.junit.jupiter.api.BeforeAll; + +/** + * 纯单元测试基类:确保 MyBatis-Plus 实体元数据已初始化。 + */ +public abstract class MybatisUnitTest { + + /** + * 测试类执行前初始化 TableInfo 缓存。 + */ + @BeforeAll + static void initMybatisTableInfo() { + TableInfoInitializer.init(); + } +} diff --git a/src/test/java/com/example/lab/support/TableInfoInitializer.java b/src/test/java/com/example/lab/support/TableInfoInitializer.java new file mode 100644 index 0000000..23b904f --- /dev/null +++ b/src/test/java/com/example/lab/support/TableInfoInitializer.java @@ -0,0 +1,42 @@ +package com.example.lab.support; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +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.ReservationSeries; +import com.example.lab.domain.entity.User; +import org.apache.ibatis.builder.MapperBuilderAssistant; + +import java.util.List; + +/** + * 单元测试辅助:为纯 Mockito 测试初始化 MyBatis-Plus 的实体元数据与 Lambda 缓存。 + * + *

LambdaQueryWrapper 依赖 {@code TableInfo} 缓存解析方法引用对应的列名, + * 该缓存在 Spring 容器启动时由 MP 自动装配;脱离容器的单元测试需要手动安装一次。

+ */ +public final class TableInfoInitializer { + + private static volatile boolean initialized; + + private TableInfoInitializer() { + } + + /** + * 幂等地初始化全部实体的 TableInfo。 + */ + public static synchronized void init() { + if (initialized) { + return; + } + List> entities = List.of(User.class, Lab.class, Device.class, Course.class, + Reservation.class, ReservationSeries.class); + for (Class entity : entities) { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), entity); + } + initialized = true; + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..55c75ba --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,16 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:labtest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: "" + sql: + init: + mode: always + schema-locations: classpath:db/schema.sql + data-locations: classpath:db/data.sql + +lab: + jwt: + secret: lab-test-secret-key-0123456789-0123456789-0123456789 + expire-minutes: 60