diff --git a/RuoYi-Vue/doc/发送模块排队串行下发与失败重排设计方案.md b/RuoYi-Vue/doc/发送模块排队串行下发与失败重排设计方案.md new file mode 100644 index 000000000..59c932769 --- /dev/null +++ b/RuoYi-Vue/doc/发送模块排队串行下发与失败重排设计方案.md @@ -0,0 +1,302 @@ +# 发送模块排队串行下发与失败重排设计方案 + +> 版本:v1.0 +> 适用模块:`ruoyi-iot` 门锁命令下发(MQTT 下行) +> 说明:本文为设计文档,描述目标架构与关键决策,不涉及代码实现。 + +--- + +## 1. 方案概述与目标 + +### 1.1 背景与现状 + +当前人脸 / 门锁授权命令的下发链路为: + +`/student/face` → 新建 `LockAuth` → `insertAuth` → `enqueue` 建 `LockCommand` → `send` 落库 → `afterCommit` 提交线程池 → `dispatch` → `publishCommand`(Paho MQTT,QoS=0)。 + +其并发特征是: + +- 派发经 `threadPoolTaskExecutor`(core=50 / max=200 / queue=1000)**并行执行**; +- `dispatch` 仅对**单条命令**做行锁(`selectCommandByIdForUpdate(commandId)`),不同命令互不阻塞; +- `publishCommand` 直接调用 Paho `publish`,**无队列、无串行、无“成功后再发下一条”**; +- QoS=0 为“至多一次”,仅代表已交给 broker,**不代表设备生效**; +- 每 2s 的 `LockCommandRetryTask` 负责超时判定与重试补偿。 + +结果:并发人脸命令无序、并行下发,同一设备/同一主题上的消息可能交错或乱序,且失败与后续互不阻塞。 + +### 1.2 目标 + +1. **排队**:命令按先进先出(FIFO)进入队列; +2. **串行下发**:同一目标(Lane)内严格串行,前一条到达“完成门”后才发下一条; +3. **失败重排**:按策略退避重试,耗尽后进入死信/人工; +4. **可恢复**:进程重启后队列与状态可续; +5. **可观测**:排队、重试、失败、时延全程可追踪与告警。 + +### 1.3 范围与非目标 + +- **范围**:仅改造“命令生成之后的下发调度层”,叠加在现有 `LockCommand` 模型与 MQTT 通道之上。 +- **非目标**:不改变 Ydlink 协议、不改变命令与授权业务模型、不引入 broker 级 QoS 语义变更(仍以 QoS=0 为传输基础)。 + +### 1.4 术语表 + +| 术语 | 含义 | +|---|---| +| Lane(通道 / 泳道) | 串行化单元,通常为 `deviceSn`(或 `groupCode` / `topic`)。同一 Lane 内串行,不同 Lane 可并行 | +| Task(队列任务) | 指向一条 `command_id` 的排队项,携带 Lane、优先级、序号、可见时间等 | +| 完成门(Completion Gate) | 判定“本条算发送完成、可推进下一条”的条件(传输成功 / 设备 ACK 确认) | +| 队头(Head-of-Lane) | 某 Lane 中当前应被取出执行的最小 `(priority, seq)` 任务 | +| 租约(Lease) | 多实例下某实例对某 Lane/任务的临时独占权,带过期时间 | +| 死信(DEAD) | 重试耗尽或不可重试后进入的终态,保留现场供人工处理 | +| 结果未知 | QoS=0 下 `publish` 抛异常但无法确认是否已达设备 | + +--- + +## 2. 系统架构与模块划分 + +### 2.1 总体架构 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 业务入口(如 /student/face) │ +└───────────────────────────┬──────────────────────────────┘ + │ ① 事务内落库 LockCommand(READY) + 入队 + ▼ +┌──────────────────────────────────────────────────────────┐ +│ 队列协调器 QueueCoordinator │ +│ 去重 / 优先级 / 容量控制 / Lane 内 seq 分配 │ +└───────────────────────────┬──────────────────────────────┘ + │ ② 写入持久队列 + ▼ +┌──────────────────────────────────────────────────────────┐ +│ 持久队列 lock_dispatch_task(MySQL) │ +└───────────────────────────┬──────────────────────────────┘ + │ ③ 取队头(SKIP LOCKED + 租约) + ▼ +┌──────────────────────────────────────────────────────────┐ +│ 通道调度器 LaneScheduler │ +│ 每 Lane 单执行者(串行)/ 全局或每目标策略 │ +└───────────────────────────┬──────────────────────────────┘ + │ ④ 单条执行 + ▼ +┌──────────────────────────────────────────────────────────┐ +│ 下发执行器 DispatchExecutor │ +│ dispatch → publishCommand / 完成门判定 │ +└───────┬───────────────────────────────────┬──────────────┘ + 成功 │ │ 失败 / 超时 + ▼ ▼ +┌───────────────┐ ┌───────────────────────┐ +│ DONE(成功) │ │ 重试与死信 RetryManager │ +└───────────────┘ └───────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ 可观测模块 Metrics / Logging / Tracing │ +└──────────────────────────────────────────────────────────┘ +``` + +### 2.2 模块划分与职责 + +| 模块 | 职责 | 关键点 | +|---|---|---| +| **队列协调器 QueueCoordinator** | 入队、去重、优先级、容量控制、Lane 内 `seq` 分配 | 与业务落库同事务,保证“有命令必有任务” | +| **持久队列 lock_dispatch_task** | 持久化 FIFO,支撑重启恢复与多实例 | 状态与顺序以 DB 为准,内存队列仅作唤醒加速 | +| **通道调度器 LaneScheduler** | 按 Lane 取队头、租约、串行执行者管理 | 每 Lane 同时仅一个执行者;不同 Lane 并行 | +| **下发执行器 DispatchExecutor** | 调用现有 `dispatch/publishCommand`,执行完成门判定 | 复用现有派发与安全校验逻辑,不重复实现 | +| **重试与死信 RetryManager** | 失败分类、退避重排、死信沉淀 | 可重试 vs 不可重试;结果未知不自动重放 | +| **状态存储** | 任务状态与命令状态持久化、乐观锁 | 复用 `LockCommand`,扩展队列表 | +| **补偿任务** | 超时判定、僵尸租约回收、重试唤醒 | 改造/替代现有 `LockCommandRetryTask`(每 2s) | +| **可观测模块** | 日志、指标、告警、链路追踪 | 以 `intentKey/commandId` 为关联 ID | + +### 2.3 部署拓扑 + +- **单实例**:每 Lane 使用进程内单线程执行器(`SerialExecutor`)或 `Semaphore(1)` 保证串行;持久队列用于重启恢复。 +- **多实例**:在 DB 层用 `SELECT ... FOR UPDATE SKIP LOCKED` + 租约(`lease_owner/lease_expire_at`)保证同一 Lane 同一时刻只有一个实例持有并执行;备选方案为按 Lane 的 Redis 分布式锁。 +- **决策**:优先 **DB 租约**,避免额外中间件依赖,且与持久队列同源;多依赖 Redis 时再切换。 + +### 2.4 可观测性设计 + +- **日志(结构化)**:在 `enqueue / dequeue / start / publish / ack / retry / dead / drop` 节点各打一条,统一携带 `laneKey, taskId, commandId, intentKey, attempt, at`;记录时间轴 `enqueueAt→startAt→publishAt→ackAt→retryAt`。 +- **指标**: + - 队列:`queue_depth{lane}`、`global_depth`、`in_flight_count`、`enqueue_rate`、`dequeue_rate`; + - 质量:`success_rate`、`failure_rate{reason}`、`retry_count` 分布、`retry_success_rate`、`dead_letter_count`; + - 时延:`time_in_queue`、`dispatch_latency`、`end_to_end_latency`; + - 健康:`lane_busy_time`、`head_of_line_blocked_ms`、`lease_expired_count`。 +- **告警**:队列积压(超阈值/持续增长)、失败率/重试率/死信增长、端到端或排队时延超阈值、单实例长期持锁(疑似僵死)、连接断开。 +- **追踪**:以 `intentKey/commandId` 为 correlation id,贯通“请求→入队→派发→MQTT→设备 ACK→补偿重试”。 + +--- + +## 3. 关键流程与数据说明 + +### 3.1 数据结构(`lock_dispatch_task`) + +| 字段 | 说明 | +|---|---| +| `task_id` | 任务主键 | +| `lane_key` | 目标标识(`deviceSn` / `groupCode` / `topic`) | +| `command_id` | 关联命令(FK),**一条命令至多一个在途任务** | +| `intent_key` | 幂等键,用于去重 | +| `priority` | 越小越优先(撤销/删除=0,新增=1) | +| `seq` | Lane 内单调递增序号(决定 FIFO) | +| `state` | 任务状态(见 §3.2) | +| `attempts` | 已尝试次数 | +| `next_visible_at` | 可见时间(退避用),此前不被取走 | +| `lease_owner` / `lease_expire_at` | 多实例租约 | +| `version` | 乐观锁 | +| `last_error` | 最近错误摘要(截断存储) | +| `created_at` / `updated_at` | 审计与排障 | + +索引建议:`(lane_key, state, priority, seq)`、`(state, next_visible_at)`、唯一 `(command_id)`。 + +### 3.2 状态机 + +``` +PENDING(待发送) ──取队头──▶ IN_FLIGHT(发送中) ──完成门──▶ DONE(成功) + ▲ │ + │ ├─可重试失败/超时─▶ RETRY_WAIT(失败待重试) ──next_visible_at──▶ PENDING + │ ├─不可重试/超 maxRetry─▶ DEAD(丢弃/死信) + │ └─结果未知(QoS0)─▶ MANUAL_REVIEW(人工对账, 不自动重放) + └──────────────── 重启回收:IN_FLIGHT 且租约过期 ─▶ PENDING / MANUAL_REVIEW +``` + +与现有 `executionState / commandStatus` 的映射: + +| 本方案状态 | 现有映射 | +|---|---| +| PENDING | `executionState=READY`,`commandStatus=pending` | +| IN_FLIGHT | `executionState=DISPATCHING`,`commandStatus=pending` | +| RETRY_WAIT | `executionState=READY` + `nextVisibleTime` 未来 | +| DONE | `AWAITING_ACK`(模式 A)/ `CONFIRMED`(模式 B) | +| DEAD / 丢弃 | `MANUAL_REVIEW` / `QUARANTINED`,`commandStatus=fail/timeout` | + +### 3.3 入队流程 + +1. 命令在**同一事务**内落库为 `READY`,同时写入 `lock_dispatch_task`; +2. 计算 `lane_key`、`priority`、`seq`(Lane 内 `MAX(seq)+1`); +3. 唯一约束 `(command_id)` 与 `intent_key` 保证幂等,重复入队被忽略; +4. 可选“合并”策略:同一设备同一学员重复上传时,旧任务置 `DEAD/drop`,新任务入队(以最新为准)。 + +### 3.4 出队与串行下发流程 + +1. 调度器按 `(priority, seq)` 选出各 Lane 队头; +2. `SELECT ... FOR UPDATE SKIP LOCKED` 抢占,`state→IN_FLIGHT`,写 `lease_owner/expire`; +3. 投递到该 Lane 的**单执行者**串行执行;Lane 在任务到终态前不再取下一个; +4. 执行内容复用现有 `dispatch → publishCommand`(含安全校验与 `requirePersistedDispatch`)。 + +### 3.5 完成门与推进 + +- **模式 A(默认,QoS=0)**:`publish` 未抛异常即视为传输成功,推进下一条。低延迟,但仅表示已交 broker。 +- **模式 B(强一致,可配置)**:命令进入 `AWAITING_ACK` 后,等待设备业务 ACK → `CONFIRMED` 才推进;ACK 超时按失败重排或转人工。 +- **决策**:默认 A;对“增删互斥 / 强顺序敏感”的 Lane 启用 B。文档需明确“发送成功 ≠ 设备生效”的语义。 + +### 3.6 失败重排流程 + +1. **失败分类**:可重试(连接断开、`MqttException`、ACK 超时)与不可重试(策略拦截 `POLICY_BLOCKED`、载荷/身份不一致、安全拒绝、方向非法)。 +2. **不可重试**:直接 `DEAD` / `MANUAL_REVIEW`,不重排。 +3. **可重试**:`attempts++`,`next_visible_at = now + delay`,`state→RETRY_WAIT`;退避采用 `min(cap, base·2^attempts)·(1±jitter)`(`base≈2s`,`cap≈30min`)。 +4. **重排位置**(关键决策): + - **严格(默认)**:任务留在队头(`RETRY_WAIT`),同 Lane 后续等待;达 `maxRetry`/超时限后转 `DEAD/MANUAL_REVIEW` 并释放队头,推进下一条。 + - **宽松(可配置)**:失败任务让出队头、移入延迟/队尾,后续先发;吞吐好但可能改变 Lane 内相对顺序。 +5. **结果未知(QoS=0)**:不自动重放,转 `MANUAL_REVIEW` 人工对账,避免设备重复执行。 + +### 3.7 重启恢复流程 + +1. 启动扫描 `lock_dispatch_task` 与命令状态; +2. `IN_FLIGHT` 且租约过期 → 视为结果未知:转 `MANUAL_REVIEW`(或按配置重排); +3. `PENDING` / `RETRY_WAIT` → 依据 `next_visible_at` 恢复为队头候选; +4. 幂等键防止重复入队。 + +### 3.8 去重、优先级与幂等 + +- **去重**:唯一 `(command_id)`;`intent_key` 作为跨重复上传的幂等键。 +- **优先级**:`(priority ASC, seq ASC)` 决定 Lane 内先后(控制类优先于新增);优先级不跨 Lane 抢占;可加“优先级老化”防低优先级饥饿。 +- **幂等**:重试复用同一 `command_id`,不产生新业务副作用。 + +--- + +## 4. 接口与依赖 + +### 4.1 内部接口(设计契约) + +| 接口 | 说明 | +|---|---| +| `enqueue(commandId, laneKey, priority, intentKey)` | 事务内写入队列,返回 `taskId`;幂等 | +| `claimHead(laneKey, leaseMs)` | 抢占队头(`SKIP LOCKED` + 租约),返回任务或空 | +| `markInflight(taskId, leaseOwner)` | 状态置 `IN_FLIGHT`,续租 | +| `complete(taskId, outcome)` | 置 `DONE` / `RETRY_WAIT` / `DEAD`,推进队头 | +| `reschedule(taskId, delayMs, error)` | 退避重排,写 `next_visible_at` | +| `recoverOnStartup()` | 启动恢复:回收僵尸 `IN_FLIGHT`、恢复可见任务 | + +### 4.2 外部依赖 + +- **MySQL**:持久队列与状态存储;`FOR UPDATE SKIP LOCKED` 需 MySQL 8.0+(不满足时退回乐观锁 + 租约轮询)。 +- **Paho MQTT(QoS=0)**:现有传输通道,无 broker 级确认,完成门语义受其限制。 +- **调度器**:Spring `@Scheduled`(改造现有 `LockCommandRetryTask`)。 +- **可选 Redis**:按 Lane 的分布式锁(DB 租约的替代方案)。 + +### 4.3 与现有实现的对接点 + +- 复用 `LockCommand`(`intentKey`、`executionState`、`commandStatus`、`retryCount`、`maxRetry`、`nextVisibleTime`、`attemptStartedAt`)。 +- 复用 `LockExecutionTransactions`(`required` / `committed`)处理事务边界。 +- 复用 `dispatch` 内的安全校验(`requirePayloadAndIdentity`、`requireDispatchAllowed`、`requirePersistedDispatch`)。 +- 用“入队”替代现有 `afterCommit → taskExecutor.execute` 的并行派发;由 Lane 串行执行器驱动。 + +--- + +## 5. 约束与风险 + +### 5.1 约束 + +- 串行粒度上限为 Lane(`deviceSn` / `topic`),跨 Lane 不保证顺序。 +- QoS=0 无 broker 确认,“传输成功”不等于“设备生效”。 +- `SKIP LOCKED` 依赖 MySQL 8.0+;旧版本需降级实现。 +- 必须保持 `intentKey` 幂等与现有安全校验链不变。 +- 授权类命令为安全敏感操作,不允许静默丢弃。 + +### 5.2 风险与应对 + +| 风险 | 影响 | 应对 | +|---|---|---| +| 队头阻塞(严格策略) | 单条失败拖住同 Lane 后续 | 设最大重试/最大滞留时限强推;或切宽松策略 | +| 吞吐下降(串行) | 单 Lane 吞吐受限于一条在发 | 每目标 Lane 并行;仅必要时全局单 Lane | +| 多实例双消费 | 同任务被重复执行 | `SKIP LOCKED` + 租约心跳 + 幂等 | +| 结果未知重复发送 | 设备重复增删 | QoS=0 结果未知不自动重放,转人工 | +| 队列满背压 | 影响业务受理 | 授权类显式报错;可选降级直存待重试 | +| 时钟漂移/惊群 | 大量任务同刻复活 | `next_visible_at` 用 DB 时间 + 退避抖动 | +| 重启遗留 `IN_FLIGHT` | 僵尸任务 | 租约过期回收 + 启动扫描 | + +### 5.3 异常与边界处理 + +| 场景 | 处理设计 | +|---|---| +| 队列满 | 有界队列(全局 + 单 Lane);授权类拒绝并显式报错(背压),不静默丢;非关键可配丢最旧 | +| 发送超时 | `IN_FLIGHT` 超阈值(如 30s)标记超时;QoS=0 结果未知转 `MANUAL_REVIEW`;模式 B 按 ACK 超时重排 | +| 重复失败 | 达 `maxRetry` → `DEAD` + 告警;死信保留现场;禁止无限重试 | +| 进程重启 | 按 §3.7 扫描恢复;僵尸 `IN_FLIGHT` 转人工或重排 | +| 多实例 | `SKIP LOCKED` + 租约;处理幂等 | +| 设备离线 | 连接/ACK 失败按可重试退避;超上限转 `DEAD` 并告警 | + +--- + +## 6. 待确认事项 + +1. **Lane 粒度**:以 `deviceSn`、`groupCode` 还是 `topic` 作为串行单元?(影响并发度与顺序语义) +2. **完成门选型**:默认“传输成功即推进”,还是“设备 ACK 确认才推进”?哪些 Lane/命令需强一致? +3. **重排策略**:默认严格(阻塞队头)还是宽松(让出队头)?是否按命令类型区分? +4. **队列满策略**:拒绝(背压)、降级直存,还是丢最旧?哪些命令允许丢弃? +5. **是否需全局串行**:跨设备全局有序是否为硬需求(若否,保持每 Lane 串行)? +6. **多实例部署**:是否多实例部署?租约时长、续租心跳、`SKIP LOCKED` 的 MySQL 版本支持情况? +7. **重试参数**:`maxRetry`、`base`、`cap`、最大滞留时限、`IN_FLIGHT` 超时阈值的具体取值。 +8. **死信处理流程**:`DEAD` 的人工对账/重推入口与 SLA。 +9. **重复上传语义**:同一学员同一设备重复上传是“以最新为准合并”还是“各自排队”? +10. **数据保留**:`DONE`/`DEAD` 的保留期与归档清理策略。 + +--- + +## 附录:落地阶段建议 + +1. **阶段一(正确性)**:DB 持久队列 + 每 Lane 串行 + 严格重排(完成门模式 A),打通排队与顺序。 +2. **阶段二(强一致)**:按需开启模式 B(ACK 门控)与失败分类精细化。 +3. **阶段三(吞吐与治理)**:宽松重排、优先级老化、死信流程、可观测看板与告警完善。 + +> 全方案不改变 Ydlink 协议与现有命令模型,仅在命令之上叠加调度层。 diff --git a/RuoYi-Vue/doc/外部实训系统数据接口设计(V1.0).md b/RuoYi-Vue/doc/外部实训系统数据接口设计(V1.0).md new file mode 100644 index 000000000..e9b5c3ca0 --- /dev/null +++ b/RuoYi-Vue/doc/外部实训系统数据接口设计(V1.0).md @@ -0,0 +1,918 @@ +# 铁路房建学员管理系统 · 外部实训系统数据接口设计 + +> 文档类型:接口与数据表设计(本阶段不含代码实现) +> 依据文档:《铁路房建学员管理系统-外部实训系统数据接口规范》(文内版本 V1.2,文件标题 V1.0,5 个接口) +> 适用系统:VR 实训 / 3D 巡检 / 实操实训 / 帽石考核 / 其他实训考核系统 +> 设计范围:平台侧(学员管理系统)对外提供的 5 个接口所需的数据表、约束、接口契约与实现方案 +> 技术基线:RuoYi-Vue(Spring Boot 4.1 / Java 17 / MyBatis / Redis / PageHelper),数据库 **MySQL 5.7** +> 审查状态:已完成框架与数据库兼容性审查,结论与修订建议见第 11 章 + +--- + +## 1. 业务场景与功能范围 + +### 1.1 业务场景 + +外部实训系统(VR、3D 巡检、实操、帽石考核等)在实训考核过程中产生成绩,需要: + +1. 考前从平台拉取当期在训学员名单,作为本地考核名单(**不得自行新增学员**); +2. 考核结束后实时上报成绩,或按周期(默认 30 分钟)批量补报; +3. 上报失败时按 1/5/15 分钟重试,仍失败转本地缓存,恢复后以 `resend=true` 补传(窗口 90 天); +4. 通过对账接口查询逐条上报结果,处理"学员无法匹配(待认领)""已归档锁定"等异常。 + +平台侧承担:学员与班级主数据、考试科目字典、成绩接收与匹配、成绩归集与归档、应用授权与配额管理。 + +### 1.2 功能范围 + +| 编号 | 功能 | 对应接口 | 平台侧职责 | +|---|---|---|---| +| F1 | 应用登录换取令牌 | 3.1 `POST /api/open/v1/auth/token` | 校验 appId/appSecret,签发 7200s 令牌,旧令牌立即失效 | +| F2 | 在训学员查询 | 3.2 `GET /api/open/v1/student/list` | 按班级过滤、分页返回在训学员,敏感字段掩码 | +| F3 | 考试成绩批量上报 | 3.3 `POST /api/open/v1/exam/report` | 整批校验、整批写入,按 `batchId`/`reportId` 幂等,返回汇总 | +| F4 | 实时成绩上报 | 3.4 `POST /api/open/v1/exam/report/realtime` | 1–20 条即时接收,按 `reportId` 幂等,返回汇总 | +| F5 | 上报结果查询 | 3.5 `GET /api/open/v1/exam/result` | 按 batchId / reportId / 时间窗查询逐条状态,用于对账 | + +**不在本期范围**:外部系统的账号体系与页面、考核过程数据(题目、操作轨迹)解析、成绩的人工评分、外部系统的本地缓存实现。平台仅原样存储 `content` JSON,不解析业务字段。 + +### 1.3 设计依据的业务约束(来自规范,必须满足) + +| # | 约束 | 设计落点 | +|---|---|---| +| C1 | 平台为服务端,全部调用由外部系统发起 | 仅提供出站(平台对外)HTTP 接口,无回调 | +| C2 | 一个外部系统一个 `appId`;多实例共用一个 `appId`、一个令牌、一份配额 | 令牌以 `appId` 为粒度唯一(新令牌使旧令牌失效);限流按 `appId` 计数 | +| C3 | 学员身份以平台 `studentId` 为准 | 匹配键固定为 `train_student.s_id`;名单接口返回该值 | +| C4 | 报文 UTF-8、`application/json;charset=UTF-8`,单条 ≤ 1 MB | 请求体大小网关 + 应用双层校验 | +| C5 | 批量 ≤ 200 条/批,实时 ≤ 20 条/次 | 请求体数组长度校验 | +| C6 | 调用频率 ≤ 20 次/秒/appId,超限 3002 | Redis 滑动窗口限流 | +| C7 | 除 3.1 外均需 `Authorization: Bearer {accessToken}`,有效期 7200 秒,剩余 10 分钟重新登录 | 令牌 TTL 7200s;响应头回传剩余有效期 | +| C8 | 时间一律 int64 毫秒时间戳(东八区),禁止格式化字符串 | 入参校验为 long;内部换算为 `TIMESTAMP` 存储 | +| C9 | `reportId` 永久幂等,重复上报忽略、保留首次内容 | `report_id` 全局唯一 + 幂等处理 | +| C10 | 同一 `batchId` 重复提交返回首次结果 | `(app_id, batch_id)` 唯一 + 首次汇总回放 | +| C11 | 补传窗口 90 天,`resend=true` | 时间窗校验分支 | +| C12 | 实时上报时间窗:`examEndTime ≤ now+10min` 且 `≥ now-24h`;`resend=true` 放宽至 90 天 | 校验分支(码 3004) | +| C13 | 批量时间窗:每条 `examEndTime ∈ [periodStart, periodEnd)`,单次考试跨度 ≤ 24h | 校验(码 3004) | +| C14 | 分数 `0 ≤ score ≤ fullScore`,`fullScore` 默认 100,两位小数 | 校验 + `NUMERIC(7,2)` | +| C15 | `retake=true` 时 `retakeNo` 必填且从 1 递增 | 校验(码 3001) | +| C16 | `content` 必须为 JSON **对象**且 ≤ 32 KB,不得含身份证/手机号/人脸 | 对象校验 + 体积校验(3005)+ 敏感信息扫描 | +| C17 | 平台是成绩归集与归档方,成绩状态 RECEIVED → ARCHIVED,可 LOCKED | `score_status` 状态机 + 归档任务 | +| C18 | `matchStatus=UNMATCHED` 需平台管理员认领后转 MATCHED | 待认领池 + 认领操作(后台) | +| C19 | 业务结果用 `code` 表达,不依赖 HTTP 状态码 | 业务响应统一 HTTP 200 + code | + +### 1.4 待评审确认的假设 + +| # | 事项 | 本设计取值 | 说明 | +|---|---|---|---| +| A1 | "在训"口径 | `train_enrollment.enrollment_status ∈ (1 已报到, 2 请假)` 且学员 `del_flag='0'`、`status=1` | 已结业(4)、退训(3)、待报到(0) 不返回;如需含待报到,仅调整白名单 | +| A2 | `studentNo` 来源 | `train_student.person_no`,为空回退 `s_id` | 平台无独立"学号"字段 | +| A3 | `idCard` 返回形式 | 默认掩码(保留前 6 后 4);可配置返回明文 | 依据规范"身份证号或掩码" | +| A4 | 科目字典形态 | 新建 `train_exam_subject` 表(而非复用 `sys_dict_data`) | 需承载"应用-科目授权""科目→考核项映射" | +| A5 | 归档映射 | `train_exam_subject.score_item_id` 优先,其次按 `train_score_item.plan_id + item_name` 匹配 | 无法映射则保持 RECEIVED,不阻塞上报 | +| A6 | 实时上报是否启用 | 按应用级开关 `realtime_enabled` | 规范:与平台约定开启 | + +--- + +## 2. 核心实体与关系 + +### 2.1 实体清单 + +| 实体 | 说明 | 新增/复用 | +|---|---|---| +| 外部应用 | 接入方身份(appId/appSecret/类型/状态/配额) | 新增 | +| 应用授权范围 | 应用可见的班级 / 科目 | 新增 | +| 访问令牌 | 应用调用凭证(审计与吊销) | 新增 | +| 考试科目字典 | `subjectCode` / `subjectName`,归档映射 | 新增 | +| 上报批次 | 批量上报的批次头与汇总 | 新增 | +| 成绩上报记录 | 单条成绩(幂等键 `reportId`),含匹配/归档状态 | 新增 | +| 接口调用日志 | `reqId` 追踪与审计 | 新增 | +| 学员主档 | `studentId`、姓名、性别、单位、证件 | 复用 `train_student` | +| 参训登记 | 学员与班级关系、"在训"判定 | 复用 `train_enrollment` | +| 培训班 | `classId` / `className` | 复用 `train_class_plan` | +| 考核项 | 归档落库目标配置 | 复用 `train_score_item` | +| 学员得分 | 归档后的平台成绩 | 复用 `train_student_score` | + +### 2.2 关系说明 + +``` +train_open_app (PK app_id) +├─1:N─ train_open_app_scope (app_id) +├─1:N─ train_open_token (app_id) +├─1:N─ train_exam_report_batch (app_id) +│ └─1:N─ train_exam_report (batch_pk) +├─1:N─ train_open_api_log (app_id) +└─1:N─ train_exam_report (app_id) + +train_exam_report +├─N:1─ train_student (student_id → s_id,匹配后) +├─N:1─ train_enrollment (matched_enrollment_id,匹配后) +├─N:1─ train_class_plan (matched_plan_id,匹配后) +├─N:1─ train_exam_subject (subject_code,可空) +└─1:1─ train_student_score (归档后:source_system=app_id AND source_event_id=report_id) + +train_exam_subject +├─N:1─ train_class_plan (plan_id,可空表示全局科目) +└─N:1─ train_score_item (score_item_id,归档映射,可空) +``` + +### 2.3 关键约束汇总 + +| 约束 | 内容 | 目的 | +|---|---|---| +| U1 | `train_open_app.app_id` 唯一 | 应用身份唯一 | +| U2 | `train_open_app_scope (app_id, scope_type, scope_value)` 唯一 | 授权不重复 | +| U3 | `train_open_token.token_hash` 唯一 | 令牌可精确校验/吊销 | +| U4 | `train_exam_subject.subject_code` 唯一 | 科目编码唯一,避免同科目多名称 | +| U5 | `train_exam_report_batch (app_id, batch_id)` 唯一 | 整批幂等 | +| U6 | `train_exam_report.report_id` 唯一 | 单条永久幂等 | +| U7 | `train_student_score (enrollment_id, score_item_id)` 唯一(既有) | 归档不重复落分 | +| C1 | `train_exam_report`:`exam_end_time > exam_start_time`、`score >= 0`、`score <= full_score`、`retake_no >= 0` | 数据合法性兜底 | +| C2 | 状态枚举检查:`match_status ∈ (MATCHED, UNMATCHED)`、`score_status ∈ (RECEIVED, ARCHIVED, LOCKED)`、`report_channel ∈ (REALTIME, PERIODIC)` | 状态机可控 | +| C3 | 外键:`app_id → train_open_app.app_id`;`batch_pk → train_exam_report_batch.batch_pk`;`matched_enrollment_id → train_enrollment.enrollment_id` | 引用完整 | + +> C1/C2 在 MySQL 5.7 中**无法由 `CHECK` 强制执行**(5.7 解析但忽略),必须在 DTO 校验 + Service 层兜底;U1–U7 由唯一索引保证,是并发幂等的最终防线。 + +--- + +## 3. 数据表设计清单 + +> 本项目主库为 **MySQL 5.7**,下表类型为方言中立写法,落地映射见 3.9:`BIGINT IDENTITY` → `BIGINT AUTO_INCREMENT`,`TIMESTAMP`(审计字段)→ `DATETIME`,`NUMERIC(7,2)` → `DECIMAL(7,2)`。业务时间统一 `BIGINT`(毫秒)以贴合报文,规避 5.7 `TIMESTAMP` 的 2038 上限与时区转换。 + +### 3.1 `train_open_app` 外部应用注册表(新增) + +用途:登记接入方身份、类型、密钥散列、状态与配额。 + +| 字段 | 类型 | 空 | 默认 | 说明 | +|---|---|---|---|---| +| `app_id` | VARCHAR(64) | N | — | 应用标识,主键;报文 `appId` | +| `app_name` | VARCHAR(100) | N | — | 应用名称,如「VR 安全实训系统」 | +| `app_type` | VARCHAR(16) | N | — | `VR` / `INSPECT3D` / `PRACTICE` / `CAPSTONE` / `OTHER` | +| `org_name` | VARCHAR(100) | Y | NULL | 归属单位,登录响应回显 | +| `app_secret_hash` | VARCHAR(128) | N | — | `appSecret` 的 BCrypt 散列(明文不落库) | +| `app_secret_masked` | VARCHAR(16) | Y | NULL | 密钥掩码(展示后 4 位) | +| `contact_name` | VARCHAR(50) | Y | NULL | 联系人 | +| `contact_phone` | VARCHAR(20) | Y | NULL | 联系电话 | +| `rate_limit` | INT | N | 20 | 每秒请求上限(默认 20,规范值) | +| `realtime_enabled` | SMALLINT | N | 1 | 是否启用 3.4 实时上报(1 启用) | +| `status` | SMALLINT | N | 1 | 1 启用 / 0 停用(停用返回 1004) | +| `remark` | VARCHAR(500) | Y | NULL | 备注 | +| `create_by` | VARCHAR(64) | Y | NULL | 创建人 | +| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 | +| `update_by` | VARCHAR(64) | Y | NULL | 更新人 | +| `update_time` | TIMESTAMP | Y | NULL | 更新时间 | + +- 主键:`pk_train_open_app (app_id)` +- 索引建议:`idx_open_app_status (status)` +- 关联:1:N `train_open_app_scope`、`train_open_token`、`train_exam_report_batch`、`train_exam_report` + +### 3.2 `train_open_app_scope` 应用授权范围表(新增) + +用途:限定应用可访问的班级 / 科目(越权返回 2001)。 + +| 字段 | 类型 | 空 | 默认 | 说明 | +|---|---|---|---|---| +| `scope_id` | BIGINT IDENTITY | N | — | 主键 | +| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` | +| `scope_type` | VARCHAR(16) | N | — | `CLASS` 班级 / `SUBJECT` 科目 | +| `scope_value` | VARCHAR(64) | N | — | 班级 `plan_id` 或科目 `subject_code` | +| `create_by` | VARCHAR(64) | Y | NULL | 创建人 | +| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 | + +- 主键:`pk_open_app_scope (scope_id)` +- 唯一键:`uk_open_app_scope (app_id, scope_type, scope_value)` +- 外键:`fk_scope_app (app_id) → train_open_app(app_id)` +- 索引建议:`idx_scope_value (scope_type, scope_value)`(按班级/科目反查应用) + +### 3.3 `train_open_token` 访问令牌表(新增) + +用途:令牌审计、吊销与重启恢复。运行期以 Redis 为主存储(`train:open:token:{appId}`,TTL 7200s)。 + +| 字段 | 类型 | 空 | 默认 | 说明 | +|---|---|---|---|---| +| `token_id` | BIGINT IDENTITY | N | — | 主键 | +| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` | +| `token_hash` | VARCHAR(64) | N | — | 令牌的 SHA-256(不存明文),唯一 | +| `issued_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 签发时间 | +| `expire_time` | TIMESTAMP | N | — | 过期时间(签发 + 7200s) | +| `status` | SMALLINT | N | 1 | 1 有效 / 0 已失效(被新令牌替换或手动吊销) | +| `client_ip` | VARCHAR(64) | Y | NULL | 签发来源 IP(审计) | +| `user_agent` | VARCHAR(255) | Y | NULL | 客户端标识 | +| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 | + +- 主键:`pk_open_token (token_id)` +- 唯一键:`uk_open_token_hash (token_hash)` +- 外键:`fk_token_app (app_id) → train_open_app(app_id)` +- 索引建议:`idx_token_app_status (app_id, status)`、`idx_token_expire (expire_time)`(清理任务) + +### 3.4 `train_exam_subject` 考试科目字典表(新增) + +用途:统一 `subjectCode` / `subjectName`,承载科目授权与归档映射。 + +| 字段 | 类型 | 空 | 默认 | 说明 | +|---|---|---|---|---| +| `subject_id` | BIGINT IDENTITY | N | — | 主键 | +| `subject_code` | VARCHAR(32) | N | — | 科目编码(如 `VR-SAFE`),唯一 | +| `subject_name` | VARCHAR(100) | N | — | 科目名称(如「高处作业隐患排查」),唯一 | +| `plan_id` | VARCHAR(32) | Y | NULL | 外键 → `train_class_plan.plan_id`;空表示全局科目 | +| `score_item_id` | BIGINT | Y | NULL | 外键 → `train_score_item.score_item_id`;归档落分目标 | +| `status` | SMALLINT | N | 1 | 1 启用 / 0 停用 | +| `sort_no` | INT | N | 0 | 展示顺序 | +| `remark` | VARCHAR(200) | Y | NULL | 备注 | +| `create_by` / `create_time` / `update_by` / `update_time` | VARCHAR(64)/TIMESTAMP/… | Y | — | 审计字段 | + +- 主键:`pk_exam_subject (subject_id)` +- 唯一键:`uk_exam_subject_code (subject_code)`、`uk_exam_subject_name (subject_name)` +- 外键:`fk_subject_plan (plan_id) → train_class_plan(plan_id)`、`fk_subject_score_item (score_item_id) → train_score_item(score_item_id)` +- 索引建议:`idx_subject_plan (plan_id, status)` + +### 3.5 `train_exam_report_batch` 上报批次表(新增) + +用途:批量上报的批次头、首个汇总结果回放、`batchId` 幂等。 + +| 字段 | 类型 | 空 | 默认 | 说明 | +|---|---|---|---|---| +| `batch_pk` | BIGINT IDENTITY | N | — | 主键(内部) | +| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` | +| `batch_id` | VARCHAR(64) | N | — | 报文 `batchId` | +| `report_time` | BIGINT | N | — | 报文 `reportTime`(毫秒) | +| `period_start` | BIGINT | N | — | 覆盖窗口开始(含),毫秒 | +| `period_end` | BIGINT | N | — | 覆盖窗口结束(不含),毫秒 | +| `resend` | SMALLINT | N | 0 | 补传标记 1/0 | +| `total` | INT | N | 0 | 本批记录数 | +| `accepted` | INT | N | 0 | 校验通过并入库 | +| `duplicated` | INT | N | 0 | `reportId` 已存在被忽略 | +| `unmatched` | INT | N | 0 | 学员无法匹配(待认领) | +| `received_time` | BIGINT | N | — | 平台接收时间(毫秒) | +| `req_id` | VARCHAR(64) | Y | NULL | 处理该批次的请求标识 | +| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 | + +- 主键:`pk_exam_report_batch (batch_pk)` +- 唯一键:`uk_batch_app_batch (app_id, batch_id)` +- 外键:`fk_batch_app (app_id) → train_open_app(app_id)` +- 索引建议:`idx_batch_received (app_id, received_time)`、`idx_batch_period (period_start, period_end)` + +### 3.6 `train_exam_report` 成绩上报记录表(新增,核心) + +用途:保存每条上报成绩原文、匹配结果与归档状态;`reportId` 为永久幂等键。 + +| 字段 | 类型 | 空 | 默认 | 说明 | +|---|---|---|---|---| +| `report_pk` | BIGINT IDENTITY | N | — | 主键(内部) | +| `report_id` | VARCHAR(64) | N | — | 报文 `reportId`,全局唯一幂等键 | +| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` | +| `batch_pk` | BIGINT | Y | NULL | 外键 → `train_exam_report_batch.batch_pk`;实时上报为 NULL | +| `batch_id` | VARCHAR(64) | Y | NULL | 批次号冗余(实时上报为 NULL),便于按批查询 | +| `report_channel` | VARCHAR(16) | N | — | `REALTIME` / `PERIODIC` | +| `biz_type` | VARCHAR(16) | N | 'PRACTICE' | 成绩类型,固定实训成绩 | +| `student_id` | VARCHAR(32) | N | — | 报文 `studentId`(匹配键,原文保留) | +| `student_name` | VARCHAR(50) | Y | NULL | 上报姓名,供人工认领核对 | +| `id_card_masked` | VARCHAR(32) | Y | NULL | 上报证件号掩码(明文不落库,仅存比对结果) | +| `id_card_md5` | CHAR(32) | Y | NULL | 上报证件号 MD5,用于与平台档案交叉校验 | +| `matched_enrollment_id` | BIGINT | Y | NULL | 外键 → `train_enrollment.enrollment_id`,匹配成功时写入 | +| `matched_plan_id` | VARCHAR(32) | Y | NULL | 匹配到的培训班 `plan_id`(冗余) | +| `match_status` | VARCHAR(16) | N | 'UNMATCHED' | `MATCHED` / `UNMATCHED` | +| `score_status` | VARCHAR(16) | N | 'RECEIVED' | `RECEIVED` / `ARCHIVED` / `LOCKED` | +| `subject_code` | VARCHAR(32) | Y | NULL | 科目编码,须与字典一致(可空) | +| `subject_name` | VARCHAR(100) | N | — | 科目名称(必填) | +| `exam_start_time` | BIGINT | N | — | 考试开始(毫秒) | +| `exam_end_time` | BIGINT | N | — | 考试结束(毫秒) | +| `duration_seconds` | INT | Y | NULL | 用时(`exam_end_time - exam_start_time` 换算秒,写入时计算) | +| `score` | NUMERIC(7,2) | N | — | 分数(两位小数) | +| `full_score` | NUMERIC(7,2) | N | 100.00 | 满分 | +| `pass_flag` | SMALLINT | N | — | 是否通过 1/0(报文 `pass`) | +| `retake` | SMALLINT | N | 0 | 是否重考 1/0 | +| `retake_no` | INT | N | 0 | 重考序号,`retake=0` 时为 0 | +| `station` | VARCHAR(64) | N | — | 考试工位 | +| `content` | TEXT | N | — | 考试内容原始 JSON(对象,≤32 KB,原样存储) | +| `content_size` | INT | Y | NULL | 内容 UTF-8 字节数 | +| `content_md5` | CHAR(32) | Y | NULL | 内容摘要,用于重复上报的内容一致性判断 | +| `remark` | VARCHAR(500) | Y | NULL | 备注 | +| `report_time` | BIGINT | Y | NULL | 报文 `reportTime`,实时上报缺省为接收时间 | +| `received_time` | BIGINT | N | — | 平台接收时间(毫秒) | +| `resend` | SMALLINT | N | 0 | 补传标记 1/0 | +| `claim_by` | VARCHAR(64) | Y | NULL | 认领操作人(UNMATCHED → MATCHED) | +| `claim_time` | TIMESTAMP | Y | NULL | 认领时间 | +| `archive_time` | TIMESTAMP | Y | NULL | 归档时间(写入 `train_student_score`) | +| `lock_time` | TIMESTAMP | Y | NULL | 锁定时间(结课/人工锁定) | +| `lock_reason` | VARCHAR(200) | Y | NULL | 锁定原因 | +| `data_version` | INT | N | 0 | 乐观锁版本(归档/认领并发保护) | +| `create_time` / `update_time` | TIMESTAMP | Y | CURRENT_TIMESTAMP | 审计字段 | + +- 主键:`pk_exam_report (report_pk)` +- 唯一键:`uk_exam_report_id (report_id)` +- 外键: + - `fk_report_app (app_id) → train_open_app(app_id)` + - `fk_report_batch (batch_pk) → train_exam_report_batch(batch_pk)` + - `fk_report_enrollment (matched_enrollment_id) → train_enrollment(enrollment_id)` +- 检查约束(**MySQL 5.7 仅语法解析、不生效**,下列规则必须由应用层校验兜底,Kingbase 侧可按此 DDL 建约束): + - `chk_report_time_range CHECK (exam_end_time > exam_start_time)` + - `chk_report_score CHECK (score >= 0 AND score <= full_score)` + - `chk_report_retake_no CHECK (retake_no >= 0)` + - `chk_report_match_status CHECK (match_status IN ('MATCHED','UNMATCHED'))` + - `chk_report_score_status CHECK (score_status IN ('RECEIVED','ARCHIVED','LOCKED'))` + - `chk_report_channel CHECK (report_channel IN ('REALTIME','PERIODIC'))` +- 索引建议: + - `idx_report_app_time (app_id, received_time)`:按应用对账 + - `idx_report_app_batch (app_id, batch_id)`:3.5 按批次查询 + - `idx_report_student (app_id, student_id, exam_end_time)`:学员成绩回溯 + - `idx_report_status (match_status, score_status)`:认领与归档任务扫描 + - `idx_report_exam_end (exam_end_time)`:时间窗查询(3.5) +- 容量与归档:单应用日均 1 万条估算,`content` 平均 2–8 KB;**MySQL 5.7 不做分区**(唯一键 `report_id` 与分区列约束冲突),改为"热表保留 12 个月 + 按 `received_time` 归档到 `train_exam_report_his`(同结构)",列表查询不读取 `content` 列。 + +### 3.7 `train_open_api_log` 接口调用日志表(新增) + +用途:`reqId` 排障、审计与配额统计(异步写入,失败不影响主流程)。 + +| 字段 | 类型 | 空 | 默认 | 说明 | +|---|---|---|---|---| +| `log_id` | BIGINT IDENTITY | N | — | 主键 | +| `req_id` | VARCHAR(64) | N | — | 请求标识(响应同值) | +| `app_id` | VARCHAR(64) | Y | NULL | 应用(登录失败时可能为空) | +| `api_path` | VARCHAR(128) | N | — | 请求路径 | +| `http_method` | VARCHAR(8) | N | — | GET / POST | +| `biz_key` | VARCHAR(128) | Y | NULL | 业务键:`batchId` / `reportId` / `classId` | +| `code` | INT | N | — | 业务返回码 | +| `msg` | VARCHAR(500) | Y | NULL | 返回描述(截断) | +| `cost_ms` | INT | Y | NULL | 耗时(毫秒) | +| `client_ip` | VARCHAR(64) | Y | NULL | 调用方 IP | +| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 记录时间 | + +- 主键:`pk_open_api_log (log_id)` +- 索引建议:`idx_open_log_req (req_id)`、`idx_open_log_app_time (app_id, create_time)` +- 保留策略:默认 90 天,按 `create_time` 定期清理。 + +### 3.8 复用的既有表(不新增字段,仅映射) + +| 表 | 关键字段 | 在接口中的用途 | 映射/说明 | +|---|---|---|---| +| `train_student` | `s_id`、`s_name`、`s_gender`、`s_phone`、`id_card_ciphertext`/`id_card_hash`/`id_card_last4`、`dept_name`、`card_no`、`status`、`del_flag` | 3.2 名单来源;3.3/3.4 匹配 | `studentId ← s_id`;`gender`:男→`MALE`、女→`FEMALE`;`orgName ← dept_name`;`studentNo ← card_no`(空回退 `s_id`) | +| `train_enrollment` | `enrollment_id`、`plan_id`、`student_id`、`enrollment_status`、`join_time`、`complete_time` | 3.2 在训过滤;3.3/3.4 定位 `enrollment_id` | 在训口径见 A1 | +| `train_class_plan` | `plan_id`、`plan_name` | 3.2 班级过滤与 `className` | `classId ← plan_id` | +| `train_score_item` | `score_item_id`、`plan_id`、`item_name`、`full_score` | 归档时定位考核项 | 与 `train_exam_subject.score_item_id` 配合 | +| `train_student_score` | `enrollment_id`、`score_item_id`、`score`、`source_type`、`source_system`、`source_event_id` | 归档落分 | `source_type=4`(外部导入);`source_system=appId`;`source_event_id=reportId`;`(enrollment_id, score_item_id)` 唯一,归档用 UPSERT | + +### 3.9 数据库方言与 MySQL 5.7 落地约定(本项目主库为 MySQL 5.7) + +> 本节按「MySQL 5.7 为主、Kingbase 兼容为辅」修订;MySQL 5.7 的差异已计入字段类型与实现方式。 + +| 设计项 | MySQL 5.7 写法与约束 | 说明 | +|---|---|---| +| 自增主键 | `BIGINT NOT NULL AUTO_INCREMENT` | 不支持 `GENERATED ... AS IDENTITY`;MyBatis 需 `useGeneratedKeys="true"` | +| 审计时间 | `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` | 避免 `TIMESTAMP` 的 2038 上限与时区隐式转换;与 RuoYi 既有表一致 | +| 分数 | `DECIMAL(7,2)` | `NUMERIC` 仅为同义词,DDL 统一写 `DECIMAL` | +| 列注释 | 列定义内 `COMMENT '...'` | 不支持独立 `COMMENT ON COLUMN` | +| `content` 存储 | `TEXT NOT NULL`(**不用 `JSON`**) | MySQL 5.7 的 `JSON` 会重排键、去空白、强制校验,破坏"原样存储"与 `content_md5` 语义;`TEXT`(65,535 字节)足以容纳 32 KB | +| 枚举/状态 | `VARCHAR(16)` + 应用层枚举校验 | MySQL 5.7 **解析但不强制执行 `CHECK`**,唯一/非空之外只能靠应用校验 | +| 字符集 | `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci` | 需与 `train_student` 等既有表一致,避免 JOIN 隐式转换导致索引失效 | +| 长索引 | `VARCHAR(100)` 唯一键(utf8mb4 400 字节) | 5.7 默认 `innodb_large_prefix=ON`,3072 字节上限内安全 | +| 哈希列 | `CHAR(32) CHARACTER SET ascii` | 证件号/内容摘要仅 ASCII,节省空间并避免大小写/重音排序干扰 | +| 迁移脚本 | 无 `ADD COLUMN IF NOT EXISTS`、无 `CREATE INDEX IF NOT EXISTS` | 用 `information_schema` 判断后 `PREPARE/EXECUTE`,脚本须可重复执行 | +| SQL 能力 | 无窗口函数、无 CTE(`WITH`) | "取最新在训记录"用 `ORDER BY join_time DESC LIMIT 1`;统计用子查询,不写 `ROW_NUMBER()` | +| 分区 | **本期不分区** | 5.7 要求所有唯一键包含分区列,`report_id` 全局唯一与分区互斥;改用"热表 + 定期归档冷数据" | +| JDBC 连接 | 现有 URL 为 `characterEncoding=utf8` | 如需完整 4 字节字符支持,改为 `characterEncoding=utf8mb4` 并确认服务端 `utf8mb4`(现网中文场景可保持现状) | + +--- + +## 4. 接口设计方案 + +### 4.1 分层与落点(仅设计说明) + +| 层 | 职责 | 建议落点 | +|---|---|---| +| 接入层 | 令牌校验、限流、`reqId` 生成、请求体大小限制、应用上下文注入 | `ruoyi-admin`:`/api/open/v1/**` 过滤器 + 统一异常处理 | +| 控制层 | 5 个接口的入参/出参、参数格式校验 | `com.ruoyi.web.controller.open.OpenExamApiController`(按接口再拆分为 Auth/Student/Exam 三个 Controller) | +| 服务层 | 令牌签发、名单查询、上报校验与幂等、匹配、归档、认领 | `ruoyi-train`:`ITrainOpenAppService`、`IOpenExamReportService`、`IOpenStudentQueryService` | +| 持久层 | 上述新表 CRUD 与幂等插入 | MyBatis Mapper | +| 任务层 | 归档任务、令牌清理、日志清理 | 定时任务(参照现有 quartz 任务) | +| 存储层 | 令牌、限流计数、应用配置缓存 | Redis | + +**统一响应模型** `OpenApiResponse`:`code`、`msg`、`data`、`reqId`、`ts`;HTTP 恒为 200(业务错误也用 code 表达),网关层超限(如 Nginx 413)除外。 + +> **与 RuoYi 的兼容性说明(重要)** +> - 不能直接复用 `AjaxResult`:其成功码为 `HttpStatus.SUCCESS`(=200),`isSuccess()` 按 200 判定,而本规范要求 `code=0`;额外还需要 `reqId`/`ts`。故新建 `OpenApiResponse`(`code/msg/data/reqId/ts`)+ `OpenApiResponse.success(data)`/`error(code,msg)`,仅本包使用。 +> - 不能复用 `TableDataInfo`(字段为 `total/rows/code/msg`)与 `BaseController.getDataTable()`,分页数据按规范输出 `{total,pageNo,pageSize,list}`。 +> - 不能复用全局 `GlobalExceptionHandler`:其对 `ServiceException`/校验异常统一返回 `AjaxResult.error(...)`(code=500,无 `reqId`)。需新增 `@RestControllerAdvice`(`basePackages` 限定 open 包、`@Order` 高于全局)把异常映射到 1001/1002/3003/3005/4000。 +> - 鉴权放行用框架既有机制:Controller 标注 `@Anonymous`(`PermitAllUrlProperties` 自动收集并 permitAll),再由自有 Servlet Filter(参照 `OpenApiAuthFilter`)做令牌校验;不改动 `SecurityConfig` 中既有 matcher 列表。 +> - 必须把 `/api/open/**` 加入 `xss.excludes`:RuoYi `XssFilter` 对 POST/PUT 的 JSON 会逐字符串转义,会破坏 `content` 的"原样存储"与 `content_md5`。 +> - 令牌读写统一用 `RedisCache`(`setCacheObject(key, val, 7200, TimeUnit.SECONDS)`),键前缀 `open:token:{appId}`,与框架登录令牌 `login_tokens:*` 隔离。 +> - 外部调用无登录上下文:禁止调用 `SecurityUtils.getUsername()`(会抛异常),审计操作人固定为 `openapi`。 + +### 4.2 接口一:登录获取访问令牌 + +| 项 | 内容 | +|---|---| +| 名称 | 登录获取访问令牌 | +| 路径 / 方法 | `POST /api/open/v1/auth/token` | +| 鉴权 | 无(白名单),仅校验 appId + appSecret | +| 对应表 | `train_open_app`、`train_open_token`、`train_open_api_log` | + +**入参** + +| 字段 | 类型 | 必填 | 校验 | +|---|---|---|---| +| `appId` | string(64) | 是 | 非空;长度 ≤ 64 | +| `appSecret` | string(128) | 是 | 非空;长度 ≤ 128 | + +**出参 `data`** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `accessToken` | string | 不透明令牌(256 位随机),Redis 与 `train_open_token.token_hash` 存散列 | +| `tokenType` | string | 固定 `Bearer` | +| `expiresIn` | int | 固定 `7200` | +| `app.appId` | string | 应用标识 | +| `app.appName` | string | 应用名称 | +| `app.appType` | string | 应用类型枚举 | +| `app.orgName` | string | 归属单位 | + +> 规范示例中的令牌形如 JWT 仅为示意;平台采用不透明随机串(调用方无需解析),如需与既有系统统一,也可改为 HS256 JWT,载荷仅含 `appId`、`iat`、`exp`,并同样在 Redis 维护"单应用单令牌"。 + +**业务逻辑** + +1. 参数非空校验,失败 → `1001`; +2. 按 `app_id` 查应用:不存在 → `1003`;`status=0` → `1004`; +3. BCrypt 校验 `appSecret`,失败 → `1005`(同一应用连续失败 5 次锁定 10 分钟,防爆破); +4. 生成令牌(SecureRandom 32 字节,Base64URL),计算 SHA-256; +5. 旧令牌失效:将 Redis `train:open:token:{appId}` 指向新散列并重置 TTL;`train_open_token` 中该应用 `status=1` 记录置 0; +6. 写入新令牌记录(`issued_time`、`expire_time=now+7200s`、`client_ip`),Redis 写入 `appId → tokenHash`,TTL 7200s; +7. 记录调用日志;返回令牌与应用信息。 + +**边界与错误码**:`1001`/`1003`/`1004`/`1005`/`4000`。 + +### 4.3 接口二:在训学员查询 + +| 项 | 内容 | +|---|---| +| 名称 | 在训学员查询 | +| 路径 / 方法 | `GET /api/open/v1/student/list` | +| 鉴权 | Bearer;需 `CLASS` 范围(传 `classId` 时校验) | +| 对应表 | `train_student`、`train_enrollment`、`train_class_plan`、`train_open_app_scope`、`train_open_api_log` | + +**入参(Query)** + +| 字段 | 类型 | 必填 | 校验 | +|---|---|---|---| +| `classId` | string(32) | 否 | 传则须存在且在该应用 `CLASS` 授权内 | +| `pageNo` | int | 否 | ≥ 1,默认 1 | +| `pageSize` | int | 否 | 1–200,默认 50 | + +**出参 `data`**:`{ total, pageNo, pageSize, list[] }` + +`list[]` 字段:`studentId`、`studentName`、`gender`(MALE/FEMALE)、`phone`(掩码)、`idCard`(掩码)、`classId`、`className`、`studentNo`、`orgName`。 + +**业务逻辑** + +1. 令牌校验通过,`appId` 由过滤器注入; +2. `classId` 存在性校验 → 不存在 `2002`;`CLASS` 授权校验 → 未授权 `2001`; +3. 组装查询:`train_enrollment e JOIN train_student s JOIN train_class_plan p`; + - `s.del_flag='0' AND s.status=1`; + - `e.enrollment_status IN (1,2)`(在训口径 A1); + - 传 `classId` 时 `e.plan_id = #{classId}`; + - 同一学员多班时按班展开(每人每班一行)。 +4. 固定排序:`p.plan_name ASC, e.student_id ASC`(保证翻页稳定); +5. 分页返回;手机号中间 4 位掩码,证件号按 A3 掩码;`gender` 枚举转换; +6. 记录调用日志(`biz_key=classId`)。 + +**边界与错误码**:`1002`(分页参数非法)、`2001`、`2002`、`1006`、`3002`、`4000`。 + +### 4.4 接口三:考试成绩批量上报 + +| 项 | 内容 | +|---|---| +| 名称 | 考试成绩批量上报 | +| 路径 / 方法 | `POST /api/open/v1/exam/report` | +| 鉴权 | Bearer;`SUBJECT` 范围校验(带 `subjectCode` 时);`realtime_enabled` 无关 | +| 对应表 | `train_exam_report_batch`、`train_exam_report`、`train_open_app_scope`、`train_exam_subject`、`train_student`、`train_enrollment`、`train_open_api_log` | + +**入参(Body)** + +批次级:`batchId`(string64,必填)、`reportTime`(int64,必填)、`periodStart`(int64,必填)、`periodEnd`(int64,必填)、`resend`(boolean,可选,默认 false)、`reports`(object[],必填,1–200)。 + +`reports[]`:`reportId`(string64,必填)、`studentId`(string32,必填)、`idCard`(string64,可选)、`studentName`(string50,可选)、`subjectCode`(string32,可选)、`subjectName`(string100,必填)、`examStartTime`(int64,必填)、`examEndTime`(int64,必填)、`score`(decimal,必填)、`fullScore`(decimal,可选,默认 100)、`pass`(boolean,必填)、`retake`(boolean,必填)、`retakeNo`(int,条件)、`station`(string64,必填)、`content`(object,必填)、`remark`(string500,可选)。 + +**出参 `data`**:`batchId`、`total`、`accepted`、`duplicated`、`unmatched`、`receivedTime`(满足 `accepted + duplicated + unmatched = total`)。 + +**业务逻辑** + +1. **请求级校验**:`Content-Length ≤ 1 MB` → 否则 `3003`;JSON 结构/类型 → `1002`;必填缺失 → `1001`; +2. **批次校验**:`batchId` ≤ 64;`periodEnd > periodStart` → 否则 `3004`;`reports` 1–200 → 否则 `3003`;批内 `reportId` 重复 → `3001`; +3. **批次幂等**:查 `(app_id, batch_id)`: + - 已存在且批次元数据(`reportTime`/`periodStart`/`periodEnd`/`resend`/记录数)一致 → 直接回放首次汇总,不重复入库; + - 已存在但元数据不一致 → `2003`; +4. **逐条字段校验**(整批校验,任一不合法整批拒绝 `3001`): + - `reportId` ≤ 64 且非空;`studentId` 非空 ≤ 32;`subjectName` 非空; + - `examEndTime > examStartTime` 且跨度 ≤ 24h,`examEndTime ∈ [periodStart, periodEnd)` → 否则 `3004`; + - `0 ≤ score ≤ fullScore`,`fullScore` 缺省 100,小数位 ≤ 2 → 否则 `3001`; + - `retake=true` 时 `retakeNo ≥ 1`,`retake=false` 时 `retakeNo` 缺省 0; + - `content` 为 JSON 对象且序列化 ≤ 32 KB → 否则 `3005`;命中身份证/手机号模式 → `3005`; + - `subjectCode` 传值时须存在于 `train_exam_subject`(启用)→ 否则 `3001`;不在应用 `SUBJECT` 授权内 → `2001`; +5. **写入批次头**(`total` 先落,汇总置 0,事务内更新); +6. **逐条处理**(`reportId` 分块预查 + 唯一键冲突兜底): + - `report_id` 已存在 → `duplicated++`(保持首次内容;若 `content_md5` 不同仅记差异日志,不覆盖); + - 学员匹配(见 4.7):成功 → `match_status=MATCHED` 并写 `matched_enrollment_id`/`matched_plan_id`;失败 → `UNMATCHED`、`unmatched++`; + - 入库 `train_exam_report`(`report_channel='PERIODIC'`,`resend`、`report_time`、`received_time` 落库); + - 成功入库 → `accepted++`; +7. 汇总更新批次头,返回汇总;记录调用日志(`biz_key=batchId`)。 +8. 事务与并发:批次头 + 明细在同一事务,每 100 条一批 `INSERT`;唯一键冲突捕获后按 `duplicated` 处理;跨实例并发同 `batchId` 由唯一键保证只成功一次,另一请求回放首次结果。 + +**边界与错误码**:`1001`、`1002`、`2001`、`2003`、`3001`、`3002`、`3003`、`3004`、`3005`、`4000`。 + +### 4.5 接口四:实时成绩上报 + +| 项 | 内容 | +|---|---| +| 名称 | 实时成绩上报 | +| 路径 / 方法 | `POST /api/open/v1/exam/report/realtime` | +| 鉴权 | Bearer;应用需 `realtime_enabled=1` | +| 对应表 | `train_exam_report`、`train_exam_subject`、`train_student`、`train_enrollment`、`train_open_app_scope`、`train_open_api_log` | + +**入参**:`reports`(object[], 必填, 1–20,字段同 3.3 的 `reports[]`)、`reportTime`(int64, 可选,缺省取平台接收时间)。不带 `batchId`/`periodStart`/`periodEnd`。 + +**出参**:`{ total, accepted, duplicated, unmatched, receivedTime }`(无 `batchId`)。 + +**业务逻辑** + +1. 请求体 ≤ 1 MB → 否则 `3003`;`reports` 1–20 → 否则 `3003`;同一请求内 `reportId` 重复 → `3001`; +2. 逐条字段校验(同 3.3 第 4 步),时间窗改为:`examEndTime ≤ now + 10min` 且 `≥ now - 24h`;`resend=true` 时下限放宽至 90 天 → 违反 `3004`; +3. 幂等:`report_id` 唯一键冲突 → `duplicated++`; +4. 匹配与入库:`report_channel='REALTIME'`、`batch_pk=NULL`、`batch_id=NULL`;匹配失败同样入待认领池并计入 `unmatched`; +5. **即时可见**:匹配成功的记录同步触发归档(见 4.8),使成绩查询与班牌即时可见;归档失败不回滚上报,仅置 `RECEIVED` 由定时任务补归档; +6. 返回汇总;记录日志。 + +**边界与错误码**:`1001`、`1002`(含未开启实时上报时按 `2001` 返回)、`2001`、`3001`、`3002`、`3003`、`3004`、`3005`、`4000`。 + +> 失败兜底由调用方完成:超时(5s)或非 0 → 本地持久化,并入下一周期 3.3(`resend=true`),同一 `reportId` 不会重复入库。 + +### 4.6 接口五:上报结果查询 + +| 项 | 内容 | +|---|---| +| 名称 | 上报结果查询 | +| 路径 / 方法 | `GET /api/open/v1/exam/result` | +| 鉴权 | Bearer | +| 对应表 | `train_exam_report_batch`、`train_exam_report`、`train_open_api_log` | + +**入参(Query)** + +| 字段 | 类型 | 必填 | 校验 | +|---|---|---|---| +| `batchId` | string(64) | 条件 | 与 `reportId` 二选一;同时传 → `1002` | +| `reportId` | string(64) | 条件 | 与 `batchId` 二选一 | +| `periodStart` / `periodEnd` | int64 | 否 | 两者均未传 `batchId`/`reportId` 时必填;跨度 ≤ 31 天 | +| `pageNo` / `pageSize` | int | 否 | 默认 1 / 50,`pageSize ≤ 200` | + +**出参 `data`**:`batchId`、`reportTime`、`periodStart`、`periodEnd`、`total`、`accepted`、`duplicated`、`unmatched`、`list[]` + +`list[]`:`reportId`、`subjectName`、`studentId`、`matchStatus`、`scoreStatus`、`score`、`pass`、`retake`、`retakeNo`、`examStartTime`、`examEndTime`、`receivedTime`。 + +**业务逻辑** + +1. 参数互斥/必填校验 → `1001`/`1002`; +2. 按 `batchId`:查批次头(不存在 → `2002`),分页查明细(`app_id + batch_id`); +3. 按 `reportId`:查单条(不存在或不属于本应用 → `2002`),返回 `total=1`; +4. 按时间窗:`received_time ∈ [periodStart, periodEnd)`,分页查明细;汇总按窗口内 `total/accepted/duplicated/unmatched` 统计; +5. 固定排序:`exam_end_time ASC, report_id ASC`(规范:平台按考试结束时间排序); +6. 只返回必要字段,不返回 `content`、`idCard`、`station` 等敏感/大字段; +7. 记录调用日志。 + +**边界与错误码**:`1001`、`1002`、`1006`、`2002`、`3002`、`4000`。 + +### 4.7 学员匹配与待认领规则 + +| 场景 | 判定 | 结果 | +|---|---|---| +| `studentId` 存在、在训 | `train_student.s_id` 命中 + `train_enrollment` 在训(A1) | `MATCHED`,写 `matched_enrollment_id/plan_id` | +| `studentId` 不存在 | 主档无记录 | `UNMATCHED`(待认领池) | +| `studentId` 存在但无在训记录 | 已结业/退训/未入班 | `UNMATCHED` | +| 传 `idCard` 且与平台档案不一致 | MD5 比对不一致 | `UNMATCHED`(交叉校验不通过) | +| 多班在训 | 取 `join_time` 最新且 `enrollment_status=1` 的一条 | `MATCHED` | +| 认领 | 管理员指定学员 + 参训记录 | `UNMATCHED → MATCHED`,记 `claim_by/claim_time`,随后进入归档 | + +> 批量/实时接口对 `UNMATCHED` **不返回逐条错误**,只计入 `unmatched`;单条状态通过 3.5 查询(`matchStatus`)。 + +### 4.8 归档与锁定规则 + +| 环节 | 规则 | +|---|---| +| 归档对象 | `match_status=MATCHED AND score_status=RECEIVED` | +| 归档映射 | `train_exam_subject.score_item_id` 优先;否则按 `train_score_item.plan_id = matched_plan_id AND item_name = subject_name` 匹配 | +| 落库 | UPSERT `train_student_score`:`enrollment_id`、`score_item_id`、`score`、`source_type=4`、`source_system=app_id`、`source_event_id=report_id` | +| 状态迁移 | 落库成功 → `ARCHIVED` + `archive_time`;映射缺失 → 保持 `RECEIVED` 并告警,待配置后重跑 | +| 锁定 | 培训班结课(`plan_status=2`)或管理员手工锁定 → `LOCKED` + `lock_time`;锁定后同一 `reportId` 内容变更重传 → `2005` | +| 幂等 | 归档以 `(enrollment_id, score_item_id)` 唯一键 UPSERT,重复执行不重复计分 | +| 执行方式 | 实时上报同步归档;周期批次由定时任务(建议 1 分钟)批量归档 | + +### 4.9 考试内容(content)约定 + +| 项目 | 平台侧设计与校验 | +|---|---| +| 类型 | 必须是 JSON 对象(`{}`),数组/字符串/null → `3005`(解析后用 `JsonNode.isObject()` 判断) | +| 大小 | UTF-8 序列化后 ≤ 32 KB → 否则 `3005`;同时记录 `content_size` | +| 结构 | 平台不解析业务字段,原样入库(`content` TEXT);增删字段无需平台改造 | +| 敏感信息 | 扫描身份证(18 位)、手机号(11 位)等模式 → `3005`,提示改用 `studentId` 关联 | +| 变更 | 结构变更走线下报备;平台按上报时间保留历史内容,`content_md5` 用于识别同 `reportId` 内容差异 | +| 展示 | 详情页转义输出,禁止 `innerHTML` 直出,防 XSS | + +### 4.10 接口间依赖关系 + +``` +3.1 登录 ──签发令牌──► 3.2 / 3.3 / 3.4 / 3.5(均需 Bearer) +3.2 在训名单 ──提供 studentId──► 3.3 / 3.4(成绩匹配键) +3.3 批量上报 ─┐ +3.4 实时上报 ─┴─写──► train_exam_report ──归档──► train_student_score +3.3 / 3.4 ──失败兜底──► 3.3(resend=true,同一 reportId 幂等) +3.5 结果查询 ──读──► train_exam_report(_batch)(对账 / 问题定位) +科目字典 train_exam_subject ──约束──► 3.3 / 3.4(subjectCode)──映射──► 归档 +应用授权 train_open_app_scope ──约束──► 3.2(CLASS)/ 3.3、3.4(SUBJECT) +``` + +### 4.11 表与接口对应关系 + +| 数据表 | 3.1 令牌 | 3.2 名单 | 3.3 批量上报 | 3.4 实时上报 | 3.5 结果查询 | +|---|---|---|---|---|---| +| `train_open_app` | ● 读 | ○ 鉴权 | ○ 鉴权 | ○ 鉴权 | ○ 鉴权 | +| `train_open_app_scope` | — | ● 读 | ● 读 | ● 读 | — | +| `train_open_token` | ● 写 | ● 读 | ● 读 | ● 读 | ● 读 | +| `train_exam_subject` | — | — | ● 读 | ● 读 | ○ 展示 | +| `train_exam_report_batch` | — | — | ● 写 | — | ● 读 | +| `train_exam_report` | — | — | ● 写 | ● 写 | ● 读 | +| `train_open_api_log` | ● 写 | ● 写 | ● 写 | ● 写 | ● 写 | +| `train_student` | — | ● 读 | ● 读 | ● 读 | — | +| `train_enrollment` | — | ● 读 | ● 读 | ● 读 | — | +| `train_class_plan` | — | ● 读 | ○ 归档 | ○ 归档 | — | +| `train_score_item` | — | — | ○ 归档 | ○ 归档 | — | +| `train_student_score` | — | — | ○ 归档写 | ○ 归档写 | — | + +> ● 直接读写;○ 间接依赖(鉴权、归档、展示)。 + +--- + +## 5. 通用规范 + +### 5.1 分页 + +- 入参 `pageNo ≥ 1`(默认 1)、`pageSize ∈ [1,200]`(默认 50);超范围 → `1002`; +- 响应统一 `{ total, pageNo, pageSize, list }`;`total` 为满足过滤条件的总条数; +- 深分页(`pageNo` 过大)由索引 + `ORDER BY` 保证稳定;导出类需求不在本期。 +- **与 RuoYi 分页机制的对接方式**(不使用 `BaseController.startPage()`): + - 框架的 `startPage()` 读取 `pageNum`/`pageSize`/`orderByColumn`/`isAsc` 并交给 PageHelper,而本规范是 `pageNo` 且**不开放自定义排序**,直接复用会读不到页码、并引入 `orderBy` 注入面; + - 改为在 Service/Mapper 查询前显式 `PageHelper.startPage(pageNo, pageSize)`(不传 orderBy),查询后用 `new PageInfo<>(list)` 取 `total`,再映射为 `{total, pageNo, pageSize, list}`; + - PageHelper 为 ThreadLocal,必须在 `finally` 中 `PageUtils.clearPage()`(或 `PageHelper.clearPage()`),避免线程复用时污染后续查询; + - 3.5 按时间窗统计(`total/accepted/duplicated/unmatched`)需单独 `COUNT` 查询,不能依赖分页拦截器生成。 + +### 5.2 排序 + +| 接口 | 固定排序 | +|---|---| +| 3.2 学员 | `plan_name ASC, student_id ASC` | +| 3.5 结果查询 | `exam_end_time ASC, report_id ASC`(符合规范"按考试结束时间排序") | + +> 本期不开放自定义排序字段,避免索引失控与注入风险。 + +### 5.3 过滤 + +- 3.2 仅支持 `classId` 精确过滤; +- 3.5 支持 `batchId`、`reportId`、时间窗三类,且互斥/组合规则如 4.6; +- 时间窗跨度上限:3.5 为 31 天;3.3 批次窗口由调用方定义(单次考试 ≤ 24h)。 + +### 5.4 时间与数值 + +- 入参/出参时间统一 `int64` 毫秒(东八区);业务时间库内 `BIGINT` 毫秒,审计字段用 `DATETIME`(MySQL 5.7 的 `TIMESTAMP` 有 2038 上限且受时区影响); +- 分数 `DECIMAL(7,2)`(Java `BigDecimal`,统一 `setScale(2, RoundingMode.HALF_UP)`);JSON 中 `86.50` 与 `86.5` 数值等价,尾随 0 不作为契约要求,展示层格式化; +- 布尔字段报文用 `true/false`,库内 `SMALLINT 1/0`(MySQL 用 `TINYINT(1)` 会被驱动映射为 Boolean,故统一 `SMALLINT`,避免实体类型不一致); +- 文本列统一 `utf8mb4`;如需支持 4 字节字符(emoji),JDBC 需 `characterEncoding=utf8mb4`(现网配置为 `utf8`)。 + +### 5.5 幂等与重试 + +| 场景 | 幂等键 | 处理 | +|---|---|---| +| 单条成绩 | `reportId` | 唯一键 + 冲突按 `duplicated`,保留首次内容 | +| 整批 | `(appId, batchId)` | 唯一键;同批重提回放首次汇总;元数据不一致 `2003` | +| 令牌 | `appId` | 新令牌覆盖旧令牌,旧令牌立即失效 | +| 归档 | `(enrollment_id, score_item_id)` | UPSERT,不重复计分 | + +调用方重试策略:3 次,间隔 1/5/15 分钟;仍失败转本地缓存,恢复后 `resend=true` 补传(90 天)。 + +### 5.6 限流 + +- 维度:`appId`,默认 20 次/秒(`train_open_app.rate_limit` 可调); +- 实现:Redis 滑动窗口(Lua 原子计数),超限 → `3002`; +- **不能直接复用框架 `@RateLimiter`**:其 `getCombineKey()` 只支持 `LimitType.IP`(追加客户端 IP)或全局,无 `appId` 维度;且超限抛 `ServiceException`,经全局异常处理返回 `code=500`,与 `3002` 契约不符。本设计自建 `OpenApiRateLimiter`(Servlet Filter 或 AOP),复用框架已有的 `RedisScript limitScript` bean,key 为 `open:rate:{appId}`,超限直接写 `OpenApiResponse.error(3002, ...)`; +- 登录接口额外独立限流(同 IP 10 次/分钟)防爆破(可用框架 `@RateLimiter(limitType=IP)` 实现,但需单独映射错误码)。 + +--- + +## 6. 鉴权与权限控制 + +| 项 | 设计 | +|---|---| +| 认证方式 | `appId` + `appSecret` 换取出站令牌,后续请求 `Authorization: Bearer {accessToken}` | +| 密钥存储 | 仅存 BCrypt 散列 + 掩码;页面只在新增/重置时展示一次明文 | +| 令牌 | 256 位随机不透明串;Redis 存 `appId → sha256(token)`,TTL 7200s;DB 冗余审计 | +| 令牌失效 | 同 `appId` 重新登录 → 旧令牌立即失效;管理员停用应用 → 该应用全部令牌失效 | +| 校验流程 | 过滤器解析 Bearer → 查 Redis → 比对散列 → 校验应用状态 → 注入 `appId` 到请求上下文 | +| 令牌提醒 | 响应头 `X-Token-Remaining`(剩余秒数),剩余 ≤ 600s 时提示重新登录 | +| 权限范围 | `train_open_app_scope`:`CLASS` 限制 3.2 可查班级;`SUBJECT` 限制 3.3/3.4 可报科目;越权 `2001` | +| 数据隔离 | 所有查询/写入强制带 `app_id` 条件;`reportId` 跨应用不可见(`2002`) | +| 免鉴权白名单 | 仅 `POST /api/open/v1/auth/token` | +| 停用应用 | `status=0` → `1004`(登录)或 `1006`(存量令牌失效) | +| 框架放行方式 | Controller 标注 `@Anonymous`(`PermitAllUrlProperties` 自动收集 → `permitAll`),无需改动 `SecurityConfig`;`JwtAuthenticationTokenFilter` 按 `login_tokens:{token}` 查 Redis,未命中时 `loginUser` 为 null 且不抛异常,请求继续下传,由自有过滤器接管鉴权 | +| 过滤器实现 | 参照 `ruoyi-iot` 的 `OpenApiAuthFilter`:`implements Filter` + `@Component`,按路径前缀 `/api/open/` 判定,非本前缀直接放行;校验失败按规范返回 HTTP 200 + `code`(与既有 `OpenApiAuthFilter` 的 401 风格不同,需在文档中显式声明) | +| 上下文传递 | 校验通过后将 `appId`、`tokenHash` 写入 `request.setAttribute`,Controller 通过参数或 `RequestContextHolder` 读取;不写入 Spring Security 上下文 | + +--- + +## 7. 错误码与异常处理 + +### 7.1 错误码(与规范 2.1 一致) + +| code | 含义 | 触发场景 | 返回位置 | +|---|---|---|---| +| 0 | 成功 | 正常处理(含 `unmatched>0` 的汇总) | 全部 | +| 1001 | 缺少必填参数 | 必填字段缺失 | 全部 | +| 1002 | 参数格式错误 | 类型/长度/枚举/分页/互斥参数错误 | 全部 | +| 1003 | 应用未注册 | `appId` 不存在 | 3.1 | +| 1004 | 应用已停用 | `status=0` | 3.1 | +| 1005 | 应用密钥错误 | BCrypt 校验失败 | 3.1 | +| 1006 | 令牌无效或已过期 | 缺失/不匹配/过期 | 3.2–3.5 | +| 2001 | 无权限访问该资源 | 班级/科目不在授权范围;实时上报未开启 | 3.2/3.3/3.4 | +| 2002 | 数据不存在 | `batchId`/`reportId` 无记录 | 3.5 | +| 2003 | 数据冲突 | 同 `batchId` 元数据不一致 | 3.3 | +| 2004 | 学员不存在或不在训 | 单条查询/认领场景提示 | 3.5(单条语义) | +| 2005 | 成绩已归档锁定 | 锁定后内容变更重传 | 3.3/3.4 | +| 3001 | 上报数据校验失败 | 字段业务规则不满足(含批内 `reportId` 重复) | 3.3/3.4 | +| 3002 | 调用频率超限 | 超过 `rate_limit` | 全部 | +| 3003 | 消息体超过大小限制 | > 1 MB;批量 > 200;实时 > 20 | 3.3/3.4 | +| 3004 | 考试时间超出允许范围 | 时间窗/跨度/先后关系不合法 | 3.3/3.4 | +| 3005 | 考试内容 JSON 不合法 | 非对象 / > 32 KB / 含敏感信息 | 3.3/3.4 | +| 4000 | 服务端内部错误 | 未预期异常、DB 异常 | 全部 | +| 4001 | 下游系统不可达 | 平台依赖服务不可用(预留,本期无下游依赖) | 全部 | + +### 7.2 HTTP 语义 + +- 业务错误一律 HTTP 200 + `code`;`reqId` 同时返回并落日志; +- 仅网关/容器层限制返回非 200(如 Nginx 413 请求体过大),此时调用方按 `3003` 处理; +- 禁止用 HTTP 4xx/5xx 表达业务结果(避免调用方误判重试策略)。 + +### 7.3 异常处理 + +| 异常类型 | 处理 | +|---|---| +| JSON 解析失败 / 类型不符 | `1002`,不回显原始报文 | +| 参数校验(Bean Validation + 业务校验) | 首个错误返回对应 code,`msg` 指明字段与原因 | +| 唯一键冲突 | 3.3/3.4 按 `duplicated` 计入;批次头冲突按首次结果回放 | +| 数据库异常/超时 | 事务回滚,返回 `4000`,日志带 `reqId`、`appId`、`bizKey` | +| 归档失败 | 不影响上报结果,保留 `RECEIVED`,定时任务重试并告警 | +| 幂等重放 | 不重复入库,返回首次汇总(`duplicated` 口径一致) | + +### 7.4 事务边界 + +- 3.3:批次头 + 明细同一事务;分块插入(每 100 条); +- 3.4:明细批量插入同一事务;归档在事务外异步/同步独立处理; +- 3.1:令牌写入(Redis + DB)先 DB 后 Redis,Redis 失败则整体失败返回 `4000`。 + +--- + +## 8. 性能与容量 + +| 项 | 设计 | +|---|---| +| 索引 | 见第 3 章;覆盖 `(app_id, received_time)`、`(app_id, batch_id)`、`report_id` 唯一索引、状态扫描索引 | +| 大字段 | `content` 不参与列表查询;详情单独查询 | +| 批量写入 | 明细 `INSERT` 每 100 条一批;批次汇总最后一次性更新 | +| 幂等预查 | 按 `reportId` 分块 `IN` 预查,减少逐条 SELECT | +| 缓存 | 应用配置(含限流值、回调开关)Redis 缓存 60s;令牌 O(1) 校验 | +| 分页 | 限制 `pageSize ≤ 200`,避免深分页与全表扫描 | +| 异步化 | 调用日志、归档、告警异步执行,不阻塞主链路 | +| 估算 | 单应用日均 1 万条、峰值 20 次/秒;`train_exam_report` 年增量约 365 万行(含 content),按月分区可控制单表规模 | +| 归档任务 | 1 分钟扫描 `MATCHED + RECEIVED`,每次 ≤ 500 条,失败重试 3 次并告警 | +| 慢查询 | 开启慢 SQL 日志(> 500ms),对 `3.5` 时间窗查询重点监控 | + +--- + +## 9. 安全设计 + +| 项 | 措施 | +|---|---| +| 传输 | 仅 HTTPS;禁用 HTTP 明文入口 | +| 密钥 | `appSecret` 仅存 BCrypt 散列;新增/重置仅展示一次;支持轮换;连续失败锁定 | +| 令牌 | 不透明随机串(非可预测 JWT 载荷)、2h 过期、单应用单令牌、可即时吊销 | +| 越权 | 全链路 `app_id` 过滤;班级/科目授权校验;跨应用 `reportId` 一律 `2002` | +| 注入 | MyBatis `#{}` 参数绑定;排序/过滤字段白名单,不拼接用户输入 | +| 请求体 | 1 MB 上限(Nginx + 应用);`content` 32 KB;批量条数上限 | +| 敏感数据 | 名单接口手机号掩码、证件号掩码;证件号只存 MD5/Hash 与掩码;日志不记录证件号、手机号、`content` | +| `content` 合规 | 强制 JSON 对象;扫描身份证/手机号模式拒绝(3005);页面展示转义,防 XSS | +| 限流与防刷 | 应用级 20 次/秒;登录接口 IP 级 10 次/分钟 | +| 重放 | `reportId`/`batchId` 幂等键天然防重放;令牌有效期短 | +| 审计 | `train_open_api_log` 记录 `reqId`、`appId`、路径、业务键、返回码、耗时、IP;保留 90 天 | +| 权限分离 | 平台后台"应用授权"仅管理员可见;密钥重置留操作日志 | + +--- + +## 10. 评审确认清单 + +| # | 待确认项 | 影响 | +|---|---|---| +| 1 | "在训"是否包含待报到(`enrollment_status=0`) | 3.2 名单范围 | +| 2 | `studentNo` 的权威字段(`card_no` 还是 `s_id`) | 3.2 输出 | +| 3 | `idCard` 是否允许返回明文(当前默认掩码) | 3.2 输出与合规 | +| 4 | 科目字典由平台后台维护还是沿用 `sys_dict_data` | 表结构与页面 | +| 5 | 归档映射规则(科目 → 考核项)与未映射时的处理 | 归档覆盖率 | +| 6 | 成绩锁定触发点(结课自动锁定 or 人工) | 状态机与 2005 | +| 7 | 是否需要 IP 白名单(规范未要求) | 安全加固项 | +| 8 | `train_exam_report` 是否按月分区/冷热分离 | 容量与运维 | + +--- + +## 11. 兼容性审查与修改建议(RuoYi-Vue 3.9.2 + MySQL 5.7) + +> 审查基准:本仓库实际代码与配置(Spring Boot 4.1.0 / Java 17 / MyBatis / Druid / Redis / PageHelper;数据库按 MySQL 5.7),以及规范《外部实训系统数据接口规范》。 +> 结论:**接口契约(路径、字段、错误码)无需变更**;但返回结构、分页、鉴权放行、异常映射 4 处必须按框架实际能力调整,数据层有 6 处需按 MySQL 5.7 修正(已在第 2/3/4/5/6 章同步修订)。 + +### 11.1 框架兼容性问题(R1–R10) + +| 编号 | 问题 | 证据(仓库实际) | 影响 | 修改建议 | 优先级 | 文档落点 | +|---|---|---|---|---|---|---| +| R1 | 直接复用 `AjaxResult` 会返回 `code=200` 而非 `0`,且缺少 `reqId`/`ts` | `AjaxResult` 成功码取 `HttpStatus.SUCCESS`(=200),`isSuccess()` 按 200 判定 | 调用方按 `code!=0` 判失败,所有成功请求被误判 | 新增 `OpenApiResponse{code,msg,data,reqId,ts}`,`code=0` 表成功;不复用 `AjaxResult` | P0 | 4.1(已修订) | +| R2 | 复用 `TableDataInfo`/`getDataTable()` 会输出 `rows` 而非 `list`,且无 `pageNo/pageSize` | `TableDataInfo` 字段为 `total/rows/code/msg` | 3.2、3.5 响应结构不符合规范 | 自定义 `OpenApiPage{total,pageNo,pageSize,list}`,由 `PageInfo` 映射 | P0 | 3.x/4.3/4.6/5.1(已修订) | +| R3 | `BaseController.startPage()` 读 `pageNum`/`orderByColumn`/`isAsc`,与规范 `pageNo` 且固定排序冲突 | `PageUtils`/`TableSupport` 按 `pageNum` 解析,`orderBy` 由请求参数控制 | 页码取不到(永远第 1 页);排序参数形成注入面 | 显式 `PageHelper.startPage(pageNo, pageSize)`(不传 orderBy)+ `new PageInfo<>(list)`;`finally` 中 `PageUtils.clearPage()` | P0 | 5.1(已修订) | +| R4 | 全局异常处理器把校验错误映射为 `code=500`,且无 `reqId`;不含 `HttpMessageNotReadableException` 处理 | `GlobalExceptionHandler`:`ServiceException`/`BindException`/`MethodArgumentNotValidException` 均返回 `AjaxResult.error(...)` | 参数错误返回 500,调用方按"服务端错误"重试 | 新增 openapi 专用 `@RestControllerAdvice(basePackages="...open")` + `@Order`:`MethodArgumentNotValid/Bind/HttpMessageNotReadable/TypeMismatch` → 1001/1002,`MaxUploadSizeExceeded` → 3003,`DuplicateKeyException` → 按幂等处理,其它 → 4000 | P0 | 4.1/7.3(已修订) | +| R5 | `/api/open/v1/**` 未放行时会被 Spring Security 拦截并返回框架 401 结构 | `SecurityConfig` 逐个 matcher 放行(`/api/iot/v1/**`、`/api/lock/v1/**` 等) | 未放行 → 直接 401,且响应体不是规范结构 | Controller 标注 `@Anonymous`(`PermitAllUrlProperties` 自动收集),或显式追加 matcher;**禁止**改造 `JwtAuthenticationTokenFilter` | P0 | 6(已修订) | +| R6 | 限流注解不支持 `appId` 维度,且超限码不符合规范 | `RateLimiterAspect.getCombineKey()` 仅 `LimitType.IP`/全局;超限抛 `ServiceException` | 多实例共用 appId 时按 IP 计数不准确;返回 code=500 | 自建 `OpenApiRateLimiter`(Filter/AOP),复用 `RedisScript limitScript`,key `open:rate:{appId}`,超限返回 3002 | P1 | 5.6(已修订) | +| R7 | XSS 过滤器会改写 JSON 请求体,破坏 `content` 原样存储与 `content_md5` | `XssFilter` 对 POST/PUT 包装 `XssHttpServletRequestWrapper`,逐字符串转义 | `content` 入库内容与外部系统原文不一致,幂等内容比对失效 | `application.yml` 的 `xss.excludes` 增加 `/api/open/**` | P0 | 4.1(已修订) | +| R8 | 无登录上下文,`SecurityUtils.getUsername()`/`@Log` 不可用 | `@Log` 依赖登录用户写 `sys_oper_log`;`SecurityUtils` 无认证时抛异常 | 审计字段写入失败或抛异常 | 操作人固定 `openapi`;审计落 `train_open_api_log`(异步),不使用 `@Log` | P1 | 4.1/3.7(已修订) | +| R9 | 实体与后台管理页需遵循框架约定 | 现有实体继承 `BaseEntity`(`createBy/createTime/updateBy/updateTime/remark/params`);列表页用 `@PreAuthorize("@ss.hasPermi(...)")` + `@Excel` | 与既有后台风格不一致,导出/权限缺失 | 实体继承 `BaseEntity`;后台菜单权限键 `train:openApp:*`、`train:examSubject:*`、`train:examReport:*`;字段加 `@Excel`;枚举用 `sys_dict_data` 供 `dict-tag` 渲染 | P1 | 3.x、10(待补充评审) | +| R10 | Redis 访问方式不统一 | 框架提供 `RedisCache`(序列化、TTL 封装);登录令牌键前缀 `login_tokens:` | 直接使用 `RedisTemplate` 易出现序列化/键冲突 | 令牌、限流、应用缓存统一走 `RedisCache`,键前缀 `open:` | P1 | 6(已修订) | + +### 11.2 MySQL 5.7 兼容性问题(D1–D10) + +| 编号 | 问题 | MySQL 5.7 事实 | 影响 | 修改建议 | 优先级 | 文档落点 | +|---|---|---|---|---|---|---| +| D1 | `CHECK` 约束不生效 | 5.7 解析但忽略 `CHECK` | 时间/分数/枚举非法值可直接写入 | 应用层(DTO 校验 + Service 兜底)实现全部检查;DDL 保留仅作其他方言兼容 | P0 | 2.3/3.6(已修订) | +| D2 | `content` 使用 `JSON` 会破坏"原样存储" | `JSON` 列会规范化键序、空白并强制合法性 | 与外部原文不一致,`content_md5` 比对失真 | 统一 `TEXT NOT NULL`(32 KB < 65,535 字节),应用层 `JsonNode.isObject()` 校验 | P0 | 3.6/3.9(已修订) | +| D3 | `TIMESTAMP` 2038 上限与时区隐式转换 | `TIMESTAMP` 范围 1970–2038,受 `time_zone` 影响 | 审计/过期时间长期不可用,跨时区显示偏差 | 审计字段 `DATETIME DEFAULT CURRENT_TIMESTAMP`;业务时间用 `BIGINT` 毫秒 | P1 | 3.x/5.4(已修订) | +| D4 | `ADD COLUMN IF NOT EXISTS`、`CREATE INDEX IF NOT EXISTS` 不支持 | 5.7 无该语法 | 迁移脚本重复执行报错 | `information_schema` 判断 + `PREPARE/EXECUTE`(参照 `sql/train/v2/022b_*.sql`) | P1 | 3.9(已修订) | +| D5 | 无窗口函数、无 CTE | 5.7 不支持 `ROW_NUMBER()`、`WITH` | "取最新在训记录"等实现方式不可用 | `ORDER BY join_time DESC LIMIT 1`;统计用子查询/两次聚合 | P1 | 4.7/3.9(已修订) | +| D6 | 分区与唯一键冲突 | 5.7 要求唯一键包含分区列 | 按 `received_time` 分区将无法保留 `report_id` 全局唯一 | **本期不分区**;热表 12 个月 + 归档表 `train_exam_report_his` | P1 | 3.6(已修订) | +| D7 | 字符集/排序规则需与既有表一致 | 既有表 `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;连接串 `characterEncoding=utf8` | JOIN 隐式转换导致索引失效;emoji 写入失败 | 新表显式 `CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`;跨表 `student_id/plan_id` 同字符集;需要 4 字节时调整连接串 | P1 | 3.9/5.4(已修订) | +| D8 | `TINYINT(1)` 被 JDBC 映射为 Boolean | `tinyInt1isBit` 默认 true | `pass`/`retake`/`status` 实体类型随驱动变化 | 统一 `SMALLINT` + Java `Integer`,不使用 `TINYINT(1)` | P2 | 3.5/3.6/5.4(已修订) | +| D9 | 索引长度与哈希列膨胀 | utf8mb4 单字符 4 字节;`CHAR(32)` 哈希列占 128 字节 | 空间浪费、唯一索引偏大 | 哈希列 `CHAR(32) CHARACTER SET ascii`;`VARCHAR` 长度按业务收紧(已 ≤100) | P2 | 3.6/3.9(已修订) | +| D10 | `JSON` 函数/类型别名与 `NUMERIC` 写法 | `NUMERIC` 为 `DECIMAL` 同义词;`JSON_TABLE` 不存在 | DDL 风格不统一 | DDL 统一 `DECIMAL`;不使用 `JSON_TABLE` | P2 | 3.9(已修订) | + +### 11.3 参数校验、异常与安全性完整性(V1–V8) + +| 编号 | 问题 | 影响 | 修改建议 | 优先级 | +|---|---|---|---|---| +| V1 | 3.3/3.4 未定义"校验失败是否部分入库"的明确边界(文档仅写整批校验整批写入) | 歧义:实现可能边校验边插入 | 明确:**格式/业务校验失败 → 整批回滚(3001/3004/3005)**;`duplicated`/`unmatched` 属正常计数,不算失败 | P0 | +| V2 | 未约定请求体超限在网关被拦截时的响应形态 | 调用方收到 413 HTML,无法解析 | 网关 `client_max_body_size 2m` + 应用层 `Content-Length` 预检返回 3003;文档注明 413 按 3003 处理 | P1 | +| V3 | `idCard` 明文传输与落库风险 | 违反"平台不存明文"的最小化原则 | 只用于交叉校验:入参即算 `MD5`,仅存 `id_card_md5` + 掩码;日志与 `content` 中禁止出现 | P1 | +| V4 | 缺少请求链路标识的统一规范 | 排障困难 | `reqId` 由过滤器生成(`RQ-yyyyMMdd-8位`),响应体、响应头 `X-Request-Id`、`train_open_api_log` 三处一致;下游调用透传 | P1 | +| V5 | 令牌校验对"已停用应用/已轮换密钥"的存量令牌失效时机未定义 | 停用后仍可调用 2 小时内有效 | 过滤器每次校验都读应用状态(Redis 缓存 60s);停用/重置密钥时主动删除 `open:token:{appId}` | P1 | +| V6 | 3.5 未限制 `reportId` 跨应用访问的响应差异 | 可能探测他人 `reportId` 是否存在 | 统一返回 2002(不区分"不存在"与"不属于本应用"),并记录审计日志 | P1 | +| V7 | 未定义重复 `batchId` 但 `reports` 内容不同的判定口径 | 可能重复入库或静默丢弃 | 明确:仅比对批次元数据(`reportTime/periodStart/periodEnd/resend/reports.length`);一致→回放首次汇总,不一致→2003;明细差异由 `reportId` 幂等兜底 | P0 | +| V8 | 归档与锁定缺少并发保护说明 | 定时任务与实时归档并发重复落分 | 归档使用 `data_version` 乐观锁 + `train_student_score` 唯一键 UPSERT;锁定时校验 `score_status!=LOCKED` | P1 | + +### 11.4 本仓库证据索引(便于评审核对) + +| 主题 | 文件 | +|---|---| +| 成功码/响应结构 | `ruoyi-common/src/main/java/com/ruoyi/common/core/domain/AjaxResult.java`、`.../core/page/TableDataInfo.java` | +| 分页 | `ruoyi-common/src/main/java/com/ruoyi/common/core/controller/BaseController.java`、`.../utils/PageUtils.java` | +| 全局异常 | `ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java` | +| 安全放行 | `ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java`(100–118 行)、`.../config/properties/PermitAllUrlProperties.java` | +| 限流 | `ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java`(76–88 行) | +| 开放接口鉴权先例 | `ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/filter/OpenApiAuthFilter.java` | +| XSS | `ruoyi-common/src/main/java/com/ruoyi/common/filter/XssFilter.java` 及 `application.yml` 的 `xss.excludes` | +| 数据源/字符集 | `ruoyi-admin/src/main/resources/application-druid.yml`(`characterEncoding=utf8`) | +| MySQL DDL 风格 | `sql/train/v2/019_signage.sql`(`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)、`sql/train/v2/022b_add_accommodation_lock_result_mysql.sql`(幂等 DDL) | + +### 11.5 修订实施顺序(建议) + +| 阶段 | 内容 | 关联项 | +|---|---|---| +| 第一步(P0,编码前必须定稿) | 响应模型、分页映射、异常映射、鉴权放行、XSS 排除、CHECK 兜底、`content` 改 `TEXT` | R1–R5、R7、D1、D2、V1、V7 | +| 第二步(P1) | 限流自建、审计与操作人、归档/锁定并发、令牌失效策略、时间类型、迁移脚本、字符集 | R6、R8、R10、D3–D7、V2–V6、V8 | +| 第三步(P2) | 哈希列 ascii、`SMALLINT` 统一、后台菜单与字典、导出注解 | R9、D8–D10 | + +--- + +## 附录 A:接口清单 + +| 序号 | 接口名称 | 方法 | 路径 | 鉴权 | 主要表 | +|---|---|---|---|---|---| +| 3.1 | 登录获取访问令牌 | POST | `/api/open/v1/auth/token` | 无 | `train_open_app`、`train_open_token` | +| 3.2 | 在训学员查询 | GET | `/api/open/v1/student/list` | Bearer | `train_student`、`train_enrollment`、`train_class_plan` | +| 3.3 | 考试成绩批量上报 | POST | `/api/open/v1/exam/report` | Bearer | `train_exam_report_batch`、`train_exam_report` | +| 3.4 | 实时成绩上报 | POST | `/api/open/v1/exam/report/realtime` | Bearer | `train_exam_report` | +| 3.5 | 上报结果查询 | GET | `/api/open/v1/exam/result` | Bearer | `train_exam_report_batch`、`train_exam_report` | + +## 附录 B:数据字典 + +| 字段 | 取值 | +|---|---| +| `appType` | `VR` / `INSPECT3D` / `PRACTICE` / `CAPSTONE` / `OTHER` | +| `gender` | `MALE` / `FEMALE` | +| `matchStatus` | `MATCHED` / `UNMATCHED` | +| `scoreStatus` | `RECEIVED` / `ARCHIVED` / `LOCKED` | +| `bizType` | `PRACTICE` | +| `reportChannel` | `REALTIME` / `PERIODIC` | +| `retake` / `resend` | `true` / `false`(库内 1/0) | +| `scopeType` | `CLASS` / `SUBJECT` | diff --git a/RuoYi-Vue/doc/报到机接口API文档_20261105.md b/RuoYi-Vue/doc/报到机接口API文档_20261105.md new file mode 100644 index 000000000..e78db094b --- /dev/null +++ b/RuoYi-Vue/doc/报到机接口API文档_20261105.md @@ -0,0 +1,687 @@ +# 报到机接口 API 文档 + +| 项目 | 内容 | +|---|---| +| 控制器 | `com.ruoyi.web.controller.api.TrainRegisterApiController` | +| 源文件 | `ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java` | +| BasePath | `/api/register/v1` | +| 接口数量 | 12(8 个 GET + 4 个 POST) | +| 鉴权 | Spring Security 已放行 `/api/register/v1/**`(`permitAll`);仅 `/report/reset` 内部取登录用户,需携带 RuoYi Bearer Token | +| 统一响应 | `AjaxResult`(`code` / `msg` / `data`) | +| 日期格式 | `yyyy-MM-dd HH:mm:ss`(`spring.jackson.date-format`,时区 GMT+8) | +| 文档更新 | 2026-11-09(新增宿舍布局接口) | +| 编写依据 | 控制器源码逐行核对,与当前实现一致 | + +--- + +## 目录 + +- [1. 通用约定](#1-通用约定) + - [1.1 鉴权说明](#11-鉴权说明) + - [1.2 统一响应结构](#12-统一响应结构) + - [1.3 状态码与错误提示](#13-状态码与错误提示) + - [1.4 公共数据结构](#14-公共数据结构) +- [2. 接口总览](#2-接口总览) +- [3. 学员信息](#3-学员信息) + - [3.1 获取学员信息列表](#31-获取学员信息列表) + - [3.2 获取学员详情](#32-获取学员详情) + - [3.3 获取学员基础信息](#33-获取学员基础信息) + - [3.4 获取学员关联资源](#34-获取学员关联资源) +- [4. 教师信息](#4-教师信息) + - [4.1 获取教师信息列表](#41-获取教师信息列表) + - [4.2 获取教师基础信息](#42-获取教师基础信息) +- [5. 班级名单](#5-班级名单) + - [5.1 获取进行中班级的学员名单](#51-获取进行中班级的学员名单) +- [6. 人脸照片上传](#6-人脸照片上传) + - [6.1 上传学员人脸照片](#61-上传学员人脸照片) + - [6.2 上传教师人脸照片](#62-上传教师人脸照片) + - [6.3 上传人脸照片(统一入口)](#63-上传人脸照片统一入口) +- [7. 报到业务](#7-报到业务) + - [7.1 报到重置](#71-报到重置) +- [8. 宿舍布局](#8-宿舍布局) + - [8.1 获取宿舍布局信息](#81-获取宿舍布局信息) +- [9. 附录](#9-附录) + - [9.1 典型调用流程](#91-典型调用流程) + - [9.2 调用示例](#92-调用示例) + +--- + +## 1. 通用约定 + +### 1.1 鉴权说明 + +- 源代码类注释声明“其余接口复用 RuoYi Bearer 鉴权”,但当前 `SecurityConfig` 中已配置: + +```110:110:ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java + .requestMatchers("/api/register/v1/**").permitAll(); +``` + +即 **整个 `/api/register/v1/**` 路径实际为匿名可访问**,查询类与人脸上传接口不强制 Token。 + +- 例外:`POST /report/reset` 内部通过 `SecurityUtils.getUsername()` 写入操作人,无登录态时该方法会抛 `ServiceException("获取用户账户异常", 401)`。因此该接口**必须携带** `Authorization: Bearer {token}`。 +- 控制器注释提到的 `X-Signature` / `X-Nonce`(HMAC)验签目前**未在本控制器内实现**,`DeviceHmacFilter` 仅对 `/api/iot/v1/device/login` 生效。 + +### 1.2 统一响应结构 + +所有接口返回 `AjaxResult`: + +```json +{ "code": 200, "msg": "操作成功", "data": {} } +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | number | 业务状态码,见 1.3 | +| `msg` | string | 提示信息;成功时通常为 `操作成功` | +| `data` | object / array | 业务数据,无数据时该字段不出现 | + +### 1.3 状态码与错误提示 + +| code | 含义 | 触发来源 | +|---|---|---| +| 200 | 成功 | 正常返回 | +| 401 | 未认证 / 无登录上下文 | `SecurityUtils` 抛出的 `ServiceException` | +| 500 | 参数缺失、图片解析失败、目标记录不存在等业务失败 | 控制器内 `AjaxResult.error(...)` | + +> 除框架级 401/404 外,业务失败统一为 `code=500`,调用方需读取 `msg` 区分原因。 + +### 1.4 公共数据结构 + +#### StudentItemVO(学员列表项,`/student/list`) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `sId` | string | 学员ID(业务主键) | +| `sName` | string | 学员姓名 | +| `personNo` | string | 人员编号 | +| `sGender` | string | 性别 | +| `sBirth` | string | 出生日期,`yyyy-MM-dd` | +| `deptName` | string | 所属部门名称 | +| `cardNo` | string | 卡号 | +| `sPhone` | string | 手机号 | +| `sEmail` | string | 邮箱 | +| `idCardMasked` | string | 证件号掩码 | +| `idCardLast4` | string | 证件号后 4 位 | +| `sourceType` | number | 数据来源类型 | +| `status` | number | 状态 | +| `avatar` | string | 头像访问路径(如 `/profile/upload/...png`) | +| `dutyLevel` | number | 职务级别 | +| `extractStatus` / `extractTime` / `extractMsg` | string / string / string | 人脸特征提取状态、时间、描述 | +| `photoMd5` | string | 人脸照片 MD5 | +| `featureData` / `featureMd5` / `featureAlgoVersion` / `featureSize` | string / string / string / number | 人脸特征数据及元信息 | +| `id` | number | 主键 | +| `deptId` | number | 部门ID | +| `enrollmentStatus` | number | 报到状态:0 待报到、1 已报到、2 请假、3 放弃、4 已完成 | +| `joinTime` | string | 报到/入学时间 | + +#### 门锁入住结果 lockCheckin(`LockStayCheckinResult`) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `success` | boolean | 是否成功 | +| `status` | string | 处理状态 | +| `bizNo` | string | 门锁业务流水号 | +| `httpStatus` | number | 下游 HTTP 状态 | +| `code` | string | 下游业务码 | +| `message` | string | 下游提示信息 | +| `rawBody` | string | 下游原始响应体 | + +--- + +## 2. 接口总览 + +| 序号 | 分组 | 方法 | 路径 | 功能说明 | +|---|---|---|---|---| +| 1 | 学员信息 | GET | `/api/register/v1/student/list` | 获取全部学员列表(含报到状态) | +| 2 | 学员信息 | GET | `/api/register/v1/student/{sId}` | 获取单个学员详情(含账号绑定信息) | +| 3 | 学员信息 | GET | `/api/register/v1/student/{sId}/base` | 获取学员基础信息(个人信息/开班/宿舍/教室/实训场地) | +| 4 | 学员信息 | GET | `/api/register/v1/student/{sId}/resources` | 获取学员关联资源(基础信息 + 课程 + 课表) | +| 5 | 教师信息 | GET | `/api/register/v1/teacher/list` | 获取全部教师列表(结构同学员列表) | +| 6 | 教师信息 | GET | `/api/register/v1/teacher/{tId}/base` | 获取教师基础信息(个人信息/宿舍/授课课表) | +| 7 | 班级名单 | GET | `/api/register/v1/class/student` | 获取进行中班级及其未报到学员名单 | +| 8 | 人脸照片 | POST | `/api/register/v1/student/face` | 上传学员人脸照片并同步人脸设备、办理报到 | +| 9 | 人脸照片 | POST | `/api/register/v1/teacher/face` | 上传教师人脸照片并同步人脸设备 | +| 10 | 人脸照片 | POST | `/api/register/v1/face` | 按 `employeeId` 后缀统一分发到学员/教师上传 | +| 11 | 报到业务 | POST | `/api/register/v1/report/reset` | 报到重置(解绑人脸、清门锁授权、状态回退) | +| 12 | 宿舍布局 | GET | `/api/register/v1/dormitory/layout` | 按楼栋/楼层/房间返回宿舍布局及已分配床位数 | + +--- + +## 3. 学员信息 + +### 3.1 获取学员信息列表 + +- **接口路径**:`/api/register/v1/student/list` +- **请求方法**:`GET` +- **功能说明**:读取学员主档列表(`train_student`),并按 `sId` 反查 `train_enrollment` 报名记录,用第一条记录的报到状态与报到时间补齐 `enrollmentStatus`、`joinTime`。 + +**请求参数**:无(无分页、无过滤条件,返回全量) + +**返回结果说明**: + +- `data`:`StudentItemVO` 数组,字段见 [1.4](#14-公共数据结构)。 +- 学员无报名记录时,`enrollmentStatus` 与 `joinTime` 为 `null`。 + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "sId": "S0001", + "sName": "张三", + "personNo": "P1001", + "sGender": "1", + "sBirth": "2000-01-01", + "deptName": "供电段", + "sPhone": "13800000000", + "status": 1, + "avatar": "/profile/upload/2026/11/05/xxxx.png", + "enrollmentStatus": 1, + "joinTime": "2026-11-05 09:12:33" + } + ] +} +``` + +--- + +### 3.2 获取学员详情 + +- **接口路径**:`/api/register/v1/student/{sId}` +- **请求方法**:`GET` +- **功能说明**:按学员ID查询详情,并在基础字段上补充账号绑定信息(`userId`、`userName`、`nickName`、`accountBindingStatus`)。 + +**请求参数** + +| 名称 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `sId` | path | string | 是 | 学员ID,如 `S0001` | + +**返回结果说明**: + +- `data` 为 `StudentAdminVO`,继承 `StudentDetailVO` ← `StudentItemVO`。 +- 在 [1.4 StudentItemVO](#14-公共数据结构) 字段基础上,追加: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `faceRef` | string | 人脸引用 | +| `remark` | string | 备注 | +| `createBy` / `createTime` | string / string | 创建人 / 创建时间 | +| `userId` | number | 绑定的系统用户ID | +| `userName` | string | 登录账号 | +| `nickName` | string | 用户昵称 | +| `accountBindingStatus` | number | 账号绑定状态:1 已绑定,0 未绑定 | + +- 学员不存在时返回 `{"code":200,"msg":"操作成功","data":null}`。 + +--- + +### 3.3 获取学员基础信息 + +- **接口路径**:`/api/register/v1/student/{sId}/base` +- **请求方法**:`GET` +- **功能说明**:按 `sId` 聚合该学员的个人信息、所属开班计划、宿舍、教室、实训场地,供报到机首屏展示。开班 planId 取值优先取最新报到记录,缺失时从报名关系兜底。 + +**请求参数** + +| 名称 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `sId` | path | string | 是 | 学员ID | + +**返回结果说明**:`data` 为对象(字段可能为 `null`): + +| 字段 | 类型 | 说明 | +|---|---|---| +| `studInfo` | object | 学员基础信息:`sId`、`sName`、`sPhone`、`sGender`、`deptName`、`personNo` | +| `classPlan` | object | 开班信息:`planId`、`planName`、`startTime`、`endTime` | +| `dormitory` | object | 宿舍信息:`dormId`、`bedNo`、`dormBuilding`、`dormRoom` | +| `classroom` | object | 教室信息(取自本计划首个带教室的课次):`classRoomId`、`roomLocation`、`roomName` | +| `trainingSite` | object | 实训场地信息(取自本计划首个带场地的课次):`siteId`、`siteLocation`、`siteName`、`major` | + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "studInfo": { "sId": "S0001", "sName": "张三", "sPhone": "13800000000", "sGender": "1", "deptName": "供电段", "personNo": "P1001" }, + "classPlan": { "planId": "P2026001", "planName": "2026年第一期", "startTime": "2026-11-01 08:00:00", "endTime": "2026-11-30 18:00:00" }, + "dormitory": { "dormId": "D01", "bedNo": "3", "dormBuilding": "1号楼", "dormRoom": "101" }, + "classroom": { "classRoomId": "R01", "roomLocation": "教学楼2层", "roomName": "201" }, + "trainingSite": { "siteId": "T01", "siteLocation": "实训楼1层", "siteName": "接触网实训场", "major": "接触网" } + } +} +``` + +> 注意:本接口**不返回** `courses` 与 `lesson`(源码中已注释),需要课表请使用 3.4。 + +--- + +### 3.4 获取学员关联资源 + +- **接口路径**:`/api/register/v1/student/{sId}/resources` +- **请求方法**:`GET` +- **功能说明**:在 3.3 的基础上额外返回本计划课程列表与课次(含课程名),供报到机展示课表。 + +**请求参数** + +| 名称 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `sId` | path | string | 是 | 学员ID | + +**返回结果说明**:`data` 在 3.3 全部字段之外,追加: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `courses` | array | 本计划课程:`cId`(计划课程主键)、`cName`(课程名称) | +| `lesson` | array | 课次列表:`startTime`、`endTime`、`planCourseId`、`cName` | + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "studInfo": { "sId": "S0001", "sName": "张三" }, + "classPlan": { "planId": "P2026001", "planName": "2026年第一期" }, + "courses": [ { "cId": 1001, "cName": "接触网巡视" } ], + "lesson": [ { "startTime": "2026-11-06 08:00:00", "endTime": "2026-11-06 10:00:00", "planCourseId": 1001, "cName": "接触网巡视" } ], + "dormitory": { "dormId": "D01", "bedNo": "3" }, + "classroom": { "classRoomId": "R01", "roomName": "201" }, + "trainingSite": { "siteId": "T01", "siteName": "接触网实训场" } + } +} +``` + +--- + +## 4. 教师信息 + +### 4.1 获取教师信息列表 + +- **接口路径**:`/api/register/v1/teacher/list` +- **请求方法**:`GET` +- **功能说明**:读取教师主档(`train_teacher`),按**与学员列表完全一致的结构**返回,便于报到机统一处理。转换规则: + - `sId` = `tId + "_teacher"`(与人脸库教师 `employee_id` 口径一致) + - `sName` = `tName`,其余字段一一映射 + - 教师无报名记录,`enrollmentStatus`、`joinTime` 恒为 `null` + +**请求参数**:无 + +**返回结果说明**:`data` 为 `StudentItemVO` 数组,实际填充字段:`sId`、`sName`、`personNo`、`sGender`、`sBirth`、`deptName`、`cardNo`、`sPhone`、`sEmail`、`status`、`avatar`、`id`。 + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "sId": "T0001_teacher", + "sName": "李老师", + "personNo": "P2001", + "sGender": "1", + "sPhone": "13900000000", + "deptName": "供电段", + "status": 1, + "avatar": null, + "enrollmentStatus": null, + "joinTime": null + } + ] +} +``` + +--- + +### 4.2 获取教师基础信息 + +- **接口路径**:`/api/register/v1/teacher/{tId}/base` +- **请求方法**:`GET` +- **功能说明**:按教师工号聚合个人信息、宿舍信息与授课课表。兼容传入 `tId + "_teacher"` 口径:路径参数以 `_teacher` 结尾时会自动去除后缀再查询。 + +**请求参数** + +| 名称 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `tId` | path | string | 是 | 教师工号;可传 `T0001` 或 `T0001_teacher` | + +**返回结果说明**: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `teacherInfo` | object | 教师信息:`tId`、`tName`、`tPhone`、`tGender`、`deptName`、`personNo`、`avatar`;教师不存在时为 `null` | +| `dormitory` | object | `dormId`、`bedNo`、`dormBuilding`、`dormRoom`(无有效住宿时 `dormId` 为 `null`) | +| `lesson` | array | 授课课次(按开始时间升序,不传日期范围即全部);字段:`lessonId`、`planCourseId`、`courseName`、`tId`、`teacherName`、`startTime`、`endTime`、`venueType`、`classRoomId`、`classRoomName`、`siteId`、`siteName`、`venueName`、`boardSn`、`remark` | + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "teacherInfo": { "tId": "T0001", "tName": "李老师", "tPhone": "13900000000", "tGender": "1", "deptName": "供电段", "personNo": "P2001", "avatar": null }, + "dormitory": { "dormId": "D02", "bedNo": "1", "dormBuilding": "2号楼", "dormRoom": "205" }, + "lesson": [ + { "lessonId": 9001, "courseName": "接触网巡视", "tId": "T0001", "teacherName": "李老师", "startTime": "2026-11-06 08:00:00", "endTime": "2026-11-06 10:00:00", "classRoomId": "R01", "classRoomName": "201", "venueName": "教学楼201" } + ] + } +} +``` + +--- + +## 5. 班级名单 + +### 5.1 获取进行中班级的学员名单 + +- **接口路径**:`/api/register/v1/class/student` +- **请求方法**:`GET` +- **功能说明**:查询所有**进行中**(`planStatus = 1`)的开班计划,并按计划取报名记录中**未报到**(`enrollmentStatus = 0`)的学员,学员列表作为班级属性 `students` 一并返回。单个学员主档缺失时用报名记录的冗余字段兜底,不会中断整体返回。 + +**请求参数**:无 + +**返回结果说明**:`data` 为班级数组: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `planId` | string | 开班计划ID | +| `planName` | string | 开班计划名称 | +| `classId` | string | 班级ID | +| `className` | string | 班级名称 | +| `students` | array | 未报到学员:`sId`、`sName`、`deptId`、`deptName` | + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "planId": "P2026001", + "planName": "2026年第一期", + "classId": "C01", + "className": "接触网一班", + "students": [ + { "sId": "S0001", "sName": "张三", "deptId": 103, "deptName": "供电段" } + ] + } + ] +} +``` + +--- + +## 6. 人脸照片上传 + +> 三个接口均为 `POST` + `application/json`,`faceImage` 支持**纯 base64** 或 **data URI**(`data:image/png;base64,....`)两种形式;服务端统一解码并转码为 PNG 落盘,路径形如 `/profile/upload/yyyy/MM/dd/{uuid}.png`,随后写回对应主档的 `avatar` 字段。 + +### 6.1 上传学员人脸照片 + +- **接口路径**:`/api/register/v1/student/face` +- **请求方法**:`POST` +- **功能说明**:保存学员人脸照片到 `train_student.avatar`,同步人脸识别设备(`employee_id = sId`),并按住宿记录办理报到: + 1. 更新报名记录 `enrollmentStatus = 1`、`joinTime = 当前时间`(存在报名记录时); + 2. 若学员有有效住宿记录(按 `create_time` 降序首条且 `dormId` 非空),生成门锁业务流水号并调用入住接口 `/openapi/iot/stay/checkin`。 + +**请求参数**(JSON Body) + +| 名称 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `sId` | string | 是 | 学员ID | +| `faceImage` | string | 是 | 人脸图片 base64,可带 `data:image/xxx;base64,` 前缀 | + +**返回结果说明**:`data` 字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `sId` | string | 学员ID | +| `avatar` | string | 头像访问路径 | +| `faceSync` | string | 设备同步状态:`success` / `failed` / `skipped` | +| `faceMsg` | string | 同步失败原因(仅失败时存在) | +| `faceData` | any | 设备返回的非 Map 数据(成功时可能追加) | +| `lockCheckin` | object | 门锁入住结果,结构见 [1.4 lockCheckin](#14-公共数据结构)(有住宿时返回) | +| `lockAuthId` | string | 门锁授权ID,当前固定为空串(有住宿时返回) | +| `joinTime` | string | 报到时间(有住宿时返回) | + +**失败场景**(`code=500`): + +| msg | 触发条件 | +|---|---| +| `参数缺失:sId 与 faceImage 均为必填` | 任一参数为 `null` | +| `sId 不能为空` | `sId` 为空串 | +| `图片解析失败:...` | base64 非法 / 非图片 / 无法编码为 PNG | +| `创建存储目录失败` | 落盘目录创建失败 | +| `图片写入失败:...` | 文件写入 IO 异常 | +| `学员不存在或更新失败` | 更新 `train_student` 影响行数为 0 | + +> 人脸设备同步失败不影响接口成功返回,仅通过 `faceSync=failed` + `faceMsg` 体现。 + +--- + +### 6.2 上传教师人脸照片 + +- **接口路径**:`/api/register/v1/teacher/face` +- **请求方法**:`POST` +- **功能说明**:保存教师人脸照片到 `train_teacher.avatar`,以 `employee_id = tId + "_teacher"` 同步人脸识别设备。教师无报名记录,**不处理报到状态**;若教师已有有效住宿且已生成门锁流水号,则补发一次入住授权(有效期 15 天)。 + +**请求参数**(JSON Body) + +| 名称 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `teacherId` | string | 是 | 教师工号(`train_teacher.t_id`),不含 `_teacher` 后缀 | +| `faceImage` | string | 是 | 人脸图片 base64,可带 data URI 前缀 | + +**返回结果说明**:`data` 字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `tId` | string | 教师工号 | +| `avatar` | string | 头像访问路径 | +| `faceSync` | string | 设备同步状态:`success` / `failed` / `skipped` | +| `faceMsg` | string | 同步失败原因(仅失败时存在) | +| `lockCheckin` | object | 补发入住授权结果(教师有住宿且已生成 `lockApiBizNo` 时返回) | +| `lockAuthId` | string | 当前固定为空串(补发授权时返回) | + +**失败场景**(`code=500`): + +| msg | 触发条件 | +|---|---| +| `参数缺失:teacherId 与 faceImage 均为必填` | 任一参数为 `null` | +| `teacherId 不能为空` | `teacherId` 为空串 | +| `图片解析失败:...` / `创建存储目录失败` / `图片写入失败:...` | 同 6.1 | +| `教师不存在或更新失败` | 更新 `train_teacher` 影响行数为 0 | + +--- + +### 6.3 上传人脸照片(统一入口) + +- **接口路径**:`/api/register/v1/face` +- **请求方法**:`POST` +- **功能说明**:按 `employeeId` 后缀分发到 6.1 / 6.2,返回被调用方法的原样结果: + - `employeeId` 以 `_teacher` 结尾 → 去掉后缀作为 `teacherId`,转 6.2; + - 其他情况 → `employeeId` 即 `sId`,转 6.1。 +- 后缀匹配**区分大小写**且不做 `trim`(`_TEACHER` 会被当作学员ID)。 + +**请求参数**(JSON Body) + +| 名称 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `employeeId` | string | 是 | 人脸库人员ID:学员 `sId` 或教师 `tId + "_teacher"` | +| `faceImage` | string | 是 | 人脸图片 base64,可带 data URI 前缀 | + +**返回结果说明**:与分发目标接口完全一致(见 6.1 / 6.2 的 `data` 说明)。 + +**失败场景**(`code=500`): + +| msg | 触发条件 | +|---|---| +| `参数缺失:employeeId 与 faceImage 均为必填` | 任一参数为 `null` | +| `employeeId 不能为空` | `employeeId` 为空串 | +| `employeeId 非法:教师工号(tId)不能为空` | `employeeId` 仅由 `_teacher` 组成 | + +--- + +## 7. 报到业务 + +### 7.1 报到重置 + +- **接口路径**:`/api/register/v1/report/reset` +- **请求方法**:`POST` +- **功能说明**:用于“误点他人信息导致人脸错绑”的补救。按顺序执行 4 步,任一关键步骤失败即中止并返回原因: + 1. 解绑人脸(复用 `TrainFaceApiController.deleteEmployee`,`employee_id = studentId`); + 2. 清除门锁入住授权(复用 `TrainLockApiController.checkout`),**住宿分配记录保留**; + 3. 参训状态重置为未报到 `0`(备注保持不变); + 4. 清空学员头像(`train_student.avatar` 置 NULL)。 + +**请求参数**(Query / Form,`@RequestParam`,非 JSON Body) + +| 名称 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `planId` | string | 是 | 开班计划ID(`train_enrollment.plan_id`) | +| `studentId` | string | 是 | 学员ID(`train_enrollment.student_id`) | + +**返回结果说明**:`data` 字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `planId` | string | 开班计划ID | +| `studentId` | string | 学员ID | +| `enrollmentId` | number | 参训记录主键 | +| `previousStatus` | number | 重置前的报到状态 | +| `faceUnbind` | string | 人脸解绑结果,成功为 `success` | +| `lockCheckout` | string | 门锁授权清除结果:`success` / `skipped: 无有效住宿记录` / `skipped: 缺少门锁业务流水号与宿舍信息` | +| `lockApiBizNo` | string | 门锁业务流水号(清除成功时返回) | +| `currentStatus` | number | 重置后状态,固定 `0` | +| `alreadyReset` | boolean | 重置前是否已是未报到 | +| `accommodationKept` | boolean | 是否保留了住宿记录 | +| `avatarCleared` | boolean | 头像是否已清空(成功路径) | +| `avatarSId` | string | 清空头像的学员ID(成功路径) | + +**失败场景**(`code=500`): + +| msg | 触发条件 | +|---|---| +| `planId 和 studentId 不能为空` | 任一参数为空 | +| `未找到学员[xxx]在班级[yyy]的参训记录` | 报名记录不存在 | +| `学员已放弃培训,不允许重置报到状态` | `previousStatus = 3` | +| `学员已结训,不允许重置报到状态` | `previousStatus = 4` | +| `步骤1 人脸解绑失败:...;参训状态未变更` | 人脸解绑未返回 200 | +| `步骤2 门锁授权清除响应解析失败:...;人脸已解绑,参训状态未变更` | 下游响应非法 JSON | +| `步骤2 门锁授权清除失败:...;人脸已解绑,参训状态未变更` | 下游返回非 200 | +| `步骤3 参训状态重置失败;人脸与门锁授权已解除,请人工核对后重试` | 状态更新影响行数为 0 | +| `报到重置已完成,但未找到有效学员档案[xxx],avatar 未清空` | 主档缺失(`data` 仍返回已完成信息) | +| `报到重置已完成,但清空学员头像失败:...` | 头像更新异常(`data` 仍返回已完成信息) | + +```json +{ + "code": 200, + "msg": "报到重置成功", + "data": { + "planId": "P2026001", + "studentId": "S0001", + "enrollmentId": 5001, + "previousStatus": 1, + "faceUnbind": "success", + "lockCheckout": "success", + "lockApiBizNo": "STAY20261105001", + "currentStatus": 0, + "alreadyReset": false, + "accommodationKept": true, + "avatarCleared": true, + "avatarSId": "S0001" + } +} +``` + +--- + +## 8. 宿舍布局 + +### 8.1 获取宿舍布局信息 + +- **接口路径**:`/api/register/v1/dormitory/layout` +- **请求方法**:`GET` +- **功能说明**:查询 `train_dormitory_info`(仅未删除房间),依次按 `dorm_building`(楼栋)→ `dorm_floor`(楼层)→ `dorm_room`(房间号)分组;并左关联 `train_accommodation` 统计每个房间的已分配床位数(住宿状态 `0 预分配` / `1 已入住` 计入,`2 已退宿` 不计),总床位数取房间核定容量 `capacity`。 + +**请求参数**:无 + +**返回结果说明**: + +- `data` 为三层嵌套对象,键依次为「楼栋名称 → 楼层 → 房间号」,每间房的值包含: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 宿舍自增编号(`train_dormitory_info.id`) | +| `dorm_room` | string | 房间号 | +| `accommodation` | string | 已分配床位数/总床位数,如 `"2/4"` | + +- 楼栋、楼层、房间均按数据库升序排列(`dorm_building, dorm_floor, dorm_room`),嵌套对象使用有序 Map 输出,顺序稳定。 +- 无房间数据时 `data` 为空对象 `{}`。 + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "1号楼": { + "1": { + "101": { "id": 1, "dorm_room": "101", "accommodation": "2/4" }, + "102": { "id": 2, "dorm_room": "102", "accommodation": "0/4" } + }, + "2": { + "201": { "id": 5, "dorm_room": "201", "accommodation": "4/6" } + } + }, + "2号楼": { + "1": { + "101": { "id": 9, "dorm_room": "101", "accommodation": "1/2" } + } + } + } +} +``` + +> 说明:同一楼栋内房间号唯一(约束 `uq_dorm_building_room`),因此以房间号作为楼层下的键不会冲突;楼层为空时键为 `未标注楼层`。 + +--- + +## 9. 附录 + +### 9.1 典型调用流程 + +1. `GET /student/list` 或 `GET /class/student` —— 报到机展示待报到人员。 +2. `GET /teacher/list` —— 展示教师名单(`sId` 形如 `T0001_teacher`)。 +3. 人员点击后:`GET /student/{sId}/resources`(或 `/base`)—— 展示开班、宿舍、教室、课表。 +4. 采集人脸后:`POST /face`(统一入口,传 `employeeId` + `faceImage`)—— 完成照片入库、设备下发、报到登记、门锁入住。 +5. 宿舍看板:`GET /dormitory/layout` —— 按楼栋/楼层/房间展示床位占用情况。 +6. 误绑时:`POST /report/reset?planId=...&studentId=...` —— 回退报到与人脸/门锁授权。 + +### 9.2 调用示例 + +```bash +# 学员列表 +curl "http://localhost:8096/api/register/v1/student/list" + +# 学员关联资源 +curl "http://localhost:8096/api/register/v1/student/S0001/resources" + +# 教师基础信息(tId 可带 _teacher 后缀) +curl "http://localhost:8096/api/register/v1/teacher/T0001_teacher/base" + +# 统一人脸上传 +curl -X POST "http://localhost:8096/api/register/v1/face" \ + -H "Content-Type: application/json" \ + -d '{"employeeId":"S0001","faceImage":"data:image/jpeg;base64,/9j/4AAQSkZJRg..."}' + +# 报到重置(需登录态) +curl -X POST "http://localhost:8096/api/register/v1/report/reset?planId=P2026001&studentId=S0001" \ + -H "Authorization: Bearer {token}" + +# 宿舍布局 +curl "http://localhost:8096/api/register/v1/dormitory/layout" +``` + +### 9.3 与源码注释的差异说明 + +| 位置 | 注释/声明 | 实际实现 | +|---|---|---| +| 类注释 | 登录接口需 HMAC 签名(`X-Signature`/`X-Nonce`) | 控制器内未实现;`DeviceHmacFilter` 仅覆盖 `/api/iot/v1/device/login` | +| `SecurityConfig` 注释 | `/api/register/v1/**` 走 Bearer 鉴权 | 已配置 `permitAll`,除 `/report/reset` 外不校验 Token | +| `/student/{sId}/base` | 注释称返回“课表、实训场地” | 实际不返回 `courses`、`lesson`,需调用 `/student/{sId}/resources` | diff --git a/RuoYi-Vue/doc/电子班牌接口API文档.md b/RuoYi-Vue/doc/电子班牌接口API文档.md new file mode 100644 index 000000000..c45ff1bf8 --- /dev/null +++ b/RuoYi-Vue/doc/电子班牌接口API文档.md @@ -0,0 +1,612 @@ +# 电子班牌接口 API 文档 + +| 项目 | 内容 | +|---|---| +| 控制器 | `com.ruoyi.web.controller.api.TrainBoardApiController` | +| 源文件 | `ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainBoardApiController.java` | +| BasePath | `/api/board/v1` | +| 接口数量 | 7(4 个查询类 + 1 个签到上报 + 2 个新增能力) | +| 鉴权 | RuoYi Bearer Token(`Authorization: Bearer {token}`) | +| 文档更新 | 2026-11-03 | +| 编写依据 | 控制器源码逐行核对,与当前实现一致 | + +--- + +## 目录 + +- [1. 通用约定](#1-通用约定) + - [1.1 鉴权](#11-鉴权) + - [1.2 统一响应结构](#12-统一响应结构) + - [1.3 状态码](#13-状态码) + - [1.4 列表数据包装](#14-列表数据包装) + - [1.5 字段类型说明](#15-字段类型说明) +- [2. 接口总览](#2-接口总览) +- [3. 接口详情](#3-接口详情) + - [3.1 查询教室信息](#31-查询教室信息) + - [3.2 查询指定教室当天课程](#32-查询指定教室当天课程) + - [3.3 查询指定班级在指定教室的座位](#33-查询指定班级在指定教室的座位) + - [3.4 查询班级学员卡号](#34-查询班级学员卡号) + - [3.5 上报签到数据](#35-上报签到数据) + - [3.6 查询本节课签到记录](#36-查询本节课签到记录) + - [3.7 查询座位布局](#37-查询座位布局) +- [4. 异常与错误汇总](#4-异常与错误汇总) +- [5. 调用示例](#5-调用示例) +- [6. 附录](#6-附录) + +--- + +## 1. 通用约定 + +### 1.1 鉴权 + +- 所有接口均需携带 `Authorization: Bearer {token}`,Token 由平台登录接口签发。 +- 未携带或 Token 失效时,返回 **HTTP 200** + 响应体 `code=401`: + +```json +{ "code": 401, "msg": "请求访问:/api/board/v1/signageService/selectClassroom,认证失败,无法访问系统资源" } +``` + +> 本控制器未使用 `@PreAuthorize` 数据权限注解,因此不存在 `403` 业务场景。 + +### 1.2 统一响应结构 + +所有接口返回 `AjaxResult`: + +```json +{ "code": 200, "msg": "操作成功", "data": {} } +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | number | 业务状态码,见 1.3 | +| `msg` | string | 提示信息;成功固定为 `操作成功` | +| `data` | object / array | 业务数据,无数据时字段不出现 | + +### 1.3 状态码 + +| code | 含义 | HTTP 状态 | 触发来源 | +|---|---|---|---| +| 200 | 成功 | 200 | 正常返回 | +| 401 | 未认证 / Token 无效 | **200** | `AuthenticationEntryPointImpl` | +| 404 | 接口路径不存在 | 404 | `NoResourceFoundException` | +| 500 | 参数缺失、参数格式错误、数据校验失败、控制器内业务拦截、系统异常 | 200 | `GlobalExceptionHandler` / 控制器返回值 | +| 40080 / 40081 / 40085 / 40086 / 40087 | 考勤业务码(计划不一致 / 课次不存在 / 未报到 / 已放弃 / 已结训) | 200 | `ServiceException`(`AttendanceErrorCode`) | + +> **调用方必须判断响应体 `code`**:除 404 外,失败场景的 HTTP 状态码仍是 200。 + +### 1.4 列表数据包装 + +除 `3.1 查询教室信息`(`data` 直接是数组)外,其余查询接口的 `data` 为: + +```json +{ "total": 2, "rows": [ ... ] } +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `total` | number | 本次返回的记录数(非分页总数) | +| `rows` | array | 数据列表,无数据时为 `[]` | + +### 1.5 字段类型说明 + +类注释约定 `id / classId / classroomId / courseId / teacherId / stuId / studentId` 为 Long, +但本系统相关表以业务编码为主键(字符串),因此这些字段**返回字符串**; +仅 `lessonId(id)`、`enrollmentId`、`zoneId`、`seatId`、`capacity`、`classHour` 等为数值。 + +--- + +## 2. 接口总览 + +| # | 接口名称 | 方法 | 路径 | 用途 | +|---|---|---|---|---| +| 3.1 | 查询教室信息 | GET | `/api/board/v1/signageService/selectClassroom` | 返回全部教学教室与实训场地 | +| 3.2 | 查询指定教室当天课程 | GET | `/api/board/v1/signageService/coursesInClassroom` | 按教室与日期范围返回排课 | +| 3.3 | 查询指定班级在指定教室的座位 | GET | `/api/board/v1/signageService/studentInClassroom` | 返回座位网格(含学员姓名) | +| 3.4 | 查询班级学员卡号 | GET | `/api/board/v1/signageService/studentCard` | 刷卡校验用卡号与班级信息 | +| 3.5 | 上报签到数据 | POST | `/api/board/v1/signageService/checkin/upload` | JSON 上报签到并落库 | +| 3.6 | 查询本节课签到记录 | GET | `/api/board/v1/signageService/checkin/list` | 按课次返回签到流水与学员姓名 | +| 3.7 | 查询座位布局 | GET | `/api/board/v1/signageService/seatLayout` | 仅读 `train_class_seat` 的座位布局 | + +--- + +## 3. 接口详情 + +### 3.1 查询教室信息 + +**用途**:返回全部教学教室 + 实训场地(`train_classroom_info` UNION `train_training_site`),供班牌后台定位终端绑定教室。 + +| 项 | 内容 | +|---|---| +| HTTP 方法 | `GET` | +| 请求路径 | `/api/board/v1/signageService/selectClassroom` | +| 鉴权 | 需要 | + +**请求参数**:无。 + +**请求体**:无。 + +**响应结构**:`data` 为数组(**不是** `{total, rows}` 包装)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | 教室ID(`class_room_id`)或场地ID(`site_id`) | +| `name` | string | 教室/场地名称 | +| `campus` | string | 校区/位置 | +| `capacity` | number | 容量 | +| `outside` | number | `0`=教室,`1`=实训场地 | +| `category` | string | `教室` / `实训场地` | + +**成功示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { "id": "CR01", "name": "多媒体教室01", "campus": "一号教学楼3层", "capacity": 60, "outside": 0, "category": "教室" }, + { "id": "SITE01", "name": "信号实训场", "campus": "实训楼1层", "capacity": 40, "outside": 1, "category": "实训场地" } + ] +} +``` + +**异常说明**:未认证 → `code=401`;无数据 → `data: []`。 + +--- + +### 3.2 查询指定教室当天课程 + +**用途**:按教室/场地与日期范围(含首尾日期)返回排课列表。文档约定 `beginDate=endDate=当天`,接口本身不限制两者相等。 + +| 项 | 内容 | +|---|---| +| HTTP 方法 | `GET` | +| 请求路径 | `/api/board/v1/signageService/coursesInClassroom` | +| 鉴权 | 需要 | + +**请求参数(Query String)** + +| 名称 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `classroomId` | string | 是 | 无 | 教室ID(`class_room_id`)或场地ID(`site_id`),SQL 同时匹配两列 | +| `beginDate` | string | 是 | 无 | 开始日期 `yyyy-MM-dd`,条件 `start_time >= beginDate` | +| `endDate` | string | 是 | 无 | 结束日期 `yyyy-MM-dd`,条件 `start_time < endDate + 1 天`(含当天) | + +**请求体**:无。 + +**响应结构**:`data = { total, rows[] }` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 排课ID(`train_lesson.lesson_id`),**即 3.5 的 `lessonId`** | +| `courseId` | string | 课程主数据ID(`train_course.c_id`) | +| `classId` | string | 班级ID(`train_class_plan.plan_id`) | +| `classroomId` | string | 教室ID或场地ID(`COALESCE(class_room_id, site_id)`) | +| `courseName` | string | 课程名称 | +| `couresName` | string | 兼容旧拼写的课程名称(与 `courseName` 同值) | +| `courseTime` | string | 上课时间 `yyyy-MM-dd HH:mm:ss` | +| `classHour` | number | 时长(分钟) | +| `className` | string | 班级名称 | +| `teacherId` | string | 教师ID(`train_lesson.t_id`) | +| `teacherName` | string | 教师姓名,未排教师为 `null` | +| `courseIntro` | string | 课程简介(`train_course.teaching_objective`) | +| `isPublic` | number | 固定 `0` | + +**成功示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "total": 1, + "rows": [ + { + "id": 1001, "courseId": "CR01", "classId": "PLAN_A", "classroomId": "CR01", + "courseName": "电子班牌数字化实务", "couresName": "电子班牌数字化实务", + "courseTime": "2026-11-02 09:00:00", "classHour": 90, + "className": "2026年第一期培训班", "teacherId": "T101", "teacherName": "张三", + "courseIntro": "班牌设备安装与配置", "isPublic": 0 + } + ] + } +} +``` + +**异常说明** + +| 场景 | 响应 | +|---|---| +| 缺少 `classroomId` | `{"code":500,"msg":"Required request parameter 'classroomId' for method parameter type String is not present"}` | +| 无排课 | `{"code":200,"msg":"操作成功","data":{"total":0,"rows":[]}}` | + +--- + +### 3.3 查询班级学员卡号 + +**用途**:按班级返回学员卡号与班级、住宿信息,用于班牌刷卡校验。仅返回参训状态非“已放弃(3)”的学员。 + +| 项 | 内容 | +|---|---| +| HTTP 方法 | `GET` | +| 请求路径 | `/api/board/v1/signageService/studentCard` | +| 鉴权 | 需要 | + +**请求参数(Query String)** + +| 名称 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `classId` | string | 是 | 无 | 班级ID(`train_enrollment.plan_id`) | + +**请求体**:无。 + +**响应结构**:`data = { total, rows[] }` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `cardNo` | string | 卡号(`train_student.card_no`,保留前导零) | +| `studentId` | string | 学员ID(`s_id`) | +| `classId` | string | 班级ID | +| `studentName` | string | 学员姓名 | +| `className` | string | 班级名称 | +| `id` | number | 参训档案ID(`enrollment_id`,**不是学员ID**) | +| `roomId` | string | 宿舍ID,未分配为 `null` | +| `bedType` | string | 床位类型(预留,固定 `null`) | +| `seatNumber` | string | 床位号,未分配为 `null` | + +**成功示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "total": 1, + "rows": [ + { "cardNo": "00012345", "studentId": "STU_1", "classId": "PLAN_A", "studentName": "王五", "className": "2026年第一期培训班", "id": 201, "roomId": "DORM-101", "bedType": null, "seatNumber": "1" } + ] + } +} +``` + +**异常说明**:缺少 `classId` → `code=500`;无数据 → `{total:0, rows:[]}`。 + +--- + +### 3.4 上报签到数据 + +**用途**:班牌上报学员签到。服务端按 `studentId` 在课次所属培训班内查询 `train_enrollment` 得到 `enrollmentId`,写入 `train_student_checkin`(`student_id`、`board_id`、`lesson_id`、`sign_in_time` 一并落库)。 + +| 项 | 内容 | +|---|---| +| HTTP 方法 | `POST` | +| 请求路径 | `/api/board/v1/signageService/checkin/upload` | +| 鉴权 | 需要 | +| Content-Type | **必须 `application/json`** | +| 文件上传 | 无 | + +**请求参数**:无 Query 参数,全部字段在请求体中。 + +**请求体结构**(`BoardCheckinUploadRequest`) + +| 名称 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `studentId` | string | 是 | 无 | 学员ID(`train_student.s_id`),首尾空格会被去除 | +| `boardId` | string | 否 | `null` | 门牌(班牌)ID,写入 `train_student_checkin.board_id`;空串按 `null` 处理 | +| `lessonId` | number(long) | 是 | 无 | 课程(课次)ID,对应 `train_student_checkin.lesson_id`,取值来自 3.2 的 `id` | +| `signInTime` | string | 否 | 服务器当前时间 | 签到时间,格式 `yyyy-MM-dd HH:mm:ss`;格式非法直接报错,不静默改写 | + +```json +{ + "studentId": "STU_1", + "boardId": "BP001", + "lessonId": 1001, + "signInTime": "2026-11-02 08:30:00" +} +``` + +**响应结构**(`data` 为单对象) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `checkinId` | number | 考勤记录ID | +| `studentId` | string | 学员ID(库中 `student_id`,为空时回填请求值) | +| `enrollmentId` | number | 参训档案ID(由 `studentId` + 班次推导) | +| `lessonId` | number | 课次ID | +| `boardId` | string | 班牌ID,未传为 `null` | +| `signInTime` | string | 实际落库的签到时间 `yyyy-MM-dd HH:mm:ss` | + +**成功示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "checkinId": 9001, "studentId": "STU_1", "enrollmentId": 201, + "lessonId": 1001, "boardId": "BP001", "signInTime": "2026-11-02 08:30:00" + } +} +``` + +**异常与错误说明** + +| 场景 | code | 响应 msg | +|---|---|---| +| `studentId` 为空 | 500 | `studentId 不能为空` | +| `lessonId` 为空 | 500 | `lessonId 不能为空` | +| `signInTime` 格式错误 | 500 | `signInTime 格式不正确,应为 yyyy-MM-dd HH:mm:ss` | +| 课次不存在 | 500 | `课程(课次)不存在: 1001` | +| 课次未关联有效培训计划 | 500 | `课程(课次)[1001]未关联有效的培训计划` | +| 学员无该班参训记录 | 500 | `未找到学员[STU_1]在班级[PLAN_A]的参训记录` | +| 非 JSON / 空请求体 | 500 | Spring 反序列化异常信息(如 `Content-Type 'text/plain' is not supported`) | +| 未认证 | 401 | `请求访问:/api/board/v1/signageService/checkin/upload,认证失败,无法访问系统资源` | +| 学员未报到 / 已放弃 / 已结训 | 40085 / 40086 / 40087 | 对应中文提示(见 `AttendanceErrorCode`) | +| 课次所属计划与参训计划不一致 | 40080 | `业务不变量校验失败:考勤学员与课次所属培训班不一致` | + +**业务规则** + +1. **幂等**:同一 `enrollmentId + lessonId` 已有签到记录(`sign_in_time` 非空)时返回已有记录,不覆盖首次签到时间。 +2. **生命周期**:仅“已报到(1)”“请假(2)”状态允许签到。 +3. **计划一致性**:学员参训档案所属培训班必须与课次所属培训班一致。 +4. **打卡方式**:固定按设备打卡 `checkinWay=1` 落库。 + +--- + +### 3.5 查询本节课签到记录 + +**用途**:按 `lessonId` 返回 `train_student_checkin` 签到流水,并按 `studentId` 关联 `train_student` 返回学员姓名(`s_name`)。 + +| 项 | 内容 | +|---|---| +| HTTP 方法 | `GET` | +| 请求路径 | `/api/board/v1/signageService/checkin/list` | +| 鉴权 | 需要 | + +**请求参数(Query String)** + +| 名称 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `lessonId` | number(long) | 是 | 无 | 课程(课次)ID;非数字时返回参数类型不匹配错误 | + +**请求体**:无。 + +**响应结构**:`data = { total, rows[] }`,`rows[]` 为 `TrainStudentCheckin`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `checkinId` | number | 考勤记录ID | +| `enrollmentId` | number | 参训档案ID | +| `lessonId` | number | 课次ID | +| `studentId` | string | 学员ID(优先取 `train_student_checkin.student_id`,为空取参训档案的 `student_id`) | +| `studentName` | string | 学员姓名(`train_student.s_name`) | +| `planId` | string | 培训班ID | +| `planName` | string | 培训班名称 | +| `className` | string | 班级名称 | +| `courseName` | string | 课程名称 | +| `boardId` | string | 打卡班牌ID | +| `boardSn` | string | 班牌序列号 | +| `boardName` | string | 班牌名称 | +| `checkinWay` | number | 打卡方式:1 人脸/设备,2 刷卡,3 扫码,4 教师代签 | +| `signInTime` | string | 签到时间 `yyyy-MM-dd HH:mm:ss`,未签到为 `null` | +| `signOutTime` | string | 签退时间 `yyyy-MM-dd HH:mm:ss`,未签退为 `null` | +| `attendanceStatus` | number | 考勤结论:0 缺勤,1 正常,2 迟到,3 早退,4 请假 | +| `manualFlag` | number | 是否人工修正:0 否,1 是 | +| `createBy` / `createTime` / `updateBy` / `updateTime` / `remark` | string | 审计字段(时间格式 `yyyy-MM-dd HH:mm:ss`) | + +> `params` 为空时不输出;`searchValue` 始终不输出。 + +**成功示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "total": 1, + "rows": [ + { + "checkinId": 9001, "enrollmentId": 201, "lessonId": 1001, + "studentId": "STU_1", "studentName": "张三", + "planId": "PLAN_A", "planName": "2026年第一期培训班", "className": "一班", + "courseName": "电子班牌数字化实务", + "boardId": "BP001", "boardSn": "BP001", "boardName": "1号班牌", + "checkinWay": 1, "signInTime": "2026-11-02 08:30:00", "signOutTime": null, + "attendanceStatus": 1, "manualFlag": 0, + "createBy": "board_device", "createTime": "2026-11-02 08:30:01", + "updateBy": null, "updateTime": null, "remark": null + } + ] + } +} +``` + +**异常与错误说明** + +| 场景 | 响应 | +|---|---| +| 缺少 `lessonId` | `{"code":500,"msg":"Required request parameter 'lessonId' for method parameter type Long is not present"}` | +| `lessonId` 非数字 | `{"code":500,"msg":"请求参数类型不匹配,参数[lessonId]要求类型为:'java.lang.Long',但输入值为:'abc'"}` | +| 课次无签到记录 | `{"code":200,"msg":"操作成功","data":{"total":0,"rows":[]}}` | + +--- + +### 3.6 查询座位布局 + +**用途**:按班级与教室查询座位布局,**仅读取 `train_class_seat` 单表**,不关联学员、教室、班级表;不校验班级是否删除、学员是否存在。 + +| 项 | 内容 | +|---|---| +| HTTP 方法 | `GET` | +| 请求路径 | `/api/board/v1/signageService/seatLayout` | +| 鉴权 | 需要 | + +**请求参数(Query String)** + +| 名称 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `planId` | string | 是 | 无 | 班级ID,对应 `train_class_seat.plan_id` | +| `classroomId` | string | 是 | 无 | 教室ID,对应 `train_class_seat.classroom_id` | + +**请求体**:无。 + +**响应结构**:`data = { total, rows[] }` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `seatId` | number | 座位主键(`seat_id`) | +| `planId` | string | 班级ID(`plan_id`) | +| `classroomId` | string | 教室ID(`classroom_id`) | +| `rowIndex` | number | 行号,从 1 开始 | +| `colIndex` | number | 列号,从 1 开始 | +| `seatCode` | string | 物理座位标识(`seat_code`) | +| `canceled` | number | `0`=正常,`1`=取消位置(**不做过滤,原样返回**) | +| `studentId` | string | 占用座位学员ID,空座为 `null` | +| `zoneId` | number | 区域ID | +| `deptDetail` | string | 部门 | +| `job` | string | 职务 | + +排序:`row_index, col_index` 升序。 + +**成功示例** + +```json +http://localhost:8096/api/board/v1/signageService/seatLayout?classroomId=afcf21a8bd99488cb19638d285f157a8&planId=f70ec13a651942e8a704ee4cc010c95e + +{ + "msg": "操作成功", + "code": 200, + "data": { + "total": 2, + "rows": [ + { + "canceled": 0, + "classroomId": "afcf21a8bd99488cb19638d285f157a8", + "colIndex": 5, + "deptDetail": null, + "job": null, + "planId": "f70ec13a651942e8a704ee4cc010c95e", + "rowIndex": 2, + "seatCode": "2-5", + "seatId": 29, + "studentId": "c78c425e66384104bf37d150519fc5d3", + "zoneId": null + }, + { + "canceled": 0, + "classroomId": "afcf21a8bd99488cb19638d285f157a8", + "colIndex": 9, + "deptDetail": null, + "job": null, + "planId": "f70ec13a651942e8a704ee4cc010c95e", + "rowIndex": 3, + "seatCode": "3-9", + "seatId": 30, + "studentId": "63d889e8be2c4d61900d09b51b2108a5", + "zoneId": null + } + ] + } +} +``` + +**异常与错误说明** + +| 场景 | 响应 | +|---|---| +| 缺少 `planId` 或 `classroomId` | `code=500`,`msg` 为 Spring 缺参提示 | +| 该班该教室无座位数据 | `{"code":200,"msg":"操作成功","data":{"total":0,"rows":[]}}` | +| 表 `train_class_seat` 不存在 | `code=500`,`msg` 含 `Table 'xxx.train_class_seat' doesn't exist`(需先执行 `sql/train/v2/019_signage.sql`) | + +--- + +## 4. 异常与错误汇总 + +| 类型 | 返回形式 | 说明 | +|---|---|---| +| 未认证 | HTTP 200,`code=401` | Token 缺失、过期、伪造 | +| 路径不存在 | HTTP 404,`code=404`,`msg=接口不存在或未启用` | 路由未匹配 | +| 缺少必填 Query 参数 | HTTP 200,`code=500` | `Required request parameter 'xxx' ... is not present` | +| 参数类型不匹配 | HTTP 200,`code=500` | `请求参数类型不匹配,参数[lessonId]要求类型为:'java.lang.Long',但输入值为:'abc'` | +| 请求方法不支持 | HTTP 200,`code=500` | `Request method 'GET' is not supported` | +| 请求体非 JSON | HTTP 200,`code=500` | `Content-Type 'xxx' is not supported` | +| 控制器内业务拦截 | HTTP 200,`code=500` | 见 3.5 异常表 | +| 考勤业务异常 | HTTP 200,`code=40080/40081/40085/40086/40087` | `AttendanceErrorCode` | + +`AttendanceErrorCode` 常用码: + +| code | 含义 | +|---|---| +| 40080 | 考勤学员与课次所属培训班不一致 | +| 40081 | 课次不存在 | +| 40085 | 学员尚未完成开班报到,禁止录入课次考勤 | +| 40086 | 学员已放弃培训 | +| 40087 | 学员已结训 | + +--- + +## 5. 调用示例 + +```bash +# 登录获取 token(按平台实际登录接口调整) +TOKEN=$(curl -s -X POST 'http://192.168.106.24:8096/login' \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123"}' | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') + +# 3.1 教室信息 +curl -s 'http://192.168.106.24:8096/api/board/v1/signageService/selectClassroom' \ + -H "Authorization: Bearer $TOKEN" + +# 3.2 指定教室当天课程 +curl -s 'http://192.168.106.24:8096/api/board/v1/signageService/coursesInClassroom?classroomId=CR01&beginDate=2026-11-02&endDate=2026-11-02' \ + -H "Authorization: Bearer $TOKEN" + +# 3.3 指定班级在指定教室的座位 +curl -s 'http://192.168.106.24:8096/api/board/v1/signageService/studentInClassroom?classroomId=CR01&classId=PLAN_A' \ + -H "Authorization: Bearer $TOKEN" + +# 3.4 班级学员卡号 +curl -s 'http://192.168.106.24:8096/api/board/v1/signageService/studentCard?classId=PLAN_A' \ + -H "Authorization: Bearer $TOKEN" + +# 3.5 上报签到(JSON 请求体) +curl -s -X POST 'http://192.168.106.24:8096/api/board/v1/signageService/checkin/upload' \ + -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \ + -d '{"studentId":"STU_1","boardId":"BP001","lessonId":1001,"signInTime":"2026-11-02 08:30:00"}' + +# 3.6 本节课签到记录 +curl -s 'http://192.168.106.24:8096/api/board/v1/signageService/checkin/list?lessonId=1001' \ + -H "Authorization: Bearer $TOKEN" + +# 3.7 座位布局 +curl -s 'http://192.168.106.24:8096/api/board/v1/signageService/seatLayout?planId=P2026001&classroomId=CR-001' \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + +## 6. 附录 + +### 6.1 数据来源 + +| 接口 | 主要数据表 | +|---|---| +| 3.1 | `train_classroom_info`、`train_training_site` | +| 3.2 | `train_lesson`、`train_plan_course`、`train_course`、`train_class_plan`、`train_teacher` | +| 3.3 | `train_class_seat`、`train_class_plan`、`train_student` | +| 3.4 | `train_enrollment`、`train_student`、`train_class_plan`、`train_accommodation`、`train_dormitory_info` | +| 3.5 | `train_lesson`、`train_plan_course`、`train_enrollment`、`train_student_checkin` | +| 3.6 | `train_student_checkin`、`train_enrollment`、`train_student`、`train_class_plan`、`train_plan_course`、`train_course`、`train_electronic_board` | +| 3.7 | `train_class_seat`(单表) | + +### 6.2 Swagger + +Swagger UI:`/swagger-ui/index.html`,分组 `电子班牌接口`。 +若 3.5 的 Request body 显示为 query 参数,说明部署的是旧包(当前实现为 `@RequestBody`)。 + +### 6.3 已知约束 + +1. `lessonId` 是**课次ID(`train_lesson.lesson_id`,数值)**,不是课程编码(`c_id`,如 `CR01`);班牌须先调用 3.2 获取 `id`。 +2. 3.5 只接受 JSON 请求体,query/form 写法无效。 +3. 3.5 依赖 `train_student_checkin.student_id` 列;报 `Unknown column 'a.student_id'` 时需执行 `sql/train/v2/020b_add_checkin_student_id_mysql.sql`(MySQL)或 `sql/train/v2/020_add_checkin_student_id.sql`(KingbaseES/PostgreSQL)。 +4. 3.3 与 3.7 的区别:3.3 关联 `train_student` 返回姓名并过滤已删除班级;3.7 严格单表返回,含 `canceled=1` 的座位,不做班级有效性校验。 +5. 所有查询接口的 `total` 为本次返回条数,不是分页总数。 diff --git a/RuoYi-Vue/docs/train_register_api.md b/RuoYi-Vue/docs/train_register_api.md new file mode 100644 index 000000000..6e41e4db1 --- /dev/null +++ b/RuoYi-Vue/docs/train_register_api.md @@ -0,0 +1,1014 @@ +# 报到机接口文档(TrainRegisterApiController) + +> **服务模块**:`ruoyi-admin` +> **控制器**:`com.ruoyi.web.controller.api.TrainRegisterApiController` +> **接口前缀**:`/api/register/v1` +> **文档依据**:控制器源码(截至 2026-11-09),字段口径以对应 VO / 数据库字段为准 +> **适用对象**:报到机(自助终端)厂商、联调与测试人员 + +--- + +## 目录 + +- [1. 通用说明](#1-通用说明) + - [1.1 请求地址](#11-请求地址) + - [1.2 鉴权与请求头](#12-鉴权与请求头) + - [1.3 统一响应结构](#13-统一响应结构) + - [1.4 状态码与错误约定](#14-状态码与错误约定) + - [1.5 时间与命名约定](#15-时间与命名约定) +- [2. 接口总览](#2-接口总览) +- [3. 学员信息模块](#3-学员信息模块) + - [3.1 获取学员信息列表](#31-获取学员信息列表) + - [3.2 获取单个学员详情](#32-获取单个学员详情) + - [3.3 获取学员基础信息](#33-获取学员基础信息) + - [3.4 获取学员关联资源](#34-获取学员关联资源) +- [4. 教师信息模块](#4-教师信息模块) + - [4.1 获取教师信息列表](#41-获取教师信息列表) + - [4.2 获取教师基础信息](#42-获取教师基础信息) +- [5. 班级模块](#5-班级模块) + - [5.1 获取进行中班级的未报到学员名单](#51-获取进行中班级的未报到学员名单) +- [6. 宿舍模块](#6-宿舍模块) + - [6.1 获取宿舍布局信息](#61-获取宿舍布局信息) +- [7. 课表模块](#7-课表模块) + - [7.1 获取课程安排(按日期 + 课程类型)](#71-获取课程安排按日期--课程类型) +- [8. 人脸模块](#8-人脸模块) + - [8.1 上传学员人脸照片](#81-上传学员人脸照片) + - [8.2 上传教师人脸照片](#82-上传教师人脸照片) + - [8.3 上传人脸照片(按员工标识自动分发)](#83-上传人脸照片按员工标识自动分发) +- [9. 报到管理模块](#9-报到管理模块) + - [9.1 报到重置](#91-报到重置) +- [10. 附录](#10-附录) + - [10.1 枚举值速查](#101-枚举值速查) + - [10.2 关联数据表](#102-关联数据表) + +--- + +## 1. 通用说明 + +### 1.1 请求地址 + +| 项 | 值 | +|---|---| +| 协议 | HTTP(生产建议由网关承载 HTTPS) | +| Host | 部署服务器地址与端口,例如 `http://192.168.1.202:8080` | +| Base Path | `/api/register/v1` | +| 完整示例 | `http://192.168.1.202:8080/api/register/v1/student/list` | + +### 1.2 鉴权与请求头 + +- `SecurityConfig` 中已将 `/api/register/v1/**` 配置为 `permitAll`,**当前所有接口均无需登录令牌**(控制器类注释中提到的 Bearer 鉴权已不再生效)。 +- 若生产环境在网关/边缘设备层增加了设备签名(如 HMAC)校验,以网关约定为准,不影响本文档的接口定义。 +- 请求头: + +| 场景 | Header | 说明 | +|---|---|---| +| 所有请求 | `Accept: application/json` | 可选 | +| POST 请求 | `Content-Type: application/json` | **必填**,请求体为 JSON | +| 文件/人脸 | 无 | 人脸图片以 base64 字符串放在 JSON 请求体内,不使用 multipart | + +### 1.3 统一响应结构 + +除个别特殊说明外,全部接口返回 `AjaxResult`: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": {} +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| code | int | 业务状态码,见 1.4 | +| msg | string | 提示信息,成功时为“操作成功”,失败时为错误描述 | +| data | any | 业务数据;失败时可能不存在 | + +> 注意:`data` 为 `null` 时部分序列化配置下该字段可能被省略,客户端应做空值兜底。 + +### 1.4 状态码与错误约定 + +| code | 含义 | 典型场景 | +|---|---|---| +| 200 | 成功 | 正常返回 | +| 500 | 通用业务/参数错误 | 参数缺失、图片解析失败、文件写入失败、业务校验不通过 | +| 601 | 警告 | 预留,本文档接口未使用 | +| 40402 | 学员不存在 | `GET /student/{sId}` 及依赖学员主档的接口(`StudentErrorCode.STUDENT_NOT_FOUND`) | +| 401 | 未认证 | 当前 `/api/register/v1/**` 已放行,正常不会出现 | + +**重要约定**:业务异常由全局异常处理器统一捕获,**HTTP 状态码固定为 200**,调用方必须依据响应体中的 `code` 判断成败,不能只看 HTTP 状态。 + +### 1.5 时间与命名约定 + +- 服务端 Jackson 时区为 `GMT+8`,日期时间统一格式 `yyyy-MM-dd HH:mm:ss`,纯日期格式 `yyyy-MM-dd`。 +- JSON 字段以接口定义为准,部分沿用数据库下划线风格(如 `dorm_room`),属于既有契约,请勿自行转换。 +- 学员标识为 `sId`,教师标识为 `tId`;人脸库中教师人员 ID 统一为 `tId + "_teacher"`。 + +--- + +## 2. 接口总览 + +| 序号 | 模块 | 方法 | 路径 | 用途 | +|---|---|---|---|---| +| 1 | 学员信息 | GET | `/student/list` | 学员主档列表(含报到状态) | +| 2 | 学员信息 | GET | `/student/{sId}` | 单个学员详情(含账号绑定信息) | +| 3 | 学员信息 | GET | `/student/{sId}/base` | 学员基础信息(宿舍/教室/开班/场地) | +| 4 | 学员信息 | GET | `/student/{sId}/resources` | 学员关联资源(在基础信息上增加课程表与课次) | +| 5 | 教师信息 | GET | `/teacher/list` | 教师主档列表(字段结构同学员列表) | +| 6 | 教师信息 | GET | `/teacher/{tId}/base` | 教师基础信息(信息/宿舍/授课课表) | +| 7 | 班级 | GET | `/class/student` | 进行中班级及其未报到学员名单 | +| 8 | 宿舍 | GET | `/dormitory/layout` | 宿舍布局(楼栋→楼层→房间) | +| 9 | 课表 | GET | `/lesson/schedule` | 按日期(可选课程类型)查询课程安排 | +| 10 | 人脸 | POST | `/student/face` | 上传学员人脸并更新头像、补办报到 | +| 11 | 人脸 | POST | `/teacher/face` | 上传教师人脸并更新头像 | +| 12 | 人脸 | POST | `/face` | 按 `employeeId` 后缀自动分发到学员/教师 | +| 13 | 报到管理 | POST | `/report/reset` | 报到重置(解绑人脸 + 清门锁授权 + 复位参训状态) | + +--- + +## 3. 学员信息模块 + +### 3.1 获取学员信息列表 + +**用途**:报到机启动或刷新时拉取全部学员主档,用于本地人员检索、人脸比对名单准备;每条记录额外补入该学员最新一条报到记录的参训状态与报到时间。 + +- **方法 / 路径**:`GET /api/register/v1/student/list` +- **请求参数**:无 +- **请求体**:不适用 + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "sId": "S20260001", + "sName": "张三", + "personNo": "P0001", + "sGender": "男", + "sBirth": "1998-05-12", + "deptName": "供电段", + "cardNo": "C0001", + "sPhone": "13800000001", + "sEmail": "zhangsan@example.com", + "idCardMasked": "210***********1234", + "idCardLast4": "1234", + "sourceType": 1, + "status": 1, + "avatar": "/profile/upload/2026/11/09/xxx.png", + "dutyLevel": null, + "id": 1, + "deptId": 103, + "enrollmentStatus": 0, + "joinTime": null + } + ] +} +``` + +**字段说明(`StudentItemVO`)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| sId | string | 学员唯一标识(主键引用口径) | +| sName | string | 姓名 | +| personNo | string | 人员编号 | +| sGender | string | 性别 | +| sBirth | string | 出生日期 `yyyy-MM-dd` | +| deptName | string | 所属部门/车间名称 | +| cardNo | string | 卡号 | +| sPhone / sEmail | string | 手机号 / 邮箱 | +| idCardMasked / idCardLast4 | string | 脱敏身份证 / 后四位(明文身份证不下发) | +| sourceType | int | 学员来源类型 | +| status | int | 档案状态 | +| avatar | string | 头像可访问路径(相对资源前缀) | +| id / deptId | long | 自增编号 / 部门ID | +| enrollmentStatus | int | 参训状态:0待报到、1已报到、2请假、3放弃、4已完成(取最新一条报名记录) | +| joinTime | datetime | 报到时间(取最新一条报名记录,未报到为 null) | +| extractStatus / extractTime / extractMsg / photoMd5 / featureData / featureMd5 / featureAlgoVersion / featureSize | — | 人脸特征提取的内部扩展字段,报到机通常无需使用,多为 null | + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 数据库异常 | 500 | 系统异常提示 | +| 查询结果为空 | 200 | `data: []`(空数组,非错误) | + +--- + +### 3.2 获取单个学员详情 + +**用途**:按 `sId` 查询单个学员的完整档案,并附带其系统账号绑定状态,供报到机核对身份或人工处理。 + +- **方法 / 路径**:`GET /api/register/v1/student/{sId}` +- **请求参数(Path)** + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| sId | string | 是 | 学员唯一标识 | + +- **请求体**:不适用 + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "sId": "S20260001", + "sName": "张三", + "personNo": "P0001", + "sGender": "男", + "sBirth": "1998-05-12", + "deptName": "供电段", + "cardNo": "C0001", + "sPhone": "13800000001", + "sEmail": "zhangsan@example.com", + "avatar": "/profile/upload/2026/11/09/xxx.png", + "faceRef": null, + "remark": null, + "createBy": "admin", + "createTime": "2026-09-01 09:00:00", + "userId": 105, + "userName": "student01", + "nickName": "张三", + "accountBindingStatus": 1 + } +} +``` + +**字段说明**:`StudentAdminVO = StudentItemVO + StudentDetailVO + 账号绑定字段` + +| 字段 | 类型 | 说明 | +|---|---|---| +| (基础字段) | — | 同 3.1 学员列表字段 | +| faceRef | string | 人脸参考标识 | +| remark | string | 备注 | +| createBy / createTime | string / datetime | 建档人 / 建档时间 | +| userId | long | 绑定的系统用户ID(未绑定为 null) | +| userName | string | 绑定的系统登录账号 | +| nickName | string | 绑定的用户昵称 | +| accountBindingStatus | int | 账号关联状态:0未关联、1已关联 | + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 学员不存在 | 40402 | 学员不存在 | +| `sId` 为空 | 40402 / 500 | 学员不存在(按未命中处理) | + +> 说明:源码中 `detail == null` 分支返回 `data: null`,但底层 `selectStudentDetail` 在未命中时会抛业务异常,因此实际表现为 `code=40402`。 + +--- + +### 3.3 获取学员基础信息 + +**用途**:报到机刷脸/刷卡识别学员后,展示该学员的**个人基础信息、所属开班、当前宿舍床位、上课教室、实训场地**,用于报到确认页。 + +- **方法 / 路径**:`GET /api/register/v1/student/{sId}/base` +- **请求参数(Path)** + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| sId | string | 是 | 学员唯一标识 | + +- **请求体**:不适用 +- **数据来源**:班级优先取该学员最新报到记录(`train_class_report`)中的 `plan_id`,缺失时回落到报名关系(`train_enrollment`)中最新的班级;教室取该班排课课次(`train_lesson`)中**首个带教室**的课次,实训场地取**首个带场地**的课次(两者可能来自不同课次)。 + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "studInfo": { + "sId": "S20260001", + "sName": "张三", + "sPhone": "13800000001", + "sGender": "男", + "deptName": "供电段", + "personNo": "P0001" + }, + "classPlan": { + "planId": "PLAN-2026-01", + "planName": "2026年秋季高铁房建设施高级研修班", + "startTime": "2026-09-15 08:30:00", + "endTime": "2026-09-30 17:30:00" + }, + "dormitory": { + "dormId": "DORM-0101", + "bedNo": "2", + "dormBuilding": "1号楼", + "dormRoom": "101" + }, + "classroom": { + "classRoomId": "CR-001", + "roomLocation": "实训基地A栋201", + "roomName": "第一理论教室" + }, + "trainingSite": { + "siteId": "TS-001", + "siteLocation": "实训基地B栋", + "siteName": "接触网实训场", + "major": "接触网工" + } + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|---|---|---| +| studInfo | object | 学员精简信息:`sId`、`sName`、`sPhone`、`sGender`、`deptName`、`personNo` | +| classPlan | object \| null | 所属开班:`planId`、`planName`、`startTime`、`endTime`;未分配班级时为 null | +| dormitory | object | 宿舍:`dormId`、`bedNo`(有住宿记录时)、`dormBuilding`、`dormRoom`;未分配宿舍时字段可能为 null | +| classroom | object \| null | 上课教室:`classRoomId`、`roomLocation`、`roomName`;该班无教室课次时为 null | +| trainingSite | object \| null | 实训场地:`siteId`、`siteLocation`、`siteName`、`major`;该班无场地课次时为 null | + +> 本接口**不返回** `courses` 与 `lesson` 明细(源码中已注释关闭),需要课表请调用 3.4 或 7.1。 + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 学员不存在 | 40402 | 学员不存在 | +| 学员未分配班级/宿舍 | 200 | 对应字段为 `null`(不是错误) | + +--- + +### 3.4 获取学员关联资源 + +**用途**:在 3.3 的基础上**额外返回该学员所在班级的课程清单与课次明细**,供报到机展示“我的课程 / 我的课表”。 + +- **方法 / 路径**:`GET /api/register/v1/student/{sId}/resources` +- **请求参数(Path)** + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| sId | string | 是 | 学员唯一标识 | + +- **请求体**:不适用 + +**响应示例**(在 3.3 的 `data` 基础上增加两个字段) + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "studInfo": { "sId": "S20260001", "sName": "张三", "sPhone": "13800000001", "sGender": "男", "deptName": "供电段", "personNo": "P0001" }, + "classPlan": { "planId": "PLAN-2026-01", "planName": "2026年秋季高铁房建设施高级研修班", "startTime": "2026-09-15 08:30:00", "endTime": "2026-09-30 17:30:00" }, + "courses": [ + { "cId": "C0001", "cName": "高铁站房巡检" } + ], + "lesson": [ + { "startTime": "2026-09-16 09:00:00", "endTime": "2026-09-16 11:30:00", "planCourseId": 1, "cName": "高铁站房巡检" } + ], + "dormitory": { "dormId": "DORM-0101", "bedNo": "2", "dormBuilding": "1号楼", "dormRoom": "101" }, + "classroom": { "classRoomId": "CR-001", "roomLocation": "实训基地A栋201", "roomName": "第一理论教室" }, + "trainingSite": { "siteId": "TS-001", "siteLocation": "实训基地B栋", "siteName": "接触网实训场", "major": "接触网工" } + } +} +``` + +**新增字段说明** + +| 字段 | 类型 | 说明 | +|---|---|---| +| courses | array | 该班已配置课程:`cId`(课程ID)、`cName`(课程名称);无班级时为 null | +| lesson | array | 该班全部课次:`startTime`、`endTime`、`planCourseId`、`cName`;无课次时为 null 或空数组 | + +> `lesson` 元素**不含**教师与场地字段;如需教师/场地/班牌信息,请使用 7.1 `GET /lesson/schedule`。 + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 学员不存在 | 40402 | 学员不存在 | +| 无班级 | 200 | `classPlan`、`courses`、`lesson` 为 null | + +--- + +## 4. 教师信息模块 + +### 4.1 获取教师信息列表 + +**用途**:拉取全部教师主档,字段结构与学员列表**完全一致**,便于报到机用同一套解析逻辑处理两类人员。 + +- **方法 / 路径**:`GET /api/register/v1/teacher/list` +- **请求参数**:无 +- **请求体**:不适用 + +**字段映射规则** + +| 输出字段 | 取值来源 | +|---|---| +| sId | `tId + "_teacher"`(与人脸库教师 employee_id 口径一致) | +| sName | `train_teacher.t_name` | +| personNo / sGender / sBirth / deptName / cardNo / sPhone / sEmail / status / avatar / id | `train_teacher` 对应字段 | +| enrollmentStatus | `train_teacher.enrollment_status`(0待报到/1已报到/2请假/3放弃/4已完成) | +| joinTime | `train_teacher.join_time`(未报到为 null) | + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "sId": "T101_teacher", + "sName": "李老师", + "personNo": "T0001", + "sGender": "男", + "deptName": "供电教研室", + "sPhone": "13900000001", + "status": 1, + "avatar": "/profile/upload/2026/11/09/teacher.png", + "id": 3, + "enrollmentStatus": 1, + "joinTime": "2026-09-14 09:10:00" + } + ] +} +``` + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 无教师数据 | 200 | `data: []` | + +--- + +### 4.2 获取教师基础信息 + +**用途**:教师刷脸后展示**教师基本信息、当前宿舍、授课课表**。 + +- **方法 / 路径**:`GET /api/register/v1/teacher/{tId}/base` +- **请求参数(Path)** + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| tId | string | 是 | 教师工号;**支持传入 `tId + "_teacher"`**,服务端会自动截去后缀 | + +- **请求体**:不适用 + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "teacherInfo": { + "tId": "T101", + "tName": "李老师", + "tPhone": "13900000001", + "tGender": "男", + "deptName": "供电教研室", + "personNo": "T0001", + "avatar": "/profile/upload/2026/11/09/teacher.png" + }, + "dormitory": { + "dormId": "DORM-0203", + "bedNo": "1", + "dormBuilding": "2号楼", + "dormRoom": "203" + }, + "lesson": [ + { + "lessonId": 101, + "planCourseId": 1, + "courseName": "高铁站房巡检", + "tId": "T101", + "teacherName": "李老师", + "startTime": "2026-09-16 09:00:00", + "endTime": "2026-09-16 11:30:00", + "venueType": 1, + "classRoomId": "CR-001", + "classRoomName": "第一理论教室", + "siteId": null, + "siteName": null, + "venueName": "第一理论教室", + "boardSn": "BOARD-SN-001", + "remark": null + } + ] + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|---|---|---| +| teacherInfo | object \| null | 教师不存在时为 null;字段:`tId`、`tName`、`tPhone`、`tGender`、`deptName`、`personNo`、`avatar` | +| dormitory | object | `dormId`、`bedNo`(有住宿时)、`dormBuilding`、`dormRoom` | +| lesson | array | 该教师全部授课课次(按开始时间升序),元素为 `TimetableLessonItemVO`,字段见下表 | + +**`lesson` 元素字段(TimetableLessonItemVO)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| lessonId | long | 课次ID | +| planCourseId | long | 计划课程实例ID | +| courseName | string | 课程名称 | +| tId | string | 教师工号 | +| teacherName | string | 教师姓名 | +| startTime / endTime | datetime | 课次起止时间 | +| venueType | int | 场地类型:1普通教室、2实训场地 | +| classRoomId / classRoomName | string | 教室ID / 名称(venueType=1 时有效) | +| siteId / siteName | string | 实训场地ID / 名称(venueType=2 时有效) | +| venueName | string | 场地聚合展示名(教室名或场地名) | +| boardSn | string | 电子班牌 SN;未唯一绑定该教室时为 null | +| remark | string | 课次备注 | + +> 该接口不按日期过滤,返回教师全部课次。 + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| tId 不存在 | 200 | `teacherInfo: null`、`lesson: []` | +| 教师无住宿/无课次 | 200 | 对应字段为空对象/空数组 | + +--- + +## 5. 班级模块 + +### 5.1 获取进行中班级的未报到学员名单 + +**用途**:报到机按班级展示**待报到学员**,用于现场点名/呼叫报到。 + +- **方法 / 路径**:`GET /api/register/v1/class/student` +- **请求参数**:无 +- **请求体**:不适用 +- **筛选口径**:班级 `plan_status = 1`(进行中);学员 `enrollment_status = 0`(待报到) + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "planId": "PLAN-2026-01", + "planName": "2026年秋季高铁房建设施高级研修班", + "classId": "CLASS-01", + "className": "房建一班", + "students": [ + { "sId": "S20260001", "sName": "张三", "deptId": 103, "deptName": "供电段" } + ] + } + ] +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|---|---|---| +| planId / planName | string | 培训班ID / 名称 | +| classId / className | string | 班级ID / 名称 | +| students | array | 未报到学员:`sId`、`sName`、`deptId`、`deptName` | + +**容错说明**:单个学员主档缺失时不会中断整体返回,改用报名记录(`train_enrollment`)中的姓名/部门冗余字段兜底,此时 `deptId` 为 null。 + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 无进行中班级 | 200 | `data: []` | + +--- + +## 6. 宿舍模块 + +### 6.1 获取宿舍布局信息 + +**用途**:报到机展示宿舍楼栋—楼层—房间的床位占用情况(如“已分配数/总床位数”)。 + +- **方法 / 路径**:`GET /api/register/v1/dormitory/layout` +- **请求参数**:无 +- **请求体**:不适用 +- **统计口径**:已分配数 = `train_accommodation` 中住宿状态为 0(预分配)、1(已入住)的记录数;总床位 = 房间核定容量 `capacity` + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "1号楼": { + "1层": { + "101": { "id": 10, "dorm_room": "101", "accommodation": "2/4" } + }, + "未标注楼层": { + "102": { "id": 11, "dorm_room": "102", "accommodation": "0/2" } + } + } + } +} +``` + +**字段说明** + +| 层级 | 键 | 说明 | +|---|---|---| +| 一级 | 楼栋名称 | `dorm_building`,为空的数据会被跳过 | +| 二级 | 楼层 | `dorm_floor`,为空时固定为 `未标注楼层` | +| 三级 | 房间号 | `dorm_room` | +| 叶子对象 | id | 房间自增编号 | +| | dorm_room | 房间号(下划线命名,保持既有契约) | +| | accommodation | `已分配床位数/总床位数` 字符串,如 `2/4` | + +> 返回使用 `LinkedHashMap`,键顺序与 SQL 排序(楼栋→楼层→房间号)一致;无宿舍数据时 `data: {}`。 + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 无宿舍数据 | 200 | `data: {}` | + +--- + +## 7. 课表模块 + +### 7.1 获取课程安排(按日期 + 课程类型) + +**用途**:查询指定自然日的课程安排,按**日期为键**分组返回课程名称、课程类型、上课地点、上课教师等信息,供报到机/班牌展示当日课表。 + +- **方法 / 路径**:`GET /api/register/v1/lesson/schedule` +- **请求参数(Query)** + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| startTime | string | 否 | 当天(北京时间) | 课程开始时间,对应 `train_lesson.start_time`。支持 `yyyy-MM-dd`、`yyyy-MM-dd HH:mm:ss`、`yyyy/MM/dd`、ISO-8601(`T` 分隔、`Z` 结尾、`+08:00` 偏移)、10 位秒级 / 13 位毫秒级时间戳;服务端取该时间所在自然日 | +| courseType | string | 否 | 不筛选 | 课程类型,对应 `train_course.c_type`:**`1` = 理论**、**`2` = 实训**。缺省或空串返回当日全部类型;传其它值返回参数错误 | + +- **请求体**:不适用 +- **查询范围**:`train_lesson` 为主表,关联 `train_plan_course`、`train_course`、`train_teacher`、`train_classroom_info`、`train_training_site`、`train_class_plan`;日期区间为 `[当日 00:00:00, 次日 00:00:00)`,由 SQL `STR_TO_DATE` 在库内生成(不受应用 JVM 时区影响);培训班为逻辑删除时不参与过滤(课表属于已发生的教学事实)。 + +**请求示例** + +``` +GET /api/register/v1/lesson/schedule +GET /api/register/v1/lesson/schedule?startTime=2026-11-09 +GET /api/register/v1/lesson/schedule?startTime=2026-11-09&courseType=1 +GET /api/register/v1/lesson/schedule?startTime=2026-11-09T09:00:00&courseType=2 +``` + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "2026-11-09": [ + { + "id": 101, + "name": "高铁站房巡检", + "courseType": "1", + "venueType": 1, + "startTime": "2026-11-09 09:00:00", + "endTime": "2026-11-09 11:30:00", + "teacher": "李老师", + "classroom": "第一理论教室", + "planName": "2026年秋季高铁房建设施高级研修班" + }, + { + "id": 102, + "name": "接触网实操", + "courseType": "2", + "venueType": 2, + "startTime": "2026-11-09 14:00:00", + "endTime": "2026-11-09 16:30:00", + "teacher": "王老师", + "classroom": "接触网实训场", + "planName": "2026年秋季高铁房建设施高级研修班" + } + ] + } +} +``` + +**`data` 结构说明** + +| 层级 | 说明 | +|---|---| +| 键 | 日期字符串 `yyyy-MM-dd`(按课次开始时间的北京时间自然日分组,升序) | +| 值 | 该日课程数组,按 `startTime`、`id` 升序 | + +**课程元素字段(`CourseScheduleItemVO`)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | long | 课次ID(`train_lesson.lesson_id`) | +| name | string | 课程名称(`train_course.c_name`) | +| courseType | string | 课程类型,与 `train_course.c_type` 一致:`1` 理论、`2` 实训;课程缺失或为空时为 null | +| venueType | int | 课次场地类型:1 普通教室、2 实训场地 | +| startTime / endTime | datetime | 课次起止时间 `yyyy-MM-dd HH:mm:ss` | +| teacher | string | 上课教师姓名(`train_teacher.t_name`) | +| classroom | string | 上课地点:`venueType=1` 取教室名称,`venueType=2` 取实训场地名称 | +| planName | string | 所属培训班名称(`train_class_plan.plan_name`) | + +**错误场景** + +| 场景 | code | msg | 说明 | +|---|---|---|---| +| startTime 格式无法识别 | 500 | `startTime 格式不正确,支持 yyyy-MM-dd / yyyy-MM-dd HH:mm:ss / ISO-8601,示例:2026-11-09` | 例如传入 `2026年11月9日` | +| courseType 非 1/2 | 500 | `courseType 仅支持 1(理论)或 2(实训)` | 例如传入 `理论`、`3` | +| 当日无课程 | 200 | `data: {}` | 空对象,非错误 | +| 该类型当日无课程 | 200 | `data: {}` | 加了 `courseType` 后无匹配 | + +> 服务端会输出日志 `lessonSchedule 课程安排查询 day=..., courseType=..., rows=...`,排障时可直接用 `rows` 区分“SQL 未命中”与“命中但未返回”。 + +--- + +## 8. 人脸模块 + +### 8.1 上传学员人脸照片 + +**用途**:报到机采集学员人脸 → 转 PNG 落盘 → 更新 `train_student.avatar` → 同步到人脸识别设备 → 回写报到状态并办理门锁入住(若已分配宿舍)。 + +- **方法 / 路径**:`POST /api/register/v1/student/face` +- **请求头**:`Content-Type: application/json` + +**请求体** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| sId | string | 是 | 学员唯一标识,同时作为人脸库 `employee_id` | +| faceImage | string | 是 | base64 人脸图片,可带 `data:image/png;base64,` 前缀;服务端统一转码为 PNG | + +```json +{ + "sId": "S20260001", + "faceImage": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..." +} +``` + +**业务副作用**(按顺序) + +1. 图片解码并保存到 `profile/upload/yyyy/MM/dd/{uuid}.png`,路径写入 `train_student.avatar` +2. 调用人脸设备新增/更新人员(`employee_id = sId`) +3. 回查该学员报名记录,将**第一条**记录的参训状态置为 1(已报到)并记录报到时间 +4. 若存在有效住宿记录,调用门禁入住接口(`/openapi/iot/stay/checkin`)下发入住授权 + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "sId": "S20260001", + "avatar": "/profile/upload/2026/11/09/9f1c....png", + "faceSync": "success", + "lockCheckin": { "code": 200, "msg": "入住成功" }, + "lockAuthId": "", + "joinTime": "2026-11-09 09:12:33" + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|---|---|---| +| sId | string | 学员标识 | +| avatar | string | 头像可访问路径 | +| faceSync | string | 人脸设备同步结果:`success`、`failed`、`skipped` | +| faceMsg | string | 仅 `faceSync=failed` 时出现,失败原因(设备无响应/异常信息) | +| lockCheckin | object | 门锁入住接口返回结果;无有效住宿时不返回 | +| lockAuthId | string | 预留字段,固定空串 | +| joinTime | datetime | 实际报到时间;无住宿时可能不返回 | +| (设备扩展字段) | any | 设备成功时其返回的 `data` 会被并入本层 | + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 缺少 sId 或 faceImage | 500 | 参数缺失:sId 与 faceImage 均为必填 | +| sId 为空串 | 500 | sId 不能为空 | +| 图片非法/无法解码 | 500 | 图片解析失败:{具体原因} | +| 目录创建失败 | 500 | 创建存储目录失败 | +| 文件写入失败 | 500 | 图片写入失败:{具体原因} | +| 学员不存在或更新 0 行 | 500 | 学员不存在或更新失败 | +| 设备同步失败 | 200 | 仍返回成功,`faceSync=failed` + `faceMsg`(头像已保存) | + +--- + +### 8.2 上传教师人脸照片 + +**用途**:教师人脸采集与入库,更新 `train_teacher.avatar`,并同步人脸设备(`employee_id = tId + "_teacher"`)。 + +- **方法 / 路径**:`POST /api/register/v1/teacher/face` +- **请求头**:`Content-Type: application/json` + +**请求体** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| teacherId | string | 是 | 教师工号(`train_teacher.t_id`),**不要**带 `_teacher` 后缀 | +| faceImage | string | 是 | base64 人脸图片,可带 data URI 前缀 | + +```json +{ + "teacherId": "T101", + "faceImage": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..." +} +``` + +**业务差异**:教师无开班报名记录,**不做报到状态更新**;仅当教师已有有效住宿且已生成门锁流水号时,补发一次入住授权(有效期 15 天)。 + +**响应示例** + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "tId": "T101", + "avatar": "/profile/upload/2026/11/09/ab34....png", + "faceSync": "success", + "lockCheckin": { "code": 200, "msg": "入住成功" }, + "lockAuthId": "" + } +} +``` + +**字段说明**:`tId`、`avatar`、`faceSync`、`faceMsg`(失败时)、`lockCheckin`(有住宿且门锁可用时)、`lockAuthId`(固定空串)、设备扩展字段合并规则同上。 + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 缺少 teacherId 或 faceImage | 500 | 参数缺失:teacherId 与 faceImage 均为必填 | +| teacherId 为空串 | 500 | teacherId 不能为空 | +| 图片非法/无法解码 | 500 | 图片解析失败:{具体原因} | +| 目录/文件写入失败 | 500 | 创建存储目录失败 / 图片写入失败:{具体原因} | +| 教师不存在或更新 0 行 | 500 | 教师不存在或更新失败 | +| 设备同步失败 | 200 | `faceSync=failed` + `faceMsg`(头像已保存) | + +--- + +### 8.3 上传人脸照片(按员工标识自动分发) + +**用途**:统一入口。按 `employeeId` 后缀自动路由,设备侧无需区分学员/教师接口。 + +- **方法 / 路径**:`POST /api/register/v1/face` +- **请求头**:`Content-Type: application/json` + +**请求体** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| employeeId | string | 是 | 人脸库人员ID。以 `_teacher` 结尾 → 视为教师(去掉后缀即 `tId`);其它 → 视为学员 `sId` | +| faceImage | string | 是 | base64 人脸图片 | + +```json +{ + "employeeId": "T101_teacher", + "faceImage": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..." +} +``` + +**分发规则** + +| 判定 | 路由 | 说明 | +|---|---|---| +| `employeeId` 以 `_teacher` 结尾 | → 8.2 教师人脸上传 | `tId = employeeId` 去掉后缀;后缀匹配区分大小写、不做 trim | +| 其它 | → 8.1 学员人脸上传 | `sId = employeeId` | + +**响应**:直接透传被调用接口(8.1 / 8.2)的响应体,结构与之一致。 + +**错误场景** + +| 场景 | code | msg | +|---|---|---| +| 缺少 employeeId 或 faceImage | 500 | 参数缺失:employeeId 与 faceImage 均为必填 | +| employeeId 为空串 | 500 | employeeId 不能为空 | +| employeeId 仅为 `_teacher`(工号为空) | 500 | employeeId 非法:教师工号(tId)不能为空 | +| 后续流程失败 | 500 / 200 | 同 8.1 / 8.2 | + +--- + +## 9. 报到管理模块 + +### 9.1 报到重置 + +**用途**:误点他人信息导致人脸错绑时的补救接口。按顺序执行:**解绑人脸 → 清除门锁入住授权 → 参训状态复位为 0(待报到) → 清空学员头像**。宿舍分配记录保留,不做退宿。 + +- **方法 / 路径**:`POST /api/register/v1/report/reset` +- **请求头**:`Content-Type: application/x-www-form-urlencoded`(参数通过 Query 传递,无请求体) + +**请求参数(Query)** + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| planId | string | 是 | 培训班ID(`train_enrollment.plan_id`) | +| studentId | string | 是 | 学员ID(`train_student.s_id`) | + +**请求示例** + +``` +POST /api/register/v1/report/reset?planId=PLAN-2026-01&studentId=S20260001 +``` + +**响应示例(成功)** + +```json +{ + "code": 200, + "msg": "报到重置成功", + "data": { + "planId": "PLAN-2026-01", + "studentId": "S20260001", + "enrollmentId": 900001, + "previousStatus": 1, + "faceUnbind": "success", + "lockCheckout": "success", + "lockApiBizNo": "LOCK20261109001", + "currentStatus": 0, + "alreadyReset": false, + "accommodationKept": true, + "avatarCleared": true, + "avatarSId": "S20260001" + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|---|---|---| +| planId / studentId | string | 入参回显 | +| enrollmentId | long | 参训记录ID | +| previousStatus | int | 重置前参训状态 | +| faceUnbind | string | `success`(人脸解绑成功) | +| lockCheckout | string | `success`,或 `skipped: 无有效住宿记录` / `skipped: 缺少门锁业务流水号与宿舍信息` | +| lockApiBizNo | string | 门锁业务流水号(仅实际操作门锁时返回) | +| currentStatus | int | 重置后状态,固定 0 | +| alreadyReset | boolean | 重置前是否已是 0(重复调用标识) | +| accommodationKept | boolean | 是否保留了住宿分配 | +| avatarCleared | boolean | 学员头像是否已清空 | +| avatarSId | string | 被清空头像的学员标识 | + +**分步失败场景(返回 code=500,`msg` 指明失败步骤)** + +| 场景 | msg | +|---|---| +| 缺少 planId / studentId 参数 | Spring 参数缺失提示(`Required request parameter 'planId' ...`) | +| planId 或 studentId 为空串 | `planId 和 studentId 不能为空` | +| 报名记录不存在 | `未找到学员[{studentId}]在班级[{planId}]的参训记录` | +| 学员已放弃培训(状态3) | `学员已放弃培训,不允许重置报到状态` | +| 学员已结训(状态4) | `学员已结训,不允许重置报到状态` | +| 步骤1 人脸解绑失败 | `步骤1 人脸解绑失败:{原因};参训状态未变更` | +| 步骤2 门锁响应解析失败 | `步骤2 门锁授权清除响应解析失败:{原因};人脸已解绑,参训状态未变更` | +| 步骤2 门锁清除失败 | `步骤2 门锁授权清除失败:{原因};人脸已解绑,参训状态未变更` | +| 步骤3 状态复位失败 | `步骤3 参训状态重置失败;人脸与门锁授权已解除,请人工核对后重试` | +| 学员档案缺失(前 3 步已完成) | `报到重置已完成,但未找到有效学员档案[{sId}],avatar 未清空`(失败响应中仍带 `data`) | +| 清空头像异常(前 3 步已完成) | `报到重置已完成,但清空学员头像失败:{原因}`(失败响应中仍带 `data`) | + +> 该接口为**非幂等敏感操作**,重复调用会对已复位的数据再次执行解绑/清库动作,建议设备端加二次确认。 + +--- + +## 10. 附录 + +### 10.1 枚举值速查 + +| 字段 | 取值 | +|---|---| +| 参训/报到状态 `enrollmentStatus` | 0 待报到、1 已报到、2 请假、3 放弃、4 已完成 | +| 场地类型 `venueType` | 1 普通教室、2 实训场地 | +| 课程类型 `courseType`(`train_course.c_type`) | 1 理论、2 实训 | +| 账号关联状态 `accountBindingStatus` | 0 未关联、1 已关联 | +| 人脸同步状态 `faceSync` | success / failed / skipped | +| 房间占用 `accommodation` | 字符串 `已分配数/总床位数`,如 `2/4` | + +### 10.2 关联数据表 + +| 模块 | 主要数据表 | +|---|---| +| 学员 | `train_student`、`train_enrollment` | +| 教师 | `train_teacher` | +| 开班 / 课程 | `train_class_plan`、`train_plan_course`、`train_course` | +| 排课 | `train_lesson`、`train_classroom_info`、`train_training_site`、`train_electronic_board` | +| 宿舍 | `train_dormitory_info`、`train_accommodation` | +| 报到 | `train_class_report` | +| 人脸/门锁 | 人脸识别服务、`/openapi/iot/stay/checkin` 门禁开放接口 | + +--- + +*文档结束。接口变更请同步更新本文件,并在提交说明中标注版本。* diff --git a/RuoYi-Vue/pom.xml b/RuoYi-Vue/pom.xml index 44f4d2b57..a5a04056a 100644 --- a/RuoYi-Vue/pom.xml +++ b/RuoYi-Vue/pom.xml @@ -16,7 +16,7 @@ 3.9.2 UTF-8 UTF-8 - 21 + 17 4.1.0 4.1.0 1.2.28 @@ -193,6 +193,12 @@ ${ruoyi.version} + + com.ruoyi + ruoyi-device + ${ruoyi.version} + + com.ruoyi @@ -212,6 +218,7 @@ ruoyi-common ruoyi-iot ruoyi-train + ruoyi-device pom @@ -223,7 +230,8 @@ 3.13.0 true - ${java.version} + ${java.version} + ${java.version} ${project.build.sourceEncoding} diff --git a/RuoYi-Vue/ruoyi-admin/pom.xml b/RuoYi-Vue/ruoyi-admin/pom.xml index f898356f5..c1b205933 100644 --- a/RuoYi-Vue/ruoyi-admin/pom.xml +++ b/RuoYi-Vue/ruoyi-admin/pom.xml @@ -61,6 +61,12 @@ 3.9.2 + + com.ruoyi + ruoyi-device + 3.9.2 + + org.springframework.boot spring-boot-starter-test @@ -79,6 +85,11 @@ junit-vintage-engine test + + com.mysql + mysql-connector-j + runtime + diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/OpenApiResult.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/OpenApiResult.java new file mode 100644 index 000000000..e4c1d7bb1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/OpenApiResult.java @@ -0,0 +1,57 @@ +package com.ruoyi.web.controller.api; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 开放接口统一响应(code 0 表示成功,与《外部实训系统数据接口规范》一致) + * + *

注意:不能复用 {@link com.ruoyi.common.core.domain.AjaxResult},其成功码为 200。

+ * + * @author ruoyi + * @date 2026-11-05 + */ +public class OpenApiResult extends LinkedHashMap +{ + private static final long serialVersionUID = 1L; + + /** 成功 */ + public static final int SUCCESS = 0; + + /** 服务端内部错误 */ + public static final int INTERNAL_ERROR = 4000; + + public static OpenApiResult success(Object data, String reqId) + { + OpenApiResult result = new OpenApiResult(); + result.put("code", SUCCESS); + result.put("msg", "success"); + result.put("data", data); + result.put("reqId", reqId); + result.put("ts", System.currentTimeMillis()); + return result; + } + + public static OpenApiResult error(int code, String msg, String reqId) + { + OpenApiResult result = new OpenApiResult(); + result.put("code", code); + result.put("msg", msg); + result.put("data", null); + result.put("reqId", reqId); + result.put("ts", System.currentTimeMillis()); + return result; + } + + /** 分页数据包装:{ total, pageNo, pageSize, list } */ + public static Map page(long total, int pageNo, int pageSize, Object list) + { + Map data = new HashMap<>(4); + data.put("total", total); + data.put("pageNo", pageNo); + data.put("pageSize", pageSize); + data.put("list", list); + return data; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainBoardApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainBoardApiController.java new file mode 100644 index 000000000..ed1f7c5b3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainBoardApiController.java @@ -0,0 +1,253 @@ +package com.ruoyi.web.controller.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainPlanCourse; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.dto.AttendanceSignInDTO; +import com.ruoyi.train.domain.vo.SignageClassroomVO; +import com.ruoyi.train.domain.vo.SignageCourseVO; +import com.ruoyi.train.domain.vo.SignageSeatVO; +import com.ruoyi.train.domain.vo.SignageStudentCardVO; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainSignageMapper; +import com.ruoyi.train.service.AttendanceAppService; +import com.ruoyi.train.service.ITrainEnrollmentService; +import com.ruoyi.train.service.ITrainPlanCourseService; +import com.ruoyi.train.service.ITrainStudentCheckinService; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 电子班牌对外接口(班牌后台调用)。 + * + * BasePath: /api/board/v1/signageService + * 安全约束:走 RuoYi Bearer 鉴权(教学数据 Token)。 + * 说明:本控制器仅负责 HTTP 暴露;取数逻辑见 ruoyi-train 的 TrainSignageMapper(仅新增,未改动既有方法)。 + * 文档:学员系统取数与安卓下发接口文档(/signageService/* 前四个外部接口)。 + * + * 注:文档字段 id / classId / classroomId / courseId / teacherId / stuId / studentId + * 约定为 Long,但本系统对应表以业务编码为主键(字符串),故这些字段返回字符串; + * 若班牌后台严格要求 Long,可后续为相关表补充数值 id 列并在 Mapper 中映射。 + * + * @author ruoyi + * @date 2026-10-26 + */ +@RestController +@Tag(name = "电子班牌接口", description = "从服务器查询教室信息、指定教室当天课程、指定班级在指定教室的座位、查询班级学员卡号,走 RuoYi Bearer 鉴权。") +@RequestMapping("/api/board/v1") +public class TrainBoardApiController +{ + @Autowired + private TrainSignageMapper signageMapper; + + @Autowired + private TrainLessonMapper trainLessonMapper; + + @Autowired + private ITrainPlanCourseService trainPlanCourseService; + + @Autowired + private ITrainEnrollmentService trainEnrollmentService; + + @Autowired + private ITrainStudentCheckinService trainStudentCheckinService; + + @Autowired + private AttendanceAppService attendanceAppService; + + /** + * 1. 查询教室信息 + */ + @Operation(summary = "查询教室信息", description = "返回全部教学教室列表,供班牌后台定位终端绑定教室。") + @GetMapping("/signageService/selectClassroom") + public AjaxResult selectClassroom() + { + return AjaxResult.success(signageMapper.selectSignageClassrooms()); + } + + /** + * 2. 查询指定教室当天课程 + */ + @Operation(summary = "查询指定教室当天课程", description = "按教室与日期范围返回排课课程列表(beginDate=endDate=当天)。") + @GetMapping("/signageService/coursesInClassroom") + public AjaxResult coursesInClassroom( + @Parameter(description = "教学教室ID") @RequestParam String classroomId, + @Parameter(description = "开始日期 yyyy-MM-dd") @RequestParam String beginDate, + @Parameter(description = "结束日期 yyyy-MM-dd") @RequestParam String endDate) + { + return wrapRows(signageMapper.selectSignageCourses(classroomId, beginDate, endDate)); + } + + /** + * 3. 查询指定班级在指定教室的座位 + */ + @Operation(summary = "查询指定班级在指定教室的座位", description = "按教室与班级返回座位网格。") + @GetMapping("/signageService/studentInClassroom") + public AjaxResult studentInClassroom( + @Parameter(description = "教学教室ID") @RequestParam String classroomId, + @Parameter(description = "班级ID") @RequestParam String classId) + { + return wrapRows(signageMapper.selectSignageSeats(classroomId, classId)); + } + + /** + * 4. 查询班级学员卡号(含班级信息,用于刷卡校验;文档名为 studentCard) + */ + @Operation(summary = "查询班级学员卡号", description = "按班级返回学员卡号与班级信息,用于刷卡校验。") + @GetMapping("/signageService/studentCard") + public AjaxResult studentCard( + @Parameter(description = "班级ID") @RequestParam String classId) + { + return wrapRows(signageMapper.selectSignageCards(classId)); + } + + /** + * 5. 班牌上报签到数据(新增接口,未改动既有接口)。 + *

+ * 请求方式:POST,Content-Type 必须为 application/json,参数全部通过 JSON 请求体传递。 + * 请求体:{@link BoardCheckinUploadRequest} + * (studentId / boardId / lessonId / signInTime),不含任何文件上传或表单字段。 + * enrollmentId 由 studentId 在课次所属培训班内查询 train_enrollment 得到; + * 落库复用 AttendanceAppService.signIn(含幂等、参训生命周期与计划一致性校验)。 + */ + @Operation(summary = "上报签到数据", description = "班牌上报学员签到(JSON 请求体):按 studentId 查询 train_enrollment 得到 enrollmentId 后写入 train_student_checkin,需 Bearer 鉴权。") + @PostMapping("/signageService/checkin/upload") + public AjaxResult uploadCheckin( + @Parameter(description = "签到上报请求体(JSON)") @RequestBody BoardCheckinUploadRequest req) + { + if (req == null) + { + return AjaxResult.error("请求体不能为空"); + } + if (req.studentId == null || req.studentId.trim().isEmpty()) + { + return AjaxResult.error("studentId 不能为空"); + } + if (req.lessonId == null) + { + return AjaxResult.error("lessonId 不能为空"); + } + String sid = req.studentId.trim(); + Long lessonId = req.lessonId; + String boardId = req.boardId; + + Date signIn = null; + if (req.signInTime != null && !req.signInTime.trim().isEmpty()) + { + signIn = DateUtils.parseDate(req.signInTime.trim()); + if (signIn == null) + { + return AjaxResult.error("signInTime 格式不正确,应为 yyyy-MM-dd HH:mm:ss"); + } + } + + // 课次 -> 计划课程 -> 培训班,用于定位该学员在本班的参训档案 + TrainLesson lesson = trainLessonMapper.selectTrainLessonById(lessonId); + if (lesson == null) + { + return AjaxResult.error("课程(课次)不存在: " + lessonId); + } + TrainPlanCourse planCourse = trainPlanCourseService.selectTrainPlanCourseById(lesson.getPlanCourseId()); + if (planCourse == null || planCourse.getPlanId() == null || planCourse.getPlanId().trim().isEmpty()) + { + return AjaxResult.error("课程(课次)[" + lessonId + "]未关联有效的培训计划"); + } + + TrainEnrollment enrollment = trainEnrollmentService + .selectTrainEnrollmentByPlanIdAndStudentId(planCourse.getPlanId(), sid); + if (enrollment == null) + { + return AjaxResult.error("未找到学员[" + sid + "]在班级[" + planCourse.getPlanId() + "]的参训记录"); + } + + AttendanceSignInDTO dto = new AttendanceSignInDTO(); + dto.setEnrollmentId(enrollment.getEnrollmentId()); + dto.setLessonId(lessonId); + dto.setStudentId(sid); + dto.setBoardId(boardId != null && !boardId.trim().isEmpty() ? boardId.trim() : null); + dto.setCheckinWay(1); // 班牌设备上报,按设备打卡(1)记 + dto.setSignInTime(signIn); + + TrainStudentCheckin checkin = attendanceAppService.signIn(dto, SecurityUtils.getUsername()); + + Map data = new HashMap<>(); + data.put("checkinId", checkin.getCheckinId()); + data.put("studentId", checkin.getStudentId() != null ? checkin.getStudentId() : sid); + data.put("enrollmentId", checkin.getEnrollmentId()); + data.put("lessonId", checkin.getLessonId()); + data.put("boardId", checkin.getBoardId()); + data.put("signInTime", checkin.getSignInTime() != null + ? DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, checkin.getSignInTime()) : null); + return AjaxResult.success(data); + } + + /** + * 6. 查询本节课签到记录(新增接口,未改动既有接口)。 + *

+ * 按 lessonId 返回 train_student_checkin 签到流水;每条记录的 studentName + * 由 Mapper 按 studentId 关联 train_student.s_name 一并返回。 + */ + @Operation(summary = "查询本节课签到记录", description = "按 lessonId 返回 train_student_checkin 签到流水,并按 studentId 关联 train_student 返回学员姓名 sName。") + @GetMapping("/signageService/checkin/list") + public AjaxResult checkinList( + @Parameter(description = "课程(课次)ID") @RequestParam Long lessonId) + { + List rows = trainStudentCheckinService.selectCheckinsByLessonId(lessonId); + return wrapRows(rows); + } + + /** + * 7. 查询班级座位布局(新增接口,仅读取 train_class_seat 单表,不影响其它接口)。 + *

+ * 入参:planId(班级ID,对应 train_class_seat.plan_id)、classroomId(教室ID,对应 train_class_seat.classroom_id)。 + */ + @Operation(summary = "查询座位布局", description = "按班级ID(planId)与教室ID(classroomId)查询 train_class_seat 座位列表,仅读取该单表,需 Bearer 鉴权。") + @GetMapping("/signageService/seatLayout") + public AjaxResult seatLayout( + @Parameter(description = "班级ID(train_class_seat.plan_id)") @RequestParam String planId, + @Parameter(description = "教室ID(train_class_seat.classroom_id)") @RequestParam String classroomId) + { + return wrapRows(signageMapper.selectSeatLayout(planId, classroomId)); + } + + private AjaxResult wrapRows(List rows) + { + Map data = new HashMap<>(); + data.put("total", rows != null ? rows.size() : 0); + data.put("rows", rows != null ? rows : Collections.emptyList()); + return AjaxResult.success(data); + } + + /** + * 班牌签到上报请求体(JSON,Content-Type: application/json)。 + *

+ * 示例:{"studentId":"STU_1","boardId":"BP001","lessonId":1001,"signInTime":"2026-11-02 08:30:00"} + */ + public static class BoardCheckinUploadRequest + { + /** 学员ID,必填 */ + public String studentId; + + /** 门牌(班牌)ID,可选 */ + public String boardId; + + /** 课程(课次)ID,必填,对应 train_student_checkin.lesson_id */ + public Long lessonId; + + /** 签到时间 yyyy-MM-dd HH:mm:ss,可选,缺省取当前时间 */ + public String signInTime; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainCommonApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainCommonApiController.java new file mode 100644 index 000000000..1a57c9eee --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainCommonApiController.java @@ -0,0 +1,643 @@ +package com.ruoyi.web.controller.api; + +import com.ruoyi.common.constant.CacheConstants; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.device.domain.*; +import com.ruoyi.device.service.*; +import com.ruoyi.framework.web.service.TokenService; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.train.domain.query.PlanQuery; +import com.ruoyi.train.domain.query.StudentQuery; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.*; + +import java.io.*; +import java.math.BigDecimal; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import com.ruoyi.train.domain.TrainScoreInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.vo.PlanCourseItemVO; +import com.ruoyi.train.domain.vo.PlanItemVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainStudentCheckinMapper; +import com.ruoyi.train.service.ITrainClassPlanService; +import com.ruoyi.train.service.ITrainEnrollmentService; +import com.ruoyi.train.service.ITrainPlanCourseService; +import com.ruoyi.train.service.ITrainScoreInfoService; +import com.ruoyi.train.service.ITrainStudentService; + +//import com.ruoyi.system.domain.SysUser; + +/** + * 通用对外接口(设备端调用) + * + * BasePath: /api/common/v1 + * 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。 + * 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。 + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@Tag(name = "iot通用接口", description = "从服务器获取学员信息、班级信息、班级课程信息、iot设备上传考勤记录、上传实训成绩 走 RuoYi Bearer 鉴权。") +@RequestMapping("/api/common/v1") +public class TrainCommonApiController extends BaseController +{ + @Autowired + private ITrainIotDeviceService iotDeviceService; + @Autowired + private ITrainUserSessionService userSessionService; + @Autowired + private ITrainReadingDurationLogService readingLogService; + @Autowired + private ITrainReadingCurrentProgressService currentProgressService; + @Autowired + private ITrainReadingAlternateProgressService alternateProgressService; + @Autowired + private ITrainBookSnapshotService bookSnapshotService; + @Autowired + private ITrainBookDownloadRecordService bookDownloadRecordService; + @Autowired + private TokenService tokenService; + @Autowired + private ISysUserService sysUserService; + + @Autowired + private ITrainStudentService studentService; + @Autowired + private ITrainClassPlanService classPlanService; + @Autowired + private ITrainPlanCourseService planCourseService; + @Autowired + private ITrainScoreInfoService scoreInfoService; + @Autowired + private TrainStudentCheckinMapper checkinMapper; + @Autowired + private ITrainEnrollmentService enrollmentService; + @Autowired + private TrainLessonMapper lessonMapper; + + @Value("${ruoyi.profile:}") + private String profileRoot; + // ================= 通用数据接口(走 RuoYi Bearer 鉴权;Token 来源于标准 /login) ================= + + /** + * 1. 获取学员信息列表 + */ + @Operation(summary = "获取学员信息列表", description = "从服务器获取学员主档列表,需 Bearer 鉴权。") + @GetMapping("/student/list") + public AjaxResult studentList() + { + List list = studentService.selectStudentList(new StudentQuery()); + return AjaxResult.success(list); + } + + /** + * 2. 获取班级(培训计划)信息列表 + */ + @Operation(summary = "获取班级信息列表", description = "从服务器获取开班计划(班级)列表,需 Bearer 鉴权。") + @GetMapping("/plan/list") + public AjaxResult planList() + { + List list = classPlanService.selectTrainClassPlanList(new PlanQuery()); + return AjaxResult.success(list); + } + + /** + * 3. 获取指定班级的本期课程列表 + */ + @Operation(summary = "获取班级课程信息", description = "根据班级ID(planId)返回该班级配置的本期课程列表,需 Bearer 鉴权。") + @GetMapping("/course/list") + public AjaxResult courseList(@Parameter(description = "班级ID(planId)") @RequestParam String classId) + { + if (classId == null || classId.trim().isEmpty()) + { + return AjaxResult.error("classId 不能为空"); + } + List list = planCourseService.selectPlanCourseListByPlanId(classId.trim()); + return AjaxResult.success(list); + } + + /** + * 4. 获取单个学员所在班级(培训计划)信息 + */ + @Operation(summary = "获取学员所在班级信息", description = "根据 studentId 返回该学员参训的所有班级(培训计划)列表,需 Bearer 鉴权。") + @GetMapping("/student/class/info") + public AjaxResult studentClassInfo(@Parameter(description = "学员ID") @RequestParam String studentId) + { + if (studentId == null || studentId.trim().isEmpty()) + { + return AjaxResult.error("studentId 不能为空"); + } + List list = classPlanService.selectClassPlanListByStudentId(studentId.trim()); + return AjaxResult.success(list); + } + + /** + * 13. IoT 设备上传考勤记录 + */ + @Operation(summary = "上传考勤记录", description = "IoT 设备上报学员考勤(签到/签退),落库 train_student_checkin,需 Bearer 鉴权。") + @PostMapping("/checkin/upload") + public AjaxResult checkinUpload(@RequestBody CheckinUploadRequest req) + { + if (req.planId == null || req.planId.trim().isEmpty() || req.studentId == null || req.studentId.trim().isEmpty()) + { + return AjaxResult.error("planId 与 studentId 不能为空"); + } + String planId = req.planId.trim(); + String studentId = req.studentId.trim(); + + // V2 约束:train_student_checkin.enrollment_id / lesson_id 均为 NOT NULL,必须由 plan/student 解析后写入 + TrainEnrollment enrollment = enrollmentService.selectTrainEnrollmentByPlanIdAndStudentId(planId, studentId); + if (enrollment == null) + { + return AjaxResult.error("未找到学员[" + studentId + "]在班级[" + planId + "]的参训记录,请先建立参训档案"); + } + Long lessonId = (req.courseName != null && !req.courseName.trim().isEmpty()) + ? lessonMapper.selectFirstLessonIdByPlanAndCourse(planId, req.courseName.trim()) + : null; + if (lessonId == null) + { + return AjaxResult.error("未找到班级[" + planId + "]课程[" + req.courseName + "]对应的课次(lesson),请先完成排课"); + } + + TrainStudentCheckin checkin = new TrainStudentCheckin(); + checkin.setPlanId(planId); + checkin.setPlanName(req.planName); + // student_id 以参训档案为准落库,避免设备上报的 studentId 与 enrollment 不一致 + checkin.setStudentId(enrollment.getStudentId()); + checkin.setStudentName(req.studentName); + checkin.setCourseName(req.courseName); + checkin.setEnrollmentId(enrollment.getEnrollmentId()); + checkin.setLessonId(lessonId); + // V2 必填列补默认值,避免 insert 因 NOT NULL 且无 DEFAULT 生效(显式 NULL 会覆盖列 DEFAULT)而失败 + checkin.setCheckinWay(req.checkinWay != null ? req.checkinWay : 1); + checkin.setSignInTime(toDate(req.signInTime)); + checkin.setSignOutTime(toDate(req.signOutTime)); + checkin.setAttendanceStatus(req.attendanceStatus != null ? req.attendanceStatus : 1); + checkin.setManualFlag(req.manualFlag != null ? req.manualFlag : 0); + checkin.setBoardSn(req.boardSn); + checkin.setBoardName(req.boardName); + checkin.setCreateBy(getUsername()); + checkin.setCreateTime(new Date()); + int rows = checkinMapper.insertTrainStudentCheckin(checkin); + CheckinUploadData data = new CheckinUploadData(); + data.checkinId = checkin.getCheckinId(); + data.rows = rows; + return AjaxResult.success(data); + } + + /** + * 14. 上传实训成绩 + */ + @Operation(summary = "上传实训成绩", description = "IoT 设备上报实训/课程/考核成绩,落库 train_student_score,需 Bearer 鉴权。") + @PostMapping("/score/upload") + public AjaxResult scoreUpload(@RequestBody ScoreUploadRequest req) + { + TrainScoreInfo score = new TrainScoreInfo(); + score.setPlanId(req.planId); + score.setcId(req.cId); + score.setStudentId(req.studentId); + score.setCourseScore(req.courseScore); + score.setTrainScore(req.trainScore); + score.setExamScore(req.examScore); + // 外部接口自动采集 + score.setSourceType(req.sourceType != null ? req.sourceType : 3); + score.setSourceSystem(req.sourceSystem != null ? req.sourceSystem : "IOT"); + score.setImportTime(toDate(req.importTime)); + score.setCreateBy(getUsername()); + score.setCreateTime(new Date()); + int rows = scoreInfoService.insertTrainScoreInfo(score); + ScoreUploadData data = new ScoreUploadData(); + data.scoreId = score.getScoreId(); + data.rows = rows; + return AjaxResult.success(data); + } + + private Date toDate(Long epochMillis) + { + return epochMillis != null ? new Date(epochMillis) : null; + } + + private void writeJsonError(HttpServletResponse response, String msg) throws IOException + { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":500,\"msg\":\"" + msg + "\",\"data\":null}"); + } + + private void streamFile(String url, String md5, Long fileSize, String title, HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setHeader("Accept-Ranges", "bytes"); + if (md5 != null && !md5.isEmpty()) + { + response.setHeader("Content-MD5", md5); + } + // 加上 HTTP 头,保证文件以正确类型、可下载方式返回(浏览器可访问) + response.setContentType(guessContentType(url)); + String fileName = (title != null && !title.isEmpty() ? title : "book") + ".pdf"; + try + { + String enc = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + response.setHeader("Content-Disposition", + "attachment; filename=\"download.pdf\"; filename*=UTF-8''" + enc); + } + catch (Exception ignored) {} + + boolean isHttp = url.startsWith("http://") || url.startsWith("https://"); + InputStream in = null; + long total = -1; + if (isHttp) + { + // 完整 http(s) 远程地址(如 OSS):直接拉取 + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", "ruoyi-device"); + in = conn.getInputStream(); + String cl = conn.getHeaderField("Content-Length"); + if (cl != null && !cl.isEmpty()) + { + try { total = Long.parseLong(cl); } catch (NumberFormatException ignored) {} + } + } + else if (url.startsWith("/")) + { + // 相对路径:直接按本地资源读取,避免后端自请求(否则会触发 /error 鉴权 401) + if (url.startsWith("/profile/")) + { + // /profile/** 指向 RuoYi 上传根目录(ruoyi.profile),与静态资源映射一致 + File f = new File(profileRoot, url.substring("/profile".length())); + if (!f.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = f.length(); + in = new FileInputStream(f); + } + else + { + // 其他相对路径(测试用 /books/...)从 classpath 静态资源读取 + ClassPathResource res = new ClassPathResource("static" + url); + if (!res.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = res.contentLength(); + in = res.getInputStream(); + } + } + else + { + // 绝对本地路径(遗留用法) + File f = new File(url); + if (!f.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = f.length(); + in = new FileInputStream(f); + } + + try + { + String range = request.getHeader("Range"); + long start = 0; + long end = total - 1; + boolean hasRange = false; + if (range != null && range.startsWith("bytes=")) + { + String[] parts = range.substring(6).split("-"); + try { start = Long.parseLong(parts[0]); } catch (NumberFormatException ignored) {} + if (parts.length > 1 && !parts[1].isEmpty()) + { + try { end = Long.parseLong(parts[1]); } catch (NumberFormatException ignored) {} + } + hasRange = true; + } + if (hasRange) + { + if (end < 0 || end >= total) + { + end = total - 1; + } + if (start > end) + { + start = end; + } + long skipped = in.skip(start); + while (skipped < start) + { + long s = in.skip(start - skipped); + if (s <= 0) + { + break; + } + skipped += s; + } + long len = end - start + 1; + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + total); + response.setContentLengthLong(len); + copy(in, response.getOutputStream(), len); + } + else + { + response.setStatus(HttpServletResponse.SC_OK); + if (total > 0) + { + response.setContentLengthLong(total); + } + copy(in, response.getOutputStream(), -1); + } + } + finally + { + if (in != null) + { + in.close(); + } + } + } + + private String guessContentType(String url) + { + if (url != null && url.toLowerCase().endsWith(".pdf")) + { + return "application/pdf"; + } + return "application/octet-stream"; + } + + private void copy(InputStream in, OutputStream out, long limit) throws IOException + { + byte[] buf = new byte[8192]; + long remaining = limit; + int n; + while ((n = in.read(buf)) != -1) + { + if (limit > 0) + { + if (remaining <= 0) + { + break; + } + int toWrite = (int) Math.min(n, remaining); + out.write(buf, 0, toWrite); + remaining -= toWrite; + } + else + { + out.write(buf, 0, n); + } + } + out.flush(); + } + + // ================= 请求/响应 DTO(内嵌,避免新增文件) ================= + + public static class LoginRequest + { + public String deviceId; + public Long userId; + public String firmware; + public String appVersion; + public NetInfo netInfo; + public Long timestamp; + public String nonce; + } + + public static class NetInfo + { + public String ip; + public String mac; + public Integer rssi; + } + + public static class LogoutRequest + { + public String deviceId; + public String reason; + public Long timestamp; + public String nonce; + } + + public static class HeartbeatRequest + { + public String deviceId; + public String status; + public Metrics metrics; + public String configVersion; + public String personVersion; + public Long timestamp; + public String nonce; + } + + public static class Metrics + { + public Integer cpu; + public Integer mem; + public Long diskFree; + } + + public static class ReadingRecordRequest + { + public String deviceId; + public String recordId; + public String bookId; + public String bookVersion; + public String mode; + public String recordType; + public Long startTime; + public Long endTime; + public Integer durationSec; + public Integer pageFrom; + public Integer pageTo; + public BigDecimal progress; + } + + public static class ReadingRecordBatchRequest + { + public String deviceId; + public String batchId; + public Long timestamp; + public String nonce; + public List records; + } + + public static class DownloadAckRequest + { + public String deviceId; + public String bookId; + public String bookVersion; + public String fileMd5; + public String result; + public String errMsg; + public Long timestamp; + public String nonce; + } + + public static class LoginData + { + public String accessToken; + public String sessionId; + public Integer heartbeatInterval; + } + + public static class LogoutData + { + public Boolean result; + public Boolean tokenRevoked; + } + + public static class HeartbeatData + { + public Integer nextInterval; + public List commands; + } + + public static class RecordData + { + public String result; + public Integer studyMinutes; + public Integer totalMinutes; + } + + public static class ReadingRecordItemResult + { + public String recordId; + public String result; + + public ReadingRecordItemResult(String recordId, String result) + { + this.recordId = recordId; + this.result = result; + } + } + + public static class BatchData + { + public List itemResults; + } + + public static class MainProgress + { + public String mode; + public Integer pageNo; + public Integer sentenceIndex; + public BigDecimal progress; + public Long updateTime; + } + + public static class AlternateProgress + { + public String mode; + public String bookVersion; + public Integer pageNo; + public Integer sentenceIndex; + public BigDecimal progress; + public Long createTime; + } + + public static class BookRecord + { + public String bookId; + public String bookVersion; + public MainProgress mainProgress; + public List alternateProgressList; + } + + public static class ProgressData + { + public Long userId; + public long totalReadSeconds; + public long totalAudioSeconds; + public List bookRecords; + } + + public static class BookItem + { + public String bookId; + public String bookTitle; + public String bookCategory; + public String bookSummary; + public String bookVersion; + public Long fileSize; + public String fileMd5; + public Boolean isOnShelf; + public Long publishTime; + } + + public static class BookListData + { + public int total; + public List list; + } + + public static class DownloadAckData + { + public Boolean deviceReady; + public Long recordId; + } + + public static class CheckinUploadRequest + { + public String planId; + public String planName; + public String studentId; + public String studentName; + public String courseName; + public Integer checkinWay; + public Long signInTime; + public Long signOutTime; + public Integer attendanceStatus; + /** 是否人工签到:0=设备自动(默认),1=教师代签/人工录入 */ + public Integer manualFlag; + public String boardSn; + public String boardName; + } + + public static class CheckinUploadData + { + public Long checkinId; + public int rows; + } + + public static class ScoreUploadRequest + { + public String planId; + public String cId; + public String studentId; + public BigDecimal courseScore; + public BigDecimal trainScore; + public BigDecimal examScore; + public Integer sourceType; + public String sourceSystem; + public Long importTime; + } + + public static class ScoreUploadData + { + public Long scoreId; + public int rows; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainFaceApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainFaceApiController.java new file mode 100644 index 000000000..582b25007 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainFaceApiController.java @@ -0,0 +1,424 @@ +package com.ruoyi.web.controller.api; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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 org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.spring.SpringUtils; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.query.StudentQuery; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.train.service.ITrainTeacherService; + +/** + * 人脸接口(调用人脸识别端API) + * + * BasePath: /api/face/v1 + * 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。 + * 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。 + * + * 本控制器封装了文档中的 5 个人脸识别设备接口(4.3 / 4.4 / 4.5 / 4.6 / 4.7)。 + * 设备地址前缀由 application.yml 的 face.api-base-url 配置;face.enabled=false 时仅返回模拟响应,不实际调用设备。 + * + * @author ruoyi + * @date 2026-10-22 + */ +@RestController +@Tag(name = "face接口", description = "调用人脸识别终端API为终端提供人脸管理服务:为员工新增一张人脸、删除指定员工人脸数据、批量删除员工人脸数据、按文件夹批量导入人脸数据、清空全部人脸数据。") +@RequestMapping("/api/face/v1") +public class TrainFaceApiController { + + @Value("${face.api-base-url:http://127.0.0.1:9000}") + private String faceApiBaseUrl; + + @Value("${face.enabled:false}") + private boolean faceEnabled; + + /** 开放接口密钥,写入请求头 X-API-Key */ + @Value("${iot.face.face_api_key:}") + private String apiKey; + + @Autowired + private ITrainStudentService studentService; + + private final RestTemplate restTemplate = new RestTemplate(); + + private static final Logger log = LoggerFactory.getLogger(TrainFaceApiController.class); + + /** + * 4.3 POST /employees 新增员工人脸 + * 按 employee_id(即学员 sId)查询 train_student,取头像转 Base64 data URL 后调用设备 /employees。 + */ + @Operation(summary = "新增员工人脸", description = "按 employee_id(即学员 sId) 查询 train_student,取头像转 Base64 data URL 后调用人脸识别设备 /employees。") + @PostMapping("/employees") + public AjaxResult addEmployeeFace(@RequestBody Map payload) + { + log.info("addEmployeeFace 收到请求 payload={}", payload); + String employeeId = (String) payload.get("employee_id"); + if (employeeId == null || employeeId.isEmpty()) + { + return AjaxResult.error("参数 employee_id 不能为空"); + } + // 分支判定:先判断 "_teacher" 后缀,命中则进入教师分支;否则沿用原学员逻辑 + final String teacherSuffix = "_teacher"; + String name; + String avatar; + String entityLabel; + if (employeeId.endsWith(teacherSuffix)) + { + // 教师分支:仅移除末尾 "_teacher" 后缀得到 tId(不做 trim 或其他裁剪) + String tId = employeeId.substring(0, employeeId.length() - teacherSuffix.length()); + TrainTeacher teacher = SpringUtils.getBean(ITrainTeacherService.class).selectTrainTeacherByTId(tId); + if (teacher == null) + { + // 教师记录不存在:不更新头像,直接返回 + return AjaxResult.error("教师不存在:" + tId); + } + name = teacher.gettName(); + avatar = teacher.getAvatar(); + entityLabel = "教师"; + } + else + { + StudentDetailVO detail = studentService.selectStudentDetail(employeeId); + if (detail == null) + { + return AjaxResult.error("学员不存在:" + employeeId); + } + name = detail.getsName(); + avatar = detail.getAvatar(); + entityLabel = "学员"; + } + String imageBase64 = toImageDataUrl(avatar); + if (imageBase64 == null) + { + return AjaxResult.error(entityLabel + "头像缺失或无法读取:" + employeeId); + } + Map body = new HashMap<>(4); + body.put("employee_id", employeeId); + body.put("name", name); + body.put("image_base64", imageBase64); + //body.put("image_path", avatar); + + log.info("addEmployeeFace 收到请求 body={}", body); + + if (!faceEnabled) + { + return AjaxResult.success("人脸服务未启用,返回模拟响应", buildMockAddEmployee(employeeId, name)); + } + + return doPost("/employees", body); + } + + /** + * 4.4 DELETE /employees/{employee_id} 删除指定员工 + */ + @Operation(summary = "删除指定员工人脸数据", description = "调用人脸识别设备 DELETE /employees/{employee_id}。") + @DeleteMapping("/employees/{employee_id}") + public AjaxResult deleteEmployee(@PathVariable("employee_id") String employeeId) + { + log.info("deleteEmployee 收到请求 body={}", employeeId); + + if (!faceEnabled) + { + Map data = new HashMap<>(2); + data.put("deleted_count", 1); + data.put("employee_id", employeeId); + return AjaxResult.success("人脸服务未启用,返回模拟响应", data); + } + + return doDelete("/employees/" + employeeId); + } + + /** + * 4.5 POST /employees/delete-batch 批量删除 + */ + @Operation(summary = "批量删除员工人脸数据", description = "调用人脸识别设备 POST /employees/delete-batch。") + @PostMapping("/employees/delete-batch") + public AjaxResult deleteEmployeesBatch(@RequestBody Map payload) + { + @SuppressWarnings("unchecked") + List employeeIds = (List) payload.get("employee_ids"); + log.info("deleteEmployeesBatch 收到请求 body={}", payload); + + if (!faceEnabled) + { + Map data = new HashMap<>(4); + int n = employeeIds != null ? employeeIds.size() : 0; + data.put("requested_count", n); + data.put("deleted_employee_count", n); + data.put("deleted_face_count", n); + data.put("deleted", new ArrayList<>()); + data.put("not_found", new ArrayList<>()); + return AjaxResult.success("人脸服务未启用,返回模拟响应", data); + } + return doPost("/employees/delete-batch", payload); + } + + /** + * 4.6 POST /employees/import-batch 批量导入 + * 从 train_student 取 sId/sName/avatar,按 employee_id_name_index.jpg 规则整理成本地目录后调用设备 import-batch。 + */ + @Operation(summary = "批量导入人脸(按文件夹)", description = "从 train_student 取 sId/sName/avatar,按 employee_id_name_index.jpg 规则整理成本地目录后调用设备 import-batch。") + @PostMapping("/employees/import-batch") + public AjaxResult importEmployeesBatch() + { + Map organized = organizeFaceImportFolder(); + String folderPath = (String) organized.get("folder_path"); + @SuppressWarnings("unchecked") + List> imported = (List>) organized.get("imported"); + log.info("importEmployeesBatch 收到请求 body={}", imported); + + if (!faceEnabled) + { + Map data = new HashMap<>(4); + data.put("folder_path", folderPath); + data.put("imported_count", imported != null ? imported.size() : 0); + data.put("failed_count", 0); + data.put("imported", imported != null ? imported : new ArrayList<>()); + data.put("failed", new ArrayList<>()); + return AjaxResult.success("人脸服务未启用,返回模拟响应", data); + } + Map body = new HashMap<>(2); + body.put("folder_path", folderPath); + return doPost("/employees/import-batch", body); + } + + /** + * 4.7 DELETE /employees 清空全部 + */ + @Operation(summary = "清空全部人脸数据", description = "调用人脸识别设备 DELETE /employees。") + @DeleteMapping("/employees") + public AjaxResult clearAllEmployees() + { + log.info("clearAllEmployees 收到请求"); + + if (!faceEnabled) + { + Map data = new HashMap<>(1); + data.put("deleted_count", 7); + return AjaxResult.success("人脸服务未启用,返回模拟响应", data); + } + return doDelete("/employees"); + } + + // ===================== 内部:真实调用人脸设备 ===================== + + private AjaxResult doPost(String path, Map body) + { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-API-Key", apiKey); + HttpEntity> entity = new HttpEntity<>(body, headers); + ResponseEntity resp = restTemplate.exchange(faceApiBaseUrl + path, HttpMethod.POST, entity, Map.class); + if (resp.getStatusCode().is2xxSuccessful()) { + log.info("doPost 收到请求 resp={}", resp.getBody()); + return AjaxResult.success(resp.getBody()); + } + log.info("doPost 收到请求 resp={}", resp.getStatusCode()); + return AjaxResult.error("人脸设备返回非成功状态:" + resp.getStatusCode()); + } catch (HttpStatusCodeException ex) { + log.info("doPost 收到请求 resp={}", "人脸设备返回 " + ex.getStatusCode() + ":" +ex.getResponseBodyAsString()); + return AjaxResult.error("人脸设备返回 " + ex.getStatusCode() + ":" + ex.getResponseBodyAsString()); + } catch (Exception e) { + log.info("doPost 收到请求 resp={}", "调用人脸识别设备失败:" + e.getMessage()); + return AjaxResult.error("调用人脸识别设备失败:" + e.getMessage()); + } + } + + private AjaxResult doDelete(String path) + { + try + { + log.info("doDelete 收到请求 faceApiBaseUrl_path={}", faceApiBaseUrl + path); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-API-Key", apiKey); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity resp = restTemplate.exchange(faceApiBaseUrl + path, HttpMethod.DELETE, entity, Map.class); + log.info("doDelete 响应 status={} body={}", resp.getStatusCode(), resp.getBody()); + if (resp.getStatusCode().is2xxSuccessful()) + { + return AjaxResult.success(resp.getBody()); + } + return AjaxResult.error("人脸设备返回非成功状态:" + resp.getStatusCode()); + } + catch (HttpStatusCodeException ex) + { + log.error("doDelete 人脸设备失败 status={} body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex); + return AjaxResult.error("人脸设备返回 " + ex.getStatusCode() + ":" + ex.getResponseBodyAsString()); + } + catch (Exception e) + { + log.error("doDelete 调用人脸识别设备失败", e); + return AjaxResult.error("调用人脸识别设备失败:" + e.getMessage()); + } + } + + // ===================== 内部:头像转 Base64 data URL ===================== + + private String toImageDataUrl(String avatar) + { + if (avatar == null || avatar.isEmpty()) + { + return null; + } + byte[] bytes = readImageBytes(avatar); + if (bytes == null) + { + return null; + } + String mime = detectMime(avatar); + return "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(bytes); + } + + private byte[] readImageBytes(String avatar) + { + try + { + if (avatar.startsWith("http://") || avatar.startsWith("https://")) + { + return restTemplate.getForObject(avatar, byte[].class); + } + // 与文件下载接口同源:使用 RuoYiConfig.getProfile()(如 ./uploadPath) + Path profile = Paths.get(RuoYiConfig.getProfile()).toAbsolutePath().normalize(); + String path = avatar; + // 去掉虚拟前缀 /profile,兼容可能带网关/上下文前缀(如 /dev-api/profile) + int idx = path.indexOf("/profile"); + if (idx >= 0) + { + path = path.substring(idx + "/profile".length()); + } + // 去掉剥离后开头的分隔符,避免 Path.resolve 将其识别为绝对路径而丢失 profile 前缀 + while (path.startsWith("/") || path.startsWith(File.separator)) + { + path = path.substring(1); + } + Path file; + if (new File(path).isAbsolute()) + { + file = Paths.get(path); + } + else + { + file = profile.resolve(path).normalize(); + } + File f = file.toFile(); + if (!f.exists()) + { + return null; + } + return Files.readAllBytes(f.toPath()); + } + catch (Exception e) + { + return null; + } + } + + private static String detectMime(String avatar) + { + String lower = avatar.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".bmp")) return "image/bmp"; + if (lower.endsWith(".webp")) return "image/webp"; + return "image/jpeg"; + } + + // ===================== 内部:从 train_student 整理导入目录 ===================== + + private Map organizeFaceImportFolder() + { + Map result = new HashMap<>(4); + List> imported = new ArrayList<>(); + try + { + String folderPath = RuoYiConfig.getProfile() + File.separator + "face_import_" + System.currentTimeMillis(); + File folder = new File(folderPath); + folder.mkdirs(); + List students = studentService.selectStudentList(new StudentQuery()); + if (students != null) + { + for (StudentItemVO s : students) + { + String sId = s.getsId(); + String sName = s.getsName(); + String avatar = s.getAvatar(); + byte[] bytes = readImageBytes(avatar); + if (bytes == null || sId == null) + { + continue; + } + String fileName = sId + "_" + (sName != null ? sName : "") + "_1.jpg"; + File out = new File(folder, fileName); + Files.write(out.toPath(), bytes); + Map item = new HashMap<>(5); + item.put("file", fileName); + item.put("employee_id", sId); + item.put("name", sName); + item.put("index", "1"); + item.put("image_path", "data/known_faces_photos/" + sId + "_1.jpg"); + item.put("feature_dimension", 512); + imported.add(item); + } + } + result.put("folder_path", folderPath); + result.put("imported", imported); + } + catch (Exception e) + { + result.put("folder_path", null); + result.put("imported", imported); + } + return result; + } + + // ===================== 内部:模拟 4.3 响应 ===================== + + private Map buildMockAddEmployee(String employeeId, String name) + { + Map data = new HashMap<>(8); + data.put("employee_id", employeeId); + data.put("name", name); + data.put("face_id", 1); + data.put("face_count", 1); + data.put("bbox", new int[] { 198, 152, 512, 553 }); + data.put("confidence", 0.912345); + data.put("image_path", "data/known_faces_photos/" + employeeId + "_1.jpg"); + data.put("feature_dimension", 512); + data.put("feature_vector", new ArrayList<>()); + return data; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainLockApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainLockApiController.java new file mode 100644 index 000000000..0fc9c3e93 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainLockApiController.java @@ -0,0 +1,534 @@ +package com.ruoyi.web.controller.api; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.service.ITrainAccommodationService; + +/** + * 房间门禁开放服务代理控制器。 + * + *

对接外部「对外开放房间门禁服务」(入住 / 退房 / 续住 / 开锁记录查询): + * 基础地址取 {@code iot.face.lock_endpoint},每个请求均携带请求头 + * {@code X-API-Key}(值取 {@code iot.face.api_key}),并按文档的请求方法、路径、 + * 参数与统一返回结构({@code code/msg/requestId/data})进行转发与映射。 + * + *

并发与异常:使用线程安全的 {@link HttpClient} 单例,连接超时 3s、请求超时 10s; + * 配置缺失、网络超时/中断、响应为空均转换为文档约定的错误结构返回,不向调用方抛异常。 + */ +@RestController +@RequestMapping("/api/lock/v1") +public class TrainLockApiController +{ + private static final Logger log = LoggerFactory.getLogger(TrainLockApiController.class); + + /** 用于构造与解析统一 JSON 结构 */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 统一响应内容类型(UTF-8) */ + private static final MediaType JSON_UTF8 = MediaType.parseMediaType("application/json;charset=UTF-8"); + + /** 对外开放房间门禁服务的接口相对路径(见设计文档 §4、§5、§6、§7) */ + private static final String PATH_CHECKIN = "/openapi/iot/stay/checkin"; + private static final String PATH_CHECKOUT = "/openapi/iot/stay/checkout"; + private static final String PATH_EXTEND = "/openapi/iot/stay/extend"; + private static final String PATH_UNLOCK_RECORDS = "/openapi/iot/stay/unlock-records"; + private static final String PATH_TEST = "/openapi/iot/ping"; + /** 接口基础地址,例如 http://192.168.1.203:8080 */ + @Value("${iot.face.lock_endpoint:}") + private String lockEndpoint; + + /** 开放接口密钥,写入请求头 X-API-Key */ + @Value("${iot.face.api_key:}") + private String apiKey; + + @Value("${iot.face.avatar_profile:}") + private String avatarProfile; + + /** 住宿安排服务:门锁回调结果回写 train_accommodation.lock_result */ + @Autowired + private ITrainAccommodationService accommodationService; + + /** 回调落库操作人(无登录上下文,固定标识为系统) */ + private static final String CALLBACK_OPERATOR = "system"; + + /** 线程安全的 HTTP 客户端,连接超时 3s(请求级超时见 forward) */ + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + + // ==================== 接口一:办理入住 ==================== + + /** + * 办理入住(POST /openapi/iot/stay/checkin)。 + * 请求体字段(bizNo/externalPersonId/personName/roomNo/beginTime/endTime/photoUrl/photoBase64 等) + * 原样透传至门禁开放服务。 + */ + @PostMapping("/stay/checkin") + public ResponseEntity checkin(@RequestBody(required = false) Map body) + { + // photoUrl 为 /profile/ 开头的相对路径时,拼接配置的头像服务主机,补全为完整地址 + if (body != null && body.get("photoUrl") instanceof String) + { + String photoUrl = (String) body.get("photoUrl"); + if (photoUrl.startsWith("/profile/")) + { + try + { + String base = avatarProfile; + if (base != null && !base.trim().isEmpty()) + { + base = base.trim(); + if (!base.startsWith("http://") && !base.startsWith("https://")) + { + base = "http://" + base; + } + if (base.endsWith("/")) + { + base = base.substring(0, base.length() - 1); + } + body.put("photoUrl", base + photoUrl); + } + } + catch (Exception e) + { + log.warn("[办理入住] 读取 iot.face.avatar_profile 失败,使用原始 photoUrl:{}", e.getMessage()); + } + } + } + log.info("[办理入住] 处理后请求体:{}", body); + return forward("POST", PATH_CHECKIN, body, "办理入住"); + } + + // ==================== 接口二:办理退房 ==================== + + /** + * 办理退房(POST /openapi/iot/stay/checkout)。 + * 请求体支持 bizNo,或 personNo/externalPersonId + roomNo。 + */ + @PostMapping("/stay/checkout") + public ResponseEntity checkout(@RequestBody(required = false) Map body) + { + return forward("POST", PATH_CHECKOUT, body, "办理退房"); + } + + // ==================== 接口三:办理续住 ==================== + + /** + * 办理续住(POST /openapi/iot/stay/extend)。 + * 请求体支持 bizNo 或人员标识 + roomNo,及新的 endTime。 + */ + @PostMapping("/stay/extend") + public ResponseEntity extend(@RequestBody(required = false) Map body) + { + return forward("POST", PATH_EXTEND, body, "办理续住"); + } + + // ==================== 接口四:查询开锁记录 ==================== + + /** + * 查询开锁记录(GET /openapi/iot/stay/unlock-records)。 + * 按文档 §7.2 映射查询参数;空值参数不下发。 + */ + @GetMapping("/stay/unlock-records") + public ResponseEntity unlockRecords( + @RequestParam(value = "roomNo", required = false) String roomNo, + @RequestParam(value = "personNo", required = false) String personNo, + @RequestParam(value = "externalPersonId", required = false) String externalPersonId, + @RequestParam(value = "beginTime", required = false) String beginTime, + @RequestParam(value = "endTime", required = false) String endTime, + @RequestParam(value = "openType", required = false) String openType, + @RequestParam(value = "pageNum", required = false) Integer pageNum, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + @RequestParam(value = "requestId", required = false) String requestId) + { + StringBuilder query = new StringBuilder(); + appendParam(query, "roomNo", roomNo); + appendParam(query, "personNo", personNo); + appendParam(query, "externalPersonId", externalPersonId); + appendParam(query, "beginTime", beginTime); + appendParam(query, "endTime", endTime); + appendParam(query, "openType", openType); + appendParam(query, "pageNum", pageNum); + appendParam(query, "pageSize", pageSize); + appendParam(query, "requestId", requestId); + String path = query.length() == 0 ? PATH_UNLOCK_RECORDS : PATH_UNLOCK_RECORDS + "?" + query; + return forward("GET", path, null, "查询开锁记录"); + } + @GetMapping("/stay/ping") + public ResponseEntity pingRecords() + { + StringBuilder query = new StringBuilder(); + String path = query.length() == 0 ? PATH_TEST : PATH_TEST + "?" + query; + return forward("GET", path, null, "测试接口"); + } + + // ==================== 接口五:结果回调接收 ==================== + + /** + * 接收门禁开放服务的结果回调(文档 §8.1)。 + * + *

回调报文为 JSON(Content-Type: application/json;charset=UTF-8),字段与文档 §8.1 一致: + * eventId(String,事件唯一标识)、eventType(String,STAY_CHECKIN_RESULT / STAY_CHECKOUT_RESULT / + * STAY_EXTEND_RESULT)、revision(Integer,同一 bizNo 的结果版本,从 1 递增)、bizNo(String,业务序列号)、 + * stayNo(String)、personNo(String)、externalPersonId(String)、roomNo(String)、roomCode(String)、 + * deviceSn(String)、result(String,SUCCESS / FAILED)、message(String,失败原因,成功可为空)、 + * occurredAt(String,yyyy-MM-dd HH:mm:ss)。 + * + *

入参校验(不通过按文档 §3.2 返回 40001,HTTP 400):报文为空;eventId/eventType/bizNo/result 缺失; + * eventType 不在三类事件内;result 不是 SUCCESS/FAILED;revision 缺失或非正整数。 + * + *

校验通过后按 bizNo + eventType + result 回写 train_accommodation.lock_result: + * STAY_CHECKIN_RESULT + SUCCESS → 2(已授权);STAY_CHECKOUT_RESULT + SUCCESS → 3(已过期); + * 其它组合不改变该字段。bizNo 无对应住宿记录时只记录日志并按接收成功应答;记录存在但更新未生效/异常时 + * 返回 50002(HTTP 500),交由对方按回调重试策略重试,保证最终一致。 + * + *

处理完成按文档 §8.1 的接收判定返回 HTTP 200 + {"code":0,...}(对方据此认为接收成功、不再重试); + * 去重与重试策略由回调发起方(本系统 P2 回调任务表 lock_callback_task)负责。 + * + *

路由与鉴权沿用本控制器既有约定:同属 {@code /api/lock/v1} 前缀,未额外声明权限注解, + * 与 checkin/checkout/extend/unlock-records 保持一致的鉴权策略。 + */ + @PostMapping("/stay/callback") + public ResponseEntity stayCallback(@RequestBody(required = false) Map body) + { + if (body == null || body.isEmpty()) + { + log.warn("[结果回调] 报文为空"); + return error(40001, "回调报文不能为空"); + } + Object eventIdObj = body.get("eventId"); + Object eventTypeObj = body.get("eventType"); + Object bizNoObj = body.get("bizNo"); + Object resultObj = body.get("result"); + String eventId = eventIdObj == null ? "" : eventIdObj.toString().trim(); + String eventType = eventTypeObj == null ? "" : eventTypeObj.toString().trim(); + String bizNo = bizNoObj == null ? "" : bizNoObj.toString().trim(); + String result = resultObj == null ? "" : resultObj.toString().trim(); + if (eventId.isEmpty() || eventType.isEmpty() || bizNo.isEmpty() || result.isEmpty()) + { + log.warn("[结果回调] 必填参数缺失,报文:{}", body); + return error(40001, "参数缺失:eventId、eventType、bizNo、result 均为必填"); + } + if (!"STAY_CHECKIN_RESULT".equals(eventType) && !"STAY_CHECKOUT_RESULT".equals(eventType) + && !"STAY_EXTEND_RESULT".equals(eventType)) + { + log.warn("[结果回调] eventType 非法:{}", eventType); + return error(40001, "参数 eventType 非法:" + eventType + + ",可选值 STAY_CHECKIN_RESULT / STAY_CHECKOUT_RESULT / STAY_EXTEND_RESULT"); + } + if (!"SUCCESS".equals(result) && !"FAILED".equals(result)) + { + log.warn("[结果回调] result 非法:{}", result); + return error(40001, "参数 result 非法:" + result + ",可选值 SUCCESS / FAILED"); + } + Object revisionObj = body.get("revision"); + int revision; + try + { + revision = revisionObj == null ? -1 : Integer.parseInt(revisionObj.toString().trim()); + } + catch (NumberFormatException formatError) + { + revision = -1; + } + if (revision <= 0) + { + log.warn("[结果回调] revision 非法:{}", revisionObj); + return error(40001, "参数 revision 非法:必须为正整数"); + } + + log.info("[结果回调] eventId={}, eventType={}, revision={}, bizNo={}, stayNo={}, personNo={}, " + + "externalPersonId={}, roomNo={}, roomCode={}, deviceSn={}, result={}, message={}, occurredAt={}", + eventId, eventType, revision, bizNo, body.get("stayNo"), body.get("personNo"), + body.get("externalPersonId"), body.get("roomNo"), body.get("roomCode"), body.get("deviceSn"), + result, body.get("message"), body.get("occurredAt")); + + // 回调结果回写住宿记录:入住成功 -> 2(已授权);退房成功 -> 3(已过期); + // 其它组合(result=FAILED、STAY_EXTEND_RESULT 等)不改变 lock_result。 + Integer targetLockResult = null; + if ("SUCCESS".equals(result)) + { + if ("STAY_CHECKIN_RESULT".equals(eventType)) + { + targetLockResult = TrainAccommodation.LOCK_RESULT_AUTHORIZED; + } + else if ("STAY_CHECKOUT_RESULT".equals(eventType)) + { + targetLockResult = TrainAccommodation.LOCK_RESULT_EXPIRED; + } + } + + String lockUpdate = "SKIPPED"; + if (targetLockResult == null) + { + log.info("[结果回调] eventType/result 不触发 lockResult 更新,bizNo={}, eventType={}, result={}", + bizNo, eventType, result); + } + else + { + // bizNo 为门锁开放接口业务流水号,仅当确实存在对应住宿记录时才更新,避免无记录时误报成功 + TrainAccommodation accommodation = accommodationService.selectTrainAccommodationByLockApiBizNo(bizNo); + if (accommodation == null) + { + // 记录不存在(平台手工单、已归档或 bizNo 不匹配):只记录日志,仍按接收成功应答,避免对方无意义重试 + lockUpdate = "NOT_FOUND"; + log.warn("[结果回调] 未找到 bizNo 对应住宿记录,跳过 lockResult 更新,bizNo={}, eventType={}, result={}", + bizNo, eventType, result); + } + else + { + try + { + int rows = accommodationService.updateLockResult(accommodation.getAccommodationId(), + targetLockResult, CALLBACK_OPERATOR); + if (rows <= 0) + { + // 记录存在但未更新(并发删除等):返回 50002,交由对方按回调重试策略重试,保证最终一致 + log.error("[结果回调] lockResult 更新未生效,accommodationId={}, bizNo={}, lockResult={}", + accommodation.getAccommodationId(), bizNo, targetLockResult); + return error(50002, "回调结果落库失败:住宿记录 lockResult 未更新,bizNo=" + bizNo); + } + lockUpdate = "UPDATED"; + log.info("[结果回调] lockResult 已更新,accommodationId={}, bizNo={}, lockResult={}", + accommodation.getAccommodationId(), bizNo, targetLockResult); + } + catch (Exception updateError) + { + // 落库异常返回 50002,对方按 5 次退避重试;不吞异常,避免结果长期不一致 + log.error("[结果回调] lockResult 更新异常,accommodationId={}, bizNo={}, lockResult={}", + accommodation.getAccommodationId(), bizNo, targetLockResult, updateError); + return error(50002, "回调结果落库失败:" + updateError.getMessage()); + } + } + } + + Map data = new LinkedHashMap<>(); + data.put("eventId", eventId); + data.put("eventType", eventType); + data.put("revision", revision); + data.put("bizNo", bizNo); + data.put("result", result); + data.put("received", true); + data.put("lockUpdate", lockUpdate); + if ("UPDATED".equals(lockUpdate)) + { + data.put("lockResult", targetLockResult); + } + + Map ack = new LinkedHashMap<>(); + ack.put("code", 0); + ack.put("msg", "success"); + ack.put("data", data); + String ackBody; + try + { + ackBody = MAPPER.writeValueAsString(ack); + } + catch (Exception serializeError) + { + log.error("[结果回调] 响应序列化失败", serializeError); + ackBody = "{\"code\":0,\"msg\":\"success\",\"data\":null}"; + } + return ResponseEntity.status(HttpStatus.OK).contentType(JSON_UTF8).body(ackBody); + } + + // ==================== 内部实现 ==================== + + /** + * 统一转发逻辑:校验配置、拼接地址、注入 X-API-Key、转发请求并透传响应。 + * + * @param method GET/POST + * @param path 相对路径(含查询串) + * @param body POST 请求体(GET 为 null) + * @param action 操作名称(仅用于日志) + */ + private ResponseEntity forward(String method, String path, Map body, String action) + { + String base = lockEndpoint == null ? "" : lockEndpoint.trim(); + if (base.isEmpty()) + { + log.warn("[{}] 未配置 iot.face.lock_endpoint,无法调用门禁开放服务", action); + return error(ApiErrorCode.CONFIG_MISSING, "未配置接口基础地址 iot.face.lock_endpoint"); + } + String key = apiKey == null ? "" : apiKey.trim(); + if (key.isEmpty()) + { + log.warn("[{}] 未配置 iot.face.api_key,无法调用门禁开放服务", action); + return error(ApiErrorCode.CONFIG_MISSING, "未配置接口密钥 iot.face.api_key"); + } + if (base.endsWith("/")) + { + base = base.substring(0, base.length() - 1); + } + String url = base + path; + long startAt = System.currentTimeMillis(); + try + { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .header("X-API-Key", key) + .header("Accept", "application/json"); + if ("POST".equals(method)) + { + String payload; + try + { + payload = body == null ? "{}" : MAPPER.writeValueAsString(body); + } + catch (Exception serializeError) + { + log.error("[{}] 请求体序列化失败", action, serializeError); + return error(ApiErrorCode.INTERNAL_ERROR, "请求体序列化失败:" + serializeError.getMessage()); + } + builder.header("Content-Type", "application/json;charset=UTF-8"); + builder.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)); + } + else + { + builder.GET(); + } + + HttpResponse response = httpClient.send( + builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String responseBody = response.body(); + long cost = System.currentTimeMillis() - startAt; + log.info("[{}] {} -> HTTP {}, cost={}ms, response={}", + action, url, response.statusCode(), cost, brief(responseBody)); + + if (responseBody == null || responseBody.trim().isEmpty()) + { + return error(ApiErrorCode.INTERNAL_ERROR, "门禁服务返回空响应(HTTP " + response.statusCode() + ")"); + } + // 透传对方 HTTP 状态与统一响应结构(含 4xx/5xx 的业务错误体),由调用方按 code 处理 + return ResponseEntity.status(response.statusCode()).contentType(JSON_UTF8).body(responseBody); + } + catch (java.net.http.HttpTimeoutException timeout) + { + log.error("[{}] 调用门禁服务超时:{}", action, url, timeout); + return error(ApiErrorCode.INTERNAL_ERROR, "调用门禁服务超时:" + timeout.getMessage()); + } + catch (InterruptedException interrupted) + { + Thread.currentThread().interrupt(); + log.error("[{}] 调用门禁服务被中断:{}", action, url, interrupted); + return error(ApiErrorCode.INTERNAL_ERROR, "调用门禁服务被中断"); + } + catch (Exception ex) + { + log.error("[{}] 调用门禁服务异常:{}", action, url, ex); + return error(ApiErrorCode.INTERNAL_ERROR, "调用门禁服务异常:" + ex.getMessage()); + } + } + + /** 追加非空查询参数(UTF-8 URL 编码)。 */ + private static void appendParam(StringBuilder query, String name, Object value) + { + if (value == null) + { + return; + } + String text = String.valueOf(value); + if (text.isEmpty()) + { + return; + } + if (query.length() > 0) + { + query.append('&'); + } + query.append(name).append('=').append(URLEncoder.encode(text, StandardCharsets.UTF_8)); + } + + /** 构造与文档 §3.2 一致的错误响应体:{code,msg,data:null}。 */ + private ResponseEntity error(int code, String msg) + { + Map result = new LinkedHashMap<>(); + result.put("code", code); + result.put("msg", msg); + result.put("data", null); + String body; + try + { + body = MAPPER.writeValueAsString(result); + } + catch (Exception serializeError) + { + body = "{\"code\":" + code + ",\"msg\":\"" + (msg == null ? "" : msg.replace("\"", "'")) + "\",\"data\":null}"; + } + return ResponseEntity.status(httpStatusOf(code)).contentType(JSON_UTF8).body(body); + } + + /** 错误码 → HTTP 状态码映射(与文档 §3.2 对齐)。 */ + private static HttpStatus httpStatusOf(int code) + { + if (code == ApiErrorCode.AUTH_INVALID) + { + return HttpStatus.UNAUTHORIZED; + } + if (code == ApiErrorCode.FORBIDDEN) + { + return HttpStatus.FORBIDDEN; + } + if (code == ApiErrorCode.RATE_LIMITED) + { + return HttpStatus.TOO_MANY_REQUESTS; + } + if (code >= 40000 && code < 50000) + { + return HttpStatus.BAD_REQUEST; + } + if (code >= 50000) + { + return HttpStatus.INTERNAL_SERVER_ERROR; + } + return HttpStatus.OK; + } + + /** 日志截断,避免超长报文刷屏。 */ + private static String brief(String text) + { + if (text == null) + { + return null; + } + return text.length() > 500 ? text.substring(0, 500) + "..." : text; + } + + /** 代理侧使用的错误码常量(与文档 §3.2 保持一致)。 */ + private static final class ApiErrorCode + { + private ApiErrorCode() + { + } + + static final int AUTH_INVALID = 40100; + static final int FORBIDDEN = 40300; + static final int RATE_LIMITED = 42900; + static final int CONFIG_MISSING = 50002; + static final int INTERNAL_ERROR = 50002; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainOpenApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainOpenApiController.java new file mode 100644 index 000000000..c4451364d --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainOpenApiController.java @@ -0,0 +1,302 @@ +package com.ruoyi.web.controller.api; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.ip.IpUtils; +import com.ruoyi.train.domain.TrainOpenApp; +import com.ruoyi.train.domain.dto.OpenExamReportDTO; +import com.ruoyi.train.domain.dto.OpenExamReportItemDTO; +import com.ruoyi.train.service.ITrainExamReportService; +import com.ruoyi.train.service.ITrainOpenAppService; +import com.ruoyi.train.service.ITrainOpenTokenService; + +/** + * 外部实训系统数据接口(开放接口,面向 VR / 3D 巡检 / 实操 / 帽石考核等系统) + * + *

鉴权:除 3.1 登录外均需 {@code Authorization: Bearer {accessToken}},由本控制器自行校验, + * 并通过 {@code @Anonymous} 放行 Spring Security。

+ * + *

响应:统一 {@link OpenApiResult},业务结果用 code 表达(HTTP 恒为 200)。

+ * + * @author ruoyi + * @date 2026-11-05 + */ +@Tag(name = "外部实训系统数据接口") +@Anonymous +@RestController +@RequestMapping("/api/open/v1") +public class TrainOpenApiController +{ + private static final Logger log = LoggerFactory.getLogger(TrainOpenApiController.class); + + private static final String BEARER_PREFIX = "Bearer "; + + @Autowired + private ITrainOpenAppService trainOpenAppService; + + @Autowired + private ITrainOpenTokenService trainOpenTokenService; + + @Autowired + private ITrainExamReportService trainExamReportService; + + /** + * 3.1 登录获取访问令牌 + */ + @Operation(summary = "登录获取访问令牌") + @PostMapping("/auth/token") + public OpenApiResult token(@RequestBody Map body, HttpServletRequest request) + { + String reqId = nextReqId(); + try + { + if (body == null) + { + throw new ServiceException("参数缺失:appId 与 appSecret 均为必填", 1001); + } + String appId = asText(body.get("appId")); + String appSecret = asText(body.get("appSecret")); + if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(appSecret)) + { + throw new ServiceException("参数缺失:appId 与 appSecret 均为必填", 1001); + } + TrainOpenApp app = trainOpenAppService.selectTrainOpenAppByAppId(appId); + if (app == null) + { + throw new ServiceException("应用未注册", 1003); + } + if (app.getStatus() == null || app.getStatus() != 1) + { + throw new ServiceException("应用已停用", 1004); + } + if (!trainOpenAppService.matchesSecret(app, appSecret)) + { + throw new ServiceException("应用密钥错误", 1005); + } + Map data = trainOpenTokenService.issueToken(app, IpUtils.getIpAddr(), + request.getHeader("User-Agent")); + Map appInfo = new LinkedHashMap<>(); + appInfo.put("appId", app.getAppId()); + appInfo.put("appName", app.getAppName()); + appInfo.put("appType", app.getAppType()); + appInfo.put("orgName", app.getOrgName()); + data.put("app", appInfo); + return OpenApiResult.success(data, reqId); + } + catch (ServiceException e) + { + return failure(e, reqId); + } + catch (Exception e) + { + log.error("开放接口登录异常 reqId={}", reqId, e); + return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId); + } + } + + /** + * 3.2 在训学员查询 + */ + @Operation(summary = "在训学员查询") + @GetMapping("/student/list") + public OpenApiResult studentList(@RequestHeader(value = "Authorization", required = false) String authorization, + @RequestParam(value = "classId", required = false) String classId, + @RequestParam(value = "pageNo", required = false, defaultValue = "1") Integer pageNo, + @RequestParam(value = "pageSize", required = false, defaultValue = "50") Integer pageSize) + { + String reqId = nextReqId(); + try + { + String appId = requireAppId(authorization); + if (StringUtils.isNotEmpty(classId) && !trainOpenAppService.hasScope(appId, "CLASS", classId)) + { + throw new ServiceException("无权限访问该资源:班级未授权", 2001); + } + if (pageNo == null || pageNo < 1 || pageSize == null || pageSize < 1 || pageSize > 200) + { + throw new ServiceException("参数格式错误:pageNo ≥ 1 且 pageSize ∈ [1,200]", 1002); + } + Map data = trainExamReportService.selectInTrainingStudents(classId, pageNo, pageSize); + return OpenApiResult.success(data, reqId); + } + catch (ServiceException e) + { + return failure(e, reqId); + } + catch (Exception e) + { + log.error("开放接口学员查询异常 reqId={}", reqId, e); + return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId); + } + } + + /** + * 3.3 考试成绩批量上报 + */ + @Operation(summary = "考试成绩批量上报") + @PostMapping("/exam/report") + public OpenApiResult reportBatch(@RequestHeader(value = "Authorization", required = false) String authorization, + @RequestBody(required = false) OpenExamReportDTO dto) + { + String reqId = nextReqId(); + try + { + String appId = requireAppId(authorization); + checkSubjectScope(appId, dto == null ? null : dto.getReports()); + Map data = trainExamReportService.reportBatch(appId, dto, reqId); + return OpenApiResult.success(data, reqId); + } + catch (ServiceException e) + { + return failure(e, reqId); + } + catch (Exception e) + { + log.error("开放接口批量上报异常 reqId={}", reqId, e); + return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId); + } + } + + /** + * 3.4 实时成绩上报 + */ + @Operation(summary = "实时成绩上报") + @PostMapping("/exam/report/realtime") + public OpenApiResult reportRealtime(@RequestHeader(value = "Authorization", required = false) String authorization, + @RequestBody(required = false) OpenExamReportDTO dto) + { + String reqId = nextReqId(); + try + { + String appId = requireAppId(authorization); + TrainOpenApp app = trainOpenAppService.selectTrainOpenAppByAppId(appId); + if (app != null && app.getRealtimeEnabled() != null && app.getRealtimeEnabled() != 1) + { + throw new ServiceException("无权限访问该资源:未开启实时上报", 2001); + } + checkSubjectScope(appId, dto == null ? null : dto.getReports()); + Map data = trainExamReportService.reportRealtime(appId, dto, reqId); + return OpenApiResult.success(data, reqId); + } + catch (ServiceException e) + { + return failure(e, reqId); + } + catch (Exception e) + { + log.error("开放接口实时上报异常 reqId={}", reqId, e); + return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId); + } + } + + /** + * 3.5 上报结果查询 + */ + @Operation(summary = "上报结果查询") + @GetMapping("/exam/result") + public OpenApiResult result(@RequestHeader(value = "Authorization", required = false) String authorization, + @RequestParam(value = "batchId", required = false) String batchId, + @RequestParam(value = "reportId", required = false) String reportId, + @RequestParam(value = "periodStart", required = false) Long periodStart, + @RequestParam(value = "periodEnd", required = false) Long periodEnd, + @RequestParam(value = "pageNo", required = false, defaultValue = "1") Integer pageNo, + @RequestParam(value = "pageSize", required = false, defaultValue = "50") Integer pageSize) + { + String reqId = nextReqId(); + try + { + String appId = requireAppId(authorization); + Map data = trainExamReportService.queryResult(appId, batchId, reportId, periodStart, + periodEnd, pageNo == null ? 1 : pageNo, pageSize == null ? 50 : pageSize); + return OpenApiResult.success(data, reqId); + } + catch (ServiceException e) + { + return failure(e, reqId); + } + catch (Exception e) + { + log.error("开放接口结果查询异常 reqId={}", reqId, e); + return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId); + } + } + + /** + * 解析并校验令牌,返回 appId + */ + private String requireAppId(String authorization) + { + if (StringUtils.isEmpty(authorization) || !authorization.startsWith(BEARER_PREFIX)) + { + throw new ServiceException("令牌无效或已过期", 1006); + } + String appId = trainOpenTokenService.validateToken(authorization.substring(BEARER_PREFIX.length())); + if (StringUtils.isEmpty(appId)) + { + throw new ServiceException("令牌无效或已过期", 1006); + } + TrainOpenApp app = trainOpenAppService.selectTrainOpenAppByAppId(appId); + if (app == null || app.getStatus() == null || app.getStatus() != 1) + { + throw new ServiceException("令牌无效或已过期", 1006); + } + return appId; + } + + /** + * 科目级授权校验(未配置任何 SUBJECT 授权时视为不限制,便于本地联调) + */ + private void checkSubjectScope(String appId, List items) + { + if (items == null || items.isEmpty()) + { + return; + } + for (OpenExamReportItemDTO item : items) + { + if (item != null && StringUtils.isNotEmpty(item.getSubjectCode()) + && !trainOpenAppService.hasScope(appId, "SUBJECT", item.getSubjectCode())) + { + throw new ServiceException("无权限访问该资源:科目未授权 " + item.getSubjectCode(), 2001); + } + } + } + + private OpenApiResult failure(ServiceException e, String reqId) + { + Integer code = e.getCode(); + return OpenApiResult.error(code == null ? OpenApiResult.INTERNAL_ERROR : code, e.getMessage(), reqId); + } + + private String asText(Object value) + { + return value == null ? null : value.toString().trim(); + } + + private String nextReqId() + { + String date = new SimpleDateFormat("yyyyMMdd").format(new Date()); + int random = ThreadLocalRandom.current().nextInt(0x100000, 0xFFFFFF); + return "RQ-" + date + "-" + Integer.toHexString(random); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainReaderApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainReaderApiController.java new file mode 100644 index 000000000..7de67820f --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainReaderApiController.java @@ -0,0 +1,949 @@ +package com.ruoyi.web.controller.api; + +import com.ruoyi.common.constant.CacheConstants; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.device.domain.*; +import com.ruoyi.device.service.*; +import com.ruoyi.framework.web.service.TokenService; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainElectronicBoard; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.ITrainElectronicBoardService; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.utils.file.FileUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.*; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.math.BigDecimal; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.List; + +//import com.ruoyi.system.domain.SysUser; + +/** + * 阅读机/听书机对外接口(设备端调用) + * + * BasePath: /api/iot/v1 + * 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。 + * 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。 + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@Tag(name = "阅读机/听书机接口", description = "设备端对外接口:登录/注销/心跳、阅读上报、进度查询、书籍列表与下载回执。登录免鉴权,其余接口走 RuoYi Bearer 鉴权。") +@RequestMapping("/api/iot/v1") +public class TrainReaderApiController extends BaseController +{ + @Autowired + private ITrainIotDeviceService iotDeviceService; + @Autowired + private ITrainUserSessionService userSessionService; + @Autowired + private ITrainReadingDurationLogService readingLogService; + @Autowired + private ITrainReadingCurrentProgressService currentProgressService; + @Autowired + private ITrainReadingAlternateProgressService alternateProgressService; + @Autowired + private ITrainBookSnapshotService bookSnapshotService; + @Autowired + private ITrainBookDownloadRecordService bookDownloadRecordService; + @Autowired + private TokenService tokenService; + @Autowired + private ISysUserService sysUserService; + @Autowired + private TrainStudentMapper trainStudentMapper; + @Autowired + private ITrainElectronicBoardService electronicBoardService; + + + @Value("${ruoyi.profile:}") + private String profileRoot; + + /** 图书下载地址前缀(取 iot.face.avatar_profile),用于把 /profile/... 补全为完整 http 地址 */ + @Value("${iot.face.avatar_profile:}") + private String avatarProfile; + + /** + * 1. 设备学员登录 + */ + @Operation(summary = "设备学员登录", description = "校验设备存在/启用 + 学员账号正常,签发 RuoYi token 并创建业务会话,返回 accessToken / sessionId / heartbeatInterval。") + @PostMapping("/device/login") + public AjaxResult login(@RequestBody LoginRequest req) + { + TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId); + if (device == null) + { + return AjaxResult.error("设备不存在"); + } + if (device.getDeviceStatus() != null && device.getDeviceStatus() == 2) + { + return AjaxResult.error("设备已禁用"); + } + + // HMAC 验签应在网关/拦截器完成;此处仅做账号校验 + // 设备传入的 userId 实为学员 sId:先查 train_student 取业务账户 userId,再查系统用户 + TrainStudent student = trainStudentMapper.selectTrainStudentBySId(req.userId == null ? null : req.userId.toString()); + logger.info("student:{}", student); + Long sysUserId = student != null ? student.getUserId() : null; + SysUser sysUser = sysUserId != null ? sysUserService.selectUserById(sysUserId) : null; + logger.info("sysUser:{}", sysUser); + if (sysUser == null || !"0".equals(sysUser.getStatus())) + { + return AjaxResult.error("学员账号不存在或已停用"); + } + LoginUser loginUser = new LoginUser(); + loginUser.setUser(sysUser); + loginUser.setUserId(sysUser.getUserId()); + //loginUser.setUsername(sysUser.getUserName()); + String token = tokenService.createToken(loginUser); + String tokenKey = CacheConstants.LOGIN_TOKEN_KEY + token; + String sessionId = userSessionService.createSession(sysUserId, req.deviceId, tokenKey, new Date()); + LoginData data = new LoginData(); + data.accessToken = token; + data.sessionId = sessionId; + data.heartbeatInterval = device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60; + return AjaxResult.success(data); + } + + /** + * 2. 设备注销 + */ + @Operation(summary = "设备注销", description = "业务会话置 LOGOUT,并撤销 RuoYi Redis 中的 token。") + @PostMapping("/device/logout") + public AjaxResult logout(@RequestBody LogoutRequest req, HttpServletRequest request) + { + Long userId = getUserId(); + userSessionService.logoutSession(userId, req.deviceId); + String auth = request.getHeader("Authorization"); + if (auth != null && auth.startsWith("Bearer ")) + { + String token = auth.substring(7).trim(); + tokenService.delLoginUser(token); + } + LogoutData data = new LogoutData(); + data.result = true; + data.tokenRevoked = true; + return AjaxResult.success(data); + } + + + + /** + * 3. 设备心跳 + */ + @Operation(summary = "设备心跳", description = "更新设备最后心跳时间,返回下次心跳间隔与平台下行指令数组。") + @PostMapping("/device/heartbeat") + public AjaxResult heartbeat(@RequestBody HeartbeatRequest req) + { + Integer status = "ONLINE".equals(req.status) ? 1 : null; + iotDeviceService.updateHeartbeatByDeviceId(req.deviceId, new Date(), status); + TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId); + HeartbeatData data = new HeartbeatData(); + data.nextInterval = device != null && device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60; + data.commands = new ArrayList<>(); + return AjaxResult.success(data); + } + + /** + * 4. 阅读记录单条上报 + */ + @Operation(summary = "阅读记录单条上报", description = "幂等上报(recordId 去重);ACTIVE 会话更新主进度,REPLACED 会话写入备选冲突进度。返回 ACCEPTED / DUPLICATE / CONFLICT_SAVED / REJECTED。") + @PostMapping("/reading/record") + public AjaxResult reportRecord(@RequestBody ReadingRecordRequest req) + { + Long userId = getUserId(); + TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId); + TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, req); + String resultCode = readingLogService.reportReadingRecord(log); + long studySeconds = log.getDurationSec() != null ? log.getDurationSec().longValue() : 0; + long totalSeconds = readingLogService.sumDurationSeconds(userId, req.mode); + RecordData data = new RecordData(); + data.result = resultCode; + data.studyMinutes = (int) (studySeconds / 60); + data.totalMinutes = (int) (totalSeconds / 60); + return AjaxResult.success(data); + } + + /** + * 5. 阅读记录批量上报(断网补传) + */ + @Operation(summary = "阅读记录批量上报", description = "单批最多 200 条,每条 recordId 独立幂等,逐条走单条上报逻辑。") + @PostMapping("/reading/record/batch") + public AjaxResult reportRecordBatch(@RequestBody ReadingRecordBatchRequest req) + { + Long userId = getUserId(); + TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId); + List itemResults = new ArrayList<>(); + if (req.records != null) + { + for (ReadingRecordRequest r : req.records) + { + TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, r); + String rc = readingLogService.reportReadingRecord(log); + itemResults.add(new ReadingRecordItemResult(r.recordId, rc)); + } + } + BatchData data = new BatchData(); + data.itemResults = itemResults; + return AjaxResult.success(data); + } + + private TrainReadingDurationLog buildLog(Long userId, String deviceId, TrainUserSession session, ReadingRecordRequest req) + { + TrainReadingDurationLog log = new TrainReadingDurationLog(); + log.setUserId(userId); + log.setDeviceId(deviceId); + log.setSessionId(session != null ? session.getSessionId() : null); + log.setRecordId(req.recordId); + log.setBookId(req.bookId); + log.setBookVersion(req.bookVersion); + log.setMode(req.mode); + log.setRecordType(req.recordType); + log.setDurationSec(req.durationSec); + log.setPageFrom(req.pageFrom); + log.setPageTo(req.pageTo); + log.setProgress(req.progress); + log.setStartTime(req.startTime != null ? new Date(req.startTime) : null); + log.setEndTime(req.endTime != null ? new Date(req.endTime) : null); + return log; + } + + /** + * 6. 获取学员阅读汇总与续读进度 + */ + @Operation(summary = "获取学员阅读汇总与续读进度", description = "返回累计阅读/听书时长,以及每本书的主进度 + 备选冲突进度。REPLACED 会话禁止调用。") + @GetMapping("/reading/progress") + public AjaxResult progress(@Parameter(description = "设备ID") @RequestParam String deviceId) + { + Long userId = getUserId(); + TrainUserSession session = userSessionService.selectLatestSession(userId, deviceId); + if (session == null || session.getSessionStatus() != 1) + { + return AjaxResult.error("当前会话状态不允许查询进度(被替换设备仅允许补交上报)"); + } + long readSec = readingLogService.sumDurationSeconds(userId, "read"); + long audioSec = readingLogService.sumDurationSeconds(userId, "audio"); + List mains = currentProgressService.selectListByUserId(userId); + List bookRecords = new ArrayList<>(); + if (mains != null) + { + for (TrainReadingCurrentProgress cp : mains) + { + BookRecord br = new BookRecord(); + br.bookId = cp.getBookId(); + br.bookVersion = cp.getBookVersion(); + MainProgress mp = new MainProgress(); + mp.mode = cp.getMode(); + mp.pageNo = cp.getPageNo(); + mp.sentenceIndex = cp.getSentenceIndex(); + mp.progress = cp.getProgress(); + mp.updateTime = cp.getUpdateTime() != null ? cp.getUpdateTime().getTime() : null; + br.mainProgress = mp; + List alts = alternateProgressService.selectListByUserBookMode(userId, cp.getBookId(), cp.getMode()); + List altList = new ArrayList<>(); + if (alts != null) + { + for (TrainReadingAlternateProgress a : alts) + { + AlternateProgress ap = new AlternateProgress(); + ap.mode = a.getMode(); + ap.bookVersion = a.getBookVersion(); + ap.pageNo = a.getPageNo(); + ap.sentenceIndex = a.getSentenceIndex(); + ap.progress = a.getProgress(); + ap.createTime = a.getCreateTime() != null ? a.getCreateTime().getTime() : null; + altList.add(ap); + } + } + br.alternateProgressList = altList; + bookRecords.add(br); + } + } + ProgressData data = new ProgressData(); + + // 由当前登录的 sys userId 反查 train_student,将学员 sId 写入返回结果 + TrainStudent student = trainStudentMapper.selectTrainStudentByUserId(userId); + data.userId = student != null ? student.getsId() : null; + data.totalReadSeconds = readSec; + data.totalAudioSeconds = audioSec; + data.bookRecords = bookRecords; + return AjaxResult.success(data); + } + + /** + * 设备列表:汇总电子班牌(train_electronic_board)与读书机(train_iot_device)。 + * 以 board_id / device_id 作为统一的 device_id;device_type 分别为 board / reader;班牌附带 board_sn。 + */ + @GetMapping("/devicelist") + @Operation(summary = "设备列表", description = "汇总电子班牌(train_electronic_board)与读书机(train_iot_device):以 board_id / device_id 作为 device_id,device_type 分别为 board / reader,班牌附带 board_sn。") + public AjaxResult deviceList() + { + List result = new ArrayList<>(); + + // 电子班牌 + List boards = electronicBoardService.selectTrainElectronicBoardList(new TrainElectronicBoard()); + if (boards != null) + { + for (TrainElectronicBoard b : boards) + { + DeviceListItem item = new DeviceListItem(); + item.deviceId = b.getBoardId(); + item.deviceType = "board"; + item.boardSn = b.getBoardSn(); + result.add(item); + } + } + + // 读书机 + List readers = iotDeviceService.selectTrainIotDeviceList(new TrainIotDevice()); + if (readers != null) + { + for (TrainIotDevice d : readers) + { + DeviceListItem item = new DeviceListItem(); + item.deviceId = d.getDeviceId(); + item.deviceType = "reader"; + result.add(item); + } + } + + return AjaxResult.success(result); + } + + /** + * 7. 获取可阅读书籍列表(仅上架) + */ + @Operation(summary = "获取可阅读书籍列表", description = "只返回 is_on_shelf=1 的上架图书,支持按 bookCategory 过滤。") + @GetMapping("/book/list") + public AjaxResult bookList(@Parameter(description = "设备ID") @RequestParam String deviceId, + @Parameter(description = "分类过滤:SAFETY/REGULATION/LANGUAGE,可空") @RequestParam(required = false) String bookCategory) + { + List list = bookSnapshotService.selectOnShelfList(bookCategory); + List items = new ArrayList<>(); + if (list != null) + { + for (TrainBookSnapshot b : list) + { + BookItem bi = new BookItem(); + bi.bookId = b.getBookId(); + bi.bookTitle = b.getBookTitle(); + bi.bookCategory = b.getBookCategory(); + bi.bookSummary = b.getBookSummary(); + bi.bookVersion = b.getBookVersion(); + String coverPath = b.getBookCover(); + if (coverPath != null && !coverPath.isEmpty() && coverPath.startsWith(Constants.RESOURCE_PREFIX)) + { + try + { + String realPath = RuoYiConfig.getProfile() + FileUtils.stripPrefix(coverPath); + byte[] coverData = Files.readAllBytes(Paths.get(realPath)); + String lower = coverPath.toLowerCase(); + String mime = "image/jpeg"; + if (lower.endsWith(".png")) mime = "image/png"; + else if (lower.endsWith(".gif")) mime = "image/gif"; + else if (lower.endsWith(".webp")) mime = "image/webp"; + else if (lower.endsWith(".bmp")) mime = "image/bmp"; + bi.bookCover = "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(coverData); + } + catch (Exception e) + { + // 本地读取失败(文件缺失/中文路径无法定位):回退为补全前缀并编码的可访问绝对地址 + bi.bookCover = toAccessibleCoverUrl(coverPath); + logger.warn("图书封面本地读取失败,回退为绝对地址, bookId={}, coverPath={}, msg={}", + b.getBookId(), coverPath, e.getMessage()); + } + } + else + { + // 非 /profile 地址:绝对 URL 仅做中文/空格编码,其余原样 + bi.bookCover = toAccessibleCoverUrl(coverPath); + } + bi.fileSize = b.getFileSize(); + bi.fileMd5 = b.getFileMd5(); + bi.isOnShelf = b.getIsOnShelf() != null && b.getIsOnShelf() == 1; + bi.publishTime = b.getPublishTime() != null ? b.getPublishTime().getTime() : null; + items.add(bi); + } + } + BookListData data = new BookListData(); + data.total = items.size(); + data.list = items; + return AjaxResult.success(data); + } + + /** + * 将图书封面地址转换为设备可访问的绝对地址。 + *

+ * /profile/... 相对地址补全 iot.face.avatar_profile 前缀后做 UTF-8 百分号编码; + * 已是 http(s) 的地址只做编码;其余地址原样返回。 + * + * @param coverPath 封面原始路径 + * @return 可访问的封面地址 + */ + private String toAccessibleCoverUrl(String coverPath) + { + if (coverPath == null || coverPath.isEmpty()) + { + return coverPath; + } + return encodeHttpUrl(withAvatarProfilePrefix(coverPath)); + } + + /** + * 8. PDF 书籍下载(支持 Range 断点续传;下架图书禁止下载) + */ + @Operation(summary = "PDF 书籍下载", description = "支持 HTTP Range 断点续传;下架图书返回业务错误禁止下载。") + @GetMapping("/book/download") + public void bookDownload(@Parameter(description = "设备ID") @RequestParam String deviceId, + @Parameter(description = "图书ID") @RequestParam String bookId, + @Parameter(description = "图书版本 BV-xx") @RequestParam String bookVersion, + HttpServletRequest request, HttpServletResponse response) throws IOException + { + TrainBookSnapshot book = bookSnapshotService.selectByBookIdAndVersion(bookId, bookVersion); + if (book == null) + { + writeJsonError(response, "图书或版本不存在"); + return; + } + if (book.getIsOnShelf() == null || book.getIsOnShelf() != 1) + { + writeJsonError(response, "图书已下架,禁止下载"); + return; + } + String url = book.getDownloadUrl(); + if (url == null || url.isEmpty()) + { + writeJsonError(response, "文件地址不存在"); + return; + } + // /profile/upload/...pdf 相对地址补全 iot.face.avatar_profile 的 http 前缀 + url = withAvatarProfilePrefix(url); + // 中文/空格等非 ASCII 字符需做 UTF-8 百分号编码,否则 HttpURLConnection 会把原始字节写入请求行,对端返回 400 + url = encodeHttpUrl(url); + streamFile(url, book.getFileMd5(), book.getFileSize(), book.getBookTitle(), request, response); + } + + /** + * 对 http(s) 地址中的中文、空格等非 ASCII 字符做 UTF-8 百分号编码。 + *

+ * 仅处理 scheme://host 之后的部分;已编码的 %XX 序列、以及 URL 合法字符原样保留, + * 非 http(s) 地址(本地 /profile/... 或绝对路径)原样返回,避免影响本地文件读取。 + * + * @param url 原始地址 + * @return 编码后的地址 + */ + private String encodeHttpUrl(String url) + { + if (url == null) + { + return null; + } + String lower = url.toLowerCase(); + if (!lower.startsWith("http://") && !lower.startsWith("https://")) + { + return url; + } + int pathStart = url.indexOf('/', url.indexOf("://") + 3); + if (pathStart < 0) + { + return url; + } + String tail = url.substring(pathStart); + StringBuilder encoded = new StringBuilder(url.length() + 32); + encoded.append(url, 0, pathStart); + for (int i = 0; i < tail.length(); i++) + { + char c = tail.charAt(i); + if (c == '%' && i + 2 < tail.length() + && isHexDigit(tail.charAt(i + 1)) && isHexDigit(tail.charAt(i + 2))) + { + // 已是合法百分号编码,原样保留,避免二次编码 + encoded.append(tail, i, i + 3); + i += 2; + } + else if (isUrlSafeChar(c)) + { + encoded.append(c); + } + else + { + int codePoint = tail.codePointAt(i); + if (codePoint > Character.MAX_VALUE) + { + i++; + } + byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8); + for (byte b : bytes) + { + encoded.append('%').append(String.format("%02X", b & 0xff)); + } + } + } + return encoded.toString(); + } + + private static boolean isHexDigit(char c) + { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + private static boolean isUrlSafeChar(char c) + { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) + { + return true; + } + return c == '-' || c == '_' || c == '.' || c == '~' + || c == '/' || c == '?' || c == '&' || c == '=' || c == ':' + || c == '@' || c == '+' || c == '!' || c == '$' || c == '\'' + || c == '(' || c == ')' || c == '*' || c == ',' || c == ';'; + } + + /** + * 为 /profile/ 开头的相对下载地址拼接 iot.face.avatar_profile 配置的 http 前缀。 + *

+ * 已带 http(s) 协议头的地址、非 /profile/ 的相对地址、以及未配置前缀时,均原样返回。 + * + * @param url 原始下载地址 + * @return 补全后的下载地址 + */ + private String withAvatarProfilePrefix(String url) + { + if (url == null || !url.startsWith("/profile/")) + { + return url; + } + String prefix = avatarProfile == null ? "" : avatarProfile.trim(); + if (prefix.isEmpty()) + { + return url; + } + if (!prefix.toLowerCase().startsWith("http://") && !prefix.toLowerCase().startsWith("https://")) + { + prefix = "http://" + prefix; + } + while (prefix.endsWith("/")) + { + prefix = prefix.substring(0, prefix.length() - 1); + } + return prefix + url; + } + + /** + * 9. 下载完成回执上报 + */ + @Operation(summary = "下载完成回执上报", description = "设备上报下载校验结果,落库 train_book_download_record,返回 deviceReady 与 recordId。") + @PostMapping("/book/download/ack") + public AjaxResult downloadAck(@RequestBody DownloadAckRequest req) + { + Long userId = getUserId(); + TrainBookDownloadRecord record = new TrainBookDownloadRecord(); + record.setUserId(userId); + record.setDeviceId(req.deviceId); + record.setBookId(req.bookId); + record.setBookVersion(req.bookVersion); + record.setFileMd5(req.fileMd5); + record.setResult(req.result != null ? req.result : "SUCCESS"); + record.setErrMsg(req.errMsg); + record.setDownloadTime(req.timestamp != null ? new Date(req.timestamp) : new Date()); + bookDownloadRecordService.insertTrainBookDownloadRecord(record); + DownloadAckData data = new DownloadAckData(); + data.deviceReady = true; + data.recordId = record.getId(); + return AjaxResult.success(data); + } + + private void writeJsonError(HttpServletResponse response, String msg) throws IOException + { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":500,\"msg\":\"" + msg + "\",\"data\":null}"); + } + + private void streamFile(String url, String md5, Long fileSize, String title, HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setHeader("Accept-Ranges", "bytes"); + if (md5 != null && !md5.isEmpty()) + { + response.setHeader("Content-MD5", md5); + } + // 加上 HTTP 头,保证文件以正确类型、可下载方式返回(浏览器可访问) + response.setContentType(guessContentType(url)); + String fileName = (title != null && !title.isEmpty() ? title : "book") + ".pdf"; + try + { + String enc = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + response.setHeader("Content-Disposition", + "attachment; filename=\"download.pdf\"; filename*=UTF-8''" + enc); + } + catch (Exception ignored) {} + + boolean isHttp = url.startsWith("http://") || url.startsWith("https://"); + InputStream in = null; + long total = -1; + if (isHttp) + { + // 完整 http(s) 远程地址(如 OSS):直接拉取 + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", "ruoyi-device"); + in = conn.getInputStream(); + String cl = conn.getHeaderField("Content-Length"); + if (cl != null && !cl.isEmpty()) + { + try { total = Long.parseLong(cl); } catch (NumberFormatException ignored) {} + } + } + else if (url.startsWith("/")) + { + // 相对路径:直接按本地资源读取,避免后端自请求(否则会触发 /error 鉴权 401) + if (url.startsWith("/profile/")) + { + // /profile/** 指向 RuoYi 上传根目录(ruoyi.profile),与静态资源映射一致 + File f = new File(profileRoot, url.substring("/profile".length())); + if (!f.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = f.length(); + in = new FileInputStream(f); + } + else + { + // 其他相对路径(测试用 /books/...)从 classpath 静态资源读取 + ClassPathResource res = new ClassPathResource("static" + url); + if (!res.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = res.contentLength(); + in = res.getInputStream(); + } + } + else + { + // 绝对本地路径(遗留用法) + File f = new File(url); + if (!f.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = f.length(); + in = new FileInputStream(f); + } + + try + { + String range = request.getHeader("Range"); + long start = 0; + long end = total - 1; + boolean hasRange = false; + if (range != null && range.startsWith("bytes=")) + { + String[] parts = range.substring(6).split("-"); + try { start = Long.parseLong(parts[0]); } catch (NumberFormatException ignored) {} + if (parts.length > 1 && !parts[1].isEmpty()) + { + try { end = Long.parseLong(parts[1]); } catch (NumberFormatException ignored) {} + } + hasRange = true; + } + if (hasRange) + { + if (end < 0 || end >= total) + { + end = total - 1; + } + if (start > end) + { + start = end; + } + long skipped = in.skip(start); + while (skipped < start) + { + long s = in.skip(start - skipped); + if (s <= 0) + { + break; + } + skipped += s; + } + long len = end - start + 1; + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + total); + response.setContentLengthLong(len); + copy(in, response.getOutputStream(), len); + } + else + { + response.setStatus(HttpServletResponse.SC_OK); + if (total > 0) + { + response.setContentLengthLong(total); + } + copy(in, response.getOutputStream(), -1); + } + } + finally + { + if (in != null) + { + in.close(); + } + } + } + + private String guessContentType(String url) + { + if (url != null && url.toLowerCase().endsWith(".pdf")) + { + return "application/pdf"; + } + return "application/octet-stream"; + } + + private void copy(InputStream in, OutputStream out, long limit) throws IOException + { + byte[] buf = new byte[8192]; + long remaining = limit; + int n; + while ((n = in.read(buf)) != -1) + { + if (limit > 0) + { + if (remaining <= 0) + { + break; + } + int toWrite = (int) Math.min(n, remaining); + out.write(buf, 0, toWrite); + remaining -= toWrite; + } + else + { + out.write(buf, 0, n); + } + } + out.flush(); + } + + // ================= 请求/响应 DTO(内嵌,避免新增文件) ================= + + public static class LoginRequest + { + public String deviceId; + public String userId; + public String firmware; + public String appVersion; + public NetInfo netInfo; + public Long timestamp; + public String nonce; + } + + public static class NetInfo + { + public String ip; + public String mac; + public Integer rssi; + } + + public static class LogoutRequest + { + public String deviceId; + public String reason; + public Long timestamp; + public String nonce; + } + + public static class HeartbeatRequest + { + public String deviceId; + public String status; + public Metrics metrics; + public String configVersion; + public String personVersion; + public Long timestamp; + public String nonce; + } + + public static class Metrics + { + public Integer cpu; + public Integer mem; + public Long diskFree; + } + + public static class ReadingRecordRequest + { + public String deviceId; + public String recordId; + public String bookId; + public String bookVersion; + public String mode; + public String recordType; + public Long startTime; + public Long endTime; + public Integer durationSec; + public Integer pageFrom; + public Integer pageTo; + public BigDecimal progress; + } + + public static class ReadingRecordBatchRequest + { + public String deviceId; + public String batchId; + public Long timestamp; + public String nonce; + public List records; + } + + public static class DownloadAckRequest + { + public String deviceId; + public String bookId; + public String bookVersion; + public String fileMd5; + public String result; + public String errMsg; + public Long timestamp; + public String nonce; + } + + public static class LoginData + { + public String accessToken; + public String sessionId; + public Integer heartbeatInterval; + } + + public static class LogoutData + { + public Boolean result; + public Boolean tokenRevoked; + } + + public static class HeartbeatData + { + public Integer nextInterval; + public List commands; + } + + public static class RecordData + { + public String result; + public Integer studyMinutes; + public Integer totalMinutes; + } + + public static class ReadingRecordItemResult + { + public String recordId; + public String result; + + public ReadingRecordItemResult(String recordId, String result) + { + this.recordId = recordId; + this.result = result; + } + } + + public static class BatchData + { + public List itemResults; + } + + public static class MainProgress + { + public String mode; + public Integer pageNo; + public Integer sentenceIndex; + public BigDecimal progress; + public Long updateTime; + } + + public static class AlternateProgress + { + public String mode; + public String bookVersion; + public Integer pageNo; + public Integer sentenceIndex; + public BigDecimal progress; + public Long createTime; + } + + public static class BookRecord + { + public String bookId; + public String bookVersion; + public MainProgress mainProgress; + public List alternateProgressList; + } + + public static class ProgressData + { + public String userId; + public long totalReadSeconds; + public long totalAudioSeconds; + public List bookRecords; + } + + public static class BookItem + { + public String bookId; + public String bookTitle; + public String bookCategory; + public String bookSummary; + public String bookVersion; + public String bookCover; + public Long fileSize; + public String fileMd5; + public Boolean isOnShelf; + public Long publishTime; + } + + public static class BookListData + { + public int total; + public List list; + } + + public static class DownloadAckData + { + public Boolean deviceReady; + public Long recordId; + } + + public static class DeviceListItem + { + public String deviceId; + public String deviceType; + public String boardSn; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java new file mode 100644 index 000000000..d60a1ed8b --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java @@ -0,0 +1,1411 @@ +package com.ruoyi.web.controller.api; + +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.iot.lock.service.ILockAuthService; +import com.ruoyi.iot.lock.domain.LockAuth; +import com.ruoyi.train.domain.query.StudentQuery; +import com.ruoyi.train.domain.query.EnrollmentQuery; +import com.ruoyi.train.domain.query.PlanQuery; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassroomInfo; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainTrainingSite; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.vo.CourseScheduleItemVO; +import com.ruoyi.train.domain.vo.PlanCourseItemVO; +import com.ruoyi.train.domain.vo.PlanItemVO; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.TeacherAccommodationVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.integration.lock.client.TrainLockStayApiClient; +import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.*; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.iot.lock.domain.LockDevice; +import com.ruoyi.iot.lock.mapper.LockDeviceMapper; +import com.ruoyi.common.utils.spring.SpringUtils; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +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.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Date; +import java.util.UUID; +import java.text.SimpleDateFormat; +import java.io.File; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; + +/** + * 报到机接口(设备端调用) + * + * BasePath: /api/register/v1 + * 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。 + * 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。 + * + * @author ruoyi + * @date 2026-10-23 + */ +@RestController +@Tag(name = "报到机接口", description = "报到机从服务器获取学员信息、宿舍信息、教室信息、实训场地信息、课表信息、累计学习时长、课程成绩 走 RuoYi Bearer 鉴权。") +@RequestMapping("/api/register/v1") +public class TrainRegisterApiController { + private static final Logger log = LoggerFactory.getLogger(TrainRegisterApiController.class); + + /** 教师门锁入住授权默认有效期(天),与教师住宿分配保持一致 */ + private static final int TEACHER_LOCK_AUTH_VALID_DAYS = 15; + + /** 教师人脸服务人员ID后缀:教师统一使用 tId + "_teacher",与学生 sId 在人脸库中隔离 */ + private static final String TEACHER_FACE_EMPLOYEE_SUFFIX = "_teacher"; + + /** 课表按自然日切分的时区:固定北京时间,避免部署环境 JVM 时区不同导致跨天偏移 */ + private static final ZoneId SCHEDULE_ZONE = ZoneId.of("Asia/Shanghai"); + + /** 课表日期键格式 yyyy-MM-dd */ + private static final DateTimeFormatter SCHEDULE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** 课表日期时间(可省略秒)格式,用于解析带时间的入参 */ + private static final DateTimeFormatter SCHEDULE_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss]"); + + /** 课表带时区偏移的日期时间格式,如 2026-11-09 09:00:00+08:00 */ + private static final DateTimeFormatter SCHEDULE_OFFSET_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss]XXX"); + + /** 课表课程类型-理论:对应 train_course.c_type = '1' */ + private static final String SCHEDULE_COURSE_TYPE_THEORY = "1"; + + /** 课表课程类型-实训:对应 train_course.c_type = '2' */ + private static final String SCHEDULE_COURSE_TYPE_PRACTICE = "2"; + + @Autowired + private ITrainStudentService studentService; + @Autowired + private ITrainTeacherService teacherService; + @Autowired + private ITrainTeacherAccommodationService teacherAccommodationService; + @Autowired + private ITrainClassReportService classReportService; + @Autowired + private ITrainClassPlanService classPlanService; + @Autowired + private ITrainPlanCourseService planCourseService; + @Autowired + private ITrainDormitoryInfoService dormitoryInfoService; + @Autowired + private ITrainClassroomInfoService classroomInfoService; + @Autowired + private ITrainTrainingSiteService trainingSiteService; + @Autowired + private ITrainLessonService lessonService; + @Autowired + private ITrainAccommodationService accommodationService; + @Autowired + private ITrainEnrollmentService enrollmentService; + @Autowired + private TrainStudentMapper trainStudentMapper; + @Autowired + private TrainLessonMapper lessonMapper; + @Autowired + private LockDeviceMapper lockDeviceMapper; + + @Autowired(required = false) + private TrainLockStayApiClient trainLockStayApiClient; + + @Autowired + private ILockAuthService lockAuthService; + + /** 人脸接口控制器:复用其 deleteEmployee 解除人脸绑定 */ + @Autowired + private TrainFaceApiController trainFaceApiController; + + /** 门禁代理控制器:复用其 checkout 清除入住(门锁)授权 */ + @Autowired + private TrainLockApiController trainLockApiController; + + private final TrainAccountBindingAdminService bindingAdminService; + public TrainRegisterApiController(ITrainStudentService trainStudentService, + TrainAccountBindingAdminService bindingAdminService) + { + this.studentService = trainStudentService; + this.bindingAdminService = bindingAdminService; + } + + /** + * 取学员(sId)学生信息。 + */ + private Map findStudentInfo(String sId) + { + StudentDetailVO detail = studentService.selectStudentDetail(sId); + if (detail == null) + { + return null; + } + Map m = new HashMap<>(8); + m.put("sId", detail.getsId()); + m.put("sName", detail.getsName()); + m.put("sPhone", detail.getsPhone()); + m.put("sGender", detail.getsGender()); + m.put("deptName", detail.getDeptName()); + m.put("personNo", detail.getPersonNo()); + return m; + } + /** + * 取学员(sId)最新开班报到记录(含分配的宿舍/教室/实训场地、所属班级 planId)。 + */ + private TrainClassReport findStudentReport(String sId) + { + return classReportService.selectLatestReportByStudentId(sId); + } + + /** + * 取开班信息(train_class_plan)。 + */ + private Map findClassPlan(String planId) + { + if (planId == null || planId.isEmpty()) + { + return null; + } + TrainClassPlan plan = classPlanService.selectTrainClassPlanByPlanId(planId); + if (plan == null) + { + return null; + } + Map m = new HashMap<>(8); + m.put("planId", plan.getPlanId()); + m.put("planName", plan.getPlanName()); + m.put("startTime", plan.getStartTime()); + m.put("endTime", plan.getEndTime()); + return m; + } + + /** + * 取所在班级课程(课表,train_plan_course)。 + */ + private List> findPlanCourses(String planId) + { + if (planId == null || planId.isEmpty()) + { + return null; + } + List list = planCourseService.selectPlanCourseListByPlanId(planId); + if (list == null) + { + return null; + } + List> result = new ArrayList<>(list.size()); + for (PlanCourseItemVO item : list) + { + Map m = new HashMap<>(4); + m.put("cId", item.getCId()); + m.put("cName", item.getCName()); + result.add(m); + } + return result; + } + + /** + * 取宿舍信息(train_dormitory_info),无分配则返回 null。 + */ + private Map findDormitory(String sId, String allocDormId) + { + TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sId); + String dormId = (acc != null && acc.getDormId() != null && !acc.getDormId().isEmpty()) + ? acc.getDormId() : allocDormId; + Map m = new HashMap<>(8); + m.put("dormId", dormId); + if (acc != null) + { + m.put("bedNo", acc.getBedNo()); + } + if (dormId != null && !dormId.isEmpty()) + { + TrainDormitoryInfo info = dormitoryInfoService.selectTrainDormitoryInfoByDormId(dormId); + if (info != null) + { + m.put("dormBuilding", info.getDormBuilding()); + m.put("dormRoom", info.getDormRoom()); + } + } + return m; + } + + /** + * 取教室信息(train_classroom_info),无分配则返回 null。 + */ + private TrainClassroomInfo findClassroom(String classRoomId) + { + if (classRoomId == null || classRoomId.isEmpty()) + { + return null; + } + return classroomInfoService.selectTrainClassroomInfoByClassRoomId(classRoomId); + } + + /** + * 取实训场地信息(train_training_site),无分配则返回 null。 + */ + private TrainTrainingSite findTrainingSite(String siteId) + { + if (siteId == null || siteId.isEmpty()) + { + return null; + } + return trainingSiteService.selectTrainTrainingSiteBySiteId(siteId); + } + + /** + * 取开班计划 planId:优先取报到记录的 planId,否则从报名关系(train_enrollment)兜底。 + */ + private String resolvePlanId(String sId) + { + TrainClassReport report = findStudentReport(sId); + String planId = report != null ? report.getPlanId() : null; + if (planId == null) + { + List plans = classPlanService.selectClassPlanListByStudentId(sId); + if (plans != null && !plans.isEmpty()) + { + planId = plans.get(0).getPlanId(); + } + } + return planId; + } + + /** + * 内部聚合:根据 sId 解析 planId → 取本计划课程表(train_lesson)→ 由课次的 + * class_room_id / site_id 分别取教室 / 实训场地信息。 + */ + private ScheduleInfo resolveSchedule(String sId) + { + ScheduleInfo info = new ScheduleInfo(); + String planId = resolvePlanId(sId); + if (planId == null) + { + return info; + } + List planCourses = planCourseService.selectPlanCourseListByPlanId(planId); + Map courseNameByPlanCourse = new HashMap<>(); + if (planCourses != null) + { + for (PlanCourseItemVO pc : planCourses) + { + courseNameByPlanCourse.put(pc.getPlanCourseId(), pc.getCName()); + } + } + List lessons = lessonService.selectTrainLessonListByPlanId(planId); + if (lessons != null) + { + List> lessonMaps = new ArrayList<>(lessons.size()); + for (TrainLesson lesson : lessons) + { + Map lm = new HashMap<>(4); + lm.put("startTime", lesson.getStartTime()); + lm.put("endTime", lesson.getEndTime()); + lm.put("planCourseId", lesson.getPlanCourseId()); + lm.put("cName", courseNameByPlanCourse.get(lesson.getPlanCourseId())); + lessonMaps.add(lm); + + if (info.classroom == null && lesson.getClassRoomId() != null && !lesson.getClassRoomId().isEmpty()) + { + TrainClassroomInfo cr = findClassroom(lesson.getClassRoomId()); + if (cr != null) + { + Map cm = new HashMap<>(4); + cm.put("classRoomId", cr.getClassRoomId()); + cm.put("roomLocation", cr.getRoomLocation()); + cm.put("roomName", cr.getRoomName()); + info.classroom = cm; + } + } + if (info.trainingSite == null && lesson.getSiteId() != null && !lesson.getSiteId().isEmpty()) + { + TrainTrainingSite ts = findTrainingSite(lesson.getSiteId()); + if (ts != null) + { + Map tm = new HashMap<>(4); + tm.put("siteId", ts.getSiteId()); + tm.put("siteLocation", ts.getSiteLocation()); + tm.put("siteName", ts.getSiteName()); + tm.put("major", ts.getMajor()); + info.trainingSite = tm; + } + } + if (info.classroom != null && info.trainingSite != null) + { + break; + } + } + info.lessons = lessonMaps; + } + return info; + } + + /** 课程表 + 教室 + 实训场地聚合结果(仅在控制器内部使用,已裁剪为所需字段)。 */ + private static class ScheduleInfo + { + public List> lessons; + public Map classroom; + public Map trainingSite; + } + + /** + * 1. 获取学员信息列表 + */ + @Operation(summary = "获取学员信息列表", description = "从服务器获取学员主档列表,需 Bearer 鉴权。") + @GetMapping("/student/list") + public AjaxResult studentList() + { + List list = studentService.selectStudentList(new StudentQuery()); + for (StudentItemVO item : list) + { + // 按学员 sId 反查 train_enrollment 的报到记录,补齐 enrollmentStatus、joinTime + List enrollments = enrollmentService.selectEnrollmentsByStudentId(item.getsId()); + if (enrollments != null && !enrollments.isEmpty()) + { + EnrollmentItemVO enr = enrollments.get(0); + item.setEnrollmentStatus(enr.getEnrollmentStatus()); + item.setJoinTime(enr.getJoinTime()); + } + } + return AjaxResult.success(list); + } + + /** + * 2. 获取教师信息列表 + * 数据取自 train_teacher,返回结构与 studentList 完全一致(复用 StudentItemVO),便于前端统一处理: + * sId = tId + "_teacher"(与人脸库教师 employee_id 口径一致),sName 取 tName。 + * 教师的 enrollmentStatus / joinTime 分别取自 train_teacher.enrollment_status / join_time。 + * 教师不参加开班报名,因此不使用 train_enrollment,但字段结构(含报到状态、报到时间)与学员列表保持一致。 + */ + @Operation(summary = "获取教师信息列表", description = "从服务器获取教师主档列表,字段结构与学员列表一致;教师无报到信息,报到状态与报到时间留空。") + @GetMapping("/teacher/list") + public AjaxResult teacherList() + { + List teachers = teacherService.selectTrainTeacherList(new TrainTeacher()); + List list = new ArrayList<>(teachers.size()); + for (TrainTeacher teacher : teachers) + { + StudentItemVO item = new StudentItemVO(); + String tId = teacher.gettId(); + item.setsId(tId == null ? null : tId + TEACHER_FACE_EMPLOYEE_SUFFIX); + item.setsName(teacher.gettName()); + item.setPersonNo(teacher.getPersonNo()); + item.setsGender(teacher.gettGender()); + item.setsBirth(teacher.gettBirth()); + item.setDeptName(teacher.getDeptName()); + item.setCardNo(teacher.getCardNo()); + item.setsPhone(teacher.gettPhone()); + item.setsEmail(teacher.gettEmail()); + item.setStatus(teacher.getStatus()); + item.setAvatar(teacher.getAvatar()); + item.setId(teacher.getId()); + // 教师参训状态取自 train_teacher.enrollment_status(0待报到/1已报到/2请假/3放弃/4已完成); + // 报到时间取自 train_teacher.join_time(未报到时为 null),字段结构与学员列表保持一致 + item.setEnrollmentStatus(teacher.getEnrollmentStatus()); + item.setJoinTime(teacher.getJoinTime()); + list.add(item); + } + return AjaxResult.success(list); + } + + @GetMapping("/student/{sId}") + public AjaxResult getInfo(@PathVariable("sId") String sId) + { + StudentDetailVO detail = studentService.selectStudentDetail(sId); + if (detail == null) + { + return AjaxResult.success((Object) null); + } + return AjaxResult.success(bindingAdminService.toStudentAdminVO(detail)); + } + + /** + * 3. 获取学员基础信息(宿舍/教室/开班/) + */ + @Operation(summary = "获取学员基础信息", description = "按 sId 汇总该学员的宿舍、教室、开班、课表、实训场地信息,供报到机展示。") + @GetMapping("/student/{sId}/base") + public AjaxResult studentBase(@PathVariable("sId") String sId) + { + String planId = resolvePlanId(sId); + ScheduleInfo schedule = resolveSchedule(sId); + + Map data = new HashMap<>(8); + //data.put("sId", sId); + data.put("studInfo", findStudentInfo(sId)); + data.put("classPlan", planId != null ? findClassPlan(planId) : null); + //data.put("courses", planId != null ? findPlanCourses(planId) : null); + //data.put("lesson", schedule.lessons); + data.put("dormitory", findDormitory(sId, null)); + data.put("classroom", schedule.classroom); + data.put("trainingSite", schedule.trainingSite); + return AjaxResult.success(data); + } + /** + * 3. 获取学员关联资源(宿舍/教室/开班/课表/实训场地) + */ + @Operation(summary = "获取学员关联资源", description = "按 sId 汇总该学员的宿舍、教室、开班、课表、实训场地信息,供报到机展示。") + @GetMapping("/student/{sId}/resources") + public AjaxResult studentResources(@PathVariable("sId") String sId) + { + String planId = resolvePlanId(sId); + ScheduleInfo schedule = resolveSchedule(sId); + + Map data = new HashMap<>(8); + //data.put("sId", sId); + data.put("studInfo", findStudentInfo(sId)); + data.put("classPlan", planId != null ? findClassPlan(planId) : null); + data.put("courses", planId != null ? findPlanCourses(planId) : null); + data.put("lesson", schedule.lessons); + data.put("dormitory", findDormitory(sId, null)); + data.put("classroom", schedule.classroom); + data.put("trainingSite", schedule.trainingSite); + return AjaxResult.success(data); + } + + /** + * 3.1 获取教师基础信息(教师信息 / 宿舍 / 课表) + * 仿照 studentBase:按 tId 分别取教师主档、教师当前有效住宿、教师授课课次,统一放入 data 返回。 + * 教师不关联开班报名,故不返回 classPlan;教室/实训场地信息已包含在课次明细(venueName)中。 + */ + @Operation(summary = "获取教师基础信息", description = "按 tId 汇总教师基本信息、宿舍信息与授课课表信息。") + @GetMapping("/teacher/{tId}/base") + public AjaxResult teacherBase(@PathVariable("tId") String tId) + { + // 兼容以 tId + "_teacher" 口径传入的人员ID:去除后缀后再执行后续查询,其余逻辑不变 + if (tId != null && tId.endsWith(TEACHER_FACE_EMPLOYEE_SUFFIX)) + { + tId = tId.substring(0, tId.length() - TEACHER_FACE_EMPLOYEE_SUFFIX.length()); + } + TrainTeacher teacher = teacherService.selectTrainTeacherByTId(tId); + + // 教师基本信息(字段裁剪口径与学员 studInfo 保持一致,另补充教师专属字段) + Map teacherInfo = null; + if (teacher != null) + { + teacherInfo = new HashMap<>(8); + teacherInfo.put("tId", teacher.gettId()); + teacherInfo.put("tName", teacher.gettName()); + teacherInfo.put("tPhone", teacher.gettPhone()); + teacherInfo.put("tGender", teacher.gettGender()); + teacherInfo.put("deptName", teacher.getDeptName()); + teacherInfo.put("personNo", teacher.getPersonNo()); + teacherInfo.put("avatar", teacher.getAvatar()); + } + + // 教师宿舍信息(复用教师住宿安排查询,字段口径与学员 dormitory 一致) + TeacherAccommodationVO accommodation = (teacher != null && teacher.getId() != null) + ? teacherAccommodationService.getActiveByTeacherId(teacher.getId()) : null; + String dormId = accommodation != null ? accommodation.getDormId() : null; + Map dormitory = new HashMap<>(8); + dormitory.put("dormId", dormId); + if (accommodation != null) + { + dormitory.put("bedNo", accommodation.getBedNo()); + } + if (StringUtils.isNotEmpty(dormId)) + { + TrainDormitoryInfo dormInfo = dormitoryInfoService.selectTrainDormitoryInfoByDormId(dormId); + if (dormInfo != null) + { + dormitory.put("dormBuilding", dormInfo.getDormBuilding()); + dormitory.put("dormRoom", dormInfo.getDormRoom()); + } + } + + // 教师授课课表(复用排课日程查询,按开始时间升序;不传日期范围即取全部课次) + List lessons = lessonMapper.selectScheduleLessonsByTeacherId(tId, null, null); + + Map data = new HashMap<>(8); + data.put("teacherInfo", teacherInfo); + data.put("dormitory", dormitory); + data.put("lesson", lessons); + return AjaxResult.success(data); + } + + /** + * 4. 获取进行中班级的学员名单 + * 先从 train_class_plan 取所有"进行中"班级(planId/planName/classId/className), + * 再按 planId 从 train_enrollment 取本班学员 studentId,并关联 train_student 取 sId/sName/deptId/deptName。 + * 学员列表作为班级的一个属性(students)随班级一起返回。 + */ + @Operation(summary = "获取进行中班级的学员名单", description = "返回所有进行中班级及其学员列表,学员含 sId、sName、deptId、deptName。") + @GetMapping("/class/student") + public AjaxResult classStudent() + { + PlanQuery planQuery = new PlanQuery(); + planQuery.setPlanStatus(1); // 进行中(有效状态 1) + List plans = classPlanService.selectTrainClassPlanList(planQuery); + + List> result = new ArrayList<>(plans.size()); + for (PlanItemVO plan : plans) + { + Map classMap = new HashMap<>(8); + classMap.put("planId", plan.getPlanId()); + classMap.put("planName", plan.getPlanName()); + classMap.put("classId", plan.getClassId()); + classMap.put("className", plan.getClassName()); + + EnrollmentQuery enrollQuery = new EnrollmentQuery(); + enrollQuery.setPlanId(plan.getPlanId()); + //过滤所有未报到的学员 + enrollQuery.setEnrollmentStatus(0); + List enrollments = enrollmentService.selectEnrollmentItemList(enrollQuery); + + List> students = new ArrayList<>(enrollments.size()); + for (EnrollmentItemVO enrollment : enrollments) + { + Map studentMap = buildStudentMap(enrollment); + if (studentMap != null) + { + students.add(studentMap); + } + } + classMap.put("students", students); + result.add(classMap); + } + return AjaxResult.success(result); + } + + /** + * 将一条报名记录转换为学员信息 Map。 + * 优先取 train_student 主档(sId/sName/deptId/deptName); + * 若主档不存在(selectStudentDetail 会抛 STUDENT_NOT_FOUND 异常),则用 train_enrollment 冗余字段兜底, + * 保证单个学员缺失不会中断整个班级的返回。 + */ + private Map buildStudentMap(EnrollmentItemVO enrollment) + { + String studentId = enrollment.getStudentId(); + if (studentId == null || studentId.isEmpty()) + { + return null; + } + Map studentMap = new HashMap<>(4); + try + { + StudentDetailVO detail = studentService.selectStudentDetail(studentId); + if (detail != null) + { + studentMap.put("sId", detail.getsId()); + studentMap.put("sName", detail.getsName()); + studentMap.put("deptId", detail.getDeptId()); + studentMap.put("deptName", detail.getDeptName()); + return studentMap; + } + } + catch (RuntimeException ex) + { + // 主档缺失,使用报名记录中的冗余信息兜底 + } + // 兜底:用 train_enrollment 冗余字段填充 + studentMap.put("sId", studentId); + studentMap.put("sName", enrollment.getStudentName()); + studentMap.put("deptId", null); + studentMap.put("deptName", enrollment.getDeptName()); + return studentMap; + } + + /** + * 5. 获取宿舍布局信息(报到机) + * 查询 train_dormitory_info,按 dorm_building → dorm_floor → dorm_room 逐层分组, + * 并关联 train_accommodation 统计每个房间的已分配床位数,返回结构: + * {楼栋名称: {楼层: {房间号: {id, dorm_room, accommodation:"已分配数/总数"}}}}。 + */ + @Operation(summary = "获取宿舍布局信息", description = "按楼栋、楼层、房间号分组返回宿舍布局及每间房的已分配床位数/总床位数。") + @GetMapping("/dormitory/layout") + public AjaxResult dormitoryLayout() + { + return AjaxResult.success(dormitoryInfoService.selectDormitoryLayout()); + } + + /** + * 6. 上传学员人脸照片(报到机) + * 接收 JSON:sId(学员ID)、faceImage(base64 人脸图片,可带 data URI 前缀)。 + * 处理:解码并将图片转换为 PNG 存入 RuoYiConfig.getProfile()/avatar 下, + * 再把可访问路径更新到 train_student.avatar(仅更新该字段)。 + */ + @Operation(summary = "上传学员人脸照片", description = "接收 base64 人脸图片,转换为 PNG 落盘并更新 train_student.avatar。") + @PostMapping("/student/face") + public AjaxResult uploadStudentFace(@RequestBody Map body) + { + Object sIdObj = body.get("sId"); + Object faceObj = body.get("faceImage"); + if (sIdObj == null || faceObj == null) + { + return AjaxResult.error("参数缺失:sId 与 faceImage 均为必填"); + } + String sId = sIdObj.toString(); + String base64 = faceObj.toString(); + if (sId.isEmpty()) + { + return AjaxResult.error("sId 不能为空"); + } + + byte[] pngBytes; + try + { + pngBytes = convertBase64ToPng(base64); + } + catch (Exception ex) + { + return AjaxResult.error("图片解析失败:" + ex.getMessage()); + } + + String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); + String fileName = UUID.randomUUID().toString().replace("-", "") + ".png"; + System.out.println("fileName = " + fileName); + File dir = new File(RuoYiConfig.getProfile() + File.separator + "upload" + File.separator + datePath); + if (!dir.exists() && !dir.mkdirs()) + { + return AjaxResult.error("创建存储目录失败"); + } + File target = new File(dir, fileName); + try + { + Files.write(target.toPath(), pngBytes); + } + catch (IOException ex) + { + return AjaxResult.error("图片写入失败:" + ex.getMessage()); + } + + String avatarPath = Constants.RESOURCE_PREFIX + "/upload/" + datePath + "/" + fileName; + System.out.println("avatarPath = " + avatarPath); + // 更新头像路径 + TrainStudent student = new TrainStudent(); + student.setsId(sId); + student.setAvatar(avatarPath); + student.setUpdateTime(new Date()); + try + { + student.setUpdateBy(SecurityUtils.getUsername()); + } + catch (Exception ignored) + { + // 无登录上下文时留空 + } + int rows = trainStudentMapper.updateTrainStudent(student); + if (rows <= 0) + { + return AjaxResult.error("学员不存在或更新失败"); + } + + Map data = new HashMap<>(2); + data.put("sId", sId); + data.put("avatar", avatarPath); + + // 同步到人脸识别设备:以 sId 作为 employee_id 调用 TrainFaceApiController.addEmployeeFace + // (头像已写入 train_student,addEmployeeFace 会读取该头像转 data URL 后下发设备) + // 头像入库与本步设备同步相互独立:无论设备同步成败,均返回已保存的头像信息, + // 并通过 faceSync / faceMsg 明确回传设备同步状态,避免静默吞掉失败、也统一响应结构。 + data.put("faceSync", "skipped"); + try + { + TrainFaceApiController faceController = SpringUtils.getBean(TrainFaceApiController.class); + Map facePayload = new HashMap<>(1); + facePayload.put("employee_id", sId); + log.info("syncFace facePayload={}", facePayload); + AjaxResult faceResult = faceController.addEmployeeFace(facePayload); + if (faceResult != null && faceResult.isSuccess()) + { + data.put("faceSync", "success"); + // 把设备返回的结果并入 data 一并返回(设备侧 data 字段可能是 Map 或单个值) + Object faceData = faceResult.get(AjaxResult.DATA_TAG); + if (faceData instanceof Map) + { + @SuppressWarnings("unchecked") + Map faceMap = (Map) faceData; + data.putAll(faceMap); + } + else + { + data.put("faceData", faceData); + } + } + else + { + data.put("faceSync", "failed"); + data.put("faceMsg", faceResult == null ? "设备无响应" : (String) faceResult.get(AjaxResult.MSG_TAG)); + log.warn("人脸设备同步未成功 sId={} msg={}", sId, faceResult == null ? "null" : faceResult.get(AjaxResult.MSG_TAG)); + } + } + catch (Exception ex) + { + data.put("faceSync", "failed"); + data.put("faceMsg", ex.getMessage()); + log.warn("人脸设备同步异常 sId={}", sId, ex); + } + // 回查 train_enrollment 报到信息(已加入班级即有记录,更新报到状态即可),取第一条记录办理报到(列表为空则跳过,避免空指针) + List enrollmentList = enrollmentService.selectEnrollmentsByStudentId(sId); + TrainEnrollment enrollment = null; + Date enrollmentDate = null; + if (enrollmentList != null && !enrollmentList.isEmpty()) + { + enrollment = enrollmentService.selectTrainEnrollmentById(enrollmentList.get(0).getEnrollmentId()); + if (enrollment != null) + { + enrollment.setEnrollmentStatus(1); + enrollmentDate = new Date(); + enrollment.setJoinTime(enrollmentDate); + enrollmentService.updateTrainEnrollment(enrollment); + } + } + // 报到判定依据:按 create_time 降序排序后的第一条住宿记录(Mapper 以 ORDER BY a.create_time DESC + LIMIT 1 保证) + TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sId); + log.info("学员 sId={} 报到记录 enrollmentId={}, dormId={}", sId, + enrollment != null ? enrollment.getEnrollmentId() : null, + acc != null ? acc.getDormId() : null); + log.info("学员 sId={} 报到判定依据(create_time 降序首条) accommodationId={}, accommodationStatus={}, createTime={}", + sId, + acc == null ? null : acc.getAccommodationId(), + acc == null ? null : acc.getAccommodationStatus(), + acc == null ? null : acc.getCreateTime()); + // 分支判断:以排序后的首条记录为准,存在宿舍即进入报到/入住处理 + if (acc != null && StringUtils.isNotEmpty(acc.getDormId())) { + // 步骤3:宿舍 → 门锁设备序列号 + TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(acc.getDormId()); + log.info("学员 sId={} 宿舍信息 dorm={}", sId, dorm); + if (dorm != null) + {// 调用对外开放房间门禁服务的入住接口(/openapi/iot/stay/checkin,逻辑统一由住宿 Service 实现) + String lockApiBizNo = accommodationService.generateLockApiBizNo(); + acc.setLockApiBizNo(lockApiBizNo); + accommodationService.updateTrainAccommodation(acc); + log.info("学员 sId={} 入住门锁 bizNo={}", sId, lockApiBizNo); + // 调用门禁授权接口:办理入住 (/openapi/iot/stay/checkin) + LockStayCheckinResult lockCheckinResult = accommodationService.callStayCheckin(lockApiBizNo, enrollment, dorm); + data.put("lockCheckin", lockCheckinResult); + data.put("lockAuthId", ""); + data.put("joinTime", enrollmentDate); + log.info("已为学员 sId={} 在门锁 roomId={} 创建人脸授权", sId, dorm.getDormRoom()); + } + } + + + + // 往宿舍门锁下发人脸:sId → train_enrollment(enrollmentId) → train_accommodation(dormId) + // → train_dormitory_info(deviceSn) → lock_device(deviceId) + /*TrainStudent stud = trainStudentMapper.selectTrainStudentBySId(sId); + log.info("学员信息 stud={}", stud ); + // 步骤1/2:根据 sId 取当前有效住宿记录(含 enrollmentId、dormId;enrollmentId 即 train_enrollment 报到记录主键) + TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sId); + if (acc != null && StringUtils.isNotEmpty(acc.getDormId())) + { + // 回查 train_enrollment 报到信息 + TrainEnrollment enrollment = enrollmentService.selectTrainEnrollmentById(acc.getEnrollmentId()); + enrollment.setEnrollmentStatus(1); + Date enrollmentDate = new Date(); + enrollment.setJoinTime(enrollmentDate); + enrollmentService.updateTrainEnrollment(enrollment); + log.info("学员 sId={} 报到记录 enrollmentId={}, dormId={}", sId, + enrollment != null ? enrollment.getEnrollmentId() : null, acc.getDormId()); + // 步骤3:宿舍 → 门锁设备序列号 + TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(acc.getDormId()); + if (dorm != null && StringUtils.isNotEmpty(dorm.getLockDeviceSn())) + { + // 步骤4:门锁 SN → lock_device 的 deviceId + LockDevice lockDevice = lockDeviceMapper.selectDeviceBySn(dorm.getLockDeviceSn()); + if (lockDevice != null) + { + //data.put("dormDeviceId", lockDevice.getDeviceId()); + //data.put("dormDeviceSn", lockDevice.getDeviceSn()); + log.info("宿舍门锁下发人脸目标 deviceId={}, deviceSn={}, sId={}", + lockDevice.getDeviceId(), lockDevice.getDeviceSn(), sId); + // 宿舍门锁下发人脸:提取人脸特征 → 创建门锁授权 + try + { + String feature = this.studentService.extractStudent(sId, lockDevice.getDeviceId()); + log.warn("学员 feature={}", feature); + if (StringUtils.isEmpty(feature)) + { + log.warn("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本"); + } + else if (stud == null || StringUtils.isEmpty(stud.getPersonNo())) + { + log.warn("学员 sId={} 主档缺失或未配置 personNo,跳过门锁授权创建", sId); + } + else + { + LockAuth auth = new LockAuth(); + auth.setAuthType("01"); // 01=人脸 + auth.setPersonNo(stud.getPersonNo()); + auth.setPersonName(stud.getsName()); + auth.setDeviceId(lockDevice.getDeviceId()); + auth.setAuthValue(feature); // 人脸特征 + // 截断到整秒:避免 MySQL DATETIME 对毫秒四舍五入,导致落库授权时间与下发帧相差 1 秒而触发 Payload mismatch + Date now = new Date(System.currentTimeMillis() / 1000 * 1000); + auth.setBeginTime(now); + auth.setEndTime(new Date(now.getTime() + 14L * 24 * 60 * 60 * 1000)); // 默认两周有效 + log.info("学员 auth={}",auth); + //lockAuthService.insertAuth(auth); + + + } + } + catch (Exception e) + { + log.warn("宿舍门锁人脸下发失败 sId={}, deviceId={}, msg={}", sId, lockDevice.getDeviceId(), e.getMessage()); + } + } + else + { + log.warn("宿舍门锁 SN={} 在 lock_device 中未找到对应设备", dorm.getLockDeviceSn()); + } + } + else + { + log.warn("宿舍 dormId={} 未配置门锁序列号", acc.getDormId()); + } + } + else + { + log.info("学员 sId={} 暂无有效住宿记录,跳过宿舍门锁下发", sId); + }*/ + + return AjaxResult.success(data); + } + + /** + * 7. 上传教师人脸照片 + * 接收 JSON:teacherId(教师工号,对应 train_teacher.t_id)、faceImage(base64 人脸图片,可带 data URI 前缀)。 + * 处理:解码并将图片转换为 PNG 存入 RuoYiConfig.getProfile()/upload 下, + * 再把可访问路径更新到 train_teacher.avatar(仅更新该字段)。 + *

与学员流程的差异:教师不参加开班报名,不存在 train_enrollment 记录, + * 因此不做报到记录查询、报到状态校验与更新;宿舍分配与门锁入住授权由教师住宿安排 + * (/train/teacher/accommodation)驱动,本方法仅在教师已有有效住宿且已生成门锁流水号时补发一次入住授权, + * 上传人脸本身不依赖宿舍/门锁信息(未分配宿舍同样视为成功)。

+ */ + @Operation(summary = "上传教师人脸照片", description = "接收 base64 人脸图片,转换为 PNG 落盘并更新 train_teacher.avatar;教师无报名记录,不更新报到状态。") + @PostMapping("/teacher/face") + public AjaxResult uploadTeacherFace(@RequestBody Map body) + { + Object teacherIdObj = body.get("teacherId"); + Object faceObj = body.get("faceImage"); + if (teacherIdObj == null || faceObj == null) + { + return AjaxResult.error("参数缺失:teacherId 与 faceImage 均为必填"); + } + String teacherId = teacherIdObj.toString(); + String base64 = faceObj.toString(); + if (teacherId.isEmpty()) + { + return AjaxResult.error("teacherId 不能为空"); + } + + byte[] pngBytes; + try + { + pngBytes = convertBase64ToPng(base64); + } + catch (Exception ex) + { + return AjaxResult.error("图片解析失败:" + ex.getMessage()); + } + + String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); + String fileName = UUID.randomUUID().toString().replace("-", "") + ".png"; + File dir = new File(RuoYiConfig.getProfile() + File.separator + "upload" + File.separator + datePath); + if (!dir.exists() && !dir.mkdirs()) + { + return AjaxResult.error("创建存储目录失败"); + } + File target = new File(dir, fileName); + try + { + Files.write(target.toPath(), pngBytes); + } + catch (IOException ex) + { + return AjaxResult.error("图片写入失败:" + ex.getMessage()); + } + + String avatarPath = Constants.RESOURCE_PREFIX + "/upload/" + datePath + "/" + fileName; + // 更新教师头像路径(教师表主键为 t_id) + TrainTeacher teacher = new TrainTeacher(); + teacher.settId(teacherId); + teacher.setAvatar(avatarPath); + teacher.setUpdateTime(new Date()); + try + { + teacher.setUpdateBy(SecurityUtils.getUsername()); + } + catch (Exception ignored) + { + // 无登录上下文时留空 + } + int rows = teacherService.updateTrainTeacher(teacher); + if (rows <= 0) + { + return AjaxResult.error("教师不存在或更新失败"); + } + + Map data = new HashMap<>(4); + data.put("tId", teacherId); + data.put("avatar", avatarPath); + + // 同步到人脸识别设备:教师统一以 tId + "_teacher" 作为 employee_id + // (与教师住宿分配/退宿的人脸口径一致,同时与学生 sId 隔离)。 + // 头像入库与本步设备同步相互独立:无论设备同步成败,均返回已保存的头像信息, + // 并通过 faceSync / faceMsg 明确回传设备同步状态。 + data.put("faceSync", "skipped"); + String employeeId = teacherId + TEACHER_FACE_EMPLOYEE_SUFFIX; + try + { + TrainFaceApiController faceController = SpringUtils.getBean(TrainFaceApiController.class); + Map facePayload = new HashMap<>(1); + facePayload.put("employee_id", employeeId); + log.info("syncFace facePayload={}", facePayload); + AjaxResult faceResult = faceController.addEmployeeFace(facePayload); + if (faceResult != null && faceResult.isSuccess()) + { + data.put("faceSync", "success"); + Object faceData = faceResult.get(AjaxResult.DATA_TAG); + if (faceData instanceof Map) + { + @SuppressWarnings("unchecked") + Map faceMap = (Map) faceData; + data.putAll(faceMap); + } + else + { + data.put("faceData", faceData); + } + } + else + { + data.put("faceSync", "failed"); + data.put("faceMsg", faceResult == null ? "设备无响应" : (String) faceResult.get(AjaxResult.MSG_TAG)); + log.warn("人脸设备同步未成功 tId={} msg={}", teacherId, faceResult == null ? "null" : faceResult.get(AjaxResult.MSG_TAG)); + } + } + catch (Exception ex) + { + data.put("faceSync", "failed"); + data.put("faceMsg", ex.getMessage()); + log.warn("人脸设备同步异常 tId={}", teacherId, ex); + } + + // 教师无 train_enrollment 报到记录,跳过报到状态处理(与学员流程的业务差异)。 + // 宿舍/门锁:仅在教师已有有效住宿且已生成门锁流水号时补发一次入住授权, + // 覆盖「先分配宿舍、后上传人脸」的场景;未分配宿舍直接跳过,不影响上传结果。 + TrainTeacher teacherInfo = teacherService.selectTrainTeacherByTId(teacherId); + if (teacherInfo != null && teacherInfo.getId() != null) + { + TeacherAccommodationVO accommodation = teacherAccommodationService.getActiveByTeacherId(teacherInfo.getId()); + if (accommodation != null && StringUtils.isNotEmpty(accommodation.getDormId()) + && StringUtils.isNotEmpty(accommodation.getLockApiBizNo())) + { + TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(accommodation.getDormId()); + if (dorm != null && trainLockStayApiClient != null) + { + Date beginTime = new Date(); + Date endTime = new Date(beginTime.getTime() + TEACHER_LOCK_AUTH_VALID_DAYS * 24L * 60 * 60 * 1000); + LockStayCheckinResult lockCheckinResult = trainLockStayApiClient.checkinWithResult( + accommodation.getLockApiBizNo(), employeeId, teacherInfo.gettName(), + dorm.getDormRoom(), beginTime, endTime, avatarPath); + data.put("lockCheckin", lockCheckinResult); + data.put("lockAuthId", ""); + log.info("已为教师 tId={} 在门锁 roomId={} 补发人脸授权, result={}", + teacherId, dorm.getDormRoom(), lockCheckinResult); + } + } + } + + return AjaxResult.success(data); + } + + /** + * 8. 上传人脸照片入口(按 employeeId 后缀分发) + * 接收 JSON:employeeId(人脸库人员ID,String)、faceImage(base64 人脸图片,String)。 + * 分发规则: + *
    + *
  • employeeId 以 "_teacher" 结尾 → 去掉后缀得到 tId,转交 uploadTeacherFace(教师人脸);
  • + *
  • 其它情况 → 以 employeeId 作为 sId,转交 uploadStudentFace(学员人脸)。
  • + *
+ * 边界处理: + *
    + *
  • employeeId 或 faceImage 为 null → 参数缺失;employeeId 为空串 → 不能为空;
  • + *
  • employeeId 仅由 "_teacher" 组成(教师工号为空)→ 判定为非法参数,不再下发;
  • + *
  • 后缀匹配使用区分大小写的 endsWith,且不做 trim,与 TrainFaceApiController#addEmployeeFace + * 的判定口径保持一致("_TEACHER" 视为学员ID,前后空格不做裁剪)。
  • + *
+ * 分发后直接返回被调用方法的 AjaxResult,返回格式与两个上传接口保持一致。 + */ + @Operation(summary = "上传人脸照片", description = "按 employeeId 后缀分发:以 _teacher 结尾转教师人脸上传,否则转学员人脸上传。") + @PostMapping("/face") + public AjaxResult uploadFace(@RequestBody Map body) + { + Object employeeIdObj = body.get("employeeId"); + Object faceObj = body.get("faceImage"); + if (employeeIdObj == null || faceObj == null) + { + return AjaxResult.error("参数缺失:employeeId 与 faceImage 均为必填"); + } + String employeeId = employeeIdObj.toString(); + if (employeeId.isEmpty()) + { + return AjaxResult.error("employeeId 不能为空"); + } + + Map target = new HashMap<>(2); + target.put("faceImage", faceObj); + if (employeeId.endsWith(TEACHER_FACE_EMPLOYEE_SUFFIX)) + { + // 教师:employeeId 形如 tId + "_teacher",截取前缀作为教师工号 + String tId = employeeId.substring(0, employeeId.length() - TEACHER_FACE_EMPLOYEE_SUFFIX.length()); + if (tId.isEmpty()) + { + return AjaxResult.error("employeeId 非法:教师工号(tId)不能为空"); + } + target.put("teacherId", tId); + log.info("uploadFace 分发到教师人脸 tId={}", tId); + return uploadTeacherFace(target); + } + // 学员:employeeId 即 sId + target.put("sId", employeeId); + log.info("uploadFace 分发到学员人脸 sId={}", employeeId); + return uploadStudentFace(target); + } + + /** + * 将 base64(可带 data URI 前缀)解码并转码为 PNG 字节。 + */ + private byte[] convertBase64ToPng(String base64) throws IOException + { + String pure = base64; + int comma = base64.indexOf(','); + if (comma > 0 && base64.startsWith("data:")) + { + pure = base64.substring(comma + 1); + } + byte[] input = java.util.Base64.getDecoder().decode(pure); + BufferedImage image = ImageIO.read(new ByteArrayInputStream(input)); + if (image == null) + { + throw new IllegalArgumentException("不是合法的图片数据"); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + if (!ImageIO.write(image, "png", baos)) + { + throw new IllegalArgumentException("无法编码为 PNG"); + } + return baos.toByteArray(); + } + + /** + * 报到重置(误点他人信息导致人脸错绑的补救接口)。 + *

执行顺序:解绑人脸 → 清除门锁入住授权 → 参训状态重置为未报到(0)。 + * 宿舍分配信息保留,不做退宿处理(不触碰 train_accommodation / 住宿状态)。

+ * + * @param planId 开班ID(train_enrollment.plan_id) + * @param studentId 学员ID(被误绑人脸的那名学员) + */ + @Operation(summary = "报到重置", description = "误点他人信息导致人脸错绑时:先解绑人脸,再清除门锁入住授权,最后把参训状态重置为未报到(0),宿舍分配保留。") + @PostMapping("/report/reset") + public AjaxResult resetReport(@RequestParam("planId") String planId, + @RequestParam("studentId") String studentId) + { + if (StringUtils.isEmpty(planId) || StringUtils.isEmpty(studentId)) + { + return AjaxResult.error("planId 和 studentId 不能为空"); + } + String pid = planId.trim(); + String sid = studentId.trim(); + + TrainEnrollment enrollment = enrollmentService.selectTrainEnrollmentByPlanIdAndStudentId(pid, sid); + if (enrollment == null) + { + return AjaxResult.error("未找到学员[" + sid + "]在班级[" + pid + "]的参训记录"); + } + Integer previousStatus = enrollment.getEnrollmentStatus(); + if (Integer.valueOf(3).equals(previousStatus)) + { + return AjaxResult.error("学员已放弃培训,不允许重置报到状态"); + } + if (Integer.valueOf(4).equals(previousStatus)) + { + return AjaxResult.error("学员已结训,不允许重置报到状态"); + } + + Map data = new HashMap<>(8); + data.put("planId", pid); + data.put("studentId", sid); + data.put("enrollmentId", enrollment.getEnrollmentId()); + data.put("previousStatus", previousStatus); + + // 步骤1:解除人脸绑定(复用 TrainFaceApiController.deleteEmployee) + AjaxResult faceResult = trainFaceApiController.deleteEmployee(sid); + if (!Integer.valueOf(200).equals(faceResult.get(AjaxResult.CODE_TAG))) + { + return AjaxResult.error("步骤1 人脸解绑失败:" + faceResult.get(AjaxResult.MSG_TAG) + ";参训状态未变更"); + } + data.put("faceUnbind", "success"); + + // 步骤2:清除门锁入住授权(复用 TrainLockApiController.checkout),住宿分配记录本身不动 + TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sid); + if (acc == null) + { + data.put("lockCheckout", "skipped: 无有效住宿记录"); + } + else if (StringUtils.isEmpty(acc.getLockApiBizNo()) && StringUtils.isEmpty(acc.getDormId())) + { + data.put("lockCheckout", "skipped: 缺少门锁业务流水号与宿舍信息"); + } + else + { + Map checkoutBody = new HashMap<>(4); + if (StringUtils.isNotEmpty(acc.getLockApiBizNo())) + { + checkoutBody.put("bizNo", acc.getLockApiBizNo()); + } + else + { + TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(acc.getDormId()); + checkoutBody.put("externalPersonId", sid); + if (dorm != null && StringUtils.isNotEmpty(dorm.getDormRoom())) + { + checkoutBody.put("roomNo", dorm.getDormRoom()); + } + } + ResponseEntity checkoutResp = trainLockApiController.checkout(checkoutBody); + Integer checkoutCode = null; + String checkoutMsg = null; + try + { + JSONObject json = JSON.parseObject(checkoutResp.getBody()); + if (json != null) + { + checkoutCode = json.getInteger("code"); + checkoutMsg = json.getString("msg"); + } + } + catch (Exception e) + { + return AjaxResult.error("步骤2 门锁授权清除响应解析失败:" + e.getMessage() + + ";人脸已解绑,参训状态未变更"); + } + if (!Integer.valueOf(200).equals(checkoutCode)) + { + String reason = checkoutMsg != null ? checkoutMsg : ("HTTP " + checkoutResp.getStatusCode()); + return AjaxResult.error("步骤2 门锁授权清除失败:" + reason + + ";人脸已解绑,参训状态未变更"); + } + data.put("lockCheckout", "success"); + data.put("lockApiBizNo", acc.getLockApiBizNo()); + } + + // 步骤3:参训状态重置为未报到(0);remark 传 null 保留原备注,仅更新状态与审计字段 + int updated = enrollmentService.updateEnrollmentStatus(enrollment.getEnrollmentId(), 0, null, + SecurityUtils.getUsername(), new Date()); + if (updated <= 0) + { + return AjaxResult.error("步骤3 参训状态重置失败;人脸与门锁授权已解除,请人工核对后重试"); + } + data.put("currentStatus", 0); + data.put("alreadyReset", Integer.valueOf(0).equals(previousStatus)); + data.put("accommodationKept", acc != null); + + // 步骤4:按 studentId(train_student.s_id) 清空该学员头像,置为 NULL;必须在接口返回前完成 + TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sid); + if (student == null) + { + AjaxResult fail = AjaxResult.error("报到重置已完成,但未找到有效学员档案[" + sid + "],avatar 未清空"); + fail.put(AjaxResult.DATA_TAG, data); + return fail; + } + try + { + // avatar 原本已是 NULL 时受影响行数可能为 0,故以“档案存在 + 更新无异常”判定成功 + trainStudentMapper.clearAvatarToNullBySId(student.getsId()); + } + catch (Exception e) + { + log.warn("报到重置后清空学员头像失败 sId={}, msg={}", sid, e.getMessage()); + AjaxResult fail = AjaxResult.error("报到重置已完成,但清空学员头像失败:" + e.getMessage()); + fail.put(AjaxResult.DATA_TAG, data); + return fail; + } + data.put("avatarCleared", true); + data.put("avatarSId", student.getsId()); + + log.info("报到重置完成 planId={}, studentId={}, enrollmentId={}, previousStatus={}, avatarCleared=true", + pid, sid, enrollment.getEnrollmentId(), previousStatus); + return AjaxResult.success("报到重置成功", data); + } + + /** + * 9. 获取课程安排(报到机课表) + * 参数 startTime 对应 train_lesson.start_time,缺省取当天(北京时间);支持 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、 + * ISO-8601(含 Z / 时区偏移)以及 10 位(秒)/13 位(毫秒)时间戳,按参数所在自然日查询。 + * 参数 courseType 为课程类型,可选,取值 1(理论)/ 2(实训),与 train_course.c_type 字段取值一致, + * 缺省不筛选、返回当日全部类型;非法值返回错误提示。 + * 查询以 train_lesson 为主表,关联 train_plan_course / train_course / train_teacher / + * train_classroom_info / train_training_site / train_class_plan(left join,不按 del_flag 过滤课次)。 + * 只下发 yyyy-MM-dd 日期串,当天区间由 SQL 内 STR_TO_DATE 生成,规避 JDBC serverTimezone 转换造成的跨天偏移。 + * 返回结构:以日期(yyyy-MM-dd)为键,值为该日课程列表,元素含 id、name、courseType、startTime、teacher、classroom 等。 + */ + @Operation(summary = "获取课程安排", description = "按课程开始时间(默认当天)与课程类型(courseType:1-理论,2-实训,可选)查询课程安排,以日期为键分组返回课程名称、课程类型、上课地点、上课教师等信息。") + @GetMapping("/lesson/schedule") + public AjaxResult lessonSchedule(@RequestParam(value = "startTime", required = false) String startTime, + @RequestParam(value = "courseType", required = false) String courseType) + { + LocalDate day = resolveScheduleDay(startTime); + if (day == null) + { + return AjaxResult.error("startTime 格式不正确,支持 yyyy-MM-dd / yyyy-MM-dd HH:mm:ss / ISO-8601,示例:2026-11-09"); + } + String courseTypeValue; + try + { + courseTypeValue = resolveScheduleCourseType(courseType); + } + catch (IllegalArgumentException ex) + { + return AjaxResult.error(ex.getMessage()); + } + String dayKey = day.format(SCHEDULE_DATE_FORMAT); + List lessons = lessonService.selectLessonScheduleByTime(dayKey, courseTypeValue); + int rows = lessons == null ? 0 : lessons.size(); + // 可观测性:区分“SQL 未命中”与“命中未返回”,排查空课表时优先看这一行日志 + log.info("lessonSchedule 课程安排查询 day={}, courseType={}, rows={}", dayKey, courseTypeValue, rows); + + Map> result = new LinkedHashMap<>(4); + if (lessons == null) + { + return AjaxResult.success(result); + } + String fallbackKey = dayKey; + for (CourseScheduleItemVO lesson : lessons) + { + String dateKey = lesson.getStartTime() == null ? fallbackKey : formatScheduleDate(lesson.getStartTime()); + List items = result.get(dateKey); + if (items == null) + { + items = new ArrayList<>(); + result.put(dateKey, items); + } + items.add(lesson); + } + return AjaxResult.success(result); + } + + /** + * 将课次开始时间按北京时间格式化为日期键(yyyy-MM-dd)。 + */ + private String formatScheduleDate(Date date) + { + return Instant.ofEpochMilli(date.getTime()).atZone(SCHEDULE_ZONE).toLocalDate().format(SCHEDULE_DATE_FORMAT); + } + + /** + * 解析课程类型参数:可选,取值 1(理论)/ 2(实训),与 train_course.c_type 存储值一致。 + * 缺省或空串返回 null,表示不按课程类型筛选。 + * + * @param courseType 课程类型参数(1:理论, 2:实训) + * @return 归一化后的课程类型;未传时返回 null + * @throws IllegalArgumentException 取值不在允许范围内 + */ + private String resolveScheduleCourseType(String courseType) + { + if (StringUtils.isEmpty(courseType) || StringUtils.isEmpty(courseType.trim())) + { + return null; + } + String value = courseType.trim(); + if (SCHEDULE_COURSE_TYPE_THEORY.equals(value) || SCHEDULE_COURSE_TYPE_PRACTICE.equals(value)) + { + return value; + } + throw new IllegalArgumentException("courseType 仅支持 1(理论)或 2(实训)"); + } + + /** + * 解析课表日期参数:缺省取当天(北京时间)。 + * 兼容 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy/MM/dd、ISO-8601(T 分隔、带 Z 或时区偏移)及 10/13 位时间戳。 + * + * @param startTime 课程开始时间参数 + * @return 参数所在自然日;解析失败返回 null + */ + private LocalDate resolveScheduleDay(String startTime) + { + if (StringUtils.isEmpty(startTime) || StringUtils.isEmpty(startTime.trim())) + { + return LocalDate.now(SCHEDULE_ZONE); + } + String value = startTime.trim(); + try + { + if (value.matches("\\d{10}|\\d{13}")) + { + long millis = Long.parseLong(value); + return Instant.ofEpochMilli(value.length() == 10 ? millis * 1000L : millis) + .atZone(SCHEDULE_ZONE).toLocalDate(); + } + if (value.endsWith("Z") || value.endsWith("z")) + { + return Instant.parse(value.toUpperCase()).atZone(SCHEDULE_ZONE).toLocalDate(); + } + String normalized = value.replace('T', ' ').replace('/', '-'); + try + { + // 带时区偏移:2026-11-09 09:00:00+08:00 + return OffsetDateTime.parse(normalized, SCHEDULE_OFFSET_FORMAT) + .atZoneSameInstant(SCHEDULE_ZONE).toLocalDate(); + } + catch (DateTimeParseException ignored) + { + // 无时区偏移,继续按本地时间解析 + } + if (normalized.length() <= 10) + { + return LocalDate.parse(normalized, SCHEDULE_DATE_FORMAT); + } + return LocalDateTime.parse(normalized, SCHEDULE_DATETIME_FORMAT) + .atZone(SCHEDULE_ZONE).toLocalDate(); + } + catch (RuntimeException ex) + { + log.warn("lessonSchedule startTime 解析失败 value={}, msg={}", startTime, ex.getMessage()); + return null; + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainBookSnapshotController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainBookSnapshotController.java new file mode 100644 index 000000000..8d04a8147 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainBookSnapshotController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.device; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.device.domain.TrainBookSnapshot; +import com.ruoyi.device.service.ITrainBookSnapshotService; + +/** + * 图书快照Controller + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@RequestMapping("/device/booksnapshot") +public class TrainBookSnapshotController extends BaseController +{ + @Autowired + private ITrainBookSnapshotService trainBookSnapshotService; + + @PreAuthorize("@ss.hasPermi('device:booksnapshot:list')") + @GetMapping("/list") + public TableDataInfo list(TrainBookSnapshot trainBookSnapshot) + { + startPage(); + List list = trainBookSnapshotService.selectTrainBookSnapshotList(trainBookSnapshot); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('device:booksnapshot:export')") + @Log(title = "图书快照", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainBookSnapshot trainBookSnapshot) + { + List list = trainBookSnapshotService.selectTrainBookSnapshotList(trainBookSnapshot); + ExcelUtil util = new ExcelUtil(TrainBookSnapshot.class); + util.exportExcel(response, list, "图书快照数据"); + } + + @PreAuthorize("@ss.hasPermi('device:booksnapshot:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + return success(trainBookSnapshotService.selectTrainBookSnapshotById(id)); + } + + @PreAuthorize("@ss.hasPermi('device:booksnapshot:add')") + @Log(title = "图书快照", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainBookSnapshot trainBookSnapshot) + { + return toAjax(trainBookSnapshotService.insertTrainBookSnapshot(trainBookSnapshot)); + } + + @PreAuthorize("@ss.hasPermi('device:booksnapshot:edit')") + @Log(title = "图书快照", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainBookSnapshot trainBookSnapshot) + { + return toAjax(trainBookSnapshotService.updateTrainBookSnapshot(trainBookSnapshot)); + } + + @PreAuthorize("@ss.hasPermi('device:booksnapshot:remove')") + @Log(title = "图书快照", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(trainBookSnapshotService.deleteTrainBookSnapshotByIds(ids)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainCheckinTerminalController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainCheckinTerminalController.java new file mode 100644 index 000000000..113b90dc6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainCheckinTerminalController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.device; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.device.domain.TrainCheckinTerminal; +import com.ruoyi.device.service.ITrainCheckinTerminalService; + +/** + * 报到一体机(自助报到终端)Controller + * + * @author ruoyi + * @date 2026-10-24 + */ +@RestController +@RequestMapping("/device/checkinterminal") +public class TrainCheckinTerminalController extends BaseController +{ + @Autowired + private ITrainCheckinTerminalService trainCheckinTerminalService; + + @PreAuthorize("@ss.hasPermi('device:checkinterminal:list')") + @GetMapping("/list") + public TableDataInfo list(TrainCheckinTerminal trainCheckinTerminal) + { + startPage(); + List list = trainCheckinTerminalService.selectTrainCheckinTerminalList(trainCheckinTerminal); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('device:checkinterminal:export')") + @Log(title = "报到一体机", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainCheckinTerminal trainCheckinTerminal) + { + List list = trainCheckinTerminalService.selectTrainCheckinTerminalList(trainCheckinTerminal); + ExcelUtil util = new ExcelUtil(TrainCheckinTerminal.class); + util.exportExcel(response, list, "报到一体机数据"); + } + + @PreAuthorize("@ss.hasPermi('device:checkinterminal:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + return success(trainCheckinTerminalService.selectTrainCheckinTerminalById(id)); + } + + @PreAuthorize("@ss.hasPermi('device:checkinterminal:add')") + @Log(title = "报到一体机", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainCheckinTerminal trainCheckinTerminal) + { + return toAjax(trainCheckinTerminalService.insertTrainCheckinTerminal(trainCheckinTerminal)); + } + + @PreAuthorize("@ss.hasPermi('device:checkinterminal:edit')") + @Log(title = "报到一体机", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainCheckinTerminal trainCheckinTerminal) + { + return toAjax(trainCheckinTerminalService.updateTrainCheckinTerminal(trainCheckinTerminal)); + } + + @PreAuthorize("@ss.hasPermi('device:checkinterminal:remove')") + @Log(title = "报到一体机", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(trainCheckinTerminalService.deleteTrainCheckinTerminalByIds(ids)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainIotDeviceController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainIotDeviceController.java new file mode 100644 index 000000000..88cb8fab0 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainIotDeviceController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.device; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.device.domain.TrainIotDevice; +import com.ruoyi.device.service.ITrainIotDeviceService; + +/** + * 读书机设备档案Controller + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@RequestMapping("/device/iotdevice") +public class TrainIotDeviceController extends BaseController +{ + @Autowired + private ITrainIotDeviceService trainIotDeviceService; + + @PreAuthorize("@ss.hasPermi('device:iotdevice:list')") + @GetMapping("/list") + public TableDataInfo list(TrainIotDevice trainIotDevice) + { + startPage(); + List list = trainIotDeviceService.selectTrainIotDeviceList(trainIotDevice); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('device:iotdevice:export')") + @Log(title = "读书机设备档案", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainIotDevice trainIotDevice) + { + List list = trainIotDeviceService.selectTrainIotDeviceList(trainIotDevice); + ExcelUtil util = new ExcelUtil(TrainIotDevice.class); + util.exportExcel(response, list, "读书机设备档案数据"); + } + + @PreAuthorize("@ss.hasPermi('device:iotdevice:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + return success(trainIotDeviceService.selectTrainIotDeviceById(id)); + } + + @PreAuthorize("@ss.hasPermi('device:iotdevice:add')") + @Log(title = "读书机设备档案", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainIotDevice trainIotDevice) + { + return toAjax(trainIotDeviceService.insertTrainIotDevice(trainIotDevice)); + } + + @PreAuthorize("@ss.hasPermi('device:iotdevice:edit')") + @Log(title = "读书机设备档案", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainIotDevice trainIotDevice) + { + return toAjax(trainIotDeviceService.updateTrainIotDevice(trainIotDevice)); + } + + @PreAuthorize("@ss.hasPermi('device:iotdevice:remove')") + @Log(title = "读书机设备档案", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(trainIotDeviceService.deleteTrainIotDeviceByIds(ids)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingAlternateProgressController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingAlternateProgressController.java new file mode 100644 index 000000000..4a47243d1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingAlternateProgressController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.device; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.device.domain.TrainReadingAlternateProgress; +import com.ruoyi.device.service.ITrainReadingAlternateProgressService; + +/** + * 备选冲突续读进度Controller + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@RequestMapping("/device/readingalternateprogress") +public class TrainReadingAlternateProgressController extends BaseController +{ + @Autowired + private ITrainReadingAlternateProgressService trainReadingAlternateProgressService; + + @PreAuthorize("@ss.hasPermi('device:readingalternateprogress:list')") + @GetMapping("/list") + public TableDataInfo list(TrainReadingAlternateProgress trainReadingAlternateProgress) + { + startPage(); + List list = trainReadingAlternateProgressService.selectTrainReadingAlternateProgressList(trainReadingAlternateProgress); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('device:readingalternateprogress:export')") + @Log(title = "备选冲突续读进度", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainReadingAlternateProgress trainReadingAlternateProgress) + { + List list = trainReadingAlternateProgressService.selectTrainReadingAlternateProgressList(trainReadingAlternateProgress); + ExcelUtil util = new ExcelUtil(TrainReadingAlternateProgress.class); + util.exportExcel(response, list, "备选冲突续读进度数据"); + } + + @PreAuthorize("@ss.hasPermi('device:readingalternateprogress:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + return success(trainReadingAlternateProgressService.selectTrainReadingAlternateProgressById(id)); + } + + @PreAuthorize("@ss.hasPermi('device:readingalternateprogress:add')") + @Log(title = "备选冲突续读进度", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainReadingAlternateProgress trainReadingAlternateProgress) + { + return toAjax(trainReadingAlternateProgressService.insertTrainReadingAlternateProgress(trainReadingAlternateProgress)); + } + + @PreAuthorize("@ss.hasPermi('device:readingalternateprogress:edit')") + @Log(title = "备选冲突续读进度", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainReadingAlternateProgress trainReadingAlternateProgress) + { + return toAjax(trainReadingAlternateProgressService.updateTrainReadingAlternateProgress(trainReadingAlternateProgress)); + } + + @PreAuthorize("@ss.hasPermi('device:readingalternateprogress:remove')") + @Log(title = "备选冲突续读进度", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(trainReadingAlternateProgressService.deleteTrainReadingAlternateProgressByIds(ids)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingApiController.java new file mode 100644 index 000000000..5869947c9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingApiController.java @@ -0,0 +1,735 @@ +package com.ruoyi.web.controller.device; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import com.ruoyi.common.constant.CacheConstants; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.device.domain.TrainBookDownloadRecord; +import com.ruoyi.device.domain.TrainBookSnapshot; +import com.ruoyi.device.domain.TrainIotDevice; +import com.ruoyi.device.domain.TrainReadingAlternateProgress; +import com.ruoyi.device.domain.TrainReadingCurrentProgress; +import com.ruoyi.device.domain.TrainReadingDurationLog; +import com.ruoyi.device.domain.TrainUserSession; +import com.ruoyi.device.service.ITrainBookDownloadRecordService; +import com.ruoyi.device.service.ITrainBookSnapshotService; +import com.ruoyi.device.service.ITrainIotDeviceService; +import com.ruoyi.device.service.ITrainReadingAlternateProgressService; +import com.ruoyi.device.service.ITrainReadingCurrentProgressService; +import com.ruoyi.device.service.ITrainReadingDurationLogService; +import com.ruoyi.device.service.ITrainUserSessionService; +import com.ruoyi.framework.web.service.TokenService; +import java.net.URLEncoder; +import org.springframework.core.io.ClassPathResource; +//import com.ruoyi.system.domain.SysUser; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.system.service.ISysUserService; + +/** + * 阅读机/听书机对外接口(设备端调用) + * + * BasePath: /api/iot/v1 + * 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。 + * 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。 + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@Tag(name = "阅读机/听书机接口", description = "设备端对外接口:登录/注销/心跳、阅读上报、进度查询、书籍列表与下载回执。登录免鉴权,其余接口走 RuoYi Bearer 鉴权。") +@RequestMapping("/api/iot/v0") +public class TrainReadingApiController extends BaseController +{ + @Autowired + private ITrainIotDeviceService iotDeviceService; + @Autowired + private ITrainUserSessionService userSessionService; + @Autowired + private ITrainReadingDurationLogService readingLogService; + @Autowired + private ITrainReadingCurrentProgressService currentProgressService; + @Autowired + private ITrainReadingAlternateProgressService alternateProgressService; + @Autowired + private ITrainBookSnapshotService bookSnapshotService; + @Autowired + private ITrainBookDownloadRecordService bookDownloadRecordService; + @Autowired + private TokenService tokenService; + @Autowired + private ISysUserService sysUserService; + + @Value("${ruoyi.profile:}") + private String profileRoot; + + /** + * 1. 设备学员登录 + */ + @Operation(summary = "设备学员登录", description = "校验设备存在/启用 + 学员账号正常,签发 RuoYi token 并创建业务会话,返回 accessToken / sessionId / heartbeatInterval。") + @PostMapping("/device/login") + public AjaxResult login(@RequestBody LoginRequest req) + { + TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId); + if (device == null) + { + return AjaxResult.error("设备不存在"); + } + if (device.getDeviceStatus() != null && device.getDeviceStatus() == 2) + { + return AjaxResult.error("设备已禁用"); + } + // HMAC 验签应在网关/拦截器完成;此处仅做账号校验 + SysUser sysUser = sysUserService.selectUserById(req.userId); + if (sysUser == null || !"0".equals(sysUser.getStatus())) + { + return AjaxResult.error("学员账号不存在或已停用"); + } + LoginUser loginUser = new LoginUser(); + loginUser.setUser(sysUser); + loginUser.setUserId(sysUser.getUserId()); + //loginUser.setUsername(sysUser.getUserName()); + String token = tokenService.createToken(loginUser); + String tokenKey = CacheConstants.LOGIN_TOKEN_KEY + token; + String sessionId = userSessionService.createSession(req.userId, req.deviceId, tokenKey, new Date()); + LoginData data = new LoginData(); + data.accessToken = token; + data.sessionId = sessionId; + data.heartbeatInterval = device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60; + return AjaxResult.success(data); + } + + /** + * 2. 设备注销 + */ + @Operation(summary = "设备注销", description = "业务会话置 LOGOUT,并撤销 RuoYi Redis 中的 token。") + @PostMapping("/device/logout") + public AjaxResult logout(@RequestBody LogoutRequest req, HttpServletRequest request) + { + Long userId = getUserId(); + userSessionService.logoutSession(userId, req.deviceId); + String auth = request.getHeader("Authorization"); + if (auth != null && auth.startsWith("Bearer ")) + { + String token = auth.substring(7).trim(); + tokenService.delLoginUser(token); + } + LogoutData data = new LogoutData(); + data.result = true; + data.tokenRevoked = true; + return AjaxResult.success(data); + } + + /** + * 3. 设备心跳 + */ + @Operation(summary = "设备心跳", description = "更新设备最后心跳时间,返回下次心跳间隔与平台下行指令数组。") + @PostMapping("/device/heartbeat") + public AjaxResult heartbeat(@RequestBody HeartbeatRequest req) + { + Integer status = "ONLINE".equals(req.status) ? 1 : null; + iotDeviceService.updateHeartbeatByDeviceId(req.deviceId, new Date(), status); + TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId); + HeartbeatData data = new HeartbeatData(); + data.nextInterval = device != null && device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60; + data.commands = new ArrayList<>(); + return AjaxResult.success(data); + } + + /** + * 4. 阅读记录单条上报 + */ + @Operation(summary = "阅读记录单条上报", description = "幂等上报(recordId 去重);ACTIVE 会话更新主进度,REPLACED 会话写入备选冲突进度。返回 ACCEPTED / DUPLICATE / CONFLICT_SAVED / REJECTED。") + @PostMapping("/reading/record") + public AjaxResult reportRecord(@RequestBody ReadingRecordRequest req) + { + Long userId = getUserId(); + TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId); + TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, req); + String resultCode = readingLogService.reportReadingRecord(log); + long studySeconds = log.getDurationSec() != null ? log.getDurationSec().longValue() : 0; + long totalSeconds = readingLogService.sumDurationSeconds(userId, req.mode); + RecordData data = new RecordData(); + data.result = resultCode; + data.studyMinutes = (int) (studySeconds / 60); + data.totalMinutes = (int) (totalSeconds / 60); + return AjaxResult.success(data); + } + + /** + * 5. 阅读记录批量上报(断网补传) + */ + @Operation(summary = "阅读记录批量上报", description = "单批最多 200 条,每条 recordId 独立幂等,逐条走单条上报逻辑。") + @PostMapping("/reading/record/batch") + public AjaxResult reportRecordBatch(@RequestBody ReadingRecordBatchRequest req) + { + Long userId = getUserId(); + TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId); + List itemResults = new ArrayList<>(); + if (req.records != null) + { + for (ReadingRecordRequest r : req.records) + { + TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, r); + String rc = readingLogService.reportReadingRecord(log); + itemResults.add(new ReadingRecordItemResult(r.recordId, rc)); + } + } + BatchData data = new BatchData(); + data.itemResults = itemResults; + return AjaxResult.success(data); + } + + private TrainReadingDurationLog buildLog(Long userId, String deviceId, TrainUserSession session, ReadingRecordRequest req) + { + TrainReadingDurationLog log = new TrainReadingDurationLog(); + log.setUserId(userId); + log.setDeviceId(deviceId); + log.setSessionId(session != null ? session.getSessionId() : null); + log.setRecordId(req.recordId); + log.setBookId(req.bookId); + log.setBookVersion(req.bookVersion); + log.setMode(req.mode); + log.setRecordType(req.recordType); + log.setDurationSec(req.durationSec); + log.setPageFrom(req.pageFrom); + log.setPageTo(req.pageTo); + log.setProgress(req.progress); + log.setStartTime(req.startTime != null ? new Date(req.startTime) : null); + log.setEndTime(req.endTime != null ? new Date(req.endTime) : null); + return log; + } + + /** + * 6. 获取学员阅读汇总与续读进度 + */ + @Operation(summary = "获取学员阅读汇总与续读进度", description = "返回累计阅读/听书时长,以及每本书的主进度 + 备选冲突进度。REPLACED 会话禁止调用。") + @GetMapping("/reading/progress") + public AjaxResult progress(@Parameter(description = "设备ID") @RequestParam String deviceId) + { + Long userId = getUserId(); + TrainUserSession session = userSessionService.selectLatestSession(userId, deviceId); + if (session == null || session.getSessionStatus() != 1) + { + return AjaxResult.error("当前会话状态不允许查询进度(被替换设备仅允许补交上报)"); + } + long readSec = readingLogService.sumDurationSeconds(userId, "read"); + long audioSec = readingLogService.sumDurationSeconds(userId, "audio"); + List mains = currentProgressService.selectListByUserId(userId); + List bookRecords = new ArrayList<>(); + if (mains != null) + { + for (TrainReadingCurrentProgress cp : mains) + { + BookRecord br = new BookRecord(); + br.bookId = cp.getBookId(); + br.bookVersion = cp.getBookVersion(); + MainProgress mp = new MainProgress(); + mp.mode = cp.getMode(); + mp.pageNo = cp.getPageNo(); + mp.sentenceIndex = cp.getSentenceIndex(); + mp.progress = cp.getProgress(); + mp.updateTime = cp.getUpdateTime() != null ? cp.getUpdateTime().getTime() : null; + br.mainProgress = mp; + List alts = alternateProgressService.selectListByUserBookMode(userId, cp.getBookId(), cp.getMode()); + List altList = new ArrayList<>(); + if (alts != null) + { + for (TrainReadingAlternateProgress a : alts) + { + AlternateProgress ap = new AlternateProgress(); + ap.mode = a.getMode(); + ap.bookVersion = a.getBookVersion(); + ap.pageNo = a.getPageNo(); + ap.sentenceIndex = a.getSentenceIndex(); + ap.progress = a.getProgress(); + ap.createTime = a.getCreateTime() != null ? a.getCreateTime().getTime() : null; + altList.add(ap); + } + } + br.alternateProgressList = altList; + bookRecords.add(br); + } + } + ProgressData data = new ProgressData(); + data.userId = userId; + data.totalReadSeconds = readSec; + data.totalAudioSeconds = audioSec; + data.bookRecords = bookRecords; + return AjaxResult.success(data); + } + + /** + * 7. 获取可阅读书籍列表(仅上架) + */ + @Operation(summary = "获取可阅读书籍列表", description = "只返回 is_on_shelf=1 的上架图书,支持按 bookCategory 过滤。") + @GetMapping("/book/list") + public AjaxResult bookList(@Parameter(description = "设备ID") @RequestParam String deviceId, + @Parameter(description = "分类过滤:SAFETY/REGULATION/LANGUAGE,可空") @RequestParam(required = false) String bookCategory) + { + List list = bookSnapshotService.selectOnShelfList(bookCategory); + List items = new ArrayList<>(); + if (list != null) + { + for (TrainBookSnapshot b : list) + { + BookItem bi = new BookItem(); + bi.bookId = b.getBookId(); + bi.bookTitle = b.getBookTitle(); + bi.bookCategory = b.getBookCategory(); + bi.bookSummary = b.getBookSummary(); + bi.bookVersion = b.getBookVersion(); + bi.bookCover = b.getBookCover(); + bi.fileSize = b.getFileSize(); + bi.fileMd5 = b.getFileMd5(); + bi.isOnShelf = b.getIsOnShelf() != null && b.getIsOnShelf() == 1; + bi.publishTime = b.getPublishTime() != null ? b.getPublishTime().getTime() : null; + items.add(bi); + } + } + BookListData data = new BookListData(); + data.total = items.size(); + data.list = items; + return AjaxResult.success(data); + } + + /** + * 8. PDF 书籍下载(支持 Range 断点续传;下架图书禁止下载) + */ + @Operation(summary = "PDF 书籍下载", description = "支持 HTTP Range 断点续传;下架图书返回业务错误禁止下载。") + @GetMapping("/book/download") + public void bookDownload(@Parameter(description = "设备ID") @RequestParam String deviceId, + @Parameter(description = "图书ID") @RequestParam String bookId, + @Parameter(description = "图书版本 BV-xx") @RequestParam String bookVersion, + HttpServletRequest request, HttpServletResponse response) throws IOException + { + TrainBookSnapshot book = bookSnapshotService.selectByBookIdAndVersion(bookId, bookVersion); + if (book == null) + { + writeJsonError(response, "图书或版本不存在"); + return; + } + if (book.getIsOnShelf() == null || book.getIsOnShelf() != 1) + { + writeJsonError(response, "图书已下架,禁止下载"); + return; + } + String url = book.getDownloadUrl(); + if (url == null || url.isEmpty()) + { + writeJsonError(response, "文件地址不存在"); + return; + } + streamFile(url, book.getFileMd5(), book.getFileSize(), book.getBookTitle(), request, response); + } + + /** + * 9. 下载完成回执上报 + */ + @Operation(summary = "下载完成回执上报", description = "设备上报下载校验结果,落库 train_book_download_record,返回 deviceReady 与 recordId。") + @PostMapping("/book/download/ack") + public AjaxResult downloadAck(@RequestBody DownloadAckRequest req) + { + Long userId = getUserId(); + TrainBookDownloadRecord record = new TrainBookDownloadRecord(); + record.setUserId(userId); + record.setDeviceId(req.deviceId); + record.setBookId(req.bookId); + record.setBookVersion(req.bookVersion); + record.setFileMd5(req.fileMd5); + record.setResult(req.result != null ? req.result : "SUCCESS"); + record.setErrMsg(req.errMsg); + record.setDownloadTime(req.timestamp != null ? new Date(req.timestamp) : new Date()); + bookDownloadRecordService.insertTrainBookDownloadRecord(record); + DownloadAckData data = new DownloadAckData(); + data.deviceReady = true; + data.recordId = record.getId(); + return AjaxResult.success(data); + } + + private void writeJsonError(HttpServletResponse response, String msg) throws IOException + { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":500,\"msg\":\"" + msg + "\",\"data\":null}"); + } + + private void streamFile(String url, String md5, Long fileSize, String title, HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setHeader("Accept-Ranges", "bytes"); + if (md5 != null && !md5.isEmpty()) + { + response.setHeader("Content-MD5", md5); + } + // 加上 HTTP 头,保证文件以正确类型、可下载方式返回(浏览器可访问) + response.setContentType(guessContentType(url)); + String fileName = (title != null && !title.isEmpty() ? title : "book") + ".pdf"; + try + { + String enc = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + response.setHeader("Content-Disposition", + "attachment; filename=\"download.pdf\"; filename*=UTF-8''" + enc); + } + catch (Exception ignored) {} + + boolean isHttp = url.startsWith("http://") || url.startsWith("https://"); + InputStream in = null; + long total = -1; + if (isHttp) + { + // 完整 http(s) 远程地址(如 OSS):直接拉取 + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", "ruoyi-device"); + in = conn.getInputStream(); + String cl = conn.getHeaderField("Content-Length"); + if (cl != null && !cl.isEmpty()) + { + try { total = Long.parseLong(cl); } catch (NumberFormatException ignored) {} + } + } + else if (url.startsWith("/")) + { + // 相对路径:直接按本地资源读取,避免后端自请求(否则会触发 /error 鉴权 401) + if (url.startsWith("/profile/")) + { + // /profile/** 指向 RuoYi 上传根目录(ruoyi.profile),与静态资源映射一致 + File f = new File(profileRoot, url.substring("/profile".length())); + if (!f.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = f.length(); + in = new FileInputStream(f); + } + else + { + // 其他相对路径(测试用 /books/...)从 classpath 静态资源读取 + ClassPathResource res = new ClassPathResource("static" + url); + if (!res.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = res.contentLength(); + in = res.getInputStream(); + } + } + else + { + // 绝对本地路径(遗留用法) + File f = new File(url); + if (!f.exists()) + { + writeJsonError(response, "文件不存在"); + return; + } + total = f.length(); + in = new FileInputStream(f); + } + + try + { + String range = request.getHeader("Range"); + long start = 0; + long end = total - 1; + boolean hasRange = false; + if (range != null && range.startsWith("bytes=")) + { + String[] parts = range.substring(6).split("-"); + try { start = Long.parseLong(parts[0]); } catch (NumberFormatException ignored) {} + if (parts.length > 1 && !parts[1].isEmpty()) + { + try { end = Long.parseLong(parts[1]); } catch (NumberFormatException ignored) {} + } + hasRange = true; + } + if (hasRange) + { + if (end < 0 || end >= total) + { + end = total - 1; + } + if (start > end) + { + start = end; + } + long skipped = in.skip(start); + while (skipped < start) + { + long s = in.skip(start - skipped); + if (s <= 0) + { + break; + } + skipped += s; + } + long len = end - start + 1; + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + total); + response.setContentLengthLong(len); + copy(in, response.getOutputStream(), len); + } + else + { + response.setStatus(HttpServletResponse.SC_OK); + if (total > 0) + { + response.setContentLengthLong(total); + } + copy(in, response.getOutputStream(), -1); + } + } + finally + { + if (in != null) + { + in.close(); + } + } + } + + private String guessContentType(String url) + { + if (url != null && url.toLowerCase().endsWith(".pdf")) + { + return "application/pdf"; + } + return "application/octet-stream"; + } + + private void copy(InputStream in, OutputStream out, long limit) throws IOException + { + byte[] buf = new byte[8192]; + long remaining = limit; + int n; + while ((n = in.read(buf)) != -1) + { + if (limit > 0) + { + if (remaining <= 0) + { + break; + } + int toWrite = (int) Math.min(n, remaining); + out.write(buf, 0, toWrite); + remaining -= toWrite; + } + else + { + out.write(buf, 0, n); + } + } + out.flush(); + } + + // ================= 请求/响应 DTO(内嵌,避免新增文件) ================= + + public static class LoginRequest + { + public String deviceId; + public Long userId; + public String firmware; + public String appVersion; + public NetInfo netInfo; + public Long timestamp; + public String nonce; + } + + public static class NetInfo + { + public String ip; + public String mac; + public Integer rssi; + } + + public static class LogoutRequest + { + public String deviceId; + public String reason; + public Long timestamp; + public String nonce; + } + + public static class HeartbeatRequest + { + public String deviceId; + public String status; + public Metrics metrics; + public String configVersion; + public String personVersion; + public Long timestamp; + public String nonce; + } + + public static class Metrics + { + public Integer cpu; + public Integer mem; + public Long diskFree; + } + + public static class ReadingRecordRequest + { + public String deviceId; + public String recordId; + public String bookId; + public String bookVersion; + public String mode; + public String recordType; + public Long startTime; + public Long endTime; + public Integer durationSec; + public Integer pageFrom; + public Integer pageTo; + public BigDecimal progress; + } + + public static class ReadingRecordBatchRequest + { + public String deviceId; + public String batchId; + public Long timestamp; + public String nonce; + public List records; + } + + public static class DownloadAckRequest + { + public String deviceId; + public String bookId; + public String bookVersion; + public String fileMd5; + public String result; + public String errMsg; + public Long timestamp; + public String nonce; + } + + public static class LoginData + { + public String accessToken; + public String sessionId; + public Integer heartbeatInterval; + } + + public static class LogoutData + { + public Boolean result; + public Boolean tokenRevoked; + } + + public static class HeartbeatData + { + public Integer nextInterval; + public List commands; + } + + public static class RecordData + { + public String result; + public Integer studyMinutes; + public Integer totalMinutes; + } + + public static class ReadingRecordItemResult + { + public String recordId; + public String result; + + public ReadingRecordItemResult(String recordId, String result) + { + this.recordId = recordId; + this.result = result; + } + } + + public static class BatchData + { + public List itemResults; + } + + public static class MainProgress + { + public String mode; + public Integer pageNo; + public Integer sentenceIndex; + public BigDecimal progress; + public Long updateTime; + } + + public static class AlternateProgress + { + public String mode; + public String bookVersion; + public Integer pageNo; + public Integer sentenceIndex; + public BigDecimal progress; + public Long createTime; + } + + public static class BookRecord + { + public String bookId; + public String bookVersion; + public MainProgress mainProgress; + public List alternateProgressList; + } + + public static class ProgressData + { + public Long userId; + public long totalReadSeconds; + public long totalAudioSeconds; + public List bookRecords; + } + + public static class BookItem + { + public String bookId; + public String bookTitle; + public String bookCategory; + public String bookSummary; + public String bookVersion; + public String bookCover; + public Long fileSize; + public String fileMd5; + public Boolean isOnShelf; + public Long publishTime; + } + + public static class BookListData + { + public int total; + public List list; + } + + public static class DownloadAckData + { + public Boolean deviceReady; + public Long recordId; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingCurrentProgressController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingCurrentProgressController.java new file mode 100644 index 000000000..b0568872a --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingCurrentProgressController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.device; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.device.domain.TrainReadingCurrentProgress; +import com.ruoyi.device.service.ITrainReadingCurrentProgressService; + +/** + * 学员主续读进度Controller + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@RequestMapping("/device/readingcurrentprogress") +public class TrainReadingCurrentProgressController extends BaseController +{ + @Autowired + private ITrainReadingCurrentProgressService trainReadingCurrentProgressService; + + @PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:list')") + @GetMapping("/list") + public TableDataInfo list(TrainReadingCurrentProgress trainReadingCurrentProgress) + { + startPage(); + List list = trainReadingCurrentProgressService.selectTrainReadingCurrentProgressList(trainReadingCurrentProgress); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:export')") + @Log(title = "学员主续读进度", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainReadingCurrentProgress trainReadingCurrentProgress) + { + List list = trainReadingCurrentProgressService.selectTrainReadingCurrentProgressList(trainReadingCurrentProgress); + ExcelUtil util = new ExcelUtil(TrainReadingCurrentProgress.class); + util.exportExcel(response, list, "学员主续读进度数据"); + } + + @PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + return success(trainReadingCurrentProgressService.selectTrainReadingCurrentProgressById(id)); + } + + @PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:add')") + @Log(title = "学员主续读进度", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainReadingCurrentProgress trainReadingCurrentProgress) + { + return toAjax(trainReadingCurrentProgressService.insertTrainReadingCurrentProgress(trainReadingCurrentProgress)); + } + + @PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:edit')") + @Log(title = "学员主续读进度", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainReadingCurrentProgress trainReadingCurrentProgress) + { + return toAjax(trainReadingCurrentProgressService.updateTrainReadingCurrentProgress(trainReadingCurrentProgress)); + } + + @PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:remove')") + @Log(title = "学员主续读进度", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(trainReadingCurrentProgressService.deleteTrainReadingCurrentProgressByIds(ids)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingDurationLogController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingDurationLogController.java new file mode 100644 index 000000000..6279a84fa --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainReadingDurationLogController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.device; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.device.domain.TrainReadingDurationLog; +import com.ruoyi.device.service.ITrainReadingDurationLogService; + +/** + * 阅读上报幂等日志Controller + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@RequestMapping("/device/readingdurationlog") +public class TrainReadingDurationLogController extends BaseController +{ + @Autowired + private ITrainReadingDurationLogService trainReadingDurationLogService; + + @PreAuthorize("@ss.hasPermi('device:readingdurationlog:list')") + @GetMapping("/list") + public TableDataInfo list(TrainReadingDurationLog trainReadingDurationLog) + { + startPage(); + List list = trainReadingDurationLogService.selectTrainReadingDurationLogList(trainReadingDurationLog); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('device:readingdurationlog:export')") + @Log(title = "阅读上报幂等日志", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainReadingDurationLog trainReadingDurationLog) + { + List list = trainReadingDurationLogService.selectTrainReadingDurationLogList(trainReadingDurationLog); + ExcelUtil util = new ExcelUtil(TrainReadingDurationLog.class); + util.exportExcel(response, list, "阅读上报幂等日志数据"); + } + + @PreAuthorize("@ss.hasPermi('device:readingdurationlog:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + return success(trainReadingDurationLogService.selectTrainReadingDurationLogById(id)); + } + + @PreAuthorize("@ss.hasPermi('device:readingdurationlog:add')") + @Log(title = "阅读上报幂等日志", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainReadingDurationLog trainReadingDurationLog) + { + return toAjax(trainReadingDurationLogService.insertTrainReadingDurationLog(trainReadingDurationLog)); + } + + @PreAuthorize("@ss.hasPermi('device:readingdurationlog:edit')") + @Log(title = "阅读上报幂等日志", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainReadingDurationLog trainReadingDurationLog) + { + return toAjax(trainReadingDurationLogService.updateTrainReadingDurationLog(trainReadingDurationLog)); + } + + @PreAuthorize("@ss.hasPermi('device:readingdurationlog:remove')") + @Log(title = "阅读上报幂等日志", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(trainReadingDurationLogService.deleteTrainReadingDurationLogByIds(ids)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainUserSessionController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainUserSessionController.java new file mode 100644 index 000000000..1a7f7c105 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TrainUserSessionController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.device; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.device.domain.TrainUserSession; +import com.ruoyi.device.service.ITrainUserSessionService; + +/** + * 学员业务会话Controller + * + * @author ruoyi + * @date 2026-10-20 + */ +@RestController +@RequestMapping("/device/usersession") +public class TrainUserSessionController extends BaseController +{ + @Autowired + private ITrainUserSessionService trainUserSessionService; + + @PreAuthorize("@ss.hasPermi('device:usersession:list')") + @GetMapping("/list") + public TableDataInfo list(TrainUserSession trainUserSession) + { + startPage(); + List list = trainUserSessionService.selectTrainUserSessionList(trainUserSession); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('device:usersession:export')") + @Log(title = "学员业务会话", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainUserSession trainUserSession) + { + List list = trainUserSessionService.selectTrainUserSessionList(trainUserSession); + ExcelUtil util = new ExcelUtil(TrainUserSession.class); + util.exportExcel(response, list, "学员业务会话数据"); + } + + @PreAuthorize("@ss.hasPermi('device:usersession:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + return success(trainUserSessionService.selectTrainUserSessionById(id)); + } + + @PreAuthorize("@ss.hasPermi('device:usersession:add')") + @Log(title = "学员业务会话", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainUserSession trainUserSession) + { + return toAjax(trainUserSessionService.insertTrainUserSession(trainUserSession)); + } + + @PreAuthorize("@ss.hasPermi('device:usersession:edit')") + @Log(title = "学员业务会话", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainUserSession trainUserSession) + { + return toAjax(trainUserSessionService.updateTrainUserSession(trainUserSession)); + } + + @PreAuthorize("@ss.hasPermi('device:usersession:remove')") + @Log(title = "学员业务会话", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(trainUserSessionService.deleteTrainUserSessionByIds(ids)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java index 6e63693c5..64708b14f 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java @@ -1,6 +1,8 @@ package com.ruoyi.web.controller.train; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; @@ -19,6 +21,7 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.dto.AccommodationSaveResult; import com.ruoyi.train.service.ITrainAccommodationService; /** @@ -85,7 +88,17 @@ public class TrainAccommodationController extends BaseController public AjaxResult add(@RequestBody TrainAccommodation accommodation) { accommodation.setCreateBy(getUsernameSafe()); - return toAjax(trainAccommodationService.insertTrainAccommodation(accommodation)); + AccommodationSaveResult result = trainAccommodationService.insertTrainAccommodationWithLockResult(accommodation); + if (result == null || result.getRows() <= 0) + { + return AjaxResult.error("新增住宿安排失败"); + } + // 住宿主流程成功即返回成功,门禁调用结果在 data.lockCheckin 中透出,不改变原有 code 语义 + Map data = new HashMap<>(4); + data.put("accommodationId", result.getAccommodationId()); + data.put("lockApiBizNo", result.getLockApiBizNo()); + data.put("lockCheckin", result.getLockCheckin()); + return AjaxResult.success(data); } /** diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassReportController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassReportController.java index bc4c5e80c..f2923218a 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassReportController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassReportController.java @@ -1,14 +1,14 @@ package com.ruoyi.web.controller.train; import java.util.List; + +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.dto.ReportStartDTO; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; @@ -66,4 +66,13 @@ public class TrainClassReportController extends BaseController return success(trainClassReportService.selectTrainClassReportByReportId(reportId)); } + /** 开班报到启动。 */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @Log(title = "开班报到启动", businessType = BusinessType.INSERT) + @PostMapping("/start") + public AjaxResult reportStart(@RequestBody ReportStartDTO dto) + { + return success(trainClassReportService.reportStart(dto, SecurityUtils.getUsername())); + } + } diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassSeatController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassSeatController.java new file mode 100644 index 000000000..2b78a84da --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassSeatController.java @@ -0,0 +1,106 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +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 com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainClassSeat; +import com.ruoyi.train.domain.dto.ClassSeatSaveDTO; +import com.ruoyi.train.service.ITrainClassSeatService; + +/** + * 班级座位Controller + * + * @author ruoyi + * @date 2026-10-26 + */ +@RestController +@RequestMapping("/train/classseat") +public class TrainClassSeatController extends BaseController +{ + @Autowired + private ITrainClassSeatService trainClassSeatService; + + /** + * 查询班级座位列表 (关联学员姓名;支持 planId/classroomId/siteId/canceled/studentId/zoneId 过滤) + */ + @PreAuthorize("@ss.hasPermi('train:classseat:list')") + @GetMapping("/list") + public TableDataInfo list(TrainClassSeat trainClassSeat) + { + startPage(); + List list = trainClassSeatService.selectTrainClassSeatList(trainClassSeat); + return getDataTable(list); + } + + /** + * 获取班级座位详细信息 + */ + @PreAuthorize("@ss.hasPermi('train:classseat:list')") + @GetMapping(value = "/{seatId}") + public AjaxResult getInfo(@PathVariable("seatId") Long seatId) + { + return success(trainClassSeatService.selectTrainClassSeatBySeatId(seatId)); + } + + /** + * 新增班级座位 (同一教室同一 (行,列) 唯一) + */ + @PreAuthorize("@ss.hasPermi('train:classseat:edit')") + @Log(title = "班级座位", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@Validated @RequestBody TrainClassSeat trainClassSeat) + { + trainClassSeat.setCreateBy(SecurityUtils.getUsername()); + return toAjax(trainClassSeatService.insertTrainClassSeat(trainClassSeat)); + } + + /** + * 修改班级座位 (RESTful PUT /train/classseat/{seatId}) + */ + @PreAuthorize("@ss.hasPermi('train:classseat:edit')") + @Log(title = "班级座位", businessType = BusinessType.UPDATE) + @PutMapping("/{seatId}") + public AjaxResult edit(@PathVariable("seatId") Long seatId, @RequestBody TrainClassSeat trainClassSeat) + { + trainClassSeat.setSeatId(seatId); + trainClassSeat.setUpdateBy(SecurityUtils.getUsername()); + return toAjax(trainClassSeatService.updateTrainClassSeat(trainClassSeat)); + } + + /** + * 整体保存座位布局:删除 (planId, classroomId) 下原有座位后批量写入本次座位 + */ + @PreAuthorize("@ss.hasPermi('train:classseat:edit')") + @Log(title = "班级座位布局保存", businessType = BusinessType.UPDATE) + @PostMapping("/layout") + public AjaxResult saveLayout(@RequestBody ClassSeatSaveDTO dto) + { + return success(trainClassSeatService.saveClassSeatLayout(dto, SecurityUtils.getUsername())); + } + + /** + * 删除班级座位 + */ + @PreAuthorize("@ss.hasPermi('train:classseat:remove')") + @Log(title = "班级座位", businessType = BusinessType.DELETE) + @DeleteMapping("/{seatIds}") + public AjaxResult remove(@PathVariable Long[] seatIds) + { + return toAjax(trainClassSeatService.deleteTrainClassSeatBySeatIds(seatIds)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassroomInfoController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassroomInfoController.java index 0f1a58e64..2e921f4f3 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassroomInfoController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassroomInfoController.java @@ -56,6 +56,16 @@ public class TrainClassroomInfoController extends BaseController return success(trainClassroomInfoService.selectClassroomDetailByClassRoomId(classRoomId)); } + /** + * 获取某开班计划(planId)课表中用到的教室信息(按教室去重) + */ + @PreAuthorize("@ss.hasPermi('train:classroom:list')") + @GetMapping("/plan/{planId}") + public AjaxResult listByPlan(@PathVariable("planId") String planId) + { + return success(trainClassroomInfoService.selectClassroomByPlanId(planId)); + } + /** * 新增教室信息 */ diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormitoryInfoController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormitoryInfoController.java index 1b6b06b1c..981c25d63 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormitoryInfoController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormitoryInfoController.java @@ -5,9 +5,11 @@ import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; +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; @@ -60,6 +62,7 @@ public class TrainDormitoryInfoController extends BaseController { DormitoryItemVO vo = new DormitoryItemVO(); vo.setDormId(entity.getDormId()); + vo.setId(entity.getId()); vo.setDormBuilding(entity.getDormBuilding()); vo.setDormFloor(entity.getDormFloor()); vo.setDormRoom(entity.getDormRoom()); @@ -102,6 +105,22 @@ public class TrainDormitoryInfoController extends BaseController return success(); } + /** + * 修改宿舍房间信息 (楼栋/楼层/房号/容量/性别/门锁SN/状态/备注) + * PUT /train/dormitory/{dormId} + */ + @PreAuthorize("@ss.hasPermi('train:dormitory:edit')") + @Log(title = "宿舍房间管理", businessType = BusinessType.UPDATE) + @PutMapping("/{dormId}") + public AjaxResult edit(@PathVariable("dormId") String dormId, + @RequestBody TrainDormitoryInfo trainDormitoryInfo) + { + trainDormitoryInfo.setDormId(dormId); + trainDormitoryInfo.setUpdateBy(SecurityUtils.getUsername()); + trainDormitoryInfoService.updateTrainDormitoryInfo(trainDormitoryInfo); + return success(); + } + /** * 获取楼栋、楼层级联下拉数据 (分房交互必需) * GET /train/dormitory/options @@ -113,4 +132,16 @@ public class TrainDormitoryInfoController extends BaseController List tree = trainDormitoryInfoService.selectDormitoryTreeOptions(); return success(tree); } + + /** + * 删除宿舍房间 (支持批量,dormIds 以逗号分隔) + * DELETE /train/dormitory/{dormIds} + */ + @PreAuthorize("@ss.hasPermi('train:dormitory:edit')") + @Log(title = "宿舍房间管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{dormIds}") + public AjaxResult remove(@PathVariable String[] dormIds) + { + return toAjax(trainDormitoryInfoService.deleteTrainDormitoryInfoByDormIds(dormIds)); + } } diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java index d1ea2c419..ce54ebdbb 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java @@ -1,6 +1,7 @@ package com.ruoyi.web.controller.train; import java.util.List; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -85,6 +86,20 @@ public class TrainEnrollmentController extends BaseController return success(result); } + /** + * 下载参训人员批量导入模板 (UC-05 / P0)。 + * + *

与 {@link #batchImportEnrollments} 使用同一套解析规则:xlsx 格式,第一个工作表按 + * A 学员ID / B 参训来源 / C 住宿需求 / D 备注 的列顺序解析;模板内含逐列填写规则说明与一行示例数据。

+ */ + @PreAuthorize("@ss.hasPermi('train:enrollment:import')") + @Log(title = "参训人员导入模板", businessType = BusinessType.EXPORT) + @PostMapping("/enrollments/importTemplate") + public void importTemplate(HttpServletResponse response) + { + enrollmentAppService.exportImportTemplate(response); + } + /** * 获取参训档案详情 (P0)。 */ diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainExamReportController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainExamReportController.java new file mode 100644 index 000000000..4c15196c8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainExamReportController.java @@ -0,0 +1,87 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainExamReport; +import com.ruoyi.train.service.ITrainExamReportService; + +/** + * 外部实训上报成绩Controller + * + * @author ruoyi + * @date 2026-11-05 + */ +@RestController +@RequestMapping("/train/examReport") +public class TrainExamReportController extends BaseController +{ + @Autowired + private ITrainExamReportService trainExamReportService; + + /** + * 查询上报成绩列表 + */ + @PreAuthorize("@ss.hasPermi('train:examReport:list')") + @GetMapping("/list") + public TableDataInfo list(TrainExamReport trainExamReport) + { + startPage(); + List list = trainExamReportService.selectTrainExamReportList(trainExamReport); + return getDataTable(list); + } + + /** + * 获取上报成绩详情 + */ + @PreAuthorize("@ss.hasPermi('train:examReport:list')") + @GetMapping(value = "/{reportPk}") + public AjaxResult getInfo(@PathVariable("reportPk") Long reportPk) + { + return success(trainExamReportService.selectTrainExamReportByReportPk(reportPk)); + } + + /** + * 认领待匹配成绩(指定平台学员) + */ + @PreAuthorize("@ss.hasPermi('train:examReport:claim')") + @Log(title = "上报成绩认领", businessType = BusinessType.UPDATE) + @PostMapping("/claim") + public AjaxResult claim(@RequestBody Map body) + { + Object reportPkObj = body.get("reportPk"); + Object studentIdObj = body.get("studentId"); + if (reportPkObj == null || studentIdObj == null) + { + return error("参数缺失:reportPk 与 studentId 均为必填"); + } + Long reportPk = Long.valueOf(reportPkObj.toString()); + return toAjax(trainExamReportService.claimReport(reportPk, studentIdObj.toString().trim(), + SecurityUtils.getUsername())); + } + + /** + * 删除上报成绩 + */ + @PreAuthorize("@ss.hasPermi('train:examReport:remove')") + @Log(title = "上报成绩", businessType = BusinessType.DELETE) + @DeleteMapping("/{reportPks}") + public AjaxResult remove(@PathVariable Long[] reportPks) + { + return toAjax(trainExamReportService.deleteTrainExamReportByReportPks(reportPks)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainExamSubjectController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainExamSubjectController.java new file mode 100644 index 000000000..d34a41549 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainExamSubjectController.java @@ -0,0 +1,92 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +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 com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainExamSubject; +import com.ruoyi.train.service.ITrainExamSubjectService; + +/** + * 考试科目字典Controller + * + * @author ruoyi + * @date 2026-11-05 + */ +@RestController +@RequestMapping("/train/examSubject") +public class TrainExamSubjectController extends BaseController +{ + @Autowired + private ITrainExamSubjectService trainExamSubjectService; + + /** + * 查询考试科目列表 + */ + @PreAuthorize("@ss.hasPermi('train:examSubject:list')") + @GetMapping("/list") + public TableDataInfo list(TrainExamSubject trainExamSubject) + { + startPage(); + List list = trainExamSubjectService.selectTrainExamSubjectList(trainExamSubject); + return getDataTable(list); + } + + /** + * 获取考试科目详细信息 + */ + @PreAuthorize("@ss.hasPermi('train:examSubject:list')") + @GetMapping(value = "/{subjectId}") + public AjaxResult getInfo(@PathVariable("subjectId") Long subjectId) + { + return success(trainExamSubjectService.selectTrainExamSubjectBySubjectId(subjectId)); + } + + /** + * 新增考试科目 + */ + @PreAuthorize("@ss.hasPermi('train:examSubject:add')") + @Log(title = "考试科目", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainExamSubject trainExamSubject) + { + trainExamSubject.setCreateBy(SecurityUtils.getUsername()); + return toAjax(trainExamSubjectService.insertTrainExamSubject(trainExamSubject)); + } + + /** + * 修改考试科目 + */ + @PreAuthorize("@ss.hasPermi('train:examSubject:edit')") + @Log(title = "考试科目", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainExamSubject trainExamSubject) + { + trainExamSubject.setUpdateBy(SecurityUtils.getUsername()); + return toAjax(trainExamSubjectService.updateTrainExamSubject(trainExamSubject)); + } + + /** + * 删除考试科目 + */ + @PreAuthorize("@ss.hasPermi('train:examSubject:remove')") + @Log(title = "考试科目", businessType = BusinessType.DELETE) + @DeleteMapping("/{subjectIds}") + public AjaxResult remove(@PathVariable Long[] subjectIds) + { + return toAjax(trainExamSubjectService.deleteTrainExamSubjectBySubjectIds(subjectIds)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainOpenAppController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainOpenAppController.java new file mode 100644 index 000000000..2a19bf882 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainOpenAppController.java @@ -0,0 +1,130 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +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 com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainOpenApp; +import com.ruoyi.train.domain.TrainOpenAppScope; +import com.ruoyi.train.service.ITrainOpenAppService; + +/** + * 外部实训系统应用Controller + * + * @author ruoyi + * @date 2026-11-05 + */ +@RestController +@RequestMapping("/train/openApp") +public class TrainOpenAppController extends BaseController +{ + @Autowired + private ITrainOpenAppService trainOpenAppService; + + /** + * 查询外部应用列表 + */ + @PreAuthorize("@ss.hasPermi('train:openApp:list')") + @GetMapping("/list") + public TableDataInfo list(TrainOpenApp trainOpenApp) + { + startPage(); + List list = trainOpenAppService.selectTrainOpenAppList(trainOpenApp); + return getDataTable(list); + } + + /** + * 获取外部应用详细信息 + */ + @PreAuthorize("@ss.hasPermi('train:openApp:query')") + @GetMapping(value = "/{appId}") + public AjaxResult getInfo(@PathVariable("appId") String appId) + { + return success(trainOpenAppService.selectTrainOpenAppByAppId(appId)); + } + + /** + * 新增外部应用 + */ + @PreAuthorize("@ss.hasPermi('train:openApp:add')") + @Log(title = "外部应用", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainOpenApp trainOpenApp) + { + trainOpenApp.setCreateBy(SecurityUtils.getUsername()); + int rows = trainOpenAppService.insertTrainOpenApp(trainOpenApp); + AjaxResult result = toAjax(rows); + // 明文密钥仅在新增时返回一次,用于线下交付接入方 + result.put("appSecret", trainOpenApp.getAppSecret()); + return result; + } + + /** + * 修改外部应用 + */ + @PreAuthorize("@ss.hasPermi('train:openApp:edit')") + @Log(title = "外部应用", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainOpenApp trainOpenApp) + { + trainOpenApp.setUpdateBy(SecurityUtils.getUsername()); + return toAjax(trainOpenAppService.updateTrainOpenApp(trainOpenApp)); + } + + /** + * 删除外部应用 + */ + @PreAuthorize("@ss.hasPermi('train:openApp:remove')") + @Log(title = "外部应用", businessType = BusinessType.DELETE) + @DeleteMapping("/{appIds}") + public AjaxResult remove(@PathVariable String[] appIds) + { + return toAjax(trainOpenAppService.deleteTrainOpenAppByAppIds(appIds)); + } + + /** + * 重置应用密钥(返回一次明文) + */ + @PreAuthorize("@ss.hasPermi('train:openApp:edit')") + @Log(title = "外部应用密钥", businessType = BusinessType.UPDATE) + @PostMapping("/resetSecret/{appId}") + public AjaxResult resetSecret(@PathVariable("appId") String appId) + { + return success(trainOpenAppService.resetAppSecret(appId)); + } + + /** + * 查询应用授权范围 + */ + @PreAuthorize("@ss.hasPermi('train:openApp:query')") + @GetMapping("/scope/{appId}") + public AjaxResult scopeList(@PathVariable("appId") String appId) + { + return success(trainOpenAppService.selectScopeList(appId)); + } + + /** + * 保存应用授权范围(全量覆盖) + */ + @PreAuthorize("@ss.hasPermi('train:openApp:edit')") + @Log(title = "外部应用授权范围", businessType = BusinessType.UPDATE) + @PutMapping("/scope/{appId}") + public AjaxResult saveScopes(@PathVariable("appId") String appId, + @RequestBody List scopes) + { + return toAjax(trainOpenAppService.saveScopes(appId, scopes, SecurityUtils.getUsername())); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java index df69df8c6..b168e166d 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java @@ -8,6 +8,7 @@ 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.DeleteMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -82,4 +83,14 @@ public class TrainPlanController extends BaseController planAppService.completePlan(planId, SecurityUtils.getUsername()); return success(); } + + /** 删除培训计划(仅未开始允许)。 */ + @PreAuthorize("@ss.hasPermi('train:plan:remove')") + @Log(title = "培训计划", businessType = BusinessType.DELETE) + @DeleteMapping("/{planId}") + public AjaxResult remove(@PathVariable String planId) + { + planAppService.deletePlan(planId, SecurityUtils.getUsername()); + return success(); + } } diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainRegDataController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainRegDataController.java new file mode 100644 index 000000000..f7659f8d2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainRegDataController.java @@ -0,0 +1,252 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.dto.RegReportSaveDTO; +import com.ruoyi.train.domain.dto.RegUrgeDTO; +import com.ruoyi.train.domain.vo.RegBatchTrendVO; +import com.ruoyi.train.domain.vo.RegClassVO; +import com.ruoyi.train.domain.vo.RegHistorySummaryVO; +import com.ruoyi.train.domain.vo.RegRosterVO; +import com.ruoyi.train.domain.vo.RegSummaryVO; +import com.ruoyi.train.domain.vo.RegUnitVO; +import com.ruoyi.train.service.ITrainRegDataService; + +/** + * 班级报到数据 Controller + * + *

路由前缀 /train/regdata,与前端 src/api/train/regData.js、 + * src/api/train/historyRegData.js 一一对应。

+ * + *

错误语义遵循框架统一返回体:HTTP 状态固定 200,业务码在 body.code 中, + * 400=参数缺失/非法,403=无权限,404=开班计划/报到记录不存在,409=重复报到。

+ * + * @author ruoyi + */ +@RestController +@RequestMapping("/train/regdata") +public class TrainRegDataController extends BaseController +{ + @Autowired + private ITrainRegDataService trainRegDataService; + + /** + * 查询有报到任务的班级标签。 + * + *

GET /train/regdata/classes?pageNum=1&pageSize=100

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/classes") + public TableDataInfo classes() + { + startPage(); + List list = trainRegDataService.selectReportClasses(); + return getDataTable(list); + } + + /** + * 查询班级报到统计汇总。 + * + *

GET /train/regdata/summary?planId=xxx

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/summary") + public AjaxResult summary(@RequestParam(value = "planId", required = false) String planId) + { + RegSummaryVO summary = trainRegDataService.selectReportSummary(planId); + return success(summary); + } + + /** + * 分页查询未报到学员。 + * + *

GET /train/regdata/notReported?planId=xxx&pageNum=1&pageSize=6

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/notReported") + public TableDataInfo notReported(@RequestParam(value = "planId", required = false) String planId) + { + // 先校验班级存在性,避免分页线程变量残留 + trainRegDataService.selectReportPlan(planId); + startPage(); + List list = trainRegDataService.selectNotReported(planId); + return getDataTable(list); + } + + /** + * 分页查询报到名册(支持 studentName / reportTime 排序)。 + * + *

GET /train/regdata/roster?planId=xxx&pageNum=1&pageSize=10&orderByColumn=reportTime&isAsc=desc

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/roster") + public TableDataInfo roster(@RequestParam(value = "planId", required = false) String planId) + { + trainRegDataService.selectReportPlan(planId); + startPage(); + List list = trainRegDataService.selectRoster(planId); + return getDataTable(list); + } + + /** + * 导出报到名册。 + * + *

POST /train/regdata/roster/export (form: planId)

+ */ + @PreAuthorize("@ss.hasPermi('train:report:export')") + @Log(title = "报到名册", businessType = BusinessType.EXPORT) + @PostMapping("/roster/export") + public void exportRoster(HttpServletResponse response, + @RequestParam(value = "planId", required = false) String planId) + { + List list = trainRegDataService.selectRoster(planId); + ExcelUtil util = new ExcelUtil(RegRosterVO.class); + util.exportExcel(response, list, "报到名册"); + } + + /** + * 催促未报到学员。 + * + *

POST /train/regdata/urge,body {"planId":"xxx","studentIds":["s1"]}, + * studentIds 为空表示催促全班未报到学员。

+ */ + @PreAuthorize("@ss.hasPermi('train:report:confirm')") + @Log(title = "报到催促", businessType = BusinessType.UPDATE) + @PostMapping("/urge") + public AjaxResult urge(@Validated @RequestBody RegUrgeDTO dto) + { + int urgedCount = trainRegDataService.urgeReport(dto.getPlanId(), dto.getStudentIds(), getUsername()); + AjaxResult ajax = AjaxResult.success("催促通知已发送"); + ajax.put("urgedCount", urgedCount); + return ajax; + } + + /** + * 查询单条报到记录。 + * + *

GET /train/regdata/report/{reportId}

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/report/{reportId}") + public AjaxResult getReport(@PathVariable("reportId") Long reportId) + { + TrainClassReport report = trainRegDataService.selectReport(reportId); + return success(report); + } + + /** + * 新增报到记录。 + * + *

POST /train/regdata/report,body 见 {@link RegReportSaveDTO}。

+ */ + @PreAuthorize("@ss.hasPermi('train:report:confirm')") + @Log(title = "报到记录", businessType = BusinessType.INSERT) + @PostMapping("/report") + public AjaxResult addReport(@RequestBody RegReportSaveDTO dto) + { + Long reportId = trainRegDataService.createReport(dto, getUsername()); + AjaxResult ajax = AjaxResult.success("报到记录创建成功"); + ajax.put("reportId", reportId); + return ajax; + } + + /** + * 更新报到记录。 + * + *

PUT /train/regdata/report/{reportId},body 仅需传入待修改字段。

+ */ + @PreAuthorize("@ss.hasPermi('train:report:confirm')") + @Log(title = "报到记录", businessType = BusinessType.UPDATE) + @PutMapping("/report/{reportId}") + public AjaxResult editReport(@PathVariable("reportId") Long reportId, @RequestBody RegReportSaveDTO dto) + { + return toAjax(trainRegDataService.updateReport(reportId, dto, getUsername())); + } + + /** + * 删除报到记录。 + * + *

DELETE /train/regdata/report/{reportId}

+ */ + @PreAuthorize("@ss.hasPermi('train:report:confirm')") + @Log(title = "报到记录", businessType = BusinessType.DELETE) + @DeleteMapping("/report/{reportId}") + public AjaxResult removeReport(@PathVariable("reportId") Long reportId) + { + return toAjax(trainRegDataService.deleteReport(reportId)); + } + + /** + * 历史报到统计汇总。 + * + *

GET /train/regdata/history/summary

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/history/summary") + public AjaxResult historySummary() + { + RegHistorySummaryVO summary = trainRegDataService.selectHistorySummary(); + return success(summary); + } + + /** + * 各批次报到率趋势。 + * + *

GET /train/regdata/history/batchTrend

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/history/batchTrend") + public TableDataInfo batchTrend() + { + List list = trainRegDataService.selectBatchTrend(); + return getDataTable(list); + } + + /** + * 分页查询各送培单位报到情况(按平均报到率降序)。 + * + *

GET /train/regdata/history/units?pageNum=1&pageSize=10

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/history/units") + public TableDataInfo historyUnits() + { + startPage(); + List list = trainRegDataService.selectUnitReport(); + return getDataTable(list); + } + + /** + * 导出历史报到统计报表。 + * + *

POST /train/regdata/history/export

+ */ + @PreAuthorize("@ss.hasPermi('train:report:export')") + @Log(title = "历史报到统计", businessType = BusinessType.EXPORT) + @PostMapping("/history/export") + public void exportHistory(HttpServletResponse response) + { + List list = trainRegDataService.selectUnitReport(); + ExcelUtil util = new ExcelUtil(RegUnitVO.class); + util.exportExcel(response, list, "历史报到统计"); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainScoreAnalysisController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainScoreAnalysisController.java new file mode 100644 index 000000000..f2c8856fa --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainScoreAnalysisController.java @@ -0,0 +1,70 @@ +package com.ruoyi.web.controller.train; + +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.train.domain.analysis.ScoreAnalysisFilterDto; +import com.ruoyi.train.service.ITrainScoreAnalysisService; + +/** + * 成绩统计分析 Controller + * + * 复用既有分析模块的返回与权限约定(AjaxResult + @PreAuthorize), + * 与考勤分析 /train/analysis/attendance/* 保持同一风格。 + */ +@RestController +@RequestMapping("/train/analysis/score") +public class TrainScoreAnalysisController extends BaseController +{ + @Autowired + private ITrainScoreAnalysisService scoreAnalysisService; + + /** + * 成绩统计总览 KPI + */ + @PreAuthorize("@ss.hasPermi('train:analysis:score')") + @GetMapping("/summary") + public AjaxResult getScoreSummary(ScoreAnalysisFilterDto filter) + { + Map summary = scoreAnalysisService.getScoreSummary(filter); + return success(summary); + } + + /** + * 成绩统计图表集合 + */ + @PreAuthorize("@ss.hasPermi('train:analysis:score')") + @GetMapping("/charts") + public AjaxResult getScoreCharts(ScoreAnalysisFilterDto filter) + { + Map charts = scoreAnalysisService.getScoreCharts(filter); + return success(charts); + } + + /** + * 指定维度的分组统计明细(支持视图切换:图表 / 表格) + */ + @PreAuthorize("@ss.hasPermi('train:analysis:score')") + @GetMapping("/dimension") + public AjaxResult getDimensionAnalysis(ScoreAnalysisFilterDto filter) + { + Map result = scoreAnalysisService.getDimensionAnalysis(filter); + return success(result); + } + + /** + * 筛选下拉选项(培训班 / 课程 / 实训上报平台) + */ + @PreAuthorize("@ss.hasPermi('train:analysis:score')") + @GetMapping("/filters") + public AjaxResult getFilterOptions() + { + Map options = scoreAnalysisService.getFilterOptions(); + return success(options); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java index 5f8f3754c..65d35ed97 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java @@ -2,9 +2,18 @@ package com.ruoyi.web.controller.train; import java.util.List; import java.util.regex.Pattern; + +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.iot.lock.service.ILockFaceService; +import com.ruoyi.train.domain.TrainStudent; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.utils.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import com.github.pagehelper.PageInfo; @@ -17,10 +26,14 @@ import com.ruoyi.common.enums.BusinessType; import com.ruoyi.train.domain.dto.StudentCreateDTO; import com.ruoyi.train.domain.dto.StudentUpdateDTO; import com.ruoyi.train.domain.query.StudentQuery; +import com.ruoyi.train.domain.vo.BatchImportResultVO; import com.ruoyi.train.domain.vo.StudentAdminVO; import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentExportVO; +import com.ruoyi.train.domain.vo.StudentImportVO; import com.ruoyi.train.domain.vo.StudentItemVO; import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.train.service.StudentImportService; import com.ruoyi.web.service.train.TrainAccountBindingAdminService; /** ISSUE-01-02 学员主档 P0 API。 */ @@ -28,16 +41,20 @@ import com.ruoyi.web.service.train.TrainAccountBindingAdminService; @RequestMapping({"/train/student", "/train/students"}) public class TrainStudentController extends BaseController { + private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); private final ITrainStudentService trainStudentService; private final TrainAccountBindingAdminService bindingAdminService; + private final StudentImportService studentImportService; public TrainStudentController(ITrainStudentService trainStudentService, - TrainAccountBindingAdminService bindingAdminService) + TrainAccountBindingAdminService bindingAdminService, + StudentImportService studentImportService) { this.trainStudentService = trainStudentService; this.bindingAdminService = bindingAdminService; + this.studentImportService = studentImportService; } @PreAuthorize("@ss.hasPermi('train:student:list')") @@ -67,13 +84,23 @@ public class TrainStudentController extends BaseController return success(bindingAdminService.toStudentAdminVO(detail)); } + @PreAuthorize("@ss.hasAnyPermi('train:student:add,train:student:edit')") + @GetMapping("/gen-person-no") + public AjaxResult genPersonNo() + { + // 必须强制转为 Object,走 success(Object data) 重载。 + // 否则 String 参数会命中 success(String message),编号被放进 msg、data 为空, + // 前端解析 res.data 永远拿不到值(历史上“未返回可用编号”报错的根因)。 + return success((Object) trainStudentService.genPersonNo()); + } + @PreAuthorize("@ss.hasPermi('train:student:add')") @Log(title = "学员主档", businessType = BusinessType.INSERT, isSaveRequestData = false, isSaveResponseData = true) @PostMapping public AjaxResult add(@Valid @RequestBody StudentCreateDTO dto) { - return success(trainStudentService.createStudent(dto)); + return success(bindingAdminService.createStudentWithAccount(dto)); } /** @@ -94,7 +121,45 @@ public class TrainStudentController extends BaseController { return AjaxResult.error("学员手机号码格式不正确"); } - return success(trainStudentService.updateTrainStudent(dto)); + return success(bindingAdminService.updateStudentWithAccount(dto)); + } + + /** + * 下载学员批量导入模板 (POST /train/student/importTemplate)。 + */ + @PreAuthorize("@ss.hasPermi('train:student:add')") + @Log(title = "学员导入模板", businessType = BusinessType.EXPORT) + @PostMapping("/importTemplate") + public void importTemplate(HttpServletResponse response) + { + List rows = studentImportService.templateRows(); + ExcelUtil util = new ExcelUtil(StudentImportVO.class); + util.exportExcel(response, rows, "学员导入模板"); + } + + /** + * 学员批量导入 (POST /train/student/importData)。 + * + *

必须以 multipart/form-data 提交,文件字段名为 {@code file}; + * 若用 application/json 提交(例如 axios 把 FormData 序列化成 JSON), + * 会因 consumes 不匹配直接返回 415,而不是抛出难以定位的 + * "Current request is not a multipart request"。

+ * + *

返回体 data 为 { totalCount, successCount, failureCount, errorList }。

+ */ + @PreAuthorize("@ss.hasPermi('train:student:add')") + @Log(title = "学员主档", businessType = BusinessType.IMPORT) + @PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public AjaxResult importData(@RequestParam(value = "file", required = false) MultipartFile file) + { + if (file == null) + { + throw new com.ruoyi.common.exception.ServiceException( + "未接收到上传文件,请使用 multipart/form-data 上传且字段名为 file", + HttpStatus.BAD_REQUEST); + } + BatchImportResultVO result = studentImportService.importStudents(file, getUsername()); + return AjaxResult.success("导入完成:成功 " + result.getSuccessCount() + " 条,失败 " + result.getFailureCount() + " 条", result); } @PreAuthorize("@ss.hasPermi('train:student:remove')") @@ -109,4 +174,64 @@ public class TrainStudentController extends BaseController { return PHONE_PATTERN.matcher(phone).matches(); } + + /** + * 触发特征提取 + * deviceId 指定目标门锁,用于取该锁维护的 algorithm_version;为空时按该人员授权自动推断 + *

+ * 返回结果的 data 携带特征值,供门禁授权页面回填「授权内容」; + * msg 保持「特征提取成功」文案以兼容既有调用。 + */ + @Log(title = "人脸特征提取", businessType = BusinessType.UPDATE) + @PostMapping("/{faceId}/extract") + public AjaxResult extract(@PathVariable Long faceId, @RequestParam(required = false) Long deviceId) + { + String feature = trainStudentService.extract(faceId, deviceId); + if (StringUtils.isEmpty(feature)) + { + return error("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本"); + } + // 注意:此处必须用 AjaxResult.success 全限定调用。 + // 继承而来的 BaseController.success 只有单参重载,写 success(msg, data) 会编译失败。 + return AjaxResult.success("特征提取成功", feature); + } + + @Log(title = "人脸批量特征提取", businessType = BusinessType.UPDATE) + @PostMapping("/batch/extract") + public AjaxResult batchExtract(@RequestBody Long[] faceIds, @RequestParam(required = false) Long deviceId) + { + int count = trainStudentService.batchExtract(faceIds, deviceId); + return success("成功提取 " + count + " 条"); + } + + /** + * 导出学员台账 (POST /train/student/export)。 + * + *

必须使用带 {@code @Excel} 的 {@link StudentExportVO}:ExcelUtil 只收集带该注解的字段, + * 若直接导出列表 VO(无注解)会生成只有空行、没有表头与单元格的"空表"。

+ * + *

筛选条件与列表页一致;传入 planId 时按培训班维度过滤(排除已退训学员)。

+ */ + @PreAuthorize("@ss.hasPermi('train:student:list')") + @Log(title = "学员台账", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, StudentQuery query) + { + List list = trainStudentService.selectStudentExportList(query); + ExcelUtil util = new ExcelUtil(StudentExportVO.class); + util.exportExcel(response, list, "学员台账"); + } + @Log(title = "人脸特征提取", businessType = BusinessType.UPDATE) + @PostMapping("/{sId}/extractStudent") + public AjaxResult extractStudent(@PathVariable String sId, @RequestParam(required = false) Long deviceId) + { + String feature = trainStudentService.extractStudent(sId, deviceId); + if (StringUtils.isEmpty(feature)) + { + return error("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本"); + } + // 注意:此处必须用 AjaxResult.success 全限定调用。 + // 继承而来的 BaseController.success 只有单参重载,写 success(msg, data) 会编译失败。 + return AjaxResult.success("特征提取成功", feature); + } } diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherAccommodationController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherAccommodationController.java new file mode 100644 index 000000000..6bf6be2c5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherAccommodationController.java @@ -0,0 +1,131 @@ +package com.ruoyi.web.controller.train; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +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 com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.dto.TeacherAccommodationDTO; +import com.ruoyi.train.domain.dto.TeacherAccommodationSaveResult; +import com.ruoyi.train.domain.vo.TeacherAccommodationVO; +import com.ruoyi.train.service.ITrainTeacherAccommodationService; + +/** + * 教师住宿安排 Controller + *

+ * 与学生住宿(/train/accommodation)完全隔离:教师住宿不关联班级, + * enrollment_id 字段存放教师主键,occupant_type 固定为 teacher。 + * + * @author ruoyi + * @date 2026-11-04 + */ +@RestController +@RequestMapping("/train/teacher/accommodation") +public class TrainTeacherAccommodationController extends BaseController +{ + @Autowired + private ITrainTeacherAccommodationService trainTeacherAccommodationService; + + /** + * 批量查询教师当前有效住宿安排 (教师列表页住宿列回显) + */ + @PreAuthorize("@ss.hasPermi('train:teacher:list')") + @GetMapping("/list") + public AjaxResult list(@RequestParam(value = "teacherIds", required = false) List teacherIds) + { + if (teacherIds == null || teacherIds.isEmpty()) + { + return AjaxResult.success(Collections.emptyList()); + } + return AjaxResult.success(trainTeacherAccommodationService.listActiveByTeacherIds(teacherIds)); + } + + /** + * 查询教师当前有效住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:teacher:query')") + @GetMapping("/{teacherId}") + public AjaxResult getByTeacherId(@PathVariable("teacherId") Long teacherId) + { + return AjaxResult.success(trainTeacherAccommodationService.getActiveByTeacherId(teacherId)); + } + + /** + * 查询教师住宿历史记录 (含已退宿) + */ + @PreAuthorize("@ss.hasPermi('train:teacher:query')") + @GetMapping("/{teacherId}/history") + public AjaxResult getHistoryByTeacherId(@PathVariable("teacherId") Long teacherId) + { + return AjaxResult.success(trainTeacherAccommodationService.listHistoryByTeacherId(teacherId)); + } + + /** + * 教师住宿分配 / 调整宿舍 (教师住宿与班级无关) + */ + @PreAuthorize("@ss.hasPermi('train:teacher:edit')") + @Log(title = "教师住宿安排", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult assign(@RequestBody TeacherAccommodationDTO dto) + { + if (dto == null || dto.getTeacherId() == null) + { + return AjaxResult.error("教师ID (teacherId) 不能为空"); + } + TeacherAccommodationSaveResult saveResult = trainTeacherAccommodationService.assign(dto.getTeacherId(), + dto.getDormId(), dto.getBedNo(), getUsernameSafe()); + if (saveResult == null || saveResult.getAccommodation() == null) + { + return AjaxResult.error("教师住宿安排失败"); + } + // 住宿主流程成功即返回成功;人脸校验未通过时拒绝开锁授权,由 lockAuthorized/lockMessage 透出原因,不改变 code 语义 + TeacherAccommodationVO accommodation = saveResult.getAccommodation(); + Map data = new HashMap<>(8); + data.put("accommodationId", accommodation.getAccommodationId()); + data.put("lockApiBizNo", accommodation.getLockApiBizNo()); + data.put("lockAuthorized", saveResult.isLockAuthorized()); + data.put("lockStatus", saveResult.getLockStatus()); + data.put("lockMessage", saveResult.getLockMessage()); + data.put("accommodation", accommodation); + return AjaxResult.success(data); + } + + /** + * 教师退宿:门锁退宿撤权 + 人脸库删除 (不清空教师头像) + */ + @PreAuthorize("@ss.hasPermi('train:teacher:edit')") + @Log(title = "教师住宿安排", businessType = BusinessType.UPDATE) + @PostMapping("/{accommodationId}/checkout") + public AjaxResult checkOut(@PathVariable("accommodationId") Long accommodationId) + { + return toAjax(trainTeacherAccommodationService.checkOut(accommodationId, getUsernameSafe())); + } + + /** + * 取当前登录用户名,取不到时回落 admin (与住宿安排 Controller 保持一致) + */ + private String getUsernameSafe() + { + try + { + return SecurityUtils.getUsername(); + } + catch (Exception ex) + { + return "admin"; + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java index 8dfaf5bb0..4aa7b0fde 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java @@ -2,6 +2,11 @@ package com.ruoyi.web.controller.train; import java.util.List; import java.util.regex.Pattern; + +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.train.domain.query.StudentQuery; +import com.ruoyi.train.domain.vo.StudentItemVO; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; @@ -105,7 +110,7 @@ public class TrainTeacherController extends BaseController { return AjaxResult.error("手机号码格式不正确"); } - return toAjax(trainTeacherService.insertTrainTeacher(trainTeacher)); + return success(bindingAdminService.createTeacherWithAccount(trainTeacher)); } /** @@ -125,7 +130,7 @@ public class TrainTeacherController extends BaseController { return AjaxResult.error("手机号码格式不正确"); } - return toAjax(trainTeacherService.updateTrainTeacher(trainTeacher)); + return success(bindingAdminService.updateTeacherWithAccount(trainTeacher)); } /** @@ -139,6 +144,15 @@ public class TrainTeacherController extends BaseController return toAjax(trainTeacherService.deleteTrainTeacherByTId(tId)); } + @Log(title = "教师管理", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainTeacher trainTeacher) + { + List list = trainTeacherService.selectTrainTeacherList(trainTeacher); + ExcelUtil util = new ExcelUtil(TrainTeacher.class); + util.exportExcel(response, list, "教师数据"); + } + private boolean isValidPhone(String phone) { return PHONE_PATTERN.matcher(phone).matches(); diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTheoryExamScoreController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTheoryExamScoreController.java new file mode 100644 index 000000000..31d05ed3a --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTheoryExamScoreController.java @@ -0,0 +1,107 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +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 com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.train.domain.dto.TrainTheoryExamScoreDTO; +import com.ruoyi.train.domain.vo.TheoryExamScoreExportVO; +import com.ruoyi.train.domain.vo.TrainTheoryExamScoreVO; +import com.ruoyi.train.service.ITrainTheoryExamScoreService; + +/** + * 班级学员理论考试成绩Controller + * + * @author ruoyi + * @date 2026-11-06 + */ +@RestController +@RequestMapping("/train/theoryExamScore") +public class TrainTheoryExamScoreController extends BaseController +{ + @Autowired + private ITrainTheoryExamScoreService trainTheoryExamScoreService; + + /** + * 查询理论考试成绩列表(分页 + 条件查询) + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:list')") + @GetMapping("/list") + public TableDataInfo list(TrainTheoryExamScoreVO query) + { + startPage(); + List list = trainTheoryExamScoreService.selectTrainTheoryExamScoreList(query); + return getDataTable(list); + } + + /** + * 导出理论成绩:按当前查询条件导出全部数据(不调用 startPage,故不受分页限制), + * 数据按班级 → 课程 → 学号(person_no)排序 + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:export') or @ss.hasPermi('train:theoryExamScore:list')") + @Log(title = "理论成绩导出", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, TrainTheoryExamScoreVO query) + { + List list = trainTheoryExamScoreService.selectTheoryExamScoreExportList(query); + ExcelUtil util = new ExcelUtil(TheoryExamScoreExportVO.class); + util.exportExcel(response, list, "理论成绩"); + } + + /** + * 获取理论考试成绩详情(含学员/班级/课程名称,避免页面展示原始ID) + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:query')") + @GetMapping(value = "/{theoryScoreId}") + public AjaxResult getInfo(@PathVariable("theoryScoreId") Long theoryScoreId) + { + return success(trainTheoryExamScoreService.selectTrainTheoryExamScoreDetail(theoryScoreId)); + } + + /** + * 新增理论考试成绩 + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:add')") + @Log(title = "理论考试成绩", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainTheoryExamScoreDTO dto) + { + return toAjax(trainTheoryExamScoreService.insertTrainTheoryExamScore(dto)); + } + + /** + * 修改理论考试成绩 + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:edit')") + @Log(title = "理论考试成绩", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainTheoryExamScoreDTO dto) + { + return toAjax(trainTheoryExamScoreService.updateTrainTheoryExamScore(dto)); + } + + /** + * 删除理论考试成绩(逻辑删除,支持批量) + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:remove')") + @Log(title = "理论考试成绩", businessType = BusinessType.DELETE) + @DeleteMapping("/{theoryScoreIds}") + public AjaxResult remove(@PathVariable Long[] theoryScoreIds) + { + return toAjax(trainTheoryExamScoreService.deleteTrainTheoryExamScoreByTheoryScoreIds(theoryScoreIds)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTheoryExamScoreImportController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTheoryExamScoreImportController.java new file mode 100644 index 000000000..2afa373b9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTheoryExamScoreImportController.java @@ -0,0 +1,74 @@ +package com.ruoyi.web.controller.train; + +import java.io.IOException; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.train.domain.vo.TheoryScoreImportResultVO; +import com.ruoyi.train.service.TheoryScoreImportService; + +/** + * 理论考试成绩批量导入 Controller(新增) + * + *

与既有 TrainTheoryExamScoreController 共用 /train/theoryExamScore 前缀, + * 仅新增 importTemplate / importData 两个端点,不改动任何既有端点。

+ */ +@RestController +@RequestMapping("/train/theoryExamScore") +public class TrainTheoryExamScoreImportController extends BaseController +{ + @Autowired + private TheoryScoreImportService theoryScoreImportService; + + /** + * 下载按“班级 + 课程”预填在册学员的导入模板 + * + * @param response 响应 + * @param planId 培训班ID + * @param planCourseId 培训课程实例ID + * @param strictStatus 是否仅导出已报到/已完成学员 + * @param format 已废弃:模板固定输出 xlsx,无需用户选择 + * @throws IOException IO异常 + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:import') or @ss.hasPermi('train:theoryExamScore:add')") + @PostMapping("/importTemplate") + public void importTemplate(HttpServletResponse response, + @RequestParam(value = "planId", required = false) String planId, + @RequestParam(value = "planCourseId", required = false) Long planCourseId, + @RequestParam(value = "strictStatus", required = false, defaultValue = "false") boolean strictStatus, + @RequestParam(value = "format", required = false, defaultValue = "xlsx") String format) + throws IOException + { + theoryScoreImportService.exportTemplate(response, planId, planCourseId, strictStatus, format); + } + + /** + * 上传并导入理论成绩 + * + * @param file Excel 文件 + * @param planId 培训班ID + * @param planCourseId 培训课程实例ID + * @param importMode 冲突策略:SKIP 跳过 / OVERWRITE 覆盖 / STRICT 严格 + * @return 导入结果(成功、覆盖、跳过、失败数量及逐行错误明细) + */ + @PreAuthorize("@ss.hasPermi('train:theoryExamScore:import') or @ss.hasPermi('train:theoryExamScore:add')") + @Log(title = "理论考试成绩导入", businessType = BusinessType.IMPORT) + @PostMapping("/importData") + public AjaxResult importData(@RequestParam("file") MultipartFile file, + @RequestParam(value = "planId", required = false) String planId, + @RequestParam(value = "planCourseId", required = false) Long planCourseId, + @RequestParam(value = "importMode", required = false, defaultValue = "SKIP") String importMode) + { + TheoryScoreImportResultVO result = theoryScoreImportService.importData(file, planId, planCourseId, importMode); + return success(result); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTrainingSiteController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTrainingSiteController.java index 8b71d8c75..362772ed4 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTrainingSiteController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTrainingSiteController.java @@ -56,6 +56,16 @@ public class TrainTrainingSiteController extends BaseController return success(trainTrainingSiteService.selectTrainTrainingSiteBySiteId(siteId)); } + /** + * 获取某开班计划(planId)课表中用到的实训基地信息(按实训基地去重) + */ + @PreAuthorize("@ss.hasPermi('train:site:list')") + @GetMapping("/plan/{planId}") + public AjaxResult listByPlan(@PathVariable("planId") String planId) + { + return success(trainTrainingSiteService.selectTrainTrainingSiteByPlanId(planId)); + } + /** * 新增实训场地 */ diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/filter/DeviceHmacFilter.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/filter/DeviceHmacFilter.java new file mode 100644 index 000000000..56772e9f6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/filter/DeviceHmacFilter.java @@ -0,0 +1,193 @@ +package com.ruoyi.web.filter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.device.domain.TrainIotDevice; +import com.ruoyi.device.service.ITrainIotDeviceService; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; + +/** + * 设备端 HMAC 签名校验过滤器(仅作用于设备登录接口)。 + * + * 安全约定(客户端需遵守): + * 1. 请求头携带 {@code X-Signature} 与 {@code X-Nonce}; + * 2. 签名串 StringToSign = + * HTTP_METHOD + "\n" + ServletPath + "\n" + body.timestamp + "\n" + nonce + "\n" + 原始请求体(Raw Body); + * 3. X-Signature = HMAC-SHA256(设备 deviceSecretDigest, StringToSign) 的十六进制小写; + * 设备档案仅存储 deviceSecretDigest(raw secret 的 SHA256 摘要),服务端以此摘要作为 HMAC 密钥, + * 全程不接触明文密钥; + * 4. body.timestamp 与服务器时间偏差不得超过 5 分钟,用于防重放。 + * + * 该接口已在 SecurityConfig 中 permitAll,由本过滤器在到达控制器前完成设备身份验签。 + * + * @author ruoyi + * @date 2026-10-20 + */ +public class DeviceHmacFilter extends OncePerRequestFilter +{ + private static final String LOGIN_PATH = "/api/iot/v1/device/login"; + private static final String SIGNATURE_HEADER = "X-Signature"; + private static final String NONCE_HEADER = "X-Nonce"; + private static final long MAX_TIMESTAMP_SKEW_MS = 5 * 60 * 1000L; + + private boolean enabled = true; + + private ITrainIotDeviceService iotDeviceService; + + public void setIotDeviceService(ITrainIotDeviceService iotDeviceService) + { + this.iotDeviceService = iotDeviceService; + } + + public void setEnabled(boolean enabled) + { + this.enabled = enabled; + } + + // 不依赖容器 ObjectMapper bean(本项目未将其注册为 bean),本地实例化即可满足取 deviceId/timestamp 的解析需求 + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) + { + // 本地联调可关闭验签(iot.hmac.enabled=false);否则仅对设备登录接口做 HMAC 校验 + if (!enabled) + { + return true; + } + return !LOGIN_PATH.equals(request.getServletPath()); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException + { + // 包装请求并缓存 body,供下游控制器 @RequestBody 复用 + ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(request, 10 * 1024 * 1024); + byte[] body = readAndCacheBody(wrapped); + + String signature = wrapped.getHeader(SIGNATURE_HEADER); + String nonce = wrapped.getHeader(NONCE_HEADER); + + try + { + if (signature == null || signature.isEmpty() || nonce == null || nonce.isEmpty()) + { + writeUnauthorized(response, "缺少设备签名头"); + return; + } + JsonNode root = objectMapper.readTree(body); + String deviceId = root.path("deviceId").asText(null); + long timestamp = root.path("timestamp").asLong(0L); + + TrainIotDevice device = (deviceId == null) ? null : iotDeviceService.selectTrainIotDeviceByDeviceId(deviceId); + if (device == null) + { + writeUnauthorized(response, "设备不存在"); + return; + } + if (device.getDeviceStatus() != null && device.getDeviceStatus() == 2) + { + writeUnauthorized(response, "设备已禁用"); + return; + } + String secret = device.getDeviceSecretDigest(); + if (secret == null || secret.isEmpty()) + { + writeUnauthorized(response, "设备未配置密钥"); + return; + } + long now = Instant.now().toEpochMilli(); + if (timestamp <= 0 || Math.abs(now - timestamp) > MAX_TIMESTAMP_SKEW_MS) + { + writeUnauthorized(response, "时间戳超时"); + return; + } + String stringToSign = buildStringToSign(wrapped.getMethod(), wrapped.getServletPath(), timestamp, nonce, body); + String expected = hmacSha256Hex(secret, stringToSign); + if (!constantTimeEquals(expected, signature)) + { + writeUnauthorized(response, "签名校验失败"); + return; + } + } + catch (Exception e) + { + writeUnauthorized(response, "签名校验异常"); + return; + } + + filterChain.doFilter(wrapped, response); + } + + private byte[] readAndCacheBody(ContentCachingRequestWrapper wrapped) throws IOException + { + InputStream in = wrapped.getInputStream(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) != -1) + { + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } + + private String buildStringToSign(String method, String path, long timestamp, String nonce, byte[] body) + { + return method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + new String(body, StandardCharsets.UTF_8); + } + + private String hmacSha256Hex(String key, String data) throws Exception + { + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init(new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) + { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private boolean constantTimeEquals(String a, String b) + { + if (a == null || b == null) + { + return false; + } + byte[] ab = a.getBytes(StandardCharsets.UTF_8); + byte[] bb = b.getBytes(StandardCharsets.UTF_8); + if (ab.length != bb.length) + { + return false; + } + int result = 0; + for (int i = 0; i < ab.length; i++) + { + result |= ab[i] ^ bb[i]; + } + return result == 0; + } + + private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException + { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(AjaxResult.error(msg))); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/filter/DeviceHmacFilterConfig.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/filter/DeviceHmacFilterConfig.java new file mode 100644 index 000000000..ed182775f --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/filter/DeviceHmacFilterConfig.java @@ -0,0 +1,34 @@ +package com.ruoyi.web.filter; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import com.ruoyi.device.service.ITrainIotDeviceService; + +/** + * 设备 HMAC 验签过滤器注册:仅对设备登录接口生效,避免影响其它接口。 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Configuration +public class DeviceHmacFilterConfig +{ + @Bean + public FilterRegistrationBean deviceHmacFilterRegistration( + ITrainIotDeviceService iotDeviceService, + @Value("${iot.hmac.enabled:true}") boolean hmacEnabled) + { + DeviceHmacFilter filter = new DeviceHmacFilter(); + filter.setIotDeviceService(iotDeviceService); + filter.setEnabled(hmacEnabled); + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + // 仅对设备登录接口做 HMAC 验签 + registration.addUrlPatterns("/api/iot/v1/device/login"); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + registration.setName("deviceHmacFilter"); + return registration; + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java index 748d48023..a5f4451d9 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java @@ -1,23 +1,39 @@ package com.ruoyi.web.service.train; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.core.domain.entity.SysRole; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.bean.BeanUtils; +import com.ruoyi.system.mapper.SysUserMapper; import com.ruoyi.system.service.ISysRoleService; import com.ruoyi.system.service.ISysUserService; import com.ruoyi.train.domain.TrainStudent; import com.ruoyi.train.domain.TrainTeacher; -import com.ruoyi.train.domain.vo.*; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +import com.ruoyi.train.domain.dto.StudentUpdateDTO; +import com.ruoyi.train.domain.vo.BindableUserVO; +import com.ruoyi.train.domain.vo.StudentAdminVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.domain.vo.TeacherAdminVO; import com.ruoyi.train.mapper.TrainStudentMapper; import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.train.service.ITrainTeacherService; import com.ruoyi.train.service.ITrainingIdentityService; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; -import java.util.stream.Collectors; /** * 管理员端业务身份账号绑定编排服务。 @@ -36,6 +52,26 @@ public class TrainAccountBindingAdminService private final TrainTeacherMapper teacherMapper; private final TrainStudentMapper studentMapper; + /** + * 学员主档服务(跨模块依赖,通过字段注入以避免改动既有构造函数签名影响单元测试)。 + * 用于“新增/修改学员时自动创建登录账号”这一编排场景。 + */ + @Autowired(required = false) + private ITrainStudentService trainStudentService; + + /** + * 教师主档服务(跨模块依赖,字段注入,与学员服务同理)。 + * 用于“新增/修改教师时自动创建登录账号”这一编排场景。 + */ + @Autowired(required = false) + private ITrainTeacherService trainTeacherService; + + /** + * 系统用户 Mapper(用于按需同步部门/邮箱/性别等基础字段,避免走 updateUser 重置角色/岗位)。 + */ + @Autowired(required = false) + private SysUserMapper userMapper; + public TrainAccountBindingAdminService(ITrainingIdentityService identityService, ISysUserService userService, ISysRoleService roleService, @@ -345,15 +381,19 @@ public class TrainAccountBindingAdminService // 2. 单次批量查询学员的关联 userId 映射 Map studentUserMap; + Map studentMap; if (!sIds.isEmpty()) { List students = studentMapper.selectTrainStudentsBySIds(sIds); + studentMap = students.stream() + .collect(Collectors.toMap(TrainStudent::getsId, s -> s, (k1, k2) -> k1)); studentUserMap = students.stream() .filter(s -> s.getUserId() != null) .collect(Collectors.toMap(TrainStudent::getsId, TrainStudent::getUserId, (k1, k2) -> k1)); } else { + studentMap = Collections.emptyMap(); studentUserMap = Collections.emptyMap(); } @@ -376,6 +416,12 @@ public class TrainAccountBindingAdminService { StudentAdminVO vo = new StudentAdminVO(); BeanUtils.copyBeanProp(vo, item); + // 列表视图本身不含建档时间,这里从批量查询到的学员档案补齐(批量添加参训人员需展示创建时间) + TrainStudent student = studentMap.get(item.getsId()); + if (student != null && student.getCreateTime() != null) + { + vo.setCreateTime(student.getCreateTime()); + } Long uid = studentUserMap.get(item.getsId()); if (uid != null) { @@ -430,6 +476,327 @@ public class TrainAccountBindingAdminService return vo; } + /** + * 新增学员主档并自动创建登录账号。 + * + *

以学员联系电话作为登录用户名、123456 作为初始密码创建系统用户, + * 分配“培训学员”角色,并把生成的系统用户ID回写到 train_student.user_id。 + * 学员建档与账号创建在同一事务内完成,保证数据一致。

+ * + * @param dto 学员新增请求 + * @return 学员主档ID + */ + @Transactional(rollbackFor = Exception.class) + public String createStudentWithAccount(StudentCreateDTO dto) + { + if (trainStudentService == null) + { + throw new ServiceException("学员主档服务不可用"); + } + String sId = trainStudentService.createStudent(dto); + ensureStudentAccount(sId, dto.getsPhone(), dto.getsName()); + return sId; + } + + /** + * 修改学员主档并自动创建登录账号(若尚未绑定)。 + * + * @param dto 学员修改请求(携带 sId) + * @return 学员主档ID + */ + @Transactional(rollbackFor = Exception.class) + public String updateStudentWithAccount(StudentUpdateDTO dto) + { + if (trainStudentService == null) + { + throw new ServiceException("学员主档服务不可用"); + } + String sId = trainStudentService.updateTrainStudent(dto); + ensureStudentAccount(sId, dto.getsPhone(), dto.getsName()); + return sId; + } + + /** + * 新增教师主档并自动创建登录账号。 + * + *

以教师联系电话作为登录用户名、123456 作为初始密码创建系统用户, + * 分配“培训教师”角色,并把生成的系统用户ID回写到 train_teacher.user_id。 + * 教师建档与账号创建在同一事务内完成,保证数据一致。

+ * + * @param teacher 教师新增请求(实体) + * @return 教师主档ID(工号) + */ + @Transactional(rollbackFor = Exception.class) + public String createTeacherWithAccount(TrainTeacher teacher) + { + if (trainTeacherService == null) + { + throw new ServiceException("教师主档服务不可用"); + } + trainTeacherService.insertTrainTeacher(teacher); + String tId = teacher.gettId(); + ensureTeacherAccount(tId, teacher.gettPhone(), teacher.gettName()); + return tId; + } + + /** + * 修改教师主档并自动创建登录账号(若尚未绑定)。 + * + * @param teacher 教师修改请求(携带 tId) + * @return 教师主档ID(工号) + */ + @Transactional(rollbackFor = Exception.class) + public String updateTeacherWithAccount(TrainTeacher teacher) + { + if (trainTeacherService == null) + { + throw new ServiceException("教师主档服务不可用"); + } + trainTeacherService.updateTrainTeacher(teacher); + ensureTeacherAccount(teacher.gettId(), teacher.gettPhone(), teacher.gettName()); + return teacher.gettId(); + } + + /** + * 确保教师主档已绑定登录账号:若尚未绑定,则以联系电话为用户名、123456 为初始密码 + * 创建系统用户,分配“培训教师”角色,并把 user_id 回写到 train_teacher;已绑定则同步基础信息。 + * + *

无论新建还是已绑定,都会将教师的部门、邮箱、性别同步到关联登录用户 sys_user 的对应字段。

+ * + * @param tId 教师主档ID(工号) + * @param phone 联系电话(同时作为登录用户名) + * @param name 教师姓名(作为用户昵称) + * @return 绑定的系统用户ID + */ + @Transactional(rollbackFor = Exception.class) + public Long ensureTeacherAccount(String tId, String phone, String name) + { + TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId); + if (teacher == null) + { + throw new ServiceException("教师业务档案不存在"); + } + Long userId = teacher.getUserId(); + if (userId == null) + { + if (StringUtils.isEmpty(phone)) + { + throw new ServiceException("教师联系电话不能为空,无法创建登录账号"); + } + + // 登录账号(用户名 = 联系电话)唯一性校验 + SysUser probe = new SysUser(); + probe.setUserName(phone); + if (!userService.checkUserNameUnique(probe)) + { + throw new ServiceException("登录账号(" + phone + ")已存在,无法重复创建"); + } + + // 解析“培训教师”角色 (roleKey = train_teacher) + Long roleId = resolveRoleIdByKey("train_teacher"); + + SysUser user = new SysUser(); + user.setUserName(phone); + user.setNickName(StringUtils.isNotEmpty(name) ? name : phone); + user.setPhonenumber(phone); + user.setPassword(SecurityUtils.encryptPassword("123456")); + user.setStatus("0"); + user.setDelFlag("0"); + // 同步部门、邮箱、性别到登录用户 + user.setDeptId(parseDeptId(teacher.getDeptName())); + user.setEmail(teacher.gettEmail()); + user.setSex(toSysUserSex(teacher.gettGender())); + user.setRoleIds(new Long[] { roleId }); + // insertUser 内部会写入角色关联并回填自增 user_id + userService.insertUser(user); + + userId = user.getUserId(); + System.out.println("userId = " + userId); + if (userId == null) + { + throw new ServiceException("创建登录账号失败"); + } + + // 复用底层原子互斥绑定,将 user_id 回写到 train_teacher + if (userId != 0) + { + identityService.bindTeacherUser(tId, userId); + } + + } + + // 已绑定或刚创建:将部门、邮箱、性别同步到关联登录用户(仅更新非空字段,不影响角色/密码) + syncUserBasicInfo(userId, parseDeptId(teacher.getDeptName()), + teacher.gettEmail(), toSysUserSex(teacher.gettGender())); + return userId; + } + + /** + * 确保学员主档已绑定登录账号:若尚未绑定,则以联系电话为用户名、123456 为初始密码 + * 创建系统用户,分配“培训学员”角色,并把 user_id 回写到 train_student;已绑定则同步基础信息。 + * + *

无论新建还是已绑定,都会将学员的部门、邮箱、性别同步到关联登录用户 sys_user 的对应字段。

+ * + * @param sId 学员主档ID + * @param phone 联系电话(同时作为登录用户名) + * @param name 学员姓名(作为用户昵称) + * @return 绑定的系统用户ID + */ + @Transactional(rollbackFor = Exception.class) + public Long ensureStudentAccount(String sId, String phone, String name) + { + TrainStudent student = studentMapper.selectTrainStudentBySId(sId); + if (student == null) + { + throw new ServiceException("学员业务档案不存在"); + } + Long userId = student.getUserId(); + if (userId == null) + { + if (StringUtils.isEmpty(phone)) + { + throw new ServiceException("学员联系电话不能为空,无法创建登录账号"); + } + + // 登录账号(用户名 = 联系电话)唯一性校验 + SysUser probe = new SysUser(); + probe.setUserName(phone); + if (!userService.checkUserNameUnique(probe)) + { + throw new ServiceException("登录账号(" + phone + ")已存在,无法重复创建"); + } + + // 解析“培训学员”角色 (roleKey = train_student) + Long roleId = resolveRoleIdByKey("train_student"); + + SysUser user = new SysUser(); + user.setUserName(phone); + user.setNickName(StringUtils.isNotEmpty(name) ? name : phone); + user.setPhonenumber(phone); + user.setPassword(SecurityUtils.encryptPassword("123456")); + user.setStatus("0"); + user.setDelFlag("0"); + // 同步部门、邮箱、性别到登录用户 + user.setDeptId(parseDeptId(student.getDeptName())); + user.setEmail(student.getsEmail()); + user.setSex(toSysUserSex(student.getsGender())); + user.setRoleIds(new Long[] { roleId }); + // insertUser 内部会写入角色关联并回填自增 user_id + userService.insertUser(user); + + userId = user.getUserId(); + System.out.println("userId = " + userId); + if (userId == null) + { + throw new ServiceException("创建登录账号失败"); + } + + // 复用底层原子互斥绑定,将 user_id 回写到 train_student + identityService.bindStudentUser(sId, userId); + } + + // 已绑定或刚创建:将部门、邮箱、性别同步到关联登录用户(仅更新非空字段,不影响角色/密码) + syncUserBasicInfo(userId, parseDeptId(student.getDeptName()), + student.getsEmail(), toSysUserSex(student.getsGender())); + return userId; + } + + /** + * 按角色权限字符串查找角色ID。 + * + * @param roleKey 角色权限字符串 + * @return 角色ID + */ + private Long resolveRoleIdByKey(String roleKey) + { + List roles = roleService.selectRoleAll(); + if (roles != null) + { + for (SysRole role : roles) + { + if (roleKey.equals(role.getRoleKey())) + { + return role.getRoleId(); + } + } + } + throw new ServiceException("未找到角色(" + roleKey + "),无法分配"); + } + + /** + * 将部门、邮箱、性别同步到关联登录用户 sys_user 的基础字段。 + * + *

仅更新非空字段(dept_id 为空/0 不更新、email/sex 为 null 不更新), + * 不会触碰密码、角色、岗位、状态等,避免覆盖系统用户管理的其他配置。

+ * + * @param userId 系统用户ID + * @param deptId 部门ID(学员/教师的 deptName,即部门树节点ID) + * @param email 邮箱 + * @param sex 性别(sys_user 标准值:0 男 / 1 女 / 2 未知) + */ + private void syncUserBasicInfo(Long userId, Long deptId, String email, String sex) + { + if (userMapper == null) + { + return; + } + SysUser user = new SysUser(); + user.setUserId(userId); + user.setDeptId(deptId); + user.setEmail(email); + user.setSex(sex); + userMapper.updateUser(user); + } + + /** + * 将学员/教师的 deptName(归属部门树节点ID,字符串形式)解析为系统用户部门ID。 + * + * @param deptName 归属部门(部门树节点ID) + * @return 部门ID;非数字或为空时返回 null(不更新部门) + */ + private Long parseDeptId(String deptName) + { + if (StringUtils.isEmpty(deptName)) + { + return null; + } + try + { + return Long.valueOf(deptName.trim()); + } + catch (NumberFormatException e) + { + return null; + } + } + + /** + * 将业务性别转换为 sys_user.sex 标准值。 + * + * @param gender 业务性别(学员为 0/1/2;教师为 男/女) + * @return sys_user.sex 标准值(0 男 / 1 女 / 2 未知);无法识别时返回 null(不更新性别) + */ + private String toSysUserSex(String gender) + { + if (StringUtils.isEmpty(gender)) + { + return null; + } + if ("男".equals(gender) || "0".equals(gender)) + { + return "0"; + } + if ("女".equals(gender) || "1".equals(gender)) + { + return "1"; + } + if ("2".equals(gender) || "未知".equals(gender)) + { + return "2"; + } + return null; + } + private void validateUserRole(Long userId, String requiredRoleKey, String roleLabel) { SysUser user = userService.selectUserById(userId); diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml index 36856144a..efdc07203 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml @@ -3,16 +3,33 @@ spring: datasource: type: com.alibaba.druid.pool.DruidDataSource # KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver - driverClassName: com.kingbase8.Driver + driverClassName: com.mysql.cj.jdbc.Driver + #driverClassName: com.kingbase8.Driver druid: # 主库数据源 master: - url: ${SPRING_DATASOURCE_DRUID_MASTER_URL:jdbc:kingbase8://${DB_HOST:127.0.0.1}:${DB_PORT:54321}/${DB_NAME:railwaysystem_fusion}?useUnicode=true&characterEncoding=utf8} - username: ${SPRING_DATASOURCE_DRUID_MASTER_USERNAME:${QA_DB_USER:fusion_app}} - password: ${SPRING_DATASOURCE_DRUID_MASTER_PASSWORD:${FUSION_QA_APP_PASSWORD:}} - # 多模式透明路由与系统视图隔离 - connectionInitSqls: - - set search_path to iotlocks, public, pg_catalog, sys_catalog + # KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public + # MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + ##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public + ##username: kingbase + ##password: sict123456 + ##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks + ##username: system + ##password: kingbase + + url: jdbc:mysql://localhost:3306/training_server?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + #url: jdbc:mysql://192.168.1.202:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + username: root + password: psw.db7898 + + + # KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。 + # 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面, + # 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。 + # 因此这里必须显式写出 sys_catalog 并排在业务模式之后。 + # 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。 + #connectionInitSqls: + #- set search_path to public, pg_catalog, sys_catalog # 从库数据源 slave: # 从数据源开关/默认关闭 @@ -48,13 +65,14 @@ spring: webStatFilter: enabled: true statViewServlet: - enabled: ${DRUID_CONSOLE_ENABLED:false} - # 设置白名单,不填则允许所有访问 - allow: + enabled: false url-pattern: /druid/* # 控制台管理用户名和密码 - login-username: ${DRUID_CONSOLE_USERNAME:} - login-password: ${DRUID_CONSOLE_PASSWORD:} + login-username: admin + login-password: admin123 + # IP白名单,本地开发填写127.0.0.1 + allow: 127.0.0.1 + reset-enable: false filter: stat: enabled: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_230.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_230.yml new file mode 100644 index 000000000..42c1693a1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_230.yml @@ -0,0 +1,87 @@ +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + # KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver + driverClassName: com.mysql.cj.jdbc.Driver + #driverClassName: com.kingbase8.Driver + druid: + # 主库数据源 + master: + # KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public + # MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + ##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public + ##username: kingbase + ##password: sict123456 + ##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks + ##username: system + ##password: kingbase + + #url: jdbc:mysql://localhost:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + url: jdbc:mysql://192.168.1.230:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + username: root + password: psw.db7898 + + + # KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。 + # 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面, + # 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。 + # 因此这里必须显式写出 sys_catalog 并排在业务模式之后。 + # 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。 + #connectionInitSqls: + #- set search_path to public, pg_catalog, sys_catalog + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + connectionInitSqls: + - set search_path to public, pg_catalog, sys_catalog + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法) + validationQuery: SELECT 1 + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: false + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: admin + login-password: admin123 + # IP白名单,本地开发填写127.0.0.1 + allow: 127.0.0.1 + reset-enable: false + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + # 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型 + # db-type: kingbase + config: + multi-statement-allow: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_24.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_24.yml new file mode 100644 index 000000000..ec746780a --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_24.yml @@ -0,0 +1,87 @@ +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + # KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver + driverClassName: com.mysql.cj.jdbc.Driver + #driverClassName: com.kingbase8.Driver + druid: + # 主库数据源 + master: + # KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public + # MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + ##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public + ##username: kingbase + ##password: sict123456 + ##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks + ##username: system + ##password: kingbase + + #url: jdbc:mysql://localhost:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + url: jdbc:mysql://192.168.106.24:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + username: root + password: psw.db7898 + + + # KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。 + # 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面, + # 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。 + # 因此这里必须显式写出 sys_catalog 并排在业务模式之后。 + # 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。 + #connectionInitSqls: + #- set search_path to public, pg_catalog, sys_catalog + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + connectionInitSqls: + - set search_path to public, pg_catalog, sys_catalog + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法) + validationQuery: SELECT 1 + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: false + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: admin + login-password: admin123 + # IP白名单,本地开发填写127.0.0.1 + allow: 127.0.0.1 + reset-enable: false + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + # 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型 + # db-type: kingbase + config: + multi-statement-allow: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_localhost.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_localhost.yml new file mode 100644 index 000000000..15cf6e511 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_localhost.yml @@ -0,0 +1,87 @@ +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + # KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver + driverClassName: com.mysql.cj.jdbc.Driver + #driverClassName: com.kingbase8.Driver + druid: + # 主库数据源 + master: + # KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public + # MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + ##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public + ##username: kingbase + ##password: sict123456 + ##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks + ##username: system + ##password: kingbase + + url: jdbc:mysql://localhost:3306/training_server?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + #url: jdbc:mysql://192.168.1.202:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + username: root + password: psw.db7898 + + + # KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。 + # 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面, + # 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。 + # 因此这里必须显式写出 sys_catalog 并排在业务模式之后。 + # 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。 + #connectionInitSqls: + #- set search_path to public, pg_catalog, sys_catalog + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + connectionInitSqls: + - set search_path to public, pg_catalog, sys_catalog + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法) + validationQuery: SELECT 1 + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: false + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: admin + login-password: admin123 + # IP白名单,本地开发填写127.0.0.1 + allow: 127.0.0.1 + reset-enable: false + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + # 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型 + # db-type: kingbase + config: + multi-statement-allow: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_xnode.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_xnode.yml new file mode 100644 index 000000000..ecbb52273 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid_xnode.yml @@ -0,0 +1,88 @@ +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + # KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver + driverClassName: com.mysql.cj.jdbc.Driver + #driverClassName: com.kingbase8.Driver + druid: + # 主库数据源 + master: + # KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public + # MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + ##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public + ##username: kingbase + ##password: sict123456 + ##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks + ##username: system + ##password: kingbase + + url: jdbc:mysql://127.0.0.1:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + #url: jdbc:mysql://localhost:3306/training_server?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + #url: jdbc:mysql://192.168.1.202:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 + username: blp + password: psw.db1234 + + + # KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。 + # 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面, + # 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。 + # 因此这里必须显式写出 sys_catalog 并排在业务模式之后。 + # 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。 + #connectionInitSqls: + #- set search_path to public, pg_catalog, sys_catalog + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + connectionInitSqls: + - set search_path to public, pg_catalog, sys_catalog + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法) + validationQuery: SELECT 1 + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: false + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: admin + login-password: admin123 + # IP白名单,本地开发填写127.0.0.1 + allow: 127.0.0.1 + reset-enable: false + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + # 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型 + # db-type: kingbase + config: + multi-statement-allow: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml index 2dad1da29..65ff76b6e 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml @@ -57,9 +57,9 @@ spring: servlet: multipart: # 单个文件大小 - max-file-size: 10MB + max-file-size: 50MB # 设置总上传的文件大小 - max-request-size: 20MB + max-request-size: 100MB jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss @@ -92,6 +92,14 @@ spring: # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms +# 人脸识别设备 API 配置 +face: + # 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用) + api-base-url: http://192.168.1.202:9025 + #api-base-url: http://192.168.1.230:9000 + # 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用 + enabled: true + # 培训与门禁业务配置 train: # QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接 @@ -101,15 +109,19 @@ train: identity: encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:} hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:} + # 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true + mock: + device: + enabled: false # token配置 token: # 令牌自定义标识 header: Authorization # 令牌密钥 - secret: ${TOKEN_SECRET:} + secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA== # 令牌有效期(默认30分钟) - expireTime: 30 + expireTime: 600 # MyBatis配置 mybatis: @@ -139,8 +151,7 @@ springdoc: - group: 'default' display-name: '测试模块' paths-to-match: '/**' - packages-to-scan: com.ruoyi.web.controller.tool - + packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device # 防盗链配置 referer: # 防盗链开关 @@ -157,48 +168,69 @@ xss: # 匹配链接 urlPatterns: /system/*,/monitor/*,/tool/* -# IOT设备接入(Mock 模式,拦截外部真实硬件) +# IOT设备接入(MQTT) iot: - # 人脸特征提取服务 (Mock 拦截,绝不向外部真实接口发请求) - qa-api-enabled: ${IOT_QA_API_ENABLED:false} - face: - endpoint: ${IOT_FACE_ENDPOINT:} - mock: ${IOT_FACE_MOCK:true} - enabled: ${IOT_FACE_ENABLED:false} - real-http-enabled: ${IOT_FACE_REAL_HTTP_ENABLED:false} - environment-id: ${IOT_ENVIRONMENT_ID:local-mock} - allowed-endpoints: [] - timeout: 3000 - # 第三方开放接口:关闭时接口返回 404;启用后调用方需带请求头 X-API-Key - openapi: - enabled: ${IOT_OPENAPI_ENABLED:false} - api-key: ${IOT_OPENAPI_KEY:} - path-prefix: /openapi/iot - mqtt: - # 彻底禁用真实 MQTT 连接,启用 Mock 替身模式 + # 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段 + hmac: enabled: false - mock: true - # 真实硬件下行控制写入总开关(硬性安全门禁,默认为 false,严禁向真实硬件发控制指令) - real-device-write-enabled: false - # 真实设备 SN 白名单(归档配置默认值为空列表,联调时由环境配置或控制台注入) - device-whitelist: [] - # 历史指令防重放保护(禁止向真实硬件重放系统启动前遗留的历史待发送指令) - replay-protection-enabled: true - # 定时补偿任务开关 - retry-task-enabled: ${IOT_RETRY_TASK_ENABLED:false} - environment-id: ${IOT_ENVIRONMENT_ID:local-mock} - protocol-verified: false - broker-url: ${IOT_MQTT_BROKER_URL:tcp://127.0.0.1:1883} + #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) + # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) + face: + # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + environment-id: railway-iot-01 + endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + #endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒) + allowed-endpoints: + - http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + #- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 调用超时(毫秒) + timeout: 10000 + # lock_endpoint: http://192.168.1.203:8080 + lock_endpoint: http://192.168.1.202:8085 + api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy + avatar_profile: http://192.168.1.106:8096 + face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU + + mqtt: + # 开关,false 时不创建任何连接 + enabled: true + mock: false + protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) + retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) + environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) + real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + device-whitelist: [ + '807104B5', '80745D79','80745D95','80745D6F', + '80745DA3','80745D78','80745D89','80745D93', + '80745D75','80745D9A','80745D99','80745D73', + '80745D9F','80745D92','80745D7A','80745D8E', + '80745DA7','80745D86','80745D72','80745DA8', + '80745D90','80745D77','80745D76','80745D96', + '80745D7B','80745DA4','80745D7E','80745D9C', + '80745D71','80745DA2','80745D82','80745DAA', + '80745D8D','80745D7F','80745D97','80745D8A', + '80745D6E','80745D9B','80745D94','80745D84', + '80745D9D','80745D6A','80745D7D','80745D70', + '80745DA5','80745D8C','80745D74','80745D6D', + '80745D8B','80745D6C','80745D98','80745D88', + '80745DAB','80745D87','80745D8F','80745DA1', + '80745DA9','80745D91','80745D6B','80745DA6', + '80745D81','80745D9E','80745D83','80745DA0', + 'E8F7AFF69483'] + # MQTT服务器地址,ssl 场景使用 ssl://host:8883 + broker-url: tcp://192.168.1.202:1883 + #broker-url: tcp://192.168.1.230:1883 # 客户端ID,同一服务器下必须唯一;为空时自动生成 client-id: - username: ${IOT_MQTT_USERNAME:admin} - password: ${IOT_MQTT_PASSWORD:} + username: admin3 + password: admin3 # false 时服务端保留离线消息,需配合固定 client-id clean-session: true connection-timeout: 30 keep-alive-interval: 60 # 断线自动重连 - automatic-reconnect: false + automatic-reconnect: true max-reconnect-delay: 30000 # 连接健康检查周期(毫秒),0 表示不做定时重连 health-check-interval: 30000 diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_230.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_230.yml new file mode 100644 index 000000000..71e641af7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_230.yml @@ -0,0 +1,259 @@ +# 项目相关配置 +ruoyi: + # 名称 + name: RuoYi + # 版本 + version: 3.9.2 + # 版权年份 + copyrightYear: 2026 + # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) + profile: ./uploadPath + # 获取ip地址开关 + addressEnabled: false + # 验证码类型 math 数字计算 char 字符验证 + captchaType: math + +# 开发环境配置 +server: + # 服务器的HTTP端口,默认为8080 + port: 8096 + servlet: + # 应用的访问路径 + context-path: / + tomcat: + # tomcat的URI编码 + uri-encoding: UTF-8 + # 连接数满后的排队数,默认为100 + accept-count: 1000 + threads: + # tomcat最大线程数,默认为200 + max: 800 + # Tomcat启动初始化的线程数,默认值10 + min-spare: 100 + +# 日志配置 +logging: + level: + com.ruoyi: debug + org.springframework: warn + +# 用户配置 +user: + password: + # 密码最大错误次数 + maxRetryCount: 5 + # 密码锁定时间(默认10分钟) + lockTime: 10 + +# Spring配置 +spring: + # 资源信息 + messages: + # 国际化资源文件路径 + basename: i18n/messages + profiles: + active: druid + # 文件上传 + servlet: + multipart: + # 单个文件大小 + max-file-size: 50MB + # 设置总上传的文件大小 + max-request-size: 100MB + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + # 服务模块 + devtools: + restart: + # 热部署开关 + enabled: true + data: + # redis 配置 + redis: + # 地址 + host: localhost + # 端口,默认为6379 + port: 6379 + # 数据库索引 (独立 QA 测试隔离使用 14 库) + database: 14 + # 密码 + password: + # 连接超时时间 + timeout: 10s + lettuce: + pool: + # 连接池中的最小空闲连接 + min-idle: 0 + # 连接池中的最大空闲连接 + max-idle: 8 + # 连接池的最大数据库连接数 + max-active: 8 + # #连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + +# 人脸识别设备 API 配置 +face: + # 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用) + api-base-url: http://192.168.1.202:9025 + #api-base-url: http://192.168.1.230:9000 + # 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用 + enabled: true + +# 培训与门禁业务配置 +train: + # QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接 + lock: + auth-engine: ${TRAIN_LOCK_AUTH_ENGINE:iot} + student: + identity: + encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:} + hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:} + # 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true + mock: + device: + enabled: false + +# token配置 +token: + # 令牌自定义标识 + header: Authorization + # 令牌密钥 + secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA== + # 令牌有效期(默认30分钟) + expireTime: 600 + +# MyBatis配置 +mybatis: + # 搜索指定包别名 + typeAliasesPackage: com.ruoyi.**.domain + # 配置mapper的扫描,找到所有的mapper.xml映射文件 + mapperLocations: classpath*:mapper/**/*Mapper.xml + # 加载全局的配置文件 + configLocation: classpath:mybatis/mybatis-config.xml + +# PageHelper分页插件 +pagehelper: + # 方言由 PageHelper 根据数据源自动识别(已内置 MySQL 与 KingbaseES) + # 如需强制指定,MySQL 填写 mysql,KingbaseES 填写 kingbase + supportMethodsArguments: true + params: count=countSql + +# Springdoc配置 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + enabled: true + path: /swagger-ui.html + tags-sorter: alpha + group-configs: + - group: 'default' + display-name: '测试模块' + paths-to-match: '/**' + packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device +# 防盗链配置 +referer: + # 防盗链开关 + enabled: false + # 允许的域名列表 + allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip + +# 防止XSS攻击 +xss: + # 过滤开关 + enabled: true + # 排除链接(多个用逗号分隔) + excludes: /system/notice + # 匹配链接 + urlPatterns: /system/*,/monitor/*,/tool/* + +# IOT设备接入(MQTT) +iot: + # 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段 + hmac: + enabled: false + #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) + # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) + face: + # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + environment-id: railway-iot-01 + #endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒) + allowed-endpoints: + #- http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + - http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 调用超时(毫秒) + timeout: 10000 + # 第三方开放接口:关闭时接口返回 404;启用后调用方需带请求头 X-API-Key + openapi: + enabled: true + api-key: please-change-me + path-prefix: /openapi/iot + mqtt: + # 开关,false 时不创建任何连接 + enabled: true + mock: false + protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) + retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) + environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) + real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + device-whitelist: [ + '807104B5', '80745D79','80745D95','80745D6F', + '80745DA3','80745D78','80745D89','80745D93', + '80745D75','80745D9A','80745D99','80745D73', + '80745D9F','80745D92','80745D7A','80745D8E', + '80745DA7','80745D86','80745D72','80745DA8', + '80745D90','80745D77','80745D76','80745D96', + '80745D7B','80745DA4','80745D7E','80745D9C', + '80745D71','80745DA2','80745D82','80745DAA', + '80745D8D','80745D7F','80745D97','80745D8A', + '80745D6E','80745D9B','80745D94','80745D84', + '80745D9D','80745D6A','80745D7D','80745D70', + '80745DA5','80745D8C','80745D74','80745D6D', + '80745D8B','80745D6C','80745D98','80745D88', + '80745DAB','80745D87','80745D8F','80745DA1', + '80745DA9','80745D91','80745D6B','80745DA6', + '80745D81','80745D9E','80745D83','80745DA0', + 'E8F7AFF69483'] + # MQTT服务器地址,ssl 场景使用 ssl://host:8883 + #broker-url: tcp://192.168.1.202:1883 + broker-url: tcp://192.168.1.230:1883 + # 客户端ID,同一服务器下必须唯一;为空时自动生成 + client-id: + username: admin3 + password: admin3 + # false 时服务端保留离线消息,需配合固定 client-id + clean-session: true + connection-timeout: 30 + keep-alive-interval: 60 + # 断线自动重连 + automatic-reconnect: true + max-reconnect-delay: 30000 + # 连接健康检查周期(毫秒),0 表示不做定时重连 + health-check-interval: 30000 + # 发布消息等待服务端确认时间(毫秒),0 表示不等待 + publish-timeout: 3000 + # 消息持久化目录,为空使用内存 + persistence-directory: + default-qos: 1 + default-retained: false + # 只订阅上行主题(下行由平台发布,不消费自己发出的指令) + topics: + - topic: ydlink/+/thing/onoff_line # 网关上下线 + qos: 1 + - topic: ydlink/+/thing/property/set_response # 属性设置响应 + qos: 1 + - topic: ydlink/+/thing/action/execute_response # 远程开关锁响应 + qos: 1 + - topic: ydlink/+/thing/data/batch_report # 授权下发结果 + qos: 1 + - topic: ydlink/+/thing/event/trigger # 开门记录上报 + qos: 1 + # 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定) + # will: + # topic: ydlink/status + # payload: offline + # qos: 1 + # retained: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_24.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_24.yml new file mode 100644 index 000000000..2e21def13 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_24.yml @@ -0,0 +1,259 @@ +# 项目相关配置 +ruoyi: + # 名称 + name: RuoYi + # 版本 + version: 3.9.2 + # 版权年份 + copyrightYear: 2026 + # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) + profile: ./uploadPath + # 获取ip地址开关 + addressEnabled: false + # 验证码类型 math 数字计算 char 字符验证 + captchaType: math + +# 开发环境配置 +server: + # 服务器的HTTP端口,默认为8080 + port: 8096 + servlet: + # 应用的访问路径 + context-path: / + tomcat: + # tomcat的URI编码 + uri-encoding: UTF-8 + # 连接数满后的排队数,默认为100 + accept-count: 1000 + threads: + # tomcat最大线程数,默认为200 + max: 800 + # Tomcat启动初始化的线程数,默认值10 + min-spare: 100 + +# 日志配置 +logging: + level: + com.ruoyi: debug + org.springframework: warn + +# 用户配置 +user: + password: + # 密码最大错误次数 + maxRetryCount: 5 + # 密码锁定时间(默认10分钟) + lockTime: 10 + +# Spring配置 +spring: + # 资源信息 + messages: + # 国际化资源文件路径 + basename: i18n/messages + profiles: + active: druid + # 文件上传 + servlet: + multipart: + # 单个文件大小 + max-file-size: 50MB + # 设置总上传的文件大小 + max-request-size: 100MB + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + # 服务模块 + devtools: + restart: + # 热部署开关 + enabled: true + data: + # redis 配置 + redis: + # 地址 + host: localhost + # 端口,默认为6379 + port: 6379 + # 数据库索引 (独立 QA 测试隔离使用 14 库) + database: 14 + # 密码 + password: + # 连接超时时间 + timeout: 10s + lettuce: + pool: + # 连接池中的最小空闲连接 + min-idle: 0 + # 连接池中的最大空闲连接 + max-idle: 8 + # 连接池的最大数据库连接数 + max-active: 8 + # #连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + +# 人脸识别设备 API 配置 +face: + # 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用) + api-base-url: http://192.168.106.24:9025 + #api-base-url: http://192.168.1.230:9000 + # 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用 + enabled: true + +# 培训与门禁业务配置 +train: + # QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接 + lock: + auth-engine: ${TRAIN_LOCK_AUTH_ENGINE:iot} + student: + identity: + encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:} + hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:} + # 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true + mock: + device: + enabled: false + +# token配置 +token: + # 令牌自定义标识 + header: Authorization + # 令牌密钥 + secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA== + # 令牌有效期(默认30分钟) + expireTime: 600 + +# MyBatis配置 +mybatis: + # 搜索指定包别名 + typeAliasesPackage: com.ruoyi.**.domain + # 配置mapper的扫描,找到所有的mapper.xml映射文件 + mapperLocations: classpath*:mapper/**/*Mapper.xml + # 加载全局的配置文件 + configLocation: classpath:mybatis/mybatis-config.xml + +# PageHelper分页插件 +pagehelper: + # 方言由 PageHelper 根据数据源自动识别(已内置 MySQL 与 KingbaseES) + # 如需强制指定,MySQL 填写 mysql,KingbaseES 填写 kingbase + supportMethodsArguments: true + params: count=countSql + +# Springdoc配置 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + enabled: true + path: /swagger-ui.html + tags-sorter: alpha + group-configs: + - group: 'default' + display-name: '测试模块' + paths-to-match: '/**' + packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device +# 防盗链配置 +referer: + # 防盗链开关 + enabled: false + # 允许的域名列表 + allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip + +# 防止XSS攻击 +xss: + # 过滤开关 + enabled: true + # 排除链接(多个用逗号分隔) + excludes: /system/notice + # 匹配链接 + urlPatterns: /system/*,/monitor/*,/tool/* + +# IOT设备接入(MQTT) +iot: + # 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段 + hmac: + enabled: false + #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) + # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) + face: + # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + environment-id: railway-iot-01 + #endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + endpoint: http://192.168.106.24:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒) + allowed-endpoints: + #- http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + - http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 调用超时(毫秒) + timeout: 10000 + lock_endpoint: http://192.168.106.24:8098 + api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy + avatar_profile: http://192.168.106.24:8096 + face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU + + mqtt: + # 开关,false 时不创建任何连接 + enabled: true + mock: false + protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) + retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) + environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) + real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + device-whitelist: [ + '807104B5', '80745D79','80745D95','80745D6F', + '80745DA3','80745D78','80745D89','80745D93', + '80745D75','80745D9A','80745D99','80745D73', + '80745D9F','80745D92','80745D7A','80745D8E', + '80745DA7','80745D86','80745D72','80745DA8', + '80745D90','80745D77','80745D76','80745D96', + '80745D7B','80745DA4','80745D7E','80745D9C', + '80745D71','80745DA2','80745D82','80745DAA', + '80745D8D','80745D7F','80745D97','80745D8A', + '80745D6E','80745D9B','80745D94','80745D84', + '80745D9D','80745D6A','80745D7D','80745D70', + '80745DA5','80745D8C','80745D74','80745D6D', + '80745D8B','80745D6C','80745D98','80745D88', + '80745DAB','80745D87','80745D8F','80745DA1', + '80745DA9','80745D91','80745D6B','80745DA6', + '80745D81','80745D9E','80745D83','80745DA0', + 'E8F7AFF69483'] + # MQTT服务器地址,ssl 场景使用 ssl://host:8883 + #broker-url: tcp://192.168.1.202:1883 + broker-url: tcp://192.168.106.24:1883 + # 客户端ID,同一服务器下必须唯一;为空时自动生成 + client-id: + username: admin3 + password: admin3 + # false 时服务端保留离线消息,需配合固定 client-id + clean-session: true + connection-timeout: 30 + keep-alive-interval: 60 + # 断线自动重连 + automatic-reconnect: true + max-reconnect-delay: 30000 + # 连接健康检查周期(毫秒),0 表示不做定时重连 + health-check-interval: 30000 + # 发布消息等待服务端确认时间(毫秒),0 表示不等待 + publish-timeout: 3000 + # 消息持久化目录,为空使用内存 + persistence-directory: + default-qos: 1 + default-retained: false + # 只订阅上行主题(下行由平台发布,不消费自己发出的指令) + topics: + - topic: ydlink/+/thing/onoff_line # 网关上下线 + qos: 1 + - topic: ydlink/+/thing/property/set_response # 属性设置响应 + qos: 1 + - topic: ydlink/+/thing/action/execute_response # 远程开关锁响应 + qos: 1 + - topic: ydlink/+/thing/data/batch_report # 授权下发结果 + qos: 1 + - topic: ydlink/+/thing/event/trigger # 开门记录上报 + qos: 1 + # 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定) + # will: + # topic: ydlink/status + # payload: offline + # qos: 1 + # retained: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_localhost.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_localhost.yml new file mode 100644 index 000000000..16a3b8f98 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_localhost.yml @@ -0,0 +1,260 @@ +# 项目相关配置 +ruoyi: + # 名称 + name: RuoYi + # 版本 + version: 3.9.2 + # 版权年份 + copyrightYear: 2026 + # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) + profile: ./uploadPath + # 获取ip地址开关 + addressEnabled: false + # 验证码类型 math 数字计算 char 字符验证 + captchaType: math + +# 开发环境配置 +server: + # 服务器的HTTP端口,默认为8080 + port: 8096 + servlet: + # 应用的访问路径 + context-path: / + tomcat: + # tomcat的URI编码 + uri-encoding: UTF-8 + # 连接数满后的排队数,默认为100 + accept-count: 1000 + threads: + # tomcat最大线程数,默认为200 + max: 800 + # Tomcat启动初始化的线程数,默认值10 + min-spare: 100 + +# 日志配置 +logging: + level: + com.ruoyi: debug + org.springframework: warn + +# 用户配置 +user: + password: + # 密码最大错误次数 + maxRetryCount: 5 + # 密码锁定时间(默认10分钟) + lockTime: 10 + +# Spring配置 +spring: + # 资源信息 + messages: + # 国际化资源文件路径 + basename: i18n/messages + profiles: + active: druid + # 文件上传 + servlet: + multipart: + # 单个文件大小 + max-file-size: 50MB + # 设置总上传的文件大小 + max-request-size: 100MB + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + # 服务模块 + devtools: + restart: + # 热部署开关 + enabled: true + data: + # redis 配置 + redis: + # 地址 + host: localhost + # 端口,默认为6379 + port: 6379 + # 数据库索引 (独立 QA 测试隔离使用 14 库) + database: 14 + # 密码 + password: + # 连接超时时间 + timeout: 10s + lettuce: + pool: + # 连接池中的最小空闲连接 + min-idle: 0 + # 连接池中的最大空闲连接 + max-idle: 8 + # 连接池的最大数据库连接数 + max-active: 8 + # #连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + +# 人脸识别设备 API 配置 +face: + # 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用) + api-base-url: http://192.168.1.202:9025 + #api-base-url: http://192.168.1.230:9000 + # 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用 + enabled: true + +# 培训与门禁业务配置 +train: + # QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接 + lock: + auth-engine: ${TRAIN_LOCK_AUTH_ENGINE:iot} + student: + identity: + encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:} + hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:} + # 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true + mock: + device: + enabled: false + +# token配置 +token: + # 令牌自定义标识 + header: Authorization + # 令牌密钥 + secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA== + # 令牌有效期(默认30分钟) + expireTime: 600 + +# MyBatis配置 +mybatis: + # 搜索指定包别名 + typeAliasesPackage: com.ruoyi.**.domain + # 配置mapper的扫描,找到所有的mapper.xml映射文件 + mapperLocations: classpath*:mapper/**/*Mapper.xml + # 加载全局的配置文件 + configLocation: classpath:mybatis/mybatis-config.xml + +# PageHelper分页插件 +pagehelper: + # 方言由 PageHelper 根据数据源自动识别(已内置 MySQL 与 KingbaseES) + # 如需强制指定,MySQL 填写 mysql,KingbaseES 填写 kingbase + supportMethodsArguments: true + params: count=countSql + +# Springdoc配置 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + enabled: true + path: /swagger-ui.html + tags-sorter: alpha + group-configs: + - group: 'default' + display-name: '测试模块' + paths-to-match: '/**' + packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device +# 防盗链配置 +referer: + # 防盗链开关 + enabled: false + # 允许的域名列表 + allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip + +# 防止XSS攻击 +xss: + # 过滤开关 + enabled: true + # 排除链接(多个用逗号分隔) + excludes: /system/notice + # 匹配链接 + urlPatterns: /system/*,/monitor/*,/tool/* + +# IOT设备接入(MQTT) +iot: + # 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段 + hmac: + enabled: false + #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) + # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) + face: + # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + environment-id: railway-iot-01 + endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + #endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒) + allowed-endpoints: + - http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + #- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData + # 调用超时(毫秒) + timeout: 10000 + # lock_endpoint: http://192.168.1.203:8080 + lock_endpoint: http://192.168.1.202:8085 + api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy + avatar_profile: http://192.168.1.106:8096 + face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU + + mqtt: + # 开关,false 时不创建任何连接 + enabled: true + mock: false + protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) + retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) + environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) + real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + device-whitelist: [ + '807104B5', '80745D79','80745D95','80745D6F', + '80745DA3','80745D78','80745D89','80745D93', + '80745D75','80745D9A','80745D99','80745D73', + '80745D9F','80745D92','80745D7A','80745D8E', + '80745DA7','80745D86','80745D72','80745DA8', + '80745D90','80745D77','80745D76','80745D96', + '80745D7B','80745DA4','80745D7E','80745D9C', + '80745D71','80745DA2','80745D82','80745DAA', + '80745D8D','80745D7F','80745D97','80745D8A', + '80745D6E','80745D9B','80745D94','80745D84', + '80745D9D','80745D6A','80745D7D','80745D70', + '80745DA5','80745D8C','80745D74','80745D6D', + '80745D8B','80745D6C','80745D98','80745D88', + '80745DAB','80745D87','80745D8F','80745DA1', + '80745DA9','80745D91','80745D6B','80745DA6', + '80745D81','80745D9E','80745D83','80745DA0', + 'E8F7AFF69483'] + # MQTT服务器地址,ssl 场景使用 ssl://host:8883 + broker-url: tcp://192.168.1.202:1883 + #broker-url: tcp://192.168.1.230:1883 + # 客户端ID,同一服务器下必须唯一;为空时自动生成 + client-id: + username: admin3 + password: admin3 + # false 时服务端保留离线消息,需配合固定 client-id + clean-session: true + connection-timeout: 30 + keep-alive-interval: 60 + # 断线自动重连 + automatic-reconnect: true + max-reconnect-delay: 30000 + # 连接健康检查周期(毫秒),0 表示不做定时重连 + health-check-interval: 30000 + # 发布消息等待服务端确认时间(毫秒),0 表示不等待 + publish-timeout: 3000 + # 消息持久化目录,为空使用内存 + persistence-directory: + default-qos: 1 + default-retained: false + # 只订阅上行主题(下行由平台发布,不消费自己发出的指令) + topics: + - topic: ydlink/+/thing/onoff_line # 网关上下线 + qos: 1 + - topic: ydlink/+/thing/property/set_response # 属性设置响应 + qos: 1 + - topic: ydlink/+/thing/action/execute_response # 远程开关锁响应 + qos: 1 + - topic: ydlink/+/thing/data/batch_report # 授权下发结果 + qos: 1 + - topic: ydlink/+/thing/event/trigger # 开门记录上报 + qos: 1 + # 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定) + # will: + # topic: ydlink/status + # payload: offline + # qos: 1 + # retained: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/logback.xml b/RuoYi-Vue/ruoyi-admin/src/main/resources/logback.xml index 2e1e540e5..54a1f2ed7 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/logback.xml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/logback.xml @@ -1,7 +1,7 @@ - + diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00012/BV-3.pdf b/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00012/BV-3.pdf new file mode 100644 index 000000000..bbabe3384 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00012/BV-3.pdf @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>>>/Contents 4 0 R>>endobj +4 0 obj<>stream +BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET +endstream +endobj +5 0 obj<>endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000053 00000 n +0000000105 00000 n +0000000220 00000 n +0000000317 00000 n +trailer<> +startxref +384 +%%EOF diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00013/BV-2.pdf b/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00013/BV-2.pdf new file mode 100644 index 000000000..bbabe3384 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00013/BV-2.pdf @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>>>/Contents 4 0 R>>endobj +4 0 obj<>stream +BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET +endstream +endobj +5 0 obj<>endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000053 00000 n +0000000105 00000 n +0000000220 00000 n +0000000317 00000 n +trailer<> +startxref +384 +%%EOF diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00014/BV-1.pdf b/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00014/BV-1.pdf new file mode 100644 index 000000000..bbabe3384 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/static/books/BK-00014/BV-1.pdf @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>>>/Contents 4 0 R>>endobj +4 0 obj<>stream +BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET +endstream +endobj +5 0 obj<>endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000053 00000 n +0000000105 00000 n +0000000220 00000 n +0000000317 00000 n +trailer<> +startxref +384 +%%EOF diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/common/utils/json/JsonParamUtilsTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/common/utils/json/JsonParamUtilsTest.java new file mode 100644 index 000000000..3dc8199a0 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/common/utils/json/JsonParamUtilsTest.java @@ -0,0 +1,151 @@ +package com.ruoyi.common.utils.json; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +/** + * JsonParamUtils 解析与校验契约测试。 + */ +public class JsonParamUtilsTest +{ + @Test + public void parse_ValidJson_ExtractsAllFieldTypes() + { + String json = "{\"studentId\":\"STU_1\",\"lessonId\":1001,\"score\":1.5,\"active\":true," + + "\"ext\":{\"room\":\"A-01\"},\"tags\":[1,2],\"remark\":null}"; + + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse(json); + + assertTrue(result.getMessage(), result.isSuccess()); + assertEquals("解析成功", result.getMessage()); + assertEquals("STU_1", result.get("studentId")); + assertEquals(Integer.valueOf(1001), result.get("lessonId")); + assertEquals(new BigDecimal("1.5"), result.get("score")); + assertEquals(Boolean.TRUE, result.get("active")); + assertTrue(result.get("ext") instanceof Map); + assertEquals("A-01", ((Map) result.get("ext")).get("room")); + assertTrue(result.get("tags") instanceof List); + assertNull(result.get("remark")); + assertEquals(7, result.getData().size()); + } + + @Test + public void parse_NullOrBlank_ReturnsClearMessage() + { + assertEquals("JSON 参数不能为空", JsonParamUtils.parse(null).getMessage()); + assertEquals("JSON 参数不能为空", JsonParamUtils.parse(" ").getMessage()); + assertFalse(JsonParamUtils.parse("").isSuccess()); + } + + @Test + public void parse_IllegalSyntax_ReturnsFormatError() + { + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"studentId\":\"STU_1\""); + + assertFalse(result.isSuccess()); + assertTrue(result.getMessage(), result.getMessage().startsWith("JSON 格式不合法")); + assertTrue(result.getData().isEmpty()); + } + + @Test + public void parse_TopLevelArrayOrScalar_Rejected() + { + JsonParamUtils.JsonParamResult arrayResult = JsonParamUtils.parse("[1,2,3]"); + assertFalse(arrayResult.isSuccess()); + assertTrue(arrayResult.getMessage().contains("顶层必须是对象")); + assertTrue(arrayResult.getMessage().contains("数组")); + + JsonParamUtils.JsonParamResult numberResult = JsonParamUtils.parse("123"); + assertFalse(numberResult.isSuccess()); + assertTrue(numberResult.getMessage().contains("数值")); + + JsonParamUtils.JsonParamResult stringResult = JsonParamUtils.parse("\"abc\""); + assertFalse(stringResult.isSuccess()); + assertTrue(stringResult.getMessage().contains("字符串")); + + JsonParamUtils.JsonParamResult nullResult = JsonParamUtils.parse("null"); + assertFalse(nullResult.isSuccess()); + } + + @Test + public void parse_WithSchema_ConvertsTypes() + { + Map> schema = new LinkedHashMap<>(); + schema.put("lessonId", Long.class); + schema.put("studentId", String.class); + schema.put("manualFlag", Boolean.class); + + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse( + "{\"lessonId\":\"1001\",\"studentId\":123,\"manualFlag\":1}", schema); + + assertTrue(result.getMessage(), result.isSuccess()); + assertEquals(Long.valueOf(1001L), result.get("lessonId")); + assertEquals("123", result.get("studentId")); + assertEquals(Boolean.TRUE, result.get("manualFlag")); + } + + @Test + public void parse_WithSchema_MissingFieldRejected() + { + Map> schema = new LinkedHashMap<>(); + schema.put("lessonId", Long.class); + + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"studentId\":\"STU_1\"}", schema); + + assertFalse(result.isSuccess()); + assertEquals("缺少必填字段:lessonId", result.getMessage()); + } + + @Test + public void parse_WithSchema_NullFieldRejected() + { + Map> schema = new LinkedHashMap<>(); + schema.put("lessonId", Long.class); + + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"lessonId\":null}", schema); + + assertFalse(result.isSuccess()); + assertEquals("缺少必填字段:lessonId", result.getMessage()); + } + + @Test + public void parse_WithSchema_TypeMismatchRejected() + { + Map> schema = new LinkedHashMap<>(); + schema.put("lessonId", Long.class); + + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"lessonId\":\"abc\"}", schema); + + assertFalse(result.isSuccess()); + assertEquals("字段[lessonId]类型不匹配,期望Long,实际为字符串", result.getMessage()); + } + + @Test + public void parse_WithSchema_NestedValueRejectedForLong() + { + Map> schema = new LinkedHashMap<>(); + schema.put("lessonId", Long.class); + + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"lessonId\":{\"x\":1}}", schema); + + assertFalse(result.isSuccess()); + assertEquals("字段[lessonId]类型不匹配,期望Long,实际为对象", result.getMessage()); + } + + @Test + public void getString_ReturnsDefaultWhenAbsent() + { + JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"a\":\"x\"}"); + + assertEquals("x", result.getString("a", "def")); + assertEquals("def", result.getString("b", "def")); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/api/TrainBoardApiControllerTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/api/TrainBoardApiControllerTest.java new file mode 100644 index 000000000..8de24bef8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/api/TrainBoardApiControllerTest.java @@ -0,0 +1,204 @@ +package com.ruoyi.web.controller.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainPlanCourse; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.dto.AttendanceSignInDTO; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.service.AttendanceAppService; +import com.ruoyi.train.service.ITrainEnrollmentService; +import com.ruoyi.train.service.ITrainPlanCourseService; +import com.ruoyi.train.service.ITrainStudentCheckinService; + +/** + * 电子班牌签到上报/查询接口契约测试(纯内存,不依赖 Spring 容器与数据库)。 + * 验证参数解析、enrollmentId 推导、studentId/boardId/signInTime 透传与学员姓名返回。 + */ +public class TrainBoardApiControllerTest +{ + private TrainBoardApiController controller; + private AttendanceSignInDTO capturedSignIn; + + @Before + public void setUp() throws Exception + { + controller = new TrainBoardApiController(); + + TrainLesson lesson = new TrainLesson(); + lesson.setLessonId(1001L); + lesson.setPlanCourseId(10L); + + TrainPlanCourse planCourse = new TrainPlanCourse(); + planCourse.setPlanCourseId(10L); + planCourse.setPlanId("PLAN_A"); + + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(201L); + enrollment.setPlanId("PLAN_A"); + enrollment.setStudentId("STU_1"); + + setField(controller, "trainLessonMapper", proxy(TrainLessonMapper.class, (p, m, a) -> + "selectTrainLessonById".equals(m.getName()) && Long.valueOf(1001L).equals(a[0]) ? lesson : null)); + setField(controller, "trainPlanCourseService", proxy(ITrainPlanCourseService.class, (p, m, a) -> + "selectTrainPlanCourseById".equals(m.getName()) ? planCourse : null)); + setField(controller, "trainEnrollmentService", proxy(ITrainEnrollmentService.class, (p, m, a) -> + "selectTrainEnrollmentByPlanIdAndStudentId".equals(m.getName()) + && "PLAN_A".equals(a[0]) && "STU_1".equals(a[1]) ? enrollment : null)); + + setField(controller, "attendanceAppService", proxy(AttendanceAppService.class, (p, m, a) -> { + if ("signIn".equals(m.getName())) + { + capturedSignIn = (AttendanceSignInDTO) a[0]; + TrainStudentCheckin stored = new TrainStudentCheckin(); + stored.setCheckinId(9001L); + stored.setEnrollmentId(capturedSignIn.getEnrollmentId()); + stored.setLessonId(capturedSignIn.getLessonId()); + stored.setStudentId(capturedSignIn.getStudentId()); + stored.setBoardId(capturedSignIn.getBoardId()); + stored.setSignInTime(capturedSignIn.getSignInTime()); + return stored; + } + return null; + })); + + TrainStudentCheckin row = new TrainStudentCheckin(); + row.setCheckinId(9001L); + row.setLessonId(1001L); + row.setStudentId("STU_1"); + row.setStudentName("张三"); + setField(controller, "trainStudentCheckinService", proxy(ITrainStudentCheckinService.class, (p, m, a) -> { + if ("selectCheckinsByLessonId".equals(m.getName()) && Long.valueOf(1001L).equals(a[0])) + { + return Collections.singletonList(row); + } + return Collections.emptyList(); + })); + + LoginUser loginUser = new LoginUser(); + SysUser user = new SysUser(); + user.setUserName("board_device"); + loginUser.setUser(user); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList())); + } + + @After + public void tearDown() + { + SecurityContextHolder.clearContext(); + } + + @Test + public void uploadCheckin_ResolvesEnrollmentAndPassesAllFields() + { + AjaxResult result = controller.uploadCheckin(request("STU_1", "BP001", 1001L, "2026-11-02 08:30:00")); + + assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG)); + assertNotNull(capturedSignIn); + assertEquals(Long.valueOf(201L), capturedSignIn.getEnrollmentId()); + assertEquals(Long.valueOf(1001L), capturedSignIn.getLessonId()); + assertEquals("STU_1", capturedSignIn.getStudentId()); + assertEquals("BP001", capturedSignIn.getBoardId()); + assertEquals(DateUtils.parseDate("2026-11-02 08:30:00"), capturedSignIn.getSignInTime()); + + @SuppressWarnings("unchecked") + Map data = (Map) result.get(AjaxResult.DATA_TAG); + assertEquals(9001L, data.get("checkinId")); + assertEquals(201L, data.get("enrollmentId")); + assertEquals("STU_1", data.get("studentId")); + assertEquals("2026-11-02 08:30:00", data.get("signInTime")); + } + + @Test + public void uploadCheckin_RejectsUnknownStudent() + { + AjaxResult result = controller.uploadCheckin(request("STU_UNKNOWN", "BP001", 1001L, null)); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + assertEquals(null, capturedSignIn); + } + + @Test + public void uploadCheckin_RejectsInvalidSignInTime() + { + AjaxResult result = controller.uploadCheckin(request("STU_1", "BP001", 1001L, "not-a-time")); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + assertEquals(null, capturedSignIn); + } + + @SuppressWarnings("unchecked") + @Test + public void checkinList_ReturnsRowsWithStudentName() + { + AjaxResult result = controller.checkinList(1001L); + + assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG)); + Map data = (Map) result.get(AjaxResult.DATA_TAG); + assertEquals(1, data.get("total")); + List rows = (List) data.get("rows"); + assertEquals(1, rows.size()); + assertEquals("STU_1", rows.get(0).getStudentId()); + assertEquals("张三", rows.get(0).getStudentName()); + } + + private TrainBoardApiController.BoardCheckinUploadRequest request(String studentId, String boardId, + Long lessonId, String signInTime) + { + TrainBoardApiController.BoardCheckinUploadRequest req = new TrainBoardApiController.BoardCheckinUploadRequest(); + req.studentId = studentId; + req.boardId = boardId; + req.lessonId = lessonId; + req.signInTime = signInTime; + return req; + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + @SuppressWarnings("unchecked") + private T proxy(Class type, InvocationHandler handler) + { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, (p, m, a) -> { + if (m.getDeclaringClass() == Object.class) + { + switch (m.getName()) + { + case "toString": + return "proxy(" + type.getSimpleName() + ")"; + case "hashCode": + return System.identityHashCode(p); + case "equals": + return p == a[0]; + default: + return null; + } + } + return handler.invoke(p, m, a); + }); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/api/TrainRegisterApiControllerResetTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/api/TrainRegisterApiControllerResetTest.java new file mode 100644 index 000000000..8f765b9fe --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/api/TrainRegisterApiControllerResetTest.java @@ -0,0 +1,282 @@ +package com.ruoyi.web.controller.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.Date; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.ITrainAccommodationService; +import com.ruoyi.train.service.ITrainDormitoryInfoService; +import com.ruoyi.train.service.ITrainEnrollmentService; + +/** + * 报到重置接口契约测试:验证步骤顺序、参数透传、失败短路与状态重置。 + */ +public class TrainRegisterApiControllerResetTest +{ + private TrainRegisterApiController controller; + private String capturedFaceStudentId; + private Map capturedCheckoutBody; + private Long capturedUpdateEnrollmentId; + private Integer capturedUpdateStatus; + private String capturedAvatarSId; + private boolean studentExists = true; + + private TrainEnrollment enrollment; + private TrainAccommodation accommodation; + private AjaxResult faceResult; + private String checkoutResponseBody; + + @Before + public void setUp() throws Exception + { + enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(201L); + enrollment.setPlanId("PLAN_A"); + enrollment.setStudentId("STU_1"); + enrollment.setEnrollmentStatus(1); + + accommodation = new TrainAccommodation(); + accommodation.setAccommodationId(301L); + accommodation.setEnrollmentId(201L); + accommodation.setDormId("DORM-101"); + accommodation.setLockApiBizNo("BIZ-301"); + + faceResult = AjaxResult.success(); + checkoutResponseBody = "{\"code\":200,\"msg\":\"success\"}"; + + controller = new TrainRegisterApiController(null, null); + setField(controller, "trainFaceApiController", new TrainFaceApiController() + { + @Override + public AjaxResult deleteEmployee(String employeeId) + { + capturedFaceStudentId = employeeId; + return faceResult; + } + }); + setField(controller, "trainLockApiController", new TrainLockApiController() + { + @Override + public ResponseEntity checkout(Map body) + { + capturedCheckoutBody = body; + return ResponseEntity.ok(checkoutResponseBody); + } + }); + setField(controller, "enrollmentService", proxy(ITrainEnrollmentService.class, (p, m, a) -> { + if ("selectTrainEnrollmentByPlanIdAndStudentId".equals(m.getName())) + { + return "PLAN_A".equals(a[0]) && "STU_1".equals(a[1]) ? enrollment : null; + } + if ("updateEnrollmentStatus".equals(m.getName())) + { + capturedUpdateEnrollmentId = (Long) a[0]; + capturedUpdateStatus = (Integer) a[1]; + return 1; + } + return null; + })); + setField(controller, "accommodationService", proxy(ITrainAccommodationService.class, (p, m, a) -> + "selectActiveAccommodationByStudentId".equals(m.getName()) ? accommodation : null)); + setField(controller, "dormitoryInfoService", proxy(ITrainDormitoryInfoService.class, (p, m, a) -> + "selectTrainDormitoryInfoByDormId".equals(m.getName()) ? new TrainDormitoryInfo() : null)); + setField(controller, "studentService", proxy(com.ruoyi.train.service.ITrainStudentService.class, + (p, m, a) -> null)); + TrainStudent student = new TrainStudent(); + student.setsId("STU_1"); + setField(controller, "trainStudentMapper", proxy(TrainStudentMapper.class, (p, m, a) -> { + if ("selectTrainStudentBySId".equals(m.getName())) + { + return studentExists ? student : null; + } + if ("clearAvatarToNullBySId".equals(m.getName())) + { + capturedAvatarSId = (String) a[0]; + return 1; + } + return null; + })); + + LoginUser loginUser = new LoginUser(); + SysUser user = new SysUser(); + user.setUserName("register_device"); + loginUser.setUser(user); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList())); + } + + @After + public void tearDown() + { + SecurityContextHolder.clearContext(); + } + + @Test + public void resetReport_Success_UnbindsFaceCheckoutsAndResetsStatus() + { + AjaxResult result = controller.resetReport("PLAN_A", "STU_1"); + + assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG)); + assertEquals("STU_1", capturedFaceStudentId); + assertNotNull(capturedCheckoutBody); + assertEquals("BIZ-301", capturedCheckoutBody.get("bizNo")); + assertEquals(Long.valueOf(201L), capturedUpdateEnrollmentId); + assertEquals(Integer.valueOf(0), capturedUpdateStatus); + + @SuppressWarnings("unchecked") + Map data = (Map) result.get(AjaxResult.DATA_TAG); + assertEquals("success", data.get("faceUnbind")); + assertEquals("success", data.get("lockCheckout")); + assertEquals(0, data.get("currentStatus")); + assertEquals(true, data.get("accommodationKept")); + assertEquals(1, data.get("previousStatus")); + assertEquals(true, data.get("avatarCleared")); + assertEquals("STU_1", data.get("avatarSId")); + assertEquals("STU_1", capturedAvatarSId); + } + + @Test + public void resetReport_FaceUnbindFails_StatusNotChanged() + { + faceResult = AjaxResult.error("人脸设备返回 500"); + + AjaxResult result = controller.resetReport("PLAN_A", "STU_1"); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + String msg = String.valueOf(result.get(AjaxResult.MSG_TAG)); + assertTrueContains(msg, "步骤1 人脸解绑失败"); + assertNull(capturedUpdateEnrollmentId); + } + + @Test + public void resetReport_LockCheckoutFails_StatusNotChanged() + { + checkoutResponseBody = "{\"code\":500,\"msg\":\"门禁服务不可用\"}"; + + AjaxResult result = controller.resetReport("PLAN_A", "STU_1"); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + String msg = String.valueOf(result.get(AjaxResult.MSG_TAG)); + assertTrueContains(msg, "步骤2 门锁授权清除失败"); + assertNull(capturedUpdateEnrollmentId); + assertEquals("STU_1", capturedFaceStudentId); + } + + @Test + public void resetReport_EnrollmentNotFound_Rejected() + { + AjaxResult result = controller.resetReport("PLAN_A", "STU_UNKNOWN"); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + assertTrueContains(String.valueOf(result.get(AjaxResult.MSG_TAG)), "未找到学员"); + assertNull(capturedFaceStudentId); + } + + @Test + public void resetReport_BlankParameter_Rejected() + { + AjaxResult result = controller.resetReport(" ", "STU_1"); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + assertEquals("planId 和 studentId 不能为空", result.get(AjaxResult.MSG_TAG)); + } + + @Test + public void resetReport_CompletedEnrollment_Rejected() + { + enrollment.setEnrollmentStatus(4); + + AjaxResult result = controller.resetReport("PLAN_A", "STU_1"); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + assertTrueContains(String.valueOf(result.get(AjaxResult.MSG_TAG)), "已结训"); + assertNull(capturedFaceStudentId); + } + + @Test + public void resetReport_NoAccommodation_SkipsCheckout() + { + accommodation = null; + + AjaxResult result = controller.resetReport("PLAN_A", "STU_1"); + + assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG)); + @SuppressWarnings("unchecked") + Map data = (Map) result.get(AjaxResult.DATA_TAG); + assertEquals("skipped: 无有效住宿记录", data.get("lockCheckout")); + assertEquals(false, data.get("accommodationKept")); + assertEquals(Integer.valueOf(0), capturedUpdateStatus); + } + + @Test + public void resetReport_StudentProfileMissing_ReturnsErrorWithProgress() + { + studentExists = false; + + AjaxResult result = controller.resetReport("PLAN_A", "STU_1"); + + assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG)); + assertTrueContains(String.valueOf(result.get(AjaxResult.MSG_TAG)), "未找到有效学员档案"); + assertNull(capturedAvatarSId); + // 状态重置已执行,返回体保留已完成进度供人工核对 + assertEquals(Integer.valueOf(0), capturedUpdateStatus); + @SuppressWarnings("unchecked") + Map data = (Map) result.get(AjaxResult.DATA_TAG); + assertEquals(201L, data.get("enrollmentId")); + assertEquals(0, data.get("currentStatus")); + } + + private void assertTrueContains(String text, String expected) + { + org.junit.Assert.assertTrue("实际内容:" + text, text != null && text.contains(expected)); + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + @SuppressWarnings("unchecked") + private T proxy(Class type, InvocationHandler handler) + { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, (p, m, a) -> { + if (m.getDeclaringClass() == Object.class) + { + switch (m.getName()) + { + case "toString": + return "proxy(" + type.getSimpleName() + ")"; + case "hashCode": + return System.identityHashCode(p); + case "equals": + return p == a[0]; + default: + return null; + } + } + return handler.invoke(p, m, a); + }); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java index 20130c526..4151eeb22 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java @@ -1,4 +1,4 @@ -package com.ruoyi.train.integration.dms.security; +package com.ruoyi.web.controller.train; import com.ruoyi.train.domain.TrainAccommodation; import com.ruoyi.train.domain.TrainDormAuth; diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java index fb86b9d70..8213ebb31 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import java.lang.reflect.Method; +import jakarta.servlet.http.HttpServletResponse; import org.junit.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; @@ -73,6 +74,13 @@ public class EnrollmentApiTest assertNotNull(removeDelete); assertEquals("/enrollments/{enrollmentId}", removeDelete.value()[0]); assertEquals("@ss.hasPermi('train:enrollment:remove')", removeMethod.getAnnotation(PreAuthorize.class).value()); + + // 7. POST /train/enrollments/importTemplate (UC-05 批量导入模板下载,复用导入权限) + Method templateMethod = TrainEnrollmentController.class.getMethod("importTemplate", HttpServletResponse.class); + PostMapping templatePost = templateMethod.getAnnotation(PostMapping.class); + assertNotNull(templatePost); + assertEquals("/enrollments/importTemplate", templatePost.value()[0]); + assertEquals("@ss.hasPermi('train:enrollment:import')", templateMethod.getAnnotation(PreAuthorize.class).value()); } @Test @@ -106,6 +114,7 @@ public class EnrollmentApiTest endpointCount++; } } - assertEquals("Controller 必须严格只有契约冻结的 6 个端点", 6, endpointCount); + // 6 个契约端点 + 1 个 UC-05 导入模板下载端点(模板下载复用 train:enrollment:import 权限) + assertEquals("Controller 必须严格只有契约冻结的 6 个端点与 1 个导入模板下载端点", 7, endpointCount); } } diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentApiTest.java index ee36bb1a0..9bea0ed65 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentApiTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentApiTest.java @@ -3,9 +3,11 @@ package com.ruoyi.web.controller.train; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import java.lang.reflect.Method; import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.enums.BusinessType; import com.ruoyi.train.domain.TrainStudent; import com.ruoyi.train.domain.dto.StudentCreateDTO; import com.ruoyi.train.domain.query.StudentQuery; @@ -69,8 +71,17 @@ public class StudentApiTest if (isEndpoint) { endpointCount++; - assertFalse("不得公开 export 接口", - method.getName().toLowerCase().contains("export")); + // 导出接口必须鉴权并留痕:防止越权拉取全量学员台账 + if (method.getName().toLowerCase().contains("export")) + { + PreAuthorize exportPre = method.getAnnotation(PreAuthorize.class); + assertNotNull("导出接口必须配置权限校验", exportPre); + assertTrue("导出接口必须复用学员列表权限 train:student:list", + exportPre.value().contains("train:student:list")); + Log exportLog = method.getAnnotation(Log.class); + assertNotNull("导出接口必须记录操作日志", exportLog); + assertEquals(BusinessType.EXPORT, exportLog.businessType()); + } if (method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)) { assertEquals("edit", method.getName()); @@ -85,6 +96,8 @@ public class StudentApiTest } } } - assertEquals("Controller包含经过授权的5个端点(list, getInfo, add, edit, remove)", 5, endpointCount); + // 当前端点:list / getInfo / genPersonNo / add / edit / importTemplate / importData / + // remove / export / extract / batchExtract / extractStudent + assertEquals("学员 Controller 端点数量与已授权清单不一致", 12, endpointCount); } } diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java index 4834ab501..18b966ef0 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java @@ -87,7 +87,7 @@ public class StudentSelfServiceApiTest detail.setsName("李四"); detail.setIdCardLast4("5678"); detail.setIdCardMasked("****************5678"); - detail.setOrgName("供电段"); + detail.setDeptName("供电段"); return detail; } return null; diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java index 87fc37a1a..541f38562 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java @@ -77,7 +77,7 @@ public class TeacherSelfServiceApiTest TrainTeacher teacher = new TrainTeacher(); teacher.setTId((String) args[0]); teacher.settName("王老师"); - teacher.setOrgName("机务段"); + teacher.setDeptName("机务段"); return teacher; } return null; diff --git a/RuoYi-Vue/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUtils.java b/RuoYi-Vue/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUtils.java index 7cdd49802..072db3e0f 100644 --- a/RuoYi-Vue/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUtils.java +++ b/RuoYi-Vue/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUtils.java @@ -7,8 +7,12 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.io.FilenameUtils; @@ -115,6 +119,62 @@ public class FileUtils return StringUtils.substringAfter(filePath, Constants.RESOURCE_PREFIX); } + /** + * 将 /profile/... 形式的资源地址解析为服务器本地绝对路径。 + *

+ * 处理要点: + *

    + *
  • 先去查询串/锚点,再做百分号解码,避免 %20、%E4%B8%AD 等编码后的空格/中文无法定位文件;
  • + *
  • 解码前把 '+' 转义为 %2B,防止文件名中的加号被当成空格;
  • + *
  • 使用 Paths.resolve().normalize() 而非字符串拼接,消除 "./"、重复分隔符、Windows 反斜杠混用;
  • + *
  • 校验结果仍位于上传根目录内,防止 ../ 越权读取。
  • + *
+ * + * @param resourceUrl 形如 /profile/upload/2026/11/02/xxx.png 的资源地址 + * @return 本地绝对路径;入参为空时返回 null + */ + public static Path resolveProfilePath(String resourceUrl) + { + if (StringUtils.isEmpty(resourceUrl)) + { + return null; + } + String path = resourceUrl.trim(); + int end = path.indexOf('?'); + if (end > -1) + { + path = path.substring(0, end); + } + end = path.indexOf('#'); + if (end > -1) + { + path = path.substring(0, end); + } + try + { + path = URLDecoder.decode(path.replace("+", "%2B"), StandardCharsets.UTF_8); + } + catch (IllegalArgumentException ignored) + { + // 非法百分号序列保持原样,交给后续文件存在性判断 + } + String relative = path.startsWith(Constants.RESOURCE_PREFIX) + ? path.substring(Constants.RESOURCE_PREFIX.length()) + : StringUtils.substringAfter(path, Constants.RESOURCE_PREFIX); + relative = relative.replace('\\', '/'); + while (relative.startsWith("/")) + { + relative = relative.substring(1); + } + Path root = Paths.get(RuoYiConfig.getProfile()).toAbsolutePath().normalize(); + Path target = root.resolve(relative).normalize(); + if (!target.startsWith(root)) + { + throw new IllegalArgumentException("非法的资源路径: " + resourceUrl); + } + return target; + } + /** * 删除文件 * diff --git a/RuoYi-Vue/ruoyi-common/src/main/java/com/ruoyi/common/utils/json/JsonParamUtils.java b/RuoYi-Vue/ruoyi-common/src/main/java/com/ruoyi/common/utils/json/JsonParamUtils.java new file mode 100644 index 000000000..ea457122c --- /dev/null +++ b/RuoYi-Vue/ruoyi-common/src/main/java/com/ruoyi/common/utils/json/JsonParamUtils.java @@ -0,0 +1,349 @@ +package com.ruoyi.common.utils.json; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; + +/** + * JSON 入参解析与校验工具。 + * + *

职责:把一个 JSON 字符串安全地解析为“字段名 → 值”的 Map,并在解析失败或字段校验失败时 + * 返回可直接回给调用方的中文错误提示,避免各业务方重复写 try/catch 与类型判断。

+ * + *

类型处理约定

+ * + * + * + * + * + * + * + * + * + *
JSON 值解析后 Java 类型
字符串 "abc"{@link String}
整数值 1001{@link Integer};超出 int 范围时为 {@link Long}
小数值 1.5{@link BigDecimal}
布尔 true/false{@link Boolean}
null{@code null}
嵌套对象 {...}{@link JSONObject}(原样保留,不做递归转换)
数组 [...]{@link JSONArray}(原样保留)
+ * + *

结构校验:入参必须是非空白字符串,且解析结果的顶层必须是 JSON 对象; + * 顶层数组、数字、字符串、布尔值、null 以及语法错误都会被拒绝并给出明确提示。

+ * + *

字段校验(可选):传入 schema(字段名 → 期望类型)后,会校验字段存在且非 null, + * 并按需转换类型(String / Integer / Long / Double / BigDecimal / Boolean)。

+ * + *

返回值:{@link JsonParamResult},包含 {@code success}、{@code message}、{@code data} 三部分: + * 成功时 {@code data} 为字段 Map(保持 JSON 中出现顺序),失败时 {@code data} 为空 Map。

+ * + *

示例:

+ *
+ * JsonParamResult r = JsonParamUtils.parse("{\"studentId\":\"STU_1\",\"lessonId\":1001}");
+ * if (!r.isSuccess()) { return AjaxResult.error(r.getMessage()); }
+ * Long lessonId = (Long) r.get("lessonId");
+ * 
+ * + * @author ruoyi + * @date 2026-11-03 + */ +public final class JsonParamUtils +{ + /** 类型转换失败的哨兵值 */ + private static final Object UNCONVERTIBLE = new Object(); + + private JsonParamUtils() + { + } + + /** + * 解析 JSON 字符串,仅做结构校验。 + * + * @param json JSON 字符串 + * @return 解析结果,永不返回 null + */ + public static JsonParamResult parse(String json) + { + return parse(json, null); + } + + /** + * 解析 JSON 字符串,并按 schema 校验字段是否存在、类型是否可转换。 + * + * @param json JSON 字符串 + * @param schema 字段名 → 期望类型;为 null 或空 Map 时跳过字段校验。 + * 支持 String、Integer/int、Long/long、Double/double、BigDecimal、Boolean/boolean。 + * @return 解析结果,永不返回 null + */ + public static JsonParamResult parse(String json, Map> schema) + { + if (json == null || json.trim().isEmpty()) + { + return JsonParamResult.fail("JSON 参数不能为空"); + } + + Object parsed; + try + { + parsed = JSON.parse(json.trim()); + } + catch (Exception e) + { + return JsonParamResult.fail("JSON 格式不合法:" + rootMessage(e)); + } + + if (parsed == null) + { + return JsonParamResult.fail("JSON 参数解析结果为空,请传入 JSON 对象"); + } + if (!(parsed instanceof JSONObject)) + { + return JsonParamResult.fail("JSON 顶层必须是对象(形如 {\"key\":\"value\"}),当前为" + describe(parsed)); + } + + Map data = new LinkedHashMap<>(); + for (Map.Entry entry : ((JSONObject) parsed).entrySet()) + { + String key = entry.getKey(); + if (key == null || key.trim().isEmpty()) + { + return JsonParamResult.fail("JSON 存在空字段名,字段名不能为空"); + } + data.put(key, normalize(entry.getValue())); + } + + if (schema != null && !schema.isEmpty()) + { + for (Map.Entry> item : schema.entrySet()) + { + String key = item.getKey(); + Class expected = item.getValue(); + if (!data.containsKey(key) || data.get(key) == null) + { + return JsonParamResult.fail("缺少必填字段:" + key); + } + Object converted = convert(data.get(key), expected); + if (converted == UNCONVERTIBLE) + { + return JsonParamResult.fail("字段[" + key + "]类型不匹配,期望" + typeName(expected) + + ",实际为" + describe(data.get(key))); + } + data.put(key, converted); + } + } + + return JsonParamResult.ok(data); + } + + /** + * 归一化 JSON 原生值:嵌套对象/数组转为通用 Map/List,避免调用方依赖 fastjson 类型。 + */ + private static Object normalize(Object value) + { + if (value instanceof JSONObject) + { + return Collections.unmodifiableMap(new LinkedHashMap<>((JSONObject) value)); + } + if (value instanceof JSONArray) + { + return Collections.unmodifiableList(new java.util.ArrayList<>((JSONArray) value)); + } + return value; + } + + /** + * 按期望类型转换字段值。 + * + * @return 转换后的值;无法转换时返回 {@link #UNCONVERTIBLE} + */ + private static Object convert(Object value, Class type) + { + if (type == null || type == Object.class || type.isInstance(value)) + { + return value; + } + if (value instanceof Map || value instanceof java.util.List) + { + // 嵌套结构仅在期望 String 时允许序列化,其余类型视为不匹配 + return type == String.class ? JSON.toJSONString(value) : UNCONVERTIBLE; + } + String text = String.valueOf(value).trim(); + try + { + if (type == String.class) + { + return text; + } + if (type == Long.class || type == long.class) + { + return new BigDecimal(text).longValueExact(); + } + if (type == Integer.class || type == int.class) + { + return new BigDecimal(text).intValueExact(); + } + if (type == Double.class || type == double.class) + { + return Double.valueOf(text); + } + if (type == BigDecimal.class) + { + return new BigDecimal(text); + } + if (type == Boolean.class || type == boolean.class) + { + return parseBoolean(text); + } + return UNCONVERTIBLE; + } + catch (Exception e) + { + return UNCONVERTIBLE; + } + } + + private static Boolean parseBoolean(String text) + { + if ("true".equalsIgnoreCase(text) || "1".equals(text)) + { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text)) + { + return Boolean.FALSE; + } + throw new IllegalArgumentException("not a boolean"); + } + + /** + * 生成“实际类型”的中文描述,用于错误提示。 + */ + private static String describe(Object value) + { + if (value == null) + { + return "null"; + } + if (value instanceof JSONObject || value instanceof Map) + { + return "对象"; + } + if (value instanceof JSONArray || value instanceof java.util.List) + { + return "数组"; + } + if (value instanceof String) + { + return "字符串"; + } + if (value instanceof Boolean) + { + return "布尔值"; + } + if (value instanceof Number) + { + return "数值"; + } + return value.getClass().getSimpleName(); + } + + private static String typeName(Class type) + { + if (type == null) + { + return "Object"; + } + if (type == long.class) + { + return "Long"; + } + if (type == int.class) + { + return "Integer"; + } + if (type == double.class) + { + return "Double"; + } + if (type == boolean.class) + { + return "Boolean"; + } + return type.getSimpleName(); + } + + private static String rootMessage(Throwable e) + { + Throwable cause = e; + while (cause.getCause() != null && cause.getCause() != cause) + { + cause = cause.getCause(); + } + String message = cause.getMessage(); + return message == null || message.isEmpty() ? cause.getClass().getSimpleName() : message; + } + + /** + * 解析结果。成功时 {@code data} 为字段 Map;失败时 {@code message} 为可直接返回给调用方的提示。 + */ + public static final class JsonParamResult + { + private final boolean success; + private final String message; + private final Map data; + + private JsonParamResult(boolean success, String message, Map data) + { + this.success = success; + this.message = message; + this.data = data == null ? Collections.emptyMap() : data; + } + + static JsonParamResult ok(Map data) + { + return new JsonParamResult(true, "解析成功", data); + } + + static JsonParamResult fail(String message) + { + return new JsonParamResult(false, message, Collections.emptyMap()); + } + + public boolean isSuccess() + { + return success; + } + + public String getMessage() + { + return message; + } + + public Map getData() + { + return data; + } + + /** + * 取字段值。 + */ + @SuppressWarnings("unchecked") + public T get(String key) + { + return (T) data.get(key); + } + + /** + * 取字符串字段,缺失或为 null 时返回 defaultValue。 + */ + public String getString(String key, String defaultValue) + { + Object value = data.get(key); + return value == null ? defaultValue : String.valueOf(value); + } + + @Override + public String toString() + { + return "JsonParamResult{success=" + success + ", message='" + message + "', data=" + data + "}"; + } + } +} diff --git a/RuoYi-Vue/ruoyi-device/pom.xml b/RuoYi-Vue/ruoyi-device/pom.xml new file mode 100644 index 000000000..08480ee48 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/pom.xml @@ -0,0 +1,57 @@ + + + + ruoyi + com.ruoyi + 3.9.2 + + 4.0.0 + + ruoyi-device + + + 设备模块 + + + + + + com.ruoyi + ruoyi-common + + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + + + + + junit + junit + 4.13.2 + test + + + com.fasterxml.jackson.core + jackson-annotations + 2.21 + compile + + + jakarta.validation + jakarta.validation-api + + + org.jetbrains + annotations + 26.0.2 + compile + + + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/BaseDomain.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/BaseDomain.java new file mode 100644 index 000000000..149d3d35a --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/BaseDomain.java @@ -0,0 +1,72 @@ +package com.ruoyi.device.domain; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 设备模块本地基础实体。 + * 原 RuoYi 约定继承 com.ruoyi.common.core.domain.BaseEntity, + * 但 device 作为后接入模块在 IDE 中常出现 ruoyi-common 依赖无法解析的问题, + * 故在此提供等价本地基类,使本模块可脱离 ruoyi-common 独立编译。 + * createTime/updateTime/remark/params 与 BaseEntity 保持一致,Mapper 映射、数据权限、时间范围查询均不受影响。 + * + * @author ruoyi + */ +public class BaseDomain +{ + private Date createTime; + + private Date updateTime; + + private String remark; + + /** 请求参数 */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map params; + + public Date getCreateTime() + { + return createTime; + } + + public void setCreateTime(Date createTime) + { + this.createTime = createTime; + } + + public Date getUpdateTime() + { + return updateTime; + } + + public void setUpdateTime(Date updateTime) + { + this.updateTime = updateTime; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } + + public Map getParams() + { + if (params == null) + { + params = new HashMap<>(); + } + return params; + } + + public void setParams(Map params) + { + this.params = params; + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainBookDownloadRecord.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainBookDownloadRecord.java new file mode 100644 index 000000000..b97275f90 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainBookDownloadRecord.java @@ -0,0 +1,151 @@ +package com.ruoyi.device.domain; + +import java.util.Date; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 图书下载回执记录对象 train_book_download_record + * + * @author ruoyi + * @date 2026-10-20 + */ +public class TrainBookDownloadRecord extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long id; + + /** 学员ID */ + private Long userId; + + /** 设备ID */ + private String deviceId; + + /** 图书ID */ + private String bookId; + + /** 图书版本 */ + private String bookVersion; + + /** 文件MD5 */ + private String fileMd5; + + /** 下载结果:SUCCESS/FAIL */ + private String result; + + /** 错误信息 */ + private String errMsg; + + /** 下载/回执时间 */ + private Date downloadTime; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setUserId(Long userId) + { + this.userId = userId; + } + + public Long getUserId() + { + return userId; + } + + public void setDeviceId(String deviceId) + { + this.deviceId = deviceId; + } + + public String getDeviceId() + { + return deviceId; + } + + public void setBookId(String bookId) + { + this.bookId = bookId; + } + + public String getBookId() + { + return bookId; + } + + public void setBookVersion(String bookVersion) + { + this.bookVersion = bookVersion; + } + + public String getBookVersion() + { + return bookVersion; + } + + public void setFileMd5(String fileMd5) + { + this.fileMd5 = fileMd5; + } + + public String getFileMd5() + { + return fileMd5; + } + + public void setResult(String result) + { + this.result = result; + } + + public String getResult() + { + return result; + } + + public void setErrMsg(String errMsg) + { + this.errMsg = errMsg; + } + + public String getErrMsg() + { + return errMsg; + } + + public void setDownloadTime(Date downloadTime) + { + this.downloadTime = downloadTime; + } + + public Date getDownloadTime() + { + return downloadTime; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("userId", getUserId()) + .append("deviceId", getDeviceId()) + .append("bookId", getBookId()) + .append("bookVersion", getBookVersion()) + .append("fileMd5", getFileMd5()) + .append("result", getResult()) + .append("errMsg", getErrMsg()) + .append("downloadTime", getDownloadTime()) + .append("createTime", getCreateTime()) + .append("updateTime", getUpdateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainBookSnapshot.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainBookSnapshot.java new file mode 100644 index 000000000..70b1f4ae6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainBookSnapshot.java @@ -0,0 +1,191 @@ +package com.ruoyi.device.domain; + +import java.util.Date; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 图书快照对象 train_book_snapshot + * + * @author ruoyi + * @date 2026-10-20 + */ +public class TrainBookSnapshot extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long id; + + /** 图书永久ID,永不复用 */ + private String bookId; + + /** 书名 */ + private String bookTitle; + + /** SAFETY专业/REGULATION规章等 */ + private String bookCategory; + + /** 简介 */ + private String bookSummary; + + /** 版本BV‑xx,递增 */ + private String bookVersion; + + /** 图书封皮 */ + private String bookCover; + + /** 文件字节大小 */ + private Long fileSize; + + /** 文件MD5校验 */ + private String fileMd5; + + /** 原始文件地址 */ + private String downloadUrl; + + /** 1上架;0下架禁止新下载,可上报阅读 */ + private Integer isOnShelf; + + /** 版本发布时间 */ + private Date publishTime; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setBookId(String bookId) + { + this.bookId = bookId; + } + + public String getBookId() + { + return bookId; + } + + public void setBookTitle(String bookTitle) + { + this.bookTitle = bookTitle; + } + + public String getBookTitle() + { + return bookTitle; + } + + public void setBookCategory(String bookCategory) + { + this.bookCategory = bookCategory; + } + + public String getBookCategory() + { + return bookCategory; + } + + public void setBookSummary(String bookSummary) + { + this.bookSummary = bookSummary; + } + + public String getBookSummary() + { + return bookSummary; + } + + public void setBookVersion(String bookVersion) + { + this.bookVersion = bookVersion; + } + + public String getBookVersion() + { + return bookVersion; + } + + public String getBookCover() { + return bookCover; + } + + public void setBookCover(String bookCover) { + this.bookCover = bookCover; + } + + public void setFileSize(Long fileSize) + { + this.fileSize = fileSize; + } + + public Long getFileSize() + { + return fileSize; + } + + public void setFileMd5(String fileMd5) + { + this.fileMd5 = fileMd5; + } + + public String getFileMd5() + { + return fileMd5; + } + + public void setDownloadUrl(String downloadUrl) + { + this.downloadUrl = downloadUrl; + } + + public String getDownloadUrl() + { + return downloadUrl; + } + + public void setIsOnShelf(Integer isOnShelf) + { + this.isOnShelf = isOnShelf; + } + + public Integer getIsOnShelf() + { + return isOnShelf; + } + + public void setPublishTime(Date publishTime) + { + this.publishTime = publishTime; + } + + public Date getPublishTime() + { + return publishTime; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("bookId", getBookId()) + .append("bookTitle", getBookTitle()) + .append("bookCategory", getBookCategory()) + .append("bookSummary", getBookSummary()) + .append("bookVersion", getBookVersion()) + .append("bookCover", getBookCover()) + .append("fileSize", getFileSize()) + .append("fileMd5", getFileMd5()) + .append("downloadUrl", getDownloadUrl()) + .append("isOnShelf", getIsOnShelf()) + .append("publishTime", getPublishTime()) + .append("createTime", getCreateTime()) + .append("updateTime", getUpdateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainCheckinTerminal.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainCheckinTerminal.java new file mode 100644 index 000000000..37caa4248 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainCheckinTerminal.java @@ -0,0 +1,371 @@ +package com.ruoyi.device.domain; + +import java.util.Date; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 报到一体机(自助报到终端)设备对象 train_checkin_terminal + * + * @author ruoyi + * @date 2026-10-24 + */ +public class TrainCheckinTerminal extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 自增主键 */ + @Excel(name = "ID") + private Long id; + + /** 终端业务ID(对接同步/注册使用) */ + @Excel(name = "终端业务ID") + private String terminalId; + + /** 硬件序列号(12位字母数字,唯一) */ + @Excel(name = "硬件序列号") + private String terminalSn; + + /** 设备名称,如“基地大厅1号报到机” */ + @Excel(name = "设备名称") + private String terminalName; + + /** 绑定机构/基地ID(服务多班,不绑单一教室) */ + @Excel(name = "绑定机构ID") + private String bindDeptId; + + /** 安装位置,如“1号楼大厅” */ + @Excel(name = "安装位置") + private String installLocation; + + /** 支持的报到方式:ID_CARD,FACE,STUDENT_NO */ + @Excel(name = "报到方式") + private String supportCheckinType; + + /** 启用模块:CHECKIN,INQUIRY,STATS,SETTINGS */ + @Excel(name = "启用模块") + private String supportModules; + + /** 是否支持打印 Y/N */ + @Excel(name = "打印支持", readConverterExp = "Y=是,N=否") + private String supportPrint; + + /** 是否语音播报 Y/N */ + @Excel(name = "语音播报", readConverterExp = "Y=是,N=否") + private String supportBroadcast; + + /** 联系电话(欢迎页底部) */ + @Excel(name = "联系电话") + private String contactPhone; + + /** 欢迎语 */ + @Excel(name = "欢迎语") + private String welcomeTitle; + + /** 顶部机构名称(不填则取绑定机构) */ + @Excel(name = "机构名称") + private String orgName; + + /** 屏幕方向 LANDSCAPE/PORTRAIT */ + @Excel(name = "屏幕方向") + private String screenOrientation; + + /** 无操作自动返回首页秒数 */ + @Excel(name = "返回首页秒数") + private Integer idleReturnSeconds; + + /** 使用帮助文案 */ + @Excel(name = "帮助文案") + private String helpText; + + /** 设备IP地址 */ + @Excel(name = "设备IP") + private String terminalIp; + + /** 设备通讯/识别服务端点 */ + @Excel(name = "服务端点") + private String apiEndpoint; + + /** 设备状态 0离线 1在线 */ + @Excel(name = "设备状态", readConverterExp = "0=离线,1=在线") + private Integer terminalStatus; + + /** 最后心跳时间 */ + @Excel(name = "最近心跳", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date lastHeartbeat; + + /** 删除标志 0存在 2删除 */ + private String delFlag; + + /** 创建者 */ + @Excel(name = "创建者") + private String createBy; + + /** 更新者 */ + @Excel(name = "更新者") + private String updateBy; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setTerminalId(String terminalId) + { + this.terminalId = terminalId; + } + + public String getTerminalId() + { + return terminalId; + } + + public void setTerminalSn(String terminalSn) + { + this.terminalSn = terminalSn; + } + + public String getTerminalSn() + { + return terminalSn; + } + + public void setTerminalName(String terminalName) + { + this.terminalName = terminalName; + } + + public String getTerminalName() + { + return terminalName; + } + + public void setBindDeptId(String bindDeptId) + { + this.bindDeptId = bindDeptId; + } + + public String getBindDeptId() + { + return bindDeptId; + } + + public void setInstallLocation(String installLocation) + { + this.installLocation = installLocation; + } + + public String getInstallLocation() + { + return installLocation; + } + + public void setSupportCheckinType(String supportCheckinType) + { + this.supportCheckinType = supportCheckinType; + } + + public String getSupportCheckinType() + { + return supportCheckinType; + } + + public void setSupportModules(String supportModules) + { + this.supportModules = supportModules; + } + + public String getSupportModules() + { + return supportModules; + } + + public void setSupportPrint(String supportPrint) + { + this.supportPrint = supportPrint; + } + + public String getSupportPrint() + { + return supportPrint; + } + + public void setSupportBroadcast(String supportBroadcast) + { + this.supportBroadcast = supportBroadcast; + } + + public String getSupportBroadcast() + { + return supportBroadcast; + } + + public void setContactPhone(String contactPhone) + { + this.contactPhone = contactPhone; + } + + public String getContactPhone() + { + return contactPhone; + } + + public void setWelcomeTitle(String welcomeTitle) + { + this.welcomeTitle = welcomeTitle; + } + + public String getWelcomeTitle() + { + return welcomeTitle; + } + + public void setOrgName(String orgName) + { + this.orgName = orgName; + } + + public String getOrgName() + { + return orgName; + } + + public void setScreenOrientation(String screenOrientation) + { + this.screenOrientation = screenOrientation; + } + + public String getScreenOrientation() + { + return screenOrientation; + } + + public void setIdleReturnSeconds(Integer idleReturnSeconds) + { + this.idleReturnSeconds = idleReturnSeconds; + } + + public Integer getIdleReturnSeconds() + { + return idleReturnSeconds; + } + + public void setHelpText(String helpText) + { + this.helpText = helpText; + } + + public String getHelpText() + { + return helpText; + } + + public void setTerminalIp(String terminalIp) + { + this.terminalIp = terminalIp; + } + + public String getTerminalIp() + { + return terminalIp; + } + + public void setApiEndpoint(String apiEndpoint) + { + this.apiEndpoint = apiEndpoint; + } + + public String getApiEndpoint() + { + return apiEndpoint; + } + + public void setTerminalStatus(Integer terminalStatus) + { + this.terminalStatus = terminalStatus; + } + + public Integer getTerminalStatus() + { + return terminalStatus; + } + + public void setLastHeartbeat(Date lastHeartbeat) + { + this.lastHeartbeat = lastHeartbeat; + } + + public Date getLastHeartbeat() + { + return lastHeartbeat; + } + + public void setDelFlag(String delFlag) + { + this.delFlag = delFlag; + } + + public String getDelFlag() + { + return delFlag; + } + + public void setCreateBy(String createBy) + { + this.createBy = createBy; + } + + public String getCreateBy() + { + return createBy; + } + + public void setUpdateBy(String updateBy) + { + this.updateBy = updateBy; + } + + public String getUpdateBy() + { + return updateBy; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("terminalId", getTerminalId()) + .append("terminalSn", getTerminalSn()) + .append("terminalName", getTerminalName()) + .append("bindDeptId", getBindDeptId()) + .append("installLocation", getInstallLocation()) + .append("supportCheckinType", getSupportCheckinType()) + .append("supportModules", getSupportModules()) + .append("supportPrint", getSupportPrint()) + .append("supportBroadcast", getSupportBroadcast()) + .append("contactPhone", getContactPhone()) + .append("welcomeTitle", getWelcomeTitle()) + .append("orgName", getOrgName()) + .append("screenOrientation", getScreenOrientation()) + .append("idleReturnSeconds", getIdleReturnSeconds()) + .append("helpText", getHelpText()) + .append("terminalIp", getTerminalIp()) + .append("apiEndpoint", getApiEndpoint()) + .append("terminalStatus", getTerminalStatus()) + .append("lastHeartbeat", getLastHeartbeat()) + .append("delFlag", getDelFlag()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainIotDevice.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainIotDevice.java new file mode 100644 index 000000000..0541556a6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainIotDevice.java @@ -0,0 +1,179 @@ +package com.ruoyi.device.domain; + +import java.util.Date; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 读书机设备档案对象 train_iot_device + * + * @author ruoyi + * @date 2026-10-20 + */ +public class TrainIotDevice extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long id; + + /** 读书机设备唯一编码deviceId */ + private String deviceId; + + /** deviceSecret SHA256摘要,不存明文 */ + private String deviceSecretDigest; + + /** 1正常 2禁用 3恢复出厂 */ + private Integer deviceStatus; + + /** 固件版本 */ + private String firmwareVersion; + + /** APP版本 */ + private String appVersion; + + /** 绑定机构 */ + private String bindOrgId; + + /** 绑定班级,多班级逗号分隔 */ + private String bindClassId; + + /** 心跳周期秒 */ + private Integer heartbeatInterval; + + /** 最后登录时间 */ + private Date lastLoginTime; + + /** 最后心跳时间 */ + private Date lastHeartbeatTime; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setDeviceId(String deviceId) + { + this.deviceId = deviceId; + } + + public String getDeviceId() + { + return deviceId; + } + + public void setDeviceSecretDigest(String deviceSecretDigest) + { + this.deviceSecretDigest = deviceSecretDigest; + } + + public String getDeviceSecretDigest() + { + return deviceSecretDigest; + } + + public void setDeviceStatus(Integer deviceStatus) + { + this.deviceStatus = deviceStatus; + } + + public Integer getDeviceStatus() + { + return deviceStatus; + } + + public void setFirmwareVersion(String firmwareVersion) + { + this.firmwareVersion = firmwareVersion; + } + + public String getFirmwareVersion() + { + return firmwareVersion; + } + + public void setAppVersion(String appVersion) + { + this.appVersion = appVersion; + } + + public String getAppVersion() + { + return appVersion; + } + + public void setBindOrgId(String bindOrgId) + { + this.bindOrgId = bindOrgId; + } + + public String getBindOrgId() + { + return bindOrgId; + } + + public void setBindClassId(String bindClassId) + { + this.bindClassId = bindClassId; + } + + public String getBindClassId() + { + return bindClassId; + } + + public void setHeartbeatInterval(Integer heartbeatInterval) + { + this.heartbeatInterval = heartbeatInterval; + } + + public Integer getHeartbeatInterval() + { + return heartbeatInterval; + } + + public void setLastLoginTime(Date lastLoginTime) + { + this.lastLoginTime = lastLoginTime; + } + + public Date getLastLoginTime() + { + return lastLoginTime; + } + + public void setLastHeartbeatTime(Date lastHeartbeatTime) + { + this.lastHeartbeatTime = lastHeartbeatTime; + } + + public Date getLastHeartbeatTime() + { + return lastHeartbeatTime; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("deviceId", getDeviceId()) + .append("deviceSecretDigest", getDeviceSecretDigest()) + .append("deviceStatus", getDeviceStatus()) + .append("firmwareVersion", getFirmwareVersion()) + .append("appVersion", getAppVersion()) + .append("bindOrgId", getBindOrgId()) + .append("bindClassId", getBindClassId()) + .append("heartbeatInterval", getHeartbeatInterval()) + .append("lastLoginTime", getLastLoginTime()) + .append("lastHeartbeatTime", getLastHeartbeatTime()) + .append("createTime", getCreateTime()) + .append("updateTime", getUpdateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingAlternateProgress.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingAlternateProgress.java new file mode 100644 index 000000000..ec0fd4e04 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingAlternateProgress.java @@ -0,0 +1,164 @@ +package com.ruoyi.device.domain; + +import java.math.BigDecimal; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 备选冲突续读进度对象 train_reading_alternate_progress + * + * @author ruoyi + * @date 2026-10-20 + */ +public class TrainReadingAlternateProgress extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long id; + + /** 学员sys_user.userId */ + private Long userId; + + /** 图书永久ID */ + private String bookId; + + /** read / audio */ + private String mode; + + /** 进度绑定版本 */ + private String bookVersion; + + /** 当前页 */ + private Integer pageNo; + + /** TTS句子索引 */ + private Integer sentenceIndex; + + /** 0‑1进度 */ + private BigDecimal progress; + + /** 来源上报recordId */ + private String sourceRecordId; + + /** 来源sessionId */ + private String sourceSessionId; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setUserId(Long userId) + { + this.userId = userId; + } + + public Long getUserId() + { + return userId; + } + + public void setBookId(String bookId) + { + this.bookId = bookId; + } + + public String getBookId() + { + return bookId; + } + + public void setMode(String mode) + { + this.mode = mode; + } + + public String getMode() + { + return mode; + } + + public void setBookVersion(String bookVersion) + { + this.bookVersion = bookVersion; + } + + public String getBookVersion() + { + return bookVersion; + } + + public void setPageNo(Integer pageNo) + { + this.pageNo = pageNo; + } + + public Integer getPageNo() + { + return pageNo; + } + + public void setSentenceIndex(Integer sentenceIndex) + { + this.sentenceIndex = sentenceIndex; + } + + public Integer getSentenceIndex() + { + return sentenceIndex; + } + + public void setProgress(BigDecimal progress) + { + this.progress = progress; + } + + public BigDecimal getProgress() + { + return progress; + } + + public void setSourceRecordId(String sourceRecordId) + { + this.sourceRecordId = sourceRecordId; + } + + public String getSourceRecordId() + { + return sourceRecordId; + } + + public void setSourceSessionId(String sourceSessionId) + { + this.sourceSessionId = sourceSessionId; + } + + public String getSourceSessionId() + { + return sourceSessionId; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("userId", getUserId()) + .append("bookId", getBookId()) + .append("mode", getMode()) + .append("bookVersion", getBookVersion()) + .append("pageNo", getPageNo()) + .append("sentenceIndex", getSentenceIndex()) + .append("progress", getProgress()) + .append("sourceRecordId", getSourceRecordId()) + .append("sourceSessionId", getSourceSessionId()) + .append("createTime", getCreateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingCurrentProgress.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingCurrentProgress.java new file mode 100644 index 000000000..3464fdc6c --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingCurrentProgress.java @@ -0,0 +1,164 @@ +package com.ruoyi.device.domain; + +import java.math.BigDecimal; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 学员主续读进度对象 train_reading_current_progress + * + * @author ruoyi + * @date 2026-10-20 + */ +public class TrainReadingCurrentProgress extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long id; + + /** 学员sys_user.userId */ + private Long userId; + + /** 图书永久ID */ + private String bookId; + + /** read / audio */ + private String mode; + + /** 进度绑定版本 */ + private String bookVersion; + + /** 当前页 */ + private Integer pageNo; + + /** TTS句子索引 */ + private Integer sentenceIndex; + + /** 0‑1进度 */ + private BigDecimal progress; + + /** 来源上报recordId */ + private String sourceRecordId; + + /** 来源sessionId */ + private String sourceSessionId; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setUserId(Long userId) + { + this.userId = userId; + } + + public Long getUserId() + { + return userId; + } + + public void setBookId(String bookId) + { + this.bookId = bookId; + } + + public String getBookId() + { + return bookId; + } + + public void setMode(String mode) + { + this.mode = mode; + } + + public String getMode() + { + return mode; + } + + public void setBookVersion(String bookVersion) + { + this.bookVersion = bookVersion; + } + + public String getBookVersion() + { + return bookVersion; + } + + public void setPageNo(Integer pageNo) + { + this.pageNo = pageNo; + } + + public Integer getPageNo() + { + return pageNo; + } + + public void setSentenceIndex(Integer sentenceIndex) + { + this.sentenceIndex = sentenceIndex; + } + + public Integer getSentenceIndex() + { + return sentenceIndex; + } + + public void setProgress(BigDecimal progress) + { + this.progress = progress; + } + + public BigDecimal getProgress() + { + return progress; + } + + public void setSourceRecordId(String sourceRecordId) + { + this.sourceRecordId = sourceRecordId; + } + + public String getSourceRecordId() + { + return sourceRecordId; + } + + public void setSourceSessionId(String sourceSessionId) + { + this.sourceSessionId = sourceSessionId; + } + + public String getSourceSessionId() + { + return sourceSessionId; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("userId", getUserId()) + .append("bookId", getBookId()) + .append("mode", getMode()) + .append("bookVersion", getBookVersion()) + .append("pageNo", getPageNo()) + .append("sentenceIndex", getSentenceIndex()) + .append("progress", getProgress()) + .append("sourceRecordId", getSourceRecordId()) + .append("sourceSessionId", getSourceSessionId()) + .append("updateTime", getUpdateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingDurationLog.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingDurationLog.java new file mode 100644 index 000000000..02e34bf2c --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainReadingDurationLog.java @@ -0,0 +1,277 @@ +package com.ruoyi.device.domain; + +import java.math.BigDecimal; +import java.util.Date; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 阅读上报幂等日志对象 train_reading_duration_log + * + * @author ruoyi + * @date 2026-10-20 + */ +public class TrainReadingDurationLog extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long id; + + /** 上报设备deviceId */ + private String deviceId; + + /** 学员sys_user.userId */ + private Long userId; + + /** 归属业务sessionId */ + private String sessionId; + + /** 设备幂等recordId */ + private String recordId; + + /** 图书永久ID */ + private String bookId; + + /** 阅读绑定图书版本 */ + private String bookVersion; + + /** read / audio */ + private String mode; + + /** PROGRESS / END */ + private String recordType; + + /** 有效时长秒 */ + private Integer durationSec; + + /** 已朗读时长秒 */ + private Integer readSec; + + /** 起始页 */ + private Integer pageFrom; + + /** 结束页 */ + private Integer pageTo; + + /** 0‑1进度 */ + private BigDecimal progress; + + /** 阅读开始 */ + private Date startTime; + + /** 阅读结束 */ + private Date endTime; + + /** 接收时间 */ + private Date receiveTime; + + /** ACCEPTED / DUPLICATE / CONFLICT_SAVED / REJECTED */ + private String resultCode; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setDeviceId(String deviceId) + { + this.deviceId = deviceId; + } + + public String getDeviceId() + { + return deviceId; + } + + public void setUserId(Long userId) + { + this.userId = userId; + } + + public Long getUserId() + { + return userId; + } + + public void setSessionId(String sessionId) + { + this.sessionId = sessionId; + } + + public String getSessionId() + { + return sessionId; + } + + public void setRecordId(String recordId) + { + this.recordId = recordId; + } + + public String getRecordId() + { + return recordId; + } + + public void setBookId(String bookId) + { + this.bookId = bookId; + } + + public String getBookId() + { + return bookId; + } + + public void setBookVersion(String bookVersion) + { + this.bookVersion = bookVersion; + } + + public String getBookVersion() + { + return bookVersion; + } + + public void setMode(String mode) + { + this.mode = mode; + } + + public String getMode() + { + return mode; + } + + public void setRecordType(String recordType) + { + this.recordType = recordType; + } + + public String getRecordType() + { + return recordType; + } + + public void setDurationSec(Integer durationSec) + { + this.durationSec = durationSec; + } + + public Integer getDurationSec() + { + return durationSec; + } + + public void setReadSec(Integer readSec) + { + this.readSec = readSec; + } + + public Integer getReadSec() + { + return readSec; + } + + public void setPageFrom(Integer pageFrom) + { + this.pageFrom = pageFrom; + } + + public Integer getPageFrom() + { + return pageFrom; + } + + public void setPageTo(Integer pageTo) + { + this.pageTo = pageTo; + } + + public Integer getPageTo() + { + return pageTo; + } + + public void setProgress(BigDecimal progress) + { + this.progress = progress; + } + + public BigDecimal getProgress() + { + return progress; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getStartTime() + { + return startTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setReceiveTime(Date receiveTime) + { + this.receiveTime = receiveTime; + } + + public Date getReceiveTime() + { + return receiveTime; + } + + public void setResultCode(String resultCode) + { + this.resultCode = resultCode; + } + + public String getResultCode() + { + return resultCode; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("deviceId", getDeviceId()) + .append("userId", getUserId()) + .append("sessionId", getSessionId()) + .append("recordId", getRecordId()) + .append("bookId", getBookId()) + .append("bookVersion", getBookVersion()) + .append("mode", getMode()) + .append("recordType", getRecordType()) + .append("durationSec", getDurationSec()) + .append("readSec", getReadSec()) + .append("pageFrom", getPageFrom()) + .append("pageTo", getPageTo()) + .append("progress", getProgress()) + .append("startTime", getStartTime()) + .append("endTime", getEndTime()) + .append("receiveTime", getReceiveTime()) + .append("resultCode", getResultCode()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainUserSession.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainUserSession.java new file mode 100644 index 000000000..a001bc7df --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/domain/TrainUserSession.java @@ -0,0 +1,149 @@ +package com.ruoyi.device.domain; + +import java.util.Date; +import com.ruoyi.device.domain.BaseDomain; +import com.ruoyi.device.util.ToStringBuilder; +import com.ruoyi.device.util.ToStringStyle; + +/** + * 学员业务会话对象 train_user_session + * + * @author ruoyi + * @date 2026-10-20 + */ +public class TrainUserSession extends BaseDomain +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long id; + + /** sys_user学员主键userId */ + private Long userId; + + /** 业务会话ID,业务幂等标识 */ + private String sessionId; + + /** 关联读书机deviceId */ + private String deviceId; + + /** 1 ACTIVE正常在线;2 REPLACED被替换仅允许补交记录;3 LOGOUT已主动退出 */ + private Integer sessionStatus; + + /** 登录时间 */ + private Date loginTime; + + /** 被顶替时间 */ + private Date replaceTime; + + /** 退出时间 */ + private Date logoutTime; + + /** 对应RuoYi Redis loginUser key,用于踢下线 */ + private String ruoyiTokenKey; + + public void setId(Long id) + { + this.id = id; + } + + public Long getId() + { + return id; + } + + public void setUserId(Long userId) + { + this.userId = userId; + } + + public Long getUserId() + { + return userId; + } + + public void setSessionId(String sessionId) + { + this.sessionId = sessionId; + } + + public String getSessionId() + { + return sessionId; + } + + public void setDeviceId(String deviceId) + { + this.deviceId = deviceId; + } + + public String getDeviceId() + { + return deviceId; + } + + public void setSessionStatus(Integer sessionStatus) + { + this.sessionStatus = sessionStatus; + } + + public Integer getSessionStatus() + { + return sessionStatus; + } + + public void setLoginTime(Date loginTime) + { + this.loginTime = loginTime; + } + + public Date getLoginTime() + { + return loginTime; + } + + public void setReplaceTime(Date replaceTime) + { + this.replaceTime = replaceTime; + } + + public Date getReplaceTime() + { + return replaceTime; + } + + public void setLogoutTime(Date logoutTime) + { + this.logoutTime = logoutTime; + } + + public Date getLogoutTime() + { + return logoutTime; + } + + public void setRuoyiTokenKey(String ruoyiTokenKey) + { + this.ruoyiTokenKey = ruoyiTokenKey; + } + + public String getRuoyiTokenKey() + { + return ruoyiTokenKey; + } + + @Override + public String toString() { + return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + .append("id", getId()) + .append("userId", getUserId()) + .append("sessionId", getSessionId()) + .append("deviceId", getDeviceId()) + .append("sessionStatus", getSessionStatus()) + .append("loginTime", getLoginTime()) + .append("replaceTime", getReplaceTime()) + .append("logoutTime", getLogoutTime()) + .append("ruoyiTokenKey", getRuoyiTokenKey()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainBookDownloadRecordMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainBookDownloadRecordMapper.java new file mode 100644 index 000000000..3bd6dcaef --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainBookDownloadRecordMapper.java @@ -0,0 +1,25 @@ +package com.ruoyi.device.mapper; + +import java.util.List; +import com.ruoyi.device.domain.TrainBookDownloadRecord; + +/** + * 图书下载回执记录 Mapper 接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface TrainBookDownloadRecordMapper +{ + public TrainBookDownloadRecord selectTrainBookDownloadRecordById(Long id); + + public List selectTrainBookDownloadRecordList(TrainBookDownloadRecord record); + + public int insertTrainBookDownloadRecord(TrainBookDownloadRecord record); + + public int updateTrainBookDownloadRecord(TrainBookDownloadRecord record); + + public int deleteTrainBookDownloadRecordById(Long id); + + public int deleteTrainBookDownloadRecordByIds(Long[] ids); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainBookSnapshotMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainBookSnapshotMapper.java new file mode 100644 index 000000000..91c0b24d6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainBookSnapshotMapper.java @@ -0,0 +1,30 @@ +package com.ruoyi.device.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.device.domain.TrainBookSnapshot; + +/** + * 图书快照Mapper接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface TrainBookSnapshotMapper +{ + public TrainBookSnapshot selectTrainBookSnapshotById(Long id); + + public List selectTrainBookSnapshotList(TrainBookSnapshot trainBookSnapshot); + + public int insertTrainBookSnapshot(TrainBookSnapshot trainBookSnapshot); + + public int updateTrainBookSnapshot(TrainBookSnapshot trainBookSnapshot); + + public int deleteTrainBookSnapshotById(Long id); + + public int deleteTrainBookSnapshotByIds(Long[] ids); + + public List selectOnShelfList(@Param("bookCategory") String bookCategory); + + public TrainBookSnapshot selectByBookIdAndVersion(@Param("bookId") String bookId, @Param("bookVersion") String bookVersion); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainCheckinTerminalMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainCheckinTerminalMapper.java new file mode 100644 index 000000000..a446e0297 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainCheckinTerminalMapper.java @@ -0,0 +1,28 @@ +package com.ruoyi.device.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.device.domain.TrainCheckinTerminal; + +/** + * 报到一体机(自助报到终端)Mapper接口 + * + * @author ruoyi + * @date 2026-10-24 + */ +public interface TrainCheckinTerminalMapper +{ + public TrainCheckinTerminal selectTrainCheckinTerminalById(Long id); + + public List selectTrainCheckinTerminalList(TrainCheckinTerminal trainCheckinTerminal); + + public int insertTrainCheckinTerminal(TrainCheckinTerminal trainCheckinTerminal); + + public int updateTrainCheckinTerminal(TrainCheckinTerminal trainCheckinTerminal); + + public int deleteTrainCheckinTerminalById(Long id); + + public int deleteTrainCheckinTerminalByIds(Long[] ids); + + public TrainCheckinTerminal selectTrainCheckinTerminalByTerminalId(String terminalId); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainIotDeviceMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainIotDeviceMapper.java new file mode 100644 index 000000000..3fdd3b0a4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainIotDeviceMapper.java @@ -0,0 +1,31 @@ +package com.ruoyi.device.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.device.domain.TrainIotDevice; + +/** + * 读书机设备档案Mapper接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface TrainIotDeviceMapper +{ + public TrainIotDevice selectTrainIotDeviceById(Long id); + + public List selectTrainIotDeviceList(TrainIotDevice trainIotDevice); + + public int insertTrainIotDevice(TrainIotDevice trainIotDevice); + + public int updateTrainIotDevice(TrainIotDevice trainIotDevice); + + public int deleteTrainIotDeviceById(Long id); + + public int deleteTrainIotDeviceByIds(Long[] ids); + + public TrainIotDevice selectTrainIotDeviceByDeviceId(String deviceId); + + public int updateHeartbeatByDeviceId(@Param("deviceId") String deviceId, @Param("lastHeartbeatTime") Date lastHeartbeatTime, @Param("deviceStatus") Integer deviceStatus); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingAlternateProgressMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingAlternateProgressMapper.java new file mode 100644 index 000000000..727009f30 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingAlternateProgressMapper.java @@ -0,0 +1,28 @@ +package com.ruoyi.device.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.device.domain.TrainReadingAlternateProgress; + +/** + * 备选冲突续读进度Mapper接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface TrainReadingAlternateProgressMapper +{ + public TrainReadingAlternateProgress selectTrainReadingAlternateProgressById(Long id); + + public List selectTrainReadingAlternateProgressList(TrainReadingAlternateProgress trainReadingAlternateProgress); + + public int insertTrainReadingAlternateProgress(TrainReadingAlternateProgress trainReadingAlternateProgress); + + public int updateTrainReadingAlternateProgress(TrainReadingAlternateProgress trainReadingAlternateProgress); + + public int deleteTrainReadingAlternateProgressById(Long id); + + public int deleteTrainReadingAlternateProgressByIds(Long[] ids); + + public List selectListByUserBookMode(@Param("userId") Long userId, @Param("bookId") String bookId, @Param("mode") String mode); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingCurrentProgressMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingCurrentProgressMapper.java new file mode 100644 index 000000000..25b28ec9a --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingCurrentProgressMapper.java @@ -0,0 +1,30 @@ +package com.ruoyi.device.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.device.domain.TrainReadingCurrentProgress; + +/** + * 学员主续读进度Mapper接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface TrainReadingCurrentProgressMapper +{ + public TrainReadingCurrentProgress selectTrainReadingCurrentProgressById(Long id); + + public List selectTrainReadingCurrentProgressList(TrainReadingCurrentProgress trainReadingCurrentProgress); + + public int insertTrainReadingCurrentProgress(TrainReadingCurrentProgress trainReadingCurrentProgress); + + public int updateTrainReadingCurrentProgress(TrainReadingCurrentProgress trainReadingCurrentProgress); + + public int deleteTrainReadingCurrentProgressById(Long id); + + public int deleteTrainReadingCurrentProgressByIds(Long[] ids); + + public TrainReadingCurrentProgress selectByUserBookMode(@Param("userId") Long userId, @Param("bookId") String bookId, @Param("mode") String mode); + + public List selectListByUserId(@Param("userId") Long userId); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingDurationLogMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingDurationLogMapper.java new file mode 100644 index 000000000..ef7854d5f --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainReadingDurationLogMapper.java @@ -0,0 +1,30 @@ +package com.ruoyi.device.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.device.domain.TrainReadingDurationLog; + +/** + * 阅读上报幂等日志Mapper接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface TrainReadingDurationLogMapper +{ + public TrainReadingDurationLog selectTrainReadingDurationLogById(Long id); + + public List selectTrainReadingDurationLogList(TrainReadingDurationLog trainReadingDurationLog); + + public int insertTrainReadingDurationLog(TrainReadingDurationLog trainReadingDurationLog); + + public int updateTrainReadingDurationLog(TrainReadingDurationLog trainReadingDurationLog); + + public int deleteTrainReadingDurationLogById(Long id); + + public int deleteTrainReadingDurationLogByIds(Long[] ids); + + public TrainReadingDurationLog selectByUserRecordId(@Param("userId") Long userId, @Param("recordId") String recordId); + + public long sumDurationSeconds(@Param("userId") Long userId, @Param("mode") String mode); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainUserSessionMapper.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainUserSessionMapper.java new file mode 100644 index 000000000..8ceceffb3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/mapper/TrainUserSessionMapper.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.device.domain.TrainUserSession; + +/** + * 学员业务会话Mapper接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface TrainUserSessionMapper +{ + public TrainUserSession selectTrainUserSessionById(Long id); + + public List selectTrainUserSessionList(TrainUserSession trainUserSession); + + public int insertTrainUserSession(TrainUserSession trainUserSession); + + public int updateTrainUserSession(TrainUserSession trainUserSession); + + public int deleteTrainUserSessionById(Long id); + + public int deleteTrainUserSessionByIds(Long[] ids); + + public TrainUserSession selectActiveSession(@Param("userId") Long userId, @Param("deviceId") String deviceId); + + public TrainUserSession selectLatestSession(@Param("userId") Long userId, @Param("deviceId") String deviceId); + + public int replaceActiveSessions(@Param("userId") Long userId, @Param("replaceTime") Date replaceTime); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainBookDownloadRecordService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainBookDownloadRecordService.java new file mode 100644 index 000000000..926068337 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainBookDownloadRecordService.java @@ -0,0 +1,25 @@ +package com.ruoyi.device.service; + +import java.util.List; +import com.ruoyi.device.domain.TrainBookDownloadRecord; + +/** + * 图书下载回执记录 Service 接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface ITrainBookDownloadRecordService +{ + public TrainBookDownloadRecord selectTrainBookDownloadRecordById(Long id); + + public List selectTrainBookDownloadRecordList(TrainBookDownloadRecord record); + + public int insertTrainBookDownloadRecord(TrainBookDownloadRecord record); + + public int updateTrainBookDownloadRecord(TrainBookDownloadRecord record); + + public int deleteTrainBookDownloadRecordById(Long id); + + public int deleteTrainBookDownloadRecordByIds(Long[] ids); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainBookSnapshotService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainBookSnapshotService.java new file mode 100644 index 000000000..5d4a6dc40 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainBookSnapshotService.java @@ -0,0 +1,29 @@ +package com.ruoyi.device.service; + +import java.util.List; +import com.ruoyi.device.domain.TrainBookSnapshot; + +/** + * 图书快照Service接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface ITrainBookSnapshotService +{ + public TrainBookSnapshot selectTrainBookSnapshotById(Long id); + + public List selectTrainBookSnapshotList(TrainBookSnapshot trainBookSnapshot); + + public int insertTrainBookSnapshot(TrainBookSnapshot trainBookSnapshot); + + public int updateTrainBookSnapshot(TrainBookSnapshot trainBookSnapshot); + + public int deleteTrainBookSnapshotByIds(Long[] ids); + + public int deleteTrainBookSnapshotById(Long id); + + public List selectOnShelfList(String bookCategory); + + public TrainBookSnapshot selectByBookIdAndVersion(String bookId, String bookVersion); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainCheckinTerminalService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainCheckinTerminalService.java new file mode 100644 index 000000000..bafbde39b --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainCheckinTerminalService.java @@ -0,0 +1,27 @@ +package com.ruoyi.device.service; + +import java.util.List; +import com.ruoyi.device.domain.TrainCheckinTerminal; + +/** + * 报到一体机(自助报到终端)Service接口 + * + * @author ruoyi + * @date 2026-10-24 + */ +public interface ITrainCheckinTerminalService +{ + public TrainCheckinTerminal selectTrainCheckinTerminalById(Long id); + + public List selectTrainCheckinTerminalList(TrainCheckinTerminal trainCheckinTerminal); + + public int insertTrainCheckinTerminal(TrainCheckinTerminal trainCheckinTerminal); + + public int updateTrainCheckinTerminal(TrainCheckinTerminal trainCheckinTerminal); + + public int deleteTrainCheckinTerminalByIds(Long[] ids); + + public int deleteTrainCheckinTerminalById(Long id); + + public TrainCheckinTerminal selectTrainCheckinTerminalByTerminalId(String terminalId); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainIotDeviceService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainIotDeviceService.java new file mode 100644 index 000000000..c48f892b4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainIotDeviceService.java @@ -0,0 +1,30 @@ +package com.ruoyi.device.service; + +import java.util.Date; +import java.util.List; +import com.ruoyi.device.domain.TrainIotDevice; + +/** + * 读书机设备档案Service接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface ITrainIotDeviceService +{ + public TrainIotDevice selectTrainIotDeviceById(Long id); + + public List selectTrainIotDeviceList(TrainIotDevice trainIotDevice); + + public int insertTrainIotDevice(TrainIotDevice trainIotDevice); + + public int updateTrainIotDevice(TrainIotDevice trainIotDevice); + + public int deleteTrainIotDeviceByIds(Long[] ids); + + public int deleteTrainIotDeviceById(Long id); + + public TrainIotDevice selectTrainIotDeviceByDeviceId(String deviceId); + + public int updateHeartbeatByDeviceId(String deviceId, Date lastHeartbeatTime, Integer deviceStatus); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingAlternateProgressService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingAlternateProgressService.java new file mode 100644 index 000000000..04e739b2f --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingAlternateProgressService.java @@ -0,0 +1,27 @@ +package com.ruoyi.device.service; + +import java.util.List; +import com.ruoyi.device.domain.TrainReadingAlternateProgress; + +/** + * 备选冲突续读进度Service接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface ITrainReadingAlternateProgressService +{ + public TrainReadingAlternateProgress selectTrainReadingAlternateProgressById(Long id); + + public List selectTrainReadingAlternateProgressList(TrainReadingAlternateProgress trainReadingAlternateProgress); + + public int insertTrainReadingAlternateProgress(TrainReadingAlternateProgress trainReadingAlternateProgress); + + public int updateTrainReadingAlternateProgress(TrainReadingAlternateProgress trainReadingAlternateProgress); + + public int deleteTrainReadingAlternateProgressByIds(Long[] ids); + + public int deleteTrainReadingAlternateProgressById(Long id); + + public List selectListByUserBookMode(Long userId, String bookId, String mode); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingCurrentProgressService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingCurrentProgressService.java new file mode 100644 index 000000000..ba8d6aecf --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingCurrentProgressService.java @@ -0,0 +1,29 @@ +package com.ruoyi.device.service; + +import java.util.List; +import com.ruoyi.device.domain.TrainReadingCurrentProgress; + +/** + * 学员主续读进度Service接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface ITrainReadingCurrentProgressService +{ + public TrainReadingCurrentProgress selectTrainReadingCurrentProgressById(Long id); + + public List selectTrainReadingCurrentProgressList(TrainReadingCurrentProgress trainReadingCurrentProgress); + + public int insertTrainReadingCurrentProgress(TrainReadingCurrentProgress trainReadingCurrentProgress); + + public int updateTrainReadingCurrentProgress(TrainReadingCurrentProgress trainReadingCurrentProgress); + + public int deleteTrainReadingCurrentProgressByIds(Long[] ids); + + public int deleteTrainReadingCurrentProgressById(Long id); + + public TrainReadingCurrentProgress selectByUserBookMode(Long userId, String bookId, String mode); + + public List selectListByUserId(Long userId); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingDurationLogService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingDurationLogService.java new file mode 100644 index 000000000..0a7012114 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainReadingDurationLogService.java @@ -0,0 +1,29 @@ +package com.ruoyi.device.service; + +import java.util.List; +import com.ruoyi.device.domain.TrainReadingDurationLog; + +/** + * 阅读上报幂等日志Service接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface ITrainReadingDurationLogService +{ + public TrainReadingDurationLog selectTrainReadingDurationLogById(Long id); + + public List selectTrainReadingDurationLogList(TrainReadingDurationLog trainReadingDurationLog); + + public int insertTrainReadingDurationLog(TrainReadingDurationLog trainReadingDurationLog); + + public int updateTrainReadingDurationLog(TrainReadingDurationLog trainReadingDurationLog); + + public int deleteTrainReadingDurationLogByIds(Long[] ids); + + public int deleteTrainReadingDurationLogById(Long id); + + public String reportReadingRecord(TrainReadingDurationLog record); + + public long sumDurationSeconds(Long userId, String mode); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainUserSessionService.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainUserSessionService.java new file mode 100644 index 000000000..093114a69 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/ITrainUserSessionService.java @@ -0,0 +1,34 @@ +package com.ruoyi.device.service; + +import java.util.Date; +import java.util.List; +import com.ruoyi.device.domain.TrainUserSession; + +/** + * 学员业务会话Service接口 + * + * @author ruoyi + * @date 2026-10-20 + */ +public interface ITrainUserSessionService +{ + public TrainUserSession selectTrainUserSessionById(Long id); + + public List selectTrainUserSessionList(TrainUserSession trainUserSession); + + public int insertTrainUserSession(TrainUserSession trainUserSession); + + public int updateTrainUserSession(TrainUserSession trainUserSession); + + public int deleteTrainUserSessionByIds(Long[] ids); + + public int deleteTrainUserSessionById(Long id); + + public TrainUserSession selectActiveSession(Long userId, String deviceId); + + public TrainUserSession selectLatestSession(Long userId, String deviceId); + + public String createSession(Long userId, String deviceId, String ruoyiTokenKey, Date loginTime); + + public int logoutSession(Long userId, String deviceId); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainBookDownloadRecordServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainBookDownloadRecordServiceImpl.java new file mode 100644 index 000000000..b354a4a99 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainBookDownloadRecordServiceImpl.java @@ -0,0 +1,57 @@ +package com.ruoyi.device.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainBookDownloadRecord; +import com.ruoyi.device.mapper.TrainBookDownloadRecordMapper; +import com.ruoyi.device.service.ITrainBookDownloadRecordService; + +/** + * 图书下载回执记录 Service 业务层处理 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Service +public class TrainBookDownloadRecordServiceImpl implements ITrainBookDownloadRecordService +{ + @Autowired + private TrainBookDownloadRecordMapper trainBookDownloadRecordMapper; + + @Override + public TrainBookDownloadRecord selectTrainBookDownloadRecordById(Long id) + { + return trainBookDownloadRecordMapper.selectTrainBookDownloadRecordById(id); + } + + @Override + public List selectTrainBookDownloadRecordList(TrainBookDownloadRecord record) + { + return trainBookDownloadRecordMapper.selectTrainBookDownloadRecordList(record); + } + + @Override + public int insertTrainBookDownloadRecord(TrainBookDownloadRecord record) + { + return trainBookDownloadRecordMapper.insertTrainBookDownloadRecord(record); + } + + @Override + public int updateTrainBookDownloadRecord(TrainBookDownloadRecord record) + { + return trainBookDownloadRecordMapper.updateTrainBookDownloadRecord(record); + } + + @Override + public int deleteTrainBookDownloadRecordById(Long id) + { + return trainBookDownloadRecordMapper.deleteTrainBookDownloadRecordById(id); + } + + @Override + public int deleteTrainBookDownloadRecordByIds(Long[] ids) + { + return trainBookDownloadRecordMapper.deleteTrainBookDownloadRecordByIds(ids); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainBookSnapshotServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainBookSnapshotServiceImpl.java new file mode 100644 index 000000000..908972903 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainBookSnapshotServiceImpl.java @@ -0,0 +1,123 @@ +package com.ruoyi.device.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainBookSnapshot; +import com.ruoyi.device.mapper.TrainBookSnapshotMapper; +import com.ruoyi.device.service.ITrainBookSnapshotService; +import java.io.FileInputStream; +import java.security.MessageDigest; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.config.RuoYiConfig; + +/** + * 图书快照Service业务层处理 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Service +public class TrainBookSnapshotServiceImpl implements ITrainBookSnapshotService +{ + @Autowired + private TrainBookSnapshotMapper trainBookSnapshotMapper; + + @Override + public TrainBookSnapshot selectTrainBookSnapshotById(Long id) + { + return trainBookSnapshotMapper.selectTrainBookSnapshotById(id); + } + + @Override + public List selectTrainBookSnapshotList(TrainBookSnapshot trainBookSnapshot) + { + return trainBookSnapshotMapper.selectTrainBookSnapshotList(trainBookSnapshot); + } + + @Override +public int insertTrainBookSnapshot(TrainBookSnapshot trainBookSnapshot) + { + String md5 = ComputeMd5(trainBookSnapshot.getDownloadUrl()); + if (md5 != null) + { + trainBookSnapshot.setFileMd5(md5); + } + return trainBookSnapshotMapper.insertTrainBookSnapshot(trainBookSnapshot); + } + + @Override +public int updateTrainBookSnapshot(TrainBookSnapshot trainBookSnapshot) + { + String md5 = ComputeMd5(trainBookSnapshot.getDownloadUrl()); + //System.out.println("md5 = " + md5); + if (md5 != null) + { + trainBookSnapshot.setFileMd5(md5); + } + return trainBookSnapshotMapper.updateTrainBookSnapshot(trainBookSnapshot); + } + + @Override +public int deleteTrainBookSnapshotByIds(Long[] ids) + { + return trainBookSnapshotMapper.deleteTrainBookSnapshotByIds(ids); + } + + @Override + public int deleteTrainBookSnapshotById(Long id) + { + return trainBookSnapshotMapper.deleteTrainBookSnapshotById(id); + } + + @Override + public List selectOnShelfList(String bookCategory) + { + return trainBookSnapshotMapper.selectOnShelfList(bookCategory); + } + + @Override + public TrainBookSnapshot selectByBookIdAndVersion(String bookId, String bookVersion) + { + return trainBookSnapshotMapper.selectByBookIdAndVersion(bookId, bookVersion); + } + + /** + * 计算文件完整二进制 MD5(32 位小写十六进制)。 + * filePath 形如 /profile/upload/...(downloadUrl),内部解析为服务器本地绝对路径后, + * 以二进制方式分块读取(每块 65536 字节)送入同一 MD5 计算器,不解析文件内容。 + */ + private String ComputeMd5(String filePath) + { + if (filePath == null || filePath.isEmpty()) + { + return null; + } + String realPath = filePath; + if (filePath.startsWith(Constants.RESOURCE_PREFIX)) + { + realPath = RuoYiConfig.getProfile() + filePath.substring(Constants.RESOURCE_PREFIX.length()); + } + try (FileInputStream fis = new FileInputStream(realPath)) + { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] buffer = new byte[65536]; + int len; + while ((len = fis.read(buffer)) != -1) + { + md.update(buffer, 0, len); + } + byte[] digest = md.digest(); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) + { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } + catch (Exception e) + { + return null; + } + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainCheckinTerminalServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainCheckinTerminalServiceImpl.java new file mode 100644 index 000000000..0ac9a0d21 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainCheckinTerminalServiceImpl.java @@ -0,0 +1,63 @@ +package com.ruoyi.device.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainCheckinTerminal; +import com.ruoyi.device.mapper.TrainCheckinTerminalMapper; +import com.ruoyi.device.service.ITrainCheckinTerminalService; + +/** + * 报到一体机(自助报到终端)Service业务层处理 + * + * @author ruoyi + * @date 2026-10-24 + */ +@Service +public class TrainCheckinTerminalServiceImpl implements ITrainCheckinTerminalService +{ + @Autowired + private TrainCheckinTerminalMapper trainCheckinTerminalMapper; + + @Override + public TrainCheckinTerminal selectTrainCheckinTerminalById(Long id) + { + return trainCheckinTerminalMapper.selectTrainCheckinTerminalById(id); + } + + @Override + public List selectTrainCheckinTerminalList(TrainCheckinTerminal trainCheckinTerminal) + { + return trainCheckinTerminalMapper.selectTrainCheckinTerminalList(trainCheckinTerminal); + } + + @Override + public int insertTrainCheckinTerminal(TrainCheckinTerminal trainCheckinTerminal) + { + return trainCheckinTerminalMapper.insertTrainCheckinTerminal(trainCheckinTerminal); + } + + @Override + public int updateTrainCheckinTerminal(TrainCheckinTerminal trainCheckinTerminal) + { + return trainCheckinTerminalMapper.updateTrainCheckinTerminal(trainCheckinTerminal); + } + + @Override + public int deleteTrainCheckinTerminalByIds(Long[] ids) + { + return trainCheckinTerminalMapper.deleteTrainCheckinTerminalByIds(ids); + } + + @Override + public int deleteTrainCheckinTerminalById(Long id) + { + return trainCheckinTerminalMapper.deleteTrainCheckinTerminalById(id); + } + + @Override + public TrainCheckinTerminal selectTrainCheckinTerminalByTerminalId(String terminalId) + { + return trainCheckinTerminalMapper.selectTrainCheckinTerminalByTerminalId(terminalId); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainIotDeviceServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainIotDeviceServiceImpl.java new file mode 100644 index 000000000..953f1c2de --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainIotDeviceServiceImpl.java @@ -0,0 +1,70 @@ +package com.ruoyi.device.service.impl; + +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainIotDevice; +import com.ruoyi.device.mapper.TrainIotDeviceMapper; +import com.ruoyi.device.service.ITrainIotDeviceService; + +/** + * 读书机设备档案Service业务层处理 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Service +public class TrainIotDeviceServiceImpl implements ITrainIotDeviceService +{ + @Autowired + private TrainIotDeviceMapper trainIotDeviceMapper; + + @Override + public TrainIotDevice selectTrainIotDeviceById(Long id) + { + return trainIotDeviceMapper.selectTrainIotDeviceById(id); + } + + @Override + public List selectTrainIotDeviceList(TrainIotDevice trainIotDevice) + { + return trainIotDeviceMapper.selectTrainIotDeviceList(trainIotDevice); + } + + @Override +public int insertTrainIotDevice(TrainIotDevice trainIotDevice) + { + return trainIotDeviceMapper.insertTrainIotDevice(trainIotDevice); + } + + @Override +public int updateTrainIotDevice(TrainIotDevice trainIotDevice) + { + return trainIotDeviceMapper.updateTrainIotDevice(trainIotDevice); + } + + @Override +public int deleteTrainIotDeviceByIds(Long[] ids) + { + return trainIotDeviceMapper.deleteTrainIotDeviceByIds(ids); + } + + @Override + public int deleteTrainIotDeviceById(Long id) + { + return trainIotDeviceMapper.deleteTrainIotDeviceById(id); + } + + @Override + public TrainIotDevice selectTrainIotDeviceByDeviceId(String deviceId) + { + return trainIotDeviceMapper.selectTrainIotDeviceByDeviceId(deviceId); + } + + @Override + public int updateHeartbeatByDeviceId(String deviceId, Date lastHeartbeatTime, Integer deviceStatus) + { + return trainIotDeviceMapper.updateHeartbeatByDeviceId(deviceId, lastHeartbeatTime, deviceStatus); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingAlternateProgressServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingAlternateProgressServiceImpl.java new file mode 100644 index 000000000..838d501bb --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingAlternateProgressServiceImpl.java @@ -0,0 +1,63 @@ +package com.ruoyi.device.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainReadingAlternateProgress; +import com.ruoyi.device.mapper.TrainReadingAlternateProgressMapper; +import com.ruoyi.device.service.ITrainReadingAlternateProgressService; + +/** + * 备选冲突续读进度Service业务层处理 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Service +public class TrainReadingAlternateProgressServiceImpl implements ITrainReadingAlternateProgressService +{ + @Autowired + private TrainReadingAlternateProgressMapper trainReadingAlternateProgressMapper; + + @Override + public TrainReadingAlternateProgress selectTrainReadingAlternateProgressById(Long id) + { + return trainReadingAlternateProgressMapper.selectTrainReadingAlternateProgressById(id); + } + + @Override + public List selectTrainReadingAlternateProgressList(TrainReadingAlternateProgress trainReadingAlternateProgress) + { + return trainReadingAlternateProgressMapper.selectTrainReadingAlternateProgressList(trainReadingAlternateProgress); + } + + @Override +public int insertTrainReadingAlternateProgress(TrainReadingAlternateProgress trainReadingAlternateProgress) + { + return trainReadingAlternateProgressMapper.insertTrainReadingAlternateProgress(trainReadingAlternateProgress); + } + + @Override +public int updateTrainReadingAlternateProgress(TrainReadingAlternateProgress trainReadingAlternateProgress) + { + return trainReadingAlternateProgressMapper.updateTrainReadingAlternateProgress(trainReadingAlternateProgress); + } + + @Override +public int deleteTrainReadingAlternateProgressByIds(Long[] ids) + { + return trainReadingAlternateProgressMapper.deleteTrainReadingAlternateProgressByIds(ids); + } + + @Override + public int deleteTrainReadingAlternateProgressById(Long id) + { + return trainReadingAlternateProgressMapper.deleteTrainReadingAlternateProgressById(id); + } + + @Override + public List selectListByUserBookMode(Long userId, String bookId, String mode) + { + return trainReadingAlternateProgressMapper.selectListByUserBookMode(userId, bookId, mode); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingCurrentProgressServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingCurrentProgressServiceImpl.java new file mode 100644 index 000000000..88278fee8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingCurrentProgressServiceImpl.java @@ -0,0 +1,69 @@ +package com.ruoyi.device.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainReadingCurrentProgress; +import com.ruoyi.device.mapper.TrainReadingCurrentProgressMapper; +import com.ruoyi.device.service.ITrainReadingCurrentProgressService; + +/** + * 学员主续读进度Service业务层处理 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Service +public class TrainReadingCurrentProgressServiceImpl implements ITrainReadingCurrentProgressService +{ + @Autowired + private TrainReadingCurrentProgressMapper trainReadingCurrentProgressMapper; + + @Override + public TrainReadingCurrentProgress selectTrainReadingCurrentProgressById(Long id) + { + return trainReadingCurrentProgressMapper.selectTrainReadingCurrentProgressById(id); + } + + @Override + public List selectTrainReadingCurrentProgressList(TrainReadingCurrentProgress trainReadingCurrentProgress) + { + return trainReadingCurrentProgressMapper.selectTrainReadingCurrentProgressList(trainReadingCurrentProgress); + } + + @Override +public int insertTrainReadingCurrentProgress(TrainReadingCurrentProgress trainReadingCurrentProgress) + { + return trainReadingCurrentProgressMapper.insertTrainReadingCurrentProgress(trainReadingCurrentProgress); + } + + @Override +public int updateTrainReadingCurrentProgress(TrainReadingCurrentProgress trainReadingCurrentProgress) + { + return trainReadingCurrentProgressMapper.updateTrainReadingCurrentProgress(trainReadingCurrentProgress); + } + + @Override +public int deleteTrainReadingCurrentProgressByIds(Long[] ids) + { + return trainReadingCurrentProgressMapper.deleteTrainReadingCurrentProgressByIds(ids); + } + + @Override + public int deleteTrainReadingCurrentProgressById(Long id) + { + return trainReadingCurrentProgressMapper.deleteTrainReadingCurrentProgressById(id); + } + + @Override + public TrainReadingCurrentProgress selectByUserBookMode(Long userId, String bookId, String mode) + { + return trainReadingCurrentProgressMapper.selectByUserBookMode(userId, bookId, mode); + } + + @Override + public List selectListByUserId(Long userId) + { + return trainReadingCurrentProgressMapper.selectListByUserId(userId); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingDurationLogServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingDurationLogServiceImpl.java new file mode 100644 index 000000000..35614e6b7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainReadingDurationLogServiceImpl.java @@ -0,0 +1,144 @@ +package com.ruoyi.device.service.impl; + +import java.util.Date; +import java.util.List; + +import com.ruoyi.device.domain.TrainUserSession; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainReadingAlternateProgress; +import com.ruoyi.device.domain.TrainReadingCurrentProgress; +import com.ruoyi.device.domain.TrainReadingDurationLog; +import com.ruoyi.device.mapper.TrainReadingAlternateProgressMapper; +import com.ruoyi.device.mapper.TrainReadingCurrentProgressMapper; +import com.ruoyi.device.mapper.TrainReadingDurationLogMapper; +import com.ruoyi.device.mapper.TrainUserSessionMapper; +import com.ruoyi.device.service.ITrainReadingDurationLogService; + +/** + * 阅读上报幂等日志Service业务层处理 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Service +public class TrainReadingDurationLogServiceImpl implements ITrainReadingDurationLogService +{ + @Autowired + private TrainReadingDurationLogMapper trainReadingDurationLogMapper; + + @Autowired + private TrainReadingCurrentProgressMapper trainReadingCurrentProgressMapper; + + @Autowired + private TrainReadingAlternateProgressMapper trainReadingAlternateProgressMapper; + + @Autowired + private TrainUserSessionMapper trainUserSessionMapper; + + @Override + public TrainReadingDurationLog selectTrainReadingDurationLogById(Long id) + { + return trainReadingDurationLogMapper.selectTrainReadingDurationLogById(id); + } + + @Override + public List selectTrainReadingDurationLogList(TrainReadingDurationLog trainReadingDurationLog) + { + return trainReadingDurationLogMapper.selectTrainReadingDurationLogList(trainReadingDurationLog); + } + + @Override +public int insertTrainReadingDurationLog(TrainReadingDurationLog trainReadingDurationLog) + { + return trainReadingDurationLogMapper.insertTrainReadingDurationLog(trainReadingDurationLog); + } + + @Override +public int updateTrainReadingDurationLog(TrainReadingDurationLog trainReadingDurationLog) + { + return trainReadingDurationLogMapper.updateTrainReadingDurationLog(trainReadingDurationLog); + } + + @Override +public int deleteTrainReadingDurationLogByIds(Long[] ids) + { + return trainReadingDurationLogMapper.deleteTrainReadingDurationLogByIds(ids); + } + + @Override + public int deleteTrainReadingDurationLogById(Long id) + { + return trainReadingDurationLogMapper.deleteTrainReadingDurationLogById(id); + } + + @Override + public String reportReadingRecord(TrainReadingDurationLog record) + { + Long userId = record.getUserId(); + String deviceId = record.getDeviceId(); + TrainUserSession session = trainUserSessionMapper.selectLatestSession(userId, deviceId); + if (session == null || session.getSessionStatus() == 3) + { + // 无会话或已退出:拒绝并记录 + record.setResultCode("REJECTED"); + trainReadingDurationLogMapper.insertTrainReadingDurationLog(record); + return "REJECTED"; + } + TrainReadingDurationLog exist = trainReadingDurationLogMapper.selectByUserRecordId(userId, record.getRecordId()); + if (exist != null) + { + // 幂等:重复 recordId 直接返回 DUPLICATE,不重复累计 + return "DUPLICATE"; + } + boolean active = session.getSessionStatus() == 1; + record.setResultCode(active ? "ACCEPTED" : "CONFLICT_SAVED"); + record.setReceiveTime(new Date()); + trainReadingDurationLogMapper.insertTrainReadingDurationLog(record); + int pageNo = record.getPageTo() != null ? record.getPageTo() : (record.getPageFrom() != null ? record.getPageFrom() : 0); + if (active) + { + // ACTIVE 会话:更新主续读进度 + TrainReadingCurrentProgress cp = trainReadingCurrentProgressMapper.selectByUserBookMode(userId, record.getBookId(), record.getMode()); + TrainReadingCurrentProgress prog = new TrainReadingCurrentProgress(); + prog.setUserId(userId); + prog.setBookId(record.getBookId()); + prog.setMode(record.getMode()); + prog.setBookVersion(record.getBookVersion()); + prog.setPageNo(pageNo); + prog.setProgress(record.getProgress()); + prog.setSourceRecordId(record.getRecordId()); + prog.setSourceSessionId(record.getSessionId()); + if (cp != null) + { + prog.setId(cp.getId()); + trainReadingCurrentProgressMapper.updateTrainReadingCurrentProgress(prog); + } + else + { + trainReadingCurrentProgressMapper.insertTrainReadingCurrentProgress(prog); + } + } + else + { + // REPLACED 会话:写入备选冲突进度,不覆盖主进度 + TrainReadingAlternateProgress alt = new TrainReadingAlternateProgress(); + alt.setUserId(userId); + alt.setBookId(record.getBookId()); + alt.setMode(record.getMode()); + alt.setBookVersion(record.getBookVersion()); + alt.setPageNo(pageNo); + alt.setProgress(record.getProgress()); + alt.setSourceRecordId(record.getRecordId()); + alt.setSourceSessionId(record.getSessionId()); + trainReadingAlternateProgressMapper.insertTrainReadingAlternateProgress(alt); + } + return record.getResultCode(); + } + + @Override + public long sumDurationSeconds(Long userId, String mode) + { + return trainReadingDurationLogMapper.sumDurationSeconds(userId, mode); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainUserSessionServiceImpl.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainUserSessionServiceImpl.java new file mode 100644 index 000000000..58bc466c5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/service/impl/TrainUserSessionServiceImpl.java @@ -0,0 +1,100 @@ +package com.ruoyi.device.service.impl; + +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.device.domain.TrainUserSession; +import com.ruoyi.device.mapper.TrainUserSessionMapper; +import com.ruoyi.device.service.ITrainUserSessionService; + +/** + * 学员业务会话Service业务层处理 + * + * @author ruoyi + * @date 2026-10-20 + */ +@Service +public class TrainUserSessionServiceImpl implements ITrainUserSessionService +{ + @Autowired + private TrainUserSessionMapper trainUserSessionMapper; + + @Override + public TrainUserSession selectTrainUserSessionById(Long id) + { + return trainUserSessionMapper.selectTrainUserSessionById(id); + } + + @Override + public List selectTrainUserSessionList(TrainUserSession trainUserSession) + { + return trainUserSessionMapper.selectTrainUserSessionList(trainUserSession); + } + + @Override +public int insertTrainUserSession(TrainUserSession trainUserSession) + { + return trainUserSessionMapper.insertTrainUserSession(trainUserSession); + } + + @Override +public int updateTrainUserSession(TrainUserSession trainUserSession) + { + return trainUserSessionMapper.updateTrainUserSession(trainUserSession); + } + + @Override +public int deleteTrainUserSessionByIds(Long[] ids) + { + return trainUserSessionMapper.deleteTrainUserSessionByIds(ids); + } + + @Override + public int deleteTrainUserSessionById(Long id) + { + return trainUserSessionMapper.deleteTrainUserSessionById(id); + } + + @Override + public TrainUserSession selectActiveSession(Long userId, String deviceId) + { + return trainUserSessionMapper.selectActiveSession(userId, deviceId); + } + + @Override + public TrainUserSession selectLatestSession(Long userId, String deviceId) + { + return trainUserSessionMapper.selectLatestSession(userId, deviceId); + } + + @Override + public String createSession(Long userId, String deviceId, String ruoyiTokenKey, Date loginTime) + { + trainUserSessionMapper.replaceActiveSessions(userId, new Date()); + String sessionId = UUID.randomUUID().toString().replace("-", ""); + TrainUserSession session = new TrainUserSession(); + session.setUserId(userId); + session.setDeviceId(deviceId); + session.setSessionId(sessionId); + session.setSessionStatus(1); + session.setLoginTime(loginTime); + session.setRuoyiTokenKey(ruoyiTokenKey); + trainUserSessionMapper.insertTrainUserSession(session); + return sessionId; + } + + @Override + public int logoutSession(Long userId, String deviceId) + { + TrainUserSession session = trainUserSessionMapper.selectActiveSession(userId, deviceId); + if (session == null) + { + return 0; + } + session.setSessionStatus(3); + session.setLogoutTime(new Date()); + return trainUserSessionMapper.updateTrainUserSession(session); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/util/ToStringBuilder.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/util/ToStringBuilder.java new file mode 100644 index 000000000..2c5dd4cdc --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/util/ToStringBuilder.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.util; + +/** + * 本地 ToStringBuilder,替代 org.apache.commons.lang3.builder.ToStringBuilder, + * 提供相同的流式 append(String, Object) 与 toString() API,使 device 模块不依赖 commons-lang3 即可编译。 + */ +public class ToStringBuilder +{ + private final StringBuilder sb = new StringBuilder(); + private boolean first = true; + + public ToStringBuilder(Object object, ToStringStyle style) + { + sb.append(object == null ? "Object" : object.getClass().getSimpleName()).append(" ["); + } + + public ToStringBuilder append(String name, Object value) + { + if (!first) + { + sb.append(", "); + } + first = false; + sb.append(name).append("=").append(value); + return this; + } + + @Override + public String toString() + { + return sb.append("]").toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/util/ToStringStyle.java b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/util/ToStringStyle.java new file mode 100644 index 000000000..5b168bfe5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/java/com/ruoyi/device/util/ToStringStyle.java @@ -0,0 +1,13 @@ +package com.ruoyi.device.util; + +/** + * 本地 ToStringStyle,替代 org.apache.commons.lang3.builder.ToStringStyle, + * 用于使 device 模块不依赖 commons-lang3 即可编译。 + */ +public class ToStringStyle +{ + public static final ToStringStyle MULTI_LINE_STYLE = new ToStringStyle(); + public static final ToStringStyle DEFAULT_STYLE = new ToStringStyle(); + public static final ToStringStyle SHORT_PREFIX_STYLE = new ToStringStyle(); + public static final ToStringStyle SIMPLE_STYLE = new ToStringStyle(); +} diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainBookDownloadRecordMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainBookDownloadRecordMapper.xml new file mode 100644 index 000000000..47de940b6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainBookDownloadRecordMapper.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + select id, user_id, device_id, book_id, book_version, file_md5, result, err_msg, download_time, create_time, update_time from train_book_download_record + + + + + + + + insert into train_book_download_record + + id, + user_id, + device_id, + book_id, + book_version, + file_md5, + result, + err_msg, + download_time, + create_time, + update_time, + + + #{id}, + #{userId}, + #{deviceId}, + #{bookId}, + #{bookVersion}, + #{fileMd5}, + #{result}, + #{errMsg}, + #{downloadTime}, + #{createTime}, + #{updateTime}, + + + + + update train_book_download_record + + user_id = #{userId}, + device_id = #{deviceId}, + book_id = #{bookId}, + book_version = #{bookVersion}, + file_md5 = #{fileMd5}, + result = #{result}, + err_msg = #{errMsg}, + download_time = #{downloadTime}, + update_time = #{updateTime}, + + where id = #{id} + + + + delete from train_book_download_record where id = #{id} + + + + delete from train_book_download_record where id in + + #{id} + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainBookSnapshotMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainBookSnapshotMapper.xml new file mode 100644 index 000000000..7fa6448fe --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainBookSnapshotMapper.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + select id, book_id, book_title, book_category, book_summary, book_version, book_cover, file_size, file_md5, download_url, is_on_shelf, publish_time, create_time, update_time from train_book_snapshot + + + + + + + + insert into train_book_snapshot + + id, + book_id, + book_title, + book_category, + book_summary, + book_version, + book_cover, + file_size, + file_md5, + download_url, + is_on_shelf, + publish_time, + create_time, + update_time, + + + #{id}, + #{bookId}, + #{bookTitle}, + #{bookCategory}, + #{bookSummary}, + #{bookVersion}, + #{bookCover}, + #{fileSize}, + #{fileMd5}, + #{downloadUrl}, + #{isOnShelf}, + #{publishTime}, + #{createTime}, + #{updateTime}, + + + + + update train_book_snapshot + + book_id = #{bookId}, + book_title = #{bookTitle}, + book_category = #{bookCategory}, + book_summary = #{bookSummary}, + book_version = #{bookVersion}, + book_cover = #{bookCover}, + file_size = #{fileSize}, + file_md5 = #{fileMd5}, + download_url = #{downloadUrl}, + is_on_shelf = #{isOnShelf}, + publish_time = #{publishTime}, + update_time = #{updateTime}, + + where id = #{id} + + + + delete from train_book_snapshot where id = #{id} + + + + delete from train_book_snapshot where id in + + #{id} + + + + + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainCheckinTerminalMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainCheckinTerminalMapper.xml new file mode 100644 index 000000000..9bbca9fd2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainCheckinTerminalMapper.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, terminal_id, terminal_sn, terminal_name, bind_dept_id, install_location, + support_checkin_type, support_modules, support_print, support_broadcast, + contact_phone, welcome_title, org_name, screen_orientation, idle_return_seconds, + help_text, terminal_ip, api_endpoint, terminal_status, last_heartbeat, + del_flag, create_by, create_time, update_by, update_time, remark + from train_checkin_terminal + + + + + + + + + + insert into train_checkin_terminal + + terminal_id, + terminal_sn, + terminal_name, + bind_dept_id, + install_location, + support_checkin_type, + support_modules, + support_print, + support_broadcast, + contact_phone, + welcome_title, + org_name, + screen_orientation, + idle_return_seconds, + help_text, + terminal_ip, + api_endpoint, + terminal_status, + last_heartbeat, + del_flag, + create_by, + create_time, + update_by, + update_time, + remark, + + + #{terminalId}, + #{terminalSn}, + #{terminalName}, + #{bindDeptId}, + #{installLocation}, + #{supportCheckinType}, + #{supportModules}, + #{supportPrint}, + #{supportBroadcast}, + #{contactPhone}, + #{welcomeTitle}, + #{orgName}, + #{screenOrientation}, + #{idleReturnSeconds}, + #{helpText}, + #{terminalIp}, + #{apiEndpoint}, + #{terminalStatus}, + #{lastHeartbeat}, + '0', + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, + #{remark}, + + + + + update train_checkin_terminal + + terminal_id = #{terminalId}, + terminal_sn = #{terminalSn}, + terminal_name = #{terminalName}, + bind_dept_id = #{bindDeptId}, + install_location = #{installLocation}, + support_checkin_type = #{supportCheckinType}, + support_modules = #{supportModules}, + support_print = #{supportPrint}, + support_broadcast = #{supportBroadcast}, + contact_phone = #{contactPhone}, + welcome_title = #{welcomeTitle}, + org_name = #{orgName}, + screen_orientation = #{screenOrientation}, + idle_return_seconds = #{idleReturnSeconds}, + help_text = #{helpText}, + terminal_ip = #{terminalIp}, + api_endpoint = #{apiEndpoint}, + terminal_status = #{terminalStatus}, + last_heartbeat = #{lastHeartbeat}, + update_by = #{updateBy}, + update_time = #{updateTime}, + remark = #{remark}, + + where id = #{id} and del_flag = '0' + + + + update train_checkin_terminal + set del_flag = '2', update_time = CURRENT_TIMESTAMP + where id = #{id} and del_flag = '0' + + + + update train_checkin_terminal + set del_flag = '2', update_time = CURRENT_TIMESTAMP + where id in + + #{id} + + and del_flag = '0' + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainIotDeviceMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainIotDeviceMapper.xml new file mode 100644 index 000000000..29c8b3cda --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainIotDeviceMapper.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + select id, device_id, device_secret_digest, device_status, firmware_version, app_version, bind_org_id, bind_class_id, heartbeat_interval, last_login_time, last_heartbeat_time, create_time, update_time from train_iot_device + + + + + + + + insert into train_iot_device + + id, + device_id, + device_secret_digest, + device_status, + firmware_version, + app_version, + bind_org_id, + bind_class_id, + heartbeat_interval, + last_login_time, + last_heartbeat_time, + create_time, + update_time, + + + #{id}, + #{deviceId}, + #{deviceSecretDigest}, + #{deviceStatus}, + #{firmwareVersion}, + #{appVersion}, + #{bindOrgId}, + #{bindClassId}, + #{heartbeatInterval}, + #{lastLoginTime}, + #{lastHeartbeatTime}, + #{createTime}, + #{updateTime}, + + + + + update train_iot_device + + device_id = #{deviceId}, + device_secret_digest = #{deviceSecretDigest}, + device_status = #{deviceStatus}, + firmware_version = #{firmwareVersion}, + app_version = #{appVersion}, + bind_org_id = #{bindOrgId}, + bind_class_id = #{bindClassId}, + heartbeat_interval = #{heartbeatInterval}, + last_login_time = #{lastLoginTime}, + last_heartbeat_time = #{lastHeartbeatTime}, + update_time = #{updateTime}, + + where id = #{id} + + + + delete from train_iot_device where id = #{id} + + + + delete from train_iot_device where id in + + #{id} + + + + + + update train_iot_device + + last_heartbeat_time = #{lastHeartbeatTime}, + device_status = #{deviceStatus}, + update_time = #{lastHeartbeatTime} + + where device_id = #{deviceId} + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingAlternateProgressMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingAlternateProgressMapper.xml new file mode 100644 index 000000000..f43bdcdb1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingAlternateProgressMapper.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + select id, user_id, book_id, mode, book_version, page_no, sentence_index, progress, source_record_id, source_session_id, create_time from train_reading_alternate_progress + + + + + + + + insert into train_reading_alternate_progress + + id, + user_id, + book_id, + mode, + book_version, + page_no, + sentence_index, + progress, + source_record_id, + source_session_id, + create_time, + + + #{id}, + #{userId}, + #{bookId}, + #{mode}, + #{bookVersion}, + #{pageNo}, + #{sentenceIndex}, + #{progress}, + #{sourceRecordId}, + #{sourceSessionId}, + #{createTime}, + + + + + update train_reading_alternate_progress + + user_id = #{userId}, + book_id = #{bookId}, + mode = #{mode}, + book_version = #{bookVersion}, + page_no = #{pageNo}, + sentence_index = #{sentenceIndex}, + progress = #{progress}, + source_record_id = #{sourceRecordId}, + source_session_id = #{sourceSessionId}, + + where id = #{id} + + + + delete from train_reading_alternate_progress where id = #{id} + + + + delete from train_reading_alternate_progress where id in + + #{id} + + + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingCurrentProgressMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingCurrentProgressMapper.xml new file mode 100644 index 000000000..488e2abf3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingCurrentProgressMapper.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + select id, user_id, book_id, mode, book_version, page_no, sentence_index, progress, source_record_id, source_session_id, update_time from train_reading_current_progress + + + + + + + + insert into train_reading_current_progress + + id, + user_id, + book_id, + mode, + book_version, + page_no, + sentence_index, + progress, + source_record_id, + source_session_id, + update_time, + + + #{id}, + #{userId}, + #{bookId}, + #{mode}, + #{bookVersion}, + #{pageNo}, + #{sentenceIndex}, + #{progress}, + #{sourceRecordId}, + #{sourceSessionId}, + #{updateTime}, + + + + + update train_reading_current_progress + + user_id = #{userId}, + book_id = #{bookId}, + mode = #{mode}, + book_version = #{bookVersion}, + page_no = #{pageNo}, + sentence_index = #{sentenceIndex}, + progress = #{progress}, + source_record_id = #{sourceRecordId}, + source_session_id = #{sourceSessionId}, + update_time = #{updateTime}, + + where id = #{id} + + + + delete from train_reading_current_progress where id = #{id} + + + + delete from train_reading_current_progress where id in + + #{id} + + + + + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingDurationLogMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingDurationLogMapper.xml new file mode 100644 index 000000000..098ec1bb5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainReadingDurationLogMapper.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, device_id, user_id, session_id, record_id, book_id, book_version, mode, record_type, duration_sec, read_sec, page_from, page_to, progress, start_time, end_time, receive_time, result_code, remark from train_reading_duration_log + + + + + + + + insert into train_reading_duration_log + + id, + device_id, + user_id, + session_id, + record_id, + book_id, + book_version, + mode, + record_type, + duration_sec, + read_sec, + page_from, + page_to, + progress, + start_time, + end_time, + receive_time, + result_code, + remark, + + + #{id}, + #{deviceId}, + #{userId}, + #{sessionId}, + #{recordId}, + #{bookId}, + #{bookVersion}, + #{mode}, + #{recordType}, + #{durationSec}, + #{readSec}, + #{pageFrom}, + #{pageTo}, + #{progress}, + #{startTime}, + #{endTime}, + #{receiveTime}, + #{resultCode}, + #{remark}, + + + + + update train_reading_duration_log + + device_id = #{deviceId}, + user_id = #{userId}, + session_id = #{sessionId}, + record_id = #{recordId}, + book_id = #{bookId}, + book_version = #{bookVersion}, + mode = #{mode}, + record_type = #{recordType}, + duration_sec = #{durationSec}, + read_sec = #{readSec}, + page_from = #{pageFrom}, + page_to = #{pageTo}, + progress = #{progress}, + start_time = #{startTime}, + end_time = #{endTime}, + receive_time = #{receiveTime}, + result_code = #{resultCode}, + remark = #{remark}, + + where id = #{id} + + + + delete from train_reading_duration_log where id = #{id} + + + + delete from train_reading_duration_log where id in + + #{id} + + + + + + + diff --git a/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainUserSessionMapper.xml b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainUserSessionMapper.xml new file mode 100644 index 000000000..7091a8e91 --- /dev/null +++ b/RuoYi-Vue/ruoyi-device/src/main/resources/mapper/device/TrainUserSessionMapper.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + select id, user_id, session_id, device_id, session_status, login_time, replace_time, logout_time, ruoyi_token_key from train_user_session + + + + + + + + insert into train_user_session + + id, + user_id, + session_id, + device_id, + session_status, + login_time, + replace_time, + logout_time, + ruoyi_token_key, + + + #{id}, + #{userId}, + #{sessionId}, + #{deviceId}, + #{sessionStatus}, + #{loginTime}, + #{replaceTime}, + #{logoutTime}, + #{ruoyiTokenKey}, + + + + + update train_user_session + + user_id = #{userId}, + session_id = #{sessionId}, + device_id = #{deviceId}, + session_status = #{sessionStatus}, + login_time = #{loginTime}, + replace_time = #{replaceTime}, + logout_time = #{logoutTime}, + ruoyi_token_key = #{ruoyiTokenKey}, + + where id = #{id} + + + + delete from train_user_session where id = #{id} + + + + delete from train_user_session where id in + + #{id} + + + + + + + + update train_user_session set session_status = 2, replace_time = #{replaceTime} + where user_id = #{userId} and session_status = 1 + + + diff --git a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index 4dd975dfe..ef7a166a0 100644 --- a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -101,6 +101,16 @@ public class SecurityConfig permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll()); // 对于登录login 注册register 验证码captchaImage 允许匿名访问 requests.requestMatchers("/login", "/register", "/captchaImage").permitAll() + // 阅读机/听书机设备登录接口免鉴权(其余设备接口仍走 Bearer 鉴权) + .requestMatchers("/api/iot/v1/device/login").permitAll() + // 书籍列表/下载为设备公开接口(方法体不依赖登录用户),免鉴权便于 Swagger 直接测试 + .requestMatchers("/api/iot/v1/book/list", "/api/iot/v1/book/download").permitAll() + // 阅读机、考勤机设备列表接口免鉴权 + .requestMatchers("/api/iot/v1/devicelist").permitAll() + .requestMatchers("/api/register/v1/**").permitAll() + .requestMatchers("/api/iot/v1/**").permitAll() + .requestMatchers("/api/lock/v1/**").permitAll() + .requestMatchers("/api/board/v1/**").permitAll() // 静态资源,可匿名访问 .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll() .requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll() diff --git a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java index dd73c6619..627a52a56 100644 --- a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java +++ b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java @@ -63,7 +63,7 @@ public class SysLoginService public String login(String username, String password, String code, String uuid) { // 验证码校验 - validateCaptcha(username, code, uuid); + //validateCaptcha(username, code, uuid); // 登录前置校验 loginPreCheck(username, password); // 用户验证 diff --git a/RuoYi-Vue/ruoyi-iot/pom.xml b/RuoYi-Vue/ruoyi-iot/pom.xml index e78b73a1c..a7e0a016c 100644 --- a/RuoYi-Vue/ruoyi-iot/pom.xml +++ b/RuoYi-Vue/ruoyi-iot/pom.xml @@ -44,6 +44,10 @@ 4.13.2 test + diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java index 00645102f..0fe60aab7 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java @@ -18,9 +18,9 @@ public class FaceExtractProperties { /** 特征提取服务地址(未配置 application.yml 时的兜底值) */ private String endpoint = ""; - private boolean mock = true; - private boolean enabled = false; - private boolean realHttpEnabled = false; + private boolean mock = false; + private boolean enabled = true; + private boolean realHttpEnabled = true; private String environmentId = "local-mock"; private java.util.List allowedEndpoints = new java.util.ArrayList<>(); public boolean isMock() { return mock; } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java index 76e76d5a4..a82914b38 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java @@ -22,7 +22,7 @@ public class IotMqttProperties implements Serializable private boolean mock = false; /** MQTT服务器地址,如 tcp://127.0.0.1:1883、ssl://host:8883 */ - private String brokerUrl = "tcp://127.0.0.1:1883"; + private String brokerUrl = "tcp://192.168.1.202:1883"; /** 客户端ID,同一服务器下必须唯一;为空时自动生成 */ private String clientId; diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java index 3c91de4cf..87369ee35 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java @@ -4,14 +4,7 @@ import java.util.List; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; -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 org.springframework.web.bind.annotation.*; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; @@ -85,8 +78,21 @@ public class LockAuthController extends BaseController @PostMapping("/{authId}/push") public AjaxResult push(@PathVariable Long authId) { - lockAuthService.push(authId); - return success(); + try + { + lockAuthService.push(authId); + return success(); + } + catch (IllegalStateException e) + { + // 授权正在下发中:以业务错误返回,避免抛出后变成“未知异常” 500 + return AjaxResult.error(e.getMessage()); + } + catch (SecurityException | IllegalArgumentException e) + { + // 已撤销/历史记录不可重新下发:以业务错误返回,避免抛出后变成“未知异常” 500 + return AjaxResult.error("该授权已撤销或属于历史记录,不可重新下发"); + } } @PreAuthorize("@ss.hasPermi('lock:auth:push')") @@ -94,8 +100,29 @@ public class LockAuthController extends BaseController @PostMapping("/batch/push") public AjaxResult batchPush(@RequestBody Long[] authIds) { - lockAuthService.batchPush(authIds); - return success(); + if (authIds == null || authIds.length == 0) + { + return AjaxResult.error("请选择要下发的授权"); + } + int successCount = 0; + int skipCount = 0; + for (Long authId : authIds) + { + try + { + lockAuthService.push(authId); + successCount++; + } + catch (SecurityException | IllegalArgumentException | IllegalStateException e) + { + skipCount++; + } + } + if (skipCount == 0) + { + return success(); + } + return AjaxResult.error("批量下发完成:" + successCount + " 条下发," + skipCount + " 条已撤销/历史记录已跳过"); } @PreAuthorize("@ss.hasPermi('lock:auth:clear')") @@ -116,6 +143,15 @@ public class LockAuthController extends BaseController return success(); } + @PreAuthorize("@ss.hasPermi('lock:auth:edit')") + @Log(title = "授权人工对账", businessType = BusinessType.UPDATE) + @PostMapping("/{authId}/reconcile") + public AjaxResult reconcile(@PathVariable Long authId, @RequestParam String outcome, @RequestParam(value = "updateBy", required = false) String updateBy) + { + lockAuthService.reconcile(authId, outcome, updateBy); + return success(); + } + @PreAuthorize("@ss.hasPermi('lock:auth:export')") @Log(title = "门禁授权", businessType = BusinessType.EXPORT) @PostMapping("/export") diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockFaceController.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockFaceController.java index 0a5642dc0..04a049168 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockFaceController.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockFaceController.java @@ -103,6 +103,21 @@ public class LockFaceController extends BaseController return AjaxResult.success("特征提取成功", feature); } + /*@PreAuthorize("@ss.hasPermi('lock:face:extract')") + @Log(title = "人脸特征提取", businessType = BusinessType.UPDATE) + @PostMapping("/{faceId}/extractStudent") + public AjaxResult extractStudent(@PathVariable String sId, @RequestParam(required = false) Long deviceId) + { + String feature = lockFaceService.extractStudent(sId, deviceId); + if (StringUtils.isEmpty(feature)) + { + return error("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本"); + } + // 注意:此处必须用 AjaxResult.success 全限定调用。 + // 继承而来的 BaseController.success 只有单参重载,写 success(msg, data) 会编译失败。 + return AjaxResult.success("特征提取成功", feature); + }*/ + @PreAuthorize("@ss.hasPermi('lock:face:extract')") @Log(title = "人脸批量特征提取", businessType = BusinessType.UPDATE) @PostMapping("/batch/extract") diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java index f1e727fc1..8e15acdb6 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java @@ -3,6 +3,8 @@ package com.ruoyi.iot.lock.security; import java.util.Date; import java.util.Objects; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import com.ruoyi.iot.config.IotMqttProperties; @@ -14,6 +16,7 @@ import com.ruoyi.iot.lock.protocol.*; /** Fail-closed policy shared by business, recovery and final transport boundaries. */ @Component public class RealIotSecurityGate { + private static final Logger log = LoggerFactory.getLogger(RealIotSecurityGate.class); @Autowired private IotMqttProperties properties; @Autowired private LockDeviceMapper deviceMapper; @Autowired private LockGatewayMapper gatewayMapper; @@ -23,6 +26,11 @@ public class RealIotSecurityGate { public RealIotSecurityGate() { } public RealIotSecurityGate(IotMqttProperties properties) { this.properties = properties; } public boolean isMock() { return properties != null && properties.isMock(); } + private static final java.util.Set REAL_MODE_CONTROL = new java.util.HashSet<>(java.util.Arrays.asList( + LockConstants.CMD_BIND, LockConstants.CMD_UNBIND, LockConstants.CMD_SYNC_TIME, + LockConstants.CMD_SWITCH, LockConstants.CMD_NET_CFG, LockConstants.CMD_LORA_CFG, + LockConstants.CMD_MQTT_CFG, LockConstants.CMD_SAVE, LockConstants.CMD_REBOOT, + LockConstants.CMD_CLEAR_DEVICE)); public boolean isValidPersonNo(String value) { return LockAuthPayloadFactory.validPerson(value); } public void bindAuthorizationIdentity(LockAuth auth) { LockDevice device = auth.getDeviceId() == null ? null : deviceMapper.selectDeviceById(auth.getDeviceId()); @@ -94,7 +102,8 @@ public class RealIotSecurityGate { } boolean authCommand = LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()) || LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()); - if (!authCommand && !isMock()) { + boolean controlCommand = REAL_MODE_CONTROL.contains(command.getCommandType()); + if (!authCommand && !controlCommand && !isMock()) { // Gateway-wide controls have no proven device-scoped contract. throw new SecurityException("Real control is restricted to reviewed device-scoped authorization commands"); } @@ -129,13 +138,22 @@ public class RealIotSecurityGate { && auth.getAuthValue().toLowerCase(java.util.Locale.ROOT).startsWith("mock")) { throw new SecurityException("Synthetic credentials cannot enter real commands"); } - if (!Objects.equals(expected.getParams(), frame.getParams()) || !Objects.equals(expected.getOp(), frame.getOp()) - || !Objects.equals(expected.getDevId(), frame.getDevId()) - || !Objects.equals(expected.getDevPsw(), frame.getDevPsw()) - || !Objects.equals(expected.getDevVer(), frame.getDevVer()) - || !Objects.equals(expected.getDevType(), frame.getDevType()) - || !Objects.equals(expected.getChksum(), frame.getChksum()) - || frame.getAt() == null || !frame.getAt().matches("[0-9]+")) { + boolean paramsEq = Objects.equals(expected.getParams(), frame.getParams()); + boolean opEq = Objects.equals(expected.getOp(), frame.getOp()); + boolean devIdEq = Objects.equals(expected.getDevId(), frame.getDevId()); + boolean devPswEq = Objects.equals(expected.getDevPsw(), frame.getDevPsw()); + boolean devVerEq = Objects.equals(expected.getDevVer(), frame.getDevVer()); + boolean devTypeEq = Objects.equals(expected.getDevType(), frame.getDevType()); + boolean chksumEq = Objects.equals(expected.getChksum(), frame.getChksum()); + boolean atEq = frame.getAt() != null && frame.getAt().matches("[0-9]+"); + if (!paramsEq || !opEq || !devIdEq || !devPswEq || !devVerEq || !devTypeEq || !chksumEq || !atEq) { + log.warn("Payload mismatch: paramsEq={}, opEq={}, devIdEq={}[exp={},frame={}], devPswEq={}[exp={},frame={}], " + + "devVerEq={}[exp={},frame={}], devTypeEq={}[exp={},frame={}], chksumEq={}[exp={},frame={}], atEq={}[frame={}]; " + + "expected.params={}; frame.params={}", + paramsEq, opEq, devIdEq, expected.getDevId(), frame.getDevId(), devPswEq, expected.getDevPsw(), frame.getDevPsw(), + devVerEq, expected.getDevVer(), frame.getDevVer(), devTypeEq, expected.getDevType(), frame.getDevType(), + chksumEq, expected.getChksum(), frame.getChksum(), atEq, frame.getAt(), + expected.getParams(), frame.getParams()); throw new IllegalArgumentException("Payload does not match the persisted authorization snapshot"); } } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java index 62b06b8f5..94f68922a 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java @@ -37,6 +37,17 @@ public interface ILockAuthService /** 批量清除 */ void batchClear(Long[] authIds); + /** + * 人工对账:将处于不确定态(DISPATCHING/AWAITING_ACK/MANUAL_REVIEW)的授权, + * 标记为操作员确认的终态(CONFIRMED=确认已生效 / REJECTED=确认未生效)。 + * 对账后授权即可重新走修改/下发流程。 + * + * @param authId 授权ID + * @param outcome 确认结果:CONFIRMED 或 REJECTED + * @param updateBy 操作人 + */ + void reconcile(Long authId, String outcome, String updateBy); + /** * 处理授权下发结果 * diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java index b227ee223..d83c2f8f5 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java @@ -31,6 +31,7 @@ public interface ILockFaceService */ String extract(Long faceId, Long deviceId); + /*String extractStudent(String sId, Long deviceId);*/ /** * 批量重提取 * diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java index 9eaf49644..865177953 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java @@ -1,6 +1,9 @@ package com.ruoyi.iot.lock.service.impl; import java.util.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.iot.lock.LockConstants; @@ -13,6 +16,7 @@ import com.ruoyi.iot.lock.service.*; /** Persisted desired intent is independent from transport and device confirmation. */ @Service public class LockAuthServiceImpl implements ILockAuthService { + private static final Logger log = LoggerFactory.getLogger(LockAuthServiceImpl.class); @Autowired private LockAuthMapper authMapper; @Autowired private LockDeviceMapper deviceMapper; @Autowired private LockGatewayMapper gatewayMapper; @@ -27,6 +31,7 @@ public class LockAuthServiceImpl implements ILockAuthService { @Override public List selectAuthsByDeviceId(Long id) { return authMapper.selectAuthsByDeviceId(id); } @Override public int insertAuth(LockAuth auth) { + log.info("学员 auth={}",auth); return transactions.required(() -> { fillIdentity(auth); securityGate.initializeIntent(auth, ExecutionStates.GRANT); @@ -34,6 +39,7 @@ public class LockAuthServiceImpl implements ILockAuthService { auth.setStatus("0"); auth.setDelFlag("0"); auth.setAuthTime(new Date()); auth.setSyncStatus(LockConstants.SYNC_PENDING); int rows = authMapper.insertAuth(auth); + System.out.println("insertAuth_rows = " + rows); enqueue(auth); return rows; }); @@ -71,12 +77,34 @@ public class LockAuthServiceImpl implements ILockAuthService { auth.setDeviceSn(device.getDeviceSn()); auth.setGatewayId(device.getGatewayId()); auth.setGroupId(gateway.getGroupId()); } @Override public void push(Long id) { + transactions.required(() -> { LockAuth auth = requireAuth(id); if (!"0".equals(auth.getStatus()) || !ExecutionStates.GRANT.equals(auth.getIntentAction())) { throw new SecurityException("A revoked or historical authorization cannot be repushed"); } - enqueue(auth); return null; + // 在途(DISPATCHING/AWAITING_ACK)不重发,避免双发 + if (ExecutionStates.DISPATCHING.equals(auth.getExecutionState()) + || ExecutionStates.AWAITING_ACK.equals(auth.getExecutionState())) { + throw new IllegalStateException("授权正在下发中,请勿重复操作"); + } + // 复位授权执行态为 READY,允许对任意终态重新下发 + auth.setExecutionState(ExecutionStates.READY); + auth.setSyncStatus(LockConstants.SYNC_PENDING); + authMapper.updateExecution(auth); + // intent_key 唯一,不可重复插入命令:已有命令则重置后重试,否则新建 + LockCommand existing = commandMapper.selectCommandByIntentKey(auth.getIntentKey()); + if (existing != null) { + existing.setExecutionState(ExecutionStates.READY); + existing.setAttemptStartedAt(null); + existing.setCommandStatus(LockConstants.STATUS_PENDING); + existing.setErrorMsg(null); + commandMapper.updateCommand(existing); + commandService.retry(existing.getCommandId()); + } else { + enqueue(auth); + } + return null; }); } @Override public void clear(Long id) { @@ -122,10 +150,27 @@ public class LockAuthServiceImpl implements ILockAuthService { return true; } catch (IllegalArgumentException | SecurityException invalid) { auth.setExecutionState(ExecutionStates.MANUAL_REVIEW); - auth.setSyncStatus(LockConstants.SYNC_FAIL); auth.setSyncMsg("INVALID_MAPPING_OR_PROTOCOL_REQUIRES_REVIEW"); + auth.setSyncStatus(LockConstants.SYNC_FAIL); + auth.setSyncMsg("INVALID_MAPPING_OR_PROTOCOL_REQUIRES_REVIEW: " + invalid.getClass().getSimpleName() + " - " + invalid.getMessage()); authMapper.updateExecution(auth); return false; } } + @Override public void reconcile(Long authId, String outcome, String updateBy) { + transactions.required(() -> { + LockAuth auth = requireAuth(authId); + if (!ExecutionStates.uncertain(auth.getExecutionState())) { + throw new IllegalStateException("仅处于不确定态(DISPATCHING/AWAITING_ACK/MANUAL_REVIEW)的授权才允许人工对账"); + } + if (!ExecutionStates.CONFIRMED.equals(outcome) && !ExecutionStates.REJECTED.equals(outcome)) { + throw new IllegalArgumentException("人工对账结果必须为 CONFIRMED 或 REJECTED"); + } + auth.setExecutionState(outcome); + auth.setSyncStatus(ExecutionStates.CONFIRMED.equals(outcome) ? LockConstants.SYNC_SUCCESS : LockConstants.SYNC_FAIL); + auth.setSyncMsg("MANUAL_RECONCILE:" + outcome + (updateBy != null ? " by " + updateBy : "")); + if (authMapper.updateExecution(auth) != 1) throw new IllegalStateException("并发对账更新冲突"); + return null; + }); + } @Override public int retryPending() { int count = 0; for (LockAuth snapshot : authMapper.selectReadyIntents()) { diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java index fb1f1b882..a425e53d6 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java @@ -1,8 +1,13 @@ package com.ruoyi.iot.lock.service.impl; import java.util.*; + +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Lazy; +import org.springframework.core.task.TaskExecutor; import org.springframework.stereotype.Service; import com.ruoyi.iot.lock.LockConstants; import com.ruoyi.iot.lock.domain.*; @@ -11,20 +16,43 @@ import com.ruoyi.iot.lock.protocol.*; import com.ruoyi.iot.lock.security.*; import com.ruoyi.iot.lock.service.*; import com.ruoyi.iot.service.IMqttService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Transactional outbox: enqueue -> committed claim -> one transport attempt -> confirmed ACK or review. */ @Service public class LockCommandServiceImpl implements ILockCommandService { + /** 默认最大重试次数 */ + private static final int DEFAULT_MAX_RETRY = 3; + + /** 默认命令超时时间(秒) */ + private static final int DEFAULT_TIMEOUT_SECONDS = 30; + @Autowired + private LockMqttLogMapper mqttLogMapper; + @Autowired private LockCommandMapper commandMapper; @Autowired private LockAuthMapper authMapper; @Autowired private LockExecutionTransactions transactions; @Autowired private RealIotSecurityGate securityGate; @Lazy @Autowired private IMqttService mqttService; + @Autowired @Qualifier("threadPoolTaskExecutor") private TaskExecutor taskExecutor; + private static final Logger log = LoggerFactory.getLogger(LockCommandServiceImpl.class); - @Override public LockCommand send(LockCommand command) { + @Override + public LockCommand send(LockCommand command) + { + if (command == null || StringUtils.isEmpty(command.getTopic())) + { + throw new IllegalArgumentException("命令主题不能为空"); + } + if (StringUtils.isEmpty(command.getCommandNo())) + { + command.setCommandNo(IdUtils.fastSimpleUUID().toUpperCase()); + } return transactions.required(() -> { LockAuth auth = lockAuth(command); - if (auth != null && !ExecutionStates.READY.equals(auth.getExecutionState())) { + if (auth != null && !ExecutionStates.READY.equals(auth.getExecutionState())) + { throw new SecurityException("Intent is not ready for a new command"); } securityGate.requirePayloadAndIdentity(command); @@ -32,18 +60,59 @@ public class LockCommandServiceImpl implements ILockCommandService { if (existing != null) return existing; command.setExecutionState(ExecutionStates.READY); command.setCommandStatus(LockConstants.STATUS_PENDING); - command.setRetryCount(0); command.setMaxRetry(0); + command.setRetryCount(0); + command.setMaxRetry(command.getMaxRetry() == null ? DEFAULT_MAX_RETRY : command.getMaxRetry()); command.setReqTime(new Date()); commandMapper.insertCommand(command); - transactions.afterCommit(() -> dispatch(command.getCommandId())); + // 报文留痕(下行) + recordLog(command); + // Kingbase 下 useGeneratedKeys 不会回填自增主键,按唯一 intent_key 取回真实 command_id + LockCommand persisted = commandMapper.selectCommandByIntentKey(command.getIntentKey()); + final Long dispatchedId = persisted != null ? persisted.getCommandId() : command.getCommandId(); + transactions.afterCommit(() -> taskExecutor.execute(() -> { + boolean ok = dispatch(dispatchedId); + log.info("命令异步派发完成 cid={} ok={}", dispatchedId, ok); + })); return command; }); } - + /** + * 指数退避:第 n 次重试延迟 2^n 分钟,最长 30 分钟 + */ + private Date nextRetryTime(int retryCount) + { + int minutes = Math.min((int) Math.pow(2, Math.min(retryCount, 5)), 30); + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, minutes); + return calendar.getTime(); + } private boolean authCommand(LockCommand command) { return LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()) || LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()); } + /** + * 下行报文留痕 + */ + private void recordLog(LockCommand command) + { + try + { + LockMqttLog logEntity = new LockMqttLog(); + logEntity.setDirection(LockConstants.DIRECTION_DOWN); + logEntity.setTopic(command.getTopic()); + logEntity.setPayload(command.getPayload()); + logEntity.setSeq(command.getCommandNo()); + logEntity.setRecvTime(command.getReqTime()); + logEntity.setHandleStatus(StringUtils.equals(command.getCommandStatus(), LockConstants.STATUS_FAIL) + ? LockConstants.HANDLE_FAIL + : LockConstants.HANDLE_SUCCESS); + mqttLogMapper.insertMqttLog(logEntity); + } + catch (Exception e) + { + log.warn("下行报文留痕失败, commandNo={}", command.getCommandNo()); + } + } // Always acquire the authorization lock before the command lock. private LockAuth lockAuth(LockCommand command) { return authCommand(command) && command.getBizId() != null @@ -66,6 +135,7 @@ public class LockCommandServiceImpl implements ILockCommandService { /** No surrounding business transaction may remain uncommitted when this method publishes. */ public boolean dispatch(Long commandId) { + log.warn("[TRACE-DISPATCH] dispatch enter cid={}", commandId); LockCommand claimed = transactions.committed(() -> { LockCommand snapshot = commandMapper.selectCommandById(commandId); if (snapshot == null) return null; @@ -97,6 +167,7 @@ public class LockCommandServiceImpl implements ILockCommandService { return null; } if (!mqttService.isConnected()) { + log.warn("[TRACE-DISPATCH] cid={} isConnected=false -> TRANSPORT_DISCONNECTED", commandId); command.setCommandStatus(LockConstants.STATUS_FAIL); command.setErrorMsg("TRANSPORT_DISCONNECTED_NOT_ATTEMPTED"); commandMapper.updateCommand(command); return null; diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java index 447cebe64..b3a3046bb 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java @@ -220,6 +220,7 @@ public class LockDeviceServiceImpl implements ILockDeviceService command.setTargetType(LockConstants.TARGET_DEVICE); command.setTargetId(deviceId); command.setTargetCode(device.getDeviceSn()); + command.setDeviceSn(device.getDeviceSn()); command.setBizId(deviceId); command.setTopic(topic); command.setPayload(YdlinkCodec.toJson(frame)); @@ -295,6 +296,7 @@ public class LockDeviceServiceImpl implements ILockDeviceService command.setTargetType(LockConstants.TARGET_GATEWAY); command.setTargetId(gateway.getGatewayId()); command.setTargetCode(gateway.getGatewaySn()); + command.setDeviceSn(gateway.getGatewaySn()); command.setBizId(bizId); command.setTopic(YdlinkTopicBuilder.gatewayPropertySet(gateway.getGatewaySn())); command.setPayload(YdlinkCodec.toJson(frame)); diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java index 79c9bb173..220db5777 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java @@ -142,6 +142,62 @@ public class LockFaceServiceImpl implements ILockFaceService faceMapper.updateExtractResult(update); return feature; } + /*@Override + public String extractStudent(String sId, Long deviceId) + { + TrainStudent stud = trainStudentMapper.selectTrainStudentBySId(sId); + if (stud.getAvatar() == null) + { + throw new IllegalArgumentException("人脸数据不存在"); + } + LockDevice explicitDevice = deviceId == null ? null : deviceMapper.selectDeviceById(deviceId); + if (!faceExtractClient.isMock()) { + if (explicitDevice == null) throw new SecurityException("An explicit face extraction device is required"); + faceExtractClient.authorize(explicitDevice.getDeviceSn()); + } + // algorithm_version 由门锁维护,优先取指定门锁,其次按该人员的授权推断 + String algoVersion = explicitDevice.getFaceAlgoVersion(); + if (StringUtils.isEmpty(algoVersion)) + { + log.warn("未获取到算法版本(algorithm_version),将不带该参数提取, faceId={}, deviceId={}", sId, deviceId); + } + TrainStudent update = new TrainStudent(); + update.setsId(sId); + update.setExtractTime(new Date()); + update.setFeatureAlgoVersion(algoVersion); + if (StringUtils.isEmpty(stud.getAvatar())) + { + update.setExtractStatus("2"); + update.setExtractMsg("未上传人脸图片"); + trainStudentMapper.updateExtractResult(update); + return null; + } + // photoUrl 存的是图片地址,特征提取服务按文件接收,这里读取图片内容 + byte[] imageBytes = faceExtractClient.isMock() ? new byte[]{0} : loadImageBytes(stud.getAvatar()); + if (imageBytes == null || imageBytes.length == 0) + { + update.setExtractStatus("2"); + update.setExtractMsg("人脸图片读取失败,请确认图片地址可访问"); + trainStudentMapper.updateExtractResult(update); + log.warn("人脸图片读取失败, faceId={}, photoUrl={}", sId, stud.getAvatar()); + return null; + } + String feature = faceExtractClient.extract(imageBytes, "face.jpg", algoVersion, explicitDevice == null ? null : explicitDevice.getDeviceSn()); + if (StringUtils.isEmpty(feature)) + { + update.setExtractStatus("2"); + update.setExtractMsg("特征提取服务未返回结果"); + trainStudentMapper.updateExtractResult(update); + log.warn("人脸特征提取失败, faceId={}", sId); + return null; + } + update.setExtractStatus("1"); + update.setExtractMsg(null); + update.setFeatureData(feature); + update.setFeatureSize(feature.length()); + trainStudentMapper.updateExtractResult(update); + return feature; + }*/ /** * 解析特征提取所需的 algorithm_version @@ -185,6 +241,12 @@ public class LockFaceServiceImpl implements ILockFaceService { try { + // 前端可能将图片存为完整 URL(含 http(s)://host:port 前缀),统一归一到 /profile/... 相对路径 + int prefixIdx = photoUrl.indexOf(Constants.RESOURCE_PREFIX + "/"); + if (prefixIdx > 0) + { + photoUrl = photoUrl.substring(prefixIdx); + } if (!photoUrl.startsWith(Constants.RESOURCE_PREFIX + "/")) { throw new SecurityException("Only local approved profile images are accepted"); } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java index 9909ea94d..f5e837776 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java @@ -30,7 +30,7 @@ public class LockCommandRetryTask @Autowired(required = false) private com.ruoyi.iot.config.IotMqttProperties properties; - @Scheduled(initialDelay = 30000L, fixedDelay = 60000L) + @Scheduled(initialDelay = 2000L, fixedDelay = 2000L) public void execute() { if (properties == null || !properties.isRetryTaskEnabled()) diff --git a/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index e29d22a50..a8c530e7c 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -139,7 +139,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + insert into sys_user( user_id, dept_id, @@ -175,7 +175,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" remark, create_time )values( - #{userId}, + #{userId}, #{deptId}, #{userName}, #{nickName}, diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AccommodationErrorCode.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AccommodationErrorCode.java index 7edfd70ac..4e3aa88f3 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AccommodationErrorCode.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AccommodationErrorCode.java @@ -21,6 +21,12 @@ public final class AccommodationErrorCode public static final int ACCOMMODATION_NOT_FOUND = 40460; public static final String ACCOMMODATION_NOT_FOUND_MESSAGE = "住宿记录不存在"; + /** + * 宿舍未绑定智能门锁(lock_device_sn)的异常标识。 + * 调房场景允许跳过门锁授权而不阻断住宿关系,服务层据此识别该业务分支。 + */ + public static final String LOCK_DEVICE_UNBOUND_MARK = "宿舍未绑定智能门锁设备"; + private AccommodationErrorCode() { } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java index 83b141af2..8cc1c52cc 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java @@ -89,6 +89,10 @@ public final class AttendanceErrorCode public static final int ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS = 40097; public static final String ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS_MESSAGE = "该学员本课次已存在考勤记录,请改用「调整考勤记录」修改考勤结论"; + /** 考勤学员与参训档案不一致:请求携带的 studentId 与参训档案所属学员不匹配 */ + public static final int ATTENDANCE_STUDENT_MISMATCH = 40098; + public static final String ATTENDANCE_STUDENT_MISMATCH_MESSAGE = "业务不变量校验失败:考勤学员与参训档案所属学员不一致"; + private AttendanceErrorCode() { } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java index 74f80efba..17758170d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java @@ -26,6 +26,18 @@ public class TrainAccommodation extends BaseEntity /** 住宿状态:2 已退宿 */ public static final Integer STATUS_CHECKED_OUT = 2; + /** 门锁业务流水号最大长度 (对齐 lock_api_biz_no varchar(64)) */ + public static final int LOCK_API_BIZ_NO_MAX_LENGTH = 64; + + /** 门锁授权结果:1 未授权 */ + public static final Integer LOCK_RESULT_UNAUTHORIZED = 1; + + /** 门锁授权结果:2 已授权 */ + public static final Integer LOCK_RESULT_AUTHORIZED = 2; + + /** 门锁授权结果:3 已过期 */ + public static final Integer LOCK_RESULT_EXPIRED = 3; + /** 住宿安排ID (主键,自增) */ private Long accommodationId; @@ -50,6 +62,14 @@ public class TrainAccommodation extends BaseEntity @Excel(name = "住宿状态", readConverterExp = "0=预分配,1=已入住,2=已退宿") private Integer accommodationStatus; + /** 门锁开放接口业务流水号 (lock_api_biz_no,最长 64) */ + @Excel(name = "门锁业务流水号") + private String lockApiBizNo; + + /** 门锁授权结果 (lock_result,1: 未授权, 2: 已授权, 3: 已过期) */ + @Excel(name = "门锁授权结果", readConverterExp = "1=未授权,2=已授权,3=已过期") + private Integer lockResult; + /* ==================== 视图兼容字段 (非持久化列,联表展示) ==================== */ /** 学员学号 */ @@ -62,7 +82,7 @@ public class TrainAccommodation extends BaseEntity private String gender; /** 所属机构/选派单位 */ - private String orgName; + private String deptName; /** 联系电话 */ private String phone; @@ -152,6 +172,26 @@ public class TrainAccommodation extends BaseEntity this.accommodationStatus = accommodationStatus; } + public String getLockApiBizNo() + { + return lockApiBizNo; + } + + public void setLockApiBizNo(String lockApiBizNo) + { + this.lockApiBizNo = lockApiBizNo; + } + + public Integer getLockResult() + { + return lockResult; + } + + public void setLockResult(Integer lockResult) + { + this.lockResult = lockResult; + } + public String getStudentId() { return studentId; @@ -182,14 +222,14 @@ public class TrainAccommodation extends BaseEntity this.gender = gender; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; } public String getPhone() @@ -252,6 +292,8 @@ public class TrainAccommodation extends BaseEntity .append("bedNo", getBedNo()) .append("assignedTime", getAssignedTime()) .append("accommodationStatus", getAccommodationStatus()) + .append("lockApiBizNo", getLockApiBizNo()) + .append("lockResult", getLockResult()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java index b19a92129..a53f1f914 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java @@ -39,6 +39,11 @@ public class TrainClassPlan extends BaseEntity @Excel(name = "报到截止时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date reportDeadline; + /** 报到开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "报到开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date reportStarttime; + /** 培训地点 */ @Excel(name = "培训地点") private String trainAddress; @@ -58,6 +63,41 @@ public class TrainClassPlan extends BaseEntity /** 删除标志(0代表存在 2代表删除) */ private String delFlag; + /** 班级ID */ + @Excel(name = "班级ID") + private String classId; + + /** 班级名称 */ + @Excel(name = "班级名称") + private String className; + + /** 所属专业 */ + @Excel(name = "所属专业") + private Integer classMajor; + + /** 班主任 */ + @Excel(name = "班主任") + private String classTeacher; + + /** 负责人 */ + @Excel(name = "负责人") + private String manager; + + /** 主办部门 */ + @Excel(name = "主办部门") + private Integer hostDeptId; + + /** 总计划学时 */ + @Excel(name = "总计划学时") + private Integer totalClassHours; + + /** 总预算 */ + @Excel(name = "总预算") + private Float totalBudget; + + /** 自增编号(数据库递增序号)。 */ + private Long id; + public void setPlanId(String planId) { this.planId = planId; @@ -108,6 +148,16 @@ public class TrainClassPlan extends BaseEntity return reportDeadline; } + public void setReportStarttime(Date reportStarttime) + { + this.reportStarttime = reportStarttime; + } + + public Date getReportStarttime() + { + return reportStarttime; + } + public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; @@ -158,6 +208,78 @@ public class TrainClassPlan extends BaseEntity return delFlag; } + public String getClassId() { + return classId; + } + + public void setClassId(String classId) { + this.classId = classId; + } + + public String getClassName() { + return className; + } + + public void setClassName(String className) { + this.className = className; + } + + public Integer getClassMajor() { + return classMajor; + } + + public void setClassMajor(Integer classMajor) { + this.classMajor = classMajor; + } + + public String getClassTeacher() { + return classTeacher; + } + + public void setClassTeacher(String classTeacher) { + this.classTeacher = classTeacher; + } + + public String getManager() { + return manager; + } + + public void setManager(String manager) { + this.manager = manager; + } + + public Integer getHostDeptId() { + return hostDeptId; + } + + public void setHostDeptId(Integer hostDeptId) { + this.hostDeptId = hostDeptId; + } + + public Integer getTotalClassHours() { + return totalClassHours; + } + + public void setTotalClassHours(Integer totalClassHours) { + this.totalClassHours = totalClassHours; + } + + public Float getTotalBudget() { + return totalBudget; + } + + public void setTotalBudget(Float totalBudget) { + this.totalBudget = totalBudget; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -166,11 +288,21 @@ public class TrainClassPlan extends BaseEntity .append("startTime", getStartTime()) .append("endTime", getEndTime()) .append("reportDeadline", getReportDeadline()) + .append("reportStarttime", getReportStarttime()) .append("trainAddress", getTrainAddress()) .append("recruitNum", getRecruitNum()) .append("applyCondition", getApplyCondition()) .append("planStatus", getPlanStatus()) .append("delFlag", getDelFlag()) + .append("classId", getClassId()) + .append("className", getClassName()) + .append("classMajor", getClassMajor()) + .append("classTeacher", getClassTeacher()) + .append("manager", getManager()) + .append("hostDeptId", getHostDeptId()) + .append("totalClassHours", getTotalClassHours()) + .append("totalBudget", getTotalBudget()) + .append("id", getId()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassReport.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassReport.java index 2161208ca..71a5dc7ea 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassReport.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassReport.java @@ -14,6 +14,7 @@ public class TrainClassReport extends BaseEntity private String planId; @Excel(name = "培训计划") private String planName; + private String className; private String studentId; @Excel(name = "学员") private String studentName; @@ -29,6 +30,13 @@ public class TrainClassReport extends BaseEntity @Excel(name = "核验说明") private String verifyNote; + /** 分配宿舍ID(train_dormitory_info.dorm_id) */ + private String allocDormId; + /** 分配教室ID(train_classroom_info.class_room_id) */ + private String allocClassroomId; + /** 分配实训场地ID(train_training_site.site_id) */ + private String allocSiteId; + public Long getReportId() { return reportId; } public void setReportId(Long reportId) { this.reportId = reportId; } public Long getEnrollmentId() { return enrollmentId; } @@ -51,4 +59,19 @@ public class TrainClassReport extends BaseEntity public void setVerifyResult(Integer verifyResult) { this.verifyResult = verifyResult; } public String getVerifyNote() { return verifyNote; } public void setVerifyNote(String verifyNote) { this.verifyNote = verifyNote; } + + public String getClassName() { + return className; + } + + public void setClassName(String className) { + this.className = className; + } + + public String getAllocDormId() { return allocDormId; } + public void setAllocDormId(String allocDormId) { this.allocDormId = allocDormId; } + public String getAllocClassroomId() { return allocClassroomId; } + public void setAllocClassroomId(String allocClassroomId) { this.allocClassroomId = allocClassroomId; } + public String getAllocSiteId() { return allocSiteId; } + public void setAllocSiteId(String allocSiteId) { this.allocSiteId = allocSiteId; } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassSeat.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassSeat.java new file mode 100644 index 000000000..d03a4dd62 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassSeat.java @@ -0,0 +1,206 @@ +package com.ruoyi.train.domain; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 班级座位对象 train_class_seat + * + * 一行代表开班计划在某教室/实训场地中的一个物理座位, + * (plan_id, classroom_id, row_index, col_index) 唯一定位一个座位。 + * + * @author ruoyi + * @date 2026-10-26 + */ +public class TrainClassSeat extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 座位ID主键 (自增) */ + private Long seatId; + + /** 班级ID (train_class_plan.plan_id) */ + @Excel(name = "班级ID") + private String planId; + + /** 教室ID (train_classroom_info.class_room_id) */ + @Excel(name = "教室ID") + private String classroomId; + + /** 教学场地类别(classroom:教室,site:实训场地) */ + @Excel(name = "教学场地类别") + private String category; + + /** 行号,从1开始 */ + @Excel(name = "行号") + private Integer rowIndex; + + /** 列号,从1开始 */ + @Excel(name = "列号") + private Integer colIndex; + + /** 物理座位标识 */ + @Excel(name = "物理座位标识") + private String seatCode; + + /** 是否取消座位 (0正常,1取消) */ + @Excel(name = "是否取消座位", readConverterExp = "0=正常,1=取消") + private Integer canceled; + + /** 占用座位学员ID (train_student.s_id) */ + @Excel(name = "占用学员ID") + private String studentId; + + /** 区域ID */ + @Excel(name = "区域ID") + private Long zoneId; + + /** 部门 */ + @Excel(name = "部门") + private String deptDetail; + + /** 职务 */ + @Excel(name = "职务") + private String job; + + public Long getSeatId() + { + return seatId; + } + + public void setSeatId(Long seatId) + { + this.seatId = seatId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getClassroomId() + { + return classroomId; + } + + public void setClassroomId(String classroomId) + { + this.classroomId = classroomId; + } + + public String getCategory() + { + return category; + } + + public void setCategory(String category) + { + this.category = category; + } + + public Integer getRowIndex() + { + return rowIndex; + } + + public void setRowIndex(Integer rowIndex) + { + this.rowIndex = rowIndex; + } + + public Integer getColIndex() + { + return colIndex; + } + + public void setColIndex(Integer colIndex) + { + this.colIndex = colIndex; + } + + public String getSeatCode() + { + return seatCode; + } + + public void setSeatCode(String seatCode) + { + this.seatCode = seatCode; + } + + public Integer getCanceled() + { + return canceled; + } + + public void setCanceled(Integer canceled) + { + this.canceled = canceled; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public Long getZoneId() + { + return zoneId; + } + + public void setZoneId(Long zoneId) + { + this.zoneId = zoneId; + } + + public String getDeptDetail() + { + return deptDetail; + } + + public void setDeptDetail(String deptDetail) + { + this.deptDetail = deptDetail; + } + + public String getJob() + { + return job; + } + + public void setJob(String job) + { + this.job = job; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("seatId", getSeatId()) + .append("planId", getPlanId()) + .append("classroomId", getClassroomId()) + .append("category", getCategory()) + .append("rowIndex", getRowIndex()) + .append("colIndex", getColIndex()) + .append("seatCode", getSeatCode()) + .append("canceled", getCanceled()) + .append("studentId", getStudentId()) + .append("zoneId", getZoneId()) + .append("deptDetail", getDeptDetail()) + .append("job", getJob()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassroomInfo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassroomInfo.java index 63261065c..57b2ff36e 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassroomInfo.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassroomInfo.java @@ -52,6 +52,12 @@ public class TrainClassroomInfo extends BaseEntity private String seatArrange; + /** 自增编号(数据库递增序号)。 */ + private Long id; + + /** 绑定的电子班牌ID(关联 train_electronic_board.board_id),可为空 */ + private String bindBoardId; + public void setClassRoomId(String classRoomId) { this.classRoomId = classRoomId; @@ -136,6 +142,22 @@ public class TrainClassroomInfo extends BaseEntity this.seatArrange = seatArrange; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getBindBoardId() { + return bindBoardId; + } + + public void setBindBoardId(String bindBoardId) { + this.bindBoardId = bindBoardId; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -153,6 +175,8 @@ public class TrainClassroomInfo extends BaseEntity .append("multimedia", getMultimedia()) .append("projector", getProjector()) .append("seatArrange", getSeatArrange()) + .append("id", getId()) + .append("bindBoardId", getBindBoardId()) .toString(); } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java index d2f09f3ce..8a4e2c85e 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java @@ -54,6 +54,9 @@ public class TrainCourse extends BaseEntity /** 删除标志 (0:存在, 2:删除) */ private String delFlag; + /** 自增编号(数据库递增序号)。 */ + private Long id; + @JsonProperty("cId") public void setcId(String cId) { @@ -214,6 +217,14 @@ public class TrainCourse extends BaseEntity return delFlag; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -227,6 +238,7 @@ public class TrainCourse extends BaseEntity .append("resourceId", getResourceId()) .append("status", getStatus()) .append("delFlag", getDelFlag()) + .append("id", getId()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormitoryInfo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormitoryInfo.java index 0815fe4cf..8fd15ce56 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormitoryInfo.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormitoryInfo.java @@ -49,6 +49,9 @@ public class TrainDormitoryInfo extends BaseEntity /** 删除标志 (0: 存在, 2: 删除) */ private String delFlag; + /** 自增编号(数据库递增序号)。 */ + private Long id; + /** 已住人数 (实时统计自 train_accommodation,非持久化列) */ private Integer usedCount; @@ -145,6 +148,14 @@ public class TrainDormitoryInfo extends BaseEntity return delFlag; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public Integer getUsedCount() { return usedCount; @@ -177,6 +188,7 @@ public class TrainDormitoryInfo extends BaseEntity .append("lockDeviceSn", getLockDeviceSn()) .append("dormStatus", getDormStatus()) .append("delFlag", getDelFlag()) + .append("id", getId()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainElectronicBoard.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainElectronicBoard.java index 99be67afd..62995e37f 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainElectronicBoard.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainElectronicBoard.java @@ -56,6 +56,9 @@ public class TrainElectronicBoard extends BaseEntity /** 删除标志 (0代表存在 2代表删除) */ private String delFlag; + /** 自增编号(数据库递增序号)。 */ + private Long id; + public void setBoardId(String boardId) { this.boardId = boardId; @@ -156,6 +159,14 @@ public class TrainElectronicBoard extends BaseEntity return delFlag; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + @Override public String toString() { return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) @@ -169,6 +180,7 @@ public class TrainElectronicBoard extends BaseEntity .append("boardStatus", getBoardStatus()) .append("lastHeartbeat", getLastHeartbeat()) .append("delFlag", getDelFlag()) + .append("id", getId()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamReport.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamReport.java new file mode 100644 index 000000000..35e3f3a29 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamReport.java @@ -0,0 +1,555 @@ +package com.ruoyi.train.domain; + +import java.math.BigDecimal; +import java.util.Date; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 考试成绩上报记录对象 train_exam_report + * + * @author ruoyi + * @date 2026-11-05 + */ +public class TrainExamReport extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long reportPk; + + /** 上报编号(永久幂等键) */ + @Excel(name = "上报编号") + private String reportId; + + /** 应用标识 */ + @Excel(name = "应用标识") + private String appId; + + /** 所属批次 */ + private Long batchPk; + + /** 批次号 */ + @Excel(name = "批次号") + private String batchId; + + /** 上报渠道(REALTIME/PERIODIC) */ + @Excel(name = "上报渠道", readConverterExp = "REALTIME=实时上报,PERIODIC=周期上报") + private String reportChannel; + + /** 成绩类型 */ + private String bizType; + + /** 学员ID */ + @Excel(name = "学员ID") + private String studentId; + + /** 上报姓名 */ + @Excel(name = "学员姓名") + private String studentName; + + /** 证件号掩码 */ + private String idCardMasked; + + /** 证件号MD5 */ + private String idCardMd5; + + /** 匹配到的参训档案ID */ + private Long matchedEnrollmentId; + + /** 匹配到的培训班ID */ + @Excel(name = "培训班ID") + private String matchedPlanId; + + /** 匹配状态(MATCHED/UNMATCHED) */ + @Excel(name = "匹配状态", readConverterExp = "MATCHED=已匹配,UNMATCHED=待认领") + private String matchStatus; + + /** 成绩状态(RECEIVED/ARCHIVED/LOCKED) */ + @Excel(name = "成绩状态", readConverterExp = "RECEIVED=已接收,ARCHIVED=已归档,LOCKED=已锁定") + private String scoreStatus; + + /** 科目编码 */ + @Excel(name = "科目编码") + private String subjectCode; + + /** 科目名称 */ + @Excel(name = "科目名称") + private String subjectName; + + /** 考试开始(毫秒) */ + private Long examStartTime; + + /** 考试结束(毫秒) */ + private Long examEndTime; + + /** 用时秒数 */ + private Integer durationSeconds; + + /** 分数 */ + @Excel(name = "分数") + private BigDecimal score; + + /** 满分 */ + private BigDecimal fullScore; + + /** 是否通过(1通过 0未通过) */ + @Excel(name = "是否通过", readConverterExp = "1=通过,0=未通过") + private Integer passFlag; + + /** 是否重考(1是 0否) */ + private Integer retake; + + /** 重考序号 */ + private Integer retakeNo; + + /** 考试工位 */ + @Excel(name = "考试工位") + private String station; + + /** 考试内容原始 JSON */ + private String content; + + /** 内容字节数 */ + private Integer contentSize; + + /** 内容摘要 */ + private String contentMd5; + + /** 报文上报时间 */ + private Long reportTime; + + /** 平台接收时间 */ + @Excel(name = "接收时间") + private Long receivedTime; + + /** 补传标记 */ + private Integer resend; + + /** 认领操作人 */ + private String claimBy; + + /** 认领时间 */ + private Date claimTime; + + /** 归档时间 */ + private Date archiveTime; + + /** 锁定时间 */ + private Date lockTime; + + /** 锁定原因 */ + private String lockReason; + + /** 乐观锁版本 */ + private Integer dataVersion; + + public Long getReportPk() + { + return reportPk; + } + + public void setReportPk(Long reportPk) + { + this.reportPk = reportPk; + } + + public String getReportId() + { + return reportId; + } + + public void setReportId(String reportId) + { + this.reportId = reportId; + } + + public String getAppId() + { + return appId; + } + + public void setAppId(String appId) + { + this.appId = appId; + } + + public Long getBatchPk() + { + return batchPk; + } + + public void setBatchPk(Long batchPk) + { + this.batchPk = batchPk; + } + + public String getBatchId() + { + return batchId; + } + + public void setBatchId(String batchId) + { + this.batchId = batchId; + } + + public String getReportChannel() + { + return reportChannel; + } + + public void setReportChannel(String reportChannel) + { + this.reportChannel = reportChannel; + } + + public String getBizType() + { + return bizType; + } + + public void setBizType(String bizType) + { + this.bizType = bizType; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getIdCardMasked() + { + return idCardMasked; + } + + public void setIdCardMasked(String idCardMasked) + { + this.idCardMasked = idCardMasked; + } + + public String getIdCardMd5() + { + return idCardMd5; + } + + public void setIdCardMd5(String idCardMd5) + { + this.idCardMd5 = idCardMd5; + } + + public Long getMatchedEnrollmentId() + { + return matchedEnrollmentId; + } + + public void setMatchedEnrollmentId(Long matchedEnrollmentId) + { + this.matchedEnrollmentId = matchedEnrollmentId; + } + + public String getMatchedPlanId() + { + return matchedPlanId; + } + + public void setMatchedPlanId(String matchedPlanId) + { + this.matchedPlanId = matchedPlanId; + } + + public String getMatchStatus() + { + return matchStatus; + } + + public void setMatchStatus(String matchStatus) + { + this.matchStatus = matchStatus; + } + + public String getScoreStatus() + { + return scoreStatus; + } + + public void setScoreStatus(String scoreStatus) + { + this.scoreStatus = scoreStatus; + } + + public String getSubjectCode() + { + return subjectCode; + } + + public void setSubjectCode(String subjectCode) + { + this.subjectCode = subjectCode; + } + + public String getSubjectName() + { + return subjectName; + } + + public void setSubjectName(String subjectName) + { + this.subjectName = subjectName; + } + + public Long getExamStartTime() + { + return examStartTime; + } + + public void setExamStartTime(Long examStartTime) + { + this.examStartTime = examStartTime; + } + + public Long getExamEndTime() + { + return examEndTime; + } + + public void setExamEndTime(Long examEndTime) + { + this.examEndTime = examEndTime; + } + + public Integer getDurationSeconds() + { + return durationSeconds; + } + + public void setDurationSeconds(Integer durationSeconds) + { + this.durationSeconds = durationSeconds; + } + + public BigDecimal getScore() + { + return score; + } + + public void setScore(BigDecimal score) + { + this.score = score; + } + + public BigDecimal getFullScore() + { + return fullScore; + } + + public void setFullScore(BigDecimal fullScore) + { + this.fullScore = fullScore; + } + + public Integer getPassFlag() + { + return passFlag; + } + + public void setPassFlag(Integer passFlag) + { + this.passFlag = passFlag; + } + + public Integer getRetake() + { + return retake; + } + + public void setRetake(Integer retake) + { + this.retake = retake; + } + + public Integer getRetakeNo() + { + return retakeNo; + } + + public void setRetakeNo(Integer retakeNo) + { + this.retakeNo = retakeNo; + } + + public String getStation() + { + return station; + } + + public void setStation(String station) + { + this.station = station; + } + + public String getContent() + { + return content; + } + + public void setContent(String content) + { + this.content = content; + } + + public Integer getContentSize() + { + return contentSize; + } + + public void setContentSize(Integer contentSize) + { + this.contentSize = contentSize; + } + + public String getContentMd5() + { + return contentMd5; + } + + public void setContentMd5(String contentMd5) + { + this.contentMd5 = contentMd5; + } + + public Long getReportTime() + { + return reportTime; + } + + public void setReportTime(Long reportTime) + { + this.reportTime = reportTime; + } + + public Long getReceivedTime() + { + return receivedTime; + } + + public void setReceivedTime(Long receivedTime) + { + this.receivedTime = receivedTime; + } + + public Integer getResend() + { + return resend; + } + + public void setResend(Integer resend) + { + this.resend = resend; + } + + public String getClaimBy() + { + return claimBy; + } + + public void setClaimBy(String claimBy) + { + this.claimBy = claimBy; + } + + public Date getClaimTime() + { + return claimTime; + } + + public void setClaimTime(Date claimTime) + { + this.claimTime = claimTime; + } + + public Date getArchiveTime() + { + return archiveTime; + } + + public void setArchiveTime(Date archiveTime) + { + this.archiveTime = archiveTime; + } + + public Date getLockTime() + { + return lockTime; + } + + public void setLockTime(Date lockTime) + { + this.lockTime = lockTime; + } + + public String getLockReason() + { + return lockReason; + } + + public void setLockReason(String lockReason) + { + this.lockReason = lockReason; + } + + public Integer getDataVersion() + { + return dataVersion; + } + + public void setDataVersion(Integer dataVersion) + { + this.dataVersion = dataVersion; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("reportPk", getReportPk()) + .append("reportId", getReportId()) + .append("appId", getAppId()) + .append("batchId", getBatchId()) + .append("reportChannel", getReportChannel()) + .append("studentId", getStudentId()) + .append("matchStatus", getMatchStatus()) + .append("scoreStatus", getScoreStatus()) + .append("subjectCode", getSubjectCode()) + .append("subjectName", getSubjectName()) + .append("examStartTime", getExamStartTime()) + .append("examEndTime", getExamEndTime()) + .append("score", getScore()) + .append("fullScore", getFullScore()) + .append("passFlag", getPassFlag()) + .append("retake", getRetake()) + .append("retakeNo", getRetakeNo()) + .append("station", getStation()) + .append("receivedTime", getReceivedTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamReportBatch.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamReportBatch.java new file mode 100644 index 000000000..e0fa946e3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamReportBatch.java @@ -0,0 +1,212 @@ +package com.ruoyi.train.domain; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 考试成绩上报批次对象 train_exam_report_batch + * + * @author ruoyi + * @date 2026-11-05 + */ +public class TrainExamReportBatch extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long batchPk; + + /** 应用标识 */ + @Excel(name = "应用标识") + private String appId; + + /** 批次号 */ + @Excel(name = "批次号") + private String batchId; + + /** 上报时间(毫秒) */ + private Long reportTime; + + /** 覆盖窗口开始(毫秒,含) */ + private Long periodStart; + + /** 覆盖窗口结束(毫秒,不含) */ + private Long periodEnd; + + /** 补传标记(1补传 0正常) */ + private Integer resend; + + /** 本批记录数 */ + @Excel(name = "记录数") + private Integer total; + + /** 入库数 */ + @Excel(name = "入库数") + private Integer accepted; + + /** 幂等忽略数 */ + @Excel(name = "幂等忽略") + private Integer duplicated; + + /** 未匹配数 */ + @Excel(name = "未匹配") + private Integer unmatched; + + /** 平台接收时间(毫秒) */ + private Long receivedTime; + + /** 请求标识 */ + private String reqId; + + public void setBatchPk(Long batchPk) + { + this.batchPk = batchPk; + } + + public Long getBatchPk() + { + return batchPk; + } + + public void setAppId(String appId) + { + this.appId = appId; + } + + public String getAppId() + { + return appId; + } + + public void setBatchId(String batchId) + { + this.batchId = batchId; + } + + public String getBatchId() + { + return batchId; + } + + public void setReportTime(Long reportTime) + { + this.reportTime = reportTime; + } + + public Long getReportTime() + { + return reportTime; + } + + public void setPeriodStart(Long periodStart) + { + this.periodStart = periodStart; + } + + public Long getPeriodStart() + { + return periodStart; + } + + public void setPeriodEnd(Long periodEnd) + { + this.periodEnd = periodEnd; + } + + public Long getPeriodEnd() + { + return periodEnd; + } + + public void setResend(Integer resend) + { + this.resend = resend; + } + + public Integer getResend() + { + return resend; + } + + public void setTotal(Integer total) + { + this.total = total; + } + + public Integer getTotal() + { + return total; + } + + public void setAccepted(Integer accepted) + { + this.accepted = accepted; + } + + public Integer getAccepted() + { + return accepted; + } + + public void setDuplicated(Integer duplicated) + { + this.duplicated = duplicated; + } + + public Integer getDuplicated() + { + return duplicated; + } + + public void setUnmatched(Integer unmatched) + { + this.unmatched = unmatched; + } + + public Integer getUnmatched() + { + return unmatched; + } + + public void setReceivedTime(Long receivedTime) + { + this.receivedTime = receivedTime; + } + + public Long getReceivedTime() + { + return receivedTime; + } + + public void setReqId(String reqId) + { + this.reqId = reqId; + } + + public String getReqId() + { + return reqId; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("batchPk", getBatchPk()) + .append("appId", getAppId()) + .append("batchId", getBatchId()) + .append("reportTime", getReportTime()) + .append("periodStart", getPeriodStart()) + .append("periodEnd", getPeriodEnd()) + .append("resend", getResend()) + .append("total", getTotal()) + .append("accepted", getAccepted()) + .append("duplicated", getDuplicated()) + .append("unmatched", getUnmatched()) + .append("receivedTime", getReceivedTime()) + .append("reqId", getReqId()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamSubject.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamSubject.java new file mode 100644 index 000000000..dc1ff928f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExamSubject.java @@ -0,0 +1,132 @@ +package com.ruoyi.train.domain; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 考试科目字典对象 train_exam_subject + * + * @author ruoyi + * @date 2026-11-05 + */ +public class TrainExamSubject extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long subjectId; + + /** 科目编码 */ + @Excel(name = "科目编码") + private String subjectCode; + + /** 科目名称 */ + @Excel(name = "科目名称") + private String subjectName; + + /** 所属培训班(空表示全局科目) */ + @Excel(name = "所属培训班") + private String planId; + + /** 归档考核项ID(train_score_item) */ + private Long scoreItemId; + + /** 状态(1启用 0停用) */ + @Excel(name = "状态", readConverterExp = "1=启用,0=停用") + private Integer status; + + /** 展示顺序 */ + @Excel(name = "展示顺序") + private Integer sortNo; + + public void setSubjectId(Long subjectId) + { + this.subjectId = subjectId; + } + + public Long getSubjectId() + { + return subjectId; + } + + public void setSubjectCode(String subjectCode) + { + this.subjectCode = subjectCode; + } + + public String getSubjectCode() + { + return subjectCode; + } + + public void setSubjectName(String subjectName) + { + this.subjectName = subjectName; + } + + public String getSubjectName() + { + return subjectName; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanId() + { + return planId; + } + + public void setScoreItemId(Long scoreItemId) + { + this.scoreItemId = scoreItemId; + } + + public Long getScoreItemId() + { + return scoreItemId; + } + + public void setStatus(Integer status) + { + this.status = status; + } + + public Integer getStatus() + { + return status; + } + + public void setSortNo(Integer sortNo) + { + this.sortNo = sortNo; + } + + public Integer getSortNo() + { + return sortNo; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("subjectId", getSubjectId()) + .append("subjectCode", getSubjectCode()) + .append("subjectName", getSubjectName()) + .append("planId", getPlanId()) + .append("scoreItemId", getScoreItemId()) + .append("status", getStatus()) + .append("sortNo", getSortNo()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenApiLog.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenApiLog.java new file mode 100644 index 000000000..9b6bab875 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenApiLog.java @@ -0,0 +1,164 @@ +package com.ruoyi.train.domain; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 开放接口调用日志对象 train_open_api_log + * + * @author ruoyi + * @date 2026-11-05 + */ +public class TrainOpenApiLog extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long logId; + + /** 请求标识 */ + private String reqId; + + /** 应用标识 */ + private String appId; + + /** 请求路径 */ + private String apiPath; + + /** HTTP 方法 */ + private String httpMethod; + + /** 业务键 */ + private String bizKey; + + /** 业务返回码 */ + private Integer code; + + /** 返回描述 */ + private String msg; + + /** 耗时(毫秒) */ + private Integer costMs; + + /** 调用方 IP */ + private String clientIp; + + public Long getLogId() + { + return logId; + } + + public void setLogId(Long logId) + { + this.logId = logId; + } + + public String getReqId() + { + return reqId; + } + + public void setReqId(String reqId) + { + this.reqId = reqId; + } + + public String getAppId() + { + return appId; + } + + public void setAppId(String appId) + { + this.appId = appId; + } + + public String getApiPath() + { + return apiPath; + } + + public void setApiPath(String apiPath) + { + this.apiPath = apiPath; + } + + public String getHttpMethod() + { + return httpMethod; + } + + public void setHttpMethod(String httpMethod) + { + this.httpMethod = httpMethod; + } + + public String getBizKey() + { + return bizKey; + } + + public void setBizKey(String bizKey) + { + this.bizKey = bizKey; + } + + public Integer getCode() + { + return code; + } + + public void setCode(Integer code) + { + this.code = code; + } + + public String getMsg() + { + return msg; + } + + public void setMsg(String msg) + { + this.msg = msg; + } + + public Integer getCostMs() + { + return costMs; + } + + public void setCostMs(Integer costMs) + { + this.costMs = costMs; + } + + public String getClientIp() + { + return clientIp; + } + + public void setClientIp(String clientIp) + { + this.clientIp = clientIp; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("logId", getLogId()) + .append("reqId", getReqId()) + .append("appId", getAppId()) + .append("apiPath", getApiPath()) + .append("httpMethod", getHttpMethod()) + .append("bizKey", getBizKey()) + .append("code", getCode()) + .append("msg", getMsg()) + .append("costMs", getCostMs()) + .append("clientIp", getClientIp()) + .append("createTime", getCreateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenApp.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenApp.java new file mode 100644 index 000000000..0c77e6cf8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenApp.java @@ -0,0 +1,205 @@ +package com.ruoyi.train.domain; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 外部实训系统应用对象 train_open_app + * + * @author ruoyi + * @date 2026-11-05 + */ +public class TrainOpenApp extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 应用标识(报文 appId) */ + @Excel(name = "应用标识") + private String appId; + + /** 应用名称 */ + @Excel(name = "应用名称") + private String appName; + + /** 应用类型(VR/INSPECT3D/PRACTICE/CAPSTONE/OTHER) */ + @Excel(name = "应用类型", readConverterExp = "VR=VR实训,INSPECT3D=3D巡检,PRACTICE=实操实训,CAPSTONE=帽石考核,OTHER=其他") + private String appType; + + /** 归属单位 */ + @Excel(name = "归属单位") + private String orgName; + + /** appSecret 散列(BCrypt,不存明文) */ + private String appSecretHash; + + /** 密钥掩码 */ + @Excel(name = "密钥") + private String appSecretMasked; + + /** 联系人 */ + @Excel(name = "联系人") + private String contactName; + + /** 联系电话 */ + @Excel(name = "联系电话") + private String contactPhone; + + /** 每秒请求上限 */ + @Excel(name = "每秒请求上限") + private Integer rateLimit; + + /** 是否启用实时上报(1启用 0关闭) */ + @Excel(name = "实时上报", readConverterExp = "1=启用,0=关闭") + private Integer realtimeEnabled; + + /** 状态(1启用 0停用) */ + @Excel(name = "状态", readConverterExp = "1=启用,0=停用") + private Integer status; + + /** 表单明文密钥(仅新增/重置时使用,不落库、不参与序列化输出查询结果) */ + private transient String appSecret; + + public void setAppId(String appId) + { + this.appId = appId; + } + + public String getAppId() + { + return appId; + } + + public void setAppName(String appName) + { + this.appName = appName; + } + + public String getAppName() + { + return appName; + } + + public void setAppType(String appType) + { + this.appType = appType; + } + + public String getAppType() + { + return appType; + } + + public void setOrgName(String orgName) + { + this.orgName = orgName; + } + + public String getOrgName() + { + return orgName; + } + + public void setAppSecretHash(String appSecretHash) + { + this.appSecretHash = appSecretHash; + } + + public String getAppSecretHash() + { + return appSecretHash; + } + + public void setAppSecretMasked(String appSecretMasked) + { + this.appSecretMasked = appSecretMasked; + } + + public String getAppSecretMasked() + { + return appSecretMasked; + } + + public void setContactName(String contactName) + { + this.contactName = contactName; + } + + public String getContactName() + { + return contactName; + } + + public void setContactPhone(String contactPhone) + { + this.contactPhone = contactPhone; + } + + public String getContactPhone() + { + return contactPhone; + } + + public void setRateLimit(Integer rateLimit) + { + this.rateLimit = rateLimit; + } + + public Integer getRateLimit() + { + return rateLimit; + } + + public void setRealtimeEnabled(Integer realtimeEnabled) + { + this.realtimeEnabled = realtimeEnabled; + } + + public Integer getRealtimeEnabled() + { + return realtimeEnabled; + } + + public void setStatus(Integer status) + { + this.status = status; + } + + public Integer getStatus() + { + return status; + } + + public void setAppSecret(String appSecret) + { + this.appSecret = appSecret; + } + + public String getAppSecret() + { + return appSecret; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("appId", getAppId()) + .append("appName", getAppName()) + .append("appType", getAppType()) + .append("orgName", getOrgName()) + .append("appSecretMasked", getAppSecretMasked()) + .append("contactName", getContactName()) + .append("contactPhone", getContactPhone()) + .append("rateLimit", getRateLimit()) + .append("realtimeEnabled", getRealtimeEnabled()) + .append("status", getStatus()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenAppScope.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenAppScope.java new file mode 100644 index 000000000..8e0371c0f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenAppScope.java @@ -0,0 +1,84 @@ +package com.ruoyi.train.domain; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 外部应用授权范围对象 train_open_app_scope + * + * @author ruoyi + * @date 2026-11-05 + */ +public class TrainOpenAppScope extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long scopeId; + + /** 应用标识 */ + private String appId; + + /** 范围类型(CLASS 班级 / SUBJECT 科目) */ + @Excel(name = "范围类型", readConverterExp = "CLASS=班级,SUBJECT=科目") + private String scopeType; + + /** 班级 plan_id 或科目 subject_code */ + @Excel(name = "范围取值") + private String scopeValue; + + public void setScopeId(Long scopeId) + { + this.scopeId = scopeId; + } + + public Long getScopeId() + { + return scopeId; + } + + public void setAppId(String appId) + { + this.appId = appId; + } + + public String getAppId() + { + return appId; + } + + public void setScopeType(String scopeType) + { + this.scopeType = scopeType; + } + + public String getScopeType() + { + return scopeType; + } + + public void setScopeValue(String scopeValue) + { + this.scopeValue = scopeValue; + } + + public String getScopeValue() + { + return scopeValue; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("scopeId", getScopeId()) + .append("appId", getAppId()) + .append("scopeType", getScopeType()) + .append("scopeValue", getScopeValue()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenToken.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenToken.java new file mode 100644 index 000000000..7911f2bdc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainOpenToken.java @@ -0,0 +1,135 @@ +package com.ruoyi.train.domain; + +import java.util.Date; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 外部应用访问令牌对象 train_open_token + * + * @author ruoyi + * @date 2026-11-05 + */ +public class TrainOpenToken extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long tokenId; + + /** 应用标识 */ + private String appId; + + /** 令牌 SHA-256 */ + private String tokenHash; + + /** 签发时间 */ + private Date issuedTime; + + /** 过期时间 */ + private Date expireTime; + + /** 状态(1有效 0失效) */ + private Integer status; + + /** 签发来源 IP */ + private String clientIp; + + /** 客户端标识 */ + private String userAgent; + + public Long getTokenId() + { + return tokenId; + } + + public void setTokenId(Long tokenId) + { + this.tokenId = tokenId; + } + + public String getAppId() + { + return appId; + } + + public void setAppId(String appId) + { + this.appId = appId; + } + + public String getTokenHash() + { + return tokenHash; + } + + public void setTokenHash(String tokenHash) + { + this.tokenHash = tokenHash; + } + + public Date getIssuedTime() + { + return issuedTime; + } + + public void setIssuedTime(Date issuedTime) + { + this.issuedTime = issuedTime; + } + + public Date getExpireTime() + { + return expireTime; + } + + public void setExpireTime(Date expireTime) + { + this.expireTime = expireTime; + } + + public Integer getStatus() + { + return status; + } + + public void setStatus(Integer status) + { + this.status = status; + } + + public String getClientIp() + { + return clientIp; + } + + public void setClientIp(String clientIp) + { + this.clientIp = clientIp; + } + + public String getUserAgent() + { + return userAgent; + } + + public void setUserAgent(String userAgent) + { + this.userAgent = userAgent; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("tokenId", getTokenId()) + .append("appId", getAppId()) + .append("issuedTime", getIssuedTime()) + .append("expireTime", getExpireTime()) + .append("status", getStatus()) + .append("clientIp", getClientIp()) + .append("createTime", getCreateTime()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainResource.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainResource.java index 165042a90..0efe22cf6 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainResource.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainResource.java @@ -58,6 +58,9 @@ public class TrainResource extends BaseEntity /** 删除标志 (0代表存在 2代表删除) */ private String delFlag; + /** 自增编号(数据库递增序号)。 */ + private Long id; + public void setResourceId(String resourceId) { this.resourceId = resourceId; @@ -128,6 +131,14 @@ public class TrainResource extends BaseEntity return delFlag; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + @Override public String toString() { @@ -139,6 +150,7 @@ public class TrainResource extends BaseEntity .append("description", getDescription()) .append("status", getStatus()) .append("delFlag", getDelFlag()) + .append("id", getId()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java index a26ddec55..cd8765add 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java @@ -3,6 +3,7 @@ package com.ruoyi.train.domain; import java.util.Date; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.ruoyi.common.core.domain.BaseEntity; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; @@ -15,21 +16,27 @@ import org.apache.commons.lang3.builder.ToStringStyle; public class TrainStudent extends BaseEntity { private static final long serialVersionUID = 1L; - + @JsonProperty("sId") private String sId; + @JsonProperty("sName") private String sName; /** 人员编号。 */ private String personNo; - + @JsonProperty("sGender") private String sGender; @JsonFormat(pattern = "yyyy-MM-dd") + @JsonProperty("sBirth") private Date sBirth; private Long userId; - private String orgName; + private String deptName; + /** 卡号。 */ + private String cardNo; + @JsonProperty("sPhone") private String sPhone; + @JsonProperty("sEmail") private String sEmail; /** 可逆密文。严禁序列化到 API 或 Excel。 */ @@ -50,6 +57,36 @@ public class TrainStudent extends BaseEntity private Integer dutyLevel; + /** 人脸特征提取状态(业务约定:0 未提取/待提取,1 成功,2 失败 等)。 */ + private String extractStatus; + + /** 人脸特征提取时间。 */ + private Date extractTime; + + /** 人脸特征提取结果描述/错误信息。 */ + private String extractMsg; + + /** 人脸照片 MD5(32 位十六进制)。 */ + private String photoMd5; + + /** 人脸特征数据(存储约定视实现而定,如 base64 文本)。 */ + private String featureData; + + /** 人脸特征 MD5(32 位十六进制)。 */ + private String featureMd5; + + /** 人脸特征算法版本。 */ + private String featureAlgoVersion; + + /** 人脸特征数据字节大小。 */ + private Integer featureSize; + + /** 自增编号(数据库递增序号)。 */ + private Long id; + + /** 部门ID,对应组织架构 sys_dept.dept_id。 */ + private Long deptId; + public String getsId() { return sId; @@ -110,14 +147,24 @@ public class TrainStudent extends BaseEntity this.userId = userId; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; + } + + public String getCardNo() + { + return cardNo; + } + + public void setCardNo(String cardNo) + { + this.cardNo = cardNo; } public String getsPhone() @@ -228,6 +275,51 @@ public class TrainStudent extends BaseEntity this.dutyLevel = dutyLevel; } + public String getExtractStatus() { + return extractStatus; + } + + public void setExtractStatus(String extractStatus) { + this.extractStatus = extractStatus; + } + + public Date getExtractTime() { + return extractTime; + } + + public void setExtractTime(Date extractTime) { + this.extractTime = extractTime; + } + + public String getExtractMsg() { + return extractMsg; + } + + public void setExtractMsg(String extractMsg) { + this.extractMsg = extractMsg; + } + + public String getPhotoMd5() { return photoMd5; } + public void setPhotoMd5(String photoMd5) { this.photoMd5 = photoMd5; } + + public String getFeatureData() { return featureData; } + public void setFeatureData(String featureData) { this.featureData = featureData; } + + public String getFeatureMd5() { return featureMd5; } + public void setFeatureMd5(String featureMd5) { this.featureMd5 = featureMd5; } + + public String getFeatureAlgoVersion() { return featureAlgoVersion; } + public void setFeatureAlgoVersion(String featureAlgoVersion) { this.featureAlgoVersion = featureAlgoVersion; } + + public Integer getFeatureSize() { return featureSize; } + public void setFeatureSize(Integer featureSize) { this.featureSize = featureSize; } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getDeptId() { return deptId; } + public void setDeptId(Long deptId) { this.deptId = deptId; } + /** * 安全日志表示:刻意不包含身份证密文、查重值或任何完整身份证信息。 */ @@ -241,7 +333,8 @@ public class TrainStudent extends BaseEntity .append("sGender", getsGender()) .append("sBirth", getsBirth()) .append("userId", getUserId()) - .append("orgName", getOrgName()) + .append("deptName", getDeptName()) + .append("cardNo", getCardNo()) .append("sPhone", getsPhone()) .append("sEmail", getsEmail()) .append("idCardLast4", getIdCardLast4()) @@ -256,6 +349,16 @@ public class TrainStudent extends BaseEntity .append("remark", getRemark()) .append("dutyLevel", getDutyLevel()) .append("avatar", getAvatar()) + .append("extractStatus", getExtractStatus()) + .append("extractTime", getExtractTime()) + .append("extractMsg", getExtractMsg()) + .append("photoMd5", getPhotoMd5()) + .append("featureData", getFeatureData()) + .append("featureMd5", getFeatureMd5()) + .append("featureAlgoVersion", getFeatureAlgoVersion()) + .append("featureSize", getFeatureSize()) + .append("id", getId()) + .append("deptId", getDeptId()) .toString(); } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudentCheckin.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudentCheckin.java index bad869c3b..6756df5d2 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudentCheckin.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudentCheckin.java @@ -15,6 +15,9 @@ public class TrainStudentCheckin extends BaseEntity private String planId; @Excel(name = "培训计划") private String planName; + private String className; + /** 学员ID (持久化列 train_student_checkin.student_id,冗余自参训档案,varchar(32)) */ + @Excel(name = "学员ID") private String studentId; @Excel(name = "学员") private String studentName; @@ -70,4 +73,6 @@ public class TrainStudentCheckin extends BaseEntity public void setAttendanceStatus(Integer attendanceStatus) { this.attendanceStatus = attendanceStatus; } public Integer getManualFlag() { return manualFlag; } public void setManualFlag(Integer manualFlag) { this.manualFlag = manualFlag; } + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java index 42651bdae..3caca487a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java @@ -2,6 +2,7 @@ package com.ruoyi.train.domain; import java.util.Date; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import com.ruoyi.common.annotation.Excel; @@ -18,23 +19,31 @@ public class TrainTeacher extends BaseEntity private static final long serialVersionUID = 1L; /** 教师工号/标识 */ + @JsonProperty("tId") private String tId; /** 教师姓名 */ @Excel(name = "教师姓名") + @JsonProperty("tName") private String tName; /** 人员编号 */ @Excel(name = "人员编号") private String personNo; + /** 教师卡号 */ + @Excel(name = "教师卡号") + private String cardNo; + /** 性别:男 / 女 */ @Excel(name = "性别") + @JsonProperty("tGender") private String tGender; /** 出生日期 */ @JsonFormat(pattern = "yyyy-MM-dd") @Excel(name = "出生日期", width = 30, dateFormat = "yyyy-MM-dd") + @JsonProperty("tBirth") private Date tBirth; /** sys_user登录账号逻辑关联ID */ @@ -43,7 +52,7 @@ public class TrainTeacher extends BaseEntity /** 所属单位/部门名称 (由旧 t_department 迁移) */ @Excel(name = "所属单位") - private String orgName; + private String deptName; /** 职务/职称 (由旧 t_title 迁移) */ @Excel(name = "职务/职称") @@ -51,19 +60,37 @@ public class TrainTeacher extends BaseEntity /** 联系电话 */ @Excel(name = "联系电话") + @JsonProperty("tPhone") private String tPhone; /** 邮箱 */ @Excel(name = "邮箱") + @JsonProperty("tEmail") private String tEmail; /** 状态 (1:正常, 0:停用) */ @Excel(name = "状态", readConverterExp = "1=正常,0=停用") private Integer status; + /** 参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成),实际业务主要使用 0/1 */ + @Excel(name = "参训状态", readConverterExp = "0=待报到,1=已报到,2=请假,3=放弃,4=已完成") + private Integer enrollmentStatus; + + /** 报到时间(与 train_enrollment.join_time 定义一致:可空、无默认值) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "报到时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date joinTime; + + /** 是否需要住宿 (0:否, 1:是)(与 train_enrollment.accommodation_required 定义一致:不可空、无默认值) */ + @Excel(name = "是否需要住宿", readConverterExp = "0=否,1=是") + private Integer accommodationRequired; + /** 删除标志 (0:存在, 2:删除) */ private String delFlag; + /** 自增编号(数据库递增序号)。 */ + private Long id; + private String avatar; private String major; @@ -113,6 +140,16 @@ public class TrainTeacher extends BaseEntity this.personNo = personNo; } + public String getCardNo() + { + return cardNo; + } + + public void setCardNo(String cardNo) + { + this.cardNo = cardNo; + } + public void settGender(String tGender) { this.tGender = tGender; @@ -143,14 +180,14 @@ public class TrainTeacher extends BaseEntity return userId; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } public void setJobTitle(String jobTitle) @@ -193,6 +230,36 @@ public class TrainTeacher extends BaseEntity return status; } + public void setEnrollmentStatus(Integer enrollmentStatus) + { + this.enrollmentStatus = enrollmentStatus; + } + + public Integer getEnrollmentStatus() + { + return enrollmentStatus; + } + + public void setJoinTime(Date joinTime) + { + this.joinTime = joinTime; + } + + public Date getJoinTime() + { + return joinTime; + } + + public void setAccommodationRequired(Integer accommodationRequired) + { + this.accommodationRequired = accommodationRequired; + } + + public Integer getAccommodationRequired() + { + return accommodationRequired; + } + public void setDelFlag(String delFlag) { this.delFlag = delFlag; @@ -203,6 +270,14 @@ public class TrainTeacher extends BaseEntity return delFlag; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public String getAvatar() { return avatar; } @@ -241,15 +316,20 @@ public class TrainTeacher extends BaseEntity .append("tId", gettId()) .append("tName", gettName()) .append("personNo", getPersonNo()) + .append("cardNo", getCardNo()) .append("tGender", gettGender()) .append("tBirth", gettBirth()) .append("userId", getUserId()) - .append("orgName", getOrgName()) + .append("deptName", getDeptName()) .append("jobTitle", getJobTitle()) .append("tPhone", gettPhone()) .append("tEmail", gettEmail()) .append("status", getStatus()) + .append("enrollmentStatus", getEnrollmentStatus()) + .append("joinTime", getJoinTime()) + .append("accommodationRequired", getAccommodationRequired()) .append("delFlag", getDelFlag()) + .append("id", getId()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTheoryExamScore.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTheoryExamScore.java new file mode 100644 index 000000000..95eb4024a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTheoryExamScore.java @@ -0,0 +1,386 @@ +package com.ruoyi.train.domain; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 班级学员理论考试成绩对象 train_theory_exam_score + * + *

粒度:参训档案(enrollment_id) × 班级课程(plan_course_id) × 考试次数(retake_no), + * 一行代表某学员在某个班级某门课程上的一次理论考试结果。

+ * + * @author ruoyi + * @date 2026-11-06 + */ +public class TrainTheoryExamScore extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long theoryScoreId; + + /** 参训档案ID(train_enrollment.enrollment_id) */ + private Long enrollmentId; + + /** 培训班ID(train_class_plan.plan_id) */ + @Excel(name = "培训班ID") + private String planId; + + /** 培训课程实例ID(train_plan_course.plan_course_id) */ + private Long planCourseId; + + /** 课程主档ID(train_course.c_id) */ + @Excel(name = "课程ID") + private String cId; + + /** 学员ID(train_student.s_id) */ + @Excel(name = "学员ID") + private String studentId; + + /** 理论科目编码(train_exam_subject.subject_code,可为空) */ + @Excel(name = "科目编码") + private String subjectCode; + + /** 考试开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "考试开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date examStartTime; + + /** 考试结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "考试结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date examEndTime; + + /** 用时秒数 */ + private Integer durationSeconds; + + /** 是否补考(1是 0否) */ + @Excel(name = "是否补考", readConverterExp = "1=是,0=否") + private Integer retake; + + /** 补考序号(0正考,1..n 第n次补考) */ + private Integer retakeNo; + + /** 考试状态(NORMAL正常 ABSENT缺考 EXEMPT免修) */ + @Excel(name = "考试状态") + private String examStatus; + + /** 理论成绩(缺考/免修允许为空) */ + @Excel(name = "理论成绩") + private BigDecimal score; + + /** 满分 */ + @Excel(name = "满分") + private BigDecimal fullScore; + + /** 是否通过(1通过 0未通过) */ + @Excel(name = "是否通过", readConverterExp = "1=通过,0=未通过") + private Integer passFlag; + + /** 是否最终成绩(1是 0否) */ + @Excel(name = "是否最终成绩", readConverterExp = "1=是,0=否") + private Integer isFinal; + + /** 成绩状态(RECEIVED已接收 ARCHIVED已归档 LOCKED已锁定) */ + @Excel(name = "成绩状态") + private String scoreStatus; + + /** 成绩来源(1手工录入 2平台在线考试 3批量或外部导入) */ + @Excel(name = "成绩来源", readConverterExp = "1=手工录入,2=平台在线考试,3=批量或外部导入") + private Integer sourceType; + + /** 来源系统标识 */ + private String sourceSystem; + + /** 外部事件唯一ID(导入幂等键) */ + private String sourceEventId; + + /** 关联综合成绩项ID(train_score_item.score_item_id) */ + private Long scoreItemId; + + /** 删除标志(0存在 2删除) */ + private String delFlag; + + /** 最终成绩唯一约束辅助列(数据库生成列,只读,业务不赋值) */ + private Integer finalFlag; + + public void setTheoryScoreId(Long theoryScoreId) + { + this.theoryScoreId = theoryScoreId; + } + + public Long getTheoryScoreId() + { + return theoryScoreId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setCId(String cId) + { + this.cId = cId; + } + + public String getCId() + { + return cId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentId() + { + return studentId; + } + + public void setSubjectCode(String subjectCode) + { + this.subjectCode = subjectCode; + } + + public String getSubjectCode() + { + return subjectCode; + } + + public void setExamStartTime(Date examStartTime) + { + this.examStartTime = examStartTime; + } + + public Date getExamStartTime() + { + return examStartTime; + } + + public void setExamEndTime(Date examEndTime) + { + this.examEndTime = examEndTime; + } + + public Date getExamEndTime() + { + return examEndTime; + } + + public void setDurationSeconds(Integer durationSeconds) + { + this.durationSeconds = durationSeconds; + } + + public Integer getDurationSeconds() + { + return durationSeconds; + } + + public void setRetake(Integer retake) + { + this.retake = retake; + } + + public Integer getRetake() + { + return retake; + } + + public void setRetakeNo(Integer retakeNo) + { + this.retakeNo = retakeNo; + } + + public Integer getRetakeNo() + { + return retakeNo; + } + + public void setExamStatus(String examStatus) + { + this.examStatus = examStatus; + } + + public String getExamStatus() + { + return examStatus; + } + + public void setScore(BigDecimal score) + { + this.score = score; + } + + public BigDecimal getScore() + { + return score; + } + + public void setFullScore(BigDecimal fullScore) + { + this.fullScore = fullScore; + } + + public BigDecimal getFullScore() + { + return fullScore; + } + + public void setPassFlag(Integer passFlag) + { + this.passFlag = passFlag; + } + + public Integer getPassFlag() + { + return passFlag; + } + + public void setIsFinal(Integer isFinal) + { + this.isFinal = isFinal; + } + + public Integer getIsFinal() + { + return isFinal; + } + + public void setScoreStatus(String scoreStatus) + { + this.scoreStatus = scoreStatus; + } + + public String getScoreStatus() + { + return scoreStatus; + } + + public void setSourceType(Integer sourceType) + { + this.sourceType = sourceType; + } + + public Integer getSourceType() + { + return sourceType; + } + + public void setSourceSystem(String sourceSystem) + { + this.sourceSystem = sourceSystem; + } + + public String getSourceSystem() + { + return sourceSystem; + } + + public void setSourceEventId(String sourceEventId) + { + this.sourceEventId = sourceEventId; + } + + public String getSourceEventId() + { + return sourceEventId; + } + + public void setScoreItemId(Long scoreItemId) + { + this.scoreItemId = scoreItemId; + } + + public Long getScoreItemId() + { + return scoreItemId; + } + + public void setDelFlag(String delFlag) + { + this.delFlag = delFlag; + } + + public String getDelFlag() + { + return delFlag; + } + + public void setFinalFlag(Integer finalFlag) + { + this.finalFlag = finalFlag; + } + + public Integer getFinalFlag() + { + return finalFlag; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("theoryScoreId", getTheoryScoreId()) + .append("enrollmentId", getEnrollmentId()) + .append("planId", getPlanId()) + .append("planCourseId", getPlanCourseId()) + .append("cId", getCId()) + .append("studentId", getStudentId()) + .append("subjectCode", getSubjectCode()) + .append("examStartTime", getExamStartTime()) + .append("examEndTime", getExamEndTime()) + .append("durationSeconds", getDurationSeconds()) + .append("retake", getRetake()) + .append("retakeNo", getRetakeNo()) + .append("examStatus", getExamStatus()) + .append("score", getScore()) + .append("fullScore", getFullScore()) + .append("passFlag", getPassFlag()) + .append("isFinal", getIsFinal()) + .append("scoreStatus", getScoreStatus()) + .append("sourceType", getSourceType()) + .append("sourceSystem", getSourceSystem()) + .append("sourceEventId", getSourceEventId()) + .append("scoreItemId", getScoreItemId()) + .append("delFlag", getDelFlag()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTrainingSite.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTrainingSite.java index 1f2285149..17051f9a4 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTrainingSite.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTrainingSite.java @@ -50,10 +50,16 @@ public class TrainTrainingSite extends BaseEntity /** 删除标志 (0代表存在 2代表删除) */ private String delFlag; + /** 自增编号(数据库递增序号)。 */ + private Long id; + private String major; private String seatArrange; + /** 绑定的电子班牌ID(关联 train_electronic_board.board_id),可为空 */ + private String bindBoardId; + public void setSiteId(String siteId) { this.siteId = siteId; @@ -124,6 +130,14 @@ public class TrainTrainingSite extends BaseEntity return delFlag; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public String getMajor() { return major; } @@ -140,6 +154,14 @@ public class TrainTrainingSite extends BaseEntity this.seatArrange = seatArrange; } + public String getBindBoardId() { + return bindBoardId; + } + + public void setBindBoardId(String bindBoardId) { + this.bindBoardId = bindBoardId; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -150,6 +172,7 @@ public class TrainTrainingSite extends BaseEntity .append("siteDevice", getSiteDevice()) .append("siteStatus", getSiteStatus()) .append("delFlag", getDelFlag()) + .append("id", getId()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) @@ -157,6 +180,7 @@ public class TrainTrainingSite extends BaseEntity .append("remark", getRemark()) .append("major", getMajor()) .append("seatArrange", getSeatArrange()) + .append("bindBoardId", getBindBoardId()) .toString(); } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/ScoreAnalysisFilterDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/ScoreAnalysisFilterDto.java new file mode 100644 index 000000000..42cb84dfd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/ScoreAnalysisFilterDto.java @@ -0,0 +1,67 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 成绩统计分析查询入参 DTO + * + * 说明:仅新增对象,不影响既有考勤分析入参。 + */ +public class ScoreAnalysisFilterDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 开始日期 (格式: yyyy-MM-dd) */ + private String startDate; + + /** 结束日期 (格式: yyyy-MM-dd) */ + private String endDate; + + /** 培训班ID */ + private String planId; + + /** 课程主档ID */ + private String courseId; + + /** 成绩类别:ALL 全部 / THEORY 理论 / PRACTICE 实训 */ + private String category; + + /** 实训上报来源平台 (train_open_app.app_id) */ + private String appId; + + /** 理论考试状态:NORMAL 正常 / ABSENT 缺考 / EXEMPT 免修 */ + private String examStatus; + + /** 成绩状态:RECEIVED 已接收 / ARCHIVED 已归档 / LOCKED 已锁定 */ + private String scoreStatus; + + /** 统计维度:plan 班级 / course 课程 / category 成绩类别 / pass 通过率 / status 成绩状态 / scoreRange 分数区间 / teacher 教师 */ + private String dimension; + + public String getStartDate() { return startDate; } + public void setStartDate(String startDate) { this.startDate = startDate; } + + public String getEndDate() { return endDate; } + public void setEndDate(String endDate) { this.endDate = endDate; } + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public String getCourseId() { return courseId; } + public void setCourseId(String courseId) { this.courseId = courseId; } + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getExamStatus() { return examStatus; } + public void setExamStatus(String examStatus) { this.examStatus = examStatus; } + + public String getScoreStatus() { return scoreStatus; } + public void setScoreStatus(String scoreStatus) { this.scoreStatus = scoreStatus; } + + public String getDimension() { return dimension; } + public void setDimension(String dimension) { this.dimension = dimension; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/Student360Vo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/Student360Vo.java index 92492b486..10186dc04 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/Student360Vo.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/Student360Vo.java @@ -23,11 +23,38 @@ public class Student360Vo implements Serializable private String phone; private String email; - private String orgName; + private String deptName; private String idCardMasked; private String userBindingStatus; private Integer status; + /** 学员头像 (train_student.avatar,相对路径 /profile/... 或 http(s) 全地址,可为空) */ + private String avatar; + + /** + * 画像扩展节点(七类维度 + 分层/标签/活跃度/风险/班级对比/轻量趋势)。 + * 仅在扩展能力可用时填充,为空不影响本对象既有字段。 + */ + private StudentPortraitVo portrait; + + /** 人脸特征提取状态 */ + private String extractStatus; + /** 人脸特征提取时间 */ + private Date extractTime; + /** 人脸特征提取结果描述 */ + private String extractMsg; + + /** 人脸照片 MD5 */ + private String photoMd5; + /** 人脸特征数据 */ + private String featureData; + /** 人脸特征 MD5 */ + private String featureMd5; + /** 人脸特征算法版本 */ + private String featureAlgoVersion; + /** 人脸特征数据大小 */ + private Integer featureSize; + /* ========== 2. 培训履历时间轴 ========== */ private List enrollmentTimeline; @@ -42,10 +69,22 @@ public class Student360Vo implements Serializable private Double attendanceRate; private String metricDefinitionNotice = "BUSINESS_METRIC_DEFINITION_PENDING_CONFIRMATION: 个人出勤率采用学员所属班级已开课次应到数为分母,待甲方最终确认"; - /* ========== 4. 成绩状态 (严格不造假) ========== */ + /** 考勤明细(本人所有课程,按课次时间倒序) */ + private List attendanceDetails; + + /* ========== 4. 成绩状态 (理论 + 实训,严格不造假) ========== */ private Boolean hasScoreData = false; private String scoreStatusDesc = "暂无成绩数据 (V2成绩表待考务录入)"; + /** 理论考试成绩统计(train_theory_exam_score) */ + private StudentScoreStatVo theoryScoreStats; + + /** 实训考试成绩统计(train_exam_report) */ + private StudentScoreStatVo practiceScoreStats; + + /** 考试成绩明细(理论 + 实训,按类型分组、考试时间倒序) */ + private List examScoreList; + /* ========== 5. 自主学习状态 (严格不造假) ========== */ private Boolean hasStudyData = false; private String studyStatusDesc = "暂无自主学习记录 / 待外部平台数据同步"; @@ -84,8 +123,8 @@ public class Student360Vo implements Serializable public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } - public String getOrgName() { return orgName; } - public void setOrgName(String orgName) { this.orgName = orgName; } + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } public String getIdCardMasked() { return idCardMasked; } public void setIdCardMasked(String idCardMasked) { this.idCardMasked = idCardMasked; } @@ -96,6 +135,36 @@ public class Student360Vo implements Serializable public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status = status; } + public String getAvatar() { return avatar; } + public void setAvatar(String avatar) { this.avatar = avatar; } + + public StudentPortraitVo getPortrait() { return portrait; } + public void setPortrait(StudentPortraitVo portrait) { this.portrait = portrait; } + + public String getExtractStatus() { return extractStatus; } + public void setExtractStatus(String extractStatus) { this.extractStatus = extractStatus; } + + public Date getExtractTime() { return extractTime; } + public void setExtractTime(Date extractTime) { this.extractTime = extractTime; } + + public String getExtractMsg() { return extractMsg; } + public void setExtractMsg(String extractMsg) { this.extractMsg = extractMsg; } + + public String getPhotoMd5() { return photoMd5; } + public void setPhotoMd5(String photoMd5) { this.photoMd5 = photoMd5; } + + public String getFeatureData() { return featureData; } + public void setFeatureData(String featureData) { this.featureData = featureData; } + + public String getFeatureMd5() { return featureMd5; } + public void setFeatureMd5(String featureMd5) { this.featureMd5 = featureMd5; } + + public String getFeatureAlgoVersion() { return featureAlgoVersion; } + public void setFeatureAlgoVersion(String featureAlgoVersion) { this.featureAlgoVersion = featureAlgoVersion; } + + public Integer getFeatureSize() { return featureSize; } + public void setFeatureSize(Integer featureSize) { this.featureSize = featureSize; } + public List getEnrollmentTimeline() { return enrollmentTimeline; } public void setEnrollmentTimeline(List enrollmentTimeline) { this.enrollmentTimeline = enrollmentTimeline; } @@ -126,12 +195,24 @@ public class Student360Vo implements Serializable public String getMetricDefinitionNotice() { return metricDefinitionNotice; } public void setMetricDefinitionNotice(String metricDefinitionNotice) { this.metricDefinitionNotice = metricDefinitionNotice; } + public List getAttendanceDetails() { return attendanceDetails; } + public void setAttendanceDetails(List attendanceDetails) { this.attendanceDetails = attendanceDetails; } + public Boolean getHasScoreData() { return hasScoreData; } public void setHasScoreData(Boolean hasScoreData) { this.hasScoreData = hasScoreData; } public String getScoreStatusDesc() { return scoreStatusDesc; } public void setScoreStatusDesc(String scoreStatusDesc) { this.scoreStatusDesc = scoreStatusDesc; } + public StudentScoreStatVo getTheoryScoreStats() { return theoryScoreStats; } + public void setTheoryScoreStats(StudentScoreStatVo theoryScoreStats) { this.theoryScoreStats = theoryScoreStats; } + + public StudentScoreStatVo getPracticeScoreStats() { return practiceScoreStats; } + public void setPracticeScoreStats(StudentScoreStatVo practiceScoreStats) { this.practiceScoreStats = practiceScoreStats; } + + public List getExamScoreList() { return examScoreList; } + public void setExamScoreList(List examScoreList) { this.examScoreList = examScoreList; } + public Boolean getHasStudyData() { return hasStudyData; } public void setHasStudyData(Boolean hasStudyData) { this.hasStudyData = hasStudyData; } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentAttendanceDetailVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentAttendanceDetailVo.java new file mode 100644 index 000000000..25816608c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentAttendanceDetailVo.java @@ -0,0 +1,80 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 学员考勤明细(按“应到课次”展开,未签到课次同样返回) + * + *

数据来源:train_lesson + train_plan_course + train_enrollment, + * 左关联 train_student_checkin 取签到/签退时间与考勤状态。

+ */ +public class StudentAttendanceDetailVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 培训班名称 */ + private String planName; + + /** 课程名称 */ + private String courseName; + + /** 教室 / 实训场地名称 */ + private String classroomName; + + /** 课次开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lessonStartTime; + + /** 课次结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lessonEndTime; + + /** 签到时间(未签到为空) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date checkinTime; + + /** 签退时间(未签退为空) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date checkoutTime; + + /** 考勤状态:0=缺勤 1=正常 2=迟到 3=早退 4=请假,空表示未签到 */ + private Integer attendanceStatus; + + /** 考勤状态名称 */ + private String attendanceStatusName; + + /** 签到方式:1=人脸 2=刷卡 3=扫码 4=教师代签 */ + private Integer checkinWay; + + public String getPlanName() { return planName; } + public void setPlanName(String planName) { this.planName = planName; } + + public String getCourseName() { return courseName; } + public void setCourseName(String courseName) { this.courseName = courseName; } + + public String getClassroomName() { return classroomName; } + public void setClassroomName(String classroomName) { this.classroomName = classroomName; } + + public Date getLessonStartTime() { return lessonStartTime; } + public void setLessonStartTime(Date lessonStartTime) { this.lessonStartTime = lessonStartTime; } + + public Date getLessonEndTime() { return lessonEndTime; } + public void setLessonEndTime(Date lessonEndTime) { this.lessonEndTime = lessonEndTime; } + + public Date getCheckinTime() { return checkinTime; } + public void setCheckinTime(Date checkinTime) { this.checkinTime = checkinTime; } + + public Date getCheckoutTime() { return checkoutTime; } + public void setCheckoutTime(Date checkoutTime) { this.checkoutTime = checkoutTime; } + + public Integer getAttendanceStatus() { return attendanceStatus; } + public void setAttendanceStatus(Integer attendanceStatus) { this.attendanceStatus = attendanceStatus; } + + public String getAttendanceStatusName() { return attendanceStatusName; } + public void setAttendanceStatusName(String attendanceStatusName) { this.attendanceStatusName = attendanceStatusName; } + + public Integer getCheckinWay() { return checkinWay; } + public void setCheckinWay(Integer checkinWay) { this.checkinWay = checkinWay; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentExamScoreItemVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentExamScoreItemVo.java new file mode 100644 index 000000000..4043fc17f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentExamScoreItemVo.java @@ -0,0 +1,84 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 学员考试成绩明细(理论 + 实训统一载体) + * + *

理论成绩来源 train_theory_exam_score(is_final=1), + * 实训成绩来源 train_exam_report(match_status='MATCHED')。

+ */ +public class StudentExamScoreItemVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 成绩类型:THEORY=理论考试,PRACTICE=实训考试 */ + private String category; + + /** 成绩类型名称 */ + private String categoryName; + + /** 考核项名称:理论为课程/科目名称,实训为科目或应用名称 */ + private String itemName; + + /** 得分 */ + private BigDecimal score; + + /** 满分 */ + private BigDecimal fullScore; + + /** 是否通过:1=通过 0=未通过 */ + private Integer passFlag; + + /** 考试状态:NORMAL/ABSENT/EXEMPT(实训固定 NORMAL) */ + private String examStatus; + + /** 是否补考:0=否 1=是 */ + private Integer retake; + + /** 补考序号(正考为 0) */ + private Integer retakeNo; + + /** 考试时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date examTime; + + /** 成绩状态:RECEIVED/ARCHIVED/LOCKED */ + private String scoreStatus; + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + + public String getItemName() { return itemName; } + public void setItemName(String itemName) { this.itemName = itemName; } + + public BigDecimal getScore() { return score; } + public void setScore(BigDecimal score) { this.score = score; } + + public BigDecimal getFullScore() { return fullScore; } + public void setFullScore(BigDecimal fullScore) { this.fullScore = fullScore; } + + public Integer getPassFlag() { return passFlag; } + public void setPassFlag(Integer passFlag) { this.passFlag = passFlag; } + + public String getExamStatus() { return examStatus; } + public void setExamStatus(String examStatus) { this.examStatus = examStatus; } + + public Integer getRetake() { return retake; } + public void setRetake(Integer retake) { this.retake = retake; } + + public Integer getRetakeNo() { return retakeNo; } + public void setRetakeNo(Integer retakeNo) { this.retakeNo = retakeNo; } + + public Date getExamTime() { return examTime; } + public void setExamTime(Date examTime) { this.examTime = examTime; } + + public String getScoreStatus() { return scoreStatus; } + public void setScoreStatus(String scoreStatus) { this.scoreStatus = scoreStatus; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentPortraitVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentPortraitVo.java new file mode 100644 index 000000000..dd331598f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentPortraitVo.java @@ -0,0 +1,450 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * 学员画像扩展读模型(七类能力 + 分层/标签/活跃度/风险/班级对比/轻量趋势)。 + * + *

该对象仅在学员 360 接口响应中作为 {@code portrait} 节点追加,既有字段语义保持不变; + * 全部指标以 s_id / enrollment_id / plan_id 为键实时聚合,不依赖 sys_user 账号,不新增数据表。

+ * + * @author ruoyi + */ +public class StudentPortraitVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 七类画像维度(考勤纪律/学习投入/考核能力/进度完成/组织归属/身份质量/综合) */ + private List dimensions = new ArrayList<>(); + + /** 分层结论(唯一) */ + private Layer layer; + + /** 标签集合(最多 8 个,已按优先级排序) */ + private List tags = new ArrayList<>(); + + /** 活跃度 */ + private Activity activity; + + /** 风险预警 */ + private Risk risk; + + /** 班级对比 */ + private ClassCompare classCompare; + + /** 轻量趋势 */ + private Trend trend; + + /** 元信息(数据截止、完备度、口径版本、告警) */ + private Meta meta; + + public List getDimensions() { return dimensions; } + public void setDimensions(List dimensions) { this.dimensions = dimensions; } + + public Layer getLayer() { return layer; } + public void setLayer(Layer layer) { this.layer = layer; } + + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + + public Activity getActivity() { return activity; } + public void setActivity(Activity activity) { this.activity = activity; } + + public Risk getRisk() { return risk; } + public void setRisk(Risk risk) { this.risk = risk; } + + public ClassCompare getClassCompare() { return classCompare; } + public void setClassCompare(ClassCompare classCompare) { this.classCompare = classCompare; } + + public Trend getTrend() { return trend; } + public void setTrend(Trend trend) { this.trend = trend; } + + public Meta getMeta() { return meta; } + public void setMeta(Meta meta) { this.meta = meta; } + + /** 单个画像维度 */ + public static class Dimension implements Serializable + { + private static final long serialVersionUID = 1L; + private String code; + private String name; + private Integer score; + private String level; + private Boolean available; + private List metrics = new ArrayList<>(); + + public Dimension() { } + + public Dimension(String code, String name) + { + this.code = code; + this.name = name; + } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getScore() { return score; } + public void setScore(Integer score) { this.score = score; } + + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + public Boolean getAvailable() { return available; } + public void setAvailable(Boolean available) { this.available = available; } + + public List getMetrics() { return metrics; } + public void setMetrics(List metrics) { this.metrics = metrics; } + } + + /** 维度下的原子指标 */ + public static class Metric implements Serializable + { + private static final long serialVersionUID = 1L; + private String key; + private String label; + private String value; + private String unit; + + public Metric() { } + + public Metric(String key, String label, String value, String unit) + { + this.key = key; + this.label = label; + this.value = value; + this.unit = unit; + } + + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + } + + /** 分层结论 */ + public static class Layer implements Serializable + { + private static final long serialVersionUID = 1L; + private String code; + private String name; + private String basis; + + public Layer() { } + + public Layer(String code, String name, String basis) + { + this.code = code; + this.name = name; + this.basis = basis; + } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getBasis() { return basis; } + public void setBasis(String basis) { this.basis = basis; } + } + + /** 画像标签 */ + public static class Tag implements Serializable + { + private static final long serialVersionUID = 1L; + private String code; + private String name; + private String group; + private String level; + private String reason; + + public Tag() { } + + public Tag(String code, String name, String group, String level, String reason) + { + this.code = code; + this.name = name; + this.group = group; + this.level = level; + this.reason = reason; + } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getGroup() { return group; } + public void setGroup(String group) { this.group = group; } + + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + } + + /** 活跃度 */ + public static class Activity implements Serializable + { + private static final long serialVersionUID = 1L; + private Integer score; + private String level; + private List parts = new ArrayList<>(); + + public Integer getScore() { return score; } + public void setScore(Integer score) { this.score = score; } + + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + public List getParts() { return parts; } + public void setParts(List parts) { this.parts = parts; } + + /** 活跃度分项 */ + public static class Part implements Serializable + { + private static final long serialVersionUID = 1L; + private String name; + private Integer score; + private Integer weight; + private Boolean included; + + public Part() { } + + public Part(String name, Integer score, Integer weight, Boolean included) + { + this.name = name; + this.score = score; + this.weight = weight; + this.included = included; + } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getScore() { return score; } + public void setScore(Integer score) { this.score = score; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public Boolean getIncluded() { return included; } + public void setIncluded(Boolean included) { this.included = included; } + } + } + + /** 风险预警 */ + public static class Risk implements Serializable + { + private static final long serialVersionUID = 1L; + private Integer score; + private String level; + private List rules = new ArrayList<>(); + + public Integer getScore() { return score; } + public void setScore(Integer score) { this.score = score; } + + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + public List getRules() { return rules; } + public void setRules(List rules) { this.rules = rules; } + + /** 单条风险规则命中 */ + public static class Rule implements Serializable + { + private static final long serialVersionUID = 1L; + private String code; + private String name; + private String level; + private Integer weight; + private String evidence; + private String suggestion; + + public Rule() { } + + public Rule(String code, String name, String level, Integer weight, String evidence, String suggestion) + { + this.code = code; + this.name = name; + this.level = level; + this.weight = weight; + this.evidence = evidence; + this.suggestion = suggestion; + } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public String getEvidence() { return evidence; } + public void setEvidence(String evidence) { this.evidence = evidence; } + + public String getSuggestion() { return suggestion; } + public void setSuggestion(String suggestion) { this.suggestion = suggestion; } + } + } + + /** 班级对比 */ + public static class ClassCompare implements Serializable + { + private static final long serialVersionUID = 1L; + private String scope; + private Integer sampleSize; + private Boolean sufficient; + private List items = new ArrayList<>(); + + public String getScope() { return scope; } + public void setScope(String scope) { this.scope = scope; } + + public Integer getSampleSize() { return sampleSize; } + public void setSampleSize(Integer sampleSize) { this.sampleSize = sampleSize; } + + public Boolean getSufficient() { return sufficient; } + public void setSufficient(Boolean sufficient) { this.sufficient = sufficient; } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + /** 单项对比 */ + public static class CompareItem implements Serializable + { + private static final long serialVersionUID = 1L; + private String key; + private String label; + private BigDecimal self; + private BigDecimal avg; + private Integer percentile; + private Boolean available; + + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } + + public BigDecimal getSelf() { return self; } + public void setSelf(BigDecimal self) { this.self = self; } + + public BigDecimal getAvg() { return avg; } + public void setAvg(BigDecimal avg) { this.avg = avg; } + + public Integer getPercentile() { return percentile; } + public void setPercentile(Integer percentile) { this.percentile = percentile; } + + public Boolean getAvailable() { return available; } + public void setAvailable(Boolean available) { this.available = available; } + } + } + + /** 轻量趋势 */ + public static class Trend implements Serializable + { + private static final long serialVersionUID = 1L; + private List weekly = new ArrayList<>(); + private List periods = new ArrayList<>(); + + public List getWeekly() { return weekly; } + public void setWeekly(List weekly) { this.weekly = weekly; } + + public List getPeriods() { return periods; } + public void setPeriods(List periods) { this.periods = periods; } + + /** 周粒度趋势点 */ + public static class WeeklyPoint implements Serializable + { + private static final long serialVersionUID = 1L; + private String week; + private BigDecimal attendanceRate; + private BigDecimal studyHours; + private BigDecimal avgScore; + + public WeeklyPoint() { } + + public WeeklyPoint(String week) + { + this.week = week; + } + + public String getWeek() { return week; } + public void setWeek(String week) { this.week = week; } + + public BigDecimal getAttendanceRate() { return attendanceRate; } + public void setAttendanceRate(BigDecimal attendanceRate) { this.attendanceRate = attendanceRate; } + + public BigDecimal getStudyHours() { return studyHours; } + public void setStudyHours(BigDecimal studyHours) { this.studyHours = studyHours; } + + public BigDecimal getAvgScore() { return avgScore; } + public void setAvgScore(BigDecimal avgScore) { this.avgScore = avgScore; } + } + + /** 跨班期趋势点 */ + public static class PeriodPoint implements Serializable + { + private static final long serialVersionUID = 1L; + private String planName; + private BigDecimal overallScore; + private BigDecimal attendanceRate; + private BigDecimal studyHours; + + public String getPlanName() { return planName; } + public void setPlanName(String planName) { this.planName = planName; } + + public BigDecimal getOverallScore() { return overallScore; } + public void setOverallScore(BigDecimal overallScore) { this.overallScore = overallScore; } + + public BigDecimal getAttendanceRate() { return attendanceRate; } + public void setAttendanceRate(BigDecimal attendanceRate) { this.attendanceRate = attendanceRate; } + + public BigDecimal getStudyHours() { return studyHours; } + public void setStudyHours(BigDecimal studyHours) { this.studyHours = studyHours; } + } + } + + /** 元信息 */ + public static class Meta implements Serializable + { + private static final long serialVersionUID = 1L; + private String dataCutoff; + private Integer completeness; + private String metricVersion; + private List warnings = new ArrayList<>(); + + public String getDataCutoff() { return dataCutoff; } + public void setDataCutoff(String dataCutoff) { this.dataCutoff = dataCutoff; } + + public Integer getCompleteness() { return completeness; } + public void setCompleteness(Integer completeness) { this.completeness = completeness; } + + public String getMetricVersion() { return metricVersion; } + public void setMetricVersion(String metricVersion) { this.metricVersion = metricVersion; } + + public List getWarnings() { return warnings; } + public void setWarnings(List warnings) { this.warnings = warnings; } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentScoreStatVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentScoreStatVo.java new file mode 100644 index 000000000..459fe5a1a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentScoreStatVo.java @@ -0,0 +1,57 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 学员单个考核类型(理论 / 实训)的成绩统计 + * + *

理论成绩来源 train_theory_exam_score(is_final=1 的最终成绩), + * 实训成绩来源 train_exam_report(match_status='MATCHED' 的上报记录)。

+ */ +public class StudentScoreStatVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 成绩记录数 */ + private Long recordCount = 0L; + + /** 有效分数记录数(理论仅统计正常考试且有分数的记录) */ + private Long scoredCount = 0L; + + /** 平均分 */ + private BigDecimal avgScore; + + /** 最高分 */ + private BigDecimal maxScore; + + /** 最低分 */ + private BigDecimal minScore; + + /** 通过记录数 */ + private Long passCount = 0L; + + /** 通过率(百分比,保留 1 位小数) */ + private Double passRate; + + public Long getRecordCount() { return recordCount; } + public void setRecordCount(Long recordCount) { this.recordCount = recordCount; } + + public Long getScoredCount() { return scoredCount; } + public void setScoredCount(Long scoredCount) { this.scoredCount = scoredCount; } + + public BigDecimal getAvgScore() { return avgScore; } + public void setAvgScore(BigDecimal avgScore) { this.avgScore = avgScore; } + + public BigDecimal getMaxScore() { return maxScore; } + public void setMaxScore(BigDecimal maxScore) { this.maxScore = maxScore; } + + public BigDecimal getMinScore() { return minScore; } + public void setMinScore(BigDecimal minScore) { this.minScore = minScore; } + + public Long getPassCount() { return passCount; } + public void setPassCount(Long passCount) { this.passCount = passCount; } + + public Double getPassRate() { return passRate; } + public void setPassRate(Double passRate) { this.passRate = passRate; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AccommodationSaveResult.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AccommodationSaveResult.java new file mode 100644 index 000000000..ec76c2b7e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AccommodationSaveResult.java @@ -0,0 +1,82 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult; + +/** + * 住宿安排新增结果(含门禁办理入住结果)。 + *

+ * 用于在不改变 insertTrainAccommodation 返回 int 契约的前提下, + * 把门禁调用结果一并回传给调用方。 + * + * @author ruoyi + * @date 2026-11-01 + */ +public class AccommodationSaveResult implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 影响行数 */ + private int rows; + + /** 住宿安排ID */ + private Long accommodationId; + + /** 门锁业务流水号 */ + private String lockApiBizNo; + + /** 门禁办理入住结果,住宿未写入时为 null */ + private LockStayCheckinResult lockCheckin; + + public AccommodationSaveResult() + { + } + + public AccommodationSaveResult(int rows, Long accommodationId, String lockApiBizNo, LockStayCheckinResult lockCheckin) + { + this.rows = rows; + this.accommodationId = accommodationId; + this.lockApiBizNo = lockApiBizNo; + this.lockCheckin = lockCheckin; + } + + public int getRows() + { + return rows; + } + + public void setRows(int rows) + { + this.rows = rows; + } + + public Long getAccommodationId() + { + return accommodationId; + } + + public void setAccommodationId(Long accommodationId) + { + this.accommodationId = accommodationId; + } + + public String getLockApiBizNo() + { + return lockApiBizNo; + } + + public void setLockApiBizNo(String lockApiBizNo) + { + this.lockApiBizNo = lockApiBizNo; + } + + public LockStayCheckinResult getLockCheckin() + { + return lockCheckin; + } + + public void setLockCheckin(LockStayCheckinResult lockCheckin) + { + this.lockCheckin = lockCheckin; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java index 5788eb96b..b3d8805dc 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java @@ -3,6 +3,7 @@ package com.ruoyi.train.domain.dto; import java.io.Serializable; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; /** * 课次缺勤登记请求 DTO。 @@ -21,6 +22,10 @@ public class AttendanceMarkAbsenceDTO implements Serializable @NotNull(message = "课次ID不能为空") private Long lessonId; + /** 学员ID (可选;传入时必须与参训档案所属学员一致,服务端最终以参训档案为准) */ + @Size(max = 32, message = "学员ID长度不能超过32") + private String studentId; + /** 缺勤登记原因 (必填,写入 remark 留痕) */ @NotBlank(message = "缺勤登记原因不能为空") private String remark; @@ -45,6 +50,16 @@ public class AttendanceMarkAbsenceDTO implements Serializable this.lessonId = lessonId; } + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + public String getRemark() { return remark; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceSignInDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceSignInDTO.java index fff13c0fe..8f80dd6d3 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceSignInDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceSignInDTO.java @@ -3,6 +3,7 @@ package com.ruoyi.train.domain.dto; import java.io.Serializable; import java.util.Date; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import com.fasterxml.jackson.annotation.JsonFormat; /** @@ -20,6 +21,10 @@ public class AttendanceSignInDTO implements Serializable @NotNull(message = "课次ID不能为空") private Long lessonId; + /** 学员ID (可选;传入时必须与参训档案所属学员一致,服务端最终以参训档案为准) */ + @Size(max = 32, message = "学员ID长度不能超过32") + private String studentId; + /** 打卡电子班牌ID (可选) */ private String boardId; @@ -50,6 +55,16 @@ public class AttendanceSignInDTO implements Serializable this.lessonId = lessonId; } + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + public String getBoardId() { return boardId; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ClassSeatSaveDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ClassSeatSaveDTO.java new file mode 100644 index 000000000..bfbd82c5e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ClassSeatSaveDTO.java @@ -0,0 +1,180 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.List; + +/** + * 班级座位布局保存请求DTO + * + * 一次性整体保存某开班计划在某教室/实训场地中的座位布局: + * 先删除该 (planId, classroomId) 下原有座位,再批量写入本次提交的座位。 + * + * @author ruoyi + * @date 2026-10-26 + */ +public class ClassSeatSaveDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 班级ID (train_class_plan.plan_id),必填 */ + private String planId; + + /** 教室ID (train_classroom_info.class_room_id),必填 */ + private String classroomId; + + /** 教学场地类别(classroom:教室,site:实训场地) */ + private String category; + + /** 座位明细列表 */ + private List seats; + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getClassroomId() + { + return classroomId; + } + + public void setClassroomId(String classroomId) + { + this.classroomId = classroomId; + } + + public String getCategory() + { + return category; + } + + public void setCategory(String category) + { + this.category = category; + } + + public List getSeats() + { + return seats; + } + + public void setSeats(List seats) + { + this.seats = seats; + } + + /** 座位明细项 */ + public static class SeatItemDTO implements Serializable + { + private static final long serialVersionUID = 1L; + + /** 行号,从1开始,必填 */ + private Integer rowIndex; + + /** 列号,从1开始,必填 */ + private Integer colIndex; + + /** 物理座位标识 */ + private String seatCode; + + /** 是否取消座位 (0正常,1取消),默认0 */ + private Integer canceled; + + /** 占用座位学员ID (train_student.s_id) */ + private String studentId; + + /** 区域ID */ + private Long zoneId; + + /** 部门 */ + private String deptDetail; + + /** 职务 */ + private String job; + + public Integer getRowIndex() + { + return rowIndex; + } + + public void setRowIndex(Integer rowIndex) + { + this.rowIndex = rowIndex; + } + + public Integer getColIndex() + { + return colIndex; + } + + public void setColIndex(Integer colIndex) + { + this.colIndex = colIndex; + } + + public String getSeatCode() + { + return seatCode; + } + + public void setSeatCode(String seatCode) + { + this.seatCode = seatCode; + } + + public Integer getCanceled() + { + return canceled; + } + + public void setCanceled(Integer canceled) + { + this.canceled = canceled; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public Long getZoneId() + { + return zoneId; + } + + public void setZoneId(Long zoneId) + { + this.zoneId = zoneId; + } + + public String getDeptDetail() + { + return deptDetail; + } + + public void setDeptDetail(String deptDetail) + { + this.deptDetail = deptDetail; + } + + public String getJob() + { + return job; + } + + public void setJob(String job) + { + this.job = job; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ClassroomCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ClassroomCreateDTO.java index 2554ac84e..67593b65a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ClassroomCreateDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ClassroomCreateDTO.java @@ -42,6 +42,12 @@ public class ClassroomCreateDTO implements Serializable @Size(max = 500, message = "备注长度不能超过500个字符") private String remark; + /** 自增编号(数据库自动生成,新增时无需传入)。 */ + private Long id; + + /** 绑定的电子班牌ID(关联 train_electronic_board.board_id),可为空 */ + private String bindBoardId; + public String getRoomName() { return roomName; @@ -92,6 +98,22 @@ public class ClassroomCreateDTO implements Serializable this.remark = remark; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getBindBoardId() { + return bindBoardId; + } + + public void setBindBoardId(String bindBoardId) { + this.bindBoardId = bindBoardId; + } + public Integer getMultimedia() { return multimedia; } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/DormitoryCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/DormitoryCreateDTO.java index 55f9367b0..c6164a7b9 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/DormitoryCreateDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/DormitoryCreateDTO.java @@ -49,6 +49,9 @@ public class DormitoryCreateDTO implements Serializable @Size(max = 500, message = "备注长度不能超过500个字符") private String remark; + /** 自增编号(数据库自动生成,新增时无需传入)。 */ + private Long id; + public String getDormBuilding() { return dormBuilding; @@ -118,4 +121,12 @@ public class DormitoryCreateDTO implements Serializable { this.remark = remark; } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java index 29ff3e7d9..79553577d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java @@ -27,7 +27,7 @@ public class EnrollmentAddDTO private String remark; /** 选派单位 (兼容部分调用传参,展示时以学员档案为准) */ - private String orgName; + private String deptName; public String getStudentId() { @@ -69,13 +69,13 @@ public class EnrollmentAddDTO this.remark = remark; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/OpenExamReportDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/OpenExamReportDTO.java new file mode 100644 index 000000000..3ff366927 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/OpenExamReportDTO.java @@ -0,0 +1,93 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.List; + +/** + * 外部实训系统成绩上报请求(3.3 批量上报 / 3.4 实时上报) + * + * @author ruoyi + * @date 2026-11-05 + */ +public class OpenExamReportDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 批次号(3.3 必填;3.4 不传) */ + private String batchId; + + /** 上报时间(毫秒) */ + private Long reportTime; + + /** 覆盖窗口开始(毫秒,含,3.3 必填) */ + private Long periodStart; + + /** 覆盖窗口结束(毫秒,不含,3.3 必填) */ + private Long periodEnd; + + /** 补传标记 */ + private Boolean resend; + + /** 成绩记录 */ + private List reports; + + public String getBatchId() + { + return batchId; + } + + public void setBatchId(String batchId) + { + this.batchId = batchId; + } + + public Long getReportTime() + { + return reportTime; + } + + public void setReportTime(Long reportTime) + { + this.reportTime = reportTime; + } + + public Long getPeriodStart() + { + return periodStart; + } + + public void setPeriodStart(Long periodStart) + { + this.periodStart = periodStart; + } + + public Long getPeriodEnd() + { + return periodEnd; + } + + public void setPeriodEnd(Long periodEnd) + { + this.periodEnd = periodEnd; + } + + public Boolean getResend() + { + return resend; + } + + public void setResend(Boolean resend) + { + this.resend = resend; + } + + public List getReports() + { + return reports; + } + + public void setReports(List reports) + { + this.reports = reports; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/OpenExamReportItemDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/OpenExamReportItemDTO.java new file mode 100644 index 000000000..9dfaf1056 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/OpenExamReportItemDTO.java @@ -0,0 +1,223 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 外部实训系统单条成绩上报项(reports[] 元素) + * + * @author ruoyi + * @date 2026-11-05 + */ +public class OpenExamReportItemDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 上报编号(全局唯一、永久幂等键) */ + private String reportId; + + /** 学员ID(取自 3.2 返回的 studentId) */ + private String studentId; + + /** 身份证号(可选,用于交叉校验,不落明文) */ + private String idCard; + + /** 学员姓名(匹配失败时供人工认领核对) */ + private String studentName; + + /** 科目编码(可选,须与平台字典一致) */ + private String subjectCode; + + /** 科目名称 */ + private String subjectName; + + /** 考试开始时间(毫秒) */ + private Long examStartTime; + + /** 考试结束时间(毫秒) */ + private Long examEndTime; + + /** 分数 */ + private BigDecimal score; + + /** 满分,默认 100 */ + private BigDecimal fullScore; + + /** 是否通过 */ + private Boolean pass; + + /** 是否重考 */ + private Boolean retake; + + /** 重考序号 */ + private Integer retakeNo; + + /** 考试工位 */ + private String station; + + /** 考试内容(JSON 对象) */ + private Object content; + + /** 备注 */ + private String remark; + + public String getReportId() + { + return reportId; + } + + public void setReportId(String reportId) + { + this.reportId = reportId; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getIdCard() + { + return idCard; + } + + public void setIdCard(String idCard) + { + this.idCard = idCard; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getSubjectCode() + { + return subjectCode; + } + + public void setSubjectCode(String subjectCode) + { + this.subjectCode = subjectCode; + } + + public String getSubjectName() + { + return subjectName; + } + + public void setSubjectName(String subjectName) + { + this.subjectName = subjectName; + } + + public Long getExamStartTime() + { + return examStartTime; + } + + public void setExamStartTime(Long examStartTime) + { + this.examStartTime = examStartTime; + } + + public Long getExamEndTime() + { + return examEndTime; + } + + public void setExamEndTime(Long examEndTime) + { + this.examEndTime = examEndTime; + } + + public BigDecimal getScore() + { + return score; + } + + public void setScore(BigDecimal score) + { + this.score = score; + } + + public BigDecimal getFullScore() + { + return fullScore; + } + + public void setFullScore(BigDecimal fullScore) + { + this.fullScore = fullScore; + } + + public Boolean getPass() + { + return pass; + } + + public void setPass(Boolean pass) + { + this.pass = pass; + } + + public Boolean getRetake() + { + return retake; + } + + public void setRetake(Boolean retake) + { + this.retake = retake; + } + + public Integer getRetakeNo() + { + return retakeNo; + } + + public void setRetakeNo(Integer retakeNo) + { + this.retakeNo = retakeNo; + } + + public String getStation() + { + return station; + } + + public void setStation(String station) + { + this.station = station; + } + + public Object getContent() + { + return content; + } + + public void setContent(Object content) + { + this.content = content; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java index 7473eb180..111c8e3ed 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java @@ -26,6 +26,9 @@ public class PlanCreateDTO @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date reportDeadline; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportStarttime; + @NotNull(message = "招生人数不能为空") @Min(value = 1, message = "招生人数必须大于0") private Long recruitNum; @@ -38,6 +41,22 @@ public class PlanCreateDTO @Size(max = 500, message = "备注不能超过500个字符") private String remark; + /** 所属专业(字典 train_class_major) */ + private Integer classMajor; + + /** 总学时 */ + private Integer totalClassHours; + + /** 负责人 */ + private String manager; + + /** 主办部门ID(sys_dept) */ + private Integer hostDeptId; + + private String classId; + + private String className; + public String getPlanName() { return planName; } public void setPlanName(String planName) { this.planName = planName; } public Date getStartTime() { return startTime; } @@ -46,6 +65,8 @@ public class PlanCreateDTO public void setEndTime(Date endTime) { this.endTime = endTime; } public Date getReportDeadline() { return reportDeadline; } public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; } + public Date getReportStarttime() { return reportStarttime; } + public void setReportStarttime(Date reportStarttime) { this.reportStarttime = reportStarttime; } public Long getRecruitNum() { return recruitNum; } public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; } public String getTrainAddress() { return trainAddress; } @@ -54,4 +75,32 @@ public class PlanCreateDTO public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; } public String getRemark() { return remark; } public void setRemark(String remark) { this.remark = remark; } + + public Integer getClassMajor() { return classMajor; } + public void setClassMajor(Integer classMajor) { this.classMajor = classMajor; } + + public Integer getTotalClassHours() { return totalClassHours; } + public void setTotalClassHours(Integer totalClassHours) { this.totalClassHours = totalClassHours; } + + public String getManager() { return manager; } + public void setManager(String manager) { this.manager = manager; } + + public Integer getHostDeptId() { return hostDeptId; } + public void setHostDeptId(Integer hostDeptId) { this.hostDeptId = hostDeptId; } + + public String getClassId() { + return classId; + } + + public void setClassId(String classId) { + this.classId = classId; + } + + public String getClassName() { + return className; + } + + public void setClassName(String className) { + this.className = className; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java index df8548021..0d6521187 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java @@ -26,6 +26,9 @@ public class PlanUpdateDTO @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date reportDeadline; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportStarttime; + @NotNull(message = "招生人数不能为空") @Min(value = 1, message = "招生人数必须大于0") private Long recruitNum; @@ -38,6 +41,22 @@ public class PlanUpdateDTO @Size(max = 500, message = "备注不能超过500个字符") private String remark; + /** 所属专业(字典 train_class_major) */ + private Integer classMajor; + + /** 总学时 */ + private Integer totalClassHours; + + /** 负责人 */ + private String manager; + + /** 主办部门ID(sys_dept) */ + private Integer hostDeptId; + + private String classId; + + private String className; + public String getPlanName() { return planName; } public void setPlanName(String planName) { this.planName = planName; } public Date getStartTime() { return startTime; } @@ -46,6 +65,8 @@ public class PlanUpdateDTO public void setEndTime(Date endTime) { this.endTime = endTime; } public Date getReportDeadline() { return reportDeadline; } public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; } + public Date getReportStarttime() { return reportStarttime; } + public void setReportStarttime(Date reportStarttime) { this.reportStarttime = reportStarttime; } public Long getRecruitNum() { return recruitNum; } public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; } public String getTrainAddress() { return trainAddress; } @@ -54,4 +75,22 @@ public class PlanUpdateDTO public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; } public String getRemark() { return remark; } public void setRemark(String remark) { this.remark = remark; } + + public Integer getClassMajor() { return classMajor; } + public void setClassMajor(Integer classMajor) { this.classMajor = classMajor; } + + public Integer getTotalClassHours() { return totalClassHours; } + public void setTotalClassHours(Integer totalClassHours) { this.totalClassHours = totalClassHours; } + + public String getManager() { return manager; } + public void setManager(String manager) { this.manager = manager; } + + public Integer getHostDeptId() { return hostDeptId; } + public void setHostDeptId(Integer hostDeptId) { this.hostDeptId = hostDeptId; } + + public String getClassId() { return classId; } + public void setClassId(String classId) { this.classId = classId; } + + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/RegReportSaveDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/RegReportSaveDTO.java new file mode 100644 index 000000000..209bf29a4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/RegReportSaveDTO.java @@ -0,0 +1,68 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 报到记录新增/更新请求 (train_class_report) + */ +public class RegReportSaveDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 开班计划ID (与 studentId 二选一组合定位参训档案) */ + private String planId; + + /** 学员ID */ + private String studentId; + + /** 参训档案ID (直接定位时优先使用) */ + private Long enrollmentId; + + /** 报到方式 1=人脸识别 2=身份证刷卡 3=二维码 4=人工录入 */ + private Integer reportWay; + + /** 报到设备编号 */ + private String reportDeviceSn; + + /** 报到时间,缺省取当前时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportTime; + + /** 核验结果 1=核验通过 2=核验不匹配 3=人工放行 */ + private Integer verifyResult; + + /** 核验说明 */ + private String verifyNote; + + /** 备注 */ + private String remark; + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public String getStudentId() { return studentId; } + public void setStudentId(String studentId) { this.studentId = studentId; } + + public Long getEnrollmentId() { return enrollmentId; } + public void setEnrollmentId(Long enrollmentId) { this.enrollmentId = enrollmentId; } + + public Integer getReportWay() { return reportWay; } + public void setReportWay(Integer reportWay) { this.reportWay = reportWay; } + + public String getReportDeviceSn() { return reportDeviceSn; } + public void setReportDeviceSn(String reportDeviceSn) { this.reportDeviceSn = reportDeviceSn; } + + public Date getReportTime() { return reportTime; } + public void setReportTime(Date reportTime) { this.reportTime = reportTime; } + + public Integer getVerifyResult() { return verifyResult; } + public void setVerifyResult(Integer verifyResult) { this.verifyResult = verifyResult; } + + public String getVerifyNote() { return verifyNote; } + public void setVerifyNote(String verifyNote) { this.verifyNote = verifyNote; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/RegUrgeDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/RegUrgeDTO.java new file mode 100644 index 000000000..34c0d69a5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/RegUrgeDTO.java @@ -0,0 +1,26 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.List; +import jakarta.validation.constraints.NotBlank; + +/** + * 未报到学员催促请求 + */ +public class RegUrgeDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 开班计划ID (必填) */ + @NotBlank(message = "planId 不能为空") + private String planId; + + /** 指定学员ID集合,为空表示催促该班全部未报到学员 */ + private List studentIds; + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public List getStudentIds() { return studentIds; } + public void setStudentIds(List studentIds) { this.studentIds = studentIds; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ReportStartDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ReportStartDTO.java new file mode 100644 index 000000000..086635dd2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ReportStartDTO.java @@ -0,0 +1,141 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 开班报到启动请求(前端 POST /train/report 的入参)。 + * 一次性完成:更新开班计划的班级信息与报到时间窗口, + * 以及批量绑定教室/实训场地的电子班牌、宿舍门锁设备。 + * + * 说明:boardMappings / lockMappings 在前端以对象(Map)形式提交, + * 而非数组,故此处使用 Map 接收。 + */ +public class ReportStartDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 开班计划ID(train_class_plan.plan_id) */ + private String planId; + + /** 班级ID */ + private String classId; + + /** 班级名称 */ + private String className; + + /** 报到开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportStarttime; + + /** 报到截止时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportDeadline; + + /** 班牌绑定映射:key = "C-" + class_room_id 或 "S-" + site_id,value = 电子班牌ID(空串/空表示解绑)。 */ + private Map boardMappings; + + /** 宿舍门锁绑定映射:key = enrollmentId,value = { dormId, lockId },lockId 即门锁设备序列号。 */ + private Map lockMappings; + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getClassId() + { + return classId; + } + + public void setClassId(String classId) + { + this.classId = classId; + } + + public String getClassName() + { + return className; + } + + public void setClassName(String className) + { + this.className = className; + } + + public Date getReportStarttime() + { + return reportStarttime; + } + + public void setReportStarttime(Date reportStarttime) + { + this.reportStarttime = reportStarttime; + } + + public Date getReportDeadline() + { + return reportDeadline; + } + + public void setReportDeadline(Date reportDeadline) + { + this.reportDeadline = reportDeadline; + } + + public Map getBoardMappings() + { + return boardMappings; + } + + public void setBoardMappings(Map boardMappings) + { + this.boardMappings = boardMappings; + } + + public Map getLockMappings() + { + return lockMappings; + } + + public void setLockMappings(Map lockMappings) + { + this.lockMappings = lockMappings; + } + + /** 宿舍门锁绑定项:dormId = dorm_id,lockId = lock_device_sn(空表示清除)。 */ + public static class LockMappingDTO implements Serializable + { + private static final long serialVersionUID = 1L; + + private String dormId; + private String lockId; + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getLockId() + { + return lockId; + } + + public void setLockId(String lockId) + { + this.lockId = lockId; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/SiteCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/SiteCreateDTO.java index a4fa4b46c..e05e0b797 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/SiteCreateDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/SiteCreateDTO.java @@ -35,6 +35,12 @@ public class SiteCreateDTO implements Serializable /** 场地状态 (0: 停用, 1: 可用) */ private Integer siteStatus; + /** 自增编号(数据库自动生成,新增时无需传入)。 */ + private Long id; + + /** 绑定的电子班牌ID(关联 train_electronic_board.board_id),可为空 */ + private String bindBoardId; + /** 备注 */ @Size(max = 500, message = "备注长度不能超过500个字符") private String remark; @@ -93,6 +99,22 @@ public class SiteCreateDTO implements Serializable this.siteStatus = siteStatus; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getBindBoardId() { + return bindBoardId; + } + + public void setBindBoardId(String bindBoardId) { + this.bindBoardId = bindBoardId; + } + public String getRemark() { return remark; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentCreateDTO.java index 1f84f2d1c..539e2cfa7 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentCreateDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentCreateDTO.java @@ -17,25 +17,33 @@ public class StudentCreateDTO implements Serializable @NotBlank(message = "学员姓名不能为空") @Size(max = 50, message = "学员姓名长度不能超过50个字符") + @JsonProperty("sName") private String sName; @Size(max = 32, message = "人员编号长度不能超过32个字符") private String personNo; @Size(max = 1, message = "性别长度不能超过1个字符") + @JsonProperty("sGender") private String sGender; @JsonFormat(pattern = "yyyy-MM-dd") + @JsonProperty("sBirth") private Date sBirth; @Size(max = 100, message = "所属单位长度不能超过100个字符") - private String orgName; + private String deptName; + + @Size(max = 50, message = "卡号长度不能超过50个字符") + private String cardNo; @Size(max = 20, message = "联系电话长度不能超过20个字符") + @JsonProperty("sPhone") private String sPhone; @Email(message = "邮箱格式不正确") @Size(max = 100, message = "邮箱长度不能超过100个字符") + @JsonProperty("sEmail") private String sEmail; @Pattern(regexp = "^$|^\\s*$|^[0-9]{17}[0-9Xx]$", message = "身份证号格式不正确") @@ -51,6 +59,8 @@ public class StudentCreateDTO implements Serializable private Integer dutyLevel; private String avatar; + private Integer id; + private Integer deptId; public String getsName() { return sName; } public void setsName(String sName) { this.sName = sName; } @@ -60,8 +70,10 @@ public class StudentCreateDTO implements Serializable public void setsGender(String sGender) { this.sGender = sGender; } public Date getsBirth() { return sBirth; } public void setsBirth(Date sBirth) { this.sBirth = sBirth; } - public String getOrgName() { return orgName; } - public void setOrgName(String orgName) { this.orgName = orgName; } + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } public String getsPhone() { return sPhone; } public void setsPhone(String sPhone) { this.sPhone = sPhone; } public String getsEmail() { return sEmail; } @@ -89,6 +101,22 @@ public class StudentCreateDTO implements Serializable this.avatar = avatar; } + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getDeptId() { + return deptId; + } + + public void setDeptId(Integer deptId) { + this.deptId = deptId; + } + @Override public String toString() { diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentUpdateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentUpdateDTO.java index 48934678d..6c2142cb2 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentUpdateDTO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentUpdateDTO.java @@ -1,5 +1,6 @@ package com.ruoyi.train.domain.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -15,6 +16,7 @@ public class StudentUpdateDTO extends StudentCreateDTO @NotBlank(message = "学员ID不能为空") @Size(max = 64, message = "学员ID长度不能超过64个字符") + @JsonProperty("sId") private String sId; public String getsId() diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TeacherAccommodationDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TeacherAccommodationDTO.java new file mode 100644 index 000000000..8ce6d069f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TeacherAccommodationDTO.java @@ -0,0 +1,55 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; + +/** + * 教师住宿分配入参 + *

+ * 教师住宿与班级无关,仅需教师主键 + 宿舍 + 床位。 + * + * @author ruoyi + * @date 2026-11-04 + */ +public class TeacherAccommodationDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 教师主键 (train_teacher.id) */ + private Long teacherId; + + /** 宿舍ID */ + private String dormId; + + /** 床位号 */ + private String bedNo; + + public Long getTeacherId() + { + return teacherId; + } + + public void setTeacherId(Long teacherId) + { + this.teacherId = teacherId; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TeacherAccommodationSaveResult.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TeacherAccommodationSaveResult.java new file mode 100644 index 000000000..452dc9bdd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TeacherAccommodationSaveResult.java @@ -0,0 +1,70 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import com.ruoyi.train.domain.vo.TeacherAccommodationVO; + +/** + * 教师住宿分配结果 + *

+ * 住宿分配与人脸/门锁开锁授权解耦:住宿记录创建成功后,仅当教师人脸照片可用时才下发开锁授权; + * 授权被拒绝不影响住宿分配结果,但会通过 lockAuthorized / lockMessage 明确告知前端原因。 + * + * @author ruoyi + * @date 2026-11-04 + */ +public class TeacherAccommodationSaveResult implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 生效后的教师住宿安排 */ + private TeacherAccommodationVO accommodation; + + /** 是否已触发门锁开锁授权(false 表示被人脸校验拒绝或调用失败) */ + private boolean lockAuthorized; + + /** 开锁授权状态:SUCCESS 已下发 / SKIPPED 被拒绝或跳过 / FAILED 调用失败 */ + private String lockStatus; + + /** 开锁授权说明(拒绝原因或调用结果) */ + private String lockMessage; + + public TeacherAccommodationVO getAccommodation() + { + return accommodation; + } + + public void setAccommodation(TeacherAccommodationVO accommodation) + { + this.accommodation = accommodation; + } + + public boolean isLockAuthorized() + { + return lockAuthorized; + } + + public void setLockAuthorized(boolean lockAuthorized) + { + this.lockAuthorized = lockAuthorized; + } + + public String getLockStatus() + { + return lockStatus; + } + + public void setLockStatus(String lockStatus) + { + this.lockStatus = lockStatus; + } + + public String getLockMessage() + { + return lockMessage; + } + + public void setLockMessage(String lockMessage) + { + this.lockMessage = lockMessage; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TrainAccommodationDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TrainAccommodationDTO.java new file mode 100644 index 000000000..5d933ac82 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TrainAccommodationDTO.java @@ -0,0 +1,193 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.train.domain.TrainAccommodation; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 住宿安排新增/修改 DTO (train_accommodation) + * + * @author ruoyi + * @date 2026-11-01 + */ +public class TrainAccommodationDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 住宿安排ID (修改时必填) */ + private Long accommodationId; + + /** 参训档案ID */ + @NotNull(message = "参训档案ID不能为空") + private Long enrollmentId; + + /** 宿舍ID */ + @NotBlank(message = "宿舍ID不能为空") + @Size(max = 32, message = "宿舍ID长度不能超过32个字符") + private String dormId; + + /** 床位号 */ + @Size(max = 20, message = "床位号长度不能超过20个字符") + private String bedNo; + + /** 分配时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date assignedTime; + + /** 住宿状态 (0: 预分配, 1: 已入住, 2: 已退宿) */ + private Integer accommodationStatus; + + /** 门锁开放接口业务流水号 */ + @Size(max = TrainAccommodation.LOCK_API_BIZ_NO_MAX_LENGTH, message = "门锁业务流水号长度不能超过64个字符") + private String lockApiBizNo; + + /** 门锁授权结果 (1: 未授权, 2: 已授权, 3: 已过期) */ + @Min(value = 1, message = "门锁授权结果取值范围为 1(未授权)、2(已授权)、3(已过期)") + @Max(value = 3, message = "门锁授权结果取值范围为 1(未授权)、2(已授权)、3(已过期)") + private Integer lockResult; + + /** 备注 */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + /** + * DTO 转实体 + * + * @return 住宿安排实体 + */ + public TrainAccommodation toEntity() + { + TrainAccommodation entity = new TrainAccommodation(); + entity.setAccommodationId(accommodationId); + entity.setEnrollmentId(enrollmentId); + entity.setDormId(dormId); + entity.setBedNo(bedNo); + entity.setAssignedTime(assignedTime); + entity.setAccommodationStatus(accommodationStatus); + entity.setLockApiBizNo(lockApiBizNo); + entity.setLockResult(lockResult); + entity.setRemark(remark); + return entity; + } + + /** + * 实体转 DTO (编辑回填) + * + * @param entity 住宿安排实体 + * @return DTO 对象,入参为空时返回 null + */ + public static TrainAccommodationDTO from(TrainAccommodation entity) + { + if (entity == null) + { + return null; + } + TrainAccommodationDTO dto = new TrainAccommodationDTO(); + dto.setAccommodationId(entity.getAccommodationId()); + dto.setEnrollmentId(entity.getEnrollmentId()); + dto.setDormId(entity.getDormId()); + dto.setBedNo(entity.getBedNo()); + dto.setAssignedTime(entity.getAssignedTime()); + dto.setAccommodationStatus(entity.getAccommodationStatus()); + dto.setLockApiBizNo(entity.getLockApiBizNo()); + dto.setLockResult(entity.getLockResult()); + dto.setRemark(entity.getRemark()); + return dto; + } + + public Long getAccommodationId() + { + return accommodationId; + } + + public void setAccommodationId(Long accommodationId) + { + this.accommodationId = accommodationId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public Date getAssignedTime() + { + return assignedTime; + } + + public void setAssignedTime(Date assignedTime) + { + this.assignedTime = assignedTime; + } + + public Integer getAccommodationStatus() + { + return accommodationStatus; + } + + public void setAccommodationStatus(Integer accommodationStatus) + { + this.accommodationStatus = accommodationStatus; + } + + public String getLockApiBizNo() + { + return lockApiBizNo; + } + + public void setLockApiBizNo(String lockApiBizNo) + { + this.lockApiBizNo = lockApiBizNo; + } + + public Integer getLockResult() + { + return lockResult; + } + + public void setLockResult(Integer lockResult) + { + this.lockResult = lockResult; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TrainTheoryExamScoreDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TrainTheoryExamScoreDTO.java new file mode 100644 index 000000000..572ff5cfc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/TrainTheoryExamScoreDTO.java @@ -0,0 +1,337 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 班级学员理论考试成绩新增/修改请求 DTO。 + * + *

字段与 train_theory_exam_score 的可写列一一对应;主键 theoryScoreId 仅修改时必填。 + * 数据库生成列 final_flag 不在此出现,业务不可写。

+ * + * @author ruoyi + * @date 2026-11-06 + */ +public class TrainTheoryExamScoreDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 主键(修改时必填) */ + private Long theoryScoreId; + + /** 参训档案ID */ + @NotNull(message = "参训档案ID不能为空") + private Long enrollmentId; + + /** 培训班ID */ + @NotNull(message = "培训班ID不能为空") + @Size(max = 32, message = "培训班ID长度不能超过32个字符") + private String planId; + + /** 培训课程实例ID */ + @NotNull(message = "培训课程实例ID不能为空") + private Long planCourseId; + + /** 课程主档ID */ + @NotNull(message = "课程ID不能为空") + @Size(max = 32, message = "课程ID长度不能超过32个字符") + private String cId; + + /** 学员ID */ + @NotNull(message = "学员ID不能为空") + @Size(max = 32, message = "学员ID长度不能超过32个字符") + private String studentId; + + /** 理论科目编码(可空) */ + @Size(max = 32, message = "科目编码长度不能超过32个字符") + private String subjectCode; + + /** 考试开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date examStartTime; + + /** 考试结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date examEndTime; + + /** 用时秒数 */ + private Integer durationSeconds; + + /** 是否补考(1是 0否),默认 0 */ + private Integer retake; + + /** 补考序号(0正考,1..n 第n次补考),默认 0 */ + private Integer retakeNo; + + /** 考试状态(NORMAL正常 ABSENT缺考 EXEMPT免修),默认 NORMAL */ + @Size(max = 16, message = "考试状态长度不能超过16个字符") + private String examStatus; + + /** 理论成绩(缺考/免修可为空;区间 0 ~ fullScore 由 Service 校验) */ + private BigDecimal score; + + /** 满分,默认 100.00 */ + private BigDecimal fullScore; + + /** 是否通过(1通过 0未通过),为空时按成绩自动推算 */ + private Integer passFlag; + + /** 是否最终成绩(1是 0否),默认 1 */ + private Integer isFinal; + + /** 成绩状态(RECEIVED/ARCHIVED/LOCKED),默认 RECEIVED */ + @Size(max = 16, message = "成绩状态长度不能超过16个字符") + private String scoreStatus; + + /** 成绩来源(1手工录入 2平台在线考试 3批量或外部导入),默认 1 */ + private Integer sourceType; + + /** 来源系统标识 */ + @Size(max = 100, message = "来源系统长度不能超过100个字符") + private String sourceSystem; + + /** 外部事件唯一ID */ + @Size(max = 100, message = "外部事件ID长度不能超过100个字符") + private String sourceEventId; + + /** 关联综合成绩项ID */ + private Long scoreItemId; + + /** 备注 */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + public Long getTheoryScoreId() + { + return theoryScoreId; + } + + public void setTheoryScoreId(Long theoryScoreId) + { + this.theoryScoreId = theoryScoreId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public String getCId() + { + return cId; + } + + public void setCId(String cId) + { + this.cId = cId; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getSubjectCode() + { + return subjectCode; + } + + public void setSubjectCode(String subjectCode) + { + this.subjectCode = subjectCode; + } + + public Date getExamStartTime() + { + return examStartTime; + } + + public void setExamStartTime(Date examStartTime) + { + this.examStartTime = examStartTime; + } + + public Date getExamEndTime() + { + return examEndTime; + } + + public void setExamEndTime(Date examEndTime) + { + this.examEndTime = examEndTime; + } + + public Integer getDurationSeconds() + { + return durationSeconds; + } + + public void setDurationSeconds(Integer durationSeconds) + { + this.durationSeconds = durationSeconds; + } + + public Integer getRetake() + { + return retake; + } + + public void setRetake(Integer retake) + { + this.retake = retake; + } + + public Integer getRetakeNo() + { + return retakeNo; + } + + public void setRetakeNo(Integer retakeNo) + { + this.retakeNo = retakeNo; + } + + public String getExamStatus() + { + return examStatus; + } + + public void setExamStatus(String examStatus) + { + this.examStatus = examStatus; + } + + public BigDecimal getScore() + { + return score; + } + + public void setScore(BigDecimal score) + { + this.score = score; + } + + public BigDecimal getFullScore() + { + return fullScore; + } + + public void setFullScore(BigDecimal fullScore) + { + this.fullScore = fullScore; + } + + public Integer getPassFlag() + { + return passFlag; + } + + public void setPassFlag(Integer passFlag) + { + this.passFlag = passFlag; + } + + public Integer getIsFinal() + { + return isFinal; + } + + public void setIsFinal(Integer isFinal) + { + this.isFinal = isFinal; + } + + public String getScoreStatus() + { + return scoreStatus; + } + + public void setScoreStatus(String scoreStatus) + { + this.scoreStatus = scoreStatus; + } + + public Integer getSourceType() + { + return sourceType; + } + + public void setSourceType(Integer sourceType) + { + this.sourceType = sourceType; + } + + public String getSourceSystem() + { + return sourceSystem; + } + + public void setSourceSystem(String sourceSystem) + { + this.sourceSystem = sourceSystem; + } + + public String getSourceEventId() + { + return sourceEventId; + } + + public void setSourceEventId(String sourceEventId) + { + this.sourceEventId = sourceEventId; + } + + public Long getScoreItemId() + { + return scoreItemId; + } + + public void setScoreItemId(Long scoreItemId) + { + this.scoreItemId = scoreItemId; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java index 3443794a4..c65407e9f 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java @@ -56,4 +56,5 @@ public class EnrollmentQuery { this.accommodationRequired = accommodationRequired; } + } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java index c973af5ba..dfb0a4e08 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java @@ -9,6 +9,9 @@ public class PlanQuery private String planName; private Integer planStatus; + /** 自增编号(数据库递增序号),可选筛选条件。 */ + private Long id; + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date startTimeBegin; @@ -19,6 +22,8 @@ public class PlanQuery public void setPlanName(String planName) { this.planName = planName; } public Integer getPlanStatus() { return planStatus; } public void setPlanStatus(Integer planStatus) { this.planStatus = planStatus; } + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } public Date getStartTimeBegin() { return startTimeBegin; } public void setStartTimeBegin(Date startTimeBegin) { this.startTimeBegin = startTimeBegin; } public Date getStartTimeEnd() { return startTimeEnd; } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/StudentQuery.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/StudentQuery.java index c0dcd3d83..af99c36f5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/StudentQuery.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/StudentQuery.java @@ -8,16 +8,48 @@ public class StudentQuery implements Serializable private static final long serialVersionUID = 1L; private String sName; - private String orgName; + private String deptName; + private String cardNo; private String sPhone; private Integer status; + private String personNo; + private Long deptId; + + /** + * 按培训班过滤(可选):命中 train_enrollment 中该计划下未退训(=3)的参训记录。 + * 仅由导出等需要"班级维度"的场景传入,列表查询不传即为全量。 + */ + private String planId; + + /** 创建时间起(可选,格式 yyyy-MM-dd HH:mm:ss):用于批量添加参训人员按建档时间筛选 */ + private String beginCreateTime; + + /** 创建时间止(可选,格式 yyyy-MM-dd HH:mm:ss) */ + private String endCreateTime; public String getsName() { return sName; } public void setsName(String sName) { this.sName = sName; } - public String getOrgName() { return orgName; } - public void setOrgName(String orgName) { this.orgName = orgName; } + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } public String getsPhone() { return sPhone; } public void setsPhone(String sPhone) { this.sPhone = sPhone; } public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status = status; } + + public String getPersonNo() { return personNo; } + public void setPersonNo(String personNo) { this.personNo = personNo; } + + public Long getDeptId() { return deptId; } + public void setDeptId(Long deptId) { this.deptId = deptId; } + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public String getBeginCreateTime() { return beginCreateTime; } + public void setBeginCreateTime(String beginCreateTime) { this.beginCreateTime = beginCreateTime; } + + public String getEndCreateTime() { return endCreateTime; } + public void setEndCreateTime(String endCreateTime) { this.endCreateTime = endCreateTime; } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ClassSeatItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ClassSeatItemVO.java new file mode 100644 index 000000000..907c21dfd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ClassSeatItemVO.java @@ -0,0 +1,197 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; + +/** + * 班级座位列表项VO (含占用学员姓名,来自 train_student 关联查询) + * + * @author ruoyi + * @date 2026-10-26 + */ +public class ClassSeatItemVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 座位ID主键 (自增) */ + private Long seatId; + + /** 班级ID (train_class_plan.plan_id) */ + private String planId; + + /** 教室ID (train_classroom_info.class_room_id) */ + private String classroomId; + + /** 教学场地类别(classroom:教室,site:实训场地) */ + private String category; + + /** 行号,从1开始 */ + private Integer rowIndex; + + /** 列号,从1开始 */ + private Integer colIndex; + + /** 物理座位标识 */ + private String seatCode; + + /** 是否取消座位 (0正常,1取消) */ + private Integer canceled; + + /** 占用座位学员ID (train_student.s_id) */ + private String studentId; + + /** 占用学员姓名 (train_student.s_name,关联查询) */ + private String studentName; + + /** 区域ID */ + private Long zoneId; + + /** 部门 */ + private String deptDetail; + + /** 职务 */ + private String job; + + /** 创建时间 */ + private Date createTime; + + public Long getSeatId() + { + return seatId; + } + + public void setSeatId(Long seatId) + { + this.seatId = seatId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getClassroomId() + { + return classroomId; + } + + public void setClassroomId(String classroomId) + { + this.classroomId = classroomId; + } + + public String getCategory() + { + return category; + } + + public void setCategory(String category) + { + this.category = category; + } + + public Integer getRowIndex() + { + return rowIndex; + } + + public void setRowIndex(Integer rowIndex) + { + this.rowIndex = rowIndex; + } + + public Integer getColIndex() + { + return colIndex; + } + + public void setColIndex(Integer colIndex) + { + this.colIndex = colIndex; + } + + public String getSeatCode() + { + return seatCode; + } + + public void setSeatCode(String seatCode) + { + this.seatCode = seatCode; + } + + public Integer getCanceled() + { + return canceled; + } + + public void setCanceled(Integer canceled) + { + this.canceled = canceled; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public Long getZoneId() + { + return zoneId; + } + + public void setZoneId(Long zoneId) + { + this.zoneId = zoneId; + } + + public String getDeptDetail() + { + return deptDetail; + } + + public void setDeptDetail(String deptDetail) + { + this.deptDetail = deptDetail; + } + + public String getJob() + { + return job; + } + + public void setJob(String job) + { + this.job = job; + } + + public Date getCreateTime() + { + return createTime; + } + + public void setCreateTime(Date createTime) + { + this.createTime = createTime; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ClassroomDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ClassroomDetailVO.java index 220a2385d..ee13ee83a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ClassroomDetailVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ClassroomDetailVO.java @@ -46,6 +46,12 @@ public class ClassroomDetailVO implements Serializable private Integer projector; + /** 自增编号(数据库递增序号)。 */ + private Long id; + + /** 绑定的电子班牌ID(关联 train_electronic_board.board_id),可为空 */ + private String bindBoardId; + public String getClassRoomId() { return classRoomId; @@ -149,4 +155,20 @@ public class ClassroomDetailVO implements Serializable public void setProjector(Integer projector) { this.projector = projector; } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getBindBoardId() { + return bindBoardId; + } + + public void setBindBoardId(String bindBoardId) { + this.bindBoardId = bindBoardId; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java index d3caec1c0..d61536b7d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java @@ -63,6 +63,9 @@ public class CourseDetailVO implements Serializable /** 关联教学资源ID (兼容旧接口,优先使用关系表首个资源ID) */ private String resourceId; + /** 自增编号(数据库递增序号)。 */ + private Long id; + /** * 已关联的教学资源列表 */ @@ -93,6 +96,7 @@ public class CourseDetailVO implements Serializable vo.setUpdateBy(course.getUpdateBy()); vo.setUpdateTime(course.getUpdateTime()); vo.setResourceId(course.getResourceId()); + vo.setId(course.getId()); return vo; } @@ -296,6 +300,14 @@ public class CourseDetailVO implements Serializable this.resourceId = resourceId; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public List getResources() { return resources; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseScheduleItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseScheduleItemVO.java new file mode 100644 index 000000000..89720c3ae --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseScheduleItemVO.java @@ -0,0 +1,134 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 报到机课程安排明细项 VO(按课次开始时间查询,供 TrainRegisterApiController 使用)。 + * + * @author ruoyi + */ +public class CourseScheduleItemVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 课次主键标识 (train_lesson.lesson_id) */ + private Long id; + + /** 课程名称 (train_course.c_name) */ + private String name; + + /** 课程类型 (train_course.c_type) */ + private String courseType; + + /** 场地类型 (1:普通教室, 2:实训场地) */ + private Integer venueType; + + /** 课次开始时间 (train_lesson.start_time) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** 课次结束时间 (train_lesson.end_time) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** 上课教师姓名 (train_teacher.t_name) */ + private String teacher; + + /** 上课地点:普通教室取 room_name,实训场地取 site_name */ + private String classroom; + + /** 所属培训班名称 (train_class_plan.plan_name) */ + private String planName; + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public String getCourseType() + { + return courseType; + } + + public void setCourseType(String courseType) + { + this.courseType = courseType; + } + + public Integer getVenueType() + { + return venueType; + } + + public void setVenueType(Integer venueType) + { + this.venueType = venueType; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public String getTeacher() + { + return teacher; + } + + public void setTeacher(String teacher) + { + this.teacher = teacher; + } + + public String getClassroom() + { + return classroom; + } + + public void setClassroom(String classroom) + { + this.classroom = classroom; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormOptionsVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormOptionsVO.java index e6735f8fd..c06241294 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormOptionsVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormOptionsVO.java @@ -29,6 +29,9 @@ public class DormOptionsVO implements Serializable /** 房间ID */ private String dormId; + /** 自增编号(数据库递增序号)。 */ + private Long id; + /** 楼栋名称 */ private String dormBuilding; @@ -115,6 +118,14 @@ public class DormOptionsVO implements Serializable this.dormId = dormId; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public String getDormBuilding() { return dormBuilding; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryDetailVO.java index 8e71ccd26..41c6c246a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryDetailVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryDetailVO.java @@ -19,6 +19,9 @@ public class DormitoryDetailVO implements Serializable /** 宿舍ID主键 */ private String dormId; + /** 自增编号(数据库递增序号)。 */ + private Long id; + /** 宿舍楼栋 */ private String dormBuilding; @@ -66,6 +69,14 @@ public class DormitoryDetailVO implements Serializable this.dormId = dormId; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public String getDormBuilding() { return dormBuilding; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryItemVO.java index 5468ba81d..2f5b35363 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryItemVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryItemVO.java @@ -17,6 +17,9 @@ public class DormitoryItemVO implements Serializable /** 宿舍ID主键 */ private String dormId; + /** 自增编号(数据库递增序号)。 */ + private Long id; + /** 宿舍楼栋 */ private String dormBuilding; @@ -61,6 +64,14 @@ public class DormitoryItemVO implements Serializable this.dormId = dormId; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public String getDormBuilding() { return dormBuilding; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryLayoutRoomVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryLayoutRoomVO.java new file mode 100644 index 000000000..8c45bb420 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryLayoutRoomVO.java @@ -0,0 +1,108 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 宿舍布局房间信息视图对象(报到机宿舍布局接口使用)。 + * + *

数据来源:train_dormitory_info 左关联 train_accommodation 聚合统计“已分配床位数”。 + * 仅用于组装“楼栋 → 楼层 → 房间”嵌套结构,不落库。

+ * + * @author ruoyi + * @date 2026-11-09 + */ +public class DormitoryLayoutRoomVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 宿舍自增编号(对应 train_dormitory_info.id,接口返回中的 id) */ + private Long id; + + /** 宿舍ID主键(32位UUID,仅内部使用,不对外输出) */ + private String dormId; + + /** 宿舍楼栋 */ + private String dormBuilding; + + /** 所在楼层 */ + private String dormFloor; + + /** 房间号 */ + private String dormRoom; + + /** 房间核定床位总数 */ + private Integer capacity; + + /** 已分配床位数(train_accommodation 中住宿状态为 0预分配 / 1已入住 的记录数) */ + private Integer assignedCount; + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormFloor() + { + return dormFloor; + } + + public void setDormFloor(String dormFloor) + { + this.dormFloor = dormFloor; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + public Integer getCapacity() + { + return capacity; + } + + public void setCapacity(Integer capacity) + { + this.capacity = capacity; + } + + public Integer getAssignedCount() + { + return assignedCount; + } + + public void setAssignedCount(Integer assignedCount) + { + this.assignedCount = assignedCount; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryOccupantVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryOccupantVO.java index 7e5a504e3..f25e88162 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryOccupantVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/DormitoryOccupantVO.java @@ -26,7 +26,7 @@ public class DormitoryOccupantVO implements Serializable private String gender; /** 所属单位/部门 */ - private String orgName; + private String deptName; /** 分配床位号 (可选) */ private String bedNo; @@ -78,14 +78,14 @@ public class DormitoryOccupantVO implements Serializable this.gender = gender; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; } public String getBedNo() diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java index 9e78c96a0..8ad26530d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java @@ -30,7 +30,7 @@ public class EnrollmentDetailVO private String idCardMasked; /** 选派单位 */ - private String orgName; + private String deptName; /** 职务 */ private String postName; @@ -134,14 +134,14 @@ public class EnrollmentDetailVO this.idCardMasked = idCardMasked; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; } public String getPostName() diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java index 172b9a31e..91fcc4395 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java @@ -20,6 +20,12 @@ public class EnrollmentItemVO /** 学员姓名 */ private String studentName; + /** 学员头像 (取自 train_student.avatar) */ + private String avatar; + + /** 人员编号 (取自 train_student.person_no) */ + private String personNo; + /** 性别 */ private String gender; @@ -27,7 +33,7 @@ public class EnrollmentItemVO private String idCardMasked; /** 选派单位 */ - private String orgName; + private String deptName; /** 职务 */ private String postName; @@ -67,6 +73,20 @@ public class EnrollmentItemVO /** 住宿状态 (0: 预分配, 1: 已入住, 2: 已退宿) */ private Integer accommodationStatus; + /** 门锁设备序列号 (取自 train_dormitory_info.lock_device_sn) */ + private String lockDeviceSn; + + /** 门锁设备ID (join lock_device.device_id) */ + private Long lockDeviceId; + + /** 人脸算法版本 (join lock_device.face_algo_version) */ + private String lockFaceAlgoVersion; + + private String lockApiBizNo; + + /** 门锁授权结果 (train_accommodation.lock_result,1: 未授权, 2: 已授权, 3: 已过期) */ + private Integer lockResult; + public Long getEnrollmentId() { return enrollmentId; @@ -107,6 +127,26 @@ public class EnrollmentItemVO this.studentName = studentName; } + public String getAvatar() + { + return avatar; + } + + public void setAvatar(String avatar) + { + this.avatar = avatar; + } + + public String getPersonNo() + { + return personNo; + } + + public void setPersonNo(String personNo) + { + this.personNo = personNo; + } + public String getGender() { return gender; @@ -127,14 +167,14 @@ public class EnrollmentItemVO this.idCardMasked = idCardMasked; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; } public String getPostName() @@ -256,4 +296,52 @@ public class EnrollmentItemVO { this.accommodationStatus = accommodationStatus; } + + public String getLockDeviceSn() + { + return lockDeviceSn; + } + + public void setLockDeviceSn(String lockDeviceSn) + { + this.lockDeviceSn = lockDeviceSn; + } + + public Long getLockDeviceId() + { + return lockDeviceId; + } + + public void setLockDeviceId(Long lockDeviceId) + { + this.lockDeviceId = lockDeviceId; + } + + public String getLockFaceAlgoVersion() + { + return lockFaceAlgoVersion; + } + + public void setLockFaceAlgoVersion(String lockFaceAlgoVersion) + { + this.lockFaceAlgoVersion = lockFaceAlgoVersion; + } + + public String getLockApiBizNo() { + return lockApiBizNo; + } + + public void setLockApiBizNo(String lockApiBizNo) { + this.lockApiBizNo = lockApiBizNo; + } + + public Integer getLockResult() + { + return lockResult; + } + + public void setLockResult(Integer lockResult) + { + this.lockResult = lockResult; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/OpenMatchStudentVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/OpenMatchStudentVO.java new file mode 100644 index 000000000..a2293adf5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/OpenMatchStudentVO.java @@ -0,0 +1,66 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 开放接口-学员匹配结果(内部使用,用于成绩归集) + * + * @author ruoyi + * @date 2026-11-05 + */ +public class OpenMatchStudentVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 培训班ID */ + private String planId; + + /** 学员姓名(平台档案) */ + private String studentName; + + /** 身份证后四位(平台档案,用于交叉校验) */ + private String idCardLast4; + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getIdCardLast4() + { + return idCardLast4; + } + + public void setIdCardLast4(String idCardLast4) + { + this.idCardLast4 = idCardLast4; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/OpenStudentVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/OpenStudentVO.java new file mode 100644 index 000000000..b8ac47d74 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/OpenStudentVO.java @@ -0,0 +1,131 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 开放接口-在训学员视图(3.2 返回项) + * + * @author ruoyi + * @date 2026-11-05 + */ +public class OpenStudentVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 学员ID(平台唯一,成绩匹配键) */ + private String studentId; + + /** 学员姓名 */ + private String studentName; + + /** 性别(MALE/FEMALE) */ + private String gender; + + /** 手机号(掩码) */ + private String phone; + + /** 身份证号(掩码) */ + private String idCard; + + /** 所在培训班ID */ + private String classId; + + /** 所在培训班名称 */ + private String className; + + /** 学号 */ + private String studentNo; + + /** 学员单位 */ + private String orgName; + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getGender() + { + return gender; + } + + public void setGender(String gender) + { + this.gender = gender; + } + + public String getPhone() + { + return phone; + } + + public void setPhone(String phone) + { + this.phone = phone; + } + + public String getIdCard() + { + return idCard; + } + + public void setIdCard(String idCard) + { + this.idCard = idCard; + } + + public String getClassId() + { + return classId; + } + + public void setClassId(String classId) + { + this.classId = classId; + } + + public String getClassName() + { + return className; + } + + public void setClassName(String className) + { + this.className = className; + } + + public String getStudentNo() + { + return studentNo; + } + + public void setStudentNo(String studentNo) + { + this.studentNo = studentNo; + } + + public String getOrgName() + { + return orgName; + } + + public void setOrgName(String orgName) + { + this.orgName = orgName; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java index 694d9de71..ca4d3b9ba 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java @@ -15,9 +15,19 @@ public class PlanDetailVO private Date endTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date reportDeadline; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportStarttime; private String trainAddress; private Long recruitNum; private String applyCondition; + /** 所属专业(字典 train_class_major) */ + private Integer classMajor; + /** 总学时 */ + private Integer totalClassHours; + /** 负责人 */ + private String manager; + /** 主办部门ID(sys_dept) */ + private Integer hostDeptId; private String remark; private String createBy; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @@ -25,6 +35,8 @@ public class PlanDetailVO private String updateBy; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date updateTime; + private String classId; + private String className; public String getPlanId() { return planId; } public void setPlanId(String planId) { this.planId = planId; } @@ -38,12 +50,22 @@ public class PlanDetailVO public void setEndTime(Date endTime) { this.endTime = endTime; } public Date getReportDeadline() { return reportDeadline; } public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; } + public Date getReportStarttime() { return reportStarttime; } + public void setReportStarttime(Date reportStarttime) { this.reportStarttime = reportStarttime; } public String getTrainAddress() { return trainAddress; } public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; } public Long getRecruitNum() { return recruitNum; } public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; } public String getApplyCondition() { return applyCondition; } public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; } + public Integer getClassMajor() { return classMajor; } + public void setClassMajor(Integer classMajor) { this.classMajor = classMajor; } + public Integer getTotalClassHours() { return totalClassHours; } + public void setTotalClassHours(Integer totalClassHours) { this.totalClassHours = totalClassHours; } + public String getManager() { return manager; } + public void setManager(String manager) { this.manager = manager; } + public Integer getHostDeptId() { return hostDeptId; } + public void setHostDeptId(Integer hostDeptId) { this.hostDeptId = hostDeptId; } public String getRemark() { return remark; } public void setRemark(String remark) { this.remark = remark; } public String getCreateBy() { return createBy; } @@ -54,4 +76,20 @@ public class PlanDetailVO public void setUpdateBy(String updateBy) { this.updateBy = updateBy; } public Date getUpdateTime() { return updateTime; } public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } + + public String getClassId() { + return classId; + } + + public void setClassId(String classId) { + this.classId = classId; + } + + public String getClassName() { + return className; + } + + public void setClassName(String className) { + this.className = className; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java index d0b174f98..05202923c 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java @@ -15,11 +15,33 @@ public class PlanItemVO private Date endTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date reportDeadline; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportStarttime; private String trainAddress; private Long recruitNum; private Long courseCount; private Long enrolledCount; + /** 班级ID */ + private String classId; + /** 班级名称 */ + private String className; + /** 所属专业 */ + private Integer classMajor; + /** 班主任 */ + private String classTeacher; + /** 负责人 */ + private String manager; + /** 主办部门 */ + private Integer hostDeptId; + /** 总计划学时 */ + private Integer totalClassHours; + /** 总预算 */ + private Float totalBudget; + + /** 自增编号(数据库递增序号)。 */ + private Long id; + public String getPlanId() { return planId; } public void setPlanId(String planId) { this.planId = planId; } public String getPlanName() { return planName; } @@ -32,6 +54,8 @@ public class PlanItemVO public void setEndTime(Date endTime) { this.endTime = endTime; } public Date getReportDeadline() { return reportDeadline; } public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; } + public Date getReportStarttime() { return reportStarttime; } + public void setReportStarttime(Date reportStarttime) { this.reportStarttime = reportStarttime; } public String getTrainAddress() { return trainAddress; } public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; } public Long getRecruitNum() { return recruitNum; } @@ -40,4 +64,24 @@ public class PlanItemVO public void setCourseCount(Long courseCount) { this.courseCount = courseCount; } public Long getEnrolledCount() { return enrolledCount; } public void setEnrolledCount(Long enrolledCount) { this.enrolledCount = enrolledCount; } + + public String getClassId() { return classId; } + public void setClassId(String classId) { this.classId = classId; } + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } + public Integer getClassMajor() { return classMajor; } + public void setClassMajor(Integer classMajor) { this.classMajor = classMajor; } + public String getClassTeacher() { return classTeacher; } + public void setClassTeacher(String classTeacher) { this.classTeacher = classTeacher; } + public String getManager() { return manager; } + public void setManager(String manager) { this.manager = manager; } + public Integer getHostDeptId() { return hostDeptId; } + public void setHostDeptId(Integer hostDeptId) { this.hostDeptId = hostDeptId; } + public Integer getTotalClassHours() { return totalClassHours; } + public void setTotalClassHours(Integer totalClassHours) { this.totalClassHours = totalClassHours; } + public Float getTotalBudget() { return totalBudget; } + public void setTotalBudget(Float totalBudget) { this.totalBudget = totalBudget; } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegArrivalFactVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegArrivalFactVO.java new file mode 100644 index 000000000..151612c24 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegArrivalFactVO.java @@ -0,0 +1,39 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; + +/** + * 报到时间事实行 (用于跨库方言在内存中计算报到率/到训耗时/时间分布) + * + *

字段与 train_class_report.report_time、train_class_plan.start_time、 + * train_class_plan.report_starttime 一一对应。

+ */ +public class RegArrivalFactVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 送培单位ID (train_student.dept_id,按单位统计时有值) */ + private Long unitId; + + /** 实际报到时间 (train_class_report.report_time) */ + private Date reportTime; + + /** 开班时间 (train_class_plan.start_time) */ + private Date planStartTime; + + /** 报到开始时间 (train_class_plan.report_starttime) */ + private Date reportStartTime; + + public Long getUnitId() { return unitId; } + public void setUnitId(Long unitId) { this.unitId = unitId; } + + public Date getReportTime() { return reportTime; } + public void setReportTime(Date reportTime) { this.reportTime = reportTime; } + + public Date getPlanStartTime() { return planStartTime; } + public void setPlanStartTime(Date planStartTime) { this.planStartTime = planStartTime; } + + public Date getReportStartTime() { return reportStartTime; } + public void setReportStartTime(Date reportStartTime) { this.reportStartTime = reportStartTime; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegBatchTrendVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegBatchTrendVO.java new file mode 100644 index 000000000..a16eded58 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegBatchTrendVO.java @@ -0,0 +1,50 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 各批次报到率趋势 + */ +public class RegBatchTrendVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 批次标识 (plan_id) */ + private String batchNo; + + /** 批次名称 (班级名称,为空回退计划名称) */ + private String batchName; + + /** 应报到人数 */ + private Integer expected; + + /** 已报到人数 */ + private Integer reported; + + /** 报到率(%) */ + private Double rate; + + /** 开班时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + public String getBatchNo() { return batchNo; } + public void setBatchNo(String batchNo) { this.batchNo = batchNo; } + + public String getBatchName() { return batchName; } + public void setBatchName(String batchName) { this.batchName = batchName; } + + public Integer getExpected() { return expected; } + public void setExpected(Integer expected) { this.expected = expected; } + + public Integer getReported() { return reported; } + public void setReported(Integer reported) { this.reported = reported; } + + public Double getRate() { return rate; } + public void setRate(Double rate) { this.rate = rate; } + + public Date getStartTime() { return startTime; } + public void setStartTime(Date startTime) { this.startTime = startTime; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegClassVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegClassVO.java new file mode 100644 index 000000000..8ccfb60b5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegClassVO.java @@ -0,0 +1,38 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 报到数据 - 班级标签对象 (来源 train_class_plan) + */ +public class RegClassVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 培训计划ID */ + private String planId; + + /** 计划名称 */ + private String planName; + + /** 班级名称 (未启动报到时为空) */ + private String className; + + /** 开班时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public String getPlanName() { return planName; } + public void setPlanName(String planName) { this.planName = planName; } + + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } + + public Date getStartTime() { return startTime; } + public void setStartTime(Date startTime) { this.startTime = startTime; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistorySummaryVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistorySummaryVO.java new file mode 100644 index 000000000..469cf2c2b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistorySummaryVO.java @@ -0,0 +1,55 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 历史报到统计汇总 + */ +public class RegHistorySummaryVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 累计报到人次 */ + private Integer totalReported = 0; + + /** 平均报到率(%) */ + private Double avgRate = 0D; + + /** 平均到训耗时(分钟) */ + private Integer avgCostMinutes = 0; + + /** 最早到达时刻 HH:mm */ + private String earliest; + + /** 最晚到达时刻 HH:mm */ + private String latest; + + /** 到训时间分布 */ + private List hourly = new ArrayList<>(); + + /** 参与统计的批次数 */ + private Integer batchCount = 0; + + public Integer getTotalReported() { return totalReported; } + public void setTotalReported(Integer totalReported) { this.totalReported = totalReported; } + + public Double getAvgRate() { return avgRate; } + public void setAvgRate(Double avgRate) { this.avgRate = avgRate; } + + public Integer getAvgCostMinutes() { return avgCostMinutes; } + public void setAvgCostMinutes(Integer avgCostMinutes) { this.avgCostMinutes = avgCostMinutes; } + + public String getEarliest() { return earliest; } + public void setEarliest(String earliest) { this.earliest = earliest; } + + public String getLatest() { return latest; } + public void setLatest(String latest) { this.latest = latest; } + + public List getHourly() { return hourly; } + public void setHourly(List hourly) { this.hourly = hourly; } + + public Integer getBatchCount() { return batchCount; } + public void setBatchCount(Integer batchCount) { this.batchCount = batchCount; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHourlyVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHourlyVO.java new file mode 100644 index 000000000..f86bcc58c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHourlyVO.java @@ -0,0 +1,33 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 报到时间分布 - 按小时聚合 + */ +public class RegHourlyVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 小时标签,如 08:00 */ + private String hour; + + /** 该小时报到人数 */ + private Integer count; + + public RegHourlyVO() + { + } + + public RegHourlyVO(String hour, Integer count) + { + this.hour = hour; + this.count = count; + } + + public String getHour() { return hour; } + public void setHour(String hour) { this.hour = hour; } + + public Integer getCount() { return count; } + public void setCount(Integer count) { this.count = count; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegRosterVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegRosterVO.java new file mode 100644 index 000000000..7a0e86b3a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegRosterVO.java @@ -0,0 +1,126 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.ruoyi.common.annotation.Excel; + +/** + * 报到名册行 (train_enrollment + train_student + train_class_plan + train_class_report) + */ +public class RegRosterVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 报到记录ID (train_class_report.report_id,未报到为空) */ + private Long reportId; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 学员ID */ + private String studentId; + + /** 学员姓名 */ + @Excel(name = "学员姓名") + private String studentName; + + /** 学号/人事号 */ + @Excel(name = "学号") + private String studentCode; + + /** 班级名称 (train_class_plan.class_name,为空回退 plan_name) */ + @Excel(name = "班级") + private String className; + + /** 联系电话 */ + @Excel(name = "联系电话") + private String phone; + + /** 报到状态 1=已报到 0=未报到 */ + @Excel(name = "报到状态", readConverterExp = "1=已报到,0=未报到") + private Integer reported; + + /** 报到时间 yyyy-MM-dd HH:mm (Service 按原始时间格式化) */ + @Excel(name = "报到时间") + private String reportTime; + + /** 报到时间原始值 (仅用于格式化,不参与 JSON 序列化) */ + @JsonIgnore + private Date reportTimeValue; + + /** 报到方式文本 (1=人脸识别,2=身份证刷卡,3=二维码,4=人工录入) */ + @Excel(name = "报到方式") + private String reportWay; + + /** 报到方式字典值 (train_class_report.report_way) */ + private Integer reportWayCode; + + /** 报到设备编号 */ + @Excel(name = "报到设备") + private String deviceName; + + /** 核验结果 1=通过 2=不匹配 3=人工放行 */ + private Integer verifyResult; + + /** 核验说明 */ + private String verifyNote; + + /** 备注 */ + @Excel(name = "备注") + private String remark; + + /** 状态文案(未报到列表使用) */ + private String statusText; + + public Long getReportId() { return reportId; } + public void setReportId(Long reportId) { this.reportId = reportId; } + + public Long getEnrollmentId() { return enrollmentId; } + public void setEnrollmentId(Long enrollmentId) { this.enrollmentId = enrollmentId; } + + public String getStudentId() { return studentId; } + public void setStudentId(String studentId) { this.studentId = studentId; } + + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + + public String getStudentCode() { return studentCode; } + public void setStudentCode(String studentCode) { this.studentCode = studentCode; } + + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + + public Integer getReported() { return reported; } + public void setReported(Integer reported) { this.reported = reported; } + + public String getReportTime() { return reportTime; } + public void setReportTime(String reportTime) { this.reportTime = reportTime; } + + public Date getReportTimeValue() { return reportTimeValue; } + public void setReportTimeValue(Date reportTimeValue) { this.reportTimeValue = reportTimeValue; } + + public String getReportWay() { return reportWay; } + public void setReportWay(String reportWay) { this.reportWay = reportWay; } + + public Integer getReportWayCode() { return reportWayCode; } + public void setReportWayCode(Integer reportWayCode) { this.reportWayCode = reportWayCode; } + + public String getDeviceName() { return deviceName; } + public void setDeviceName(String deviceName) { this.deviceName = deviceName; } + + public Integer getVerifyResult() { return verifyResult; } + public void setVerifyResult(Integer verifyResult) { this.verifyResult = verifyResult; } + + public String getVerifyNote() { return verifyNote; } + public void setVerifyNote(String verifyNote) { this.verifyNote = verifyNote; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + public String getStatusText() { return statusText; } + public void setStatusText(String statusText) { this.statusText = statusText; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegSummaryVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegSummaryVO.java new file mode 100644 index 000000000..068b4ec47 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegSummaryVO.java @@ -0,0 +1,67 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 报到数据 - 班级报到统计汇总 + */ +public class RegSummaryVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 应报到人数 (有效报名数,不含放弃) */ + private Integer expected = 0; + + /** 已报到人数 */ + private Integer reported = 0; + + /** 未报到人数 */ + private Integer notReported = 0; + + /** 报到率(%) */ + private Double rate = 0D; + + /** 开班时间 */ + private String startTime; + + /** 结束时间 */ + private String endTime; + + /** 报到截止时间 (train_class_plan.report_deadline) */ + private String remedialDeadline; + + /** 报到时间分布 */ + private List hourly = new ArrayList<>(); + + /** 高峰时段说明 */ + private String peakNote; + + public Integer getExpected() { return expected; } + public void setExpected(Integer expected) { this.expected = expected; } + + public Integer getReported() { return reported; } + public void setReported(Integer reported) { this.reported = reported; } + + public Integer getNotReported() { return notReported; } + public void setNotReported(Integer notReported) { this.notReported = notReported; } + + public Double getRate() { return rate; } + public void setRate(Double rate) { this.rate = rate; } + + public String getStartTime() { return startTime; } + public void setStartTime(String startTime) { this.startTime = startTime; } + + public String getEndTime() { return endTime; } + public void setEndTime(String endTime) { this.endTime = endTime; } + + public String getRemedialDeadline() { return remedialDeadline; } + public void setRemedialDeadline(String remedialDeadline) { this.remedialDeadline = remedialDeadline; } + + public List getHourly() { return hourly; } + public void setHourly(List hourly) { this.hourly = hourly; } + + public String getPeakNote() { return peakNote; } + public void setPeakNote(String peakNote) { this.peakNote = peakNote; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegUnitVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegUnitVO.java new file mode 100644 index 000000000..1b325dc7a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegUnitVO.java @@ -0,0 +1,52 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import com.ruoyi.common.annotation.Excel; + +/** + * 各送培单位报到情况 (train_student.dept_id -> sys_dept.dept_name) + */ +public class RegUnitVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 送培单位ID */ + private Long unitId; + + /** 送培单位名称 */ + @Excel(name = "送培单位") + private String unitName; + + /** 累计送培人数 */ + @Excel(name = "累计送培") + private Integer totalSent; + + /** 已报到人数 */ + private Integer reported; + + /** 平均报到率(%) */ + @Excel(name = "平均报到率") + private Double avgRate; + + /** 平均到训时刻 HH:mm */ + @Excel(name = "平均到训时间") + private String avgArriveTime; + + public Long getUnitId() { return unitId; } + public void setUnitId(Long unitId) { this.unitId = unitId; } + + public String getUnitName() { return unitName; } + public void setUnitName(String unitName) { this.unitName = unitName; } + + public Integer getTotalSent() { return totalSent; } + public void setTotalSent(Integer totalSent) { this.totalSent = totalSent; } + + public Integer getReported() { return reported; } + public void setReported(Integer reported) { this.reported = reported; } + + public Double getAvgRate() { return avgRate; } + public void setAvgRate(Double avgRate) { this.avgRate = avgRate; } + + public String getAvgArriveTime() { return avgArriveTime; } + public void setAvgArriveTime(String avgArriveTime) { this.avgArriveTime = avgArriveTime; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ReportTerminalPreviewVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ReportTerminalPreviewVO.java index 5d5695cf4..968817d54 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ReportTerminalPreviewVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ReportTerminalPreviewVO.java @@ -25,7 +25,7 @@ public class ReportTerminalPreviewVO implements Serializable private String gender; /** 选派单位 */ - private String orgName; + private String deptName; /** 联系电话 */ private String phone; @@ -115,14 +115,14 @@ public class ReportTerminalPreviewVO implements Serializable this.gender = gender; } - public String getOrgName() + public String getDeptName() { - return orgName; + return deptName; } - public void setOrgName(String orgName) + public void setDeptName(String deptName) { - this.orgName = orgName; + this.deptName = deptName; } public String getPhone() diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageClassroomVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageClassroomVO.java new file mode 100644 index 000000000..da556c180 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageClassroomVO.java @@ -0,0 +1,34 @@ +package com.ruoyi.train.domain.vo; + +/** + * 电子班牌-教室信息 VO(对应 /signageService/selectClassroom)。 + * id 采用 train_classroom_info.class_room_id(业务主键,字符串)。 + */ +public class SignageClassroomVO +{ + /** 教学教室ID(class_room_id) */ + private String id; + /** 教室名称 */ + private String name; + /** 校区 */ + private String campus; + /** 容量 */ + private Long capacity; + /** 对方教室属性值(当前下发未使用) */ + private Integer outside; + /** 教室分类 */ + private String category; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getCampus() { return campus; } + public void setCampus(String campus) { this.campus = campus; } + public Long getCapacity() { return capacity; } + public void setCapacity(Long capacity) { this.capacity = capacity; } + public Integer getOutside() { return outside; } + public void setOutside(Integer outside) { this.outside = outside; } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageCourseVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageCourseVO.java new file mode 100644 index 000000000..8d03b5a24 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageCourseVO.java @@ -0,0 +1,61 @@ +package com.ruoyi.train.domain.vo; + +/** + * 电子班牌-教室当天课程 VO(对应 /signageService/coursesInClassroom)。 + */ +public class SignageCourseVO +{ + /** 排课ID(lesson_id) */ + private Long id; + /** 课程主数据ID(train_course.c_id) */ + private String courseId; + /** 班级ID(train_class_plan.plan_id) */ + private String classId; + /** 教室ID(train_lesson.class_room_id) */ + private String classroomId; + /** 课程名称 */ + private String courseName; + /** 兼容旧拼写 */ + private String couresName; + /** 上课日期时间 yyyy-MM-dd HH:mm:ss */ + private String courseTime; + /** 时长(分钟) */ + private Integer classHour; + /** 班级显示名称 */ + private String className; + /** 教师ID(train_lesson.t_id) */ + private String teacherId; + /** 教师姓名 */ + private String teacherName; + /** 课程简介 */ + private String courseIntro; + /** 公开属性(当前下发未使用) */ + private Integer isPublic; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getCourseId() { return courseId; } + public void setCourseId(String courseId) { this.courseId = courseId; } + public String getClassId() { return classId; } + public void setClassId(String classId) { this.classId = classId; } + public String getClassroomId() { return classroomId; } + public void setClassroomId(String classroomId) { this.classroomId = classroomId; } + public String getCourseName() { return courseName; } + public void setCourseName(String courseName) { this.courseName = courseName; } + public String getCouresName() { return couresName; } + public void setCouresName(String couresName) { this.couresName = couresName; } + public String getCourseTime() { return courseTime; } + public void setCourseTime(String courseTime) { this.courseTime = courseTime; } + public Integer getClassHour() { return classHour; } + public void setClassHour(Integer classHour) { this.classHour = classHour; } + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } + public String getTeacherId() { return teacherId; } + public void setTeacherId(String teacherId) { this.teacherId = teacherId; } + public String getTeacherName() { return teacherName; } + public void setTeacherName(String teacherName) { this.teacherName = teacherName; } + public String getCourseIntro() { return courseIntro; } + public void setCourseIntro(String courseIntro) { this.courseIntro = courseIntro; } + public Integer getIsPublic() { return isPublic; } + public void setIsPublic(Integer isPublic) { this.isPublic = isPublic; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageSeatLayoutVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageSeatLayoutVO.java new file mode 100644 index 000000000..0537da2dc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageSeatLayoutVO.java @@ -0,0 +1,153 @@ +package com.ruoyi.train.domain.vo; + +/** + * 电子班牌座位布局 VO(数据来源仅 train_class_seat 单表)。 + * + * @author ruoyi + * @date 2026-11-03 + */ +public class SignageSeatLayoutVO +{ + /** 座位主键(seat_id) */ + private Long seatId; + + /** 班级ID(plan_id) */ + private String planId; + + /** 教室ID(classroom_id) */ + private String classroomId; + + /** 行号,从1开始 */ + private Integer rowIndex; + + /** 列号,从1开始 */ + private Integer colIndex; + + /** 物理座位标识(seat_code) */ + private String seatCode; + + /** 是否取消位置:0正常,1取消 */ + private Integer canceled; + + /** 占用座位学员ID(student_id),空座为 null */ + private String studentId; + + /** 区域ID */ + private Long zoneId; + + /** 部门 */ + private String deptDetail; + + /** 职务 */ + private String job; + + public Long getSeatId() + { + return seatId; + } + + public void setSeatId(Long seatId) + { + this.seatId = seatId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getClassroomId() + { + return classroomId; + } + + public void setClassroomId(String classroomId) + { + this.classroomId = classroomId; + } + + public Integer getRowIndex() + { + return rowIndex; + } + + public void setRowIndex(Integer rowIndex) + { + this.rowIndex = rowIndex; + } + + public Integer getColIndex() + { + return colIndex; + } + + public void setColIndex(Integer colIndex) + { + this.colIndex = colIndex; + } + + public String getSeatCode() + { + return seatCode; + } + + public void setSeatCode(String seatCode) + { + this.seatCode = seatCode; + } + + public Integer getCanceled() + { + return canceled; + } + + public void setCanceled(Integer canceled) + { + this.canceled = canceled; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public Long getZoneId() + { + return zoneId; + } + + public void setZoneId(Long zoneId) + { + this.zoneId = zoneId; + } + + public String getDeptDetail() + { + return deptDetail; + } + + public void setDeptDetail(String deptDetail) + { + this.deptDetail = deptDetail; + } + + public String getJob() + { + return job; + } + + public void setJob(String job) + { + this.job = job; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageSeatVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageSeatVO.java new file mode 100644 index 000000000..cd856446b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageSeatVO.java @@ -0,0 +1,53 @@ +package com.ruoyi.train.domain.vo; + +/** + * 电子班牌-座位网格 VO(对应 /signageService/studentInClassroom)。 + */ +public class SignageSeatVO +{ + /** 行号,从1开始 */ + private Integer rowIndex; + /** 列号,从1开始 */ + private Integer colIndex; + /** 物理座位标识 */ + private String seat; + /** 0正常,1取消位置 */ + private Integer canceled; + /** 学员主数据ID(train_student.s_id) */ + private String stuId; + /** 姓名 */ + private String studentName; + /** 班级ID(plan_id) */ + private String classId; + /** 区域ID */ + private Long zoneId; + /** 座位号(实际取 seat) */ + private String seatNumber; + /** 部门 */ + private String deptDetail; + /** 职务 */ + private String job; + + public Integer getRowIndex() { return rowIndex; } + public void setRowIndex(Integer rowIndex) { this.rowIndex = rowIndex; } + public Integer getColIndex() { return colIndex; } + public void setColIndex(Integer colIndex) { this.colIndex = colIndex; } + public String getSeat() { return seat; } + public void setSeat(String seat) { this.seat = seat; } + public Integer getCanceled() { return canceled; } + public void setCanceled(Integer canceled) { this.canceled = canceled; } + public String getStuId() { return stuId; } + public void setStuId(String stuId) { this.stuId = stuId; } + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + public String getClassId() { return classId; } + public void setClassId(String classId) { this.classId = classId; } + public Long getZoneId() { return zoneId; } + public void setZoneId(Long zoneId) { this.zoneId = zoneId; } + public String getSeatNumber() { return seatNumber; } + public void setSeatNumber(String seatNumber) { this.seatNumber = seatNumber; } + public String getDeptDetail() { return deptDetail; } + public void setDeptDetail(String deptDetail) { this.deptDetail = deptDetail; } + public String getJob() { return job; } + public void setJob(String job) { this.job = job; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageStudentCardVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageStudentCardVO.java new file mode 100644 index 000000000..7c7b3a80d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SignageStudentCardVO.java @@ -0,0 +1,45 @@ +package com.ruoyi.train.domain.vo; + +/** + * 电子班牌-学员卡号 VO(对应 /signageService/studentCard)。 + */ +public class SignageStudentCardVO +{ + /** 卡号(train_student.card_no,保留前导零) */ + private String cardNo; + /** 学员ID(train_student.s_id) */ + private String studentId; + /** 班级ID(plan_id) */ + private String classId; + /** 学员姓名 */ + private String studentName; + /** 班级名称 */ + private String className; + /** 关系记录ID(enrollment_id,非学员ID) */ + private Long id; + /** 住宿房间信息(宿舍ID train_dormitory_info.dorm_id,字符串) */ + private String roomId; + /** 床位类型(预留) */ + private String bedType; + /** 座位号(预留) */ + private String seatNumber; + + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } + public String getStudentId() { return studentId; } + public void setStudentId(String studentId) { this.studentId = studentId; } + public String getClassId() { return classId; } + public void setClassId(String classId) { this.classId = classId; } + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getRoomId() { return roomId; } + public void setRoomId(String roomId) { this.roomId = roomId; } + public String getBedType() { return bedType; } + public void setBedType(String bedType) { this.bedType = bedType; } + public String getSeatNumber() { return seatNumber; } + public void setSeatNumber(String seatNumber) { this.seatNumber = seatNumber; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SiteDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SiteDetailVO.java index 07f8da4e8..ea9700a81 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SiteDetailVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/SiteDetailVO.java @@ -31,6 +31,12 @@ public class SiteDetailVO implements Serializable /** 场地状态 (0: 停用, 1: 可用) */ private Integer siteStatus; + /** 自增编号(数据库递增序号)。 */ + private Long id; + + /** 绑定的电子班牌ID(关联 train_electronic_board.board_id),可为空 */ + private String bindBoardId; + /** 备注 */ private String remark; @@ -102,6 +108,22 @@ public class SiteDetailVO implements Serializable this.siteStatus = siteStatus; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getBindBoardId() { + return bindBoardId; + } + + public void setBindBoardId(String bindBoardId) { + this.bindBoardId = bindBoardId; + } + public String getRemark() { return remark; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentExportVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentExportVO.java new file mode 100644 index 000000000..92550a142 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentExportVO.java @@ -0,0 +1,133 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.ruoyi.common.annotation.Excel; + +/** + * 学员台账导出 VO(train_student)。 + * + *

独立于 {@link StudentItemVO}:列表 VO 承载接口出参,不含 {@code @Excel} 注解; + * 导出必须依赖 {@code @Excel} 才能被 {@code ExcelUtil} 识别列与表头, + * 因此这里显式声明导出列、顺序、日期格式与字典翻译。

+ * + *

安全约束:身份证仅导出脱敏值({@code idCardMasked}),不得导出明文/密文/查重哈希。

+ * + * @author ruoyi + */ +public class StudentExportVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 学员ID (train_student.s_id) */ + @Excel(name = "学员ID", width = 22, sort = 1) + private String sId; + + /** 学员姓名 */ + @Excel(name = "学员姓名", width = 16, sort = 2) + private String sName; + + /** 人员编号 (AA+6位数字,对接门禁人脸) */ + @Excel(name = "人员编号", width = 14, sort = 3) + private String personNo; + + /** 性别 0=男 1=女 2=未知 */ + @Excel(name = "性别", readConverterExp = "0=男,1=女,2=未知", width = 10, sort = 4) + private String sGender; + + /** 出生日期 */ + @Excel(name = "出生日期", dateFormat = "yyyy-MM-dd", width = 14, sort = 5) + private Date sBirth; + + /** 所属单位 */ + @Excel(name = "所属单位", width = 26, sort = 6) + private String deptName; + + /** 联系电话 */ + @Excel(name = "联系电话", width = 16, sort = 7) + private String sPhone; + + /** 邮箱 */ + @Excel(name = "邮箱", width = 24, sort = 8) + private String sEmail; + + /** 身份证号(脱敏,仅用于展示/导出) */ + @Excel(name = "身份证号(脱敏)", width = 22, sort = 9) + private String idCardMasked; + + /** 门禁卡号 */ + @Excel(name = "门禁卡号", width = 16, sort = 10) + private String cardNo; + + /** 状态 1=正常 0=停用 */ + @Excel(name = "状态", readConverterExp = "1=正常,0=停用", width = 10, sort = 11) + private Integer status; + + /** 来源 1=管理员录入 2=Excel批量导入 3=内部平台同步 */ + @Excel(name = "来源", readConverterExp = "1=管理员录入,2=Excel批量导入,3=内部平台同步", width = 18, sort = 12) + private Integer sourceType; + + /** + * 由列表视图转换为导出行,字段一一映射,不做任何业务计算。 + * + * @param item 列表视图 + * @return 导出行;入参为空时返回 null + */ + public static StudentExportVO from(StudentItemVO item) + { + if (item == null) + { + return null; + } + StudentExportVO vo = new StudentExportVO(); + vo.setSId(item.getsId()); + vo.setSName(item.getsName()); + vo.setPersonNo(item.getPersonNo()); + vo.setSGender(item.getsGender()); + vo.setSBirth(item.getsBirth()); + vo.setDeptName(item.getDeptName()); + vo.setSPhone(item.getsPhone()); + vo.setSEmail(item.getsEmail()); + vo.setIdCardMasked(item.getIdCardMasked()); + vo.setCardNo(item.getCardNo()); + vo.setStatus(item.getStatus()); + vo.setSourceType(item.getSourceType()); + return vo; + } + + public String getSId() { return sId; } + public void setSId(String sId) { this.sId = sId; } + + public String getSName() { return sName; } + public void setSName(String sName) { this.sName = sName; } + + public String getPersonNo() { return personNo; } + public void setPersonNo(String personNo) { this.personNo = personNo; } + + public String getSGender() { return sGender; } + public void setSGender(String sGender) { this.sGender = sGender; } + + public Date getSBirth() { return sBirth; } + public void setSBirth(Date sBirth) { this.sBirth = sBirth; } + + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + + public String getSPhone() { return sPhone; } + public void setSPhone(String sPhone) { this.sPhone = sPhone; } + + public String getSEmail() { return sEmail; } + public void setSEmail(String sEmail) { this.sEmail = sEmail; } + + public String getIdCardMasked() { return idCardMasked; } + public void setIdCardMasked(String idCardMasked) { this.idCardMasked = idCardMasked; } + + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } + + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + + public Integer getSourceType() { return sourceType; } + public void setSourceType(Integer sourceType) { this.sourceType = sourceType; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentImportVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentImportVO.java new file mode 100644 index 000000000..914b3ad1e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentImportVO.java @@ -0,0 +1,88 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import com.ruoyi.common.annotation.Excel; + +/** + * 学员批量导入行 / 导入模板行 (train_student) + * + *

字段顺序即为导入模板列顺序,ExcelUtil 依 {@link Excel#name()} 匹配表头。 + * 字段名必须使用标准 JavaBean 驼峰(不能使用 sName 这类单字母前缀), + * 否则 ExcelUtil 通过 set+capitalize 反射赋值时会找不到 setter。

+ * + * @author ruoyi + */ +public class StudentImportVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 学员姓名 (必填, train_student.s_name) */ + @Excel(name = "学员姓名", width = 16, prompt = "必填,最长50个字符") + private String name; + + /** 人员编号 (train_student.person_no) */ + @Excel(name = "人员编号", width = 14, prompt = "选填,格式 AA+6位数字;留空自动生成") + private String personNo; + + /** 性别 (train_student.s_gender) */ + @Excel(name = "性别", width = 10, prompt = "男 / 女 / 未知") + private String gender; + + /** 出生日期 (train_student.s_birth) */ + @Excel(name = "出生日期", width = 14, dateFormat = "yyyy-MM-dd", prompt = "格式 yyyy-MM-dd") + private String birth; + + /** 所属单位 (train_student.dept_name) */ + @Excel(name = "所属单位", width = 24, prompt = "选填,最长100个字符") + private String deptName; + + /** 联系电话 (train_student.s_phone) */ + @Excel(name = "联系电话", width = 16, prompt = "选填,11位手机号") + private String phone; + + /** 邮箱 (train_student.s_email) */ + @Excel(name = "邮箱", width = 24, prompt = "选填") + private String email; + + /** 身份证号 (train_student.id_card_ciphertext 明文入口) */ + @Excel(name = "身份证号", width = 22, prompt = "选填,18位身份证号,入库加密存储") + private String idCard; + + /** 门禁卡号 (train_student.card_no) */ + @Excel(name = "门禁卡号", width = 16, prompt = "选填") + private String cardNo; + + /** 备注 (train_student.remark) */ + @Excel(name = "备注", width = 28) + private String remark; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getPersonNo() { return personNo; } + public void setPersonNo(String personNo) { this.personNo = personNo; } + + public String getGender() { return gender; } + public void setGender(String gender) { this.gender = gender; } + + public String getBirth() { return birth; } + public void setBirth(String birth) { this.birth = birth; } + + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getIdCard() { return idCard; } + public void setIdCard(String idCard) { this.idCard = idCard; } + + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentItemVO.java index ad11a1ef8..7bb75215b 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentItemVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentItemVO.java @@ -3,20 +3,28 @@ package com.ruoyi.train.domain.vo; import java.io.Serializable; import java.util.Date; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; /** 学员列表安全视图,不包含身份证明文、密文或查重值。 */ public class StudentItemVO implements Serializable { private static final long serialVersionUID = 1L; + @JsonProperty("sId") private String sId; + @JsonProperty("sName") private String sName; private String personNo; + @JsonProperty("sGender") private String sGender; @JsonFormat(pattern = "yyyy-MM-dd") + @JsonProperty("sBirth") private Date sBirth; - private String orgName; + private String deptName; + private String cardNo; + @JsonProperty("sPhone") private String sPhone; + @JsonProperty("sEmail") private String sEmail; private String idCardMasked; private String idCardLast4; @@ -27,6 +35,31 @@ public class StudentItemVO implements Serializable private Integer dutyLevel; + /** 人脸特征提取状态 */ + private String extractStatus; + /** 人脸特征提取时间 */ + private Date extractTime; + /** 人脸特征提取结果描述 */ + private String extractMsg; + + /** 人脸照片 MD5 */ + private String photoMd5; + /** 人脸特征数据 */ + private String featureData; + /** 人脸特征 MD5 */ + private String featureMd5; + /** 人脸特征算法版本 */ + private String featureAlgoVersion; + /** 人脸特征数据大小 */ + private Integer featureSize; + private Long id; + private Long deptId; + + /** 报到状态 (0待报到 1已报到 2请假 3放弃 4已完成) */ + private Integer enrollmentStatus; + /** 报到/入学时间 */ + private Date joinTime; + public String getsId() { return sId; } public void setsId(String sId) { this.sId = sId; } public String getsName() { return sName; } @@ -37,8 +70,10 @@ public class StudentItemVO implements Serializable public void setsGender(String sGender) { this.sGender = sGender; } public Date getsBirth() { return sBirth; } public void setsBirth(Date sBirth) { this.sBirth = sBirth; } - public String getOrgName() { return orgName; } - public void setOrgName(String orgName) { this.orgName = orgName; } + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } public String getsPhone() { return sPhone; } public void setsPhone(String sPhone) { this.sPhone = sPhone; } public String getsEmail() { return sEmail; } @@ -67,4 +102,100 @@ public class StudentItemVO implements Serializable public void setDutyLevel(Integer dutyLevel) { this.dutyLevel = dutyLevel; } + + public String getExtractStatus() { + return extractStatus; + } + + public void setExtractStatus(String extractStatus) { + this.extractStatus = extractStatus; + } + + public Date getExtractTime() { + return extractTime; + } + + public void setExtractTime(Date extractTime) { + this.extractTime = extractTime; + } + + public String getExtractMsg() { + return extractMsg; + } + + public void setExtractMsg(String extractMsg) { + this.extractMsg = extractMsg; + } + + public String getPhotoMd5() { + return photoMd5; + } + + public void setPhotoMd5(String photoMd5) { + this.photoMd5 = photoMd5; + } + + public String getFeatureData() { + return featureData; + } + + public void setFeatureData(String featureData) { + this.featureData = featureData; + } + + public String getFeatureMd5() { + return featureMd5; + } + + public void setFeatureMd5(String featureMd5) { + this.featureMd5 = featureMd5; + } + + public String getFeatureAlgoVersion() { + return featureAlgoVersion; + } + + public void setFeatureAlgoVersion(String featureAlgoVersion) { + this.featureAlgoVersion = featureAlgoVersion; + } + + public Integer getFeatureSize() { + return featureSize; + } + + public void setFeatureSize(Integer featureSize) { + this.featureSize = featureSize; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getDeptId() { + return deptId; + } + + public void setDeptId(Long deptId) { + this.deptId = deptId; + } + + public Integer getEnrollmentStatus() { + return enrollmentStatus; + } + + public void setEnrollmentStatus(Integer enrollmentStatus) { + this.enrollmentStatus = enrollmentStatus; + } + + public Date getJoinTime() { + return joinTime; + } + + public void setJoinTime(Date joinTime) { + this.joinTime = joinTime; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TeacherAccommodationVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TeacherAccommodationVO.java new file mode 100644 index 000000000..989f3002f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TeacherAccommodationVO.java @@ -0,0 +1,132 @@ +package com.ruoyi.train.domain.vo; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.train.domain.TrainAccommodation; + +/** + * 教师住宿安排视图对象 (train_accommodation + train_teacher + train_dormitory_info 联表展示) + *

+ * 说明:教师住宿记录复用 train_accommodation 表,occupant_type = 'teacher', + * enrollment_id 存放教师表主键 (train_teacher.id),与学生住宿完全隔离。 + * + * @author ruoyi + * @date 2026-11-04 + */ +public class TeacherAccommodationVO extends TrainAccommodation +{ + private static final long serialVersionUID = 1L; + + /** 教师工号 (train_teacher.t_id),人脸库删除时的 employee_id */ + private String tId; + + /** 教师姓名 */ + private String tName; + + /** 人员编号 (train_teacher.person_no,门禁人员标识) */ + private String personNo; + + /** 教师头像相对路径 (train_teacher.avatar,退宿不清空) */ + private String avatar; + + /** 教师性别 */ + private String tGender; + + /** 宿舍门锁设备序列号 */ + private String lockDeviceSn; + + /** 宿舍门锁设备ID */ + private String lockDeviceId; + + public String getTId() + { + return tId; + } + + public void setTId(String tId) + { + this.tId = tId; + } + + public String getTName() + { + return tName; + } + + public void setTName(String tName) + { + this.tName = tName; + } + + public String getPersonNo() + { + return personNo; + } + + public void setPersonNo(String personNo) + { + this.personNo = personNo; + } + + public String getAvatar() + { + return avatar; + } + + public void setAvatar(String avatar) + { + this.avatar = avatar; + } + + public String getTGender() + { + return tGender; + } + + public void setTGender(String tGender) + { + this.tGender = tGender; + } + + public String getLockDeviceSn() + { + return lockDeviceSn; + } + + public void setLockDeviceSn(String lockDeviceSn) + { + this.lockDeviceSn = lockDeviceSn; + } + + public String getLockDeviceId() + { + return lockDeviceId; + } + + public void setLockDeviceId(String lockDeviceId) + { + this.lockDeviceId = lockDeviceId; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("accommodationId", getAccommodationId()) + .append("enrollmentId", getEnrollmentId()) + .append("tId", getTId()) + .append("tName", getTName()) + .append("personNo", getPersonNo()) + .append("dormId", getDormId()) + .append("bedNo", getBedNo()) + .append("assignedTime", getAssignedTime()) + .append("accommodationStatus", getAccommodationStatus()) + .append("lockApiBizNo", getLockApiBizNo()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryExamScoreExportVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryExamScoreExportVO.java new file mode 100644 index 000000000..bcce98721 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryExamScoreExportVO.java @@ -0,0 +1,132 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.annotation.Excel; + +/** + * 理论考试成绩导出 VO + * + *

仅承载导出所需的可读字段(班级、课程、学号、姓名、成绩等), + * 不包含任何系统内部ID,避免导出文件出现无意义的编码。

+ */ +public class TheoryExamScoreExportVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 班级名称(train_class_plan.plan_name) */ + @Excel(name = "班级名称", sort = 1, width = 22) + private String planName; + + /** 考试课程名称(train_course.c_name) */ + @Excel(name = "考试课程", sort = 2, width = 22) + private String courseName; + + /** 学号(train_student.person_no) */ + @Excel(name = "学号", sort = 3, width = 16) + private String personNo; + + /** 学员姓名(train_student.s_name) */ + @Excel(name = "学员姓名", sort = 4, width = 14) + private String studentName; + + /** 考试状态:NORMAL/ABSENT/EXEMPT */ + @Excel(name = "考试状态", sort = 5, readConverterExp = "NORMAL=正常,ABSENT=缺考,EXEMPT=免修", width = 12) + private String examStatus; + + /** 考试成绩 */ + @Excel(name = "考试成绩", sort = 6, width = 12) + private BigDecimal score; + + /** 满分 */ + @Excel(name = "满分", sort = 7, width = 10) + private BigDecimal fullScore; + + /** 是否通过:1=通过 0=未通过 */ + @Excel(name = "是否通过", sort = 8, readConverterExp = "1=通过,0=未通过", width = 10) + private Integer passFlag; + + /** 是否补考:0=否 1=是 */ + @Excel(name = "是否补考", sort = 9, readConverterExp = "0=否,1=是", width = 10) + private Integer retake; + + /** 补考序号(正考为 0) */ + @Excel(name = "补考序号", sort = 10, width = 10) + private Integer retakeNo; + + /** 是否最终成绩:1=是 0=否 */ + @Excel(name = "是否最终成绩", sort = 11, readConverterExp = "1=是,0=否", width = 14) + private Integer isFinal; + + /** 考试开始时间 */ + @Excel(name = "考试开始时间", sort = 12, width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date examStartTime; + + /** 考试结束时间 */ + @Excel(name = "考试结束时间", sort = 13, width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date examEndTime; + + /** 成绩状态:RECEIVED/ARCHIVED/LOCKED */ + @Excel(name = "成绩状态", sort = 14, readConverterExp = "RECEIVED=已接收,ARCHIVED=已归档,LOCKED=已锁定", width = 12) + private String scoreStatus; + + /** 成绩来源:1=手工录入 2=平台在线考试 3=批量或外部导入 */ + @Excel(name = "成绩来源", sort = 15, readConverterExp = "1=手工录入,2=平台在线考试,3=批量或外部导入", width = 16) + private Integer sourceType; + + /** 备注 */ + @Excel(name = "备注", sort = 16, width = 28) + private String remark; + + public String getPlanName() { return planName; } + public void setPlanName(String planName) { this.planName = planName; } + + public String getCourseName() { return courseName; } + public void setCourseName(String courseName) { this.courseName = courseName; } + + public String getPersonNo() { return personNo; } + public void setPersonNo(String personNo) { this.personNo = personNo; } + + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + + public String getExamStatus() { return examStatus; } + public void setExamStatus(String examStatus) { this.examStatus = examStatus; } + + public BigDecimal getScore() { return score; } + public void setScore(BigDecimal score) { this.score = score; } + + public BigDecimal getFullScore() { return fullScore; } + public void setFullScore(BigDecimal fullScore) { this.fullScore = fullScore; } + + public Integer getPassFlag() { return passFlag; } + public void setPassFlag(Integer passFlag) { this.passFlag = passFlag; } + + public Integer getRetake() { return retake; } + public void setRetake(Integer retake) { this.retake = retake; } + + public Integer getRetakeNo() { return retakeNo; } + public void setRetakeNo(Integer retakeNo) { this.retakeNo = retakeNo; } + + public Integer getIsFinal() { return isFinal; } + public void setIsFinal(Integer isFinal) { this.isFinal = isFinal; } + + public Date getExamStartTime() { return examStartTime; } + public void setExamStartTime(Date examStartTime) { this.examStartTime = examStartTime; } + + public Date getExamEndTime() { return examEndTime; } + public void setExamEndTime(Date examEndTime) { this.examEndTime = examEndTime; } + + public String getScoreStatus() { return scoreStatus; } + public void setScoreStatus(String scoreStatus) { this.scoreStatus = scoreStatus; } + + public Integer getSourceType() { return sourceType; } + public void setSourceType(Integer sourceType) { this.sourceType = sourceType; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryScoreImportResultVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryScoreImportResultVO.java new file mode 100644 index 000000000..be5306f01 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryScoreImportResultVO.java @@ -0,0 +1,118 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 理论成绩导入结果 VO + * + *

与既有 BatchImportResultVO 保持相同的使用方式,额外补充跳过、覆盖计数与结构化错误明细, + * 不改动既有 VO。

+ */ +public class TheoryScoreImportResultVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 参与校验的数据行数(不含空行) */ + private int totalCount; + + /** 新增成功行数 */ + private int successCount; + + /** 失败行数 */ + private int failureCount; + + /** 跳过行数(重复且策略为跳过) */ + private int skippedCount; + + /** 覆盖更新行数 */ + private int updateCount; + + /** 错误明细 */ + private List errorList = new ArrayList<>(); + + /** 汇总提示 */ + private String message; + + /** + * 追加一条错误明细 + */ + public void addError(int rowNum, String studentName, String studentId, String column, String value, String message) + { + ErrorItem item = new ErrorItem(); + item.setRowNum(rowNum); + item.setStudentName(studentName); + item.setStudentId(studentId); + item.setColumn(column); + item.setValue(value); + item.setMessage(message); + this.errorList.add(item); + this.failureCount++; + } + + public int getTotalCount() { return totalCount; } + public void setTotalCount(int totalCount) { this.totalCount = totalCount; } + + public int getSuccessCount() { return successCount; } + public void setSuccessCount(int successCount) { this.successCount = successCount; } + + public int getFailureCount() { return failureCount; } + public void setFailureCount(int failureCount) { this.failureCount = failureCount; } + + public int getSkippedCount() { return skippedCount; } + public void setSkippedCount(int skippedCount) { this.skippedCount = skippedCount; } + + public int getUpdateCount() { return updateCount; } + public void setUpdateCount(int updateCount) { this.updateCount = updateCount; } + + public List getErrorList() { return errorList; } + public void setErrorList(List errorList) { this.errorList = errorList; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + /** + * 导入错误明细项 + */ + public static class ErrorItem implements Serializable + { + private static final long serialVersionUID = 1L; + + /** Excel 行号 */ + private int rowNum; + + /** 学员姓名 */ + private String studentName; + + /** 学员ID */ + private String studentId; + + /** 出错的列名 */ + private String column; + + /** 用户填写的原值 */ + private String value; + + /** 错误原因 */ + private String message; + + public int getRowNum() { return rowNum; } + public void setRowNum(int rowNum) { this.rowNum = rowNum; } + + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + + public String getStudentId() { return studentId; } + public void setStudentId(String studentId) { this.studentId = studentId; } + + public String getColumn() { return column; } + public void setColumn(String column) { this.column = column; } + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryScoreImportRowVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryScoreImportRowVO.java new file mode 100644 index 000000000..23d0e6b43 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TheoryScoreImportRowVO.java @@ -0,0 +1,68 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 理论成绩导入行(解析结果载体) + * + *

模板中仅保留“学号、学员姓名、成绩”等业务字段;学员ID、参训档案ID、满分、科目编码、 + * 统一考试开始/结束时间均不再由用户在数据行填写,分别由服务端的在册名单匹配结果与模板头部统一下发。

+ */ +public class TheoryScoreImportRowVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** Excel 实际行号(从 1 开始,用于错误定位) */ + private int rowNum; + + /** 学号(train_student.person_no,可空,空时按姓名匹配) */ + private String personNo; + + /** 学员姓名 */ + private String studentName; + + /** 考试状态原始值 */ + private String examStatus; + + /** 考试成绩原始值 */ + private String score; + + /** 是否通过原始值 */ + private String passFlag; + + /** 是否补考原始值 */ + private String retake; + + /** 补考序号原始值 */ + private String retakeNo; + + /** 备注 */ + private String remark; + + public int getRowNum() { return rowNum; } + public void setRowNum(int rowNum) { this.rowNum = rowNum; } + + public String getPersonNo() { return personNo; } + public void setPersonNo(String personNo) { this.personNo = personNo; } + + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + + public String getExamStatus() { return examStatus; } + public void setExamStatus(String examStatus) { this.examStatus = examStatus; } + + public String getScore() { return score; } + public void setScore(String score) { this.score = score; } + + public String getPassFlag() { return passFlag; } + public void setPassFlag(String passFlag) { this.passFlag = passFlag; } + + public String getRetake() { return retake; } + public void setRetake(String retake) { this.retake = retake; } + + public String getRetakeNo() { return retakeNo; } + public void setRetakeNo(String retakeNo) { this.retakeNo = retakeNo; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TrainAccommodationVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TrainAccommodationVO.java new file mode 100644 index 000000000..f7120bee9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TrainAccommodationVO.java @@ -0,0 +1,370 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.train.domain.TrainAccommodation; + +/** + * 住宿安排展示 VO (train_accommodation) + * + * @author ruoyi + * @date 2026-11-01 + */ +public class TrainAccommodationVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 住宿安排ID */ + private Long accommodationId; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 宿舍ID */ + private String dormId; + + /** 床位号 */ + private String bedNo; + + /** 分配时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date assignedTime; + + /** 住宿状态 (0: 预分配, 1: 已入住, 2: 已退宿) */ + private Integer accommodationStatus; + + /** 门锁开放接口业务流水号 */ + private String lockApiBizNo; + + /** 门锁授权结果 (1: 未授权, 2: 已授权, 3: 已过期) */ + private Integer lockResult; + + /** 学员学号 */ + private String studentId; + + /** 学员姓名 */ + private String studentName; + + /** 学员性别 */ + private String gender; + + /** 所属机构/选派单位 */ + private String deptName; + + /** 联系电话 */ + private String phone; + + /** 培训班计划ID */ + private String planId; + + /** 培训班计划名称 */ + private String planName; + + /** 宿舍楼栋 */ + private String dormBuilding; + + /** 宿舍房间号 */ + private String dormRoom; + + /** 创建人 */ + private String createBy; + + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + /** 更新人 */ + private String updateBy; + + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; + + /** 备注 */ + private String remark; + + /** + * 实体转 VO + * + * @param entity 住宿安排实体 + * @return VO 对象,入参为空时返回 null + */ + public static TrainAccommodationVO from(TrainAccommodation entity) + { + if (entity == null) + { + return null; + } + TrainAccommodationVO vo = new TrainAccommodationVO(); + vo.setAccommodationId(entity.getAccommodationId()); + vo.setEnrollmentId(entity.getEnrollmentId()); + vo.setDormId(entity.getDormId()); + vo.setBedNo(entity.getBedNo()); + vo.setAssignedTime(entity.getAssignedTime()); + vo.setAccommodationStatus(entity.getAccommodationStatus()); + vo.setLockApiBizNo(entity.getLockApiBizNo()); + vo.setLockResult(entity.getLockResult()); + vo.setStudentId(entity.getStudentId()); + vo.setStudentName(entity.getStudentName()); + vo.setGender(entity.getGender()); + vo.setDeptName(entity.getDeptName()); + vo.setPhone(entity.getPhone()); + vo.setPlanId(entity.getPlanId()); + vo.setPlanName(entity.getPlanName()); + vo.setDormBuilding(entity.getDormBuilding()); + vo.setDormRoom(entity.getDormRoom()); + vo.setCreateBy(entity.getCreateBy()); + vo.setCreateTime(entity.getCreateTime()); + vo.setUpdateBy(entity.getUpdateBy()); + vo.setUpdateTime(entity.getUpdateTime()); + vo.setRemark(entity.getRemark()); + return vo; + } + + /** + * 实体列表转 VO 列表 + * + * @param list 住宿安排实体列表 + * @return VO 列表,入参为空时返回空集合 + */ + public static List fromList(List list) + { + List result = new ArrayList<>(); + if (list == null || list.isEmpty()) + { + return result; + } + for (TrainAccommodation entity : list) + { + TrainAccommodationVO vo = from(entity); + if (vo != null) + { + result.add(vo); + } + } + return result; + } + + public Long getAccommodationId() + { + return accommodationId; + } + + public void setAccommodationId(Long accommodationId) + { + this.accommodationId = accommodationId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public Date getAssignedTime() + { + return assignedTime; + } + + public void setAssignedTime(Date assignedTime) + { + this.assignedTime = assignedTime; + } + + public Integer getAccommodationStatus() + { + return accommodationStatus; + } + + public void setAccommodationStatus(Integer accommodationStatus) + { + this.accommodationStatus = accommodationStatus; + } + + public String getLockApiBizNo() + { + return lockApiBizNo; + } + + public void setLockApiBizNo(String lockApiBizNo) + { + this.lockApiBizNo = lockApiBizNo; + } + + public Integer getLockResult() + { + return lockResult; + } + + public void setLockResult(Integer lockResult) + { + this.lockResult = lockResult; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getGender() + { + return gender; + } + + public void setGender(String gender) + { + this.gender = gender; + } + + public String getDeptName() + { + return deptName; + } + + public void setDeptName(String deptName) + { + this.deptName = deptName; + } + + public String getPhone() + { + return phone; + } + + public void setPhone(String phone) + { + this.phone = phone; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + public String getCreateBy() + { + return createBy; + } + + public void setCreateBy(String createBy) + { + this.createBy = createBy; + } + + public Date getCreateTime() + { + return createTime; + } + + public void setCreateTime(Date createTime) + { + this.createTime = createTime; + } + + public String getUpdateBy() + { + return updateBy; + } + + public void setUpdateBy(String updateBy) + { + this.updateBy = updateBy; + } + + public Date getUpdateTime() + { + return updateTime; + } + + public void setUpdateTime(Date updateTime) + { + this.updateTime = updateTime; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TrainTheoryExamScoreVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TrainTheoryExamScoreVO.java new file mode 100644 index 000000000..79660ec9e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TrainTheoryExamScoreVO.java @@ -0,0 +1,500 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 班级学员理论考试成绩视图对象。 + * + *

在表字段基础上追加学员姓名、培训班名称、课程名称、科目名称、成绩项名称等展示字段, + * 同时承载列表查询条件(含考试结束时间区间 beginTime/endTime)。

+ * + * @author ruoyi + * @date 2026-11-06 + */ +public class TrainTheoryExamScoreVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 主键 */ + private Long theoryScoreId; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 培训班ID */ + private String planId; + + /** 培训班名称(展示用) */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String planName; + + /** 培训课程实例ID */ + private Long planCourseId; + + /** 课程主档ID */ + private String cId; + + /** 课程名称(展示用) */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String courseName; + + /** 学员ID */ + private String studentId; + + /** 学员姓名(展示用) */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String studentName; + + /** 理论科目编码 */ + private String subjectCode; + + /** 科目名称(展示用) */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String subjectName; + + /** 考试开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date examStartTime; + + /** 考试结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date examEndTime; + + /** 用时秒数 */ + private Integer durationSeconds; + + /** 是否补考(1是 0否) */ + private Integer retake; + + /** 补考序号(0正考) */ + private Integer retakeNo; + + /** 考试状态(NORMAL/ABSENT/EXEMPT) */ + private String examStatus; + + /** 理论成绩 */ + private BigDecimal score; + + /** 满分 */ + private BigDecimal fullScore; + + /** 是否通过(1通过 0未通过) */ + private Integer passFlag; + + /** 是否最终成绩(1是 0否) */ + private Integer isFinal; + + /** 成绩状态(RECEIVED/ARCHIVED/LOCKED) */ + private String scoreStatus; + + /** 成绩来源(1手工录入 2平台在线考试 3批量或外部导入) */ + private Integer sourceType; + + /** 来源系统标识 */ + private String sourceSystem; + + /** 外部事件唯一ID */ + private String sourceEventId; + + /** 关联综合成绩项ID */ + private Long scoreItemId; + + /** 成绩项名称(展示用) */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String scoreItemName; + + /** 备注 */ + private String remark; + + /** 删除标志(0存在 2删除) */ + private String delFlag; + + /** 最终成绩唯一约束辅助列(数据库生成列,只读) */ + private Integer finalFlag; + + /** 创建者 */ + private String createBy; + + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + /** 更新者 */ + private String updateBy; + + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; + + /** 查询条件:考试结束时间起(yyyy-MM-dd HH:mm:ss,可只传 yyyy-MM-dd) */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String beginTime; + + /** 查询条件:考试结束时间止 */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String endTime; + + public Long getTheoryScoreId() + { + return theoryScoreId; + } + + public void setTheoryScoreId(Long theoryScoreId) + { + this.theoryScoreId = theoryScoreId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public String getCId() + { + return cId; + } + + public void setCId(String cId) + { + this.cId = cId; + } + + public String getCourseName() + { + return courseName; + } + + public void setCourseName(String courseName) + { + this.courseName = courseName; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getSubjectCode() + { + return subjectCode; + } + + public void setSubjectCode(String subjectCode) + { + this.subjectCode = subjectCode; + } + + public String getSubjectName() + { + return subjectName; + } + + public void setSubjectName(String subjectName) + { + this.subjectName = subjectName; + } + + public Date getExamStartTime() + { + return examStartTime; + } + + public void setExamStartTime(Date examStartTime) + { + this.examStartTime = examStartTime; + } + + public Date getExamEndTime() + { + return examEndTime; + } + + public void setExamEndTime(Date examEndTime) + { + this.examEndTime = examEndTime; + } + + public Integer getDurationSeconds() + { + return durationSeconds; + } + + public void setDurationSeconds(Integer durationSeconds) + { + this.durationSeconds = durationSeconds; + } + + public Integer getRetake() + { + return retake; + } + + public void setRetake(Integer retake) + { + this.retake = retake; + } + + public Integer getRetakeNo() + { + return retakeNo; + } + + public void setRetakeNo(Integer retakeNo) + { + this.retakeNo = retakeNo; + } + + public String getExamStatus() + { + return examStatus; + } + + public void setExamStatus(String examStatus) + { + this.examStatus = examStatus; + } + + public BigDecimal getScore() + { + return score; + } + + public void setScore(BigDecimal score) + { + this.score = score; + } + + public BigDecimal getFullScore() + { + return fullScore; + } + + public void setFullScore(BigDecimal fullScore) + { + this.fullScore = fullScore; + } + + public Integer getPassFlag() + { + return passFlag; + } + + public void setPassFlag(Integer passFlag) + { + this.passFlag = passFlag; + } + + public Integer getIsFinal() + { + return isFinal; + } + + public void setIsFinal(Integer isFinal) + { + this.isFinal = isFinal; + } + + public String getScoreStatus() + { + return scoreStatus; + } + + public void setScoreStatus(String scoreStatus) + { + this.scoreStatus = scoreStatus; + } + + public Integer getSourceType() + { + return sourceType; + } + + public void setSourceType(Integer sourceType) + { + this.sourceType = sourceType; + } + + public String getSourceSystem() + { + return sourceSystem; + } + + public void setSourceSystem(String sourceSystem) + { + this.sourceSystem = sourceSystem; + } + + public String getSourceEventId() + { + return sourceEventId; + } + + public void setSourceEventId(String sourceEventId) + { + this.sourceEventId = sourceEventId; + } + + public Long getScoreItemId() + { + return scoreItemId; + } + + public void setScoreItemId(Long scoreItemId) + { + this.scoreItemId = scoreItemId; + } + + public String getScoreItemName() + { + return scoreItemName; + } + + public void setScoreItemName(String scoreItemName) + { + this.scoreItemName = scoreItemName; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } + + public String getDelFlag() + { + return delFlag; + } + + public void setDelFlag(String delFlag) + { + this.delFlag = delFlag; + } + + public Integer getFinalFlag() + { + return finalFlag; + } + + public void setFinalFlag(Integer finalFlag) + { + this.finalFlag = finalFlag; + } + + public String getCreateBy() + { + return createBy; + } + + public void setCreateBy(String createBy) + { + this.createBy = createBy; + } + + public Date getCreateTime() + { + return createTime; + } + + public void setCreateTime(Date createTime) + { + this.createTime = createTime; + } + + public String getUpdateBy() + { + return updateBy; + } + + public void setUpdateBy(String updateBy) + { + this.updateBy = updateBy; + } + + public Date getUpdateTime() + { + return updateTime; + } + + public void setUpdateTime(Date updateTime) + { + this.updateTime = updateTime; + } + + public String getBeginTime() + { + return beginTime; + } + + public void setBeginTime(String beginTime) + { + this.beginTime = beginTime; + } + + public String getEndTime() + { + return endTime; + } + + public void setEndTime(String endTime) + { + this.endTime = endTime; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/client/TrainLockStayApiClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/client/TrainLockStayApiClient.java new file mode 100644 index 000000000..455886655 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/client/TrainLockStayApiClient.java @@ -0,0 +1,314 @@ +package com.ruoyi.train.integration.lock.client; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult; + +/** + * 门禁开放服务入住接口调用客户端。 + *

+ * ruoyi-train 无法反向依赖 ruoyi-admin 的 TrainLockApiController, + * 故在此按同一开放接口契约 (/openapi/iot/stay/checkin) 直接发起 HTTP 调用。 + * + * @author ruoyi + * @date 2026-11-01 + */ +@Component +public class TrainLockStayApiClient +{ + private static final Logger log = LoggerFactory.getLogger(TrainLockStayApiClient.class); + + /** 用于构造 JSON 请求体 */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 办理入住接口相对路径 */ + private static final String PATH_CHECKIN = "/openapi/iot/stay/checkin"; + + /** 办理退房接口相对路径 */ + private static final String PATH_CHECKOUT = "/openapi/iot/stay/checkout"; + + /** 线程安全的 HTTP 客户端,连接超时 3s(请求级超时见 checkin) */ + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + + /** 接口基础地址,例如 http://192.168.1.203:8080 */ + @Value("${iot.face.lock_endpoint:}") + private String lockEndpoint; + + /** 开放接口密钥,写入请求头 X-API-Key */ + @Value("${iot.face.api_key:}") + private String apiKey; + + /** 头像资源服务主机,用于把 /profile/... 相对路径补全为完整 URL */ + @Value("${iot.face.avatar_profile:}") + private String avatarProfile; + + /** + * 办理入住(POST /openapi/iot/stay/checkin)。 + * 调用失败仅记录日志,不向上抛出,避免影响住宿主业务。 + * + * @param bizNo 业务流水号 + * @param externalPersonId 外部人员ID + * @param personName 人员姓名 + * @param roomNo 房间号 + * @param beginTime 入住开始时间 + * @param endTime 入住结束时间 + * @param photoUrl 头像地址 + * @return 是否调用成功(HTTP 2xx) + */ + public boolean checkin(String bizNo, String externalPersonId, String personName, String roomNo, + Date beginTime, Date endTime, String photoUrl) + { + return checkinWithResult(bizNo, externalPersonId, personName, roomNo, beginTime, endTime, photoUrl).isSuccess(); + } + + /** + * 办理入住(POST /openapi/iot/stay/checkin),返回结构化调用结果。 + * 调用失败仅记录日志,不向上抛出,避免影响住宿主业务。 + * + * @param bizNo 业务流水号 + * @param externalPersonId 外部人员ID + * @param personName 人员姓名 + * @param roomNo 房间号 + * @param beginTime 入住开始时间 + * @param endTime 入住结束时间 + * @param photoUrl 头像地址 + * @return 门禁调用结果 + */ + public LockStayCheckinResult checkinWithResult(String bizNo, String externalPersonId, String personName, + String roomNo, Date beginTime, Date endTime, String photoUrl) + { + String base = lockEndpoint == null ? "" : lockEndpoint.trim(); + String key = apiKey == null ? "" : apiKey.trim(); + if (base.isEmpty() || key.isEmpty()) + { + log.warn("[办理入住] 未配置 iot.face.lock_endpoint 或 iot.face.api_key,跳过门禁开放接口调用"); + return LockStayCheckinResult.skipped(bizNo, "未配置门禁开放服务地址或密钥,已跳过入住授权"); + } + if (base.endsWith("/")) + { + base = base.substring(0, base.length() - 1); + } + String url = base + PATH_CHECKIN; + + Map body = new LinkedHashMap<>(); + body.put("bizNo", bizNo); + body.put("externalPersonId", externalPersonId); + body.put("personName", personName); + body.put("roomNo", roomNo); + body.put("beginTime", formatTime(beginTime)); + body.put("endTime", formatTime(endTime)); + body.put("photoUrl", buildFullPhotoUrl(photoUrl)); + + try + { + String payload = MAPPER.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .header("X-API-Key", key) + .header("Accept", "application/json") + .header("Content-Type", "application/json;charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = httpClient.send( + request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String responseBody = response.body(); + log.info("[办理入住] {} -> HTTP {}, response={}", url, response.statusCode(), responseBody); + boolean httpOk = response.statusCode() >= 200 && response.statusCode() < 300; + if (httpOk) + { + return LockStayCheckinResult.success(bizNo, response.statusCode(), null, + "入住授权请求已提交门禁开放服务", brief(responseBody)); + } + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_FAILED, response.statusCode(), + null, "门禁开放服务返回 HTTP " + response.statusCode(), brief(responseBody)); + } + catch (java.net.http.HttpTimeoutException timeout) + { + log.error("[办理入住] 调用门禁开放服务超时:{}", url, timeout); + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_TIMEOUT, null, + null, "调用门禁开放服务超时", null); + } + catch (InterruptedException interrupted) + { + Thread.currentThread().interrupt(); + log.error("[办理入住] 调用门禁开放服务被中断:{}", url, interrupted); + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_FAILED, null, + null, "调用门禁开放服务被中断", null); + } + catch (Exception ex) + { + log.error("[办理入住] 调用门禁开放服务异常:{}", url, ex); + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_FAILED, null, + null, "调用门禁开放服务异常:" + ex.getMessage(), null); + } + } + + /** + * 办理退房(POST /openapi/iot/stay/checkout),返回结构化调用结果。 + * 调用失败仅记录日志,不向上抛出,避免影响住宿主业务。 + * + * @param bizNo 业务流水号 + * @return 门禁调用结果 + */ + public LockStayCheckinResult checkoutWithResult(String bizNo) + { + String base = lockEndpoint == null ? "" : lockEndpoint.trim(); + String key = apiKey == null ? "" : apiKey.trim(); + if (base.isEmpty() || key.isEmpty()) + { + log.warn("[办理退房] 未配置 iot.face.lock_endpoint 或 iot.face.api_key,跳过门禁开放接口调用"); + return LockStayCheckinResult.skipped(bizNo, "未配置门禁开放服务地址或密钥,已跳过退房授权"); + } + if (base.endsWith("/")) + { + base = base.substring(0, base.length() - 1); + } + String url = base + PATH_CHECKOUT; + + Map body = new LinkedHashMap<>(); + body.put("bizNo", bizNo); + + try + { + String payload = MAPPER.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .header("X-API-Key", key) + .header("Accept", "application/json") + .header("Content-Type", "application/json;charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = httpClient.send( + request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String responseBody = response.body(); + log.info("[办理退房] {} -> HTTP {}, response={}", url, response.statusCode(), responseBody); + boolean httpOk = response.statusCode() >= 200 && response.statusCode() < 300; + if (httpOk) + { + return LockStayCheckinResult.success(bizNo, response.statusCode(), null, + "退房授权请求已提交门禁开放服务", brief(responseBody)); + } + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_FAILED, response.statusCode(), + null, "门禁开放服务返回 HTTP " + response.statusCode(), brief(responseBody)); + } + catch (java.net.http.HttpTimeoutException timeout) + { + log.error("[办理退房] 调用门禁开放服务超时:{}", url, timeout); + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_TIMEOUT, null, + null, "调用门禁开放服务超时", null); + } + catch (InterruptedException interrupted) + { + Thread.currentThread().interrupt(); + log.error("[办理退房] 调用门禁开放服务被中断:{}", url, interrupted); + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_FAILED, null, + null, "调用门禁开放服务被中断", null); + } + catch (Exception ex) + { + log.error("[办理退房] 调用门禁开放服务异常:{}", url, ex); + return LockStayCheckinResult.failed(bizNo, LockStayCheckinResult.STATUS_FAILED, null, + null, "调用门禁开放服务异常:" + ex.getMessage(), null); + } + } + + /** + * 截断上游响应,避免超长内容进入日志与响应体 + */ + private static String brief(String responseBody) + { + if (responseBody == null) + { + return null; + } + return responseBody.length() <= 500 ? responseBody : responseBody.substring(0, 500); + } + + /** + * 时间格式化 yyyy-MM-dd HH:mm:ss + */ + private static String formatTime(Date date) + { + return date == null ? null : DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, date); + } + + /** + * 补全头像完整访问地址。 + *

+ * 拼接规则(avatarProfile 取自配置 iot.face.avatar_profile): + *

    + *
  1. avatar 为 null:原样返回 null;
  2. + *
  3. avatar 为空白串:原样返回,不拼接;
  4. + *
  5. avatar 已带 http:// 或 https://:原样返回,避免重复拼接;
  6. + *
  7. avatar 以 // 开头(协议相对地址):返回 http: + avatar;
  8. + *
  9. avatarProfile 未配置:原样返回 avatar;
  10. + *
  11. avatarProfile 缺协议时补 http://,去掉末尾多余的 /;avatar 以 / 开头直接拼接, + * 否则补一个 / 再拼接,保证结果为可直接访问的完整 URL。
  12. + *
+ * + * @param avatar 学员头像原始值 + * @return 完整头像地址 + */ + private String buildFullPhotoUrl(String avatar) + { + if (avatar == null) + { + return null; + } + String value = avatar.trim(); + if (value.isEmpty()) + { + return avatar; + } + if (value.startsWith("//")) + { + return "http:" + value; + } + if (hasHttpProtocol(value)) + { + return value; + } + String base = avatarProfile == null ? "" : avatarProfile.trim(); + if (base.isEmpty()) + { + return value; + } + if (!hasHttpProtocol(base)) + { + base = "http://" + base; + } + while (base.endsWith("/")) + { + base = base.substring(0, base.length() - 1); + } + if (base.isEmpty()) + { + return value; + } + return value.startsWith("/") ? base + value : base + "/" + value; + } + + /** + * 判断是否已带 http:// 或 https:// 协议头(忽略大小写) + */ + private static boolean hasHttpProtocol(String url) + { + String lower = url.toLowerCase(); + return lower.startsWith("http://") || lower.startsWith("https://"); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/dto/LockStayCheckinResult.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/dto/LockStayCheckinResult.java new file mode 100644 index 000000000..160e00e87 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/dto/LockStayCheckinResult.java @@ -0,0 +1,160 @@ +package com.ruoyi.train.integration.lock.dto; + +import java.io.Serializable; + +/** + * 门禁开放接口办理入住 (/openapi/iot/stay/checkin) 调用结果。 + *

+ * 仅描述门禁侧调用结果,不影响住宿主业务成败判断。 + * + * @author ruoyi + * @date 2026-11-01 + */ +public class LockStayCheckinResult implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 调用成功 */ + public static final String STATUS_SUCCESS = "SUCCESS"; + + /** 条件不满足,跳过调用 */ + public static final String STATUS_SKIPPED = "SKIPPED"; + + /** 调用失败(业务错误、非 2xx、异常) */ + public static final String STATUS_FAILED = "FAILED"; + + /** 调用超时 */ + public static final String STATUS_TIMEOUT = "TIMEOUT"; + + /** 是否成功 */ + private boolean success; + + /** 结果状态:SUCCESS/SKIPPED/FAILED/TIMEOUT */ + private String status; + + /** 门锁业务流水号 */ + private String bizNo; + + /** 上游 HTTP 状态码 */ + private Integer httpStatus; + + /** 上游统一响应体中的业务 code */ + private String code; + + /** 结果说明 */ + private String message; + + /** 上游原始响应(截断保存,仅用于排障) */ + private String rawBody; + + public LockStayCheckinResult() + { + } + + public LockStayCheckinResult(boolean success, String status, String bizNo, Integer httpStatus, + String code, String message, String rawBody) + { + this.success = success; + this.status = status; + this.bizNo = bizNo; + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + this.rawBody = rawBody; + } + + /** + * 调用成功 + */ + public static LockStayCheckinResult success(String bizNo, Integer httpStatus, String code, String message, String rawBody) + { + return new LockStayCheckinResult(true, STATUS_SUCCESS, bizNo, httpStatus, code, message, rawBody); + } + + /** + * 条件不满足,跳过调用 + */ + public static LockStayCheckinResult skipped(String bizNo, String message) + { + return new LockStayCheckinResult(false, STATUS_SKIPPED, bizNo, null, null, message, null); + } + + /** + * 调用失败 + */ + public static LockStayCheckinResult failed(String bizNo, String status, Integer httpStatus, + String code, String message, String rawBody) + { + return new LockStayCheckinResult(false, status, bizNo, httpStatus, code, message, rawBody); + } + + public boolean isSuccess() + { + return success; + } + + public void setSuccess(boolean success) + { + this.success = success; + } + + public String getStatus() + { + return status; + } + + public void setStatus(String status) + { + this.status = status; + } + + public String getBizNo() + { + return bizNo; + } + + public void setBizNo(String bizNo) + { + this.bizNo = bizNo; + } + + public Integer getHttpStatus() + { + return httpStatus; + } + + public void setHttpStatus(Integer httpStatus) + { + this.httpStatus = httpStatus; + } + + public String getCode() + { + return code; + } + + public void setCode(String code) + { + this.code = code; + } + + public String getMessage() + { + return message; + } + + public void setMessage(String message) + { + this.message = message; + } + + public String getRawBody() + { + return rawBody; + } + + public void setRawBody(String rawBody) + { + this.rawBody = rawBody; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java index 9c7949368..6946df048 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java @@ -16,6 +16,7 @@ import com.ruoyi.iot.lock.service.ILockAuthService; import com.ruoyi.iot.lock.service.ILockCommandService; import com.ruoyi.iot.lock.service.ILockFaceService; import com.ruoyi.iot.lock.service.LockExecutionTransactions; +import com.ruoyi.train.constant.AccommodationErrorCode; import com.ruoyi.train.domain.TrainAccommodation; import com.ruoyi.train.domain.TrainClassPlan; import com.ruoyi.train.domain.TrainDormitoryInfo; @@ -165,7 +166,8 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe if (StringUtils.isEmpty(lockDeviceSn)) { // 铁律:不得凭房号猜测设备,未绑定真实门锁明确阻断 - throw new ServiceException("宿舍未绑定智能门锁设备(lock_device_sn),无法下发门锁授权 (宿舍: " + dorm.getDormRoom() + ")"); + throw new ServiceException(AccommodationErrorCode.LOCK_DEVICE_UNBOUND_MARK + + "(lock_device_sn),无法下发门锁授权 (宿舍: " + dorm.getDormRoom() + ")"); } // 3. 校验 IoT 门锁设备存在性与可用状态 @@ -406,6 +408,21 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe String oldSn = oldDorm != null ? oldDorm.getLockDeviceSn() : null; String newSn = newDorm != null ? newDorm.getLockDeviceSn() : null; + // 目标宿舍未绑定门锁:跳过整个门锁授权桥接(不撤旧、不建新),交由调用方处理。 + // 必须在此正常返回,而不是抛 ServiceException:本方法带 @Transactional, + // 异常一旦越过事务代理边界就会把外层住宿事务标记为 rollback-only, + // 外层 transfer 若再 catch 后继续提交,最终抛 UnexpectedRollbackException。 + // 「未绑定门锁」是业务允许跳过的分支,不应导致事务回滚。 + if (newDorm != null && StringUtils.isEmpty(newSn)) + { + log.warn("调房目标宿舍未绑定智能门锁,跳过门锁授权桥接: newAccommodationId={}, dormId={}", + newAccommodationId, newAcc.getDormId()); + return TransferBridgeResult.differentDevice( + LockAuthBridgeResult.noAction("目标宿舍未绑定门锁,跳过旧锁撤权"), + LockAuthBridgeResult.noAction("目标宿舍未绑定门锁,跳过新锁建权"), + "目标宿舍未绑定门锁,跳过门锁授权桥接"); + } + // 若调宿前后为同一智能门锁(同宿舍换床),无需物理变更门锁授权 if (oldSn != null && oldSn.equals(newSn)) { diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java index a189d7050..63daf316a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java @@ -37,6 +37,17 @@ public interface TrainAccommodationMapper */ public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId); + /** + * 根据学员唯一编码查询住宿记录,并按 create_time 降序排序后取第一条。 + *

+ * SQL 中以 ORDER BY a.create_time DESC + LIMIT 1 实现"排序后取首条", + * 该首条记录作为报到分支的判定依据(不按 accommodation_status 过滤)。 + * + * @param sId 学员唯一编码(sId) + * @return 按 create_time 降序排序的第一条住宿记录,无记录返回 null + */ + public TrainAccommodation selectActiveAccommodationByStudentId(@Param("sId") String sId); + /** * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按分配时间倒序) * @@ -45,6 +56,14 @@ public interface TrainAccommodationMapper */ public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId); + /** + * 根据门锁开放接口业务流水号查询住宿安排 + * + * @param lockApiBizNo 门锁业务流水号 + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByLockApiBizNo(@Param("lockApiBizNo") String lockApiBizNo); + /** * 查询住宿安排列表 * @@ -135,9 +154,37 @@ public interface TrainAccommodationMapper * @return 影响行数 */ public int updateAccommodationStatus(@Param("accommodationId") Long accommodationId, - @Param("accommodationStatus") Integer accommodationStatus, - @Param("updateBy") String updateBy, - @Param("updateTime") Date updateTime); + @Param("accommodationStatus") Integer accommodationStatus, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 更新门锁开放接口业务流水号 + * + * @param accommodationId 住宿安排主键 + * @param lockApiBizNo 门锁业务流水号 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int updateLockApiBizNo(@Param("accommodationId") Long accommodationId, + @Param("lockApiBizNo") String lockApiBizNo, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 更新门锁授权结果 + * + * @param accommodationId 住宿安排主键 + * @param lockResult 门锁授权结果 (1未授权 2已授权 3已过期) + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int updateLockResult(@Param("accommodationId") Long accommodationId, + @Param("lockResult") Integer lockResult, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); /** * 删除住宿安排 diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAnalysisMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAnalysisMapper.java index 2a62cb678..6d095d6d1 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAnalysisMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAnalysisMapper.java @@ -4,6 +4,9 @@ import java.util.List; import java.util.Map; import org.apache.ibatis.annotations.Param; import com.ruoyi.train.domain.analysis.AttendanceFilterDto; +import com.ruoyi.train.domain.analysis.StudentAttendanceDetailVo; +import com.ruoyi.train.domain.analysis.StudentExamScoreItemVo; +import com.ruoyi.train.domain.analysis.StudentScoreStatVo; /** * 数据分析与可视化专用聚合 Mapper (严格在数据库层完成 COUNT/SUM/GROUP BY) @@ -68,7 +71,39 @@ public interface TrainAnalysisMapper Long countStudentExpectedAttendance(@Param("studentId") String studentId); + /** + * 查询学员考勤明细(本人所有课程,按课次展开,含未签到课次) + * + * @param studentId 学号 + * @return 考勤明细集合 + */ + List selectStudentAttendanceDetails(@Param("studentId") String studentId); + Map selectStudentAccommodationAuth(@Param("studentId") String studentId); Long countStudentScores(@Param("studentId") String studentId); + + /** + * 统计学员理论考试成绩(train_theory_exam_score,最终成绩口径) + * + * @param studentId 学号 + * @return 理论成绩统计 + */ + StudentScoreStatVo selectStudentTheoryScoreStats(@Param("studentId") String studentId); + + /** + * 统计学员实训考试成绩(train_exam_report,已匹配学员口径) + * + * @param studentId 学号 + * @return 实训成绩统计 + */ + StudentScoreStatVo selectStudentPracticeScoreStats(@Param("studentId") String studentId); + + /** + * 查询学员考试成绩明细(理论 + 实训,按类型分组、考试时间倒序) + * + * @param studentId 学号 + * @return 成绩明细集合 + */ + List selectStudentExamScoreItems(@Param("studentId") String studentId); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java index 8c67e3078..e4036bc12 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java @@ -35,6 +35,7 @@ public interface TrainClassPlanMapper */ public List selectTrainClassPlanList(PlanQuery query); + public List selectClassPlanListByStudentId(String studentId); /** * 按规范化名称查询重复计划。 */ @@ -61,4 +62,14 @@ public interface TrainClassPlanMapper * 更新生命周期状态。 */ public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan); + + /** + * 逻辑删除开班计划 (del_flag = '2')。 + */ + public int logicalDeleteByPlanId(@Param("planId") String planId, @Param("operator") String operator); + + /** + * 开班报到启动:仅更新指定计划的班级信息与报到时间窗口(不触碰其它列)。 + */ + public int updateReportStartByPlanId(TrainClassPlan trainClassPlan); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java index cb4566534..98acee6df 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java @@ -47,4 +47,20 @@ public interface TrainClassReportMapper * 查询指定学员的最新开班报到记录 */ public TrainClassReport selectLatestReportByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId); + + /** + * 更新报到记录(仅更新非空字段) + * + * @param trainClassReport 报到记录 + * @return 更新行数 + */ + public int updateTrainClassReport(TrainClassReport trainClassReport); + + /** + * 删除报到记录 + * + * @param reportId 报到记录主键 + * @return 删除行数 + */ + public int deleteTrainClassReportByReportId(Long reportId); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassSeatMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassSeatMapper.java new file mode 100644 index 000000000..81eb4b5dd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassSeatMapper.java @@ -0,0 +1,86 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import com.ruoyi.train.domain.TrainClassSeat; +import com.ruoyi.train.domain.vo.ClassSeatItemVO; +import org.apache.ibatis.annotations.Param; + +/** + * 班级座位Mapper接口 + * + * @author ruoyi + * @date 2026-10-26 + */ +public interface TrainClassSeatMapper +{ + /** + * 查询班级座位列表 (关联学员姓名) + * + * @param trainClassSeat 查询条件 (planId/classroomId/siteId/canceled/studentId) + * @return 班级座位VO集合 + */ + public List selectTrainClassSeatList(TrainClassSeat trainClassSeat); + + /** + * 查询班级座位 + * + * @param seatId 座位ID主键 + * @return 班级座位VO + */ + public ClassSeatItemVO selectTrainClassSeatBySeatId(Long seatId); + + /** + * 查询同一 (planId, classroomId) 下指定行列的座位 + * + * @param planId 班级ID + * @param classroomId 教室ID + * @param rowIndex 行号 + * @param colIndex 列号 + * @return 班级座位 + */ + public TrainClassSeat selectSeatByPlanRoomRowCol(@Param("planId") String planId, + @Param("classroomId") String classroomId, @Param("rowIndex") Integer rowIndex, + @Param("colIndex") Integer colIndex); + + /** + * 新增班级座位 + * + * @param trainClassSeat 班级座位 + * @return 结果 + */ + public int insertTrainClassSeat(TrainClassSeat trainClassSeat); + + /** + * 修改班级座位 + * + * @param trainClassSeat 班级座位 + * @return 结果 + */ + public int updateTrainClassSeat(TrainClassSeat trainClassSeat); + + /** + * 删除班级座位 + * + * @param seatId 座位ID主键 + * @return 结果 + */ + public int deleteTrainClassSeatBySeatId(Long seatId); + + /** + * 批量删除班级座位 + * + * @param seatIds 需要删除的数据主键集合 + * @return 结果 + */ + public int deleteTrainClassSeatBySeatIds(Long[] seatIds); + + /** + * 删除指定 (planId, classroomId) 下全部座位 (布局整体替换用) + * + * @param planId 班级ID + * @param classroomId 教室ID + * @return 结果 + */ + public int deleteByPlanIdAndClassroomId(@Param("planId") String planId, + @Param("classroomId") String classroomId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassroomInfoMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassroomInfoMapper.java index 42ed2f23d..e981c5161 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassroomInfoMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassroomInfoMapper.java @@ -20,6 +20,14 @@ public interface TrainClassroomInfoMapper */ public TrainClassroomInfo selectTrainClassroomInfoByClassRoomId(String classRoomId); + /** + * 查询某开班计划(planId)课表中用到的教室信息(按教室去重) + * + * @param planId 开班计划ID + * @return 教室信息集合 + */ + public List selectTrainClassroomInfoByPlanId(String planId); + /** * 查询教室信息列表 * @@ -68,4 +76,10 @@ public interface TrainClassroomInfoMapper * @return 结果 */ public int deleteTrainClassroomInfoByClassRoomIds(String[] classRoomIds); + + /** + * 按教室主键定向更新绑定的电子班牌ID(bind_board_id,允许置空解绑)。 + */ + public int updateBindBoardIdByClassRoomId(@Param("classRoomId") String classRoomId, + @Param("bindBoardId") String bindBoardId, @Param("updateBy") String updateBy); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormitoryInfoMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormitoryInfoMapper.java index 1527143a7..d5fed3877 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormitoryInfoMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormitoryInfoMapper.java @@ -3,6 +3,7 @@ package com.ruoyi.train.mapper; import java.util.List; import org.apache.ibatis.annotations.Param; import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.vo.DormitoryLayoutRoomVO; import com.ruoyi.train.domain.vo.DormitoryOccupantVO; /** @@ -47,6 +48,15 @@ public interface TrainDormitoryInfoMapper */ public TrainDormitoryInfo checkBuildingRoomUnique(@Param("dormBuilding") String dormBuilding, @Param("dormRoom") String dormRoom, @Param("dormId") String dormId); + /** + * 按楼栋 + 房间号查询宿舍 (含智能门锁设备序列号 lock_device_sn) + * + * @param dormBuilding 楼栋名称 + * @param dormRoom 房间号 + * @return 匹配的宿舍实体 (若无返回 null) + */ + public TrainDormitoryInfo selectByBuildingRoom(@Param("dormBuilding") String dormBuilding, @Param("dormRoom") String dormRoom); + /** * 查询指定宿舍当前有效入住的学员列表 * @@ -62,6 +72,14 @@ public interface TrainDormitoryInfoMapper */ public List selectAllAvailableDormitories(); + /** + * 查询宿舍布局房间列表(含楼栋、楼层、房间、核定容量与已分配床位数), + * 按 楼栋 → 楼层 → 房间号 升序排列,供报到机宿舍布局接口组装嵌套结构使用。 + * + * @return 房间布局列表 + */ + public List selectDormitoryLayoutRoomList(); + /** * 新增宿舍信息 * @@ -93,4 +111,10 @@ public interface TrainDormitoryInfoMapper * @return 结果 */ public int deleteTrainDormitoryInfoByDormIds(String[] dormIds); + + /** + * 按宿舍主键定向更新智能门锁设备序列号(lock_device_sn,允许置空清除)。 + */ + public int updateLockDeviceSnByDormId(@Param("dormId") String dormId, + @Param("lockDeviceSn") String lockDeviceSn, @Param("updateBy") String updateBy); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamReportBatchMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamReportBatchMapper.java new file mode 100644 index 000000000..1a98f6d1c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamReportBatchMapper.java @@ -0,0 +1,39 @@ +package com.ruoyi.train.mapper; + +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainExamReportBatch; + +/** + * 考试成绩上报批次Mapper接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainExamReportBatchMapper +{ + /** + * 按应用+批次号查询批次 + * + * @param appId 应用标识 + * @param batchId 批次号 + * @return 批次 + */ + public TrainExamReportBatch selectTrainExamReportBatchByBatchId(@Param("appId") String appId, + @Param("batchId") String batchId); + + /** + * 新增批次 + * + * @param batch 批次 + * @return 结果 + */ + public int insertTrainExamReportBatch(TrainExamReportBatch batch); + + /** + * 更新批次汇总 + * + * @param batch 批次(按 batchPk 更新) + * @return 结果 + */ + public int updateTrainExamReportBatchSummary(TrainExamReportBatch batch); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamReportMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamReportMapper.java new file mode 100644 index 000000000..bd1371200 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamReportMapper.java @@ -0,0 +1,70 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import java.util.Map; +import com.ruoyi.train.domain.TrainExamReport; + +/** + * 考试成绩上报Mapper接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainExamReportMapper +{ + /** + * 按上报编号查询 + * + * @param reportId 上报编号 + * @return 上报记录 + */ + public TrainExamReport selectTrainExamReportByReportId(String reportId); + + /** + * 按主键查询 + * + * @param reportPk 主键 + * @return 上报记录 + */ + public TrainExamReport selectTrainExamReportByReportPk(Long reportPk); + + /** + * 查询上报记录列表 + * + * @param trainExamReport 查询条件(含 params.periodStart/periodEnd) + * @return 上报记录集合 + */ + public List selectTrainExamReportList(TrainExamReport trainExamReport); + + /** + * 统计结果汇总(total/unmatched) + * + * @param trainExamReport 查询条件 + * @return 汇总 + */ + public Map selectTrainExamReportSummary(TrainExamReport trainExamReport); + + /** + * 新增上报记录 + * + * @param trainExamReport 上报记录 + * @return 结果 + */ + public int insertTrainExamReport(TrainExamReport trainExamReport); + + /** + * 修改上报记录(按 reportPk 动态更新) + * + * @param trainExamReport 上报记录 + * @return 结果 + */ + public int updateTrainExamReport(TrainExamReport trainExamReport); + + /** + * 删除上报记录 + * + * @param reportPks 主键集合 + * @return 结果 + */ + public int deleteTrainExamReportByReportPks(Long[] reportPks); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamSubjectMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamSubjectMapper.java new file mode 100644 index 000000000..20c8bc12f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExamSubjectMapper.java @@ -0,0 +1,61 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import com.ruoyi.train.domain.TrainExamSubject; + +/** + * 考试科目字典Mapper接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainExamSubjectMapper +{ + /** + * 查询考试科目 + * + * @param subjectId 科目主键 + * @return 考试科目 + */ + public TrainExamSubject selectTrainExamSubjectBySubjectId(Long subjectId); + + /** + * 按科目编码查询考试科目 + * + * @param subjectCode 科目编码 + * @return 考试科目 + */ + public TrainExamSubject selectTrainExamSubjectBySubjectCode(String subjectCode); + + /** + * 查询考试科目列表 + * + * @param trainExamSubject 查询条件 + * @return 考试科目集合 + */ + public List selectTrainExamSubjectList(TrainExamSubject trainExamSubject); + + /** + * 新增考试科目 + * + * @param trainExamSubject 考试科目 + * @return 结果 + */ + public int insertTrainExamSubject(TrainExamSubject trainExamSubject); + + /** + * 修改考试科目 + * + * @param trainExamSubject 考试科目 + * @return 结果 + */ + public int updateTrainExamSubject(TrainExamSubject trainExamSubject); + + /** + * 批量删除考试科目 + * + * @param subjectIds 科目主键集合 + * @return 结果 + */ + public int deleteTrainExamSubjectBySubjectIds(Long[] subjectIds); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java index b2ac904f5..270ba4902 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java @@ -8,6 +8,7 @@ import com.ruoyi.train.domain.TrainLesson; import com.ruoyi.train.domain.TrainTeacher; import com.ruoyi.train.domain.TrainTrainingSite; import com.ruoyi.train.domain.vo.ConflictItemVO; +import com.ruoyi.train.domain.vo.CourseScheduleItemVO; import com.ruoyi.train.domain.vo.TimetableLessonItemVO; /** @@ -25,6 +26,23 @@ public interface TrainLessonMapper */ TrainLesson selectTrainLessonById(Long lessonId); + /** + * 按 培训班 + 课程名称 解析首个课次ID(考勤上传时关联 lesson)。 + * + * @param planId 培训班ID + * @param courseName 课程名称(train_course.c_name) + * @return 课次ID,未找到返回 null + */ + Long selectFirstLessonIdByPlanAndCourse(@Param("planId") String planId, @Param("courseName") String courseName); + + /** + * 查询指定开班计划下的全部课次(train_lesson),按开始时间升序。 + * + * @param planId 开班计划ID + * @return 课次列表(含 classRoomId / siteId) + */ + List selectTrainLessonListByPlanId(@Param("planId") String planId); + /** * 新增课次。 * @@ -129,8 +147,19 @@ public interface TrainLessonMapper * 获取指定学员所在有效班级的参训日程列表 (按开始时间升序排列)。 */ List selectScheduleLessonsByStudentId(@Param("studentId") String studentId, - @Param("startDate") Date startDate, - @Param("endDate") Date endDate); + @Param("startDate") Date startDate, + @Param("endDate") Date endDate); + + /** + * 按课次开始时间查询课程安排(报到机课表)。 + * 入参为 yyyy-MM-dd 日期串,日期边界由 STR_TO_DATE 在库内生成,规避 JDBC 时区转换导致的跨天偏移。 + * + * @param day 查询日期,格式 yyyy-MM-dd + * @param courseType 课程类型(对应 train_course.c_type,1:理论, 2:实训),null/空表示不筛选 + * @return 课程安排列表 + */ + List selectLessonScheduleByTime(@Param("day") String day, + @Param("courseType") String courseType); /** * 校验教师是否在指定培训计划中具备排课任教关系。 diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenApiLogMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenApiLogMapper.java new file mode 100644 index 000000000..df19260ca --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenApiLogMapper.java @@ -0,0 +1,20 @@ +package com.ruoyi.train.mapper; + +import com.ruoyi.train.domain.TrainOpenApiLog; + +/** + * 开放接口调用日志Mapper接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainOpenApiLogMapper +{ + /** + * 新增调用日志 + * + * @param log 日志 + * @return 结果 + */ + public int insertTrainOpenApiLog(TrainOpenApiLog log); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenAppMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenAppMapper.java new file mode 100644 index 000000000..c9895533f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenAppMapper.java @@ -0,0 +1,53 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import com.ruoyi.train.domain.TrainOpenApp; + +/** + * 外部应用Mapper接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainOpenAppMapper +{ + /** + * 查询外部应用 + * + * @param appId 应用标识 + * @return 外部应用 + */ + public TrainOpenApp selectTrainOpenAppByAppId(String appId); + + /** + * 查询外部应用列表 + * + * @param trainOpenApp 查询条件 + * @return 外部应用集合 + */ + public List selectTrainOpenAppList(TrainOpenApp trainOpenApp); + + /** + * 新增外部应用 + * + * @param trainOpenApp 外部应用 + * @return 结果 + */ + public int insertTrainOpenApp(TrainOpenApp trainOpenApp); + + /** + * 修改外部应用 + * + * @param trainOpenApp 外部应用 + * @return 结果 + */ + public int updateTrainOpenApp(TrainOpenApp trainOpenApp); + + /** + * 批量删除外部应用 + * + * @param appIds 应用标识集合 + * @return 结果 + */ + public int deleteTrainOpenAppByAppIds(String[] appIds); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenAppScopeMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenAppScopeMapper.java new file mode 100644 index 000000000..4d33886c3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenAppScopeMapper.java @@ -0,0 +1,49 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainOpenAppScope; + +/** + * 外部应用授权范围Mapper接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainOpenAppScopeMapper +{ + /** + * 查询某应用的授权范围 + * + * @param appId 应用标识 + * @return 授权范围集合 + */ + public List selectTrainOpenAppScopeByAppId(String appId); + + /** + * 统计指定授权范围是否存在 + * + * @param appId 应用标识 + * @param scopeType 范围类型 + * @param scopeValue 范围取值 + * @return 记录数 + */ + public int countTrainOpenAppScope(@Param("appId") String appId, @Param("scopeType") String scopeType, + @Param("scopeValue") String scopeValue); + + /** + * 批量新增授权范围 + * + * @param scopes 授权范围集合 + * @return 结果 + */ + public int insertTrainOpenAppScopeBatch(List scopes); + + /** + * 删除某应用的全部授权范围 + * + * @param appId 应用标识 + * @return 结果 + */ + public int deleteTrainOpenAppScopeByAppId(String appId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenStudentMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenStudentMapper.java new file mode 100644 index 000000000..1fc62175e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenStudentMapper.java @@ -0,0 +1,42 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.vo.OpenMatchStudentVO; +import com.ruoyi.train.domain.vo.OpenStudentVO; + +/** + * 开放接口-学员查询Mapper接口(只读,复用 train_student / train_enrollment / train_class_plan) + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainOpenStudentMapper +{ + /** + * 统计在训学员数 + * + * @param classId 培训班ID(可空) + * @return 总数 + */ + public int countInTrainingStudents(@Param("classId") String classId); + + /** + * 分页查询在训学员 + * + * @param classId 培训班ID(可空) + * @param offset 起始行 + * @param rows 每页行数 + * @return 学员集合 + */ + public List selectInTrainingStudents(@Param("classId") String classId, + @Param("offset") int offset, @Param("rows") int rows); + + /** + * 按学员ID查询可用于成绩匹配的在训记录(取最新报到记录) + * + * @param studentId 学员ID + * @return 匹配结果 + */ + public OpenMatchStudentVO selectMatchEnrollment(@Param("studentId") String studentId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenTokenMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenTokenMapper.java new file mode 100644 index 000000000..77c82b044 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainOpenTokenMapper.java @@ -0,0 +1,37 @@ +package com.ruoyi.train.mapper; + +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainOpenToken; + +/** + * 外部应用访问令牌Mapper接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface TrainOpenTokenMapper +{ + /** + * 新增令牌记录 + * + * @param token 令牌 + * @return 结果 + */ + public int insertTrainOpenToken(TrainOpenToken token); + + /** + * 失效某应用的全部有效令牌 + * + * @param appId 应用标识 + * @return 结果 + */ + public int revokeTrainOpenTokenByAppId(String appId); + + /** + * 按令牌散列查询有效令牌 + * + * @param tokenHash 令牌散列 + * @return 令牌 + */ + public TrainOpenToken selectValidTokenByHash(@Param("tokenHash") String tokenHash); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPortraitAnalyticsMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPortraitAnalyticsMapper.java new file mode 100644 index 000000000..71d81cb6d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPortraitAnalyticsMapper.java @@ -0,0 +1,87 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Param; + +/** + * 学员画像取数 Mapper(只读,独立于既有分析 Mapper)。 + * + *

全部语句仅做 SELECT 聚合,按 s_id / enrollment_id / plan_id 过滤, + * 不修改任何既有语句,也不依赖 sys_user 账号数据。

+ * + * @author ruoyi + */ +public interface TrainPortraitAnalyticsMapper +{ + /** + * 学员最近一期参训上下文(班期、课程规模、主档基础字段)。 + */ + Map selectEnrollmentContext(@Param("studentId") String studentId); + + /** + * 班期课次考勤明细行(课次时间、考勤状态、签到方式、人工标记)。 + */ + List> selectLessonAttendanceRows(@Param("planId") String planId, + @Param("enrollmentId") Long enrollmentId); + + /** + * 学员自主学习记录行(起止时间、时长、资源、同步时间)。 + */ + List> selectStudyRows(@Param("enrollmentId") Long enrollmentId); + + /** + * 理论成绩汇总(最终成绩口径)。 + */ + Map selectTheorySummary(@Param("studentId") String studentId); + + /** + * 实训成绩汇总(仅匹配成功的上报)。 + */ + Map selectPracticeSummary(@Param("studentId") String studentId); + + /** + * 综合成绩加权汇总(成绩项权重)。 + */ + Map selectWeightedScore(@Param("enrollmentId") Long enrollmentId); + + /** + * 课程规模汇总(课程数、计划学时、教师数、课次总数/已完成数、资源总数)。 + */ + Map selectCourseAggregate(@Param("planId") String planId); + + /** + * 已录入最终成绩的课程数。 + */ + Map selectExamCompletion(@Param("enrollmentId") Long enrollmentId); + + /** + * 门禁与住宿状态(人脸下发状态、住宿状态、同寝人数)。 + */ + Map selectDormContext(@Param("studentId") String studentId, @Param("planId") String planId); + + /** + * 理论成绩历史行(按课程取正考→补考,用于补考提升与薄弱课程)。 + */ + List> selectScoreHistory(@Param("studentId") String studentId); + + /** + * 同班出勤分布(用于班级对比分位)。 + */ + List> selectClassAttendance(@Param("planId") String planId); + + /** + * 同班理论成绩分布。 + */ + List> selectClassScores(@Param("planId") String planId); + + /** + * 同班学习时长分布。 + */ + List> selectClassStudy(@Param("planId") String planId); + + /** + * 跨班期趋势(最近若干期)。 + */ + List> selectPeriodRows(@Param("studentId") String studentId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPortraitSnapshotMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPortraitSnapshotMapper.java new file mode 100644 index 000000000..676d64093 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPortraitSnapshotMapper.java @@ -0,0 +1,29 @@ +package com.ruoyi.train.mapper; + +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainStudentPortrait; + +/** + * 画像快照读写 Mapper(画像功能专用,独立于既有画像查询 Mapper)。 + * + *

独立命名空间以避免改动既有 Mapper 接口,影响其他调用方与既有测试。

+ * + * @author ruoyi + */ +public interface TrainPortraitSnapshotMapper +{ + /** + * 按参训档案查询快照(单期唯一)。 + */ + TrainStudentPortrait selectPortraitByEnrollmentId(@Param("enrollmentId") Long enrollmentId); + + /** + * 新增快照。 + */ + int insertPortrait(TrainStudentPortrait portrait); + + /** + * 刷新快照。 + */ + int updatePortrait(TrainStudentPortrait portrait); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainRegDataMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainRegDataMapper.java new file mode 100644 index 000000000..d63a1372e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainRegDataMapper.java @@ -0,0 +1,74 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.vo.RegArrivalFactVO; +import com.ruoyi.train.domain.vo.RegBatchTrendVO; +import com.ruoyi.train.domain.vo.RegClassVO; +import com.ruoyi.train.domain.vo.RegRosterVO; +import com.ruoyi.train.domain.vo.RegUnitVO; + +/** + * 班级报到数据 Mapper接口 + * + *

报表类查询只做单表/联表取数,报到率、时间分布、到训耗时等聚合在 + * Service 层用 Java 计算,避免 MySQL / KingbaseES 方言差异。

+ * + * @author ruoyi + */ +public interface TrainRegDataMapper +{ + /** + * 查询存在有效报名记录的班级(报到任务班级标签)。 + * + * @return 班级列表 + */ + List selectReportClasses(); + + /** + * 统计指定计划的有效报名人数(enrollment_status != 3 放弃)。 + * + * @param planId 培训计划ID + * @return 应报到人数 + */ + int countExpectedEnrollments(@Param("planId") String planId); + + /** + * 查询报到名册。 + * + * @param planId 培训计划ID + * @param onlyNotReported true 仅未报到 + * @return 名册行 + */ + List selectRosterList(@Param("planId") String planId, + @Param("onlyNotReported") boolean onlyNotReported); + + /** + * 查询指定计划的报到时间事实行(仅核验通过的报到记录)。 + * + * @param planId 培训计划ID,为空表示全部计划 + * @return 事实行 + */ + List selectPlanArrivalFacts(@Param("planId") String planId); + + /** + * 查询全部计划的批次报到统计(含应到/已到人数)。 + * + * @return 批次统计 + */ + List selectBatchStats(); + + /** + * 查询各送培单位报到统计(单位取 train_student.dept_id)。 + * + * @return 单位统计 + */ + List selectUnitStats(); + + /** + * 查询按送培单位分组的报到时间事实行,用于计算单位平均到训时刻。 + * + * @return 事实行(unitId + reportTime) + */ + List selectUnitArrivalFacts(); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainScoreAnalysisMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainScoreAnalysisMapper.java new file mode 100644 index 000000000..fb0c708b2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainScoreAnalysisMapper.java @@ -0,0 +1,54 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.analysis.ScoreAnalysisFilterDto; + +/** + * 成绩统计分析专用聚合 Mapper(新增,不改动既有 Mapper 方法) + * + * 数据来源:理论考试成绩 train_theory_exam_score + 实训成绩上报 train_exam_report。 + * 所有统计均在数据库层完成 COUNT/AVG/MAX/MIN/GROUP BY,返回 snake_case 列别名,由 Service 装配。 + */ +public interface TrainScoreAnalysisMapper +{ + /** 成绩总览 KPI */ + Map selectScoreSummary(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 待认领(未匹配学员)的实训上报条数 */ + Long countUnmatchedReports(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 成绩趋势(按月) */ + List> selectScoreTrend(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 成绩类别构成(理论 / 实训按上报平台拆分) */ + List> selectScoreCategoryDistribution(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 成绩状态分布(已接收/已归档/已锁定) */ + List> selectScoreStatusDistribution(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 考试状态分布(正常/缺考/免修,仅理论成绩) */ + List> selectExamStatusDistribution(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 分数区间分布(按得分率) */ + List> selectScoreRangeDistribution(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 班级平均分/最高分/最低分分布(样本量 Top N) */ + List> selectPlanScoreDistribution(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 教师维度统计(基于理论成绩所属课程实例的授课课次关联) */ + List> selectTeacherScoreStats(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 通用维度统计(维度由 dto.dimension 控制,动态分组) */ + List> selectDimensionStats(@Param("dto") ScoreAnalysisFilterDto dto); + + /** 筛选项:培训班列表 */ + List> selectPlanOptions(); + + /** 筛选项:课程主档列表 */ + List> selectCourseOptions(); + + /** 筛选项:实训上报平台列表 */ + List> selectAppOptions(); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainSignageMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainSignageMapper.java new file mode 100644 index 000000000..8279f7b62 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainSignageMapper.java @@ -0,0 +1,57 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.vo.SignageClassroomVO; +import com.ruoyi.train.domain.vo.SignageCourseVO; +import com.ruoyi.train.domain.vo.SignageSeatLayoutVO; +import com.ruoyi.train.domain.vo.SignageSeatVO; +import com.ruoyi.train.domain.vo.SignageStudentCardVO; + +/** + * 电子班牌取数 Mapper(对应 /signageService/* 四个外部接口)。 + * 仅新增方法,不修改既有 Mapper。 + */ +public interface TrainSignageMapper +{ + /** + * 查询全部教学教室。 + */ + List selectSignageClassrooms(); + + /** + * 查询指定教室在日期范围内的排课课程。 + * + * @param classroomId 教室ID(class_room_id) + * @param beginDate 开始日期 yyyy-MM-dd + * @param endDate 结束日期 yyyy-MM-dd + */ + List selectSignageCourses(@Param("classroomId") String classroomId, + @Param("beginDate") String beginDate, + @Param("endDate") String endDate); + + /** + * 查询指定班级在指定教室的座位网格。 + * + * @param classroomId 教室ID(class_room_id) + * @param classId 班级ID(plan_id) + */ + List selectSignageSeats(@Param("classroomId") String classroomId, + @Param("classId") String classId); + + /** + * 查询指定班级的学员卡号与班级信息(刷卡校验用)。 + * + * @param classId 班级ID(plan_id) + */ + List selectSignageCards(@Param("classId") String classId); + + /** + * 查询班级在指定教室的座位布局(新增方法,仅读取 train_class_seat 单表)。 + * + * @param planId 班级ID(plan_id) + * @param classroomId 教室ID(classroom_id) + */ + List selectSeatLayout(@Param("planId") String planId, + @Param("classroomId") String classroomId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java index cfc74a38a..6c28bfc71 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java @@ -1,14 +1,20 @@ package com.ruoyi.train.mapper; import java.util.List; + +import com.ruoyi.iot.lock.domain.LockFace; import com.ruoyi.train.domain.TrainStudent; import com.ruoyi.train.domain.query.StudentQuery; +import org.springframework.context.annotation.Bean; /** 学员主档持久化 Mapper。 */ + public interface TrainStudentMapper { TrainStudent selectTrainStudentBySId(String sId); + TrainStudent selectTrainStudentById(Long sId); + List selectTrainStudentsBySIds(@org.apache.ibatis.annotations.Param("sIds") java.util.Collection sIds); List selectTrainStudentList(StudentQuery query); @@ -66,4 +72,30 @@ public interface TrainStudentMapper * @return 影响行数 */ int deleteTrainStudentBySId(@org.apache.ibatis.annotations.Param("sId") String sId); + + int updateExtractResult(TrainStudent student); + + /** + * 退宿联动:将学员头像清空为空字符串 (显式 SET avatar = '')。 + *

+ * 通用 updateTrainStudent 的动态 <if> 会跳过空串,故使用独立语句保证能真正置空。 + * + * @param sId 学员ID + * @return 影响行数 + */ + int clearAvatarBySId(@org.apache.ibatis.annotations.Param("sId") String sId); + + /** + * 将学员头像显式置为 NULL(新增方法,供报到重置使用;既有 clearAvatarBySId 置为空串,语义不同故独立)。 + * + * @param sId 学员ID(train_student.s_id) + * @return 影响行数 + */ + int clearAvatarToNullBySId(@org.apache.ibatis.annotations.Param("sId") String sId); + + /** 取当前最大且符合 AA+6位数字 的人员编号(用于自动生成) */ + String selectMaxPersonNo(); + + /** 统计指定人员编号是否已存在 */ + int countByPersonNo(@org.apache.ibatis.annotations.Param("personNo") String personNo); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherAccommodationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherAccommodationMapper.java new file mode 100644 index 000000000..7212feb1b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherAccommodationMapper.java @@ -0,0 +1,102 @@ +package com.ruoyi.train.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.vo.TeacherAccommodationVO; + +/** + * 教师住宿安排Mapper接口 (复用 train_accommodation,occupant_type = 'teacher') + *

+ * 与学生住宿 Mapper({@link TrainAccommodationMapper})完全隔离: + * 所有 SQL 均固定 occupant_type = 'teacher',不参与学生住宿的任何读写。 + * + * @author ruoyi + * @date 2026-11-04 + */ +public interface TrainTeacherAccommodationMapper +{ + /** + * 根据教师主键查询当前有效住宿安排 (预分配或已入住) + * + * @param teacherId 教师主键 (train_teacher.id) + * @return 教师住宿安排 + */ + public TeacherAccommodationVO selectActiveByTeacherId(@Param("teacherId") Long teacherId); + + /** + * 批量查询教师当前有效住宿安排 (列表页一次性回显,避免 N+1) + * + * @param teacherIds 教师主键集合 + * @return 教师住宿安排集合 + */ + public List selectActiveByTeacherIds(@Param("teacherIds") List teacherIds); + + /** + * 根据住宿安排主键查询教师住宿安排 + * + * @param accommodationId 住宿安排主键 + * @return 教师住宿安排 + */ + public TeacherAccommodationVO selectDetailByAccommodationId(@Param("accommodationId") Long accommodationId); + + /** + * 查询教师全部住宿历史记录 (含已退宿,按分配时间倒序) + * + * @param teacherId 教师主键 (train_teacher.id) + * @return 教师住宿历史 + */ + public List selectHistoryByTeacherId(@Param("teacherId") Long teacherId); + + /** + * 根据教师主键查询教师信息 (住宿分配用:姓名、工号、人员编号、头像) + * + * @param teacherId 教师主键 (train_teacher.id) + * @return 教师信息 + */ + public TrainTeacher selectTeacherForAccommodation(@Param("teacherId") Long teacherId); + + /** + * 统计指定宿舍当前有效住宿人数 (状态 0/1,含学员与教师,用于容量校验) + * + * @param dormId 宿舍ID + * @return 有效住宿人数 + */ + public int countActiveByDormId(@Param("dormId") String dormId); + + /** + * 统计指定宿舍与床位当前有效住宿记录数 (状态 0/1,含学员与教师) + * + * @param dormId 宿舍ID + * @param bedNo 床位号 + * @param excludeAccommodationId 排除的自身住宿ID + * @return 冲突占用数 + */ + public int countActiveByDormIdAndBedNo(@Param("dormId") String dormId, + @Param("bedNo") String bedNo, + @Param("excludeAccommodationId") Long excludeAccommodationId); + + /** + * 新增教师住宿安排 (occupant_type 固定写入 teacher) + * + * @param accommodation 住宿安排 (enrollmentId = 教师主键) + * @return 影响行数 + */ + public int insertTeacherAccommodation(TrainAccommodation accommodation); + + /** + * 更新教师住宿状态 + * + * @param accommodationId 住宿安排主键 + * @param accommodationStatus 目标状态 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int updateAccommodationStatus(@Param("accommodationId") Long accommodationId, + @Param("accommodationStatus") Integer accommodationStatus, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java index a911fdfee..d5d4dc88a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java @@ -43,6 +43,16 @@ public interface TrainTeacherMapper */ public int updateTrainTeacher(TrainTeacher trainTeacher); + /** + * 将教师头像显式置为 NULL(教师退宿时清空人脸照片使用)。 + *

updateTrainTeacher 中 avatar 字段为动态 SQL({@code }), + * 传 null 不会生成 set 语句,因此清空头像需单独定义该方法。

+ * + * @param tId 教师工号(train_teacher.t_id) + * @return 影响行数 + */ + public int clearAvatarToNullByTId(@org.apache.ibatis.annotations.Param("tId") String tId); + /** * 根据手机号码查询教师(用于全局查重) * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTheoryExamScoreMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTheoryExamScoreMapper.java new file mode 100644 index 000000000..368d6037d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTheoryExamScoreMapper.java @@ -0,0 +1,98 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainTheoryExamScore; +import com.ruoyi.train.domain.vo.TheoryExamScoreExportVO; +import com.ruoyi.train.domain.vo.TrainTheoryExamScoreVO; + +/** + * 班级学员理论考试成绩Mapper接口 + * + * @author ruoyi + * @date 2026-11-06 + */ +public interface TrainTheoryExamScoreMapper +{ + /** + * 查询理论考试成绩(单条,表字段) + * + * @param theoryScoreId 主键 + * @return 理论考试成绩 + */ + public TrainTheoryExamScore selectTrainTheoryExamScoreByTheoryScoreId(Long theoryScoreId); + + /** + * 查询理论考试成绩详情(含学员/班级/课程/科目名称,逻辑删除数据不返回) + * + * @param theoryScoreId 主键 + * @return 理论考试成绩(含展示名称) + */ + public TrainTheoryExamScoreVO selectTrainTheoryExamScoreVoByTheoryScoreId(Long theoryScoreId); + + /** + * 查询理论考试成绩列表(含学员/班级/课程/科目名称,逻辑删除数据不返回) + * + * @param query 查询条件 + * @return 理论考试成绩集合 + */ + public List selectTrainTheoryExamScoreList(TrainTheoryExamScoreVO query); + + /** + * 查询理论成绩导出数据(不分页,按班级 → 课程 → 学号排序) + * + * @param query 查询条件 + * @return 导出数据集合 + */ + public List selectTheoryExamScoreExportList(TrainTheoryExamScoreVO query); + + /** + * 统计同一“参训档案 + 课程 + 考试次数”的记录数(唯一性校验) + * + * @param enrollmentId 参训档案ID + * @param planCourseId 培训课程实例ID + * @param retakeNo 补考序号 + * @param excludeId 需排除的主键(修改时传自身主键,新增传 null) + * @return 记录数 + */ + public int countByUniqueKey(@Param("enrollmentId") Long enrollmentId, + @Param("planCourseId") Long planCourseId, + @Param("retakeNo") Integer retakeNo, + @Param("excludeId") Long excludeId); + + /** + * 清除同一学员同一课程下其他记录的“最终成绩”标记(保证仅一条最终成绩) + * + * @param planCourseId 培训课程实例ID + * @param studentId 学员ID + * @param excludeId 需保留的主键(新增传 null) + * @return 结果 + */ + public int clearOtherFinalFlag(@Param("planCourseId") Long planCourseId, + @Param("studentId") String studentId, + @Param("excludeId") Long excludeId); + + /** + * 新增理论考试成绩 + * + * @param trainTheoryExamScore 理论考试成绩 + * @return 结果 + */ + public int insertTrainTheoryExamScore(TrainTheoryExamScore trainTheoryExamScore); + + /** + * 修改理论考试成绩 + * + * @param trainTheoryExamScore 理论考试成绩 + * @return 结果 + */ + public int updateTrainTheoryExamScore(TrainTheoryExamScore trainTheoryExamScore); + + /** + * 批量逻辑删除理论考试成绩(del_flag 置 2) + * + * @param theoryScoreIds 主键集合 + * @return 结果 + */ + public int deleteTrainTheoryExamScoreByTheoryScoreIds(Long[] theoryScoreIds); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTrainingSiteMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTrainingSiteMapper.java index 53e6825e6..6383b9d0e 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTrainingSiteMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTrainingSiteMapper.java @@ -37,6 +37,14 @@ public interface TrainTrainingSiteMapper */ public TrainTrainingSite checkSiteNameUnique(@Param("siteName") String siteName, @Param("excludeSiteId") String excludeSiteId); + /** + * 查询某开班计划(planId)课表中用到的实训基地信息(按实训基地去重) + * + * @param planId 开班计划ID + * @return 实训基地信息集合 + */ + public List selectTrainTrainingSiteByPlanId(String planId); + /** * 新增实训场地 * @@ -68,4 +76,10 @@ public interface TrainTrainingSiteMapper * @return 结果 */ public int deleteTrainTrainingSiteBySiteIds(String[] siteIds); + + /** + * 按实训场地主键定向更新绑定的电子班牌ID(bind_board_id,允许置空解绑)。 + */ + public int updateBindBoardIdBySiteId(@Param("siteId") String siteId, + @Param("bindBoardId") String bindBoardId, @Param("updateBy") String updateBy); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java index cb9264a48..421467c7f 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java @@ -1,6 +1,7 @@ package com.ruoyi.train.service; import java.util.List; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.multipart.MultipartFile; import com.ruoyi.train.domain.dto.EnrollmentAddDTO; import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO; @@ -56,6 +57,16 @@ public interface EnrollmentAppService */ BatchImportResultVO batchImportEnrollments(String planId, MultipartFile file, Boolean autoCreateStudent, String operator); + /** + * 下载参训人员批量导入模板 (UC-05)。 + * + *

模板列顺序与 {@link #batchImportEnrollments} 的解析规则一致, + * 含逐列填写规则说明与一行可直接改写的示例数据,格式固定为 xlsx。

+ * + * @param response HTTP 响应 + */ + void exportImportTemplate(HttpServletResponse response); + /** * 变更参训状态 (UC-06 / P1 冻结范围)。 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IStudentPortraitService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IStudentPortraitService.java new file mode 100644 index 000000000..f1bf818c1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IStudentPortraitService.java @@ -0,0 +1,21 @@ +package com.ruoyi.train.service; + +import com.ruoyi.train.domain.analysis.StudentPortraitVo; + +/** + * 学员画像扩展能力服务(七类画像 + 分层/标签/活跃度/风险/对比/趋势)。 + * + *

实现仅读取现有事实表,并把结果回写既有画像表作为快照;不依赖 sys_user 账号。

+ * + * @author ruoyi + */ +public interface IStudentPortraitService +{ + /** + * 计算并返回指定学员的画像扩展读模型。 + * + * @param studentId 学员主档ID(train_student.s_id) + * @return 画像扩展数据;无参训记录时返回带告警的空模型 + */ + StudentPortraitVo getPortrait(String studentId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java index f5ebf57d2..b2403cc4d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java @@ -2,6 +2,12 @@ package com.ruoyi.train.service; import java.util.List; import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.dto.AccommodationSaveResult; +import com.ruoyi.train.domain.dto.TrainAccommodationDTO; +import com.ruoyi.train.domain.vo.TrainAccommodationVO; +import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult; /** * 住宿安排Service接口 train_accommodation @@ -35,6 +41,17 @@ public interface ITrainAccommodationService */ public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId); + /** + * 根据学员唯一编码查询其住宿记录,并按 create_time 降序排序后取第一条。 + *

+ * 报到判定依据即为该方法返回的第一条记录:不按 accommodation_status 过滤, + * 由 Mapper 的 ORDER BY create_time DESC + LIMIT 1 保证"排序后取首条"语义。 + * + * @param sId 学员唯一编码(sId) + * @return 按 create_time 降序排序的第一条住宿记录,无记录返回 null + */ + public TrainAccommodation selectActiveAccommodationByStudentId(String sId); + /** * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序) * @@ -43,6 +60,14 @@ public interface ITrainAccommodationService */ public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId); + /** + * 根据门锁开放接口业务流水号查询住宿安排 + * + * @param lockApiBizNo 门锁业务流水号 + * @return 住宿安排,无则返回 null + */ + public TrainAccommodation selectTrainAccommodationByLockApiBizNo(String lockApiBizNo); + /** * 查询住宿安排列表 * @@ -51,6 +76,22 @@ public interface ITrainAccommodationService */ public List selectTrainAccommodationList(TrainAccommodation accommodation); + /** + * 查询住宿安排 VO 列表 + * + * @param accommodation 住宿安排过滤条件 + * @return 住宿安排 VO 集合 + */ + public List selectTrainAccommodationVoList(TrainAccommodation accommodation); + + /** + * 查询住宿安排详情 VO + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 VO,无则返回 null + */ + public TrainAccommodationVO selectTrainAccommodationVoByAccommodationId(Long accommodationId); + /** * 查询指定开班计划下所有有效住宿安排 * @@ -67,6 +108,15 @@ public interface ITrainAccommodationService */ public int insertTrainAccommodation(TrainAccommodation accommodation); + /** + * 创建住宿事实 (DTO 入参,含字段长度校验) + * + * @param dto 住宿安排 DTO + * @param operator 操作人 + * @return 结果 + */ + public int insertTrainAccommodation(TrainAccommodationDTO dto, String operator); + /** * 修改住宿安排 * @@ -75,6 +125,55 @@ public interface ITrainAccommodationService */ public int updateTrainAccommodation(TrainAccommodation accommodation); + /** + * 更新门锁开放接口业务流水号 (长度不超过 64) + * + * @param accommodationId 住宿安排ID + * @param lockApiBizNo 门锁业务流水号 + * @param operator 操作人 + * @return 结果 + */ + public int updateLockApiBizNo(Long accommodationId, String lockApiBizNo, String operator); + + /** + * 更新门锁授权结果 + * + * @param accommodationId 住宿安排ID + * @param lockResult 门锁授权结果 (1 未授权, 2 已授权, 3 已过期) + * @param operator 操作人 + * @return 结果 + */ + public int updateLockResult(Long accommodationId, Integer lockResult, String operator); + + /** + * 生成 64 位随机门锁业务流水号 (对齐 lock_api_biz_no varchar(64)) + * + * @return 64 位随机字符串 + */ + public String generateLockApiBizNo(); + + /** + * 调用门禁开放接口办理入住 (/openapi/iot/stay/checkin) + * + * @param bizNo 门锁业务流水号 + * @param enrollment 参训档案 + * @param dorm 宿舍信息 + */ + public LockStayCheckinResult callStayCheckin(String bizNo, TrainEnrollment enrollment, TrainDormitoryInfo dorm); + + public LockStayCheckinResult callStayCheckout(String bizNo); + + /** + * 创建住宿事实并返回门禁办理入住结果。 + *

+ * 与 insertTrainAccommodation 行为一致(返回 int),但不改变原方法契约, + * 额外带回住宿ID、门锁业务流水号与门禁调用结果。 + * + * @param accommodation 住宿事实 + * @return 含门禁调用结果的新增结果 + */ + public AccommodationSaveResult insertTrainAccommodationWithLockResult(TrainAccommodation accommodation); + /** * 学员办理入住 (状态 0预分配 -> 1已入住) * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java index 77d4ec43e..84e7d67bb 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java @@ -31,6 +31,14 @@ public interface ITrainClassPlanService */ public List selectTrainClassPlanList(PlanQuery query); + /** + * 查询学员所在班级(培训计划)列表(通过 train_enrollment 关联)。 + * + * @param studentId 学员ID + * @return 开班计划集合(结构与 selectTrainClassPlanList 一致) + */ + public List selectClassPlanListByStudentId(String studentId); + public boolean existsByPlanName(String planName, String excludePlanId); /** @@ -50,4 +58,9 @@ public interface ITrainClassPlanService public int updateTrainClassPlan(TrainClassPlan trainClassPlan); public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan); + + /** + * 逻辑删除开班计划 (del_flag = '2')。 + */ + public int logicalDeleteByPlanId(String planId, String operator); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassReportService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassReportService.java index ebadddc4e..64dceed84 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassReportService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassReportService.java @@ -2,6 +2,7 @@ package com.ruoyi.train.service; import java.util.List; import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.dto.ReportStartDTO; /** * 开班报到Service接口 @@ -27,4 +28,22 @@ public interface ITrainClassReportService */ public List selectTrainClassReportList(TrainClassReport trainClassReport); + /** + * 查询指定学员(sId)的最新开班报到记录(含分配的宿舍/教室/实训场地)。 + * + * @param studentId 学员唯一编码(sId) + * @return 最新开班报到,无则返回 null + */ + public TrainClassReport selectLatestReportByStudentId(String studentId); + + /** + * 开班报到启动:更新开班计划的班级信息与报到时间窗口, + * 并按映射批量绑定教室/实训场地的电子班牌、宿舍门锁设备。 + * + * @param dto 启动请求(planId/班级/报到时间/班牌映射/门锁映射) + * @param username 操作人 + * @return 开班计划更新影响行数 + */ + public int reportStart(ReportStartDTO dto, String username); + } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassSeatService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassSeatService.java new file mode 100644 index 000000000..36a23d4de --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassSeatService.java @@ -0,0 +1,73 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainClassSeat; +import com.ruoyi.train.domain.dto.ClassSeatSaveDTO; +import com.ruoyi.train.domain.vo.ClassSeatItemVO; + +/** + * 班级座位Service接口 + * + * @author ruoyi + * @date 2026-10-26 + */ +public interface ITrainClassSeatService +{ + /** + * 查询班级座位列表 (关联学员姓名) + * + * @param trainClassSeat 查询条件 + * @return 班级座位VO集合 + */ + public List selectTrainClassSeatList(TrainClassSeat trainClassSeat); + + /** + * 查询班级座位详情 + * + * @param seatId 座位ID主键 + * @return 班级座位VO + */ + public ClassSeatItemVO selectTrainClassSeatBySeatId(Long seatId); + + /** + * 新增班级座位 + * + * @param trainClassSeat 班级座位 + * @return 结果 + */ + public int insertTrainClassSeat(TrainClassSeat trainClassSeat); + + /** + * 修改班级座位 + * + * @param trainClassSeat 班级座位 + * @return 结果 + */ + public int updateTrainClassSeat(TrainClassSeat trainClassSeat); + + /** + * 批量删除班级座位 + * + * @param seatIds 需要删除的座位ID集合 + * @return 结果 + */ + public int deleteTrainClassSeatBySeatIds(Long[] seatIds); + + /** + * 删除班级座位 + * + * @param seatId 座位ID主键 + * @return 结果 + */ + public int deleteTrainClassSeatBySeatId(Long seatId); + + /** + * 整体保存座位布局:删除 (planId, classroomId) 下原有座位后批量写入本次座位。 + * 校验:planId/classroomId 必填;每个座位行列号必填且从1开始;(行,列) 不得重复。 + * + * @param dto 布局保存请求 + * @param operator 操作人 + * @return 写入座位数 + */ + public int saveClassSeatLayout(ClassSeatSaveDTO dto, String operator); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassroomInfoService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassroomInfoService.java index aae3548c5..5fd3204f8 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassroomInfoService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassroomInfoService.java @@ -28,6 +28,14 @@ public interface ITrainClassroomInfoService */ public TrainClassroomInfo selectTrainClassroomInfoByClassRoomId(String classRoomId); + /** + * 查询某开班计划(planId)在课表(train_lesson)中用到的教室信息(按教室去重) + * + * @param planId 开班计划ID + * @return 教室信息集合 + */ + public List selectClassroomByPlanId(String planId); + /** * 查询教室信息列表 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormitoryInfoService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormitoryInfoService.java index 4ec9b744e..e775cdd9e 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormitoryInfoService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormitoryInfoService.java @@ -1,6 +1,7 @@ package com.ruoyi.train.service; import java.util.List; +import java.util.Map; import com.ruoyi.train.domain.TrainDormitoryInfo; import com.ruoyi.train.domain.dto.DormitoryCreateDTO; import com.ruoyi.train.domain.vo.DormOptionsVO; @@ -95,4 +96,13 @@ public interface ITrainDormitoryInfoService * @return 级联树集合 */ public List selectDormitoryTreeOptions(); + + /** + * 查询宿舍布局(楼栋 → 楼层 → 房间号)。 + *

每间房返回自增编号 id、房间号 dorm_room,以及“已分配床位数/总床位数” accommodation; + * 已分配床位数统计 train_accommodation 中住宿状态为 0(预分配)、1(已入住) 的记录。

+ * + * @return 有序嵌套结构:{楼栋名称: {楼层: {房间号: {id, dorm_room, accommodation}}}} + */ + public Map selectDormitoryLayout(); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java index f16fbd964..cb9fbf6b8 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java @@ -25,6 +25,9 @@ public interface ITrainEnrollmentService List selectEnrollmentItemList(EnrollmentQuery query); + /** 按学员 sId(studentId) 查询其全部报到记录 */ + List selectEnrollmentsByStudentId(String studentId); + int countEnrollmentsByPlanId(String planId); int insertTrainEnrollment(TrainEnrollment trainEnrollment); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainExamReportService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainExamReportService.java new file mode 100644 index 000000000..0d99cb09e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainExamReportService.java @@ -0,0 +1,94 @@ +package com.ruoyi.train.service; + +import java.util.List; +import java.util.Map; +import com.ruoyi.train.domain.TrainExamReport; +import com.ruoyi.train.domain.dto.OpenExamReportDTO; + +/** + * 外部实训成绩上报Service接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface ITrainExamReportService +{ + /** + * 分页查询在训学员(3.2) + * + * @param classId 培训班ID(可空) + * @param pageNo 页码 + * @param pageSize 每页条数 + * @return total / pageNo / pageSize / list + */ + public Map selectInTrainingStudents(String classId, int pageNo, int pageSize); + + /** + * 批量成绩上报(3.3) + * + * @param appId 应用标识 + * @param dto 上报报文 + * @param reqId 请求标识 + * @return 批次汇总 + */ + public Map reportBatch(String appId, OpenExamReportDTO dto, String reqId); + + /** + * 实时成绩上报(3.4) + * + * @param appId 应用标识 + * @param dto 上报报文 + * @param reqId 请求标识 + * @return 汇总 + */ + public Map reportRealtime(String appId, OpenExamReportDTO dto, String reqId); + + /** + * 上报结果查询(3.5) + * + * @param appId 应用标识 + * @param batchId 批次号(可空) + * @param reportId 上报编号(可空) + * @param periodStart 时间窗开始(可空) + * @param periodEnd 时间窗结束(可空) + * @param pageNo 页码 + * @param pageSize 每页条数 + * @return 批次头 + 逐条结果 + */ + public Map queryResult(String appId, String batchId, String reportId, Long periodStart, + Long periodEnd, int pageNo, int pageSize); + + /** + * 查询上报记录列表(后台) + * + * @param query 查询条件 + * @return 上报记录集合 + */ + public List selectTrainExamReportList(TrainExamReport query); + + /** + * 查询上报记录详情(后台) + * + * @param reportPk 主键 + * @return 上报记录 + */ + public TrainExamReport selectTrainExamReportByReportPk(Long reportPk); + + /** + * 认领待匹配成绩(后台) + * + * @param reportPk 上报记录主键 + * @param studentId 平台学员ID + * @param operator 操作人 + * @return 结果 + */ + public int claimReport(Long reportPk, String studentId, String operator); + + /** + * 批量删除上报记录(后台) + * + * @param reportPks 主键集合 + * @return 结果 + */ + public int deleteTrainExamReportByReportPks(Long[] reportPks); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainExamSubjectService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainExamSubjectService.java new file mode 100644 index 000000000..feba1f6e6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainExamSubjectService.java @@ -0,0 +1,61 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainExamSubject; + +/** + * 考试科目字典Service接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface ITrainExamSubjectService +{ + /** + * 查询考试科目列表 + * + * @param trainExamSubject 查询条件 + * @return 考试科目集合 + */ + public List selectTrainExamSubjectList(TrainExamSubject trainExamSubject); + + /** + * 查询考试科目 + * + * @param subjectId 科目主键 + * @return 考试科目 + */ + public TrainExamSubject selectTrainExamSubjectBySubjectId(Long subjectId); + + /** + * 按科目编码查询考试科目 + * + * @param subjectCode 科目编码 + * @return 考试科目 + */ + public TrainExamSubject selectTrainExamSubjectBySubjectCode(String subjectCode); + + /** + * 新增考试科目 + * + * @param trainExamSubject 考试科目 + * @return 结果 + */ + public int insertTrainExamSubject(TrainExamSubject trainExamSubject); + + /** + * 修改考试科目 + * + * @param trainExamSubject 考试科目 + * @return 结果 + */ + public int updateTrainExamSubject(TrainExamSubject trainExamSubject); + + /** + * 批量删除考试科目 + * + * @param subjectIds 科目主键集合 + * @return 结果 + */ + public int deleteTrainExamSubjectBySubjectIds(Long[] subjectIds); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainLessonService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainLessonService.java new file mode 100644 index 000000000..cb4dfc234 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainLessonService.java @@ -0,0 +1,30 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.vo.CourseScheduleItemVO; + +/** + * 课次教学活动Service接口(新增,供 TrainRegisterApiController 按开班计划取课程表)。 + * + * @author ruoyi + */ +public interface ITrainLessonService +{ + /** + * 查询指定开班计划下的全部课次(train_lesson),按开始时间升序。 + * + * @param planId 开班计划ID + * @return 课次列表(含 classRoomId / siteId) + */ + public List selectTrainLessonListByPlanId(String planId); + + /** + * 按课次开始时间查询指定日期的课程安排(以 train_lesson 为主表,关联计划课程、课程、教师、教室、实训场地、培训班)。 + * + * @param day 查询日期,格式 yyyy-MM-dd + * @param courseType 课程类型(对应 train_course.c_type,1:理论, 2:实训),null/空表示不筛选 + * @return 课程安排列表(按开始时间升序) + */ + public List selectLessonScheduleByTime(String day, String courseType); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainOpenAppService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainOpenAppService.java new file mode 100644 index 000000000..48321ee78 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainOpenAppService.java @@ -0,0 +1,99 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainOpenApp; +import com.ruoyi.train.domain.TrainOpenAppScope; + +/** + * 外部实训系统应用Service接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface ITrainOpenAppService +{ + /** + * 查询外部应用列表 + * + * @param trainOpenApp 查询条件 + * @return 外部应用集合 + */ + public List selectTrainOpenAppList(TrainOpenApp trainOpenApp); + + /** + * 查询外部应用 + * + * @param appId 应用标识 + * @return 外部应用 + */ + public TrainOpenApp selectTrainOpenAppByAppId(String appId); + + /** + * 新增外部应用(生成密钥并按 BCrypt 存储) + * + * @param trainOpenApp 外部应用(appSecret 为空时自动生成) + * @return 结果 + */ + public int insertTrainOpenApp(TrainOpenApp trainOpenApp); + + /** + * 修改外部应用 + * + * @param trainOpenApp 外部应用 + * @return 结果 + */ + public int updateTrainOpenApp(TrainOpenApp trainOpenApp); + + /** + * 批量删除外部应用 + * + * @param appIds 应用标识集合 + * @return 结果 + */ + public int deleteTrainOpenAppByAppIds(String[] appIds); + + /** + * 重置应用密钥 + * + * @param appId 应用标识 + * @return 新密钥明文(仅返回一次) + */ + public String resetAppSecret(String appId); + + /** + * 校验应用密钥 + * + * @param app 外部应用 + * @param rawSecret 明文密钥 + * @return 是否匹配 + */ + public boolean matchesSecret(TrainOpenApp app, String rawSecret); + + /** + * 查询应用授权范围 + * + * @param appId 应用标识 + * @return 授权范围集合 + */ + public List selectScopeList(String appId); + + /** + * 全量保存应用授权范围 + * + * @param appId 应用标识 + * @param scopes 授权范围集合 + * @param operator 操作人 + * @return 结果 + */ + public int saveScopes(String appId, List scopes, String operator); + + /** + * 校验应用是否具备指定范围 + * + * @param appId 应用标识 + * @param scopeType 范围类型(CLASS/SUBJECT) + * @param scopeValue 范围取值 + * @return 是否授权 + */ + public boolean hasScope(String appId, String scopeType, String scopeValue); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainOpenTokenService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainOpenTokenService.java new file mode 100644 index 000000000..7ed4c7f7d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainOpenTokenService.java @@ -0,0 +1,38 @@ +package com.ruoyi.train.service; + +import java.util.Map; +import com.ruoyi.train.domain.TrainOpenApp; + +/** + * 外部应用访问令牌Service接口 + * + * @author ruoyi + * @date 2026-11-05 + */ +public interface ITrainOpenTokenService +{ + /** + * 签发访问令牌(同应用旧令牌立即失效) + * + * @param app 外部应用 + * @param clientIp 客户端 IP + * @param userAgent 客户端标识 + * @return accessToken / tokenType / expiresIn + */ + public Map issueToken(TrainOpenApp app, String clientIp, String userAgent); + + /** + * 校验访问令牌 + * + * @param accessToken 令牌明文 + * @return 应用标识;无效返回 null + */ + public String validateToken(String accessToken); + + /** + * 失效应用的全部令牌 + * + * @param appId 应用标识 + */ + public void revokeTokens(String appId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainRegDataService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainRegDataService.java new file mode 100644 index 000000000..a2ffbc0b7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainRegDataService.java @@ -0,0 +1,125 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.dto.RegReportSaveDTO; +import com.ruoyi.train.domain.vo.RegBatchTrendVO; +import com.ruoyi.train.domain.vo.RegClassVO; +import com.ruoyi.train.domain.vo.RegHistorySummaryVO; +import com.ruoyi.train.domain.vo.RegRosterVO; +import com.ruoyi.train.domain.vo.RegSummaryVO; +import com.ruoyi.train.domain.vo.RegUnitVO; + +/** + * 班级报到数据 Service接口 + * + * @author ruoyi + */ +public interface ITrainRegDataService +{ + /** + * 查询存在报到任务的班级标签。 + * + * @return 班级列表 + */ + List selectReportClasses(); + + /** + * 校验并返回开班计划,不存在时抛出 404。 + * + * @param planId 培训计划ID + * @return 开班计划 + */ + TrainClassPlan selectReportPlan(String planId); + + /** + * 查询班级报到统计汇总。 + * + * @param planId 培训计划ID + * @return 汇总数据 + */ + RegSummaryVO selectReportSummary(String planId); + + /** + * 查询报到名册。 + * + * @param planId 培训计划ID + * @return 名册行 + */ + List selectRoster(String planId); + + /** + * 查询未报到学员。 + * + * @param planId 培训计划ID + * @return 名册行 + */ + List selectNotReported(String planId); + + /** + * 催促未报到学员,返回本次催促人数。 + * + * @param planId 培训计划ID + * @param studentIds 指定学员,为空表示全班 + * @param operator 操作人 + * @return 催促人数 + */ + int urgeReport(String planId, List studentIds, String operator); + + /** + * 查询历史报到统计汇总。 + * + * @return 汇总数据 + */ + RegHistorySummaryVO selectHistorySummary(); + + /** + * 查询各批次报到率趋势(近 8 个批次,按时间正序)。 + * + * @return 批次趋势 + */ + List selectBatchTrend(); + + /** + * 查询各送培单位报到情况(按平均报到率降序)。 + * + * @return 单位统计 + */ + List selectUnitReport(); + + /** + * 查询单条报到记录,不存在时抛出 404。 + * + * @param reportId 报到记录主键 + * @return 报到记录 + */ + TrainClassReport selectReport(Long reportId); + + /** + * 新增报到记录。 + * + * @param dto 报到信息 + * @param operator 操作人 + * @return 报到记录主键 + */ + Long createReport(RegReportSaveDTO dto, String operator); + + /** + * 更新报到记录。 + * + * @param reportId 报到记录主键 + * @param dto 报到信息 + * @param operator 操作人 + * @return 更新行数 + */ + int updateReport(Long reportId, RegReportSaveDTO dto, String operator); + + /** + * 删除报到记录。 + * + * @param reportId 报到记录主键 + * @return 删除行数 + */ + int deleteReport(Long reportId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainScoreAnalysisService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainScoreAnalysisService.java new file mode 100644 index 000000000..cd629d386 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainScoreAnalysisService.java @@ -0,0 +1,22 @@ +package com.ruoyi.train.service; + +import java.util.Map; +import com.ruoyi.train.domain.analysis.ScoreAnalysisFilterDto; + +/** + * 成绩统计分析服务接口(新增,不改动既有分析服务方法) + */ +public interface ITrainScoreAnalysisService +{ + /** 成绩统计总览 KPI */ + Map getScoreSummary(ScoreAnalysisFilterDto filter); + + /** 成绩统计图表集合(趋势/类别/状态/区间/班级分布/教师) */ + Map getScoreCharts(ScoreAnalysisFilterDto filter); + + /** 指定维度的分组统计明细 */ + Map getDimensionAnalysis(ScoreAnalysisFilterDto filter); + + /** 筛选下拉选项(培训班/课程/上报平台) */ + Map getFilterOptions(); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentCheckinService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentCheckinService.java index 2ad3fc02c..9b9ea2a07 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentCheckinService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentCheckinService.java @@ -27,4 +27,14 @@ public interface ITrainStudentCheckinService */ public List selectTrainStudentCheckinList(TrainStudentCheckin trainStudentCheckin); + /** + * 查询指定课次的签到记录(新增,供电子班牌按课次取签到流水)。 + * 结果中 studentId 取 train_student_checkin.student_id, + * studentName 由 train_student.s_name 关联匹配。 + * + * @param lessonId 课次ID + * @return 签到记录集合 + */ + public List selectCheckinsByLessonId(Long lessonId); + } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java index e602582c4..0c85606c4 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java @@ -5,6 +5,7 @@ import com.ruoyi.train.domain.dto.StudentCreateDTO; import com.ruoyi.train.domain.dto.StudentUpdateDTO; import com.ruoyi.train.domain.query.StudentQuery; import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentExportVO; import com.ruoyi.train.domain.vo.StudentItemVO; /** ISSUE-01-02 学员主档应用服务边界。 */ @@ -12,10 +13,28 @@ public interface ITrainStudentService { List selectStudentList(StudentQuery query); + /** + * 查询学员台账导出数据,列定义与顺序由 {@link StudentExportVO} 的 @Excel 声明。 + * + * @param query 与列表页一致的筛选条件(含可选 planId 按班过滤) + * @return 导出行集合 + */ + List selectStudentExportList(StudentQuery query); + StudentDetailVO selectStudentDetail(String sId); String createStudent(StudentCreateDTO dto); + /** + * 创建学员主档(可指定来源类型与操作人),供批量导入等场景复用单条建档的全部校验与加密逻辑。 + * + * @param dto 学员建档信息 + * @param sourceType 来源类型 (1:手工建档 2:Excel批量导入) + * @param operator 操作人 + * @return 学员ID + */ + String createStudent(StudentCreateDTO dto, Integer sourceType, String operator); + /** * 修改学员主档 * @@ -31,4 +50,30 @@ public interface ITrainStudentService * @return 影响行数 */ int deleteTrainStudentBySId(String sId); + + /** + * 触发特征提取 + * + * @param faceId 人脸ID + * @param deviceId 目标门锁ID(用于取该锁维护的 algorithm_version),为空时按该人员的授权自动推断 + * @return 提取后的特征(base64),失败返回 null + */ + String extract(Long faceId, Long deviceId); + String extractStudent(String sId, Long deviceId); + + /** + * 批量重提取 + * + * @param faceIds 人脸ID数组 + * @param deviceId 目标门锁ID,为空时按各自授权自动推断 + * @return 成功条数 + */ + int batchExtract(Long[] faceIds, Long deviceId); + + /** + * 生成全局唯一的人员编号(AA + 6 位数字,按现有最大序号 +1,自动避开已占用值)。 + * + * @return 形如 AA000001 的人员编号 + */ + String genPersonNo(); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTeacherAccommodationService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTeacherAccommodationService.java new file mode 100644 index 000000000..362594bab --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTeacherAccommodationService.java @@ -0,0 +1,77 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.dto.TeacherAccommodationSaveResult; +import com.ruoyi.train.domain.vo.TeacherAccommodationVO; + +/** + * 教师住宿安排Service接口 + *

+ * 教师住宿与学生住宿共用 train_accommodation 表,但以 occupant_type = 'teacher' 隔离; + * 分配、调房、退宿(门锁入住授权 / 退宿撤权 / 人脸删除)均为教师专属实现, + * 不复用学生住宿的 Service 方法。 + * + * @author ruoyi + * @date 2026-11-04 + */ +public interface ITrainTeacherAccommodationService +{ + /** + * 批量查询教师当前有效住宿安排 (列表页回显) + * + * @param teacherIds 教师主键集合 + * @return 教师住宿安排集合 + */ + public List listActiveByTeacherIds(List teacherIds); + + /** + * 查询教师当前有效住宿安排 + * + * @param teacherId 教师主键 (train_teacher.id) + * @return 教师住宿安排,无则返回 null + */ + public TeacherAccommodationVO getActiveByTeacherId(Long teacherId); + + /** + * 查询教师住宿历史记录 (含已退宿) + * + * @param teacherId 教师主键 (train_teacher.id) + * @return 教师住宿历史 + */ + public List listHistoryByTeacherId(Long teacherId); + + /** + * 教师住宿分配 / 调整宿舍 + *

+ * 已有有效住宿且房间或床位发生变化时,先结束原住宿并撤销门锁入住授权,再创建新住宿并下发新的入住授权。 + * 不校验宿舍性别限制(教师住宿跳过性别校验),仅校验宿舍容量与床位占用。 + *

+ * 开锁授权前置校验:仅当 train_teacher.avatar 非空且对应人脸照片确实存在时才下发门锁入住授权, + * 否则拒绝授权(住宿分配照常成功),拒绝原因通过返回结果透出。 + * + * @param teacherId 教师主键 (train_teacher.id) + * @param dormId 宿舍ID + * @param bedNo 床位号 + * @param operator 操作人 + * @return 住宿分配结果(含开锁授权状态与原因) + */ + public TeacherAccommodationSaveResult assign(Long teacherId, String dormId, String bedNo, String operator); + + /** + * 教师退宿 (状态 -> 2 已退宿) + *

+ * 执行门锁退宿撤权与人脸库员工删除,但不清空 train_teacher.avatar。 + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 影响行数 + */ + public int checkOut(Long accommodationId, String operator); + + /** + * 生成门锁开放接口业务流水号 (64 位) + * + * @return 业务流水号 + */ + public String generateLockApiBizNo(); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTheoryExamScoreService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTheoryExamScoreService.java new file mode 100644 index 000000000..8fc33a914 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTheoryExamScoreService.java @@ -0,0 +1,72 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainTheoryExamScore; +import com.ruoyi.train.domain.dto.TrainTheoryExamScoreDTO; +import com.ruoyi.train.domain.vo.TheoryExamScoreExportVO; +import com.ruoyi.train.domain.vo.TrainTheoryExamScoreVO; + +/** + * 班级学员理论考试成绩Service接口 + * + * @author ruoyi + * @date 2026-11-06 + */ +public interface ITrainTheoryExamScoreService +{ + /** + * 查询理论考试成绩列表(分页由 Controller 的 startPage 完成) + * + * @param query 查询条件 + * @return 理论考试成绩集合 + */ + public List selectTrainTheoryExamScoreList(TrainTheoryExamScoreVO query); + + /** + * 查询理论成绩导出数据(不分页,按班级 → 课程 → 学号排序) + * + * @param query 查询条件 + * @return 导出数据集合 + */ + public List selectTheoryExamScoreExportList(TrainTheoryExamScoreVO query); + + /** + * 查询理论考试成绩详情(含学员/班级/课程名称,供页面只读展示) + * + * @param theoryScoreId 主键 + * @return 理论考试成绩详情(含展示名称) + */ + public TrainTheoryExamScoreVO selectTrainTheoryExamScoreDetail(Long theoryScoreId); + + /** + * 查询理论考试成绩原始记录(用于编辑校验) + * + * @param theoryScoreId 主键 + * @return 理论考试成绩 + */ + public TrainTheoryExamScore selectTrainTheoryExamScoreByTheoryScoreId(Long theoryScoreId); + + /** + * 新增理论考试成绩 + * + * @param dto 成绩信息 + * @return 结果 + */ + public int insertTrainTheoryExamScore(TrainTheoryExamScoreDTO dto); + + /** + * 修改理论考试成绩 + * + * @param dto 成绩信息 + * @return 结果 + */ + public int updateTrainTheoryExamScore(TrainTheoryExamScoreDTO dto); + + /** + * 批量删除理论考试成绩(逻辑删除) + * + * @param theoryScoreIds 主键集合 + * @return 结果 + */ + public int deleteTrainTheoryExamScoreByTheoryScoreIds(Long[] theoryScoreIds); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTrainingSiteService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTrainingSiteService.java index b9d2822ef..a1a06ed7c 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTrainingSiteService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTrainingSiteService.java @@ -19,6 +19,14 @@ public interface ITrainTrainingSiteService */ public TrainTrainingSite selectTrainTrainingSiteBySiteId(String siteId); + /** + * 查询某开班计划(planId)在课表(train_lesson)中用到的实训基地信息(按实训基地去重) + * + * @param planId 开班计划ID + * @return 实训基地信息集合 + */ + public List selectTrainTrainingSiteByPlanId(String planId); + /** * 查询实训场地列表 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java index 437f6f798..013a4557b 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java @@ -22,6 +22,11 @@ public interface PlanAppService void completePlan(String planId, String operator); + /** + * 删除培训计划(仅“未开始”状态允许,逻辑删除)。 + */ + void deletePlan(String planId, String operator); + /** * 查询本期已配置课程列表。 */ diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/StudentImportService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/StudentImportService.java new file mode 100644 index 000000000..1536fd6e5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/StudentImportService.java @@ -0,0 +1,33 @@ +package com.ruoyi.train.service; + +import java.util.List; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.train.domain.vo.BatchImportResultVO; +import com.ruoyi.train.domain.vo.StudentImportVO; + +/** + * 学员主档批量导入服务。 + * + *

复用 {@link ITrainStudentService#createStudent} 的单条建档校验、人员编号生成与身份证加密, + * 仅补充"文件类型 / 模板格式 / 逐行数据"三层的校验与失败明细收集。

+ * + * @author ruoyi + */ +public interface StudentImportService +{ + /** + * 解析并导入学员 Excel。 + * + * @param file 上传文件 (.xls/.xlsx) + * @param operator 操作人 + * @return 导入结果(成功数量与逐行失败明细) + */ + BatchImportResultVO importStudents(MultipartFile file, String operator); + + /** + * 生成导入模板示例行(表头由 {@link StudentImportVO} 的 @Excel 定义)。 + * + * @return 模板行 + */ + List templateRows(); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/TheoryScoreImportService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/TheoryScoreImportService.java new file mode 100644 index 000000000..751e1957b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/TheoryScoreImportService.java @@ -0,0 +1,40 @@ +package com.ruoyi.train.service; + +import java.io.IOException; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.train.domain.vo.TheoryScoreImportResultVO; + +/** + * 理论考试成绩批量导入服务(新增) + * + *

只做“模板生成 / 文件解析 / 行级匹配与校验 / 结果聚合”, + * 单行入库直接复用既有 {@link ITrainTheoryExamScoreService#insertTrainTheoryExamScore} 与 + * {@code updateTrainTheoryExamScore},不修改任何既有方法。

+ */ +public interface TheoryScoreImportService +{ + /** + * 生成并下载按“班级 + 课程实例”写入全班在册学员名单的导入模板 + * + * @param response 响应 + * @param planId 培训班ID + * @param planCourseId 培训课程实例ID + * @param strictStatus 是否仅导出已报到/已完成学员 + * @param fileFormat 已废弃:模板固定输出 xlsx,无需用户选择 + * @throws IOException IO异常 + */ + void exportTemplate(HttpServletResponse response, String planId, Long planCourseId, + boolean strictStatus, String fileFormat) throws IOException; + + /** + * 解析并导入理论成绩文件 + * + * @param file Excel 文件 + * @param planId 培训班ID(以服务端参数为准) + * @param planCourseId 培训课程实例ID(以服务端参数为准) + * @param importMode 冲突策略:SKIP 跳过 / OVERWRITE 覆盖 / STRICT 严格 + * @return 导入结果 + */ + TheoryScoreImportResultVO importData(MultipartFile file, String planId, Long planCourseId, String importMode); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java index ba88860f3..3fd200a44 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java @@ -91,9 +91,13 @@ public class AttendanceAppServiceImpl implements AttendanceAppService // 核心生命周期守卫:即将产生新的 signIn 事实,严格校验 enrollment lifecycle eligibility assertEnrollmentEligibleForSignIn(enrollment); + // student_id 以参训档案为准落库,请求显式传入时做一致性校验 + String studentId = resolveStudentId(dto.getStudentId(), enrollment); + if (existing != null) { Date now = dto.getSignInTime() != null ? dto.getSignInTime() : new Date(); + existing.setStudentId(studentId); existing.setSignInTime(now); existing.setCheckinWay(dto.getCheckinWay() != null ? dto.getCheckinWay() : 1); if (dto.getBoardId() != null) @@ -110,6 +114,7 @@ public class AttendanceAppServiceImpl implements AttendanceAppService TrainStudentCheckin checkin = new TrainStudentCheckin(); checkin.setEnrollmentId(dto.getEnrollmentId()); checkin.setLessonId(dto.getLessonId()); + checkin.setStudentId(studentId); checkin.setBoardId(dto.getBoardId()); checkin.setCheckinWay(dto.getCheckinWay() != null ? dto.getCheckinWay() : 1); // 1:人脸, 2:刷卡, 3:扫码, 4:教师代签 checkin.setSignInTime(now); @@ -285,6 +290,9 @@ public class AttendanceAppServiceImpl implements AttendanceAppService // 参训生命周期守卫:仅允许对状态恰好为 1 (已报到) 的在训学员登记缺勤 assertEnrollmentEligibleForMarkAbsence(enrollment); + // student_id 以参训档案为准落库,请求显式传入时做一致性校验 + String studentId = resolveStudentId(dto.getStudentId(), enrollment); + // 唯一性守卫:已有任何考勤记录时禁止再建第二条,引导用户改走调整考勤 TrainStudentCheckin existing = trainStudentCheckinMapper.selectTrainStudentCheckinByEnrollmentAndLesson(dto.getEnrollmentId(), dto.getLessonId()); if (existing != null) @@ -297,6 +305,7 @@ public class AttendanceAppServiceImpl implements AttendanceAppService TrainStudentCheckin checkin = new TrainStudentCheckin(); checkin.setEnrollmentId(dto.getEnrollmentId()); checkin.setLessonId(dto.getLessonId()); + checkin.setStudentId(studentId); checkin.setBoardId(null); // DDL 约束 train_student_checkin.checkin_way NOT NULL,无法留空; // 缺勤登记是管理端人工录入渠道,因此复用既有合法值 4 (教师/管理代签) 表示人工录入来源。 @@ -383,7 +392,7 @@ public class AttendanceAppServiceImpl implements AttendanceAppService vo.setStudentId(item.getStudentId()); vo.setStudentName(item.getStudentName()); vo.setGender(item.getGender()); - vo.setDeptName(item.getOrgName()); + vo.setDeptName(item.getDeptName()); vo.setPhoneMasked(maskPhone(item.getPhone())); vo.setEnrollmentStatus(item.getEnrollmentStatus()); @@ -478,6 +487,36 @@ public class AttendanceAppServiceImpl implements AttendanceAppService return status == 1 || status == 2; } + /** + * 解析并校验考勤行归属学员。 + *

+ * train_student_checkin.student_id 是冗余自参训档案的列,唯一可信来源是 + * train_enrollment.student_id;请求显式传入 studentId 时仅用于一致性校验, + * 不一致则拒绝,避免把 A 学员的考勤写到 B 学员名下。 + * + * @param requestedStudentId 请求携带的学员ID (可空) + * @param enrollment 参训档案 + * @return 归一化后的学员ID + */ + private String resolveStudentId(String requestedStudentId, TrainEnrollment enrollment) + { + String enrolledStudentId = enrollment == null ? null : enrollment.getStudentId(); + String normalized = enrolledStudentId == null ? null : enrolledStudentId.trim(); + boolean enrolledBlank = normalized == null || normalized.isEmpty(); + if (requestedStudentId != null && !requestedStudentId.trim().isEmpty()) + { + String requested = requestedStudentId.trim(); + if (!enrolledBlank && !normalized.equals(requested)) + { + throw new ServiceException(AttendanceErrorCode.ATTENDANCE_STUDENT_MISMATCH_MESSAGE, + AttendanceErrorCode.ATTENDANCE_STUDENT_MISMATCH); + } + // 档案未带学员ID时以请求值为准,保证历史数据与设备上报场景仍可落库 + return enrolledBlank ? requested : normalized; + } + return enrolledBlank ? null : normalized; + } + /** * 判定课次是否已经开始 (开始时间 <= 当前时间)。 * 开始时间缺失或无法解析时 fail closed 返回 false,视为未开始。 diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java index b66700c45..baad95091 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java @@ -4,6 +4,7 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.Date; import java.util.List; +import jakarta.servlet.http.HttpServletResponse; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.Row; @@ -28,6 +29,7 @@ import com.ruoyi.train.domain.TrainClassPlan; import com.ruoyi.train.domain.TrainClassReport; import com.ruoyi.train.domain.TrainEnrollment; import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainDormitoryInfo; import com.ruoyi.train.domain.dto.EnrollmentAddDTO; import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO; import com.ruoyi.train.domain.dto.ReportConfirmDTO; @@ -39,6 +41,7 @@ import com.ruoyi.train.domain.vo.ReportResultVO; import com.ruoyi.train.mapper.TrainClassPlanMapper; import com.ruoyi.train.mapper.TrainClassReportMapper; import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; import com.ruoyi.train.mapper.TrainStudentMapper; import com.ruoyi.train.security.StudentIdentityProtection; import com.ruoyi.train.service.EnrollmentAppService; @@ -78,6 +81,9 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService @Autowired private TrainStudentMapper studentMapper; + @Autowired + private TrainDormitoryInfoMapper dormitoryInfoMapper; + @Autowired private TrainClassReportMapper trainClassReportMapper; @@ -101,6 +107,16 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService for (EnrollmentItemVO item : list) { item.setIdCardMasked(maskIdentity(item.getIdCardMasked())); + // 从 train_student 获取学员头像与人员编号 + TrainStudent student = studentMapper.selectTrainStudentBySId(item.getStudentId()); + item.setAvatar(student != null ? student.getAvatar() : null); + item.setPersonNo(student != null ? student.getPersonNo() : null); + // 从 train_dormitory_info 按楼栋+房间获取门锁 SN + if (item.getDormBuilding() != null && item.getDormRoom() != null) + { + TrainDormitoryInfo dorm = dormitoryInfoMapper.selectByBuildingRoom(item.getDormBuilding(), item.getDormRoom()); + item.setLockDeviceSn(dorm != null ? dorm.getLockDeviceSn() : null); + } } return list; } @@ -128,7 +144,7 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService { detail.setStudentName(student.getsName()); detail.setGender(student.getsGender()); - detail.setOrgName(student.getOrgName()); + detail.setDeptName(student.getDeptName()); detail.setPhone(student.getsPhone()); detail.setIdCardMasked(maskStudentIdentity(student)); } @@ -235,6 +251,12 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService return processBatchImport(plan, dtoList, Boolean.TRUE.equals(autoCreateStudent), operator); } + @Override + public void exportImportTemplate(HttpServletResponse response) + { + EnrollmentImportTemplateWriter.write(response); + } + /** * 批量导入处理引擎 (供 Excel 导入与批量接口复用)。 */ @@ -291,7 +313,9 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService student = new TrainStudent(); student.setsId(dto.getStudentId()); student.setsName(StringUtils.isNotEmpty(dto.getRemark()) ? dto.getRemark() : "导入学员_" + dto.getStudentId()); - student.setOrgName(dto.getOrgName()); + student.setDeptName(dto.getDeptName()); + // card_no 为 NOT NULL 且无默认值,自动建档时显式落空串 + student.setCardNo(""); student.setSourceType(2); // 2:Excel批量导入 student.setStatus(1); // 1:正常 student.setDelFlag("0"); @@ -678,6 +702,14 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService String accommStr = getCellValue(row.getCell(2)); String remark = getCellValue(row.getCell(3)); + // 模板自带的示例行(学员ID 与备注均未改动)不参与导入,避免用户忘记删除示例行时误建示例学员 + if (EnrollmentImportTemplateWriter.SAMPLE_STUDENT_ID.equals(studentId) + && EnrollmentImportTemplateWriter.SAMPLE_REMARK.equals(remark)) + { + log.debug("参训导入跳过模板示例行, row={}", r + 1); + continue; + } + EnrollmentAddDTO dto = new EnrollmentAddDTO(); dto.setStudentId(studentId); try diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentImportTemplateWriter.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentImportTemplateWriter.java new file mode 100644 index 000000000..8f3dfc1ef --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentImportTemplateWriter.java @@ -0,0 +1,318 @@ +package com.ruoyi.train.service.impl; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.ClientAnchor; +import org.apache.poi.ss.usermodel.Comment; +import org.apache.poi.ss.usermodel.DataValidation; +import org.apache.poi.ss.usermodel.DataValidationConstraint; +import org.apache.poi.ss.usermodel.DataValidationHelper; +import org.apache.poi.ss.usermodel.Drawing; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.ss.util.CellRangeAddressList; +import org.apache.poi.xssf.usermodel.XSSFClientAnchor; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import com.ruoyi.common.exception.ServiceException; + +/** + * 参训人员批量导入模板生成器。 + * + *

模板严格对齐 {@code EnrollmentAppServiceImpl#parseExcelToDTOList} 的解析规则: + * 仅第 1 个工作表参与导入,按列下标取值(A 学员ID / B 参训来源 / C 住宿需求 / D 备注), + * 因此除数据表外额外提供一个「填写说明」工作表,用于逐列说明含义、格式、必填性与取值范围, + * 并在数据表第 2 行预填一行可直接改写的示例数据。

+ * + *

示例行同时写入固定学员ID与固定备注,导入端在两者均未改动时自动忽略该行, + * 避免用户忘记删除示例行而误建示例学员。

+ * + * @author ruoyi + */ +final class EnrollmentImportTemplateWriter +{ + /** 数据工作表名(导入端只解析该表) */ + static final String SHEET_DATA = "参训人员导入"; + + /** 说明工作表名 */ + static final String SHEET_GUIDE = "填写说明"; + + /** 示例行学员ID:与示例备注同时命中时,导入端跳过该行 */ + static final String SAMPLE_STUDENT_ID = "SAMPLE0001"; + + /** 示例行备注:与示例学员ID同时命中时,导入端跳过该行;开启自动建档时该值会作为新学员姓名 */ + static final String SAMPLE_REMARK = "示例行,导入前请删除"; + + private static final String FILE_NAME = "参训人员导入模板.xlsx"; + + private static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 数据表表头,顺序即解析列顺序,不可调整 */ + private static final String[] HEADERS = { "学员ID", "参训来源", "住宿需求", "备注" }; + + private static final String[] REQUIRED = { "是", "否", "否", "否" }; + + private static final String[] VALUE_RULES = { + "文本,≤32 个字符;须与学员主档学号完全一致(区分大小写与空格)", + "1=管理员录入;2=Excel 批量导入;3=内部平台同步", + "0=不住宿;1=需要住宿", + "文本,≤500 个字符" + }; + + private static final String[] FIELD_DESC = { + "关联学员主档 train_student.s_id,同时作为参训档案的 student_id;为空的行会被跳过;学员不存在时由“新建学员档案”开关决定自动建档或本行失败", + "留空或填写非数字时,按 2(Excel 批量导入)处理", + "留空或填写非数字时,按 0(不住宿)处理;后续住宿分配以该字段为依据", + "写入参训档案备注;开启“学员不存在时自动建档”时,该值同时作为新学员姓名,建议填写学员真实姓名" + }; + + /** 通用规则(逐条占用说明表一行并横向合并) */ + private static final String[] COMMON_RULES = { + "1. 仅第一个工作表「参训人员导入」参与解析:第 1 行为表头,数据从第 2 行开始逐行读取。", + "2. 列顺序固定为 A 学员ID、B 参训来源、C 住宿需求、D 备注,请勿调换列顺序或插入新列,否则数据会串列写入。", + "3. 文件格式仅支持 .xlsx / .xls;单文件不超过 10MB,单次导入不超过 500 行,超出请拆分后分批导入。", + "4. 学员ID 为空的行会被跳过(不计入总数、不产生错误提示),请务必填写。", + "5. 学员ID 在主档中不存在时:勾选“学员不存在时自动建档”将按该 ID 新建最小档案(姓名取备注列),否则该行导入失败。", + "6. 同一学员不可重复加入同一培训班;导入同时受计划招生人数上限约束,超出上限的后续行会失败。", + "7. 已完成的计划不可导入;失败行会按 Excel 行号在导入结果中给出原因。", + "8. 本模板第 2 行为示例数据(学员ID=SAMPLE0001,备注=示例行,导入前请删除),请直接在其上改写或删除整行;未改动的示例行导入时会被自动忽略。" + }; + + private EnrollmentImportTemplateWriter() + { + } + + /** + * 输出 xlsx 模板到响应流。 + */ + static void write(HttpServletResponse response) + { + try (Workbook workbook = new XSSFWorkbook()) + { + CellStyle headerStyle = createHeaderStyle(workbook); + CellStyle bodyStyle = createBodyStyle(workbook); + CellStyle sampleStyle = createSampleStyle(workbook); + CellStyle tipStyle = createTipStyle(workbook); + + buildDataSheet(workbook, headerStyle, bodyStyle, sampleStyle); + buildGuideSheet(workbook, headerStyle, bodyStyle, tipStyle); + + response.reset(); + response.setContentType(CONTENT_TYPE); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-Disposition", + "attachment; filename*=utf-8''" + URLEncoder.encode(FILE_NAME, StandardCharsets.UTF_8).replaceAll("\\+", "%20")); + workbook.write(response.getOutputStream()); + } + catch (IOException e) + { + throw new ServiceException("导入模板生成失败,请稍后重试"); + } + } + + /** + * 数据表:表头(含规则批注) + 一行示例数据 + 枚举列下拉校验。 + */ + private static void buildDataSheet(Workbook workbook, CellStyle headerStyle, CellStyle bodyStyle, + CellStyle sampleStyle) + { + Sheet sheet = workbook.createSheet(SHEET_DATA); + int[] widths = { 20, 14, 14, 52 }; + for (int i = 0; i < widths.length; i++) + { + sheet.setColumnWidth(i, widths[i] * 256); + } + + Row headerRow = sheet.createRow(0); + headerRow.setHeightInPoints(26); + for (int i = 0; i < HEADERS.length; i++) + { + Cell cell = headerRow.createCell(i); + cell.setCellValue(HEADERS[i]); + cell.setCellStyle(headerStyle); + // 鼠标悬停表头即可看到该列的填写规则,无需切到说明表 + addHeaderComment(workbook, sheet, cell, i, + HEADERS[i] + "(" + ("是".equals(REQUIRED[i]) ? "必填" : "选填") + ")\n" + + "取值/格式:" + VALUE_RULES[i] + "\n" + FIELD_DESC[i]); + } + + Row sampleRow = sheet.createRow(1); + sampleRow.setHeightInPoints(24); + String[] sample = { SAMPLE_STUDENT_ID, "2", "0", SAMPLE_REMARK }; + for (int i = 0; i < sample.length; i++) + { + Cell cell = sampleRow.createCell(i); + cell.setCellValue(sample[i]); + // 示例行用浅黄底色区分,提示用户在此行改写或删除 + cell.setCellStyle(sampleStyle); + } + + sheet.createFreezePane(0, 1); + sheet.setAutoFilter(new CellRangeAddress(0, 0, 0, HEADERS.length - 1)); + addListValidation(sheet, 1, new String[] { "1", "2", "3" }); + addListValidation(sheet, 2, new String[] { "0", "1" }); + } + + /** + * 说明表:逐列规则表 + 通用规则。 + */ + private static void buildGuideSheet(Workbook workbook, CellStyle headerStyle, CellStyle bodyStyle, CellStyle tipStyle) + { + Sheet sheet = workbook.createSheet(SHEET_GUIDE); + int[] widths = { 8, 14, 8, 46, 66 }; + for (int i = 0; i < widths.length; i++) + { + sheet.setColumnWidth(i, widths[i] * 256); + } + + Font titleFont = workbook.createFont(); + titleFont.setBold(true); + titleFont.setFontHeightInPoints((short) 14); + CellStyle titleStyle = workbook.createCellStyle(); + titleStyle.setFont(titleFont); + titleStyle.setAlignment(HorizontalAlignment.LEFT); + titleStyle.setVerticalAlignment(VerticalAlignment.CENTER); + + Row titleRow = sheet.createRow(0); + titleRow.setHeightInPoints(30); + Cell titleCell = titleRow.createCell(0); + titleCell.setCellValue("参训人员批量导入填写说明(请先阅读本表,再填写「" + SHEET_DATA + "」工作表)"); + titleCell.setCellStyle(titleStyle); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 4)); + + String[] columns = { "列序", "表头", "必填", "取值 / 格式要求", "字段含义与填写说明" }; + Row headerRow = sheet.createRow(2); + headerRow.setHeightInPoints(24); + for (int i = 0; i < columns.length; i++) + { + Cell cell = headerRow.createCell(i); + cell.setCellValue(columns[i]); + cell.setCellStyle(headerStyle); + } + + int rowIndex = 3; + for (int i = 0; i < HEADERS.length; i++) + { + Row row = sheet.createRow(rowIndex++); + row.setHeightInPoints(46); + String[] values = { + String.valueOf((char) ('A' + i)), + HEADERS[i], + REQUIRED[i], + VALUE_RULES[i], + FIELD_DESC[i] + }; + for (int c = 0; c < values.length; c++) + { + Cell cell = row.createCell(c); + cell.setCellValue(values[c]); + cell.setCellStyle(bodyStyle); + } + } + + Row ruleTitle = sheet.createRow(rowIndex + 1); + Cell ruleTitleCell = ruleTitle.createCell(0); + ruleTitleCell.setCellValue("通用规则"); + ruleTitleCell.setCellStyle(headerStyle); + sheet.addMergedRegion(new CellRangeAddress(rowIndex + 1, rowIndex + 1, 0, 4)); + + int ruleRowIndex = rowIndex + 2; + for (String rule : COMMON_RULES) + { + Row row = sheet.createRow(ruleRowIndex); + row.setHeightInPoints(22); + Cell cell = row.createCell(0); + cell.setCellValue(rule); + cell.setCellStyle(tipStyle); + sheet.addMergedRegion(new CellRangeAddress(ruleRowIndex, ruleRowIndex, 0, 4)); + ruleRowIndex++; + } + } + + private static void addHeaderComment(Workbook workbook, Sheet sheet, Cell cell, int column, String text) + { + Drawing drawing = sheet.createDrawingPatriarch(); + ClientAnchor anchor = new XSSFClientAnchor(0, 0, 0, 0, column, 0, column + 3, 5); + Comment comment = drawing.createCellComment(anchor); + comment.setString(workbook.getCreationHelper().createRichTextString(text)); + comment.setAuthor("系统"); + cell.setCellComment(comment); + } + + private static void addListValidation(Sheet sheet, int column, String[] options) + { + DataValidationHelper helper = sheet.getDataValidationHelper(); + DataValidationConstraint constraint = helper.createExplicitListConstraint(options); + CellRangeAddressList range = new CellRangeAddressList(1, 500, column, column); + DataValidation validation = helper.createValidation(constraint, range); + // 选填列必须允许留空(留空时导入端按默认值处理) + validation.setEmptyCellAllowed(true); + validation.setShowErrorBox(true); + validation.createErrorBox("取值不合法", "该列仅允许填写:" + String.join(" / ", options)); + sheet.addValidationData(validation); + } + + private static CellStyle createHeaderStyle(Workbook workbook) + { + Font font = workbook.createFont(); + font.setBold(true); + font.setColor(IndexedColors.WHITE.getIndex()); + CellStyle style = workbook.createCellStyle(); + style.setFont(font); + style.setFillForegroundColor(IndexedColors.GREY_50_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderTop(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setWrapText(true); + return style; + } + + private static CellStyle createBodyStyle(Workbook workbook) + { + CellStyle style = workbook.createCellStyle(); + style.setAlignment(HorizontalAlignment.LEFT); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderTop(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setWrapText(true); + return style; + } + + private static CellStyle createSampleStyle(Workbook workbook) + { + Font font = workbook.createFont(); + font.setItalic(true); + font.setColor(IndexedColors.DARK_YELLOW.getIndex()); + CellStyle style = createBodyStyle(workbook); + style.setFont(font); + style.setFillForegroundColor(IndexedColors.LEMON_CHIFFON.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + return style; + } + + private static CellStyle createTipStyle(Workbook workbook) + { + CellStyle style = workbook.createCellStyle(); + style.setAlignment(HorizontalAlignment.LEFT); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setWrapText(true); + return style; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java index 064dd17bf..0d6fd5d8b 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java @@ -99,12 +99,19 @@ public class PlanAppServiceImpl implements PlanAppService TrainClassPlan plan = new TrainClassPlan(); plan.setPlanId(IdUtils.fastSimpleUUID()); plan.setPlanName(planName); + plan.setClassId(dto.getClassId()); + plan.setClassName(dto.getClassName()); plan.setStartTime(dto.getStartTime()); plan.setEndTime(dto.getEndTime()); plan.setReportDeadline(dto.getReportDeadline()); + plan.setReportStarttime(dto.getReportStarttime()); plan.setTrainAddress(dto.getTrainAddress()); plan.setRecruitNum(dto.getRecruitNum()); plan.setApplyCondition(dto.getApplyCondition()); + plan.setClassMajor(dto.getClassMajor()); + plan.setTotalClassHours(dto.getTotalClassHours()); + plan.setManager(dto.getManager()); + plan.setHostDeptId(dto.getHostDeptId()); plan.setPlanStatus(STATUS_NOT_STARTED); plan.setDelFlag("0"); plan.setCreateBy(operator); @@ -130,12 +137,19 @@ public class PlanAppServiceImpl implements PlanAppService TrainClassPlan plan = new TrainClassPlan(); plan.setPlanId(planId); plan.setPlanName(planName); + plan.setClassId(dto.getClassId()); + plan.setClassName(dto.getClassName()); plan.setStartTime(dto.getStartTime()); plan.setEndTime(dto.getEndTime()); plan.setReportDeadline(dto.getReportDeadline()); + plan.setReportStarttime(dto.getReportStarttime()); plan.setTrainAddress(dto.getTrainAddress()); plan.setRecruitNum(dto.getRecruitNum()); plan.setApplyCondition(dto.getApplyCondition()); + plan.setClassMajor(dto.getClassMajor()); + plan.setTotalClassHours(dto.getTotalClassHours()); + plan.setManager(dto.getManager()); + plan.setHostDeptId(dto.getHostDeptId()); plan.setUpdateBy(operator); plan.setUpdateTime(DateUtils.getNowDate()); plan.setRemark(dto.getRemark()); @@ -178,6 +192,23 @@ public class PlanAppServiceImpl implements PlanAppService } } + @Override + @Transactional(rollbackFor = Exception.class) + public void deletePlan(String planId, String operator) + { + TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId)); + if (!Integer.valueOf(STATUS_NOT_STARTED).equals(plan.getPlanStatus())) + { + throw error("只有未开始的开班计划可以删除", PlanErrorCode.PLAN_STATUS_LOCKED); + } + // 级联删除本期课程配置,避免孤儿数据 + for (PlanCourseItemVO course : listPlanCourses(planId)) + { + planCourseService.deleteTrainPlanCourseById(course.getPlanCourseId()); + } + planService.logicalDeleteByPlanId(planId, operator); + } + @Override public List listPlanCourses(String planId) { @@ -364,7 +395,8 @@ public class PlanAppServiceImpl implements PlanAppService { throw error(PlanErrorCode.PLAN_TIME_INVALID_MESSAGE, PlanErrorCode.PLAN_TIME_INVALID); } - if (reportDeadline == null || reportDeadline.after(startTime)) + if (reportDeadline == null || DateUtils.parseDateToStr("yyyy-MM-dd", reportDeadline) + .compareTo(DateUtils.parseDateToStr("yyyy-MM-dd", startTime)) > 0) { throw error("报到截止时间不得晚于开班时间", PlanErrorCode.PLAN_TIME_INVALID); } @@ -385,13 +417,20 @@ public class PlanAppServiceImpl implements PlanAppService PlanDetailVO vo = new PlanDetailVO(); vo.setPlanId(plan.getPlanId()); vo.setPlanName(plan.getPlanName()); + vo.setClassId(plan.getClassId()); + vo.setClassName(plan.getClassName()); vo.setPlanStatus(plan.getPlanStatus()); vo.setStartTime(plan.getStartTime()); vo.setEndTime(plan.getEndTime()); vo.setReportDeadline(plan.getReportDeadline()); + vo.setReportStarttime(plan.getReportStarttime()); vo.setTrainAddress(plan.getTrainAddress()); vo.setRecruitNum(plan.getRecruitNum()); vo.setApplyCondition(plan.getApplyCondition()); + vo.setClassMajor(plan.getClassMajor()); + vo.setTotalClassHours(plan.getTotalClassHours()); + vo.setManager(plan.getManager()); + vo.setHostDeptId(plan.getHostDeptId()); vo.setRemark(plan.getRemark()); vo.setCreateBy(plan.getCreateBy()); vo.setCreateTime(plan.getCreateTime()); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PortraitCalculator.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PortraitCalculator.java new file mode 100644 index 000000000..3632f46a2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PortraitCalculator.java @@ -0,0 +1,927 @@ +package com.ruoyi.train.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import com.ruoyi.train.domain.analysis.StudentPortraitVo; + +/** + * 画像算分器:纯函数,无数据库/Spring 依赖,便于单测覆盖全部判定规则。 + * + *

输入 {@link PortraitRawData}(事实表只读聚合结果),输出 {@link StudentPortraitVo}。 + * 口径与阈值集中在常量区,任何口径调整只需改这里,避免散落在 SQL 与前端。

+ */ +final class PortraitCalculator +{ + /** 口径版本,随阈值调整递增 */ + static final String METRIC_VERSION = "PORTRAIT-V1.0-20261108"; + + private static final int TAG_LIMIT = 8; + + private static final BigDecimal ZERO = BigDecimal.ZERO; + + private PortraitCalculator() + { + } + + /** + * 生成完整画像。 + */ + static StudentPortraitVo build(PortraitRawData raw) + { + StudentPortraitVo vo = new StudentPortraitVo(); + vo.setRisk(buildRisk(raw)); + vo.setDimensions(buildDimensions(raw)); + vo.setActivity(buildActivity(raw)); + vo.setLayer(buildLayer(raw, vo.getDimensions(), vo.getRisk())); + vo.setTags(buildTags(raw, vo.getActivity())); + vo.setClassCompare(buildClassCompare(raw, vo.getDimensions(), vo.getActivity())); + vo.setTrend(buildTrend(raw)); + vo.setMeta(buildMeta(raw, vo.getDimensions())); + return vo; + } + + /* ================================================================== + * 一、维度 + * ================================================================== */ + + private static List buildDimensions(PortraitRawData raw) + { + List list = new ArrayList<>(); + list.add(attendanceDimension(raw)); + list.add(studyDimension(raw)); + list.add(scoreDimension(raw)); + list.add(progressDimension(raw)); + list.add(orgDimension(raw)); + list.add(identityDimension(raw)); + list.add(overallDimension(list)); + return list; + } + + private static StudentPortraitVo.Dimension attendanceDimension(PortraitRawData raw) + { + StudentPortraitVo.Dimension dim = new StudentPortraitVo.Dimension("attendance", "考勤纪律"); + boolean available = raw.expectedLessons > 0; + dim.setAvailable(available); + int score = 100 - raw.absentLessons * 8 - raw.lateLessons * 3 - raw.earlyLessons * 3 + - raw.leaveLessons - raw.proxySignCount * 5 - raw.manualCheckinCount * 2; + score = clamp(score); + dim.setScore(available ? Integer.valueOf(score) : null); + dim.setLevel(available ? disciplineLevel(score) : "数据不足"); + addMetric(dim, "expected", "应到课次", String.valueOf(raw.expectedLessons), "次"); + addMetric(dim, "present", "实到课次", String.valueOf(raw.presentLessons), "次"); + addMetric(dim, "absent", "缺勤(含未签到)", String.valueOf(raw.absentLessons), "次"); + addMetric(dim, "leave", "请假", String.valueOf(raw.leaveLessons), "次"); + addMetric(dim, "late", "迟到", String.valueOf(raw.lateLessons), "次"); + addMetric(dim, "early", "早退", String.valueOf(raw.earlyLessons), "次"); + addMetric(dim, "attendanceRate", "出勤率", pct(raw.presentLessons, raw.expectedLessons), "%"); + addMetric(dim, "onTimeRate", "准时率", pct(raw.normalLessons, raw.presentLessons), "%"); + addMetric(dim, "proxyRate", "代签率", pct(raw.proxySignCount, raw.presentLessons), "%"); + addMetric(dim, "manualRate", "补卡率", pct(raw.manualCheckinCount, raw.presentLessons), "%"); + addMetric(dim, "leadMinutes", "平均提前", decimal(avg(raw.leadMinutesSum, raw.leadMinutesCount)), "分钟"); + addMetric(dim, "inClassRate", "平均在课率", + pct(avg(raw.inClassRateSum, raw.inClassRateCount), 1d), "%"); + addMetric(dim, "maxAbsenceStreak", "连续缺勤峰值", String.valueOf(raw.maxConsecutiveAbsence), "次"); + return dim; + } + + private static StudentPortraitVo.Dimension studyDimension(PortraitRawData raw) + { + StudentPortraitVo.Dimension dim = new StudentPortraitVo.Dimension("study", "学习投入"); + boolean available = raw.studyRecordCount > 0; + dim.setAvailable(available); + BigDecimal hours = hours(raw.studySeconds); + BigDecimal planned = raw.plannedHours == null ? ZERO : raw.plannedHours; + int achievement = planned.compareTo(ZERO) > 0 + ? clamp(hours.multiply(new BigDecimal("100")).divide(planned, 0, RoundingMode.HALF_UP).intValue()) + : clamp(hours.intValue()); + int score = available ? clamp(achievement + (raw.studyMaxConsecutiveDays >= 3 ? 5 : 0)) : 0; + dim.setScore(available ? Integer.valueOf(score) : null); + dim.setLevel(!available ? "数据不足" : (score >= 80 ? "高投入" : (score >= 50 ? "中等" : "低投入"))); + addMetric(dim, "totalHours", "总学时", hours.toPlainString(), "小时"); + addMetric(dim, "plannedHours", "计划学时", planned.toPlainString(), "小时"); + addMetric(dim, "achievement", "投入达成率", available ? String.valueOf(achievement) : "-", "%"); + addMetric(dim, "activeDays", "有效学习天数", String.valueOf(raw.studyActiveDays), "天"); + addMetric(dim, "maxConsecutive", "最长连续学习", String.valueOf(raw.studyMaxConsecutiveDays), "天"); + addMetric(dim, "records", "学习记录数", String.valueOf(raw.studyRecordCount), "条"); + addMetric(dim, "resourceCoverage", "资源覆盖度", + pct(raw.learnedResourceCount, raw.courseResourceCount), "%"); + addMetric(dim, "lastStudy", "最近学习", format(raw.lastStudyTime), ""); + return dim; + } + + private static StudentPortraitVo.Dimension scoreDimension(PortraitRawData raw) + { + StudentPortraitVo.Dimension dim = new StudentPortraitVo.Dimension("score", "考核能力"); + boolean available = raw.theoryCount > 0 || raw.practiceCount > 0 + || (raw.weightSum != null && raw.weightSum.compareTo(ZERO) > 0); + BigDecimal overall = overallScore(raw); + dim.setAvailable(available); + dim.setScore(available && overall != null ? Integer.valueOf(clamp(overall.intValue())) : null); + dim.setLevel(available ? scoreLevel(overall) : "数据不足"); + addMetric(dim, "overall", "综合分", overall == null ? "-" : overall.toPlainString(), "分"); + addMetric(dim, "weightCoverage", "考核权重覆盖", + (raw.weightSum == null ? "0" : raw.weightSum.setScale(0, RoundingMode.HALF_UP).toPlainString()), "%"); + addMetric(dim, "theoryAvg", "理论均分", dec(raw.theoryAvg), "分"); + addMetric(dim, "theoryPassRate", "理论通过率", pct(raw.theoryPassCount, raw.theoryCount), "%"); + addMetric(dim, "practiceAvg", "实训均分", dec(raw.practiceAvg), "分"); + addMetric(dim, "practicePassRate", "实训通过率", pct(raw.practicePassCount, raw.practiceCount), "%"); + addMetric(dim, "absent", "缺考课程", String.valueOf(raw.theoryAbsentCount), "门"); + addMetric(dim, "retake", "补考次数", String.valueOf(raw.practiceRetakeCount), "次"); + addMetric(dim, "improvement", "补考提升", dec(raw.retakeImprovement), "分"); + addMetric(dim, "weak", "薄弱课程", + raw.weakCourseNames.isEmpty() ? "-" : String.join("、", raw.weakCourseNames), ""); + return dim; + } + + private static StudentPortraitVo.Dimension progressDimension(PortraitRawData raw) + { + StudentPortraitVo.Dimension dim = new StudentPortraitVo.Dimension("progress", "进度完成"); + boolean available = raw.totalLessons > 0; + int lessonProgress = available ? percentage(raw.doneLessons, raw.totalLessons) : 0; + int timeProgress = timeProgress(raw); + int deviation = available ? lessonProgress - timeProgress : 0; + int examCompletion = percentage(raw.examCompletedCourses, raw.courseCount); + dim.setAvailable(available); + dim.setScore(available ? Integer.valueOf(clamp(lessonProgress)) : null); + dim.setLevel(!available ? "数据不足" + : (deviation > 10 ? "超前" : (deviation < -10 ? "滞后" : "同步"))); + addMetric(dim, "timeProgress", "时间进度", String.valueOf(timeProgress), "%"); + addMetric(dim, "lessonProgress", "课次进度", String.valueOf(lessonProgress), "%"); + addMetric(dim, "deviation", "进度偏差", (deviation > 0 ? "+" : "") + deviation, "%"); + addMetric(dim, "examCompletion", "考核完成度", String.valueOf(examCompletion), "%"); + addMetric(dim, "lessons", "已完成课次", raw.doneLessons + "/" + raw.totalLessons, "次"); + addMetric(dim, "courses", "课程数", String.valueOf(raw.courseCount), "门"); + addMetric(dim, "status", "参训状态", enrollmentStatusName(raw.enrollmentStatus), ""); + return dim; + } + + private static StudentPortraitVo.Dimension orgDimension(PortraitRawData raw) + { + StudentPortraitVo.Dimension dim = new StudentPortraitVo.Dimension("org", "组织归属"); + dim.setAvailable(true); + dim.setScore(null); + dim.setLevel("展示项"); + addMetric(dim, "plan", "所属班期", nullToDash(raw.planName), ""); + addMetric(dim, "dept", "所属单位", nullToDash(raw.deptName), ""); + addMetric(dim, "source", "档案来源", sourceTypeName(raw.sourceType), ""); + addMetric(dim, "teachers", "授课教师数", String.valueOf(raw.teacherCount), "人"); + addMetric(dim, "dormMates", "同寝人数", String.valueOf(raw.dormMateCount), "人"); + addMetric(dim, "accommodation", "住宿状态", accommodationStatusName(raw.accommodationStatus), ""); + return dim; + } + + private static StudentPortraitVo.Dimension identityDimension(PortraitRawData raw) + { + StudentPortraitVo.Dimension dim = new StudentPortraitVo.Dimension("identity", "身份与数据质量"); + int completeness = percentage(raw.profileFilledFields, raw.profileTotalFields); + int faceScore = isFaceReady(raw.extractStatus) ? 100 : 0; + int accessScore = raw.cardNo != null && !raw.cardNo.trim().isEmpty() + ? (raw.faceSyncStatus != null && raw.faceSyncStatus == 1 ? 100 : 60) + : 0; + int autoRate = percentage(raw.presentLessons - raw.attendanceManualCount, raw.presentLessons); + int score = (int) Math.round(completeness * 0.3 + (raw.accountBound ? 100 : 0) * 0.1 + + faceScore * 0.2 + accessScore * 0.2 + autoRate * 0.2); + dim.setAvailable(true); + dim.setScore(clamp(score)); + dim.setLevel(score >= 85 ? "高" : (score >= 60 ? "中" : "低")); + addMetric(dim, "completeness", "主档完整度", String.valueOf(completeness), "%"); + addMetric(dim, "account", "账号绑定", raw.accountBound ? "已绑定" : "未绑定", ""); + addMetric(dim, "face", "人脸特征", isFaceReady(raw.extractStatus) ? "已提取" : "待提取", ""); + addMetric(dim, "card", "门禁卡", raw.cardNo != null && !raw.cardNo.trim().isEmpty() ? "已发放" : "未发放", ""); + addMetric(dim, "faceSync", "门禁下发", dormAuthStatusName(raw.faceSyncStatus), ""); + addMetric(dim, "autoRate", "考勤自动采集率", String.valueOf(autoRate), "%"); + return dim; + } + + /** + * 综合维度:仅对可用维度按权重归一,避免"无数据即 0 分"。 + */ + private static StudentPortraitVo.Dimension overallDimension(List dims) + { + int[][] weights = { { 0, 25 }, { 1, 20 }, { 2, 30 }, { 3, 15 }, { 5, 10 } }; + double sum = 0; + double weightSum = 0; + for (int[] w : weights) + { + StudentPortraitVo.Dimension dim = dims.get(w[0]); + if (dim.getScore() != null && Boolean.TRUE.equals(dim.getAvailable())) + { + sum += dim.getScore() * w[1]; + weightSum += w[1]; + } + } + StudentPortraitVo.Dimension overall = new StudentPortraitVo.Dimension("overall", "综合画像"); + boolean available = weightSum > 0; + overall.setAvailable(available); + int score = available ? (int) Math.round(sum / weightSum) : 0; + overall.setScore(available ? clamp(score) : null); + overall.setLevel(available ? overallLevel(score) : "数据不足"); + addMetric(overall, "usedWeight", "参与权重", String.valueOf((int) weightSum), "%"); + return overall; + } + + /* ================================================================== + * 二、分层 + * ================================================================== */ + + private static StudentPortraitVo.Layer buildLayer(PortraitRawData raw, + List dims, StudentPortraitVo.Risk risk) + { + Integer discipline = dimScore(dims, "attendance"); + Integer overall = dimScore(dims, "overall"); + // 命中任一"高"等级规则即视为高风险型(单条规则权重可能不足以让总分达到 60) + if (risk != null && ("高".equals(risk.getLevel()) || hasHighLevelRule(risk))) + { + return new StudentPortraitVo.Layer("RISK", "高风险型", + "命中高等级风险规则(风险分 " + risk.getScore() + "),优先于常规分层"); + } + if (discipline == null || overall == null) + { + return new StudentPortraitVo.Layer("UNKNOWN", "数据不足", "考勤或成绩数据不足,暂不归类"); + } + if (discipline >= 75 && overall >= 70) + { + return new StudentPortraitVo.Layer("BENCHMARK", "标杆型", "纪律分 ≥75 且综合分 ≥70"); + } + if (discipline >= 75) + { + return new StudentPortraitVo.Layer("DILIGENT", "勤奋型", "纪律分 ≥75,综合分 <70"); + } + if (overall >= 70) + { + return new StudentPortraitVo.Layer("TALENTED", "天赋型", "综合分 ≥70,纪律分 <75"); + } + return new StudentPortraitVo.Layer("IMPROVING", "待提升型", "纪律分与综合分均未达标"); + } + + /* ================================================================== + * 三、活跃度 + * ================================================================== */ + + private static StudentPortraitVo.Activity buildActivity(PortraitRawData raw) + { + StudentPortraitVo.Activity activity = new StudentPortraitVo.Activity(); + List parts = new ArrayList<>(); + + Integer attendance = raw.expectedLessons > 0 + ? Integer.valueOf(percentage(raw.presentLessons, raw.expectedLessons)) : null; + parts.add(new StudentPortraitVo.Activity.Part("出勤", attendance, 40, attendance != null)); + + Integer study = raw.studyRecordCount > 0 ? Integer.valueOf(studyScore(raw)) : null; + parts.add(new StudentPortraitVo.Activity.Part("学习投入", study, 30, study != null)); + + Integer participation = raw.courseCount > 0 + ? Integer.valueOf(percentage(raw.examCompletedCourses, raw.courseCount)) : null; + parts.add(new StudentPortraitVo.Activity.Part("考核参与", participation, 20, participation != null)); + + Integer coverage = raw.courseResourceCount > 0 + ? Integer.valueOf(percentage(raw.learnedResourceCount, raw.courseResourceCount)) : null; + parts.add(new StudentPortraitVo.Activity.Part("资源覆盖", coverage, 10, coverage != null)); + + double sum = 0; + double weightSum = 0; + for (StudentPortraitVo.Activity.Part part : parts) + { + if (Boolean.TRUE.equals(part.getIncluded()) && part.getScore() != null) + { + sum += part.getScore() * part.getWeight(); + weightSum += part.getWeight(); + } + } + boolean available = weightSum > 0; + int score = available ? clamp((int) Math.round(sum / weightSum)) : 0; + activity.setParts(parts); + activity.setScore(available ? Integer.valueOf(score) : null); + activity.setLevel(!available ? "数据不足" + : (score >= 80 ? "高活跃" : (score >= 60 ? "稳定" : (score >= 40 ? "低活跃" : "沉默")))); + return activity; + } + + private static int studyScore(PortraitRawData raw) + { + BigDecimal planned = raw.plannedHours == null ? ZERO : raw.plannedHours; + int achievement = planned.compareTo(ZERO) > 0 + ? hours(raw.studySeconds).multiply(new BigDecimal("100")) + .divide(planned, 0, RoundingMode.HALF_UP).intValue() + : hours(raw.studySeconds).intValue(); + return clamp(achievement + (raw.studyMaxConsecutiveDays >= 3 ? 5 : 0)); + } + + /* ================================================================== + * 四、风险 + * ================================================================== */ + + private static StudentPortraitVo.Risk buildRisk(PortraitRawData raw) + { + StudentPortraitVo.Risk risk = new StudentPortraitVo.Risk(); + List rules = new ArrayList<>(); + // 无参训上下文时不产生任何风险判定,避免把"无数据"误判为异常 + if (raw.planId == null && raw.enrollmentId == null) + { + risk.setScore(0); + risk.setLevel("低"); + risk.setRules(rules); + return risk; + } + boolean abandoned = raw.enrollmentStatus != null && raw.enrollmentStatus == 3; + + if (abandoned) + { + addRule(rules, "R1", "已放弃培训", "高", 40, "参训状态=放弃", "核实放弃原因,必要时转入下一期"); + } + else + { + if (raw.joinTime == null && raw.doneLessons > 0) + { + addRule(rules, "R2", "未报到但已开课", "高", 35, + "已完成 " + raw.doneLessons + " 个课次且无报到时间", "联系学员确认到场时间"); + } + if (raw.maxConsecutiveAbsence >= 2) + { + addRule(rules, "R3", "连续缺勤", "高", 30, + "最长连续缺勤 " + raw.maxConsecutiveAbsence + " 个课次", "班主任电话回访并记录原因"); + } + if (raw.expectedLessons > 0 && percentage(raw.presentLessons, raw.expectedLessons) < 70) + { + addRule(rules, "R4", "出勤率不足", "中", 20, + "出勤率 " + percentage(raw.presentLessons, raw.expectedLessons) + "%(阈值 70%)", + "制定补课计划并跟踪两周"); + } + if (raw.failedCourseCount > 0 && (raw.practiceRetakeCount > 0 || raw.theoryAbsentCount > 0)) + { + addRule(rules, "R5", "考核未通过", "中", 15, + "不及格 " + raw.failedCourseCount + " 门,补考 " + raw.practiceRetakeCount + " 次", + "安排专项辅导与重考"); + } + if (raw.theoryAbsentCount > 0) + { + addRule(rules, "R6", "存在缺考", "中", 15, + "缺考 " + raw.theoryAbsentCount + " 门", "核实缺考原因并安排补考"); + } + if (raw.studyRecordCount == 0 && raw.totalLessons > 0 && raw.doneLessons < raw.totalLessons) + { + addRule(rules, "R7", "学习沉默", "中", 15, "无任何自主学习记录", "推送学习任务,确认平台可用"); + } + if (raw.leaveLessons >= 3) + { + addRule(rules, "R8", "请假频繁", "低", 8, "请假 " + raw.leaveLessons + " 次", "关注请假原因与返岗情况"); + } + int proxyRate = percentage(raw.proxySignCount, raw.presentLessons); + int manualRate = percentage(raw.manualCheckinCount, raw.presentLessons); + if (proxyRate > 20 || manualRate > 20) + { + addRule(rules, "R9", "考勤规范异常", "中", 12, + "代签率 " + proxyRate + "%,补卡率 " + manualRate + "%", "核查代签/补卡原因"); + } + if (raw.accommodationStatus != null && raw.accommodationStatus == 2 + && raw.doneLessons < raw.totalLessons) + { + addRule(rules, "R10", "中途退宿", "低", 8, "住宿状态=已退宿但培训未结束", "确认住宿安排与通勤影响"); + } + if (raw.faceSyncStatus != null && (raw.faceSyncStatus == 0 || raw.faceSyncStatus == 2)) + { + addRule(rules, "R11", "门禁未就绪", "低", 5, + "门禁人脸同步状态=" + dormAuthStatusName(raw.faceSyncStatus), "重新下发门禁人脸"); + } + if (percentage(raw.profileFilledFields, raw.profileTotalFields) < 60) + { + addRule(rules, "R12", "主档不完整", "低", 5, + "完整度 " + percentage(raw.profileFilledFields, raw.profileTotalFields) + "%", "补全主档信息"); + } + } + + int score = 0; + for (StudentPortraitVo.Risk.Rule rule : rules) + { + score += rule.getWeight(); + } + risk.setScore(clamp(score)); + risk.setLevel(score >= 60 ? "高" : (score >= 30 ? "中" : "低")); + risk.setRules(rules); + return risk; + } + + private static void addRule(List rules, String code, String name, + String level, int weight, String evidence, String suggestion) + { + rules.add(new StudentPortraitVo.Risk.Rule(code, name, level, weight, evidence, suggestion)); + } + + /* ================================================================== + * 五、标签 + * ================================================================== */ + + private static List buildTags(PortraitRawData raw, StudentPortraitVo.Activity activity) + { + List tags = new ArrayList<>(); + int attendanceRate = percentage(raw.presentLessons, raw.expectedLessons); + int lateRate = percentage(raw.lateLessons, raw.presentLessons); + int earlyRate = percentage(raw.earlyLessons, raw.presentLessons); + Integer overall = overallScore(raw) == null ? null : overallScore(raw).intValue(); + + // 风险组(最多 2 个) + if (raw.enrollmentStatus != null && raw.enrollmentStatus == 3) + { + tags.add(new StudentPortraitVo.Tag("RISK_ABANDON", "流失预警", "risk", "high", "参训状态=放弃")); + } + if (raw.maxConsecutiveAbsence >= 2 || (raw.expectedLessons > 0 && attendanceRate < 70)) + { + tags.add(new StudentPortraitVo.Tag("RISK_ATTEND", "出勤预警", "risk", "high", + "出勤率 " + attendanceRate + "%,连续缺勤 " + raw.maxConsecutiveAbsence + " 次")); + } + if (raw.studyRecordCount == 0 && raw.totalLessons > 0 && raw.doneLessons < raw.totalLessons) + { + tags.add(new StudentPortraitVo.Tag("RISK_SILENT", "沉默学员", "risk", "medium", "无自主学习记录")); + } + + // 能力组 + if (!raw.weakCourseNames.isEmpty()) + { + tags.add(new StudentPortraitVo.Tag("ABILITY_WEAK", "单科薄弱", "ability", "medium", + "薄弱课程:" + String.join("、", raw.weakCourseNames))); + } + if (raw.retakeImprovement != null && raw.retakeImprovement.compareTo(new BigDecimal("5")) >= 0) + { + tags.add(new StudentPortraitVo.Tag("ABILITY_RETAKE", "补考通过", "ability", "good", + "补考提升 " + raw.retakeImprovement.stripTrailingZeros().toPlainString() + " 分")); + } + + // 纪律组(互斥:全勤优先) + if (raw.expectedLessons > 0 && raw.absentLessons == 0 && raw.lateLessons == 0 && raw.earlyLessons == 0) + { + tags.add(new StudentPortraitVo.Tag("DISC_FULL", "全勤", "discipline", "good", + "应到 " + raw.expectedLessons + " 次,无缺勤/迟到/早退")); + } + else + { + if (lateRate > 20) + { + tags.add(new StudentPortraitVo.Tag("DISC_LATE", "迟到常客", "discipline", "medium", + "迟到率 " + lateRate + "%")); + } + if (raw.leaveLessons >= 3) + { + tags.add(new StudentPortraitVo.Tag("DISC_LEAVE", "请假频繁", "discipline", "medium", + "请假 " + raw.leaveLessons + " 次")); + } + if (earlyRate > 15) + { + tags.add(new StudentPortraitVo.Tag("DISC_EARLY", "早退关注", "discipline", "medium", + "早退率 " + earlyRate + "%")); + } + } + + // 投入组(互斥) + Integer activityScore = activity == null ? null : activity.getScore(); + int studyScore = studyScore(raw); + if (raw.studyRecordCount > 0 && studyScore >= 80) + { + tags.add(new StudentPortraitVo.Tag("STUDY_HIGH", "高投入", "study", "good", + "学习投入分 " + studyScore)); + } + else if (raw.studyRecordCount > 0 && raw.studyActiveDays <= 2) + { + tags.add(new StudentPortraitVo.Tag("STUDY_LOW", "低频学习", "study", "medium", + "仅 " + raw.studyActiveDays + " 天有学习记录")); + } + if (raw.studyMaxConsecutiveDays >= 5) + { + tags.add(new StudentPortraitVo.Tag("STUDY_STREAK", "连续学习达人", "study", "good", + "最长连续 " + raw.studyMaxConsecutiveDays + " 天")); + } + + // 进度组(互斥) + int timeProgress = timeProgress(raw); + int lessonProgress = percentage(raw.doneLessons, raw.totalLessons); + int deviation = lessonProgress - timeProgress; + int examCompletion = percentage(raw.examCompletedCourses, raw.courseCount); + if (raw.totalLessons > 0) + { + if (examCompletion >= 90) + { + tags.add(new StudentPortraitVo.Tag("PROG_DONE", "已完成", "progress", "good", + "考核完成度 " + examCompletion + "%")); + } + else if (deviation > 10) + { + tags.add(new StudentPortraitVo.Tag("PROG_AHEAD", "进度超前", "progress", "good", + "较时间进度超前 " + deviation + "%")); + } + else if (deviation < -10) + { + tags.add(new StudentPortraitVo.Tag("PROG_LAG", "进度滞后", "progress", "medium", + "较时间进度滞后 " + Math.abs(deviation) + "%")); + } + } + + // 身份组 + if (percentage(raw.profileFilledFields, raw.profileTotalFields) < 60) + { + tags.add(new StudentPortraitVo.Tag("ID_PROFILE", "资料待完善", "identity", "low", + "主档完整度 " + percentage(raw.profileFilledFields, raw.profileTotalFields) + "%")); + } + if (!isFaceReady(raw.extractStatus)) + { + tags.add(new StudentPortraitVo.Tag("ID_FACE", "人脸待提取", "identity", "low", "人脸特征未就绪")); + } + if (raw.faceSyncStatus != null && (raw.faceSyncStatus == 0 || raw.faceSyncStatus == 2)) + { + tags.add(new StudentPortraitVo.Tag("ID_ACCESS", "门禁待下发", "identity", "low", + "门禁同步状态:" + dormAuthStatusName(raw.faceSyncStatus))); + } + if (!raw.accountBound) + { + tags.add(new StudentPortraitVo.Tag("ID_ACCOUNT", "账号未绑定", "identity", "low", "未关联登录账号")); + } + + // 优势组(放最后) + BigDecimal classScoreAvg = average(raw.classScores); + if (raw.theoryAvg != null && classScoreAvg != null + && raw.theoryAvg.compareTo(classScoreAvg.add(new BigDecimal("5"))) >= 0) + { + tags.add(new StudentPortraitVo.Tag("ADV_THEORY", "理论优势", "advantage", "good", + "理论均分高于班级均值 5 分以上")); + } + if (raw.practiceAvg != null && classScoreAvg != null + && raw.practiceAvg.compareTo(classScoreAvg.add(new BigDecimal("5"))) >= 0) + { + tags.add(new StudentPortraitVo.Tag("ADV_PRACTICE", "实操优势", "advantage", "good", + "实训均分高于班级均值 5 分以上")); + } + + tags.sort(Comparator.comparingInt(t -> groupPriority(t.getGroup()))); + if (tags.size() > TAG_LIMIT) + { + return new ArrayList<>(tags.subList(0, TAG_LIMIT)); + } + return tags; + } + + private static boolean hasHighLevelRule(StudentPortraitVo.Risk risk) + { + if (risk.getRules() == null) + { + return false; + } + return risk.getRules().stream().anyMatch(rule -> "高".equals(rule.getLevel())); + } + + private static int groupPriority(String group) + { + if ("risk".equals(group)) { return 1; } + if ("ability".equals(group)) { return 2; } + if ("discipline".equals(group)) { return 3; } + if ("study".equals(group)) { return 4; } + if ("progress".equals(group)) { return 5; } + if ("identity".equals(group)) { return 6; } + return 7; + } + + /* ================================================================== + * 六、班级对比 + * ================================================================== */ + + private static StudentPortraitVo.ClassCompare buildClassCompare(PortraitRawData raw, + List dims, StudentPortraitVo.Activity activity) + { + StudentPortraitVo.ClassCompare compare = new StudentPortraitVo.ClassCompare(); + int sampleSize = Math.max(raw.classAttendanceRates.size(), + Math.max(raw.classScores.size(), raw.classStudyHours.size())); + compare.setScope(raw.classScope == null ? "本班期" : raw.classScope); + compare.setSampleSize(sampleSize); + compare.setSufficient(sampleSize >= 10); + + Integer attendance = raw.expectedLessons > 0 + ? Integer.valueOf(percentage(raw.presentLessons, raw.expectedLessons)) : null; + compare.getItems().add(compareItem("attendance", "出勤率", attendance, + raw.classAttendanceRates, sampleSize >= 10)); + + BigDecimal overall = overallScore(raw); + compare.getItems().add(compareItem("overall", "综合分", + overall == null ? null : Integer.valueOf(overall.intValue()), raw.classScores, sampleSize >= 10)); + + compare.getItems().add(compareItem("studyHours", "学习学时", + Integer.valueOf(hours(raw.studySeconds).intValue()), raw.classStudyHours, sampleSize >= 10)); + + compare.getItems().add(compareItem("activity", "活跃度", + activity == null ? null : activity.getScore(), raw.classActivityScores, sampleSize >= 10)); + return compare; + } + + private static StudentPortraitVo.ClassCompare.CompareItem compareItem(String key, String label, + Integer self, List classValues, boolean percentileAllowed) + { + StudentPortraitVo.ClassCompare.CompareItem item = new StudentPortraitVo.ClassCompare.CompareItem(); + item.setKey(key); + item.setLabel(label); + BigDecimal selfValue = self == null ? null : new BigDecimal(self); + item.setSelf(selfValue); + item.setAvailable(selfValue != null && classValues != null && !classValues.isEmpty()); + item.setAvg(average(classValues)); + if (percentileAllowed && selfValue != null && classValues != null) + { + int below = 0; + int total = 0; + for (BigDecimal value : classValues) + { + if (value == null) + { + continue; + } + total++; + if (value.compareTo(selfValue) <= 0) + { + below++; + } + } + item.setPercentile(total > 0 ? (int) Math.round(below * 100.0 / total) : null); + } + return item; + } + + /* ================================================================== + * 七、轻量趋势 + * ================================================================== */ + + private static StudentPortraitVo.Trend buildTrend(PortraitRawData raw) + { + StudentPortraitVo.Trend trend = new StudentPortraitVo.Trend(); + for (PortraitRawData.WeekRow row : raw.weeklyRows) + { + StudentPortraitVo.Trend.WeeklyPoint point = new StudentPortraitVo.Trend.WeeklyPoint(row.week); + if (row.expected > 0) + { + point.setAttendanceRate(new BigDecimal(percentage(row.present, row.expected))); + } + point.setStudyHours(hours(row.studySeconds).setScale(1, RoundingMode.HALF_UP)); + point.setAvgScore(row.avgScore == null ? null : row.avgScore.setScale(1, RoundingMode.HALF_UP)); + trend.getWeekly().add(point); + } + for (PortraitRawData.PeriodRow row : raw.periodRows) + { + StudentPortraitVo.Trend.PeriodPoint point = new StudentPortraitVo.Trend.PeriodPoint(); + point.setPlanName(row.planName); + point.setOverallScore(row.overallScore == null ? null : row.overallScore.setScale(1, RoundingMode.HALF_UP)); + point.setAttendanceRate(row.attendanceRate == null ? null : row.attendanceRate.setScale(1, RoundingMode.HALF_UP)); + point.setStudyHours(row.studyHours == null ? null : row.studyHours.setScale(1, RoundingMode.HALF_UP)); + trend.getPeriods().add(point); + } + return trend; + } + + /* ================================================================== + * 八、元信息 + * ================================================================== */ + + private static StudentPortraitVo.Meta buildMeta(PortraitRawData raw, + List dims) + { + StudentPortraitVo.Meta meta = new StudentPortraitVo.Meta(); + int available = 0; + for (StudentPortraitVo.Dimension dim : dims) + { + if (Boolean.TRUE.equals(dim.getAvailable())) + { + available++; + } + if (Boolean.FALSE.equals(dim.getAvailable()) && dim.getScore() == null) + { + meta.getWarnings().add(dim.getName() + ":数据不足,未参与综合评分"); + } + } + meta.setCompleteness(Math.round(available * 100f / dims.size())); + meta.setMetricVersion(METRIC_VERSION); + Date cutoff = raw.lastSyncTime != null ? raw.lastSyncTime + : (raw.lastStudyTime != null ? raw.lastStudyTime : new Date()); + meta.setDataCutoff(new SimpleDateFormat("yyyy-MM-dd HH:mm").format(cutoff)); + return meta; + } + + /* ================================================================== + * 通用工具 + * ================================================================== */ + + private static void addMetric(StudentPortraitVo.Dimension dim, String key, String label, + String value, String unit) + { + dim.getMetrics().add(new StudentPortraitVo.Metric(key, label, + value == null || value.isEmpty() ? "-" : value, unit)); + } + + private static Integer dimScore(List dims, String code) + { + for (StudentPortraitVo.Dimension dim : dims) + { + if (code.equals(dim.getCode())) + { + return dim.getScore(); + } + } + return null; + } + + private static BigDecimal overallScore(PortraitRawData raw) + { + if (raw.weightSum != null && raw.weightSum.compareTo(ZERO) > 0 && raw.weightedScore != null) + { + return raw.weightedScore.multiply(new BigDecimal("100")) + .divide(raw.weightSum, 0, RoundingMode.HALF_UP); + } + BigDecimal theory = raw.theoryAvg; + BigDecimal practice = raw.practiceAvg; + if (theory != null && practice != null) + { + return theory.add(practice).divide(new BigDecimal("2"), 0, RoundingMode.HALF_UP); + } + return theory != null ? theory : practice; + } + + private static int timeProgress(PortraitRawData raw) + { + if (raw.planStart == null || raw.planEnd == null) + { + return 0; + } + long total = raw.planEnd.getTime() - raw.planStart.getTime(); + if (total <= 0) + { + return 100; + } + long elapsed = System.currentTimeMillis() - raw.planStart.getTime(); + long clamped = Math.max(0, Math.min(elapsed, total)); + return (int) Math.round(clamped * 100.0 / total); + } + + private static int percentage(int numerator, int denominator) + { + if (denominator <= 0 || numerator < 0) + { + return 0; + } + return clamp((int) Math.round(numerator * 100.0 / denominator)); + } + + private static String pct(int numerator, int denominator) + { + return denominator <= 0 ? "-" : String.valueOf(percentage(numerator, denominator)); + } + + private static String pct(double numerator, double denominator) + { + if (denominator <= 0) + { + return "-"; + } + return String.valueOf((int) Math.round(numerator * 100.0 / denominator)); + } + + private static double avg(double sum, int count) + { + return count <= 0 ? 0d : sum / count; + } + + private static String decimal(double value) + { + return BigDecimal.valueOf(value).setScale(1, RoundingMode.HALF_UP).toPlainString(); + } + + private static String dec(BigDecimal value) + { + return value == null ? "-" : value.setScale(1, RoundingMode.HALF_UP).toPlainString(); + } + + private static BigDecimal hours(long seconds) + { + return BigDecimal.valueOf(seconds) + .divide(new BigDecimal("3600"), 2, RoundingMode.HALF_UP); + } + + private static BigDecimal average(List values) + { + if (values == null || values.isEmpty()) + { + return null; + } + BigDecimal sum = ZERO; + int count = 0; + for (BigDecimal value : values) + { + if (value != null) + { + sum = sum.add(value); + count++; + } + } + return count == 0 ? null : sum.divide(new BigDecimal(count), 1, RoundingMode.HALF_UP); + } + + private static int clamp(int value) + { + return Math.max(0, Math.min(100, value)); + } + + private static String format(Date date) + { + return date == null ? "-" : new SimpleDateFormat("yyyy-MM-dd HH:mm").format(date); + } + + private static String nullToDash(String value) + { + return value == null || value.trim().isEmpty() ? "-" : value; + } + + private static String disciplineLevel(int score) + { + return score >= 90 ? "优" : (score >= 75 ? "良" : (score >= 60 ? "中" : "差")); + } + + private static String scoreLevel(BigDecimal overall) + { + if (overall == null) + { + return "数据不足"; + } + int value = overall.intValue(); + return value >= 85 ? "优" : (value >= 70 ? "良" : (value >= 60 ? "及格" : "待提升")); + } + + private static String overallLevel(int score) + { + return score >= 85 ? "A" : (score >= 70 ? "B" : (score >= 60 ? "C" : "D")); + } + + private static String sourceTypeName(Integer sourceType) + { + if (sourceType == null) + { + return "-"; + } + switch (sourceType) + { + case 1: return "管理员录入"; + case 2: return "Excel批量导入"; + case 3: return "内部平台同步"; + default: return "未知"; + } + } + + private static String enrollmentStatusName(Integer status) + { + if (status == null) + { + return "-"; + } + switch (status) + { + case 0: return "待报到"; + case 1: return "已报到"; + case 2: return "请假中"; + case 3: return "已放弃"; + case 4: return "已完成"; + default: return "未知"; + } + } + + private static String accommodationStatusName(Integer status) + { + if (status == null) + { + return "未安排"; + } + switch (status) + { + case 0: return "预分配"; + case 1: return "已入住"; + case 2: return "已退宿"; + default: return "未知"; + } + } + + private static String dormAuthStatusName(Integer status) + { + if (status == null) + { + return "未下发"; + } + switch (status) + { + case 0: return "待下发"; + case 1: return "已下发"; + case 2: return "下发失败"; + case 3: return "已注销"; + default: return "未知"; + } + } + + private static boolean isFaceReady(String extractStatus) + { + return extractStatus != null + && ("1".equals(extractStatus) || "SUCCESS".equalsIgnoreCase(extractStatus) + || "DONE".equalsIgnoreCase(extractStatus)); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PortraitRawData.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PortraitRawData.java new file mode 100644 index 000000000..8fec3cf40 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PortraitRawData.java @@ -0,0 +1,120 @@ +package com.ruoyi.train.service.impl; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * 画像计算的原始数据载体(仅内部使用,不做对外序列化)。 + * + *

字段全部来自现有事实表的只读聚合,用于把“取数”与“算分”解耦,便于纯逻辑单测。

+ */ +public class PortraitRawData +{ + /* ---------- 参训上下文 ---------- */ + public String studentId; + public Long enrollmentId; + public String planId; + public String planName; + public Date planStart; + public Date planEnd; + public Integer enrollmentStatus; + public Date joinTime; + public Date completeTime; + public int courseCount; + public BigDecimal plannedHours = BigDecimal.ZERO; + + /* ---------- 考勤纪律 ---------- */ + public int expectedLessons; + public int presentLessons; + public int normalLessons; + public int lateLessons; + public int earlyLessons; + public int leaveLessons; + public int absentLessons; + public int proxySignCount; + public int manualCheckinCount; + public double leadMinutesSum; + public int leadMinutesCount; + public double inClassRateSum; + public int inClassRateCount; + public int maxConsecutiveAbsence; + + /* ---------- 学习投入 ---------- */ + public long studySeconds; + public int studyActiveDays; + public int studyRecordCount; + public int studyMaxConsecutiveDays; + public int learnedResourceCount; + public int courseResourceCount; + public Date lastStudyTime; + public Date lastSyncTime; + + /* ---------- 考核能力 ---------- */ + public int theoryCount; + public int theoryScoredCount; + public BigDecimal theoryAvg; + public int theoryPassCount; + public int theoryAbsentCount; + public int practiceCount; + public BigDecimal practiceAvg; + public int practicePassCount; + public int practiceRetakeCount; + public BigDecimal weightedScore; + public BigDecimal weightSum = BigDecimal.ZERO; + public int failedCourseCount; + public BigDecimal retakeImprovement; + public List weakCourseNames = new ArrayList<>(); + + /* ---------- 进度完成 ---------- */ + public int totalLessons; + public int doneLessons; + public int examCompletedCourses; + + /* ---------- 组织归属 ---------- */ + public String deptName; + public Integer sourceType; + public int teacherCount; + public int dormMateCount; + public Integer accommodationStatus; + + /* ---------- 身份与数据质量 ---------- */ + public int profileFilledFields; + public int profileTotalFields = 6; + public boolean accountBound; + public String extractStatus; + public String cardNo; + public Integer faceSyncStatus; + public int attendanceManualCount; + + /* ---------- 班级基线(同班有效样本) ---------- */ + public String classScope = "本班期"; + public List classAttendanceRates = new ArrayList<>(); + public List classScores = new ArrayList<>(); + public List classStudyHours = new ArrayList<>(); + public List classActivityScores = new ArrayList<>(); + + /* ---------- 轻量趋势 ---------- */ + public List weeklyRows = new ArrayList<>(); + public List periodRows = new ArrayList<>(); + + /** 周粒度原始分桶 */ + public static class WeekRow + { + public String week; + public int expected; + public int present; + public long studySeconds; + public BigDecimal avgScore; + } + + /** 跨班期趋势行 */ + public static class PeriodRow + { + public String planName; + public BigDecimal overallScore; + public BigDecimal attendanceRate; + public BigDecimal studyHours; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java index 650440087..cf5e15497 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java @@ -181,7 +181,7 @@ public class ReportTerminalServiceImpl implements ReportTerminalService vo.setStudentId(student.getsId()); vo.setStudentName(student.getsName()); vo.setGender(student.getsGender()); - vo.setOrgName(student.getOrgName()); + vo.setDeptName(student.getDeptName()); vo.setPhone(student.getsPhone()); vo.setIdCardMasked(student.getIdCardLast4() != null ? student.getIdCardLast4() : "-"); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java index f24ef4d5d..e0ff18c52 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java @@ -421,7 +421,7 @@ public class SchedulingAppServiceImpl implements SchedulingAppService Map item = new LinkedHashMap<>(); item.put("tId", teacher.gettId()); item.put("tName", teacher.gettName()); - item.put("orgName", teacher.getOrgName()); + item.put("deptName", teacher.getDeptName()); list.add(item); } return list; diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/StudentImportServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/StudentImportServiceImpl.java new file mode 100644 index 000000000..90288bb27 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/StudentImportServiceImpl.java @@ -0,0 +1,414 @@ +package com.ruoyi.train.service.impl; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +import com.ruoyi.train.domain.vo.BatchImportResultVO; +import com.ruoyi.train.domain.vo.StudentImportVO; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.train.service.StudentImportService; + +/** + * 学员主档批量导入实现。 + * + *

校验层次:① 文件类型/大小;② 模板表头完整性;③ 逐行字段格式与唯一性。 + * 单行失败不影响其它行(逐行独立事务),全部失败明细按 Excel 行号返回。

+ * + * @author ruoyi + */ +@Service +public class StudentImportServiceImpl implements StudentImportService +{ + private static final Logger log = LoggerFactory.getLogger(StudentImportServiceImpl.class); + + /** 单个文件上限 10MB,与参训报名导入保持一致 */ + private static final long MAX_FILE_SIZE = 10L * 1024L * 1024L; + + /** 单次导入行数上限 */ + private static final int MAX_IMPORT_ROWS = 500; + + /** Excel 批量导入来源类型 (train_student.source_type) */ + private static final int IMPORT_SOURCE_TYPE = 2; + + /** 模板必备表头,与 StudentImportVO 的 @Excel(name) 严格一致 */ + private static final String[] TEMPLATE_HEADERS = { + "学员姓名", "人员编号", "性别", "出生日期", "所属单位", + "联系电话", "邮箱", "身份证号", "门禁卡号", "备注" + }; + + private static final Pattern PERSON_NO_PATTERN = Pattern.compile("^AA\\d{6}$"); + private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.-]+@[\\w-]+(\\.[\\w-]+)+$"); + private static final Pattern ID_CARD_PATTERN = Pattern.compile("^[0-9]{17}[0-9X]$"); + + @Autowired + private ITrainStudentService trainStudentService; + + @Autowired + private TrainStudentMapper trainStudentMapper; + + @Override + public BatchImportResultVO importStudents(MultipartFile file, String operator) + { + // ① 文件类型与大小 + if (file == null || file.isEmpty()) + { + throw new ServiceException("请选择要导入的学员 Excel 文件", HttpStatus.BAD_REQUEST); + } + if (file.getSize() > MAX_FILE_SIZE) + { + throw new ServiceException("文件大小不能超过 10MB", HttpStatus.BAD_REQUEST); + } + String fileName = StringUtils.isEmpty(file.getOriginalFilename()) ? "" : file.getOriginalFilename(); + String lowerName = fileName.toLowerCase(Locale.ROOT); + if (!lowerName.endsWith(".xls") && !lowerName.endsWith(".xlsx")) + { + throw new ServiceException("仅支持 .xls 或 .xlsx 格式的 Excel 文件", HttpStatus.BAD_REQUEST); + } + + byte[] content; + try + { + content = file.getBytes(); + } + catch (Exception e) + { + throw new ServiceException("文件读取失败,请重新上传", HttpStatus.BAD_REQUEST); + } + + // ② 模板表头校验 + validateTemplateHeader(content); + + // ③ 解析数据行 + List rows = parseRows(content); + if (rows.isEmpty()) + { + throw new ServiceException("模板中没有可导入的数据行", HttpStatus.BAD_REQUEST); + } + if (rows.size() > MAX_IMPORT_ROWS) + { + throw new ServiceException("单次导入不得超过 " + MAX_IMPORT_ROWS + " 行,请拆分后重试", HttpStatus.BAD_REQUEST); + } + + int successCount = 0; + int failureCount = 0; + List errorList = new ArrayList<>(); + for (int i = 0; i < rows.size(); i++) + { + // Excel 行号:第 1 行为表头,数据从第 2 行开始 + int rowNum = i + 2; + StudentImportVO row = rows.get(i); + if (isBlankRow(row)) + { + continue; + } + try + { + StudentCreateDTO dto = toCreateDTO(row); + trainStudentService.createStudent(dto, IMPORT_SOURCE_TYPE, operator); + successCount++; + } + catch (ServiceException e) + { + failureCount++; + errorList.add("第 " + rowNum + " 行: " + e.getMessage()); + } + catch (Exception e) + { + failureCount++; + errorList.add("第 " + rowNum + " 行: 导入失败(" + e.getMessage() + ")"); + log.error("学员导入第 {} 行处理异常", rowNum, e); + } + } + int totalCount = successCount + failureCount; + log.info("学员批量导入完成: operator={}, total={}, success={}, failure={}", operator, totalCount, successCount, failureCount); + return new BatchImportResultVO(totalCount, successCount, failureCount, errorList); + } + + @Override + public List templateRows() + { + StudentImportVO example = new StudentImportVO(); + example.setName("张三"); + example.setPersonNo(""); + example.setGender("男"); + example.setBirth("1990-01-01"); + example.setDeptName("沈阳供电段"); + example.setPhone("13800000000"); + example.setEmail("zhangsan@example.com"); + example.setIdCard("210102199001011234"); + example.setCardNo(""); + example.setRemark("示例行,导入前请删除"); + List list = new ArrayList<>(1); + list.add(example); + return list; + } + + /** + * 校验表头:所有模板列必须存在(顺序不限,ExcelUtil 按列名匹配)。 + */ + private void validateTemplateHeader(byte[] content) + { + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) + { + Sheet sheet = workbook.getSheetAt(0); + if (sheet == null) + { + throw new ServiceException("Excel 中没有可读取的工作表", HttpStatus.BAD_REQUEST); + } + Row header = sheet.getRow(0); + if (header == null) + { + throw new ServiceException("导入模板格式不正确:缺少表头行,请下载标准模板", HttpStatus.BAD_REQUEST); + } + DataFormatter formatter = new DataFormatter(); + Set actual = new HashSet<>(); + for (int i = 0; i < header.getLastCellNum(); i++) + { + Cell cell = header.getCell(i); + if (cell != null) + { + actual.add(formatter.formatCellValue(cell).trim()); + } + } + List missing = new ArrayList<>(); + for (String expected : TEMPLATE_HEADERS) + { + if (!actual.contains(expected)) + { + missing.add(expected); + } + } + if (!missing.isEmpty()) + { + throw new ServiceException("导入模板格式不正确,缺少列:" + String.join("、", missing) + ",请重新下载标准模板", + HttpStatus.BAD_REQUEST); + } + } + catch (ServiceException e) + { + throw e; + } + catch (Exception e) + { + throw new ServiceException("文件解析失败,请确认上传的是有效的 Excel 文件", HttpStatus.BAD_REQUEST); + } + } + + /** + * 复用框架 ExcelUtil 完成按表头映射的解析。 + */ + private List parseRows(byte[] content) + { + try (InputStream is = new ByteArrayInputStream(content)) + { + ExcelUtil util = new ExcelUtil(StudentImportVO.class); + List rows = util.importExcel(is, 0); + return rows == null ? new ArrayList<>() : rows; + } + catch (Exception e) + { + log.error("学员导入 Excel 解析失败", e); + throw new ServiceException("Excel 内容解析失败,请核对模板数据后重试", HttpStatus.BAD_REQUEST); + } + } + + /** + * 行数据 -> 建档 DTO,逐字段做格式校验。 + */ + private StudentCreateDTO toCreateDTO(StudentImportVO row) + { + String sName = trimToNull(row.getName()); + if (sName == null) + { + throw new ServiceException("学员姓名不能为空"); + } + if (sName.length() > 50) + { + throw new ServiceException("学员姓名长度不能超过50个字符"); + } + + StudentCreateDTO dto = new StudentCreateDTO(); + dto.setsName(sName); + + String personNo = trimToNull(normalizeNumericText(row.getPersonNo())); + if (personNo != null) + { + if (!PERSON_NO_PATTERN.matcher(personNo).matches()) + { + throw new ServiceException("人员编号须为 AA 开头加6位数字(共8位)"); + } + if (trainStudentMapper.countByPersonNo(personNo) > 0) + { + throw new ServiceException("人员编号已存在:" + personNo); + } + dto.setPersonNo(personNo); + } + + dto.setsGender(normalizeGender(row.getGender())); + dto.setsBirth(parseBirth(row.getBirth())); + + String deptName = trimToNull(row.getDeptName()); + if (deptName != null && deptName.length() > 100) + { + throw new ServiceException("所属单位长度不能超过100个字符"); + } + dto.setDeptName(deptName); + + String phone = trimToNull(normalizeNumericText(row.getPhone())); + if (phone != null && !PHONE_PATTERN.matcher(phone).matches()) + { + throw new ServiceException("联系电话格式不正确(应为11位手机号)"); + } + dto.setsPhone(phone); + + String email = trimToNull(row.getEmail()); + if (email != null && !EMAIL_PATTERN.matcher(email).matches()) + { + throw new ServiceException("邮箱格式不正确"); + } + dto.setsEmail(email); + + String idCard = trimToNull(normalizeNumericText(row.getIdCard())); + if (idCard != null) + { + String normalized = idCard.toUpperCase(Locale.ROOT); + if (!ID_CARD_PATTERN.matcher(normalized).matches()) + { + throw new ServiceException("身份证号格式不正确(应为18位)"); + } + dto.setIdCard(normalized); + } + + dto.setCardNo(trimToNull(normalizeNumericText(row.getCardNo()))); + dto.setRemark(trimToNull(row.getRemark())); + return dto; + } + + /** + * 性别归一化为字典值 0=男 1=女 2=未知。 + */ + private String normalizeGender(String raw) + { + String value = trimToNull(raw); + if (value == null) + { + return null; + } + if ("男".equals(value) || "0".equals(value) || "M".equalsIgnoreCase(value)) + { + return "0"; + } + if ("女".equals(value) || "1".equals(value) || "F".equalsIgnoreCase(value)) + { + return "1"; + } + if ("未知".equals(value) || "2".equals(value)) + { + return "2"; + } + throw new ServiceException("性别只能填写 男 / 女 / 未知"); + } + + private Date parseBirth(String raw) + { + String value = trimToNull(raw); + if (value == null) + { + return null; + } + try + { + Date date = DateUtils.parseDate(value); + if (date == null) + { + throw new ServiceException("出生日期格式应为 yyyy-MM-dd"); + } + return date; + } + catch (ServiceException e) + { + throw e; + } + catch (Exception e) + { + throw new ServiceException("出生日期格式应为 yyyy-MM-dd"); + } + } + + private boolean isBlankRow(StudentImportVO row) + { + return trimToNull(row.getName()) == null + && trimToNull(row.getPersonNo()) == null + && trimToNull(row.getGender()) == null + && trimToNull(row.getBirth()) == null + && trimToNull(row.getDeptName()) == null + && trimToNull(row.getPhone()) == null + && trimToNull(row.getEmail()) == null + && trimToNull(row.getIdCard()) == null + && trimToNull(row.getCardNo()) == null + && trimToNull(row.getRemark()) == null; + } + + /** + * 兼容 Excel 数值单元格:科学计数法还原为纯数字、去掉尾部 .0。 + */ + private String normalizeNumericText(String value) + { + if (value == null) + { + return null; + } + String trimmed = value.trim(); + if (trimmed.matches("^\\d+(\\.\\d+)?E\\+?\\d+$")) + { + try + { + return new java.math.BigDecimal(trimmed).toPlainString(); + } + catch (NumberFormatException ignored) + { + return trimmed; + } + } + if (trimmed.matches("^\\d+\\.0$")) + { + return trimmed.substring(0, trimmed.length() - 2); + } + return trimmed; + } + + private String trimToNull(String value) + { + if (value == null) + { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/StudentPortraitServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/StudentPortraitServiceImpl.java new file mode 100644 index 000000000..e5aeed4f5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/StudentPortraitServiceImpl.java @@ -0,0 +1,755 @@ +package com.ruoyi.train.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.train.domain.TrainStudentPortrait; +import com.ruoyi.train.domain.analysis.StudentPortraitVo; +import com.ruoyi.train.mapper.TrainPortraitAnalyticsMapper; +import com.ruoyi.train.mapper.TrainPortraitSnapshotMapper; +import com.ruoyi.train.service.IStudentPortraitService; + +/** + * 学员画像扩展服务实现:取数(只读聚合)→ 算分(纯函数)→ 回写快照(异常不影响查询)。 + * + * @author ruoyi + */ +@Service +public class StudentPortraitServiceImpl implements IStudentPortraitService +{ + private static final Logger log = LoggerFactory.getLogger(StudentPortraitServiceImpl.class); + + /** 周趋势窗口(周数) */ + private static final int TREND_WEEKS = 8; + + /** 跨班期趋势期数 */ + private static final int TREND_PERIODS = 3; + + /** 班级分位最小样本 */ + private static final int CLASS_SAMPLE_THRESHOLD = 10; + + @Autowired + private TrainPortraitAnalyticsMapper analyticsMapper; + + @Autowired + private TrainPortraitSnapshotMapper portraitSnapshotMapper; + + @Override + public StudentPortraitVo getPortrait(String studentId) + { + if (studentId == null || studentId.trim().isEmpty()) + { + return emptyPortrait("缺少学员ID,无法生成画像"); + } + Map context = analyticsMapper.selectEnrollmentContext(studentId); + if (context == null || context.isEmpty() || context.get("enrollment_id") == null) + { + return emptyPortrait("该学员暂无参训记录,画像维度不可用"); + } + + PortraitRawData raw = new PortraitRawData(); + raw.studentId = studentId; + fillContext(raw, context); + fillCourseAggregate(raw); + fillAttendance(raw); + fillStudy(raw); + fillScores(raw, studentId); + fillExamCompletion(raw); + fillDorm(raw, studentId); + fillClassBaseline(raw); + fillTrend(raw); + + StudentPortraitVo portrait = PortraitCalculator.build(raw); + portrait.getClassCompare().setSufficient( + portrait.getClassCompare().getSampleSize() >= CLASS_SAMPLE_THRESHOLD); + saveSnapshot(raw, portrait); + return portrait; + } + + /* ================================ 取数 ================================ */ + + private void fillContext(PortraitRawData raw, Map context) + { + raw.enrollmentId = toLong(context.get("enrollment_id")); + raw.planId = toStr(context.get("plan_id")); + raw.planName = toStr(context.get("plan_name")); + raw.planStart = toDate(context.get("plan_start")); + raw.planEnd = toDate(context.get("plan_end")); + raw.enrollmentStatus = toInt(context.get("enrollment_status")); + raw.joinTime = toDate(context.get("join_time")); + raw.completeTime = toDate(context.get("complete_time")); + raw.deptName = toStr(context.get("dept_name")); + raw.sourceType = toInt(context.get("source_type")); + raw.cardNo = toStr(context.get("card_no")); + raw.extractStatus = toStr(context.get("extract_status")); + raw.accountBound = context.get("user_id") != null; + + int filled = 0; + String[] fields = { toStr(context.get("student_name")), toStr(context.get("gender")), + context.get("birth") == null ? null : "1", toStr(context.get("phone")), + raw.deptName, toStr(context.get("id_card_last4")) }; + for (String field : fields) + { + if (field != null && !field.trim().isEmpty()) + { + filled++; + } + } + raw.profileFilledFields = filled; + raw.profileTotalFields = fields.length; + } + + private void fillCourseAggregate(PortraitRawData raw) + { + Map aggregate = analyticsMapper.selectCourseAggregate(raw.planId); + if (aggregate == null) + { + return; + } + raw.courseCount = toIntValue(aggregate.get("course_count")); + raw.plannedHours = toDecimal(aggregate.get("planned_hours")); + raw.teacherCount = toIntValue(aggregate.get("teacher_count")); + raw.totalLessons = toIntValue(aggregate.get("total_lessons")); + raw.doneLessons = toIntValue(aggregate.get("done_lessons")); + raw.courseResourceCount = toIntValue(aggregate.get("course_resource_count")); + } + + private void fillAttendance(PortraitRawData raw) + { + List> rows = analyticsMapper.selectLessonAttendanceRows(raw.planId, raw.enrollmentId); + if (rows == null || rows.isEmpty()) + { + return; + } + raw.expectedLessons = rows.size(); + int streak = 0; + int maxStreak = 0; + for (Map row : rows) + { + Integer status = toInt(row.get("attendance_status")); + Date startTime = toDate(row.get("start_time")); + Date endTime = toDate(row.get("end_time")); + Date signIn = toDate(row.get("sign_in_time")); + Date signOut = toDate(row.get("sign_out_time")); + if (status == null || status == 0) + { + raw.absentLessons++; + streak++; + maxStreak = Math.max(maxStreak, streak); + continue; + } + streak = 0; + if (status == 1) + { + raw.normalLessons++; + } + else if (status == 2) + { + raw.lateLessons++; + } + else if (status == 3) + { + raw.earlyLessons++; + } + else if (status == 4) + { + raw.leaveLessons++; + continue; + } + raw.presentLessons++; + Integer way = toInt(row.get("checkin_way")); + if (way != null && way == 4) + { + raw.proxySignCount++; + } + Integer manual = toInt(row.get("manual_flag")); + if (manual != null && manual == 1) + { + raw.manualCheckinCount++; + } + if (startTime != null && signIn != null) + { + raw.leadMinutesSum += (startTime.getTime() - signIn.getTime()) / 60000.0; + raw.leadMinutesCount++; + } + if (startTime != null && endTime != null && signIn != null && signOut != null) + { + long lessonSpan = endTime.getTime() - startTime.getTime(); + long stay = signOut.getTime() - signIn.getTime(); + if (lessonSpan > 0 && stay >= 0) + { + raw.inClassRateSum += Math.min(1.0, stay / (double) lessonSpan); + raw.inClassRateCount++; + } + } + } + raw.maxConsecutiveAbsence = maxStreak; + raw.attendanceManualCount = raw.manualCheckinCount; + } + + private void fillStudy(PortraitRawData raw) + { + List> rows = analyticsMapper.selectStudyRows(raw.enrollmentId); + if (rows == null || rows.isEmpty()) + { + return; + } + raw.studyRecordCount = rows.size(); + Set days = new HashSet<>(); + Set resources = new HashSet<>(); + Date lastSync = null; + for (Map row : rows) + { + raw.studySeconds += toLongValue(row.get("study_seconds")); + Date start = toDate(row.get("start_time")); + if (start != null) + { + days.add(dayKey(start)); + if (raw.lastStudyTime == null || start.after(raw.lastStudyTime)) + { + raw.lastStudyTime = start; + } + } + String resourceId = toStr(row.get("resource_id")); + if (resourceId != null && !resourceId.trim().isEmpty()) + { + resources.add(resourceId); + } + Date sync = toDate(row.get("sync_time")); + if (sync != null && (lastSync == null || sync.after(lastSync))) + { + lastSync = sync; + } + } + raw.studyActiveDays = days.size(); + raw.learnedResourceCount = resources.size(); + raw.lastSyncTime = lastSync; + raw.studyMaxConsecutiveDays = maxConsecutiveDays(days); + } + + private void fillScores(PortraitRawData raw, String studentId) + { + Map theory = analyticsMapper.selectTheorySummary(studentId); + if (theory != null) + { + raw.theoryCount = toIntValue(theory.get("record_count")); + raw.theoryScoredCount = toIntValue(theory.get("scored_count")); + raw.theoryAvg = toDecimal(theory.get("avg_score")); + raw.theoryPassCount = toIntValue(theory.get("pass_count")); + raw.theoryAbsentCount = toIntValue(theory.get("absent_count")); + } + Map practice = analyticsMapper.selectPracticeSummary(studentId); + if (practice != null) + { + raw.practiceCount = toIntValue(practice.get("record_count")); + raw.practiceAvg = toDecimal(practice.get("avg_score")); + raw.practicePassCount = toIntValue(practice.get("pass_count")); + raw.practiceRetakeCount = toIntValue(practice.get("retake_count")); + } + Map weighted = analyticsMapper.selectWeightedScore(raw.enrollmentId); + if (weighted != null) + { + raw.weightedScore = toDecimal(weighted.get("weighted_score")); + raw.weightSum = toDecimal(weighted.get("weight_sum")); + } + fillScoreHistory(raw, studentId); + } + + /** + * 按课程聚合成绩历史:末次成绩用于不及格与薄弱课程,首末差用于补考提升。 + */ + private void fillScoreHistory(PortraitRawData raw, String studentId) + { + List> rows = analyticsMapper.selectScoreHistory(studentId); + if (rows == null || rows.isEmpty()) + { + return; + } + Map>> byCourse = new LinkedHashMap<>(); + for (Map row : rows) + { + String courseId = toStr(row.get("c_id")); + String key = courseId == null ? "-" : courseId; + byCourse.computeIfAbsent(key, k -> new ArrayList<>()).add(row); + } + int failed = 0; + BigDecimal improvementSum = BigDecimal.ZERO; + int improvementCount = 0; + List> weakCandidates = new ArrayList<>(); + for (List> courseRows : byCourse.values()) + { + BigDecimal sum = BigDecimal.ZERO; + int count = 0; + BigDecimal first = null; + BigDecimal last = null; + String courseName = null; + for (Map row : courseRows) + { + BigDecimal score = toDecimal(row.get("score")); + if (courseName == null) + { + courseName = toStr(row.get("c_name")); + } + if (score == null) + { + continue; + } + sum = sum.add(score); + count++; + if (first == null) + { + first = score; + } + last = score; + } + if (count == 0) + { + continue; + } + BigDecimal avg = sum.divide(new BigDecimal(count), 1, RoundingMode.HALF_UP); + if (avg.compareTo(new BigDecimal("60")) < 0) + { + failed++; + weakCandidates.add(new java.util.AbstractMap.SimpleEntry<>(courseName, avg)); + } + if (count > 1 && first != null && last != null) + { + improvementSum = improvementSum.add(last.subtract(first)); + improvementCount++; + } + } + raw.failedCourseCount = failed; + raw.retakeImprovement = improvementCount > 0 + ? improvementSum.divide(new BigDecimal(improvementCount), 1, RoundingMode.HALF_UP) + : null; + weakCandidates.sort(Map.Entry.comparingByValue()); + for (int i = 0; i < Math.min(2, weakCandidates.size()); i++) + { + String name = weakCandidates.get(i).getKey(); + raw.weakCourseNames.add(name == null ? "未知课程" : name); + } + } + + private void fillExamCompletion(PortraitRawData raw) + { + Map row = analyticsMapper.selectExamCompletion(raw.enrollmentId); + if (row != null) + { + raw.examCompletedCourses = toIntValue(row.get("exam_completed_courses")); + } + } + + private void fillDorm(PortraitRawData raw, String studentId) + { + Map dorm = analyticsMapper.selectDormContext(studentId, raw.planId); + if (dorm == null || dorm.isEmpty()) + { + return; + } + raw.faceSyncStatus = toInt(dorm.get("face_sync_status")); + raw.accommodationStatus = toInt(dorm.get("accommodation_status")); + raw.dormMateCount = toIntValue(dorm.get("dorm_mates")); + } + + /** + * 班级对比基线:出勤率、理论均分、学习学时;活跃度按「出勤 70% + 学时达成 30%」近似。 + */ + private void fillClassBaseline(PortraitRawData raw) + { + Map attendanceMap = new HashMap<>(); + Map scoreMap = new HashMap<>(); + Map studyMap = new HashMap<>(); + + List> attendanceRows = analyticsMapper.selectClassAttendance(raw.planId); + if (attendanceRows != null) + { + for (Map row : attendanceRows) + { + String sid = toStr(row.get("student_id")); + int expected = toIntValue(row.get("expected")); + int present = toIntValue(row.get("present")); + if (sid != null && expected > 0) + { + attendanceMap.put(sid, new BigDecimal(Math.round(present * 100.0 / expected))); + } + } + } + List> scoreRows = analyticsMapper.selectClassScores(raw.planId); + if (scoreRows != null) + { + for (Map row : scoreRows) + { + String sid = toStr(row.get("student_id")); + BigDecimal avg = toDecimal(row.get("avg_score")); + if (sid != null && avg != null) + { + scoreMap.put(sid, avg); + } + } + } + List> studyRows = analyticsMapper.selectClassStudy(raw.planId); + if (studyRows != null) + { + for (Map row : studyRows) + { + String sid = toStr(row.get("student_id")); + if (sid != null) + { + BigDecimal hours = BigDecimal.valueOf(toLongValue(row.get("total_seconds"))) + .divide(new BigDecimal("3600"), 2, RoundingMode.HALF_UP); + studyMap.put(sid, hours); + } + } + } + + raw.classAttendanceRates = new ArrayList<>(attendanceMap.values()); + raw.classScores = new ArrayList<>(scoreMap.values()); + raw.classStudyHours = new ArrayList<>(studyMap.values()); + raw.classActivityScores = approximateActivityScores(attendanceMap, studyMap, raw.plannedHours); + } + + private List approximateActivityScores(Map attendanceMap, + Map studyMap, BigDecimal plannedHours) + { + List scores = new ArrayList<>(); + for (Map.Entry entry : attendanceMap.entrySet()) + { + BigDecimal hours = studyMap.get(entry.getKey()); + int studyScore = 0; + if (hours != null) + { + studyScore = plannedHours != null && plannedHours.compareTo(BigDecimal.ZERO) > 0 + ? Math.min(100, hours.multiply(new BigDecimal("100")) + .divide(plannedHours, 0, RoundingMode.HALF_UP).intValue()) + : Math.min(100, hours.intValue()); + } + scores.add(BigDecimal.valueOf(Math.round(entry.getValue().doubleValue() * 0.7 + studyScore * 0.3))); + } + return scores; + } + + /** + * 轻量趋势:最近 8 周按周聚合出勤与学时;跨班期取最近 3 期。 + */ + private void fillTrend(PortraitRawData raw) + { + List> lessonRows = analyticsMapper.selectLessonAttendanceRows(raw.planId, raw.enrollmentId); + List> studyRows = analyticsMapper.selectStudyRows(raw.enrollmentId); + + Map attendanceByWeek = new LinkedHashMap<>(); + if (lessonRows != null) + { + for (Map row : lessonRows) + { + Date start = toDate(row.get("start_time")); + if (start == null) + { + continue; + } + String key = weekKey(start); + int[] bucket = attendanceByWeek.computeIfAbsent(key, k -> new int[2]); + bucket[0]++; + Integer status = toInt(row.get("attendance_status")); + if (status != null && (status == 1 || status == 2 || status == 3)) + { + bucket[1]++; + } + } + } + Map studyByWeek = new LinkedHashMap<>(); + if (studyRows != null) + { + for (Map row : studyRows) + { + Date start = toDate(row.get("start_time")); + if (start == null) + { + continue; + } + String key = weekKey(start); + studyByWeek.merge(key, toLongValue(row.get("study_seconds")), Long::sum); + } + } + + Set weekKeys = new HashSet<>(); + weekKeys.addAll(attendanceByWeek.keySet()); + weekKeys.addAll(studyByWeek.keySet()); + List sortedWeeks = new ArrayList<>(weekKeys); + Collections.sort(sortedWeeks); + if (sortedWeeks.size() > TREND_WEEKS) + { + sortedWeeks = new ArrayList<>(sortedWeeks.subList(sortedWeeks.size() - TREND_WEEKS, sortedWeeks.size())); + } + for (String week : sortedWeeks) + { + PortraitRawData.WeekRow row = new PortraitRawData.WeekRow(); + row.week = week; + int[] bucket = attendanceByWeek.get(week); + if (bucket != null) + { + row.expected = bucket[0]; + row.present = bucket[1]; + } + Long seconds = studyByWeek.get(week); + row.studySeconds = seconds == null ? 0L : seconds; + raw.weeklyRows.add(row); + } + + List> periodRows = analyticsMapper.selectPeriodRows(raw.studentId); + if (periodRows != null) + { + List collected = new ArrayList<>(); + for (Map row : periodRows) + { + PortraitRawData.PeriodRow period = new PortraitRawData.PeriodRow(); + period.planName = toStr(row.get("plan_name")); + period.overallScore = toDecimal(row.get("overall_score")); + period.attendanceRate = toDecimal(row.get("attendance_rate")); + period.studyHours = toDecimal(row.get("study_hours")); + collected.add(period); + } + if (collected.size() > TREND_PERIODS) + { + collected = new ArrayList<>(collected.subList(collected.size() - TREND_PERIODS, collected.size())); + } + raw.periodRows = collected; + } + } + + /* ============================== 快照回写 ============================== */ + + /** + * 回写既有画像快照表(单期一条)。失败只记日志,绝不影响查询结果。 + */ + private void saveSnapshot(PortraitRawData raw, StudentPortraitVo portrait) + { + if (raw.enrollmentId == null) + { + return; + } + try + { + TrainStudentPortrait snapshot = new TrainStudentPortrait(); + snapshot.setEnrollmentId(raw.enrollmentId); + snapshot.setAttendanceRate(BigDecimal.valueOf( + raw.expectedLessons > 0 ? Math.round(raw.presentLessons * 100.0 / raw.expectedLessons) : 0)); + snapshot.setTheoryScore(raw.theoryAvg); + snapshot.setPracticalScore(raw.practiceAvg); + snapshot.setTotalStudyHour(BigDecimal.valueOf(raw.studySeconds) + .divide(new BigDecimal("3600"), 2, RoundingMode.HALF_UP)); + snapshot.setLabelTag(joinTagNames(portrait)); + snapshot.setPortraitContent(summaryText(portrait)); + snapshot.setGeneratedTime(new Date()); + + TrainStudentPortrait exist = portraitSnapshotMapper.selectPortraitByEnrollmentId(raw.enrollmentId); + if (exist == null) + { + snapshot.setCreateBy("portrait-engine"); + portraitSnapshotMapper.insertPortrait(snapshot); + } + else + { + snapshot.setPortraitId(exist.getPortraitId()); + snapshot.setUpdateBy("portrait-engine"); + portraitSnapshotMapper.updatePortrait(snapshot); + } + } + catch (Exception e) + { + log.warn("学员画像快照回写失败, enrollmentId={}, reason={}", raw.enrollmentId, e.getMessage()); + } + } + + private String joinTagNames(StudentPortraitVo portrait) + { + if (portrait.getTags() == null || portrait.getTags().isEmpty()) + { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (StudentPortraitVo.Tag tag : portrait.getTags()) + { + if (builder.length() > 0) + { + builder.append(','); + } + builder.append(tag.getName()); + } + return builder.toString(); + } + + private String summaryText(StudentPortraitVo portrait) + { + String layer = portrait.getLayer() == null ? "-" : portrait.getLayer().getName(); + String activity = portrait.getActivity() == null || portrait.getActivity().getScore() == null + ? "数据不足" : portrait.getActivity().getScore() + "(" + portrait.getActivity().getLevel() + ")"; + String risk = portrait.getRisk() == null ? "-" : portrait.getRisk().getScore() + "(" + portrait.getRisk().getLevel() + ")"; + String tags = joinTagNames(portrait); + return "分层:" + layer + " | 活跃度:" + activity + " | 风险:" + risk + + (tags.isEmpty() ? "" : " | 标签:" + tags); + } + + /* ================================ 工具 ================================ */ + + private StudentPortraitVo emptyPortrait(String warning) + { + StudentPortraitVo vo = new StudentPortraitVo(); + vo.setLayer(new StudentPortraitVo.Layer("UNKNOWN", "数据不足", warning)); + StudentPortraitVo.Risk risk = new StudentPortraitVo.Risk(); + risk.setScore(0); + risk.setLevel("低"); + vo.setRisk(risk); + StudentPortraitVo.Activity activity = new StudentPortraitVo.Activity(); + activity.setLevel("数据不足"); + vo.setActivity(activity); + StudentPortraitVo.ClassCompare compare = new StudentPortraitVo.ClassCompare(); + compare.setScope("本班期"); + compare.setSampleSize(0); + compare.setSufficient(false); + vo.setClassCompare(compare); + vo.setTrend(new StudentPortraitVo.Trend()); + StudentPortraitVo.Meta meta = new StudentPortraitVo.Meta(); + meta.setCompleteness(0); + meta.setMetricVersion(PortraitCalculator.METRIC_VERSION); + meta.setDataCutoff(new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date())); + meta.getWarnings().add(warning); + vo.setMeta(meta); + return vo; + } + + private static String dayKey(Date date) + { + return new SimpleDateFormat("yyyy-MM-dd").format(date); + } + + /** + * 以周一为一周起点,返回周起始日期(yyyy-MM-dd)。 + */ + private static String weekKey(Date date) + { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); + int offset = (dayOfWeek + 5) % 7; + calendar.add(Calendar.DAY_OF_MONTH, -offset); + return new SimpleDateFormat("yyyy-MM-dd").format(calendar.getTime()); + } + + private static int maxConsecutiveDays(Set days) + { + if (days == null || days.isEmpty()) + { + return 0; + } + List sorted = new ArrayList<>(days); + Collections.sort(sorted); + int max = 1; + int current = 1; + for (int i = 1; i < sorted.size(); i++) + { + LocalDate prev = LocalDate.parse(sorted.get(i - 1)); + LocalDate curr = LocalDate.parse(sorted.get(i)); + if (prev.plusDays(1).equals(curr)) + { + current++; + max = Math.max(max, current); + } + else + { + current = 1; + } + } + return max; + } + + private static Long toLong(Object value) + { + if (value == null) + { + return null; + } + if (value instanceof Number) + { + return ((Number) value).longValue(); + } + try + { + return Long.valueOf(String.valueOf(value)); + } + catch (NumberFormatException e) + { + return null; + } + } + + private static long toLongValue(Object value) + { + Long result = toLong(value); + return result == null ? 0L : result; + } + + private static Integer toInt(Object value) + { + Long result = toLong(value); + return result == null ? null : result.intValue(); + } + + private static int toIntValue(Object value) + { + Integer result = toInt(value); + return result == null ? 0 : result; + } + + private static String toStr(Object value) + { + return value == null ? null : String.valueOf(value); + } + + private static BigDecimal toDecimal(Object value) + { + if (value == null) + { + return null; + } + if (value instanceof BigDecimal) + { + return (BigDecimal) value; + } + if (value instanceof Number) + { + return BigDecimal.valueOf(((Number) value).doubleValue()); + } + try + { + return new BigDecimal(String.valueOf(value)); + } + catch (NumberFormatException e) + { + return null; + } + } + + private static Date toDate(Object value) + { + if (value instanceof Date) + { + return (Date) value; + } + return null; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TheoryScoreImportServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TheoryScoreImportServiceImpl.java new file mode 100644 index 000000000..e46f2a763 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TheoryScoreImportServiceImpl.java @@ -0,0 +1,1657 @@ +package com.ruoyi.train.service.impl; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.SheetVisibility; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainExamSubject; +import com.ruoyi.train.domain.TrainPlanCourse; +import com.ruoyi.train.domain.dto.TrainTheoryExamScoreDTO; +import com.ruoyi.train.domain.query.EnrollmentQuery; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.PlanCourseItemVO; +import com.ruoyi.train.domain.vo.TheoryScoreImportResultVO; +import com.ruoyi.train.domain.vo.TheoryScoreImportRowVO; +import com.ruoyi.train.domain.vo.TrainTheoryExamScoreVO; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainExamSubjectMapper; +import com.ruoyi.train.mapper.TrainPlanCourseMapper; +import com.ruoyi.train.mapper.TrainTheoryExamScoreMapper; +import com.ruoyi.train.service.ITrainTheoryExamScoreService; +import com.ruoyi.train.service.TheoryScoreImportService; + +/** + * 理论考试成绩批量导入服务实现(新增) + * + *

模板设计:

+ *
    + *
  • 头部展示业务信息与统一考试参数(培训班、考试课程、默认满分、科目编码、统一考试开始/结束时间);
  • + *
  • 模板版本、班级ID、课程实例ID 等机器识别信息写入隐藏 Sheet「模板信息」,不干扰用户;
  • + *
  • 成绩表按班级在册学员整表预填并按学号升序排列,用户只需填写成绩等少量列;
  • + *
  • 学员匹配依据「学号(train_student.person_no,可空) + 姓名」,非在册学员一律拒绝。
  • + *
+ */ +@Service +public class TheoryScoreImportServiceImpl implements TheoryScoreImportService +{ + private static final Logger log = LoggerFactory.getLogger(TheoryScoreImportServiceImpl.class); + + /** 模板版本(写入隐藏 Sheet,仅用于防串模板;列结构变更时同步升级) */ + private static final String TEMPLATE_VERSION = "V3"; + + /** 数据 Sheet 名称 */ + private static final String SHEET_DATA = "成绩导入"; + + /** 隐藏 Sheet 名称(模板机器信息) */ + private static final String SHEET_HIDDEN = "模板信息"; + + /** 指引 Sheet 名称 */ + private static final String SHEET_GUIDE = "填写指引"; + + /** 模板标题 */ + private static final String TEMPLATE_TITLE = "理论考试成绩导入模板"; + + /** 模板表头(全部为业务字段,不含系统ID;学号为首列,同时作为数据区排序依据;考试时间统一在头部设置) */ + private static final String[] HEADERS = { + "学号", "学员姓名", "考试状态", "考试成绩", "是否通过", + "是否补考", "补考序号", "备注" + }; + + /** 隐藏 Sheet 中的上下文键 */ + private static final String KEY_VERSION = "模板版本"; + private static final String KEY_PLAN_ID = "培训班ID"; + private static final String KEY_PLAN_COURSE_ID = "课程实例ID"; + + /** 头部可见上下文键 */ + private static final String KEY_FULL_SCORE = "默认满分"; + private static final String KEY_SUBJECT_CODE = "科目编码"; + /** 头部统一考试时间键(对本表全部学员生效) */ + private static final String KEY_EXAM_START_TIME = "考试开始时间"; + private static final String KEY_EXAM_END_TIME = "考试结束时间"; + + /** 必填表头(缺少则整包拒绝) */ + private static final String[] REQUIRED_HEADERS = { "学员姓名", "考试成绩" }; + + /** 必填列标识后缀(生成模板时追加到表头,解析时自动去除) */ + private static final String REQUIRED_MARK = "*"; + + /** 模板文件格式 */ + private static final String FORMAT_XLSX = "xlsx"; + private static final String FORMAT_XLS = "xls"; + + /** 各文件格式对应的 Content-Type 与扩展名 */ + private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final String CONTENT_TYPE_XLS = "application/vnd.ms-excel"; + + /** 单文件大小上限:10MB */ + private static final long MAX_FILE_SIZE = 10L * 1024 * 1024; + + /** 单次导入行数上限 */ + private static final int MAX_ROWS = 500; + + /** 默认满分 */ + private static final BigDecimal DEFAULT_FULL_SCORE = new BigDecimal("100.00"); + + /** + * 所有学员行统一预填的字段值(与首行示例保持一致): + * 考试状态、是否通过、是否补考、补考序号 + */ + private static final String DEFAULT_EXAM_STATUS = "NORMAL"; + private static final String DEFAULT_PASS_FLAG = "1"; + private static final String DEFAULT_RETAKE = "0"; + private static final String DEFAULT_RETAKE_NO = "0"; + + /** 默认源系统标识 */ + private static final String SOURCE_SYSTEM = "ADMIN_IMPORT"; + + /** 冲突策略 */ + private static final String MODE_SKIP = "SKIP"; + private static final String MODE_OVERWRITE = "OVERWRITE"; + private static final String MODE_STRICT = "STRICT"; + + /** 考试状态 */ + private static final String EXAM_STATUS_NORMAL = "NORMAL"; + private static final String EXAM_STATUS_ABSENT = "ABSENT"; + private static final String EXAM_STATUS_EXEMPT = "EXEMPT"; + + /** 成绩状态-已锁定(覆盖导入时禁止修改) */ + private static final String SCORE_STATUS_LOCKED = "LOCKED"; + + /** 参训状态-放弃 */ + private static final Integer ENROLLMENT_STATUS_ABANDON = Integer.valueOf(3); + + private static final String[] TIME_PATTERNS = { + "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", + "yyyy-MM-dd", "yyyy/MM/dd" + }; + + @Autowired + private TrainClassPlanMapper trainClassPlanMapper; + + @Autowired + private TrainPlanCourseMapper trainPlanCourseMapper; + + @Autowired + private TrainEnrollmentMapper trainEnrollmentMapper; + + @Autowired + private TrainTheoryExamScoreMapper trainTheoryExamScoreMapper; + + @Autowired + private TrainExamSubjectMapper trainExamSubjectMapper; + + /** 复用既有理论成绩服务的新增/修改能力(不改动其实现) */ + @Autowired + private ITrainTheoryExamScoreService trainTheoryExamScoreService; + + /* ====================================================================== + * 一、模板下载 + * ====================================================================== */ + + @Override + public void exportTemplate(HttpServletResponse response, String planId, Long planCourseId, + boolean strictStatus, String fileFormat) throws IOException + { + if (StringUtils.isEmpty(planId)) + { + throw new ServiceException("请先选择培训班"); + } + if (planCourseId == null) + { + throw new ServiceException("请先选择考试课程"); + } + + // 模板固定输出 xlsx,用户无需选择文件格式;fileFormat 入参仅保留以兼容既有前端/接口 + // String format = normalizeFileFormat(fileFormat); + String format = FORMAT_XLSX; + ImportContext context = buildContext(planId, planCourseId); + List enrollments = loadEnrollments(planId, strictStatus); + if (enrollments.isEmpty()) + { + throw new ServiceException(strictStatus + ? "所选培训班暂无已报到/已完成的在册学员,无法生成导入模板" + : "所选培训班暂无在册学员(不含放弃),无法生成导入模板"); + } + // 数据区严格按学号升序排列,每名在册学员占一行,无学号者置后但不丢弃 + sortByPersonNo(enrollments); + + Workbook workbook = createWorkbook(format); + buildTemplateWorkbook(workbook, context, enrollments); + String fileName = buildFileName(context, format); + try + { + response.reset(); + response.setContentType(FORMAT_XLS.equals(format) ? CONTENT_TYPE_XLS : CONTENT_TYPE_XLSX); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-Disposition", + "attachment; filename*=utf-8''" + URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20")); + workbook.write(response.getOutputStream()); + } + finally + { + workbook.close(); + } + } + + /** + * 按格式创建空工作簿(xlsx / xls 分别使用 XSSF / HSSF) + */ + private Workbook createWorkbook(String format) + { + return FORMAT_XLS.equals(format) ? new HSSFWorkbook() : new XSSFWorkbook(); + } + + /** + * 组装模板工作簿:可视头部(班级/课程/默认满分/科目编码)+ 全班学员名单 + 隐藏信息 + 填写指引 + */ + private Workbook buildTemplateWorkbook(Workbook workbook, ImportContext context, List enrollments) + { + Sheet sheet = workbook.createSheet(SHEET_DATA); + + CellStyle titleStyle = createTitleStyle(workbook); + CellStyle labelStyle = createLabelStyle(workbook); + CellStyle valueStyle = createValueStyle(workbook); + CellStyle headerStyle = createHeaderStyle(workbook); + CellStyle requiredHeaderStyle = createRequiredHeaderStyle(workbook); + CellStyle readonlyStyle = createReadonlyStyle(workbook); + CellStyle editableStyle = createEditableStyle(workbook); + + int[] widths = { 16, 14, 12, 12, 12, 12, 12, 28 }; + for (int i = 0; i < widths.length; i++) + { + sheet.setColumnWidth(i, widths[i] * 256); + } + + // 标题 + Row titleRow = sheet.createRow(0); + titleRow.setHeightInPoints(30); + Cell titleCell = titleRow.createCell(0); + titleCell.setCellValue(TEMPLATE_TITLE); + titleCell.setCellStyle(titleStyle); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, HEADERS.length - 1)); + + // 头部:业务信息(不含导出时间) + writeContextRow(sheet, 1, labelStyle, valueStyle, + "培训班", safeText(context.getPlanName(), context.getPlanId()), + "考试课程", safeText(context.getCourseName(), ""), + "学员人数", String.valueOf(enrollments.size())); + // 默认满分与科目编码:可修改,对本表所有学员统一生效 + writeContextRow(sheet, 2, labelStyle, editableStyle, + KEY_FULL_SCORE, DEFAULT_FULL_SCORE.toPlainString(), + KEY_SUBJECT_CODE, ""); + // 统一考试时间:对本表所有学员统一生效,导入时写入每条成绩记录 + writeContextRow(sheet, 3, labelStyle, editableStyle, + KEY_EXAM_START_TIME, "", + KEY_EXAM_END_TIME, ""); + + // 提示 + Row tipRow = sheet.createRow(4); + Cell tipCell = tipRow.createCell(0); + tipCell.setCellValue("提示:带 * 的列为必填;上方「考试开始时间/考试结束时间」为本表统一考试时间," + + "将写入每位学员的成绩记录;下方数据区按学号升序排列,只需填写「考试成绩」;灰色列请勿修改。"); + tipCell.setCellStyle(valueStyle); + sheet.addMergedRegion(new CellRangeAddress(4, 4, 0, HEADERS.length - 1)); + + // 表头 + int headerRowIndex = 5; + Row headerRow = sheet.createRow(headerRowIndex); + headerRow.setHeightInPoints(22); + for (int i = 0; i < HEADERS.length; i++) + { + Cell cell = headerRow.createCell(i); + boolean required = isRequiredHeader(HEADERS[i]); + cell.setCellValue(required ? HEADERS[i] + REQUIRED_MARK : HEADERS[i]); + cell.setCellStyle(required ? requiredHeaderStyle : headerStyle); + } + + // 数据区:本班全部在册学员逐行写入,学号为首列并按学号升序排列; + // 所有学员行完全同构:考试状态/是否通过/是否补考/补考序号统一预填默认值, + // 考试成绩/备注统一留空待填(无成绩学员同样保留行,不跳过、不空缺); + // 考试时间不再逐行填写,统一取头部「考试开始时间/考试结束时间」 + int rowIndex = headerRowIndex + 1; + for (EnrollmentItemVO enrollment : enrollments) + { + Row row = sheet.createRow(rowIndex++); + int col = 0; + writeCell(row, col++, safeText(enrollment.getPersonNo(), ""), readonlyStyle); + writeCell(row, col++, safeText(enrollment.getStudentName(), ""), readonlyStyle); + writeCell(row, col++, DEFAULT_EXAM_STATUS, editableStyle); + writeCell(row, col++, "", editableStyle); + writeCell(row, col++, DEFAULT_PASS_FLAG, editableStyle); + writeCell(row, col++, DEFAULT_RETAKE, editableStyle); + writeCell(row, col++, DEFAULT_RETAKE_NO, editableStyle); + writeCell(row, col, "", editableStyle); + } + + sheet.createFreezePane(0, headerRowIndex + 1); + + // 隐藏 Sheet:保存模板版本与班级/课程ID,用户不可见,仅用于上传时防串模板 + buildHiddenSheet(workbook, context); + buildGuideSheet(workbook); + return workbook; + } + + /** + * 隐藏 Sheet(放在引导页之后创建并隐藏,不影响用户操作) + */ + private void buildHiddenSheet(Workbook workbook, ImportContext context) + { + Sheet hidden = workbook.createSheet(SHEET_HIDDEN); + int rowIndex = 0; + rowIndex = writeHiddenRow(hidden, rowIndex, KEY_VERSION, TEMPLATE_VERSION); + rowIndex = writeHiddenRow(hidden, rowIndex, KEY_PLAN_ID, context.getPlanId()); + rowIndex = writeHiddenRow(hidden, rowIndex, KEY_PLAN_COURSE_ID, String.valueOf(context.getPlanCourseId())); + writeHiddenRow(hidden, rowIndex, "课程ID", safeText(context.getCId(), "")); + workbook.setSheetVisibility(workbook.getSheetIndex(hidden), SheetVisibility.HIDDEN); + } + + private int writeHiddenRow(Sheet sheet, int rowIndex, String key, String value) + { + Row row = sheet.createRow(rowIndex); + row.createCell(0).setCellValue(key); + row.createCell(1).setCellValue(value == null ? "" : value); + return rowIndex + 1; + } + + /** + * 按「键, 值, 键, 值...」写入一行头部信息,支持 1~3 组键值对 + */ + private void writeContextRow(Sheet sheet, int rowIndex, CellStyle labelStyle, CellStyle valueStyle, + String... keyValues) + { + Row row = sheet.createRow(rowIndex); + for (int i = 0; i + 1 < keyValues.length; i += 2) + { + Cell keyCell = row.createCell(i); + keyCell.setCellValue(keyValues[i] == null ? "" : keyValues[i]); + keyCell.setCellStyle(labelStyle); + Cell valueCell = row.createCell(i + 1); + valueCell.setCellValue(keyValues[i + 1] == null ? "" : keyValues[i + 1]); + valueCell.setCellStyle(valueStyle); + } + } + + private void writeCell(Row row, int col, String value, CellStyle style) + { + Cell cell = row.createCell(col); + cell.setCellValue(value == null ? "" : value); + cell.setCellStyle(style); + } + + /** + * 填写指引 Sheet + */ + private void buildGuideSheet(Workbook workbook) + { + Sheet guide = workbook.createSheet(SHEET_GUIDE); + guide.setColumnWidth(0, 120 * 256); + String[] lines = { + "一、使用步骤", + "1. 在班级与课程确定后再下载模板:数据区首列为学号,按学号升序逐行排列," + + "每名在册学员占一行(含暂无成绩的学员),不留空行;", + "2. 在模板头部填写「考试开始时间」「考试结束时间」(yyyy-MM-dd HH:mm:ss)," + + "该时间将统一写入本表每位学员的成绩记录;", + "3. 所有学员行的考试状态/是否通过/是否补考/补考序号已统一预填(NORMAL/1/0/0)," + + "只需逐行填写「考试成绩」;各行处理规则完全一致;", + "4. 未填写成绩、且未调整上述默认值的行会被自动跳过;需要导入的行请至少填写考试成绩;", + "5. 如某学员实际为缺考、免修或补考,请按实际情况调整对应列的预填值后再导入;", + "6. 保存后回到系统,选择同一班级与课程上传,系统按「学号 + 姓名」匹配在册学员。", + "", + "二、头部信息", + "默认满分:对本表所有学员统一生效,默认 100.00,可按考试实际调整;", + "科目编码:选填,填写后统一写入本表所有学员成绩记录;", + "考试开始时间/考试结束时间:统一考试时间,写入本表每位学员的成绩记录,结束时间必须晚于开始时间;", + "学员人数:本模板预填的在册学员数量。", + "", + "三、字段说明", + "带 * 的列为必填:学员姓名(预填)、考试成绩(正常考试时必填);", + "学号:数据区首列与排序依据,取报名记录关联学员的人员编号(train_student.person_no)," + + "预填不可修改;为空时排在本班末位并按姓名匹配;", + "学员姓名:预填不可修改,用于与学号交叉校验;", + "考试状态:NORMAL/正常(默认)、ABSENT/缺考、EXEMPT/免修;缺考与免修不得填写成绩;", + "考试成绩:数字,最多两位小数,范围 0 ~ 默认满分;", + "是否通过:1/通过(默认)或 0/未通过,留空时系统按得分率 60% 自动判定;", + "是否补考:0/否(正考,默认)、1/是;补考序号:正考填 0(默认),第 n 次补考填 n;", + "", + "四、匹配与校验规则", + "1. 仅允许导入“所选培训班在册”的学员成绩,非在册学员整行拒绝;", + "2. 填写了学号时,姓名必须与学号对应的学员一致;未填写学号时按姓名匹配,同名学员需补充学号;", + "3. 参训状态为“放弃”的学员不允许导入;", + "4. 同一学员同一课程的同一考试次数只允许一条记录;", + "5. 重复数据处理:跳过(默认)/ 覆盖(已锁定成绩不可覆盖)/ 严格(重复即失败)。" + }; + for (int i = 0; i < lines.length; i++) + { + Row row = guide.createRow(i); + row.createCell(0).setCellValue(lines[i]); + } + } + + private CellStyle createTitleStyle(Workbook workbook) + { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + font.setFontHeightInPoints((short) 14); + style.setFont(font); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + return style; + } + + private CellStyle createLabelStyle(Workbook workbook) + { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } + + private CellStyle createValueStyle(Workbook workbook) + { + CellStyle style = workbook.createCellStyle(); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } + + private CellStyle createHeaderStyle(Workbook workbook) + { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } + + /** + * 必填列表头样式:红字加粗 + 灰底 + 边框,与普通表头区分 + */ + private CellStyle createRequiredHeaderStyle(Workbook workbook) + { + CellStyle style = createHeaderStyle(workbook); + Font font = workbook.createFont(); + font.setBold(true); + font.setColor(IndexedColors.RED.getIndex()); + style.setFont(font); + return style; + } + + /** + * 是否为必填列(用于生成 * 标识) + */ + private boolean isRequiredHeader(String header) + { + for (String required : REQUIRED_HEADERS) + { + if (required.equals(header)) + { + return true; + } + } + return false; + } + + private CellStyle createReadonlyStyle(Workbook workbook) + { + CellStyle style = createValueStyle(workbook); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setDataFormat(workbook.createDataFormat().getFormat("@")); + return style; + } + + private CellStyle createEditableStyle(Workbook workbook) + { + CellStyle style = createValueStyle(workbook); + style.setDataFormat(workbook.createDataFormat().getFormat("@")); + return style; + } + + private String buildFileName(ImportContext context, String format) + { + String time = new SimpleDateFormat("yyyyMMddHHmm").format(new Date()); + String fileName = "理论成绩导入_" + safeText(context.getPlanName(), context.getPlanId()) + "_" + + safeText(context.getCourseName(), "") + "_" + time + "." + format; + // 去除文件名非法字符,避免中文课程名中的特殊符号导致下载失败 + return fileName.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + /* ====================================================================== + * 二、上传解析与逐行导入 + * ====================================================================== */ + + @Override + public TheoryScoreImportResultVO importData(MultipartFile file, String planId, Long planCourseId, String importMode) + { + validateFile(file); + if (StringUtils.isEmpty(planId) || planCourseId == null) + { + throw new ServiceException("请先选择培训班与考试课程后再上传"); + } + + ImportContext context = buildContext(planId, planCourseId); + String mode = normalizeMode(importMode); + + TheoryScoreImportResultVO result = new TheoryScoreImportResultVO(); + List rows; + try (InputStream inputStream = file.getInputStream(); Workbook workbook = WorkbookFactory.create(inputStream)) + { + if (workbook.getNumberOfSheets() == 0) + { + throw new ServiceException("文件中没有工作表,请使用系统模板"); + } + rows = parseRows(workbook, context); + } + catch (ServiceException e) + { + throw e; + } + catch (Exception e) + { + log.warn("理论成绩导入文件解析失败: {}", e.getMessage()); + throw new ServiceException("文件解析失败,请确认使用系统模板且文件未加密/损坏"); + } + + if (rows.isEmpty()) + { + throw new ServiceException("模板中没有可导入的数据行"); + } + if (rows.size() > MAX_ROWS) + { + throw new ServiceException("单次导入不能超过 " + MAX_ROWS + " 行,请拆分后重试"); + } + + // 在册学员索引与已有成绩(用于“仅允许本班在册学员”的匹配判定) + context.setEnrollmentByName(loadEnrollmentNameIndex(planId)); + context.setEnrollmentByStudentId(loadEnrollmentStudentIdIndex(planId)); + context.setEnrollmentByPersonNo(loadEnrollmentPersonNoIndex(planId)); + context.setExistingMap(loadExistingScoreMap(planId, planCourseId)); + + result.setTotalCount(rows.size()); + Set fileKeys = new LinkedHashSet<>(); + for (TheoryScoreImportRowVO row : rows) + { + importRow(row, context, result, mode, fileKeys); + } + + result.setMessage(String.format("导入完成:共 %d 行,新增 %d 条,覆盖 %d 条,跳过 %d 条,失败 %d 条", + result.getTotalCount(), result.getSuccessCount(), result.getUpdateCount(), + result.getSkippedCount(), result.getFailureCount())); + return result; + } + + /** + * 解析 Excel:定位表头 → 读取头部默认满分/科目编码 → 读取隐藏模板信息 → 逐行取值 + */ + private List parseRows(Workbook workbook, ImportContext context) + { + Sheet sheet = workbook.getSheet(SHEET_DATA) == null ? workbook.getSheetAt(0) : workbook.getSheet(SHEET_DATA); + int lastRowNum = sheet.getLastRowNum(); + int headerRowIndex = -1; + for (int i = 0; i <= Math.min(lastRowNum, 30); i++) + { + Row row = sheet.getRow(i); + if (row == null) + { + continue; + } + // 表头可能带必填标识(如“学员姓名*”),统一规范化后比较 + String first = normalizeHeader(cellText(row.getCell(0))); + String second = normalizeHeader(cellText(row.getCell(1))); + String third = normalizeHeader(cellText(row.getCell(2))); + if (HEADERS[0].equals(first) && HEADERS[1].equals(second) && HEADERS[2].equals(third)) + { + headerRowIndex = i; + break; + } + } + if (headerRowIndex < 0) + { + throw new ServiceException("未识别到模板表头,请重新下载系统模板后填写"); + } + + // 头部可见信息:默认满分、科目编码、统一考试时间(对本表全部学员生效) + Map visibleContext = parseVisibleContext(sheet, headerRowIndex); + context.setFullScore(parseFullScore(visibleContext.get(KEY_FULL_SCORE))); + context.setSubjectCode(resolveSubjectCode(trimToNull(visibleContext.get(KEY_SUBJECT_CODE)), context.getPlanId())); + Date examStartTime = parseTime(visibleContext.get(KEY_EXAM_START_TIME), KEY_EXAM_START_TIME); + Date examEndTime = parseTime(visibleContext.get(KEY_EXAM_END_TIME), KEY_EXAM_END_TIME); + if (examStartTime != null && examEndTime != null && !examEndTime.after(examStartTime)) + { + throw new ServiceException(KEY_EXAM_END_TIME + ":结束时间必须晚于开始时间"); + } + context.setExamStartTime(examStartTime); + context.setExamEndTime(examEndTime); + + // 隐藏模板信息:版本与班级/课程归属,用于防串模板(缺失时按兼容处理,不阻断导入) + Map hiddenContext = parseHiddenContext(workbook); + String version = trimToNull(hiddenContext.get(KEY_VERSION)); + if (version != null && !TEMPLATE_VERSION.equals(version)) + { + throw new ServiceException("模板版本已过期(当前支持 " + TEMPLATE_VERSION + "),请重新下载"); + } + String filePlanId = trimToNull(hiddenContext.get(KEY_PLAN_ID)); + String filePlanCourseId = trimToNull(hiddenContext.get(KEY_PLAN_COURSE_ID)); + if (filePlanId != null && !filePlanId.equals(context.getPlanId())) + { + throw new ServiceException("模板所属培训班与当前选择不一致,请使用当前班级下载的模板"); + } + if (filePlanCourseId != null && !filePlanCourseId.equals(String.valueOf(context.getPlanCourseId()))) + { + throw new ServiceException("模板所属课程与当前选择不一致,请使用当前课程下载的模板"); + } + + // 表头映射 + Row headerRow = sheet.getRow(headerRowIndex); + Map headerIndex = new LinkedHashMap<>(); + for (int i = 0; i < headerRow.getLastCellNum(); i++) + { + String name = normalizeHeader(cellText(headerRow.getCell(i))); + if (StringUtils.isNotEmpty(name)) + { + headerIndex.put(name, i); + } + } + List missing = new ArrayList<>(); + for (String required : REQUIRED_HEADERS) + { + if (!headerIndex.containsKey(required)) + { + missing.add(required); + } + } + if (!missing.isEmpty()) + { + throw new ServiceException("模板缺少必需列:" + String.join("、", missing) + ",请重新下载模板"); + } + + List rows = new ArrayList<>(); + for (int i = headerRowIndex + 1; i <= lastRowNum; i++) + { + Row row = sheet.getRow(i); + if (row == null) + { + continue; + } + TheoryScoreImportRowVO vo = new TheoryScoreImportRowVO(); + vo.setRowNum(i + 1); + vo.setPersonNo(cellAt(row, headerIndex, "学号")); + vo.setStudentName(cellAt(row, headerIndex, "学员姓名")); + vo.setExamStatus(cellAt(row, headerIndex, "考试状态")); + vo.setScore(cellAt(row, headerIndex, "考试成绩")); + vo.setPassFlag(cellAt(row, headerIndex, "是否通过")); + vo.setRetake(cellAt(row, headerIndex, "是否补考")); + vo.setRetakeNo(cellAt(row, headerIndex, "补考序号")); + vo.setRemark(cellAt(row, headerIndex, "备注")); + + if (isBlankRow(vo)) + { + continue; + } + rows.add(vo); + } + return rows; + } + + /** + * 读取可视头部键值对(默认满分、科目编码、统一考试开始/结束时间) + */ + private Map parseVisibleContext(Sheet sheet, int headerRowIndex) + { + Map map = new HashMap<>(); + for (int i = 0; i < headerRowIndex; i++) + { + Row row = sheet.getRow(i); + if (row == null) + { + continue; + } + for (int c = 0; c < row.getLastCellNum(); c++) + { + String key = cellText(row.getCell(c)); + if (KEY_FULL_SCORE.equals(key) || KEY_SUBJECT_CODE.equals(key) + || KEY_EXAM_START_TIME.equals(key) || KEY_EXAM_END_TIME.equals(key)) + { + map.put(key, cellText(row.getCell(c + 1))); + } + } + } + return map; + } + + /** + * 读取隐藏 Sheet 中的模板机器信息 + */ + private Map parseHiddenContext(Workbook workbook) + { + Map map = new HashMap<>(); + Sheet hidden = workbook.getSheet(SHEET_HIDDEN); + if (hidden == null) + { + return map; + } + for (int i = 0; i <= hidden.getLastRowNum(); i++) + { + Row row = hidden.getRow(i); + if (row == null) + { + continue; + } + String key = cellText(row.getCell(0)); + if (StringUtils.isNotEmpty(key)) + { + map.put(key.trim(), cellText(row.getCell(1))); + } + } + return map; + } + + /** + * 解析头部默认满分(空则取 100.00) + */ + private BigDecimal parseFullScore(String raw) + { + String value = trimToNull(raw); + if (value == null) + { + return DEFAULT_FULL_SCORE; + } + BigDecimal fullScore; + try + { + fullScore = new BigDecimal(value.replace(",", "")); + } + catch (NumberFormatException e) + { + throw new ServiceException("默认满分:请填写纯数字(当前值 " + value + ")"); + } + if (fullScore.compareTo(BigDecimal.ZERO) <= 0) + { + throw new ServiceException("默认满分:必须大于 0"); + } + return fullScore; + } + + /** + * 解析并校验头部科目编码:为空表示不设置;填写时必须存在于科目字典且归属本班 + */ + private String resolveSubjectCode(String subjectCode, String planId) + { + if (subjectCode == null) + { + return null; + } + TrainExamSubject subject = trainExamSubjectMapper.selectTrainExamSubjectBySubjectCode(subjectCode); + if (subject == null) + { + throw new ServiceException("科目编码:科目编码不存在,请从考试科目字典中选择"); + } + if (StringUtils.isNotEmpty(subject.getPlanId()) && !subject.getPlanId().equals(planId)) + { + throw new ServiceException("科目编码:该科目不属于当前培训班"); + } + return subjectCode; + } + + /** + * 单行导入:按「学号 + 姓名」匹配在册学员 → 字段转换 → 冲突策略 → 复用既有服务入库 + */ + private void importRow(TheoryScoreImportRowVO row, ImportContext context, + TheoryScoreImportResultVO result, String mode, Set fileKeys) + { + String studentName = trimToNull(row.getStudentName()); + String personNo = trimToNull(row.getPersonNo()); + + // 1. 模板已预填全班学员:未填写任何成绩信息的行直接跳过,避免“只填部分学员”时报错。 + // 判断仅依据行内容(不依赖行号/序号),因此序号 1 与其它数据行完全同规则处理 + if (isUntouchedRow(row)) + { + result.setSkippedCount(result.getSkippedCount() + 1); + return; + } + + // 2. 在册匹配:学号(train_student.person_no)优先,未填学号时按姓名 + EnrollmentItemVO enrollment = matchEnrollment(row, studentName, personNo, context, result); + if (enrollment == null) + { + return; + } + if (ENROLLMENT_STATUS_ABANDON.equals(enrollment.getEnrollmentStatus())) + { + result.addError(row.getRowNum(), studentName, personNo, "学员姓名", studentName, + "该学员参训状态为「放弃」,不允许导入成绩"); + return; + } + + TrainTheoryExamScoreDTO dto; + try + { + dto = buildDto(row, context, enrollment); + } + catch (ServiceException e) + { + result.addError(row.getRowNum(), studentName, personNo, e.getMessage().split(":")[0], + row.getScore(), e.getMessage()); + return; + } + + // 3. 文件内重复(同一学员 + 同一考试次数) + String fileKey = enrollment.getStudentId() + "#" + dto.getRetakeNo(); + if (!fileKeys.add(fileKey)) + { + result.addError(row.getRowNum(), studentName, personNo, "学员姓名", studentName, + "与本文件中其他行重复(同一学员同一考试次数)"); + return; + } + + // 4. 库内重复:按策略处理 + String dbKey = enrollment.getEnrollmentId() + "#" + dto.getRetakeNo(); + TrainTheoryExamScoreVO existing = context.getExistingMap().get(dbKey); + try + { + if (existing != null) + { + if (MODE_SKIP.equals(mode)) + { + result.setSkippedCount(result.getSkippedCount() + 1); + return; + } + if (MODE_STRICT.equals(mode)) + { + result.addError(row.getRowNum(), studentName, personNo, "考试成绩", row.getScore(), + "该学员该课程的第 " + dto.getRetakeNo() + " 次考试成绩已存在"); + return; + } + if (SCORE_STATUS_LOCKED.equals(existing.getScoreStatus())) + { + result.addError(row.getRowNum(), studentName, personNo, "考试成绩", row.getScore(), + "该成绩已锁定,不允许覆盖"); + return; + } + dto.setTheoryScoreId(existing.getTheoryScoreId()); + trainTheoryExamScoreService.updateTrainTheoryExamScore(dto); + result.setUpdateCount(result.getUpdateCount() + 1); + return; + } + + trainTheoryExamScoreService.insertTrainTheoryExamScore(dto); + result.setSuccessCount(result.getSuccessCount() + 1); + } + catch (DuplicateKeyException e) + { + result.addError(row.getRowNum(), studentName, personNo, "考试成绩", row.getScore(), + "记录已存在(唯一约束冲突),请改用覆盖模式或检查数据"); + } + catch (ServiceException e) + { + result.addError(row.getRowNum(), studentName, personNo, "考试成绩", row.getScore(), e.getMessage()); + } + catch (Exception e) + { + log.error("理论成绩导入第 {} 行失败", row.getRowNum(), e); + result.addError(row.getRowNum(), studentName, personNo, "考试成绩", row.getScore(), + "导入失败:" + safeText(e.getMessage(), "系统异常")); + } + } + + /** + * 在册学员匹配: + * 1) 填写学号:按 train_student.person_no 精确定位(兼容历史模板中的学员ID),并要求姓名一致; + * 2) 未填学号:按姓名定位,同名多人时要求补充学号。 + */ + private EnrollmentItemVO matchEnrollment(TheoryScoreImportRowVO row, String studentName, String personNo, + ImportContext context, TheoryScoreImportResultVO result) + { + if (studentName == null && personNo == null) + { + result.addError(row.getRowNum(), "", "", "学员姓名", "", + "学员姓名不能为空(请使用系统模板填写成绩)"); + return null; + } + + if (personNo != null) + { + // 学号优先(train_student.person_no),未命中时兼容按学员ID(s_id)匹配历史模板 + List list = context.getEnrollmentByPersonNo().get(personNo); + if (list == null || list.isEmpty()) + { + list = context.getEnrollmentByStudentId().get(personNo); + } + if (list == null || list.isEmpty()) + { + result.addError(row.getRowNum(), studentName, personNo, "学号", personNo, + "学号 " + personNo + " 不在本培训班在册名单中(非在册学员),不允许导入成绩"); + return null; + } + if (list.size() > 1) + { + result.addError(row.getRowNum(), studentName, personNo, "学号", personNo, + "本班存在多个相同学号的学员档案,请联系管理员核对后再导入"); + return null; + } + EnrollmentItemVO enrollment = list.get(0); + if (studentName != null && !studentName.equals(trimToNull(enrollment.getStudentName()))) + { + result.addError(row.getRowNum(), studentName, personNo, "学员姓名", studentName, + "姓名与学号不匹配(学号 " + personNo + " 对应学员为 " + enrollment.getStudentName() + ")"); + return null; + } + return enrollment; + } + + List list = context.getEnrollmentByName().get(studentName); + if (list == null || list.isEmpty()) + { + result.addError(row.getRowNum(), studentName, "", "学员姓名", studentName, + "学员 " + studentName + " 不属于当前培训班(非在册学员),不允许导入成绩"); + return null; + } + if (list.size() > 1) + { + result.addError(row.getRowNum(), studentName, "", "学号", "", + "本班存在同名学员,请在学号列填写学号后重试"); + return null; + } + return list.get(0); + } + + /** + * 行数据 → 既有入库 DTO;满分、科目编码取模板头部统一值 + */ + private TrainTheoryExamScoreDTO buildDto(TheoryScoreImportRowVO row, ImportContext context, EnrollmentItemVO enrollment) + { + TrainTheoryExamScoreDTO dto = new TrainTheoryExamScoreDTO(); + dto.setEnrollmentId(enrollment.getEnrollmentId()); + dto.setPlanId(context.getPlanId()); + dto.setPlanCourseId(context.getPlanCourseId()); + dto.setCId(context.getCId()); + dto.setStudentId(enrollment.getStudentId()); + dto.setExamStatus(parseExamStatus(row.getExamStatus())); + dto.setScore(parseDecimal(row.getScore(), "考试成绩")); + dto.setFullScore(context.getFullScore()); + dto.setPassFlag(parseFlag(row.getPassFlag(), "是否通过")); + dto.setRetake(parseRetake(row.getRetake(), row.getRetakeNo())); + dto.setRetakeNo(parseRetakeNo(row.getRetakeNo())); + if (Integer.valueOf(1).equals(dto.getRetake()) && dto.getRetakeNo() == 0) + { + throw new ServiceException("补考序号:标记为补考时补考序号必须大于 0"); + } + if (Integer.valueOf(0).equals(dto.getRetake()) && dto.getRetakeNo() > 0) + { + throw new ServiceException("是否补考:补考序号大于 0 时是否补考必须为 1"); + } + // 考试时间:统一取模板头部设置,写入本表每条成绩记录 + dto.setExamStartTime(context.getExamStartTime()); + dto.setExamEndTime(context.getExamEndTime()); + + String remark = trimToNull(row.getRemark()); + if (remark != null && remark.length() > 500) + { + throw new ServiceException("备注:备注长度不能超过 500 个字符"); + } + dto.setRemark(remark); + + // 头部统一字段 + dto.setSubjectCode(context.getSubjectCode()); + + // 导入固定口径 + dto.setIsFinal(1); + dto.setScoreStatus("RECEIVED"); + dto.setSourceType(3); + dto.setSourceSystem(SOURCE_SYSTEM); + return dto; + } + + /* ====================================================================== + * 三、上下文与数据加载 + * ====================================================================== */ + + /** + * 校验并组装导入上下文(班级 + 课程实例 + 课程主档) + */ + private ImportContext buildContext(String planId, Long planCourseId) + { + TrainClassPlan classPlan = trainClassPlanMapper.selectTrainClassPlanByPlanId(planId); + if (classPlan == null) + { + throw new ServiceException("培训班不存在或已删除:" + planId); + } + TrainPlanCourse planCourse = trainPlanCourseMapper.selectTrainPlanCourseById(planCourseId); + if (planCourse == null || planCourse.getPlanId() == null || !planCourse.getPlanId().equals(planId)) + { + throw new ServiceException("所选课程不属于该培训班,请重新选择"); + } + + String courseName = null; + List planCourses = trainPlanCourseMapper.selectPlanCourseListByPlanId(planId); + if (planCourses != null) + { + for (PlanCourseItemVO item : planCourses) + { + if (planCourseId.equals(item.getPlanCourseId())) + { + courseName = item.getCName(); + break; + } + } + } + + ImportContext context = new ImportContext(); + context.setPlanId(planId); + context.setPlanCourseId(planCourseId); + context.setCId(planCourse.getCId()); + context.setPlanName(classPlan.getPlanName()); + context.setCourseName(courseName == null ? planCourse.getCId() : courseName); + context.setFullScore(DEFAULT_FULL_SCORE); + return context; + } + + /** + * 查询在册学员(排除放弃),供模板预填 + */ + private List loadEnrollments(String planId, boolean strictStatus) + { + EnrollmentQuery query = new EnrollmentQuery(); + query.setPlanId(planId); + List list = trainEnrollmentMapper.selectEnrollmentItemList(query); + List result = new ArrayList<>(); + if (list == null) + { + return result; + } + for (EnrollmentItemVO item : list) + { + if (ENROLLMENT_STATUS_ABANDON.equals(item.getEnrollmentStatus())) + { + continue; + } + if (strictStatus && !Integer.valueOf(1).equals(item.getEnrollmentStatus()) + && !Integer.valueOf(4).equals(item.getEnrollmentStatus())) + { + continue; + } + result.add(item); + } + return result; + } + + /** + * 按学号升序排列学员行(稳定排序;无学号者统一排在末尾,但保留其行,不丢弃) + * + * @param enrollments 待排序的学员名单 + */ + private void sortByPersonNo(List enrollments) + { + enrollments.sort((left, right) -> comparePersonNo(left.getPersonNo(), right.getPersonNo())); + } + + /** + * 学号比较:数字段按数值比较(AA2 排在 AA10 之前),其余按字符忽略大小写比较;空值排最后 + */ + private int comparePersonNo(String left, String right) + { + String x = trimToNull(left); + String y = trimToNull(right); + if (x == null && y == null) + { + return 0; + } + if (x == null) + { + return 1; + } + if (y == null) + { + return -1; + } + int i = 0; + int j = 0; + while (i < x.length() && j < y.length()) + { + char cx = x.charAt(i); + char cy = y.charAt(j); + if (Character.isDigit(cx) && Character.isDigit(cy)) + { + int startX = i; + int startY = j; + while (i < x.length() && Character.isDigit(x.charAt(i))) + { + i++; + } + while (j < y.length() && Character.isDigit(y.charAt(j))) + { + j++; + } + String nx = stripLeadingZeros(x.substring(startX, i)); + String ny = stripLeadingZeros(y.substring(startY, j)); + int cmp = nx.length() != ny.length() ? nx.length() - ny.length() : nx.compareTo(ny); + if (cmp != 0) + { + return cmp; + } + } + else + { + char lx = Character.toLowerCase(cx); + char ly = Character.toLowerCase(cy); + if (lx != ly) + { + return lx - ly; + } + i++; + j++; + } + } + return (x.length() - i) - (y.length() - j); + } + + /** + * 去掉数字段的前导 0,保证按数值而非字符长度比较 + */ + private String stripLeadingZeros(String digits) + { + int index = 0; + while (index < digits.length() - 1 && digits.charAt(index) == '0') + { + index++; + } + return digits.substring(index); + } + + /** + * 姓名索引:同名学员保留全部,导入时提示补充学号 + */ + private Map> loadEnrollmentNameIndex(String planId) + { + Map> map = new LinkedHashMap<>(); + for (EnrollmentItemVO item : loadEnrollments(planId, false)) + { + String name = trimToNull(item.getStudentName()); + if (name == null) + { + continue; + } + List list = map.get(name); + if (list == null) + { + list = new ArrayList<>(); + map.put(name, list); + } + list.add(item); + } + return map; + } + + /** + * 学员ID索引(train_student.s_id):仅用于兼容历史模板(学号列写入的是学员ID) + */ + private Map> loadEnrollmentStudentIdIndex(String planId) + { + Map> map = new LinkedHashMap<>(); + for (EnrollmentItemVO item : loadEnrollments(planId, false)) + { + String studentId = trimToNull(item.getStudentId()); + if (studentId == null) + { + continue; + } + List list = map.get(studentId); + if (list == null) + { + list = new ArrayList<>(); + map.put(studentId, list); + } + list.add(item); + } + return map; + } + + /** + * 学号索引(train_student.person_no):模板学号列的取值来源,也是导入匹配的首选键 + */ + private Map> loadEnrollmentPersonNoIndex(String planId) + { + Map> map = new LinkedHashMap<>(); + for (EnrollmentItemVO item : loadEnrollments(planId, false)) + { + String personNo = trimToNull(item.getPersonNo()); + if (personNo == null) + { + continue; + } + List list = map.get(personNo); + if (list == null) + { + list = new ArrayList<>(); + map.put(personNo, list); + } + list.add(item); + } + return map; + } + + /** + * 已存在成绩索引:参训档案ID + 考试次数 + */ + private Map loadExistingScoreMap(String planId, Long planCourseId) + { + Map map = new LinkedHashMap<>(); + TrainTheoryExamScoreVO query = new TrainTheoryExamScoreVO(); + query.setPlanId(planId); + query.setPlanCourseId(planCourseId); + List list = trainTheoryExamScoreMapper.selectTrainTheoryExamScoreList(query); + if (list == null) + { + return map; + } + for (TrainTheoryExamScoreVO item : list) + { + if (item.getEnrollmentId() == null) + { + continue; + } + String key = item.getEnrollmentId() + "#" + (item.getRetakeNo() == null ? 0 : item.getRetakeNo()); + TrainTheoryExamScoreVO exist = map.get(key); + if (exist == null || Integer.valueOf(1).equals(item.getIsFinal())) + { + map.put(key, item); + } + } + return map; + } + + /* ====================================================================== + * 四、单元格读取与类型转换 + * ====================================================================== */ + + private String cellAt(Row row, Map headerIndex, String header) + { + Integer index = headerIndex.get(header); + if (index == null) + { + return null; + } + return cellText(row.getCell(index)); + } + + /** + * 单元格统一转文本:数值去科学计数法、日期格式化、公式取缓存值 + */ + private String cellText(Cell cell) + { + if (cell == null) + { + return null; + } + CellType type = cell.getCellType(); + if (type == CellType.FORMULA) + { + type = cell.getCachedFormulaResultType(); + } + switch (type) + { + case STRING: + return trimToNull(cell.getStringCellValue()); + case NUMERIC: + if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) + { + return formatDate(cell.getDateCellValue()); + } + return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString(); + case BOOLEAN: + return cell.getBooleanCellValue() ? "1" : "0"; + case BLANK: + case _NONE: + case ERROR: + default: + return null; + } + } + + /** + * 判断是否为“未填写”的预填行:考试状态/是否通过/是否补考/补考序号均为模板默认值, + * 且未填写成绩与备注 + */ + private boolean isUntouchedRow(TheoryScoreImportRowVO row) + { + String status = trimToNull(row.getExamStatus()); + boolean defaultStatus = status == null || EXAM_STATUS_NORMAL.equalsIgnoreCase(status) || "正常".equals(status); + String retake = trimToNull(row.getRetake()); + boolean defaultRetake = retake == null || "0".equals(retake) || "否".equals(retake) || "正考".equals(retake); + String retakeNo = trimToNull(row.getRetakeNo()); + boolean defaultRetakeNo = retakeNo == null || "0".equals(retakeNo); + // 是否通过已由模板统一预填为默认值,未填写成绩时同样视为“未填写” + String passFlag = trimToNull(row.getPassFlag()); + boolean defaultPassFlag = passFlag == null || DEFAULT_PASS_FLAG.equals(passFlag); + return defaultStatus && defaultRetake && defaultRetakeNo && defaultPassFlag + && trimToNull(row.getScore()) == null + && trimToNull(row.getRemark()) == null; + } + + private boolean isBlankRow(TheoryScoreImportRowVO row) + { + return StringUtils.isEmpty(row.getPersonNo()) && StringUtils.isEmpty(row.getStudentName()) + && StringUtils.isEmpty(row.getScore()) && StringUtils.isEmpty(row.getExamStatus()) + && StringUtils.isEmpty(row.getRemark()); + } + + private String parseExamStatus(String raw) + { + String value = trimToNull(raw); + if (value == null) + { + return EXAM_STATUS_NORMAL; + } + String upper = value.toUpperCase(); + if (EXAM_STATUS_NORMAL.equals(upper) || "正常".equals(value)) + { + return EXAM_STATUS_NORMAL; + } + if (EXAM_STATUS_ABSENT.equals(upper) || "缺考".equals(value)) + { + return EXAM_STATUS_ABSENT; + } + if (EXAM_STATUS_EXEMPT.equals(upper) || "免修".equals(value)) + { + return EXAM_STATUS_EXEMPT; + } + throw new ServiceException("考试状态:仅支持 正常/NORMAL、缺考/ABSENT、免修/EXEMPT"); + } + + private BigDecimal parseDecimal(String raw, String column) + { + String value = trimToNull(raw); + if (value == null) + { + return null; + } + try + { + BigDecimal decimal = new BigDecimal(value.replace(",", "")); + if (decimal.scale() > 2) + { + throw new ServiceException(column + ":最多保留两位小数(当前值 " + value + ")"); + } + return decimal; + } + catch (NumberFormatException e) + { + throw new ServiceException(column + ":请填写纯数字(当前值 " + value + ")"); + } + } + + private Integer parseFlag(String raw, String column) + { + String value = trimToNull(raw); + if (value == null) + { + return null; + } + if ("1".equals(value) || "是".equals(value) || "通过".equals(value)) + { + return 1; + } + if ("0".equals(value) || "否".equals(value) || "不通过".equals(value) || "未通过".equals(value)) + { + return 0; + } + throw new ServiceException(column + ":仅支持 1/通过 或 0/未通过"); + } + + private Integer parseRetake(String rawRetake, String rawRetakeNo) + { + String value = trimToNull(rawRetake); + if (value == null) + { + String no = trimToNull(rawRetakeNo); + if (no != null) + { + try + { + return Integer.valueOf(no) > 0 ? 1 : 0; + } + catch (NumberFormatException e) + { + throw new ServiceException("补考序号:请填写整数(当前值 " + no + ")"); + } + } + return 0; + } + if ("1".equals(value) || "是".equals(value) || "补考".equals(value)) + { + return 1; + } + if ("0".equals(value) || "否".equals(value) || "正考".equals(value)) + { + return 0; + } + throw new ServiceException("是否补考:仅支持 1/是 或 0/否"); + } + + private Integer parseRetakeNo(String raw) + { + String value = trimToNull(raw); + if (value == null) + { + return 0; + } + try + { + int no = Integer.parseInt(value.replace(".0", "")); + if (no < 0) + { + throw new ServiceException("补考序号:不能为负数"); + } + return no; + } + catch (NumberFormatException e) + { + throw new ServiceException("补考序号:请填写整数(当前值 " + value + ")"); + } + } + + private Date parseTime(String raw, String column) + { + String value = trimToNull(raw); + if (value == null) + { + return null; + } + for (String pattern : TIME_PATTERNS) + { + SimpleDateFormat format = new SimpleDateFormat(pattern); + format.setLenient(false); + try + { + return format.parse(value); + } + catch (Exception ignored) + { + // 尝试下一个格式 + } + } + throw new ServiceException(column + ":时间格式应为 yyyy-MM-dd HH:mm:ss(当前值 " + value + ")"); + } + + /* ====================================================================== + * 五、公共辅助 + * ====================================================================== */ + + private void validateFile(MultipartFile file) + { + if (file == null || file.isEmpty()) + { + throw new ServiceException("请选择要导入的 Excel 文件"); + } + if (file.getSize() > MAX_FILE_SIZE) + { + throw new ServiceException("文件大小不能超过 10MB"); + } + String fileName = file.getOriginalFilename(); + String lower = fileName == null ? "" : fileName.toLowerCase(); + if (!lower.endsWith(".xls") && !lower.endsWith(".xlsx")) + { + throw new ServiceException("文件格式不正确,仅支持 .xls / .xlsx"); + } + } + + private String normalizeMode(String importMode) + { + String mode = trimToNull(importMode); + if (mode == null) + { + return MODE_SKIP; + } + String upper = mode.toUpperCase(); + if (MODE_OVERWRITE.equals(upper) || MODE_STRICT.equals(upper)) + { + return upper; + } + return MODE_SKIP; + } + + private String formatDate(Date date) + { + return date == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date); + } + + private String safeText(Object value, String defaultValue) + { + if (value == null) + { + return defaultValue; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? defaultValue : text; + } + + private String trimToNull(String value) + { + if (value == null) + { + return null; + } + String trimmed = value.replace('\u00A0', ' ').trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 规范化表头名称:去除首尾空白、内部空格与必填标识(* / *),保证带标识的模板也能正确解析 + */ + private String normalizeHeader(String value) + { + String text = trimToNull(value); + if (text == null) + { + return null; + } + text = text.replace("*", REQUIRED_MARK).replace(" ", "").replace("\u3000", ""); + while (text.endsWith(REQUIRED_MARK)) + { + text = text.substring(0, text.length() - REQUIRED_MARK.length()).trim(); + } + return text.isEmpty() ? null : text; + } + + /** + * 导入上下文(班级 + 课程 + 头部统一字段 + 在册学员索引 + 已有成绩) + */ + private static class ImportContext + { + private String planId; + + private Long planCourseId; + + private String cId; + + private String planName; + + private String courseName; + + /** 模板头部默认满分(对本表全部学员生效) */ + private BigDecimal fullScore; + + /** 模板头部科目编码(对本表全部学员生效,可空) */ + private String subjectCode; + + /** 模板头部统一考试开始时间(对本表全部学员生效,可空) */ + private Date examStartTime; + + /** 模板头部统一考试结束时间(对本表全部学员生效,可空) */ + private Date examEndTime; + + private Map> enrollmentByName = new LinkedHashMap<>(); + + /** 学员ID索引(兼容历史模板) */ + private Map> enrollmentByStudentId = new LinkedHashMap<>(); + + /** 学号索引(train_student.person_no,导入匹配首选) */ + private Map> enrollmentByPersonNo = new LinkedHashMap<>(); + + private Map existingMap = new LinkedHashMap<>(); + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public Long getPlanCourseId() { return planCourseId; } + public void setPlanCourseId(Long planCourseId) { this.planCourseId = planCourseId; } + + public String getCId() { return cId; } + public void setCId(String cId) { this.cId = cId; } + + public String getPlanName() { return planName; } + public void setPlanName(String planName) { this.planName = planName; } + + public String getCourseName() { return courseName; } + public void setCourseName(String courseName) { this.courseName = courseName; } + + public BigDecimal getFullScore() { return fullScore; } + public void setFullScore(BigDecimal fullScore) { this.fullScore = fullScore; } + + public String getSubjectCode() { return subjectCode; } + public void setSubjectCode(String subjectCode) { this.subjectCode = subjectCode; } + + public Date getExamStartTime() { return examStartTime; } + public void setExamStartTime(Date examStartTime) { this.examStartTime = examStartTime; } + + public Date getExamEndTime() { return examEndTime; } + public void setExamEndTime(Date examEndTime) { this.examEndTime = examEndTime; } + + public Map> getEnrollmentByName() { return enrollmentByName; } + public void setEnrollmentByName(Map> enrollmentByName) + { + this.enrollmentByName = enrollmentByName; + } + + public Map> getEnrollmentByStudentId() { return enrollmentByStudentId; } + public void setEnrollmentByStudentId(Map> enrollmentByStudentId) + { + this.enrollmentByStudentId = enrollmentByStudentId; + } + + public Map> getEnrollmentByPersonNo() { return enrollmentByPersonNo; } + public void setEnrollmentByPersonNo(Map> enrollmentByPersonNo) + { + this.enrollmentByPersonNo = enrollmentByPersonNo; + } + + public Map getExistingMap() { return existingMap; } + public void setExistingMap(Map existingMap) { this.existingMap = existingMap; } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java index 765ad5514..9cc5b728d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java @@ -1,22 +1,35 @@ package com.ruoyi.train.service.impl; +import java.lang.reflect.Method; +import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ClassUtils; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.spring.SpringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; import com.ruoyi.train.constant.AccommodationErrorCode; import com.ruoyi.train.domain.TrainAccommodation; import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.dto.TrainAccommodationDTO; +import com.ruoyi.train.domain.vo.TrainAccommodationVO; import com.ruoyi.train.domain.TrainDormitoryInfo; import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.dto.AccommodationSaveResult; import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator; +import com.ruoyi.train.integration.lock.client.TrainLockStayApiClient; +import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult; import com.ruoyi.train.mapper.TrainAccommodationMapper; import com.ruoyi.train.mapper.TrainClassPlanMapper; import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; import com.ruoyi.train.service.ITrainAccommodationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,6 +63,12 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService @Autowired(required = false) private com.ruoyi.train.integration.lock.service.ITrainLockAuthBridgeService iotLockAuthBridgeService; + @Autowired + private TrainStudentMapper trainStudentMapper; + + @Autowired(required = false) + private TrainLockStayApiClient trainLockStayApiClient; + @org.springframework.beans.factory.annotation.Value("${train.lock.auth-engine:dms}") private String authEngine = "dms"; @@ -93,6 +112,22 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService return trainAccommodationMapper.selectTrainAccommodationByEnrollmentId(enrollmentId); } + /** + * 根据门锁开放接口业务流水号查询住宿安排 + * + * @param lockApiBizNo 门锁业务流水号 + * @return 住宿安排 + */ + @Override + public TrainAccommodation selectTrainAccommodationByLockApiBizNo(String lockApiBizNo) + { + if (lockApiBizNo == null || lockApiBizNo.trim().isEmpty()) + { + return null; + } + return trainAccommodationMapper.selectTrainAccommodationByLockApiBizNo(lockApiBizNo.trim()); + } + /** * 根据参训档案ID查询当前活跃/有效住宿安排 * @@ -105,6 +140,21 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService return trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId); } + /** + * 根据学员唯一编码查询其住宿记录,并按 create_time 降序排序后取第一条。 + *

+ * 排序(create_time DESC)与取首条(LIMIT 1)由 Mapper SQL 保证,此处不再做二次排序, + * 调用方(如报到分支判断)应直接使用该第一条记录作为判定依据。 + * + * @param sId 学员唯一编码(sId) + * @return 按 create_time 降序排序的第一条住宿记录,无记录返回 null + */ + @Override + public TrainAccommodation selectActiveAccommodationByStudentId(String sId) + { + return trainAccommodationMapper.selectActiveAccommodationByStudentId(sId); + } + /** * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序) * @@ -129,6 +179,30 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService return trainAccommodationMapper.selectTrainAccommodationList(accommodation); } + /** + * 查询住宿安排 VO 列表 + * + * @param accommodation 住宿安排过滤条件 + * @return 住宿安排 VO 集合 + */ + @Override + public List selectTrainAccommodationVoList(TrainAccommodation accommodation) + { + return TrainAccommodationVO.fromList(trainAccommodationMapper.selectTrainAccommodationList(accommodation)); + } + + /** + * 查询住宿安排详情 VO + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 VO + */ + @Override + public TrainAccommodationVO selectTrainAccommodationVoByAccommodationId(Long accommodationId) + { + return TrainAccommodationVO.from(trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId)); + } + /** * 查询指定开班计划下所有有效住宿安排 * @@ -150,6 +224,30 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService @Override @Transactional(rollbackFor = Exception.class) public int insertTrainAccommodation(TrainAccommodation accommodation) + { + return doInsertTrainAccommodation(accommodation).getRows(); + } + + /** + * 创建住宿事实并返回门禁办理入住结果 (不改变原 insertTrainAccommodation 的 int 契约) + * + * @param accommodation 住宿事实 + * @return 含门禁调用结果的新增结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public AccommodationSaveResult insertTrainAccommodationWithLockResult(TrainAccommodation accommodation) + { + return doInsertTrainAccommodation(accommodation); + } + + /** + * 新增住宿安排的实际实现 (带前置依赖、唯一活跃约束、宿舍排他锁、容量校验与 Model C 床位时间冲突校验) + * + * @param accommodation 住宿安排 + * @return 含门禁调用结果的新增结果 + */ + private AccommodationSaveResult doInsertTrainAccommodation(TrainAccommodation accommodation) { Long enrollmentId = accommodation.getEnrollmentId(); if (enrollmentId == null) @@ -214,9 +312,20 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService { throw new ServiceException("新增住宿安排不能直接置为已退宿状态,退宿请使用专属退宿业务操作"); } + // 未指定门锁业务流水号时生成 64 位随机串 (对齐 lock_api_biz_no varchar(64)) + //if (accommodation.getLockApiBizNo() == null || accommodation.getLockApiBizNo().isBlank()) + //{ + String lockApiBizNo = generateLockApiBizNo(); + accommodation.setLockApiBizNo(lockApiBizNo); + //} + + LockStayCheckinResult lockCheckinResult = null; int rows = trainAccommodationMapper.insertTrainAccommodation(accommodation); if (rows > 0) { + // 调用门禁授权接口:办理入住 (/openapi/iot/stay/checkin) + lockCheckinResult = callStayCheckin(lockApiBizNo, enrollment, dorm); + boolean useIot = "iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null; if (useIot) { @@ -240,7 +349,27 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService } } } - return rows; + return new AccommodationSaveResult(rows, accommodation.getAccommodationId(), lockApiBizNo, lockCheckinResult); + } + + /** + * 创建住宿事实 (DTO 入参) + * + * @param dto 住宿安排 DTO + * @param operator 操作人 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int insertTrainAccommodation(TrainAccommodationDTO dto, String operator) + { + if (dto == null) + { + throw new ServiceException("住宿安排参数不能为空"); + } + TrainAccommodation accommodation = dto.toEntity(); + accommodation.setCreateBy(operator); + return insertTrainAccommodation(accommodation); } /** @@ -284,10 +413,70 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService // 验证 Model C 床位时间重叠冲突 (排除自身) validateBedOccupancyWindow(targetDormId, targetBedNo, exist.getEnrollmentId(), exist.getAccommodationId()); + // 门锁业务流水号规范化与长度校验 (对齐 lock_api_biz_no varchar(64)) + log.info("accommodation.getLockApiBizNo()0000: {}", accommodation.getLockApiBizNo()); + accommodation.setLockApiBizNo(normalizeLockApiBizNo(accommodation.getLockApiBizNo(), "修改")); + log.info("accommodation.getLockApiBizNo(): {}", accommodation.getLockApiBizNo()); + accommodation.setUpdateTime(DateUtils.getNowDate()); return trainAccommodationMapper.updateTrainAccommodation(accommodation); } + /** + * 更新门锁开放接口业务流水号 + * + * @param accommodationId 住宿安排ID + * @param lockApiBizNo 门锁业务流水号 + * @param operator 操作人 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateLockApiBizNo(Long accommodationId, String lockApiBizNo, String operator) + { + if (accommodationId == null) + { + throw new ServiceException("住宿安排ID不能为空"); + } + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + throw new ServiceException("住宿记录不存在: " + accommodationId); + } + String normalized = normalizeLockApiBizNo(lockApiBizNo, "修改"); + return trainAccommodationMapper.updateLockApiBizNo(accommodationId, normalized, operator, DateUtils.getNowDate()); + } + + /** + * 更新门锁授权结果 + * + * @param accommodationId 住宿安排ID + * @param lockResult 门锁授权结果 (1未授权 2已授权 3已过期) + * @param operator 操作人 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateLockResult(Long accommodationId, Integer lockResult, String operator) + { + if (accommodationId == null) + { + throw new ServiceException("住宿安排ID不能为空"); + } + if (!TrainAccommodation.LOCK_RESULT_UNAUTHORIZED.equals(lockResult) + && !TrainAccommodation.LOCK_RESULT_AUTHORIZED.equals(lockResult) + && !TrainAccommodation.LOCK_RESULT_EXPIRED.equals(lockResult)) + { + throw new ServiceException("门锁授权结果不合法,取值范围为 1(未授权)、2(已授权)、3(已过期)"); + } + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + throw new ServiceException("住宿记录不存在: " + accommodationId); + } + return trainAccommodationMapper.updateLockResult(accommodationId, lockResult, operator, DateUtils.getNowDate()); + } + /** * 学员办理入住 (状态 0预分配 -> 1已入住) * @@ -341,7 +530,7 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService * @return 结果 */ @Override - @Transactional + @Transactional(rollbackFor = Exception.class) public int checkOut(Long accommodationId, String operator) { TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); @@ -351,20 +540,27 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService } if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) { - if ("iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null) { - iotLockAuthBridgeService.reconcileCheckout(accommodationId, operator); - } - return 1; // Idempotent checkout still reconciles durable revocation intent. + // 原门锁撤权调用已注释,改为退宿时同步删除人脸库员工数据 + //if ("iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null) { + // iotLockAuthBridgeService.reconcileCheckout(accommodationId, operator); + //} + deleteEmployeeFaceOnCheckOut(exist); + return 1; // 幂等退宿:仍执行人脸数据删除,保证重复调用结果一致。 } int rows = trainAccommodationMapper.updateAccommodationStatus(accommodationId, TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); if (rows > 0) { + // 退宿联动数据重置:清空学员头像 + 参训状态回到待报到 (同一事务,失败整体回滚) + resetStudentFaceAndEnrollmentStatus(exist, operator); + // 退宿同步删除人脸:调用 TrainFaceApiController#deleteEmployee(sId) + deleteEmployeeFaceOnCheckOut(exist); boolean useIot = "iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null; if (useIot) { + // 原门锁撤权调用已注释(按需求改为删除人脸数据) // Failure to persist the revoke intent must roll back the checkout transaction. - iotLockAuthBridgeService.reconcileCheckout(accommodationId, operator); + //iotLockAuthBridgeService.reconcileCheckout(accommodationId, operator); } else if (accessAuthorizationOrchestrator != null) { @@ -387,6 +583,89 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService return rows; } + /** + * 退宿联动重置学员人脸相关数据(与退宿状态更新处于同一事务): + *

    + *
  1. train_student.avatar 置为空字符串 ''(sId 取自住宿记录关联参训档案的 student_id);
  2. + *
  3. train_enrollment.enrollment_status 置为 0(待报到)。
  4. + *
+ * 任一步更新失败均抛 {@link ServiceException},由 checkOut 的 + * {@code @Transactional(rollbackFor = Exception.class)} 整体回滚,保证退宿与数据更新一致。 + * + * @param accommodation 住宿安排 + * @param operator 操作人 + */ + private void resetStudentFaceAndEnrollmentStatus(TrainAccommodation accommodation, String operator) + { + if (accommodation == null || accommodation.getEnrollmentId() == null) + { + throw new ServiceException("住宿记录缺少参训档案ID,无法重置学员报到状态"); + } + if (trainEnrollmentMapper == null || trainStudentMapper == null) + { + // 非 Spring 容器(单元测试)未注入 Mapper 时跳过数据库联动;生产环境由 @Autowired 保证非空 + log.warn("退宿重置学员数据跳过:Mapper 未注入, accommodationId={}", accommodation.getAccommodationId()); + return; + } + // sId 来源:住宿记录 enrollment_id → train_enrollment.student_id → train_student.s_id + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(accommodation.getEnrollmentId()); + if (enrollment == null || StringUtils.isEmpty(enrollment.getStudentId())) + { + throw new ServiceException("未找到住宿记录对应的参训档案或学员ID,无法重置学员报到状态, accommodationId=" + + accommodation.getAccommodationId()); + } + String sId = enrollment.getStudentId(); + + // 1. 清空头像:显式 SET avatar = ''(通用动态更新会跳过空串) + int avatarRows = trainStudentMapper.clearAvatarBySId(sId); + if (avatarRows <= 0) + { + throw new ServiceException("退宿清空学员头像失败, sId=" + sId); + } + + // 2. 参训状态回到 0(待报到);remark 传 null,SQL 使用 coalesce 保留原有备注 + int statusRows = trainEnrollmentMapper.updateEnrollmentStatus(accommodation.getEnrollmentId(), 0, + null, operator, DateUtils.getNowDate()); + if (statusRows <= 0) + { + throw new ServiceException("退宿重置参训状态失败, enrollmentId=" + accommodation.getEnrollmentId()); + } + } + + /** + * 退宿时同步删除人脸库中的员工数据:调用 TrainFaceApiController#deleteEmployee(sId)。 + *

+ * ruoyi-train 不能反向依赖 ruoyi-admin,因此按 Bean 名称获取控制器后反射调用 deleteEmployee, + * 入参为学员 sId(由住宿记录关联的参训档案反查)。人脸删除失败仅记录日志,不回滚退宿事实。 + * + * @param accommodation 住宿安排 + */ + private void deleteEmployeeFaceOnCheckOut(TrainAccommodation accommodation) + { + if (accommodation == null || accommodation.getEnrollmentId() == null || trainEnrollmentMapper == null) + { + return; + } + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(accommodation.getEnrollmentId()); + if (enrollment == null || StringUtils.isEmpty(enrollment.getStudentId())) + { + log.warn("退宿删除人脸跳过:未找到参训档案或学员ID, accommodationId={}", accommodation.getAccommodationId()); + return; + } + String sId = enrollment.getStudentId(); + try + { + Object controller = SpringUtils.getBean("trainFaceApiController"); + Method deleteEmployee = ClassUtils.getUserClass(controller).getMethod("deleteEmployee", String.class); + Object result = deleteEmployee.invoke(controller, sId); + log.info("退宿同步删除人脸完成, sId={}, result={}", sId, result); + } + catch (Exception ex) + { + log.error("退宿同步删除人脸失败, sId={}, msg={}", sId, ex.getMessage()); + } + } + /** * 调房/换房业务操作 (原子事务:旧住宿转已退宿,新住宿创建预分配/入住) * @@ -431,10 +710,14 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService // 1. 结束旧住宿 trainAccommodationMapper.updateAccommodationStatus(oldAccommodationId, TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); + // 调用退宿门锁授权:对原住宿记录执行撤权 (内部已做客户端/配置缺失保护) + callStayCheckout(oldAcc.getLockApiBizNo()); + //revokeOldLockAuthOnTransfer(oldAccommodationId, oldDormId, newDormId, operator); // 2. 建立新住宿 (insertTrainAccommodation 内部已包含目标宿舍锁、容量校验与床位时间冲突校验) TrainAccommodation newAcc = new TrainAccommodation(); newAcc.setEnrollmentId(oldAcc.getEnrollmentId()); + newAcc.setLockApiBizNo(oldAcc.getLockApiBizNo()); newAcc.setDormId(newDormId); newAcc.setBedNo(newBedNo); newAcc.setAssignedTime(DateUtils.getNowDate()); @@ -442,17 +725,138 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService newAcc.setCreateBy(operator); newAcc.setRemark("由原住宿记录 " + oldAccommodationId + " (宿舍 " + oldAcc.getDormId() + ") 调房迁入"); - insertTrainAccommodation(newAcc); + //insertTrainAccommodation(newAcc); + insertTrainAccommodationWithLockResult(newAcc); if ("iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null) { - // Intent persistence failure must abort the surrounding accommodation transaction. - iotLockAuthBridgeService.reconcileTransfer(oldAccommodationId, newAcc.getAccommodationId(), operator); + try + { + // Intent persistence failure must abort the surrounding accommodation transaction. + iotLockAuthBridgeService.reconcileTransfer(oldAccommodationId, newAcc.getAccommodationId(), operator); + } + catch (ServiceException ex) + { + // 调房不再强制要求目标房间已绑定智能门锁:未绑定门锁时跳过门锁授权桥接, + // 撤旧建新的住宿关系照常生效,授权由调用方(前端 autoPushAuth / 门锁开放接口)处理; + // 其余桥接异常仍向上抛出,保持原有事务回滚语义。 + if (!isLockDeviceUnbound(ex)) + { + throw ex; + } + log.warn("调房目标宿舍未绑定智能门锁,跳过门锁授权桥接: oldAccommodationId={}, newAccommodationId={}, dormId={}", + oldAccommodationId, newAcc.getAccommodationId(), newDormId); + } } return newAcc; } + /** + * 调用门禁开放接口办理入住 (/openapi/iot/stay/checkin)。 + *

+ * 参数取值:bizNo 取门锁业务流水号;externalPersonId/personName/photoUrl 取学员的 sId/sName/avatar; + * roomNo 取宿舍房间号;beginTime 取当前时间,endTime 取当前时间加 15 天。 + *

+ * 前置守卫(任一不满足即返回 skipped,不发起任何门禁请求,住宿主流程照常生效): + *

    + *
  1. 门禁客户端、学员 Mapper、参训档案、宿舍信息缺失;
  2. + *
  3. 参训档案对应的学员不存在;
  4. + *
  5. 学员无人脸照片(avatar 为 null、空串或纯空白)——门禁侧无法建档,下发无效请求没有意义。
  6. + *
+ * + * @param bizNo 门锁业务流水号 + * @param enrollment 参训档案 + * @param dorm 宿舍信息 + * @return 入住授权结果;前置条件不满足时为 skipped 结果(不是失败) + */ + @Override + public LockStayCheckinResult callStayCheckin(String bizNo, TrainEnrollment enrollment, TrainDormitoryInfo dorm) + { + if (trainLockStayApiClient == null || trainStudentMapper == null || enrollment == null || dorm == null) + { + return LockStayCheckinResult.skipped(bizNo, "缺少门禁客户端、学员信息或宿舍信息,已跳过入住授权"); + } + TrainStudent student = trainStudentMapper.selectTrainStudentBySId(enrollment.getStudentId()); + if (student == null) + { + log.warn("办理入住门禁开放接口调用跳过:未找到学员信息, sId={}", enrollment.getStudentId()); + return LockStayCheckinResult.skipped(bizNo, "未找到学员信息,已跳过入住授权"); + } + // 头像守卫:无人脸照片时跳过入住授权,避免向门禁开放服务下发 photoUrl 为空的无效请求 + if (!hasFacePhoto(student)) + { + log.info("办理入住门禁开放接口调用跳过:学员无人脸照片, sId={}, bizNo={}", student.getsId(), bizNo); + return LockStayCheckinResult.skipped(bizNo, "学员无人脸照片,已跳过入住授权"); + } + Date beginTime = DateUtils.getNowDate(); + Date endTime = Date.from(beginTime.toInstant().plus(15, ChronoUnit.DAYS)); + return trainLockStayApiClient.checkinWithResult(bizNo, student.getsId(), student.getsName(), + dorm.getDormRoom(), beginTime, endTime, student.getAvatar()); + } + + /** + * 判断学员是否具备可用于门禁授权的人脸照片。 + *

+ * 判定标准:{@code avatar} 去除首尾空白后非空即视为存在;null、空串、纯空白均视为不存在。 + * + * @param student 学员信息 + * @return true 表示存在人脸照片 + */ + private boolean hasFacePhoto(TrainStudent student) + { + return student != null && student.getAvatar() != null && !student.getAvatar().trim().isEmpty(); + } + + @Override + public LockStayCheckinResult callStayCheckout(String bizNo) + { + if (trainLockStayApiClient == null || trainStudentMapper == null) + { + return LockStayCheckinResult.skipped(bizNo, "缺少门禁客户端、学员信息或宿舍信息,已跳过退宿授权"); + } + return trainLockStayApiClient.checkoutWithResult(bizNo); + } + + /** + * 调房时触发原住宿的门锁撤权 (退宿门锁授权)。 + *

+ * 仅在 iot 授权引擎下执行;若调房前后为同一智能门锁 (同宿舍换床),无需撤权, + * 与 reconcileTransfer 的 sameDevice 分支保持一致,避免误撤仍在使用中的门锁授权。 + * + * @param oldAccommodationId 原住宿安排ID + * @param oldDormId 原宿舍ID + * @param newDormId 新宿舍ID + * @param operator 操作人 + */ + private void revokeOldLockAuthOnTransfer(Long oldAccommodationId, String oldDormId, String newDormId, String operator) + { + if (!"iot".equalsIgnoreCase(authEngine) || iotLockAuthBridgeService == null) + { + return; + } + TrainDormitoryInfo oldDorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoByDormId(oldDormId); + TrainDormitoryInfo newDorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoByDormId(newDormId); + String oldSn = oldDorm == null ? null : oldDorm.getLockDeviceSn(); + String newSn = newDorm == null ? null : newDorm.getLockDeviceSn(); + if (oldSn != null && oldSn.equals(newSn)) + { + log.info("调房前后为同一智能门锁 ({}),跳过原住宿撤权: oldAccommodationId={}", oldSn, oldAccommodationId); + return; + } + // 撤权意图持久化失败需中止调房事务,与退宿 reconcileCheckout 的事务语义保持一致 + iotLockAuthBridgeService.reconcileCheckout(oldAccommodationId, operator); + } + + /** + * 判断桥接异常是否为"宿舍未绑定智能门锁":该业务分支允许跳过授权而不回滚住宿关系。 + */ + private boolean isLockDeviceUnbound(ServiceException ex) + { + String message = ex == null ? null : ex.getMessage(); + return message != null && message.contains(AccommodationErrorCode.LOCK_DEVICE_UNBOUND_MARK); + } + /** * 批量删除住宿安排 @@ -503,6 +907,48 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId); } + /** + * 生成 64 位随机门锁业务流水号 (两个去横线 UUID 拼接,长度恰为 64)。 + * + * @return 64 位随机字符串 + */ + @Override + public String generateLockApiBizNo() + { + String bizNo = IdUtils.fastSimpleUUID() + IdUtils.fastSimpleUUID(); + if (bizNo.length() != TrainAccommodation.LOCK_API_BIZ_NO_MAX_LENGTH) + { + throw new ServiceException("门锁业务流水号生成异常,长度必须为" + TrainAccommodation.LOCK_API_BIZ_NO_MAX_LENGTH + "位"); + } + return bizNo; + } + + /** + * 门锁业务流水号规范化:去空白、空串转 null,并校验长度不超过 64。 + * + * @param lockApiBizNo 门锁业务流水号 + * @param scene 操作场景 (用于错误提示) + * @return 规范化后的流水号 + */ + private String normalizeLockApiBizNo(String lockApiBizNo, String scene) + { + if (lockApiBizNo == null) + { + return null; + } + String value = lockApiBizNo.trim(); + if (value.isEmpty()) + { + return null; + } + if (value.length() > TrainAccommodation.LOCK_API_BIZ_NO_MAX_LENGTH) + { + throw new ServiceException("门锁业务流水号长度不能超过" + TrainAccommodation.LOCK_API_BIZ_NO_MAX_LENGTH + + "个字符 (" + scene + ")"); + } + return value; + } + /** * FIX-04: 住宿生命周期状态白名单校验。 *

diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAnalysisServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAnalysisServiceImpl.java index 4c630929e..235f50c99 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAnalysisServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAnalysisServiceImpl.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.train.domain.analysis.*; import com.ruoyi.train.mapper.TrainAnalysisMapper; +import com.ruoyi.train.service.IStudentPortraitService; import com.ruoyi.train.service.ITrainAnalysisService; /** @@ -27,6 +28,10 @@ public class TrainAnalysisServiceImpl implements ITrainAnalysisService @Autowired private TrainAnalysisMapper analysisMapper; + /** 画像扩展能力服务;单元测试/独立调用场景允许为空,为空时跳过扩展节点 */ + @Autowired(required = false) + private IStudentPortraitService portraitService; + /** * 业务定义待确认(BUSINESS_DEFINITION_PENDING_CONFIRMATION): * 请假(attendance_status=4)是否计入考勤异常统计。 @@ -326,11 +331,13 @@ public class TrainAnalysisServiceImpl implements ITrainAnalysisService } vo.setPhone(base.get("phone") != null ? String.valueOf(base.get("phone")) : "-"); vo.setEmail(base.get("email") != null ? String.valueOf(base.get("email")) : "-"); - vo.setOrgName(base.get("org_name") != null ? String.valueOf(base.get("org_name")) : "-"); + vo.setDeptName(base.get("dept_name") != null ? String.valueOf(base.get("dept_name")) : "-"); String last4 = base.get("id_card_last4") != null ? String.valueOf(base.get("id_card_last4")) : ""; vo.setIdCardMasked(last4.isEmpty() ? "未登记" : "**************" + last4); vo.setUserBindingStatus(String.valueOf(base.get("user_binding_status"))); vo.setStatus(base.get("status") != null ? ((Number) base.get("status")).intValue() : 1); + // 头像原样下发(相对路径或 http(s) 全地址),为空时由前端使用占位头像 + vo.setAvatar(base.get("avatar") != null ? String.valueOf(base.get("avatar")) : null); // 2. 参训履历时间轴 List timeline = new ArrayList<>(); @@ -411,12 +418,41 @@ public class TrainAnalysisServiceImpl implements ITrainAnalysisService vo.setAttendanceRate(0.0); } - // 4. 成绩状态 (基于 V2 train_student_score 真实聚合) - Long scoreCount = analysisMapper.countStudentScores(studentId); - if (scoreCount != null && scoreCount > 0) + // 3.1 考勤明细:本人所有课程按课次展开(含未签到课次),与上方统计同口径 + List attendanceDetails = analysisMapper.selectStudentAttendanceDetails(studentId); + if (attendanceDetails == null) + { + attendanceDetails = Collections.emptyList(); + } + for (StudentAttendanceDetailVo detail : attendanceDetails) + { + detail.setAttendanceStatusName(formatAttendanceStatusName(detail.getAttendanceStatus())); + } + vo.setAttendanceDetails(attendanceDetails); + + // 4. 成绩状态:理论考试(train_theory_exam_score)+ 实训考试(train_exam_report)两路汇聚 + StudentScoreStatVo theoryStats = normalizeScoreStat(analysisMapper.selectStudentTheoryScoreStats(studentId)); + StudentScoreStatVo practiceStats = normalizeScoreStat(analysisMapper.selectStudentPracticeScoreStats(studentId)); + Long legacyScoreCount = analysisMapper.countStudentScores(studentId); + long totalScoreCount = theoryStats.getRecordCount() + practiceStats.getRecordCount() + + (legacyScoreCount == null ? 0L : legacyScoreCount); + vo.setTheoryScoreStats(theoryStats); + vo.setPracticeScoreStats(practiceStats); + // 成绩明细:理论 + 实训统一列表,类型名称在服务端补齐 + List examScoreList = analysisMapper.selectStudentExamScoreItems(studentId); + if (examScoreList == null) + { + examScoreList = Collections.emptyList(); + } + for (StudentExamScoreItemVo item : examScoreList) + { + item.setCategoryName("PRACTICE".equals(item.getCategory()) ? "实训考试" : "理论考试"); + } + vo.setExamScoreList(examScoreList); + if (totalScoreCount > 0) { vo.setHasScoreData(true); - vo.setScoreStatusDesc(String.format("已登记 %d 项考核成绩", scoreCount)); + vo.setScoreStatusDesc(String.format("已登记 %d 项考核成绩", totalScoreCount)); } else { @@ -469,9 +505,69 @@ public class TrainAnalysisServiceImpl implements ITrainAnalysisService vo.setAuthStatusName("无门禁授权"); } + // 7. 画像扩展能力(七类维度 + 分层/标签/活跃度/风险/班级对比/轻量趋势) + // 独立节点追加,任何异常只降级该节点,不影响上述既有画像字段 + if (portraitService != null) + { + try + { + vo.setPortrait(portraitService.getPortrait(studentId)); + } + catch (Exception e) + { + log.warn("学员画像扩展数据生成失败, studentId={}, reason={}", studentId, e.getMessage()); + } + } + return vo; } + /** + * 考勤状态名称:0 缺勤、1 正常、2 迟到、3 早退、4 请假;无考勤记录为未签到 + */ + private String formatAttendanceStatusName(Integer status) + { + if (status == null) + { + return "未签到"; + } + switch (status) + { + case 0: return "缺勤"; + case 1: return "正常"; + case 2: return "迟到"; + case 3: return "早退"; + case 4: return "请假"; + default: return "未知"; + } + } + + /** + * 成绩统计兜底与通过率计算:聚合查询无数据时返回全 0 结构,避免前端展示 null + */ + private StudentScoreStatVo normalizeScoreStat(StudentScoreStatVo stat) + { + StudentScoreStatVo target = stat == null ? new StudentScoreStatVo() : stat; + if (target.getRecordCount() == null) + { + target.setRecordCount(0L); + } + if (target.getScoredCount() == null) + { + target.setScoredCount(0L); + } + if (target.getPassCount() == null) + { + target.setPassCount(0L); + } + double passRate = target.getRecordCount() > 0 + ? BigDecimal.valueOf(target.getPassCount() * 100.0 / target.getRecordCount()) + .setScale(1, RoundingMode.HALF_UP).doubleValue() + : 0.0; + target.setPassRate(passRate); + return target; + } + private AttendanceFilterDto normalizeFilter(AttendanceFilterDto filter) { AttendanceFilterDto dto = new AttendanceFilterDto(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java index 0da529dc8..12dd0bf5e 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java @@ -58,6 +58,12 @@ public class TrainClassPlanServiceImpl implements ITrainClassPlanService return trainClassPlanMapper.countTrainClassPlanByName(planName, excludePlanId) > 0; } + @Override + public List selectClassPlanListByStudentId(String studentId) + { + return trainClassPlanMapper.selectClassPlanListByStudentId(studentId); + } + /** * 新增开班计划 * @@ -89,4 +95,10 @@ public class TrainClassPlanServiceImpl implements ITrainClassPlanService { return trainClassPlanMapper.updateTrainClassPlanStatus(trainClassPlan); } + + @Override + public int logicalDeleteByPlanId(String planId, String operator) + { + return trainClassPlanMapper.logicalDeleteByPlanId(planId, operator); + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassReportServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassReportServiceImpl.java index b3d12a3cb..6578f0d3e 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassReportServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassReportServiceImpl.java @@ -1,48 +1,147 @@ -package com.ruoyi.train.service.impl; - -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import com.ruoyi.train.mapper.TrainClassReportMapper; -import com.ruoyi.train.domain.TrainClassReport; -import com.ruoyi.train.service.ITrainClassReportService; - -/** - * 开班报到Service业务层处理 - * - * @author ruoyi - * @date 2026-09-08 - */ -@Service -@Transactional(readOnly = true) -public class TrainClassReportServiceImpl implements ITrainClassReportService -{ - @Autowired - private TrainClassReportMapper trainClassReportMapper; - - /** - * 查询开班报到 - * - * @param reportId 开班报到主键 - * @return 开班报到 - */ - @Override - public TrainClassReport selectTrainClassReportByReportId(Long reportId) - { - return trainClassReportMapper.selectTrainClassReportByReportId(reportId); - } - - /** - * 查询开班报到列表 - * - * @param trainClassReport 开班报到 - * @return 开班报到 - */ - @Override - public List selectTrainClassReportList(TrainClassReport trainClassReport) - { - return trainClassReportMapper.selectTrainClassReportList(trainClassReport); - } - -} +package com.ruoyi.train.service.impl; + +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.dto.ReportStartDTO; +import com.ruoyi.train.mapper.TrainClassReportMapper; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainClassroomInfoMapper; +import com.ruoyi.train.mapper.TrainTrainingSiteMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.service.ITrainClassReportService; + +/** + * 开班报到Service业务层处理 + * + * @author ruoyi + * @date 2026-09-08 + */ +@Service +@Transactional(readOnly = true) +public class TrainClassReportServiceImpl implements ITrainClassReportService +{ + @Autowired + private TrainClassReportMapper trainClassReportMapper; + + @Autowired + private TrainClassPlanMapper trainClassPlanMapper; + + @Autowired + private TrainClassroomInfoMapper trainClassroomInfoMapper; + + @Autowired + private TrainTrainingSiteMapper trainTrainingSiteMapper; + + @Autowired + private TrainDormitoryInfoMapper trainDormitoryInfoMapper; + + /** + * 查询开班报到 + * + * @param reportId 开班报到主键 + * @return 开班报到 + */ + @Override + public TrainClassReport selectTrainClassReportByReportId(Long reportId) + { + return trainClassReportMapper.selectTrainClassReportByReportId(reportId); + } + + /** + * 查询开班报到列表 + * + * @param trainClassReport 开班报到 + * @return 开班报到 + */ + @Override + public List selectTrainClassReportList(TrainClassReport trainClassReport) + { + return trainClassReportMapper.selectTrainClassReportList(trainClassReport); + } + + @Override + public TrainClassReport selectLatestReportByStudentId(String studentId) + { + return trainClassReportMapper.selectLatestReportByStudentId(studentId); + } + + /** + * 开班报到启动:更新开班计划的班级信息与报到时间窗口, + * 并按映射批量绑定教室/实训场地的电子班牌、宿舍门锁设备。 + */ + @Override + @Transactional + public int reportStart(ReportStartDTO dto, String username) + { + if (dto == null || StringUtils.isEmpty(dto.getPlanId())) + { + throw new ServiceException("planId 不能为空"); + } + // 1. 更新开班计划的班级信息与报到时间窗口(不触碰其它列) + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(dto.getPlanId()); + plan.setClassId(dto.getClassId()); + plan.setClassName(dto.getClassName()); + plan.setReportStarttime(dto.getReportStarttime()); + plan.setReportDeadline(dto.getReportDeadline()); + plan.setUpdateBy(username); + plan.setUpdateTime(DateUtils.getNowDate()); + int rows = trainClassPlanMapper.updateReportStartByPlanId(plan); + + // 2. 更新教室/实训场地的电子班牌绑定 + // boardMappings: key = "C-" + class_room_id 或 "S-" + site_id,value = 电子班牌ID(空表示解绑) + if (dto.getBoardMappings() != null) + { + for (Map.Entry entry : dto.getBoardMappings().entrySet()) + { + String key = entry.getKey(); + if (StringUtils.isEmpty(key)) + { + continue; + } + String boardId = entry.getValue(); + if (boardId != null && boardId.isEmpty()) + { + boardId = null; + } + if (key.startsWith("S-")) + { + trainTrainingSiteMapper.updateBindBoardIdBySiteId(key.substring(2), boardId, username); + } + else if (key.startsWith("C-")) + { + trainClassroomInfoMapper.updateBindBoardIdByClassRoomId(key.substring(2), boardId, username); + } + } + } + + // 3. 更新宿舍门锁设备序列号 + // lockMappings: value = { dormId, lockId },lockId 即 lock_device_sn(空表示清除) + if (dto.getLockMappings() != null) + { + for (ReportStartDTO.LockMappingDTO mapping : dto.getLockMappings().values()) + { + if (mapping == null || StringUtils.isEmpty(mapping.getDormId())) + { + continue; + } + String lockId = mapping.getLockId(); + if (lockId != null && lockId.isEmpty()) + { + lockId = null; + } + trainDormitoryInfoMapper.updateLockDeviceSnByDormId(mapping.getDormId(), lockId, username); + } + } + return rows; + } + +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassSeatServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassSeatServiceImpl.java new file mode 100644 index 000000000..6089344f0 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassSeatServiceImpl.java @@ -0,0 +1,144 @@ +package com.ruoyi.train.service.impl; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainClassSeat; +import com.ruoyi.train.domain.dto.ClassSeatSaveDTO; +import com.ruoyi.train.domain.vo.ClassSeatItemVO; +import com.ruoyi.train.mapper.TrainClassSeatMapper; +import com.ruoyi.train.service.ITrainClassSeatService; + +/** + * 班级座位Service业务层处理 + * + * @author ruoyi + * @date 2026-10-26 + */ +@Service +public class TrainClassSeatServiceImpl implements ITrainClassSeatService +{ + @Autowired + private TrainClassSeatMapper trainClassSeatMapper; + + @Override + public List selectTrainClassSeatList(TrainClassSeat trainClassSeat) + { + return trainClassSeatMapper.selectTrainClassSeatList(trainClassSeat); + } + + @Override + public ClassSeatItemVO selectTrainClassSeatBySeatId(Long seatId) + { + return trainClassSeatMapper.selectTrainClassSeatBySeatId(seatId); + } + + @Override + public int insertTrainClassSeat(TrainClassSeat trainClassSeat) + { + checkRowColConflict(trainClassSeat.getPlanId(), trainClassSeat.getClassroomId(), + trainClassSeat.getRowIndex(), trainClassSeat.getColIndex()); + if (trainClassSeat.getCanceled() == null) + { + trainClassSeat.setCanceled(0); + } + return trainClassSeatMapper.insertTrainClassSeat(trainClassSeat); + } + + @Override + public int updateTrainClassSeat(TrainClassSeat trainClassSeat) + { + return trainClassSeatMapper.updateTrainClassSeat(trainClassSeat); + } + + @Override + public int deleteTrainClassSeatBySeatIds(Long[] seatIds) + { + return trainClassSeatMapper.deleteTrainClassSeatBySeatIds(seatIds); + } + + @Override + public int deleteTrainClassSeatBySeatId(Long seatId) + { + return trainClassSeatMapper.deleteTrainClassSeatBySeatId(seatId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int saveClassSeatLayout(ClassSeatSaveDTO dto, String operator) + { + if (dto == null || StringUtils.isEmpty(dto.getPlanId()) || StringUtils.isEmpty(dto.getClassroomId())) + { + throw new ServiceException("布局保存参数不完整:planId 和 classroomId 必填"); + } + List seats = dto.getSeats(); + if (seats == null || seats.isEmpty()) + { + throw new ServiceException("座位明细不能为空"); + } + + // 校验行列号必填、取值合法且 (行,列) 不重复 + Set seen = new HashSet<>(); + for (ClassSeatSaveDTO.SeatItemDTO item : seats) + { + if (item == null || item.getRowIndex() == null || item.getColIndex() == null) + { + throw new ServiceException("每个座位的行号(row_index)与列号(col_index)必填"); + } + if (item.getRowIndex() < 1 || item.getColIndex() < 1) + { + throw new ServiceException("行号与列号必须从1开始"); + } + String key = item.getRowIndex() + ":" + item.getColIndex(); + if (!seen.add(key)) + { + throw new ServiceException("座位位置重复:第" + item.getRowIndex() + "行第" + item.getColIndex() + "列"); + } + } + + // 整体替换:先删后插 + trainClassSeatMapper.deleteByPlanIdAndClassroomId(dto.getPlanId(), dto.getClassroomId()); + + int count = 0; + for (ClassSeatSaveDTO.SeatItemDTO item : seats) + { + TrainClassSeat seat = new TrainClassSeat(); + seat.setPlanId(dto.getPlanId()); + seat.setClassroomId(dto.getClassroomId()); + seat.setCategory(dto.getCategory()); + seat.setRowIndex(item.getRowIndex()); + seat.setColIndex(item.getColIndex()); + seat.setSeatCode(item.getSeatCode()); + seat.setCanceled(item.getCanceled() != null ? item.getCanceled() : 0); + seat.setStudentId(item.getStudentId()); + seat.setZoneId(item.getZoneId()); + seat.setDeptDetail(item.getDeptDetail()); + seat.setJob(item.getJob()); + seat.setCreateBy(operator); + count += trainClassSeatMapper.insertTrainClassSeat(seat); + } + return count; + } + + /** + * 单座新增时校验同一 (planId, classroomId) 下 (行,列) 是否已被占用 + */ + private void checkRowColConflict(String planId, String classroomId, Integer rowIndex, Integer colIndex) + { + if (StringUtils.isEmpty(planId) || StringUtils.isEmpty(classroomId) + || rowIndex == null || colIndex == null) + { + return; + } + TrainClassSeat existing = trainClassSeatMapper.selectSeatByPlanRoomRowCol(planId, classroomId, rowIndex, colIndex); + if (existing != null) + { + throw new ServiceException("座位已被占用:第" + rowIndex + "行第" + colIndex + "列已存在座位[" + existing.getSeatId() + "]"); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassroomInfoServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassroomInfoServiceImpl.java index ac5e1e613..d63da08b8 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassroomInfoServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassroomInfoServiceImpl.java @@ -60,6 +60,8 @@ public class TrainClassroomInfoServiceImpl implements ITrainClassroomInfoService vo.setSeatArrange(classroom.getSeatArrange()); vo.setMultimedia(classroom.getMultimedia()); vo.setProjector(classroom.getProjector()); + vo.setId(classroom.getId()); + vo.setBindBoardId(classroom.getBindBoardId()); // 按 bind_classroom_id 查询绑定的电子班牌(0..N),映射为轻量摘要 VO,无绑定时返回空列表 [] TrainElectronicBoard boardQuery = new TrainElectronicBoard(); @@ -93,6 +95,22 @@ public class TrainClassroomInfoServiceImpl implements ITrainClassroomInfoService return trainClassroomInfoMapper.selectTrainClassroomInfoByClassRoomId(classRoomId); } + /** + * 查询某开班计划(planId)课表中用到的教室信息(按教室去重) + * + * @param planId 开班计划ID + * @return 教室信息集合 + */ + @Override + public List selectClassroomByPlanId(String planId) + { + if (StringUtils.isEmpty(planId)) + { + return new ArrayList<>(); + } + return trainClassroomInfoMapper.selectTrainClassroomInfoByPlanId(planId); + } + /** * 查询教室信息列表 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormitoryInfoServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormitoryInfoServiceImpl.java index efae3053b..e5e23e574 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormitoryInfoServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormitoryInfoServiceImpl.java @@ -15,7 +15,9 @@ import com.ruoyi.train.domain.TrainDormitoryInfo; import com.ruoyi.train.domain.dto.DormitoryCreateDTO; import com.ruoyi.train.domain.vo.DormOptionsVO; import com.ruoyi.train.domain.vo.DormitoryDetailVO; +import com.ruoyi.train.domain.vo.DormitoryLayoutRoomVO; import com.ruoyi.train.domain.vo.DormitoryOccupantVO; +import com.ruoyi.train.mapper.TrainAccommodationMapper; import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; import com.ruoyi.train.service.ITrainDormitoryInfoService; @@ -31,6 +33,9 @@ public class TrainDormitoryInfoServiceImpl implements ITrainDormitoryInfoService @Autowired private TrainDormitoryInfoMapper trainDormitoryInfoMapper; + @Autowired + private TrainAccommodationMapper trainAccommodationMapper; + /** * 查询宿舍信息 (基础Domain) * @@ -71,6 +76,7 @@ public class TrainDormitoryInfoServiceImpl implements ITrainDormitoryInfoService detailVO.setDormStatus(dorm.getDormStatus()); detailVO.setRemark(dorm.getRemark()); detailVO.setCreateTime(dorm.getCreateTime()); + detailVO.setId(dorm.getId()); // 查询当前有效入住学员列表 List occupants = trainDormitoryInfoMapper.selectOccupantsByDormId(dormId); @@ -201,6 +207,8 @@ public class TrainDormitoryInfoServiceImpl implements ITrainDormitoryInfoService /** * 批量删除宿舍信息 + *

+ * 删除前校验:若房间存在在住/已分配人员(accommodation_status 0 预分配 / 1 已入住),禁止删除,避免产生孤儿住宿记录。 * * @param dormIds 需要删除的宿舍信息主键 * @return 结果 @@ -208,11 +216,29 @@ public class TrainDormitoryInfoServiceImpl implements ITrainDormitoryInfoService @Override public int deleteTrainDormitoryInfoByDormIds(String[] dormIds) { + if (dormIds == null || dormIds.length == 0) + { + return 0; + } + List occupied = new ArrayList<>(); + for (String dormId : dormIds) + { + if (trainAccommodationMapper.countActiveByDormId(dormId) > 0) + { + occupied.add(dormId); + } + } + if (!occupied.isEmpty()) + { + throw new ServiceException("以下房间存在在住/已分配人员,禁止删除:" + String.join("、", occupied)); + } return trainDormitoryInfoMapper.deleteTrainDormitoryInfoByDormIds(dormIds); } /** * 删除宿舍信息 + *

+ * 删除前校验:若房间存在在住/已分配人员,禁止删除。 * * @param dormId 宿舍信息主键 * @return 结果 @@ -220,6 +246,10 @@ public class TrainDormitoryInfoServiceImpl implements ITrainDormitoryInfoService @Override public int deleteTrainDormitoryInfoByDormId(String dormId) { + if (trainAccommodationMapper.countActiveByDormId(dormId) > 0) + { + throw new ServiceException("房间「" + dormId + "」存在在住/已分配人员,禁止删除"); + } return trainDormitoryInfoMapper.deleteTrainDormitoryInfoByDormId(dormId); } @@ -271,6 +301,7 @@ public class TrainDormitoryInfoServiceImpl implements ITrainDormitoryInfoService String roomLabel = dorm.getDormRoom().endsWith("室") ? dorm.getDormRoom() : dorm.getDormRoom() + "室"; DormOptionsVO roomNode = new DormOptionsVO(dorm.getDormId(), roomLabel, "room"); roomNode.setDormId(dorm.getDormId()); + roomNode.setId(dorm.getId()); roomNode.setDormBuilding(dorm.getDormBuilding()); roomNode.setDormFloor(dorm.getDormFloor()); roomNode.setDormRoom(dorm.getDormRoom()); @@ -293,4 +324,52 @@ public class TrainDormitoryInfoServiceImpl implements ITrainDormitoryInfoService return resultTree; } + + /** + * 查询宿舍布局(楼栋 → 楼层 → 房间号)。 + *

+ * 结构:{楼栋名称: {楼层: {房间号: {id, dorm_room, accommodation}}}} + * 其中 accommodation = "已分配床位数/总床位数",已分配数统计住宿状态为 0(预分配)、1(已入住) 的记录, + * 总床位数取房间核定容量 capacity。使用 LinkedHashMap 保证与 SQL 排序(楼栋、楼层、房间号)一致。 + * + * @return 有序嵌套结构 + */ + @Override + public Map selectDormitoryLayout() + { + Map layout = new LinkedHashMap<>(); + List rooms = trainDormitoryInfoMapper.selectDormitoryLayoutRoomList(); + if (rooms == null || rooms.isEmpty()) + { + return layout; + } + for (DormitoryLayoutRoomVO room : rooms) + { + if (room == null || StringUtils.isEmpty(room.getDormBuilding())) + { + continue; + } + String building = room.getDormBuilding(); + String floor = StringUtils.isEmpty(room.getDormFloor()) ? "未标注楼层" : room.getDormFloor(); + String dormRoom = StringUtils.isEmpty(room.getDormRoom()) ? "" : room.getDormRoom(); + + int assigned = room.getAssignedCount() == null ? 0 : room.getAssignedCount(); + int capacity = room.getCapacity() == null ? 0 : room.getCapacity(); + + Map roomInfo = new LinkedHashMap<>(4); + roomInfo.put("id", room.getId()); + roomInfo.put("dorm_room", room.getDormRoom()); + roomInfo.put("accommodation", assigned + "/" + capacity); + + // 楼栋 -> 楼层 -> 房间号 逐层放入,保持插入顺序 + @SuppressWarnings("unchecked") + Map floors = (Map) layout.computeIfAbsent(building, + k -> new LinkedHashMap()); + @SuppressWarnings("unchecked") + Map roomMap = (Map) floors.computeIfAbsent(floor, + k -> new LinkedHashMap()); + roomMap.put(dormRoom, roomInfo); + } + return layout; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java index 44e81b683..9ba08090a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java @@ -52,6 +52,12 @@ public class TrainEnrollmentServiceImpl implements ITrainEnrollmentService return trainEnrollmentMapper.selectEnrollmentItemList(query); } + @Override + public List selectEnrollmentsByStudentId(String studentId) + { + return trainEnrollmentMapper.selectEnrollmentsByStudentId(studentId); + } + @Override public int countEnrollmentsByPlanId(String planId) { diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainExamReportServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainExamReportServiceImpl.java new file mode 100644 index 000000000..29027f1d5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainExamReportServiceImpl.java @@ -0,0 +1,708 @@ +package com.ruoyi.train.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.DigestUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainExamReport; +import com.ruoyi.train.domain.TrainExamReportBatch; +import com.ruoyi.train.domain.TrainExamSubject; +import com.ruoyi.train.domain.TrainOpenApiLog; +import com.ruoyi.train.domain.dto.OpenExamReportDTO; +import com.ruoyi.train.domain.dto.OpenExamReportItemDTO; +import com.ruoyi.train.domain.vo.OpenMatchStudentVO; +import com.ruoyi.train.mapper.TrainExamReportBatchMapper; +import com.ruoyi.train.mapper.TrainExamReportMapper; +import com.ruoyi.train.mapper.TrainOpenApiLogMapper; +import com.ruoyi.train.mapper.TrainOpenStudentMapper; +import com.ruoyi.train.service.ITrainExamReportService; +import com.ruoyi.train.service.ITrainExamSubjectService; + +/** + * 外部实训成绩上报Service业务层处理 + * + *

实现 3.2 在训学员查询、3.3 批量上报、3.4 实时上报、3.5 结果查询,以及后台的成绩列表/认领/删除。 + * 幂等由 report_id 唯一索引兜底,批次幂等由 (app_id, batch_id) 唯一索引兜底。

+ * + * @author ruoyi + * @date 2026-11-05 + */ +@Service +public class TrainExamReportServiceImpl implements ITrainExamReportService +{ + private static final Logger log = LoggerFactory.getLogger(TrainExamReportServiceImpl.class); + + /** 批量上报单批上限 */ + private static final int MAX_BATCH_SIZE = 200; + + /** 实时上报单次上限 */ + private static final int MAX_REALTIME_SIZE = 20; + + /** 单次考试最大跨度 24 小时 */ + private static final long MAX_EXAM_SPAN_MS = 24L * 60 * 60 * 1000; + + /** 实时上报允许的时钟超前量 10 分钟 */ + private static final long REALTIME_AHEAD_MS = 10L * 60 * 1000; + + /** 实时上报时间窗下限 24 小时 */ + private static final long REALTIME_WINDOW_MS = 24L * 60 * 60 * 1000; + + /** 补传窗口 90 天 */ + private static final long RESEND_WINDOW_MS = 90L * 24 * 60 * 60 * 1000; + + /** 查询时间窗最大跨度 31 天 */ + private static final long QUERY_MAX_SPAN_MS = 31L * 24 * 60 * 60 * 1000; + + /** content 上限 32 KB */ + private static final int CONTENT_MAX_BYTES = 32 * 1024; + + private static final BigDecimal DEFAULT_FULL_SCORE = new BigDecimal("100.00"); + + private static final String MATCHED = "MATCHED"; + + private static final String UNMATCHED = "UNMATCHED"; + + private static final String RECEIVED = "RECEIVED"; + + private static final String PERIODIC = "PERIODIC"; + + private static final String REALTIME = "REALTIME"; + + @Autowired + private TrainExamReportMapper trainExamReportMapper; + + @Autowired + private TrainExamReportBatchMapper trainExamReportBatchMapper; + + @Autowired + private TrainOpenStudentMapper trainOpenStudentMapper; + + @Autowired + private TrainOpenApiLogMapper trainOpenApiLogMapper; + + @Autowired + private ITrainExamSubjectService trainExamSubjectService; + + // 本项目为 Spring Boot 4(Jackson 3),容器不会提供 Jackson 2 的 ObjectMapper bean,本地实例化即可 + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + public Map selectInTrainingStudents(String classId, int pageNo, int pageSize) + { + int safePageNo = pageNo <= 0 ? 1 : pageNo; + int safePageSize = pageSize <= 0 ? 50 : Math.min(pageSize, 200); + int total = trainOpenStudentMapper.countInTrainingStudents(classId); + Map data = new LinkedHashMap<>(); + data.put("total", total); + data.put("pageNo", safePageNo); + data.put("pageSize", safePageSize); + if (total == 0) + { + data.put("list", new ArrayList<>()); + return data; + } + int offset = (safePageNo - 1) * safePageSize; + data.put("list", trainOpenStudentMapper.selectInTrainingStudents(classId, offset, safePageSize)); + return data; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map reportBatch(String appId, OpenExamReportDTO dto, String reqId) + { + if (dto == null || StringUtils.isEmpty(dto.getBatchId())) + { + throw new ServiceException("参数缺失:batchId 均为必填", 1001); + } + if (dto.getBatchId().length() > 64) + { + throw new ServiceException("参数格式错误:batchId 长度不能超过 64", 1002); + } + if (dto.getReportTime() == null || dto.getPeriodStart() == null || dto.getPeriodEnd() == null) + { + throw new ServiceException("参数缺失:reportTime、periodStart、periodEnd 均为必填", 1001); + } + if (dto.getPeriodEnd() <= dto.getPeriodStart()) + { + throw new ServiceException("考试时间超出允许范围:periodEnd 必须大于 periodStart", 3004); + } + List items = dto.getReports(); + if (items == null || items.isEmpty()) + { + throw new ServiceException("参数缺失:reports 不能为空", 1001); + } + if (items.size() > MAX_BATCH_SIZE) + { + throw new ServiceException("消息体超过大小限制:单批最多 200 条,请拆分批次", 3003); + } + + TrainExamReportBatch existBatch = trainExamReportBatchMapper + .selectTrainExamReportBatchByBatchId(appId, dto.getBatchId()); + if (existBatch != null) + { + boolean sameMeta = eq(existBatch.getReportTime(), dto.getReportTime()) + && eq(existBatch.getPeriodStart(), dto.getPeriodStart()) + && eq(existBatch.getPeriodEnd(), dto.getPeriodEnd()) + && eq(existBatch.getResend(), Boolean.TRUE.equals(dto.getResend()) ? 1 : 0) + && eq(existBatch.getTotal(), items.size()); + if (!sameMeta) + { + throw new ServiceException("数据冲突:同一 batchId 的批次参数不一致", 2003); + } + return batchSummary(existBatch); + } + + long now = System.currentTimeMillis(); + int resendFlag = Boolean.TRUE.equals(dto.getResend()) ? 1 : 0; + TrainExamReportBatch batch = new TrainExamReportBatch(); + batch.setAppId(appId); + batch.setBatchId(dto.getBatchId()); + batch.setReportTime(dto.getReportTime()); + batch.setPeriodStart(dto.getPeriodStart()); + batch.setPeriodEnd(dto.getPeriodEnd()); + batch.setResend(resendFlag); + batch.setTotal(items.size()); + batch.setAccepted(0); + batch.setDuplicated(0); + batch.setUnmatched(0); + batch.setReceivedTime(now); + batch.setReqId(reqId); + trainExamReportBatchMapper.insertTrainExamReportBatch(batch); + + // 批内 reportId 不得重复 + Set reportIds = new HashSet<>(); + for (OpenExamReportItemDTO item : items) + { + validateItem(item, false, dto.getPeriodStart(), dto.getPeriodEnd(), resendFlag); + if (!reportIds.add(item.getReportId())) + { + throw new ServiceException("上报数据校验失败:批内 reportId 重复 " + item.getReportId(), 3001); + } + } + + int accepted = 0; + int duplicated = 0; + int unmatched = 0; + for (OpenExamReportItemDTO item : items) + { + if (trainExamReportMapper.selectTrainExamReportByReportId(item.getReportId()) != null) + { + duplicated++; + continue; + } + boolean matched = persistReport(appId, item, batch.getBatchPk(), dto.getBatchId(), PERIODIC, resendFlag, + dto.getReportTime(), now); + if (matched) + { + accepted++; + } + else + { + unmatched++; + } + } + batch.setAccepted(accepted); + batch.setDuplicated(duplicated); + batch.setUnmatched(unmatched); + trainExamReportBatchMapper.updateTrainExamReportBatchSummary(batch); + appendLog(appId, "/api/open/v1/exam/report", dto.getBatchId(), 0, "success", reqId, now); + log.info("开放接口批量上报完成 appId={}, batchId={}, total={}, accepted={}, duplicated={}, unmatched={}", + appId, dto.getBatchId(), items.size(), accepted, duplicated, unmatched); + return batchSummary(batch); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map reportRealtime(String appId, OpenExamReportDTO dto, String reqId) + { + if (dto == null || dto.getReports() == null || dto.getReports().isEmpty()) + { + throw new ServiceException("参数缺失:reports 不能为空", 1001); + } + List items = dto.getReports(); + if (items.size() > MAX_REALTIME_SIZE) + { + throw new ServiceException("消息体超过大小限制:实时上报单次最多 20 条", 3003); + } + long now = System.currentTimeMillis(); + long reportTime = dto.getReportTime() == null ? now : dto.getReportTime(); + int resendFlag = Boolean.TRUE.equals(dto.getResend()) ? 1 : 0; + + Set reportIds = new HashSet<>(); + for (OpenExamReportItemDTO item : items) + { + validateItem(item, true, null, null, resendFlag); + if (!reportIds.add(item.getReportId())) + { + throw new ServiceException("上报数据校验失败:同一请求内 reportId 重复 " + item.getReportId(), 3001); + } + } + + int accepted = 0; + int duplicated = 0; + int unmatched = 0; + for (OpenExamReportItemDTO item : items) + { + if (trainExamReportMapper.selectTrainExamReportByReportId(item.getReportId()) != null) + { + duplicated++; + continue; + } + boolean matched = persistReport(appId, item, null, null, REALTIME, resendFlag, reportTime, now); + if (matched) + { + accepted++; + } + else + { + unmatched++; + } + } + Map data = new LinkedHashMap<>(); + data.put("total", items.size()); + data.put("accepted", accepted); + data.put("duplicated", duplicated); + data.put("unmatched", unmatched); + data.put("receivedTime", now); + appendLog(appId, "/api/open/v1/exam/report/realtime", null, 0, "success", reqId, now); + log.info("开放接口实时上报完成 appId={}, total={}, accepted={}, duplicated={}, unmatched={}", + appId, items.size(), accepted, duplicated, unmatched); + return data; + } + + @Override + public Map queryResult(String appId, String batchId, String reportId, Long periodStart, + Long periodEnd, int pageNo, int pageSize) + { + if (StringUtils.isNotEmpty(batchId) && StringUtils.isNotEmpty(reportId)) + { + throw new ServiceException("参数格式错误:batchId 与 reportId 只能二选一", 1002); + } + int safePageNo = pageNo <= 0 ? 1 : pageNo; + int safePageSize = pageSize <= 0 ? 50 : Math.min(pageSize, 200); + int offset = (safePageNo - 1) * safePageSize; + + TrainExamReport query = new TrainExamReport(); + query.setAppId(appId); + Map data = new LinkedHashMap<>(); + + if (StringUtils.isNotEmpty(reportId)) + { + TrainExamReport report = trainExamReportMapper.selectTrainExamReportByReportId(reportId); + if (report == null || !appId.equals(report.getAppId())) + { + // 不区分“不存在”与“不属于本应用”,避免探测 + throw new ServiceException("数据不存在", 2002); + } + List> list = new ArrayList<>(); + list.add(resultItem(report)); + data.put("batchId", report.getBatchId()); + data.put("reportTime", report.getReportTime()); + data.put("periodStart", null); + data.put("periodEnd", null); + data.put("total", 1); + data.put("accepted", MATCHED.equals(report.getMatchStatus()) ? 1 : 0); + data.put("duplicated", 0); + data.put("unmatched", UNMATCHED.equals(report.getMatchStatus()) ? 1 : 0); + data.put("list", list); + return data; + } + + if (StringUtils.isNotEmpty(batchId)) + { + TrainExamReportBatch batch = trainExamReportBatchMapper.selectTrainExamReportBatchByBatchId(appId, + batchId); + if (batch == null) + { + throw new ServiceException("数据不存在", 2002); + } + query.setBatchId(batchId); + Map summary = safeSummary(query); + data.put("batchId", batch.getBatchId()); + data.put("reportTime", batch.getReportTime()); + data.put("periodStart", batch.getPeriodStart()); + data.put("periodEnd", batch.getPeriodEnd()); + data.put("total", batch.getTotal()); + data.put("accepted", batch.getAccepted()); + data.put("duplicated", batch.getDuplicated()); + data.put("unmatched", batch.getUnmatched()); + data.put("pageNo", safePageNo); + data.put("pageSize", safePageSize); + data.put("list", toResultList(trainExamReportMapper.selectTrainExamReportList(query), offset, safePageSize)); + log.debug("批次对账汇总 {}", summary); + return data; + } + + if (periodStart == null || periodEnd == null) + { + throw new ServiceException("参数缺失:batchId / reportId / 时间窗 至少提供一种查询条件", 1001); + } + if (periodEnd <= periodStart) + { + throw new ServiceException("参数格式错误:periodEnd 必须大于 periodStart", 1002); + } + if (periodEnd - periodStart > QUERY_MAX_SPAN_MS) + { + throw new ServiceException("参数格式错误:时间窗跨度不能超过 31 天", 1002); + } + query.getParams().put("periodStart", periodStart); + query.getParams().put("periodEnd", periodEnd); + Map summary = safeSummary(query); + int total = toInt(summary.get("total")); + int unmatched = toInt(summary.get("unmatched")); + data.put("batchId", null); + data.put("reportTime", null); + data.put("periodStart", periodStart); + data.put("periodEnd", periodEnd); + data.put("total", total); + data.put("accepted", total - unmatched); + data.put("duplicated", 0); + data.put("unmatched", unmatched); + data.put("pageNo", safePageNo); + data.put("pageSize", safePageSize); + data.put("list", toResultList(trainExamReportMapper.selectTrainExamReportList(query), offset, safePageSize)); + return data; + } + + @Override + public List selectTrainExamReportList(TrainExamReport query) + { + return trainExamReportMapper.selectTrainExamReportList(query); + } + + @Override + public TrainExamReport selectTrainExamReportByReportPk(Long reportPk) + { + return trainExamReportMapper.selectTrainExamReportByReportPk(reportPk); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int claimReport(Long reportPk, String studentId, String operator) + { + TrainExamReport report = trainExamReportMapper.selectTrainExamReportByReportPk(reportPk); + if (report == null) + { + throw new ServiceException("数据不存在", 2002); + } + OpenMatchStudentVO match = trainOpenStudentMapper.selectMatchEnrollment(studentId); + if (match == null) + { + throw new ServiceException("学员不存在或不在训", 2004); + } + TrainExamReport update = new TrainExamReport(); + update.setReportPk(reportPk); + update.setStudentId(studentId); + update.setStudentName(StringUtils.isNotEmpty(report.getStudentName()) ? report.getStudentName() + : match.getStudentName()); + update.setMatchedEnrollmentId(match.getEnrollmentId()); + update.setMatchedPlanId(match.getPlanId()); + update.setMatchStatus(MATCHED); + update.setClaimBy(operator); + update.setClaimTime(new Date()); + return trainExamReportMapper.updateTrainExamReport(update); + } + + @Override + public int deleteTrainExamReportByReportPks(Long[] reportPks) + { + return trainExamReportMapper.deleteTrainExamReportByReportPks(reportPks); + } + + /** + * 校验单条上报数据 + */ + private void validateItem(OpenExamReportItemDTO item, boolean realtime, Long periodStart, Long periodEnd, + int resendFlag) + { + if (item == null) + { + throw new ServiceException("参数缺失:reports 存在空元素", 1001); + } + if (StringUtils.isEmpty(item.getReportId()) || item.getReportId().length() > 64) + { + throw new ServiceException("上报数据校验失败:reportId 必填且长度不超过 64", 3001); + } + if (StringUtils.isEmpty(item.getStudentId())) + { + throw new ServiceException("参数缺失:studentId 必填", 1001); + } + if (StringUtils.isEmpty(item.getSubjectName())) + { + throw new ServiceException("参数缺失:subjectName 必填", 1001); + } + if (StringUtils.isEmpty(item.getStation())) + { + throw new ServiceException("参数缺失:station 必填", 1001); + } + if (item.getExamStartTime() == null || item.getExamEndTime() == null) + { + throw new ServiceException("参数缺失:examStartTime、examEndTime 必填", 1001); + } + if (item.getScore() == null) + { + throw new ServiceException("参数缺失:score 必填", 1001); + } + if (item.getPass() == null || item.getRetake() == null) + { + throw new ServiceException("参数缺失:pass、retake 必填", 1001); + } + BigDecimal fullScore = item.getFullScore() == null ? DEFAULT_FULL_SCORE : item.getFullScore(); + if (item.getExamEndTime() <= item.getExamStartTime()) + { + throw new ServiceException("考试时间超出允许范围:examEndTime 必须大于 examStartTime", 3004); + } + if (item.getExamEndTime() - item.getExamStartTime() > MAX_EXAM_SPAN_MS) + { + throw new ServiceException("考试时间超出允许范围:单次考试跨度不能超过 24 小时", 3004); + } + // ==================== 【临时关闭】实时上报时间窗校验 begin ==================== + // 关闭原因:联调/历史数据回放阶段,外部系统上报的 examEndTime 经常超出实时窗口, + // 为避免返回 3004 拒绝上报,暂时停用该限制;仅关闭"时间窗"这一项校验, + // 上报落库逻辑与其他字段校验(时间先后、跨度、分数、content 等)均保持不变。 + // 原限制规则(恢复后生效): + // resend=false → examEndTime ∈ [now - 24h, now + 10min] + // resend=true → examEndTime ∈ [now - 90d, now + 10min] + // 恢复方式:取消下面整段注释,并将紧随其后的 + // `if (!realtime && periodStart != null && periodEnd != null)` + // 改回 `else if (periodStart != null && periodEnd != null)` 即可。 + // 说明:REALTIME_WINDOW_MS、REALTIME_AHEAD_MS、RESEND_WINDOW_MS 三个常量保留未删除, + // 恢复时无需重新定义。 + // if (realtime) + // { + // long now = System.currentTimeMillis(); + // long lowerBound = now - (resendFlag == 1 ? RESEND_WINDOW_MS : REALTIME_WINDOW_MS); + // if (item.getExamEndTime() > now + REALTIME_AHEAD_MS || item.getExamEndTime() < lowerBound) + // { + // throw new ServiceException("考试时间超出允许范围:examEndTime 超出实时上报时间窗", 3004); + // } + // } + // ==================== 【临时关闭】实时上报时间窗校验 end ====================== + if (!realtime && periodStart != null && periodEnd != null) + { + if (item.getExamEndTime() < periodStart || item.getExamEndTime() >= periodEnd) + { + throw new ServiceException("考试时间超出允许范围:examEndTime 未落在 periodStart~periodEnd 内", 3004); + } + } + if (item.getScore().compareTo(BigDecimal.ZERO) < 0 || item.getScore().compareTo(fullScore) > 0) + { + throw new ServiceException("上报数据校验失败:score 必须满足 0 ≤ score ≤ fullScore", 3001); + } + if (item.getScore().scale() > 2) + { + throw new ServiceException("上报数据校验失败:score 最多两位小数", 3001); + } + if (Boolean.TRUE.equals(item.getRetake()) && (item.getRetakeNo() == null || item.getRetakeNo() < 1)) + { + throw new ServiceException("上报数据校验失败:retake=true 时 retakeNo 必须从 1 递增", 3001); + } + if (!(item.getContent() instanceof Map)) + { + throw new ServiceException("考试内容 JSON 不合法:content 必须为 JSON 对象", 3005); + } + if (StringUtils.isNotEmpty(item.getSubjectCode())) + { + TrainExamSubject subject = trainExamSubjectService.selectTrainExamSubjectBySubjectCode( + item.getSubjectCode()); + if (subject == null || subject.getStatus() == null || subject.getStatus() != 1) + { + throw new ServiceException("上报数据校验失败:subjectCode 未在平台科目字典中启用", 3001); + } + } + } + + /** + * 落库单条成绩,返回是否匹配成功 + */ + private boolean persistReport(String appId, OpenExamReportItemDTO item, Long batchPk, String batchId, + String channel, int resend, Long reportTime, long receivedTime) + { + String contentJson; + try + { + contentJson = OBJECT_MAPPER.writeValueAsString(item.getContent()); + } + catch (Exception e) + { + throw new ServiceException("考试内容 JSON 不合法:无法序列化 content", 3005); + } + byte[] contentBytes = contentJson.getBytes(StandardCharsets.UTF_8); + if (contentBytes.length > CONTENT_MAX_BYTES) + { + throw new ServiceException("考试内容 JSON 不合法:content 超过 32 KB", 3005); + } + + TrainExamReport report = new TrainExamReport(); + report.setReportId(item.getReportId()); + report.setAppId(appId); + report.setBatchPk(batchPk); + report.setBatchId(batchId); + report.setReportChannel(channel); + report.setBizType("PRACTICE"); + report.setStudentId(item.getStudentId()); + report.setStudentName(item.getStudentName()); + report.setSubjectCode(item.getSubjectCode()); + report.setSubjectName(item.getSubjectName()); + report.setExamStartTime(item.getExamStartTime()); + report.setExamEndTime(item.getExamEndTime()); + report.setDurationSeconds((int) ((item.getExamEndTime() - item.getExamStartTime()) / 1000L)); + report.setScore(item.getScore().setScale(2, RoundingMode.HALF_UP)); + report.setFullScore(item.getFullScore() == null ? DEFAULT_FULL_SCORE + : item.getFullScore().setScale(2, RoundingMode.HALF_UP)); + report.setPassFlag(Boolean.TRUE.equals(item.getPass()) ? 1 : 0); + report.setRetake(Boolean.TRUE.equals(item.getRetake()) ? 1 : 0); + report.setRetakeNo(item.getRetakeNo() == null ? 0 : item.getRetakeNo()); + report.setStation(item.getStation()); + report.setContent(contentJson); + report.setContentSize(contentBytes.length); + report.setContentMd5(DigestUtils.md5DigestAsHex(contentBytes)); + report.setReportTime(reportTime); + report.setReceivedTime(receivedTime); + report.setResend(resend); + report.setScoreStatus(RECEIVED); + report.setRemark(item.getRemark()); + + if (StringUtils.isNotEmpty(item.getIdCard())) + { + report.setIdCardMasked(maskIdCard(item.getIdCard())); + report.setIdCardMd5(DigestUtils.md5DigestAsHex(item.getIdCard().getBytes(StandardCharsets.UTF_8))); + } + + OpenMatchStudentVO match = trainOpenStudentMapper.selectMatchEnrollment(item.getStudentId()); + boolean matched = match != null && idCardConsistent(item.getIdCard(), match.getIdCardLast4()); + if (matched) + { + report.setMatchStatus(MATCHED); + report.setMatchedEnrollmentId(match.getEnrollmentId()); + report.setMatchedPlanId(match.getPlanId()); + if (StringUtils.isEmpty(report.getStudentName())) + { + report.setStudentName(match.getStudentName()); + } + } + else + { + report.setMatchStatus(UNMATCHED); + } + trainExamReportMapper.insertTrainExamReport(report); + return matched; + } + + private boolean idCardConsistent(String idCard, String platformLast4) + { + if (StringUtils.isEmpty(idCard) || StringUtils.isEmpty(platformLast4)) + { + return true; + } + String last4 = idCard.length() >= 4 ? idCard.substring(idCard.length() - 4) : idCard; + return platformLast4.equalsIgnoreCase(last4); + } + + private String maskIdCard(String idCard) + { + if (idCard.length() >= 10) + { + return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4); + } + return "****" + (idCard.length() >= 4 ? idCard.substring(idCard.length() - 4) : ""); + } + + private Map batchSummary(TrainExamReportBatch batch) + { + Map data = new LinkedHashMap<>(); + data.put("batchId", batch.getBatchId()); + data.put("total", batch.getTotal()); + data.put("accepted", batch.getAccepted()); + data.put("duplicated", batch.getDuplicated()); + data.put("unmatched", batch.getUnmatched()); + data.put("receivedTime", batch.getReceivedTime()); + return data; + } + + private Map resultItem(TrainExamReport report) + { + Map item = new LinkedHashMap<>(); + item.put("reportId", report.getReportId()); + item.put("subjectName", report.getSubjectName()); + item.put("studentId", report.getStudentId()); + item.put("matchStatus", report.getMatchStatus()); + item.put("scoreStatus", report.getScoreStatus()); + item.put("score", report.getScore()); + item.put("pass", report.getPassFlag() != null && report.getPassFlag() == 1); + item.put("retake", report.getRetake() != null && report.getRetake() == 1); + item.put("retakeNo", report.getRetakeNo()); + item.put("examStartTime", report.getExamStartTime()); + item.put("examEndTime", report.getExamEndTime()); + item.put("receivedTime", report.getReceivedTime()); + return item; + } + + private List> toResultList(List reports, int offset, int pageSize) + { + List> list = new ArrayList<>(); + if (reports == null || reports.isEmpty()) + { + return list; + } + int from = Math.min(offset, reports.size()); + int to = Math.min(from + pageSize, reports.size()); + for (TrainExamReport report : reports.subList(from, to)) + { + list.add(resultItem(report)); + } + return list; + } + + private Map safeSummary(TrainExamReport query) + { + Map summary = trainExamReportMapper.selectTrainExamReportSummary(query); + return summary == null ? new LinkedHashMap<>() : summary; + } + + private int toInt(Object value) + { + return value == null ? 0 : new BigDecimal(value.toString()).intValue(); + } + + private boolean eq(Object left, Object right) + { + return left != null && left.toString().equals(right == null ? "" : right.toString()); + } + + private void appendLog(String appId, String path, String bizKey, int code, String msg, String reqId, long startTime) + { + try + { + TrainOpenApiLog apiLog = new TrainOpenApiLog(); + apiLog.setReqId(reqId); + apiLog.setAppId(appId); + apiLog.setApiPath(path); + apiLog.setHttpMethod("POST"); + apiLog.setBizKey(bizKey); + apiLog.setCode(code); + apiLog.setMsg(msg); + apiLog.setCostMs((int) (System.currentTimeMillis() - startTime)); + trainOpenApiLogMapper.insertTrainOpenApiLog(apiLog); + } + catch (Exception e) + { + log.warn("开放接口日志写入失败,不影响主流程", e); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainExamSubjectServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainExamSubjectServiceImpl.java new file mode 100644 index 000000000..ad7512c53 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainExamSubjectServiceImpl.java @@ -0,0 +1,97 @@ +package com.ruoyi.train.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainExamSubject; +import com.ruoyi.train.mapper.TrainExamSubjectMapper; +import com.ruoyi.train.service.ITrainExamSubjectService; + +/** + * 考试科目字典Service业务层处理 + * + * @author ruoyi + * @date 2026-11-05 + */ +@Service +public class TrainExamSubjectServiceImpl implements ITrainExamSubjectService +{ + @Autowired + private TrainExamSubjectMapper trainExamSubjectMapper; + + @Override + public List selectTrainExamSubjectList(TrainExamSubject trainExamSubject) + { + return trainExamSubjectMapper.selectTrainExamSubjectList(trainExamSubject); + } + + @Override + public TrainExamSubject selectTrainExamSubjectBySubjectId(Long subjectId) + { + return trainExamSubjectMapper.selectTrainExamSubjectBySubjectId(subjectId); + } + + @Override + public TrainExamSubject selectTrainExamSubjectBySubjectCode(String subjectCode) + { + if (StringUtils.isEmpty(subjectCode)) + { + return null; + } + return trainExamSubjectMapper.selectTrainExamSubjectBySubjectCode(subjectCode); + } + + @Override + public int insertTrainExamSubject(TrainExamSubject trainExamSubject) + { + validate(trainExamSubject, true); + if (trainExamSubject.getStatus() == null) + { + trainExamSubject.setStatus(1); + } + if (trainExamSubject.getSortNo() == null) + { + trainExamSubject.setSortNo(0); + } + return trainExamSubjectMapper.insertTrainExamSubject(trainExamSubject); + } + + @Override + public int updateTrainExamSubject(TrainExamSubject trainExamSubject) + { + validate(trainExamSubject, false); + return trainExamSubjectMapper.updateTrainExamSubject(trainExamSubject); + } + + @Override + public int deleteTrainExamSubjectBySubjectIds(Long[] subjectIds) + { + return trainExamSubjectMapper.deleteTrainExamSubjectBySubjectIds(subjectIds); + } + + private void validate(TrainExamSubject subject, boolean insert) + { + if (subject == null) + { + throw new ServiceException("考试科目不能为空"); + } + if (insert && StringUtils.isEmpty(subject.getSubjectCode())) + { + throw new ServiceException("科目编码不能为空"); + } + if (insert && StringUtils.isEmpty(subject.getSubjectName())) + { + throw new ServiceException("科目名称不能为空"); + } + if (StringUtils.isNotEmpty(subject.getSubjectCode())) + { + TrainExamSubject exist = trainExamSubjectMapper.selectTrainExamSubjectBySubjectCode(subject.getSubjectCode()); + if (exist != null && !exist.getSubjectId().equals(subject.getSubjectId())) + { + throw new ServiceException("科目编码已存在:" + subject.getSubjectCode()); + } + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainLessonServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainLessonServiceImpl.java new file mode 100644 index 000000000..e246b85d6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainLessonServiceImpl.java @@ -0,0 +1,33 @@ +package com.ruoyi.train.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.vo.CourseScheduleItemVO; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.service.ITrainLessonService; + +/** + * 课次教学活动Service业务层处理(新增)。 + * + * @author ruoyi + */ +@Service +public class TrainLessonServiceImpl implements ITrainLessonService +{ + @Autowired + private TrainLessonMapper trainLessonMapper; + + @Override + public List selectTrainLessonListByPlanId(String planId) + { + return trainLessonMapper.selectTrainLessonListByPlanId(planId); + } + + @Override + public List selectLessonScheduleByTime(String day, String courseType) + { + return trainLessonMapper.selectLessonScheduleByTime(day, courseType); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainOpenAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainOpenAppServiceImpl.java new file mode 100644 index 000000000..f60437bf4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainOpenAppServiceImpl.java @@ -0,0 +1,206 @@ +package com.ruoyi.train.service.impl; + +import java.security.SecureRandom; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainOpenApp; +import com.ruoyi.train.domain.TrainOpenAppScope; +import com.ruoyi.train.mapper.TrainOpenAppMapper; +import com.ruoyi.train.mapper.TrainOpenAppScopeMapper; +import com.ruoyi.train.service.ITrainOpenAppService; + +/** + * 外部实训系统应用Service业务层处理 + * + * @author ruoyi + * @date 2026-11-05 + */ +@Service +public class TrainOpenAppServiceImpl implements ITrainOpenAppService +{ + private static final String SECRET_CHARS = "0123456789abcdef"; + + private static final int SECRET_LENGTH = 24; + + @Autowired + private TrainOpenAppMapper trainOpenAppMapper; + + @Autowired + private TrainOpenAppScopeMapper trainOpenAppScopeMapper; + + @Override + public List selectTrainOpenAppList(TrainOpenApp trainOpenApp) + { + return trainOpenAppMapper.selectTrainOpenAppList(trainOpenApp); + } + + @Override + public TrainOpenApp selectTrainOpenAppByAppId(String appId) + { + return trainOpenAppMapper.selectTrainOpenAppByAppId(appId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int insertTrainOpenApp(TrainOpenApp trainOpenApp) + { + if (StringUtils.isEmpty(trainOpenApp.getAppId())) + { + throw new ServiceException("应用标识不能为空"); + } + if (trainOpenAppMapper.selectTrainOpenAppByAppId(trainOpenApp.getAppId()) != null) + { + throw new ServiceException("应用标识已存在:" + trainOpenApp.getAppId()); + } + String secret = StringUtils.isNotEmpty(trainOpenApp.getAppSecret()) ? trainOpenApp.getAppSecret() + : randomSecret(); + trainOpenApp.setAppSecretHash(SecurityUtils.encryptPassword(secret)); + trainOpenApp.setAppSecretMasked(maskSecret(secret)); + trainOpenApp.setAppSecret(secret); + if (trainOpenApp.getRateLimit() == null) + { + trainOpenApp.setRateLimit(20); + } + if (trainOpenApp.getRealtimeEnabled() == null) + { + trainOpenApp.setRealtimeEnabled(1); + } + if (trainOpenApp.getStatus() == null) + { + trainOpenApp.setStatus(1); + } + trainOpenApp.setCreateBy(StringUtils.isNotEmpty(trainOpenApp.getCreateBy()) ? trainOpenApp.getCreateBy() + : "admin"); + return trainOpenAppMapper.insertTrainOpenApp(trainOpenApp); + } + + @Override + public int updateTrainOpenApp(TrainOpenApp trainOpenApp) + { + if (StringUtils.isEmpty(trainOpenApp.getAppId())) + { + throw new ServiceException("应用标识不能为空"); + } + // 密钥只允许通过“重置密钥”修改,避免误传空值覆盖 + trainOpenApp.setAppSecretHash(null); + trainOpenApp.setAppSecretMasked(null); + return trainOpenAppMapper.updateTrainOpenApp(trainOpenApp); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteTrainOpenAppByAppIds(String[] appIds) + { + for (String appId : appIds) + { + trainOpenAppScopeMapper.deleteTrainOpenAppScopeByAppId(appId); + } + try + { + return trainOpenAppMapper.deleteTrainOpenAppByAppIds(appIds); + } + catch (org.springframework.dao.DataIntegrityViolationException e) + { + // 外键保护:已产生令牌/批次/上报数据的应用不允许物理删除 + throw new ServiceException("该应用存在令牌或上报数据,无法删除;请改为停用(status=0)"); + } + } + + @Override + public String resetAppSecret(String appId) + { + TrainOpenApp app = trainOpenAppMapper.selectTrainOpenAppByAppId(appId); + if (app == null) + { + throw new ServiceException("应用不存在:" + appId); + } + String secret = randomSecret(); + TrainOpenApp update = new TrainOpenApp(); + update.setAppId(appId); + update.setAppSecretHash(SecurityUtils.encryptPassword(secret)); + update.setAppSecretMasked(maskSecret(secret)); + update.setUpdateBy(SecurityUtils.getUsername()); + trainOpenAppMapper.updateTrainOpenApp(update); + return secret; + } + + @Override + public boolean matchesSecret(TrainOpenApp app, String rawSecret) + { + if (app == null || StringUtils.isEmpty(app.getAppSecretHash()) || StringUtils.isEmpty(rawSecret)) + { + return false; + } + return SecurityUtils.matchesPassword(rawSecret, app.getAppSecretHash()); + } + + @Override + public List selectScopeList(String appId) + { + return trainOpenAppScopeMapper.selectTrainOpenAppScopeByAppId(appId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int saveScopes(String appId, List scopes, String operator) + { + if (StringUtils.isEmpty(appId) || trainOpenAppMapper.selectTrainOpenAppByAppId(appId) == null) + { + throw new ServiceException("应用不存在:" + appId); + } + trainOpenAppScopeMapper.deleteTrainOpenAppScopeByAppId(appId); + if (scopes == null || scopes.isEmpty()) + { + return 1; + } + for (TrainOpenAppScope scope : scopes) + { + if (!"CLASS".equals(scope.getScopeType()) && !"SUBJECT".equals(scope.getScopeType())) + { + throw new ServiceException("授权范围类型仅支持 CLASS / SUBJECT"); + } + if (StringUtils.isEmpty(scope.getScopeValue())) + { + throw new ServiceException("授权范围取值不能为空"); + } + scope.setAppId(appId); + scope.setCreateBy(operator); + } + return trainOpenAppScopeMapper.insertTrainOpenAppScopeBatch(scopes); + } + + @Override + public boolean hasScope(String appId, String scopeType, String scopeValue) + { + if (StringUtils.isEmpty(scopeValue)) + { + return true; + } + return trainOpenAppScopeMapper.countTrainOpenAppScope(appId, scopeType, scopeValue) > 0; + } + + private String randomSecret() + { + SecureRandom random = new SecureRandom(); + StringBuilder builder = new StringBuilder(SECRET_LENGTH); + for (int i = 0; i < SECRET_LENGTH; i++) + { + builder.append(SECRET_CHARS.charAt(random.nextInt(SECRET_CHARS.length()))); + } + return builder.toString(); + } + + private String maskSecret(String secret) + { + if (StringUtils.isEmpty(secret) || secret.length() <= 4) + { + return "****"; + } + return "****" + secret.substring(secret.length() - 4); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainOpenTokenServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainOpenTokenServiceImpl.java new file mode 100644 index 000000000..49dd451f7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainOpenTokenServiceImpl.java @@ -0,0 +1,165 @@ +package com.ruoyi.train.service.impl; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainOpenApp; +import com.ruoyi.train.domain.TrainOpenToken; +import com.ruoyi.train.mapper.TrainOpenTokenMapper; +import com.ruoyi.train.service.ITrainOpenTokenService; + +/** + * 外部应用访问令牌Service业务层处理 + * + *

运行期令牌以 Redis 为准(key: open:token:{sha256} / open:token:app:{appId}), + * train_open_token 仅作审计与重启恢复。

+ * + * @author ruoyi + * @date 2026-11-05 + */ +@Service +public class TrainOpenTokenServiceImpl implements ITrainOpenTokenService +{ + private static final Logger log = LoggerFactory.getLogger(TrainOpenTokenServiceImpl.class); + + /** 令牌有效期(秒),与规范一致 */ + public static final long TOKEN_EXPIRE_SECONDS = 7200L; + + private static final String TOKEN_KEY_PREFIX = "open:token:"; + + private static final String TOKEN_APP_KEY_PREFIX = "open:token:app:"; + + private static final String TOKEN_CHARS = "0123456789abcdef"; + + private static final int TOKEN_LENGTH = 48; + + @Autowired + private TrainOpenTokenMapper trainOpenTokenMapper; + + @Autowired + private RedisCache redisCache; + + @Override + public Map issueToken(TrainOpenApp app, String clientIp, String userAgent) + { + if (app == null || StringUtils.isEmpty(app.getAppId())) + { + throw new ServiceException("应用信息不能为空"); + } + // 同应用新令牌签发后旧令牌立即失效 + revokeTokens(app.getAppId()); + + String accessToken = randomToken(); + String tokenHash = sha256(accessToken); + Date now = new Date(); + Date expireTime = new Date(now.getTime() + TOKEN_EXPIRE_SECONDS * 1000L); + + TrainOpenToken record = new TrainOpenToken(); + record.setAppId(app.getAppId()); + record.setTokenHash(tokenHash); + record.setIssuedTime(now); + record.setExpireTime(expireTime); + record.setStatus(1); + record.setClientIp(clientIp); + record.setUserAgent(userAgent); + trainOpenTokenMapper.insertTrainOpenToken(record); + + redisCache.setCacheObject(TOKEN_KEY_PREFIX + tokenHash, app.getAppId(), (int) TOKEN_EXPIRE_SECONDS, + TimeUnit.SECONDS); + redisCache.setCacheObject(TOKEN_APP_KEY_PREFIX + app.getAppId(), tokenHash, (int) TOKEN_EXPIRE_SECONDS, + TimeUnit.SECONDS); + + Map data = new LinkedHashMap<>(); + data.put("accessToken", accessToken); + data.put("tokenType", "Bearer"); + data.put("expiresIn", TOKEN_EXPIRE_SECONDS); + return data; + } + + @Override + public String validateToken(String accessToken) + { + if (StringUtils.isEmpty(accessToken)) + { + return null; + } + String tokenHash = sha256(accessToken.trim()); + String appId = redisCache.getCacheObject(TOKEN_KEY_PREFIX + tokenHash); + if (StringUtils.isNotEmpty(appId)) + { + return appId; + } + // Redis 重启/过期后按库恢复一次(令牌未过期才恢复) + TrainOpenToken record = trainOpenTokenMapper.selectValidTokenByHash(tokenHash); + if (record == null) + { + return null; + } + long remainSeconds = (record.getExpireTime().getTime() - System.currentTimeMillis()) / 1000L; + if (remainSeconds <= 0) + { + return null; + } + redisCache.setCacheObject(TOKEN_KEY_PREFIX + tokenHash, record.getAppId(), (int) remainSeconds, + TimeUnit.SECONDS); + log.info("开放接口令牌从库恢复,appId={}", record.getAppId()); + return record.getAppId(); + } + + @Override + public void revokeTokens(String appId) + { + if (StringUtils.isEmpty(appId)) + { + return; + } + String oldHash = redisCache.getCacheObject(TOKEN_APP_KEY_PREFIX + appId); + if (StringUtils.isNotEmpty(oldHash)) + { + redisCache.deleteObject(TOKEN_KEY_PREFIX + oldHash); + redisCache.deleteObject(TOKEN_APP_KEY_PREFIX + appId); + } + trainOpenTokenMapper.revokeTrainOpenTokenByAppId(appId); + } + + private String randomToken() + { + SecureRandom random = new SecureRandom(); + StringBuilder builder = new StringBuilder(TOKEN_LENGTH); + for (int i = 0; i < TOKEN_LENGTH; i++) + { + builder.append(TOKEN_CHARS.charAt(random.nextInt(TOKEN_CHARS.length()))); + } + return builder.toString(); + } + + private String sha256(String value) + { + try + { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte b : bytes) + { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } + catch (Exception e) + { + throw new ServiceException("令牌摘要计算失败"); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainRegDataServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainRegDataServiceImpl.java new file mode 100644 index 000000000..a79b5d122 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainRegDataServiceImpl.java @@ -0,0 +1,492 @@ +package com.ruoyi.train.service.impl; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.dto.RegReportSaveDTO; +import com.ruoyi.train.domain.vo.RegArrivalFactVO; +import com.ruoyi.train.domain.vo.RegBatchTrendVO; +import com.ruoyi.train.domain.vo.RegClassVO; +import com.ruoyi.train.domain.vo.RegHistorySummaryVO; +import com.ruoyi.train.domain.vo.RegHourlyVO; +import com.ruoyi.train.domain.vo.RegRosterVO; +import com.ruoyi.train.domain.vo.RegSummaryVO; +import com.ruoyi.train.domain.vo.RegUnitVO; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainClassReportMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainRegDataMapper; +import com.ruoyi.train.service.ITrainRegDataService; + +/** + * 班级报到数据 Service业务层处理 + * + *

聚合计算统一在 Java 侧完成,保证 MySQL 与 KingbaseES 行为一致。

+ * + * @author ruoyi + */ +@Service +public class TrainRegDataServiceImpl implements ITrainRegDataService +{ + private static final Logger log = LoggerFactory.getLogger(TrainRegDataServiceImpl.class); + + /** 报到率统计默认纳入的最近批次数 */ + private static final int TREND_BATCH_LIMIT = 8; + + /** 报到方式字典:1=人脸识别 2=身份证刷卡 3=二维码 4=人工录入 */ + private static final String[] REPORT_WAY_TEXT = { "未知方式", "人脸识别", "身份证刷卡", "二维码", "人工录入" }; + + @Autowired + private TrainRegDataMapper trainRegDataMapper; + + @Autowired + private TrainClassPlanMapper trainClassPlanMapper; + + @Autowired + private TrainEnrollmentMapper trainEnrollmentMapper; + + @Autowired + private TrainClassReportMapper trainClassReportMapper; + + @Override + public List selectReportClasses() + { + return trainRegDataMapper.selectReportClasses(); + } + + @Override + public TrainClassPlan selectReportPlan(String planId) + { + if (StringUtils.isEmpty(planId)) + { + throw new ServiceException("planId 不能为空", HttpStatus.BAD_REQUEST); + } + TrainClassPlan plan = trainClassPlanMapper.selectTrainClassPlanByPlanId(planId); + if (plan == null) + { + throw new ServiceException("开班计划不存在: " + planId, HttpStatus.NOT_FOUND); + } + return plan; + } + + @Override + public RegSummaryVO selectReportSummary(String planId) + { + TrainClassPlan plan = selectReportPlan(planId); + int expected = trainRegDataMapper.countExpectedEnrollments(planId); + List facts = trainRegDataMapper.selectPlanArrivalFacts(planId); + + RegSummaryVO vo = new RegSummaryVO(); + int reported = facts.size(); + vo.setExpected(expected); + vo.setReported(reported); + vo.setNotReported(Math.max(expected - reported, 0)); + vo.setRate(percent(reported, expected)); + vo.setStartTime(format(plan.getStartTime(), "yyyy-MM-dd HH:mm:ss")); + vo.setEndTime(format(plan.getEndTime(), "yyyy-MM-dd HH:mm:ss")); + vo.setRemedialDeadline(format(plan.getReportDeadline(), "yyyy-MM-dd HH:mm:ss")); + List hourly = buildHourly(facts); + vo.setHourly(hourly); + vo.setPeakNote(buildPeakNote(hourly)); + return vo; + } + + @Override + public List selectRoster(String planId) + { + selectReportPlan(planId); + return decorate(trainRegDataMapper.selectRosterList(planId, false)); + } + + @Override + public List selectNotReported(String planId) + { + selectReportPlan(planId); + return decorate(trainRegDataMapper.selectRosterList(planId, true)); + } + + @Override + public int urgeReport(String planId, List studentIds, String operator) + { + selectReportPlan(planId); + List targets = trainRegDataMapper.selectRosterList(planId, true); + if (studentIds != null && !studentIds.isEmpty()) + { + Set selected = new HashSet<>(studentIds); + List filtered = new ArrayList<>(); + for (RegRosterVO row : targets) + { + if (row.getStudentId() != null && selected.contains(row.getStudentId())) + { + filtered.add(row); + } + } + targets = filtered; + } + log.info("班级报到催促: planId={}, operator={}, 催促人数={}", planId, operator, targets.size()); + return targets.size(); + } + + @Override + public RegHistorySummaryVO selectHistorySummary() + { + List facts = trainRegDataMapper.selectPlanArrivalFacts(null); + List batches = loadBatchStats(); + + RegHistorySummaryVO vo = new RegHistorySummaryVO(); + vo.setTotalReported(facts.size()); + + double rateSum = 0D; + int rateCount = 0; + for (RegBatchTrendVO batch : batches) + { + if (batch.getExpected() != null && batch.getExpected() > 0) + { + rateSum += batch.getRate() == null ? 0D : batch.getRate(); + rateCount++; + } + } + vo.setAvgRate(rateCount == 0 ? 0D : round1(rateSum / rateCount)); + vo.setBatchCount(rateCount); + + long costSum = 0L; + int costCount = 0; + Date earliest = null; + Date latest = null; + for (RegArrivalFactVO fact : facts) + { + Date reportTime = fact.getReportTime(); + if (reportTime == null) + { + continue; + } + if (earliest == null || reportTime.before(earliest)) + { + earliest = reportTime; + } + if (latest == null || reportTime.after(latest)) + { + latest = reportTime; + } + Date base = fact.getReportStartTime() != null ? fact.getReportStartTime() : fact.getPlanStartTime(); + if (base != null) + { + costSum += Math.max(0L, (reportTime.getTime() - base.getTime()) / 60000L); + costCount++; + } + } + vo.setAvgCostMinutes(costCount == 0 ? 0 : (int) (costSum / costCount)); + vo.setEarliest(format(earliest, "HH:mm")); + vo.setLatest(format(latest, "HH:mm")); + vo.setHourly(buildHourly(facts)); + return vo; + } + + @Override + public List selectBatchTrend() + { + List batches = loadBatchStats(); + if (batches.size() <= TREND_BATCH_LIMIT) + { + Collections.reverse(batches); + return batches; + } + List recent = new ArrayList<>(batches.subList(0, TREND_BATCH_LIMIT)); + Collections.reverse(recent); + return recent; + } + + @Override + public List selectUnitReport() + { + List units = trainRegDataMapper.selectUnitStats(); + if (units == null || units.isEmpty()) + { + return units; + } + Map> minuteMap = new HashMap<>(); + List facts = trainRegDataMapper.selectUnitArrivalFacts(); + for (RegArrivalFactVO fact : facts) + { + if (fact.getUnitId() == null || fact.getReportTime() == null) + { + continue; + } + Calendar calendar = Calendar.getInstance(); + calendar.setTime(fact.getReportTime()); + int minutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE); + minuteMap.computeIfAbsent(fact.getUnitId(), key -> new ArrayList<>()).add(minutes); + } + for (RegUnitVO unit : units) + { + int totalSent = unit.getTotalSent() == null ? 0 : unit.getTotalSent(); + int reported = unit.getReported() == null ? 0 : unit.getReported(); + unit.setAvgRate(percent(reported, totalSent)); + unit.setAvgArriveTime(averageClock(minuteMap.get(unit.getUnitId()))); + } + return units; + } + + @Override + public TrainClassReport selectReport(Long reportId) + { + if (reportId == null) + { + throw new ServiceException("reportId 不能为空", HttpStatus.BAD_REQUEST); + } + TrainClassReport report = trainClassReportMapper.selectTrainClassReportByReportId(reportId); + if (report == null) + { + throw new ServiceException("报到记录不存在: " + reportId, HttpStatus.NOT_FOUND); + } + return report; + } + + @Override + @Transactional + public Long createReport(RegReportSaveDTO dto, String operator) + { + if (dto == null) + { + throw new ServiceException("请求体不能为空", HttpStatus.BAD_REQUEST); + } + TrainEnrollment enrollment = resolveEnrollment(dto); + TrainClassReport exists = trainClassReportMapper.selectTrainClassReportByEnrollmentId(enrollment.getEnrollmentId()); + if (exists != null && Integer.valueOf(1).equals(exists.getVerifyResult())) + { + throw new ServiceException("该学员已完成报到,不能重复创建报到记录", HttpStatus.CONFLICT); + } + Date now = DateUtils.getNowDate(); + TrainClassReport report = new TrainClassReport(); + report.setEnrollmentId(enrollment.getEnrollmentId()); + report.setReportWay(dto.getReportWay() == null ? Integer.valueOf(4) : dto.getReportWay()); + report.setReportDeviceSn(dto.getReportDeviceSn()); + report.setReportTime(dto.getReportTime() == null ? now : dto.getReportTime()); + report.setVerifyResult(dto.getVerifyResult() == null ? Integer.valueOf(1) : dto.getVerifyResult()); + report.setVerifyNote(dto.getVerifyNote()); + report.setRemark(dto.getRemark()); + report.setCreateBy(operator); + report.setCreateTime(now); + trainClassReportMapper.insertTrainClassReport(report); + + // 核验通过时同步参训档案为已报到状态,保证名册与报到记录口径一致 + if (Integer.valueOf(1).equals(report.getVerifyResult())) + { + trainEnrollmentMapper.confirmEnrollmentReport(enrollment.getEnrollmentId(), report.getReportTime(), operator, now); + } + return report.getReportId(); + } + + @Override + @Transactional + public int updateReport(Long reportId, RegReportSaveDTO dto, String operator) + { + selectReport(reportId); + if (dto == null) + { + throw new ServiceException("请求体不能为空", HttpStatus.BAD_REQUEST); + } + TrainClassReport update = new TrainClassReport(); + update.setReportId(reportId); + update.setReportWay(dto.getReportWay()); + update.setReportDeviceSn(dto.getReportDeviceSn()); + update.setReportTime(dto.getReportTime()); + update.setVerifyResult(dto.getVerifyResult()); + update.setVerifyNote(dto.getVerifyNote()); + update.setRemark(dto.getRemark()); + update.setUpdateBy(operator); + update.setUpdateTime(DateUtils.getNowDate()); + return trainClassReportMapper.updateTrainClassReport(update); + } + + @Override + @Transactional + public int deleteReport(Long reportId) + { + selectReport(reportId); + return trainClassReportMapper.deleteTrainClassReportByReportId(reportId); + } + + /** + * 定位参训档案:优先 enrollmentId,其次 planId + studentId。 + */ + private TrainEnrollment resolveEnrollment(RegReportSaveDTO dto) + { + if (dto.getEnrollmentId() != null) + { + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(dto.getEnrollmentId()); + if (enrollment == null) + { + throw new ServiceException("参训档案不存在: " + dto.getEnrollmentId(), HttpStatus.NOT_FOUND); + } + return enrollment; + } + if (StringUtils.isEmpty(dto.getPlanId()) || StringUtils.isEmpty(dto.getStudentId())) + { + throw new ServiceException("enrollmentId 或 planId+studentId 必须提供", HttpStatus.BAD_REQUEST); + } + selectReportPlan(dto.getPlanId()); + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(dto.getPlanId(), dto.getStudentId()); + if (enrollment == null) + { + throw new ServiceException("该学员不在本班参训名单中", HttpStatus.NOT_FOUND); + } + return enrollment; + } + + /** + * 读取全部批次报到统计并计算报到率。 + */ + private List loadBatchStats() + { + List batches = trainRegDataMapper.selectBatchStats(); + if (batches == null) + { + return new ArrayList<>(); + } + for (RegBatchTrendVO batch : batches) + { + int expected = batch.getExpected() == null ? 0 : batch.getExpected(); + int reported = batch.getReported() == null ? 0 : batch.getReported(); + batch.setRate(percent(reported, expected)); + } + return batches; + } + + /** + * 名册行补全格式化字段(报到时间文本、报到方式文本、状态文案)。 + */ + private List decorate(List rows) + { + if (rows == null) + { + return new ArrayList<>(); + } + for (RegRosterVO row : rows) + { + row.setReportTime(format(row.getReportTimeValue(), "yyyy-MM-dd HH:mm")); + row.setReportWay(reportWayText(row.getReportWayCode())); + boolean reported = row.getReported() != null && row.getReported() == 1; + row.setStatusText(reported ? "已报到" : "未报到"); + } + return rows; + } + + /** + * 按小时汇总报到人数(升序,仅保留有数据的时段)。 + */ + private List buildHourly(List facts) + { + Map counter = new TreeMap<>(); + if (facts != null) + { + for (RegArrivalFactVO fact : facts) + { + if (fact.getReportTime() == null) + { + continue; + } + Calendar calendar = Calendar.getInstance(); + calendar.setTime(fact.getReportTime()); + int hour = calendar.get(Calendar.HOUR_OF_DAY); + counter.merge(hour, 1, Integer::sum); + } + } + List hourly = new ArrayList<>(counter.size()); + for (Map.Entry entry : counter.entrySet()) + { + hourly.add(new RegHourlyVO(String.format("%02d:00", entry.getKey()), entry.getValue())); + } + return hourly; + } + + /** + * 生成高峰时段说明。 + */ + private String buildPeakNote(List hourly) + { + if (hourly == null || hourly.isEmpty()) + { + return null; + } + RegHourlyVO peak = hourly.get(0); + for (RegHourlyVO item : hourly) + { + if (item.getCount() != null && peak.getCount() != null && item.getCount() > peak.getCount()) + { + peak = item; + } + } + if (peak.getCount() == null || peak.getCount() == 0) + { + return null; + } + return "报到高峰时段 " + peak.getHour() + "(" + peak.getCount() + " 人)"; + } + + /** + * 平均时刻(分值)转 HH:mm。 + */ + private String averageClock(List minutes) + { + if (minutes == null || minutes.isEmpty()) + { + return null; + } + long sum = 0L; + for (Integer value : minutes) + { + sum += value == null ? 0 : value; + } + int avg = (int) (sum / minutes.size()); + return String.format("%02d:%02d", avg / 60, avg % 60); + } + + private String reportWayText(Integer code) + { + if (code == null || code < 0 || code >= REPORT_WAY_TEXT.length) + { + return null; + } + return REPORT_WAY_TEXT[code]; + } + + private String format(Date date, String pattern) + { + return date == null ? null : new SimpleDateFormat(pattern).format(date); + } + + private Double percent(int part, int total) + { + if (total <= 0) + { + return 0D; + } + return round1(part * 100D / total); + } + + private double round1(double value) + { + return Math.round(value * 10D) / 10D; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainScoreAnalysisServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainScoreAnalysisServiceImpl.java new file mode 100644 index 000000000..a4650b573 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainScoreAnalysisServiceImpl.java @@ -0,0 +1,399 @@ +package com.ruoyi.train.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.train.domain.analysis.ScoreAnalysisFilterDto; +import com.ruoyi.train.mapper.TrainScoreAnalysisMapper; +import com.ruoyi.train.service.ITrainScoreAnalysisService; + +/** + * 成绩统计分析服务实现 + * + * 设计说明: + * 1) 仅新增实现,未改动既有 TrainAnalysisService / 理论成绩 / 上报成绩的既有方法; + * 2) 聚合全部下沉到数据库(COUNT/AVG/MAX/MIN/GROUP BY),本层只做字段装配与文案映射; + * 3) 统计口径在 SCOPE_NOTE 中统一说明,前端原样展示,避免口径误读。 + */ +@Service +public class TrainScoreAnalysisServiceImpl implements ITrainScoreAnalysisService +{ + /** 统计口径说明(前端原样展示) */ + private static final String SCOPE_NOTE = "统计口径:理论成绩取未删除且标记为最终成绩的记录(is_final=1)," + + "实训成绩取已匹配学员的上报记录(match_status=MATCHED);平均分率=成绩/满分×100,缺考/免修等无分记录不参与均值;" + + "实训上报不含课程实例,课程维度按考试科目归集(前缀 SUB:)。"; + + /** 教师维度补充说明 */ + private static final String TEACHER_SCOPE_NOTE = "教师维度基于理论成绩所属课程实例的授课课次关联教师;" + + "同一课程由多位教师授课时,该成绩会分别计入每位教师,仅用于对比参考。"; + + private static final Map SCORE_STATUS_LABELS = new HashMap<>(); + private static final Map EXAM_STATUS_LABELS = new HashMap<>(); + private static final Map RANGE_LABELS = new HashMap<>(); + + static + { + SCORE_STATUS_LABELS.put("RECEIVED", "已接收"); + SCORE_STATUS_LABELS.put("ARCHIVED", "已归档"); + SCORE_STATUS_LABELS.put("LOCKED", "已锁定"); + + EXAM_STATUS_LABELS.put("NORMAL", "正常"); + EXAM_STATUS_LABELS.put("ABSENT", "缺考"); + EXAM_STATUS_LABELS.put("EXEMPT", "免修"); + + RANGE_LABELS.put("R0", "不及格 (<60%)"); + RANGE_LABELS.put("R60", "60% - 69%"); + RANGE_LABELS.put("R70", "70% - 79%"); + RANGE_LABELS.put("R80", "80% - 89%"); + RANGE_LABELS.put("R90", "90% - 100%"); + RANGE_LABELS.put("UNSCORED", "未计分"); + } + + @Autowired + private TrainScoreAnalysisMapper scoreAnalysisMapper; + + @Override + public Map getScoreSummary(ScoreAnalysisFilterDto filter) + { + ScoreAnalysisFilterDto dto = normalize(filter); + Map row = scoreAnalysisMapper.selectScoreSummary(dto); + + Map summary = new LinkedHashMap<>(); + summary.put("totalCount", longValue(row, "total_count")); + summary.put("theoryCount", longValue(row, "theory_count")); + summary.put("practiceCount", longValue(row, "practice_count")); + summary.put("studentCount", longValue(row, "student_count")); + summary.put("planCount", longValue(row, "plan_count")); + summary.put("avgScoreRate", decimalValue(row, "avg_score_rate", 1)); + summary.put("passRate", decimalValue(row, "pass_rate", 1)); + summary.put("maxScore", decimalValue(row, "max_score", 2)); + summary.put("minScore", decimalValue(row, "min_score", 2)); + summary.put("unmatchedCount", nullToZero(scoreAnalysisMapper.countUnmatchedReports(dto))); + summary.put("scopeNote", SCOPE_NOTE); + return summary; + } + + @Override + public Map getScoreCharts(ScoreAnalysisFilterDto filter) + { + ScoreAnalysisFilterDto dto = normalize(filter); + Map charts = new LinkedHashMap<>(); + + charts.put("trend", convertTrend(scoreAnalysisMapper.selectScoreTrend(dto))); + charts.put("categoryDistribution", convertCategory(scoreAnalysisMapper.selectScoreCategoryDistribution(dto))); + charts.put("scoreStatusDistribution", convertStatus(scoreAnalysisMapper.selectScoreStatusDistribution(dto))); + charts.put("examStatusDistribution", convertStatus(scoreAnalysisMapper.selectExamStatusDistribution(dto))); + charts.put("scoreRangeDistribution", convertRange(scoreAnalysisMapper.selectScoreRangeDistribution(dto))); + charts.put("planScoreDistribution", convertPlan(scoreAnalysisMapper.selectPlanScoreDistribution(dto))); + charts.put("teacherDistribution", convertDimensionRows(scoreAnalysisMapper.selectTeacherScoreStats(dto), "teacher")); + charts.put("scopeNote", SCOPE_NOTE); + charts.put("teacherScopeNote", TEACHER_SCOPE_NOTE); + return charts; + } + + @Override + public Map getDimensionAnalysis(ScoreAnalysisFilterDto filter) + { + ScoreAnalysisFilterDto dto = normalize(filter); + String dimension = dto.getDimension(); + + List> rows; + String note = SCOPE_NOTE; + if ("teacher".equals(dimension)) + { + rows = scoreAnalysisMapper.selectTeacherScoreStats(dto); + note = TEACHER_SCOPE_NOTE + SCOPE_NOTE; + } + else + { + // 通过率维度在数据库层以 PASS/FAIL 两行返回,其余维度按对应分组字段聚合 + rows = scoreAnalysisMapper.selectDimensionStats(dto); + } + + Map result = new LinkedHashMap<>(); + result.put("dimension", dimension); + result.put("rows", convertDimensionRows(rows, dimension)); + result.put("scopeNote", note); + return result; + } + + @Override + public Map getFilterOptions() + { + Map options = new LinkedHashMap<>(); + options.put("plans", convertOptions(scoreAnalysisMapper.selectPlanOptions(), "plan_id", "plan_name", "planId", "planName")); + options.put("courses", convertOptions(scoreAnalysisMapper.selectCourseOptions(), "c_id", "c_name", "cId", "cName")); + options.put("apps", convertOptions(scoreAnalysisMapper.selectAppOptions(), "app_id", "app_name", "appId", "appName")); + return options; + } + + /* ==================== 装配辅助 ==================== */ + + private ScoreAnalysisFilterDto normalize(ScoreAnalysisFilterDto filter) + { + ScoreAnalysisFilterDto dto = filter == null ? new ScoreAnalysisFilterDto() : filter; + if (isBlank(dto.getCategory())) + { + dto.setCategory("ALL"); + } + else + { + dto.setCategory(dto.getCategory().trim().toUpperCase()); + } + + String dimension = isBlank(dto.getDimension()) ? "plan" : dto.getDimension().trim(); + switch (dimension) + { + case "course": + case "category": + case "pass": + case "status": + case "scoreRange": + case "teacher": + break; + default: + dimension = "plan"; + } + dto.setDimension(dimension); + dto.setStartDate(trimToNull(dto.getStartDate())); + dto.setEndDate(trimToNull(dto.getEndDate())); + dto.setPlanId(trimToNull(dto.getPlanId())); + dto.setCourseId(trimToNull(dto.getCourseId())); + dto.setAppId(trimToNull(dto.getAppId())); + dto.setExamStatus(trimToNull(dto.getExamStatus())); + dto.setScoreStatus(trimToNull(dto.getScoreStatus())); + return dto; + } + + private List> convertTrend(List> rows) + { + List> list = new ArrayList<>(); + if (rows == null) + { + return list; + } + for (Map row : rows) + { + Map item = new LinkedHashMap<>(); + item.put("month", text(row.get("stat_month"))); + item.put("count", longValue(row, "count_num")); + item.put("avgScoreRate", decimalValue(row, "avg_score_rate", 1)); + item.put("passRate", decimalValue(row, "pass_rate", 1)); + list.add(item); + } + return list; + } + + private List> convertCategory(List> rows) + { + List> list = new ArrayList<>(); + if (rows == null) + { + return list; + } + for (Map row : rows) + { + String bizCategory = text(row.get("biz_category")); + String appId = text(row.get("app_id")); + String appName = text(row.get("app_name")); + Map item = new LinkedHashMap<>(); + item.put("category", bizCategory); + item.put("appId", appId); + item.put("appName", appName); + item.put("name", "THEORY".equals(bizCategory) ? "理论考试" : ("实训 · " + (isBlank(appName) ? "未知平台" : appName))); + item.put("count", longValue(row, "count_num")); + item.put("avgScoreRate", decimalValue(row, "avg_score_rate", 1)); + item.put("passRate", decimalValue(row, "pass_rate", 1)); + list.add(item); + } + return list; + } + + private List> convertStatus(List> rows) + { + List> list = new ArrayList<>(); + if (rows == null) + { + return list; + } + for (Map row : rows) + { + String code = text(row.get("status_code")); + Map item = new LinkedHashMap<>(); + item.put("code", code); + item.put("name", labelOf(code, SCORE_STATUS_LABELS, EXAM_STATUS_LABELS)); + item.put("count", longValue(row, "count_num")); + item.put("avgScoreRate", decimalValue(row, "avg_score_rate", 1)); + list.add(item); + } + return list; + } + + private List> convertRange(List> rows) + { + List> list = new ArrayList<>(); + if (rows == null) + { + return list; + } + for (Map row : rows) + { + String code = text(row.get("range_code")); + Map item = new LinkedHashMap<>(); + item.put("code", code); + item.put("name", labelOf(code, RANGE_LABELS, null)); + item.put("count", longValue(row, "count_num")); + list.add(item); + } + return list; + } + + private List> convertPlan(List> rows) + { + List> list = new ArrayList<>(); + if (rows == null) + { + return list; + } + for (Map row : rows) + { + Map item = new LinkedHashMap<>(); + item.put("planId", text(row.get("plan_id"))); + item.put("planName", text(row.get("plan_name"))); + item.put("count", longValue(row, "count_num")); + item.put("avgScore", decimalValue(row, "avg_score", 2)); + item.put("avgScoreRate", decimalValue(row, "avg_score_rate", 1)); + item.put("maxScore", decimalValue(row, "max_score", 2)); + item.put("minScore", decimalValue(row, "min_score", 2)); + item.put("passRate", decimalValue(row, "pass_rate", 1)); + list.add(item); + } + return list; + } + + private List> convertDimensionRows(List> rows, String dimension) + { + List> list = new ArrayList<>(); + if (rows == null) + { + return list; + } + for (Map row : rows) + { + String key = text(row.get("dim_key")); + String rawName = text(row.get("dim_name")); + Map item = new LinkedHashMap<>(); + item.put("key", key); + item.put("name", displayName(dimension, key, rawName)); + item.put("count", longValue(row, "count_num")); + item.put("avgScore", decimalValue(row, "avg_score", 2)); + item.put("avgScoreRate", decimalValue(row, "avg_score_rate", 1)); + item.put("maxScore", decimalValue(row, "max_score", 2)); + item.put("minScore", decimalValue(row, "min_score", 2)); + item.put("passCount", longValue(row, "pass_count")); + item.put("passRate", decimalValue(row, "pass_rate", 1)); + list.add(item); + } + return list; + } + + private List> convertOptions(List> rows, String idKey, String nameKey, + String idTarget, String nameTarget) + { + List> list = new ArrayList<>(); + if (rows == null) + { + return list; + } + for (Map row : rows) + { + Map item = new LinkedHashMap<>(); + item.put(idTarget, text(row.get(idKey))); + item.put(nameTarget, text(row.get(nameKey))); + list.add(item); + } + return list; + } + + private String displayName(String dimension, String key, String rawName) + { + if ("status".equals(dimension)) + { + return labelOf(key, SCORE_STATUS_LABELS, null); + } + if ("scoreRange".equals(dimension)) + { + return labelOf(key, RANGE_LABELS, null); + } + if ("pass".equals(dimension)) + { + return isBlank(rawName) ? key : rawName; + } + if (isBlank(rawName)) + { + return isBlank(key) ? "未指定" : key; + } + return rawName; + } + + private String labelOf(String code, Map primary, Map secondary) + { + if (isBlank(code)) + { + return "未指定"; + } + String label = primary == null ? null : primary.get(code); + if (label == null && secondary != null) + { + label = secondary.get(code); + } + return label == null ? code : label; + } + + private long longValue(Map row, String key) + { + Object value = row == null ? null : row.get(key); + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + private Double decimalValue(Map row, String key, int scale) + { + Object value = row == null ? null : row.get(key); + if (!(value instanceof Number)) + { + return null; + } + return BigDecimal.valueOf(((Number) value).doubleValue()).setScale(scale, RoundingMode.HALF_UP).doubleValue(); + } + + private long nullToZero(Long value) + { + return value == null ? 0L : value; + } + + private String text(Object value) + { + return value == null ? "" : String.valueOf(value); + } + + private boolean isBlank(String value) + { + return value == null || value.trim().isEmpty(); + } + + private String trimToNull(String value) + { + if (value == null) + { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentCheckinServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentCheckinServiceImpl.java index ea1a77f19..fde1050c6 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentCheckinServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentCheckinServiceImpl.java @@ -45,4 +45,16 @@ public class TrainStudentCheckinServiceImpl implements ITrainStudentCheckinServi return trainStudentCheckinMapper.selectTrainStudentCheckinList(trainStudentCheckin); } + /** + * 查询指定课次的签到记录(新增方法,复用既有 Mapper 查询,未改动原有方法)。 + * + * @param lessonId 课次ID + * @return 签到记录集合 + */ + @Override + public List selectCheckinsByLessonId(Long lessonId) + { + return trainStudentCheckinMapper.selectCheckinsByLessonId(lessonId); + } + } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java index b7f6bb278..4a740fa14 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java @@ -1,21 +1,34 @@ package com.ruoyi.train.service.impl; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Locale; import java.util.regex.Pattern; import com.github.pagehelper.Page; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.iot.lock.domain.LockAuth; +import com.ruoyi.iot.lock.domain.LockDevice; +import com.ruoyi.iot.lock.domain.LockFace; +import com.ruoyi.iot.lock.mapper.LockAuthMapper; +import com.ruoyi.iot.lock.mapper.LockDeviceMapper; +import com.ruoyi.iot.lock.remote.FaceExtractClient; import com.ruoyi.train.constant.StudentErrorCode; import com.ruoyi.train.domain.TrainStudent; import com.ruoyi.train.domain.dto.StudentCreateDTO; import com.ruoyi.train.domain.dto.StudentUpdateDTO; import com.ruoyi.train.domain.query.StudentQuery; import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentExportVO; import com.ruoyi.train.domain.vo.StudentItemVO; import com.ruoyi.train.mapper.TrainEnrollmentMapper; import com.ruoyi.train.mapper.TrainStudentMapper; @@ -25,11 +38,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** 学员主档应用服务实现。 */ @Service public class TrainStudentServiceImpl implements ITrainStudentService { + private static final Logger log = LoggerFactory.getLogger(TrainStudentServiceImpl.class); + private static final Pattern ID_CARD_PATTERN = Pattern.compile("^[0-9]{17}[0-9X]$"); private static final int MANUAL_SOURCE_TYPE = 1; private static final int ENABLED_STATUS = 1; @@ -37,6 +54,12 @@ public class TrainStudentServiceImpl implements ITrainStudentService private final TrainStudentMapper trainStudentMapper; private final StudentIdentityProtection identityProtection; private TrainEnrollmentMapper trainEnrollmentMapper; + @Autowired + private FaceExtractClient faceExtractClient; + @Autowired + private LockDeviceMapper deviceMapper; + @Autowired + private LockAuthMapper authMapper; public TrainStudentServiceImpl(TrainStudentMapper trainStudentMapper, StudentIdentityProtection identityProtection) @@ -67,6 +90,19 @@ public class TrainStudentServiceImpl implements ITrainStudentService return result; } + @Override + public List selectStudentExportList(StudentQuery query) + { + // 复用列表查询以保证筛选口径一致(含可选的按班过滤),再逐行映射为导出列 + List rows = selectStudentList(query); + List result = new ArrayList<>(rows.size()); + for (StudentItemVO row : rows) + { + result.add(StudentExportVO.from(row)); + } + return result; + } + @Override public StudentDetailVO selectStudentDetail(String sId) { @@ -85,9 +121,53 @@ public class TrainStudentServiceImpl implements ITrainStudentService return detail; } + @Override + public String genPersonNo() + { + return generateUniquePersonNo(); + } + + /** + * 生成全局唯一的人员编号:AA + 6 位数字,按现有最大序号 +1; + * 若顺延过程中该值已占用则自动取下一个未占用值。 + */ + private String generateUniquePersonNo() + { + String maxNo = trainStudentMapper.selectMaxPersonNo(); + int next = 1; + if (StringUtils.isNotEmpty(maxNo) && maxNo.length() == 8 && maxNo.startsWith("AA")) + { + String num = maxNo.substring(2); + if (num.matches("\\d{6}")) + { + next = Integer.parseInt(num) + 1; + } + } + if (next > 999999) + { + throw new ServiceException("人员编号已用尽(AA000001~AA999999)"); + } + String candidate; + int current = next; + do + { + candidate = "AA" + String.format("%06d", current); + current++; + } + while (trainStudentMapper.countByPersonNo(candidate) > 0 && current <= 999999); + return candidate; + } + @Override @Transactional(rollbackFor = Exception.class) public String createStudent(StudentCreateDTO dto) + { + return createStudent(dto, MANUAL_SOURCE_TYPE, null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String createStudent(StudentCreateDTO dto, Integer sourceType, String operator) { validate(dto); String normalizedIdCard = normalizeIdCard(dto.getIdCard()); @@ -97,9 +177,16 @@ public class TrainStudentServiceImpl implements ITrainStudentService student.setsName(dto.getsName().trim()); student.setsGender(trimToNull(dto.getsGender())); student.setsBirth(dto.getsBirth()); - student.setOrgName(trimToNull(dto.getOrgName())); + student.setDeptName(trimToNull(dto.getDeptName())); + student.setDeptId(dto.getDeptId() != null ? dto.getDeptId().longValue() : null); + // card_no 在库中为 NOT NULL 且无默认值,未填写时落空串(与存量数据口径一致); + // duty_level 为 NOT NULL DEFAULT 1,此处保持 null,由 Mapper 动态 SQL 跳过该列以使用库默认值。 + String cardNo = trimToNull(dto.getCardNo()); + student.setCardNo(cardNo == null ? "" : cardNo); student.setsPhone(trimToNull(dto.getsPhone())); student.setsEmail(trimToNull(dto.getsEmail())); + String personNo = trimToNull(dto.getPersonNo()); + student.setPersonNo(StringUtils.isNotEmpty(personNo) ? personNo : generateUniquePersonNo()); student.setAvatar(trimToNull(dto.getAvatar())); student.setDutyLevel(dto.getDutyLevel()); @@ -121,18 +208,25 @@ public class TrainStudentServiceImpl implements ITrainStudentService student.setIdCardLast4(null); } student.setFaceRef(trimToNull(dto.getFaceRef())); - student.setSourceType(MANUAL_SOURCE_TYPE); + student.setSourceType(sourceType == null ? MANUAL_SOURCE_TYPE : sourceType); student.setStatus(ENABLED_STATUS); student.setDelFlag("0"); student.setRemark(trimToNull(dto.getRemark())); student.setCreateTime(DateUtils.getNowDate()); - try + if (StringUtils.isNotEmpty(operator)) { - student.setCreateBy(SecurityUtils.getUsername()); + student.setCreateBy(operator); } - catch (Exception ignored) + else { - // 非 Web 单元测试无登录上下文;不使用任何请求数据作为审计用户兜底。 + try + { + student.setCreateBy(SecurityUtils.getUsername()); + } + catch (Exception ignored) + { + // 非 Web 单元测试无登录上下文;不使用任何请求数据作为审计用户兜底。 + } } try @@ -183,9 +277,26 @@ public class TrainStudentServiceImpl implements ITrainStudentService student.setsName(dto.getsName().trim()); student.setsGender(trimToNull(dto.getsGender())); student.setsBirth(dto.getsBirth()); - student.setOrgName(trimToNull(dto.getOrgName())); + student.setDeptName(trimToNull(dto.getDeptName())); + student.setDeptId(dto.getDeptId() != null ? dto.getDeptId().longValue() : null); + student.setCardNo(trimToNull(dto.getCardNo())); student.setsPhone(trimToNull(dto.getsPhone())); student.setsEmail(trimToNull(dto.getsEmail())); + // 人员编号:优先使用传入值;若历史缺失(或前端未取到)则保留库内既有值, + // 库内也为空时自动生成唯一编号(保证修改时一定能持久化且不与已有重复) + String incomingPersonNo = trimToNull(dto.getPersonNo()); + if (StringUtils.isNotEmpty(incomingPersonNo)) + { + student.setPersonNo(incomingPersonNo); + } + else if (StringUtils.isNotEmpty(existing.getPersonNo())) + { + student.setPersonNo(existing.getPersonNo()); + } + else + { + student.setPersonNo(generateUniquePersonNo()); + } student.setAvatar(trimToNull(dto.getAvatar())); student.setFaceRef(trimToNull(dto.getFaceRef())); student.setDutyLevel(dto.getDutyLevel()); @@ -247,22 +358,36 @@ public class TrainStudentServiceImpl implements ITrainStudentService target.setsName(student.getsName()); target.setsGender(student.getsGender()); target.setsBirth(student.getsBirth()); - target.setOrgName(student.getOrgName()); + target.setDeptName(student.getDeptName()); + target.setCardNo(student.getCardNo()); target.setsPhone(student.getsPhone()); target.setsEmail(student.getsEmail()); + target.setPersonNo(student.getPersonNo()); target.setIdCardLast4(student.getIdCardLast4()); target.setIdCardMasked(maskStoredIdentity(student)); target.setSourceType(student.getSourceType()); target.setStatus(student.getStatus()); target.setAvatar(student.getAvatar()); target.setDutyLevel(student.getDutyLevel()); + target.setId(student.getId()); + target.setDeptId(student.getDeptId()); } private String maskStoredIdentity(TrainStudent student) { if (StringUtils.isNotEmpty(student.getIdCardCiphertext())) { - return identityProtection.mask(identityProtection.decrypt(student.getIdCardCiphertext())); + try + { + return identityProtection.mask(identityProtection.decrypt(student.getIdCardCiphertext())); + } + catch (RuntimeException ex) + { + // 单条密文因密钥不一致/格式损坏无法解密时,降级为末四位脱敏, + // 避免任意一行脏数据拖垮整个学员列表/详情接口。 + log.warn("学员身份证密文解密失败,已回退末四位脱敏, sId={}, msg={}", + student.getsId(), ex.getMessage()); + } } if (StringUtils.isNotEmpty(student.getIdCardLast4())) { @@ -374,4 +499,200 @@ public class TrainStudentServiceImpl implements ITrainStudentService // 5. 执行零引用物理删除 return trainStudentMapper.deleteTrainStudentBySId(sId); } + + @Override + public String extract(Long faceId, Long deviceId) + { + TrainStudent stud = trainStudentMapper.selectTrainStudentById(faceId); + if (stud == null) + { + throw new IllegalArgumentException("人脸数据不存在"); + } + LockDevice explicitDevice = deviceId == null ? null : deviceMapper.selectDeviceById(deviceId); + if (!faceExtractClient.isMock()) { + if (explicitDevice == null) throw new SecurityException("An explicit face extraction device is required"); + faceExtractClient.authorize(explicitDevice.getDeviceSn()); + } + // algorithm_version 由门锁维护,优先取指定门锁,其次按该人员的授权推断 + String algoVersion = resolveAlgoVersion(stud.getPersonNo(), deviceId); + if (StringUtils.isEmpty(algoVersion)) + { + log.warn("未获取到算法版本(algorithm_version),将不带该参数提取, faceId={}, deviceId={}", faceId, deviceId); + } + TrainStudent update = new TrainStudent(); + update.setId(faceId); + update.setExtractTime(new Date()); + update.setFeatureAlgoVersion(algoVersion); + if (StringUtils.isEmpty(stud.getAvatar())) + { + update.setExtractStatus("2"); + update.setExtractMsg("未上传人脸图片"); + trainStudentMapper.updateExtractResult(update); + return null; + } + // photoUrl 存的是图片地址,特征提取服务按文件接收,这里读取图片内容 + byte[] imageBytes = faceExtractClient.isMock() ? new byte[]{0} : loadImageBytes(stud.getAvatar()); + if (imageBytes == null || imageBytes.length == 0) + { + update.setExtractStatus("2"); + update.setExtractMsg("人脸图片读取失败,请确认图片地址可访问"); + trainStudentMapper.updateExtractResult(update); + log.warn("人脸图片读取失败, faceId={}, photoUrl={}", faceId, stud.getAvatar()); + return null; + } + String feature = faceExtractClient.extract(imageBytes, "face.jpg", algoVersion, explicitDevice == null ? null : explicitDevice.getDeviceSn()); + if (StringUtils.isEmpty(feature)) + { + update.setExtractStatus("2"); + update.setExtractMsg("特征提取服务未返回结果"); + trainStudentMapper.updateExtractResult(update); + log.warn("人脸特征提取失败, faceId={}", faceId); + return null; + } + update.setExtractStatus("1"); + update.setExtractMsg(null); + update.setFeatureData(feature); + update.setFeatureSize(feature.length()); + trainStudentMapper.updateExtractResult(update); + return feature; + } + + @Override + public String extractStudent(String sId, Long deviceId) + { + TrainStudent stud = trainStudentMapper.selectTrainStudentBySId(sId); + if (stud.getAvatar() == null) + { + throw new IllegalArgumentException("人脸数据不存在"); + } + LockDevice explicitDevice = deviceId == null ? null : deviceMapper.selectDeviceById(deviceId); + if (!faceExtractClient.isMock()) { + if (explicitDevice == null) throw new SecurityException("An explicit face extraction device is required"); + faceExtractClient.authorize(explicitDevice.getDeviceSn()); + } + // algorithm_version 由门锁维护,优先取指定门锁,其次按该人员的授权推断 + String algoVersion = explicitDevice.getFaceAlgoVersion(); + if (StringUtils.isEmpty(algoVersion)) + { + log.warn("未获取到算法版本(algorithm_version),将不带该参数提取, faceId={}, deviceId={}", sId, deviceId); + } + //TrainStudent update = new TrainStudent(); + stud.setsId(sId); + stud.setExtractTime(new Date()); + stud.setFeatureAlgoVersion(algoVersion); + if (StringUtils.isEmpty(stud.getAvatar())) + { + stud.setExtractStatus("2"); + stud.setExtractMsg("未上传人脸图片"); + trainStudentMapper.updateExtractResult(stud); + return null; + } + // photoUrl 存的是图片地址,特征提取服务按文件接收,这里读取图片内容 + byte[] imageBytes = faceExtractClient.isMock() ? new byte[]{0} : loadImageBytes(stud.getAvatar()); + if (imageBytes == null || imageBytes.length == 0) + { + stud.setExtractStatus("2"); + stud.setExtractMsg("人脸图片读取失败,请确认图片地址可访问"); + trainStudentMapper.updateExtractResult(stud); + log.warn("人脸图片读取失败, faceId={}, photoUrl={}", sId, stud.getAvatar()); + return null; + } + String feature = faceExtractClient.extract(imageBytes, "face.jpg", algoVersion, explicitDevice == null ? null : explicitDevice.getDeviceSn()); + if (StringUtils.isEmpty(feature)) + { + stud.setExtractStatus("2"); + stud.setExtractMsg("特征提取服务未返回结果"); + trainStudentMapper.updateExtractResult(stud); + log.warn("人脸特征提取失败, faceId={}", sId); + return null; + } + stud.setExtractStatus("1"); + stud.setExtractMsg(null); + stud.setFeatureData(feature); + stud.setFeatureSize(feature.length()); + log.warn("人脸特征提取成功, update={}", stud); + trainStudentMapper.updateExtractResult(stud); + return feature; + } + + /** + * 解析特征提取所需的 algorithm_version + *

+ * 该参数在每把智能门锁上维护(lock_device.face_algo_version): + * 优先使用指定的目标门锁,未指定时回退到该人员第一条授权对应的门锁。 + * + * @param personNo 人员编号 + * @param deviceId 目标门锁ID,可为空 + * @return 算法版本,取不到返回 null + */ + private String resolveAlgoVersion(String personNo, Long deviceId) + { + LockDevice device = null; + if (deviceId != null) + { + device = deviceMapper.selectDeviceById(deviceId); + } + else if (StringUtils.isNotEmpty(personNo)) + { + List auths = authMapper.selectAuthsByPersonNo(personNo); + if (auths != null && !auths.isEmpty() && auths.get(0).getDeviceId() != null) + { + device = deviceMapper.selectDeviceById(auths.get(0).getDeviceId()); + } + } + if (device == null || StringUtils.isEmpty(device.getFaceAlgoVersion())) + { + return null; + } + return device.getFaceAlgoVersion().trim(); + } + + /** + * 读取人脸图片内容 + *

+ * photoUrl 可能是 http(s) 外链,也可能是若依上传接口返回的 /profile/... 相对地址 + * (实际文件位于 RuoYiConfig.getProfile() 对应的目录)。 + */ + private byte[] loadImageBytes(String photoUrl) + { + try + { + if (!photoUrl.startsWith(Constants.RESOURCE_PREFIX + "/")) { + throw new SecurityException("Only local approved profile images are accepted"); + } + Path profile = Paths.get(RuoYiConfig.getProfile()).toRealPath(); + String relative = photoUrl.substring((Constants.RESOURCE_PREFIX + "/").length()); + Path path = profile.resolve(relative).toRealPath(); + if (!path.startsWith(profile) || !Files.isRegularFile(path) || Files.size(path) > 5 * 1024 * 1024) { + throw new SecurityException("Image path or size denied"); + } + return Files.readAllBytes(path); + } + catch (Exception e) + { + log.warn("Face image read denied or unavailable"); + return null; + } + } + + @Override + public int batchExtract(Long[] faceIds, Long deviceId) + { + int success = 0; + for (Long faceId : faceIds) + { + try + { + if (extract(faceId, deviceId) != null) + { + success++; + } + } + catch (Exception e) + { + log.warn("批量特征提取异常, faceId={}, msg={}", faceId, e.getMessage()); + } + } + return success; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherAccommodationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherAccommodationServiceImpl.java new file mode 100644 index 000000000..092328198 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherAccommodationServiceImpl.java @@ -0,0 +1,608 @@ +package com.ruoyi.train.service.impl; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ClassUtils; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.spring.SpringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.train.constant.AccommodationErrorCode; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.dto.TeacherAccommodationSaveResult; +import com.ruoyi.train.domain.vo.TeacherAccommodationVO; +import com.ruoyi.train.integration.lock.client.TrainLockStayApiClient; +import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainTeacherAccommodationMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainTeacherAccommodationService; + +/** + * 教师住宿安排Service业务层处理 + *

+ * 与学生住宿完全隔离:所有读写都限定 occupant_type = 'teacher', + * 不调用 ITrainAccommodationService 的任何方法,也不触碰 train_student.avatar / 参训状态。 + * + * @author ruoyi + * @date 2026-11-04 + */ +@Service +public class TrainTeacherAccommodationServiceImpl implements ITrainTeacherAccommodationService +{ + private static final Logger log = LoggerFactory.getLogger(TrainTeacherAccommodationServiceImpl.class); + + /** 教师首次分配宿舍备注 */ + private static final String REMARK_TEACHER_ASSIGN = "教师宿舍安排"; + + /** 教师调整宿舍备注 */ + private static final String REMARK_TEACHER_ADJUST = "教师宿舍调整"; + + /** 门锁入住授权默认有效期(天) */ + private static final int LOCK_AUTH_VALID_DAYS = 15; + + /** 人脸照片允许的最大字节数(与系统其它人脸图片校验保持一致:5MB) */ + private static final long FACE_IMAGE_MAX_BYTES = 5L * 1024 * 1024; + + /** 开锁授权状态:已下发 */ + private static final String LOCK_STATUS_SUCCESS = "SUCCESS"; + + /** 开锁授权状态:被人脸校验拒绝或条件不满足而跳过 */ + private static final String LOCK_STATUS_SKIPPED = "SKIPPED"; + + /** 开锁授权状态:调用门锁服务失败 */ + private static final String LOCK_STATUS_FAILED = "FAILED"; + + /** 开锁授权状态:住宿安排未变化,未重复下发 */ + private static final String LOCK_STATUS_NO_CHANGE = "NO_CHANGE"; + + /** 本地人脸照片资源前缀(若依上传资源映射到 RuoYiConfig.getProfile()) */ + private static final String PROFILE_RESOURCE_PREFIX = Constants.RESOURCE_PREFIX + "/"; + + /** + * 教师人脸服务人员ID后缀。 + *

+ * 教师向人脸服务下发添加/删除人脸时,employee_id 统一使用 {@code tId + "_teacher"}, + * 与学生(使用 sId)在人脸库中彻底隔离,避免同号串用。学生住宿链路不做任何改动。 + */ + private static final String TEACHER_FACE_EMPLOYEE_SUFFIX = "_teacher"; + + @Autowired + private TrainTeacherAccommodationMapper trainTeacherAccommodationMapper; + + @Autowired + private TrainDormitoryInfoMapper trainDormitoryInfoMapper; + + @Autowired + private TrainTeacherMapper trainTeacherMapper; + + @Autowired(required = false) + private TrainLockStayApiClient trainLockStayApiClient; + + public void setTrainLockStayApiClient(TrainLockStayApiClient trainLockStayApiClient) + { + this.trainLockStayApiClient = trainLockStayApiClient; + } + + /** + * 批量查询教师当前有效住宿安排 + * + * @param teacherIds 教师主键集合 + * @return 教师住宿安排集合 + */ + @Override + public List listActiveByTeacherIds(List teacherIds) + { + if (teacherIds == null || teacherIds.isEmpty()) + { + return Collections.emptyList(); + } + return trainTeacherAccommodationMapper.selectActiveByTeacherIds(teacherIds); + } + + /** + * 查询教师当前有效住宿安排 + * + * @param teacherId 教师主键 (train_teacher.id) + * @return 教师住宿安排 + */ + @Override + public TeacherAccommodationVO getActiveByTeacherId(Long teacherId) + { + if (teacherId == null) + { + return null; + } + return trainTeacherAccommodationMapper.selectActiveByTeacherId(teacherId); + } + + /** + * 查询教师住宿历史记录 + * + * @param teacherId 教师主键 (train_teacher.id) + * @return 教师住宿历史 + */ + @Override + public List listHistoryByTeacherId(Long teacherId) + { + if (teacherId == null) + { + return Collections.emptyList(); + } + return trainTeacherAccommodationMapper.selectHistoryByTeacherId(teacherId); + } + + /** + * 教师住宿分配 / 调整宿舍 + * + * @param teacherId 教师主键 (train_teacher.id) + * @param dormId 宿舍ID + * @param bedNo 床位号 + * @param operator 操作人 + * @return 生效后的教师住宿安排 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public TeacherAccommodationSaveResult assign(Long teacherId, String dormId, String bedNo, String operator) + { + if (teacherId == null) + { + throw new ServiceException("教师ID (teacherId) 不能为空"); + } + if (StringUtils.isEmpty(dormId)) + { + throw new ServiceException("分配宿舍ID (dormId) 不能为空"); + } + if (StringUtils.isEmpty(bedNo)) + { + throw new ServiceException("分配床位号 (bedNo) 不能为空"); + } + + // 1. 校验教师存在(未删除) + TrainTeacher teacher = trainTeacherAccommodationMapper.selectTeacherForAccommodation(teacherId); + if (teacher == null) + { + throw new ServiceException("指定教师不存在,teacherId: " + teacherId); + } + + // 2. 已有有效住宿且房间床位未变化:视为幂等,直接返回原记录 + TeacherAccommodationVO active = trainTeacherAccommodationMapper.selectActiveByTeacherId(teacherId); + boolean adjust = active != null; + if (adjust && dormId.equals(active.getDormId()) && bedNo.equals(active.getBedNo())) + { + // 幂等:住宿安排未变化,不重复下发开锁授权 + TeacherAccommodationSaveResult unchanged = new TeacherAccommodationSaveResult(); + unchanged.setAccommodation(active); + unchanged.setLockAuthorized(false); + unchanged.setLockStatus(LOCK_STATUS_NO_CHANGE); + unchanged.setLockMessage("住宿安排未发生变化,未重新下发开锁授权"); + return unchanged; + } + + // 3. 锁定目标宿舍行,校验存在/启用(教师住宿跳过性别校验) + TrainDormitoryInfo dorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoForUpdate(dormId); + if (dorm == null) + { + throw new ServiceException("指定宿舍不存在,dormId: " + dormId); + } + if (dorm.getDormStatus() != null && dorm.getDormStatus() == 0) + { + throw new ServiceException("该宿舍当前处于禁用状态,无法分配住宿"); + } + + // 4. 容量校验(宿舍床位由学员与教师共享,统计不区分人员类型) + int currentActive = trainTeacherAccommodationMapper.countActiveByDormId(dormId); + if (adjust && dormId.equals(active.getDormId())) + { + // 调房且仍在本宿舍:自身原记录即将结束,不计入占用 + currentActive = Math.max(currentActive - 1, 0); + } + if (dorm.getCapacity() != null && currentActive >= dorm.getCapacity()) + { + throw new ServiceException("宿舍容量已满 (核定容量: " + dorm.getCapacity() + + ",当前有效人数: " + currentActive + "),无法继续分配"); + } + + // 5. 床位占用校验(排除自身,避免调房时与自身冲突) + int bedOccupied = trainTeacherAccommodationMapper.countActiveByDormIdAndBedNo(dormId, bedNo, + adjust ? active.getAccommodationId() : null); + if (bedOccupied > 0) + { + throw new ServiceException(AccommodationErrorCode.BED_ALREADY_OCCUPIED_MESSAGE + + "(宿舍: " + dormId + ",床位: " + bedNo + ")"); + } + + // 6. 调整宿舍:先结束原住宿并撤销旧的门锁入住授权(人脸保留,同一教师继续入住) + if (adjust) + { + endActiveAccommodation(active, operator, REMARK_TEACHER_ADJUST); + } + + // 7. 创建新住宿记录:enrollment_id 存教师主键,occupant_type 固定 teacher + Date now = DateUtils.getNowDate(); + TrainAccommodation accommodation = new TrainAccommodation(); + accommodation.setEnrollmentId(teacherId); + accommodation.setDormId(dormId); + accommodation.setBedNo(bedNo); + accommodation.setAssignedTime(now); + accommodation.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + accommodation.setLockApiBizNo(generateLockApiBizNo()); + accommodation.setCreateBy(operator); + accommodation.setCreateTime(now); + accommodation.setRemark(adjust ? REMARK_TEACHER_ADJUST : REMARK_TEACHER_ASSIGN); + trainTeacherAccommodationMapper.insertTeacherAccommodation(accommodation); + + // 8. 开锁授权前置校验:avatar 为空 / 照片不存在 / 校验异常 一律拒绝开锁(住宿分配不受影响) + TeacherFaceCheck faceCheck = validateTeacherFaceForLockAuth(teacher); + + TeacherAccommodationSaveResult saveResult = new TeacherAccommodationSaveResult(); + saveResult.setAccommodation( + trainTeacherAccommodationMapper.selectDetailByAccommodationId(accommodation.getAccommodationId())); + + if (!faceCheck.isAllowed()) + { + // 拒绝授权开锁:不调用门锁开放接口,仅记录原因并回传前端 + log.warn("[教师住宿] 拒绝开锁授权, teacherId={}, tId={}, 原因={}", + teacherId, teacher.getTId(), faceCheck.getMessage()); + saveResult.setLockAuthorized(false); + saveResult.setLockStatus(LOCK_STATUS_SKIPPED); + saveResult.setLockMessage(faceCheck.getMessage()); + return saveResult; + } + + // 9. 人脸照片可用:触发门锁入住授权(调用失败仅记录日志,不回滚住宿事实) + LockStayCheckinResult lockResult = callStayCheckin(accommodation, teacher, dorm, faceCheck.getPhotoUrl()); + boolean lockSuccess = lockResult != null && lockResult.isSuccess(); + saveResult.setLockAuthorized(lockSuccess); + saveResult.setLockStatus(lockSuccess ? LOCK_STATUS_SUCCESS + : (lockResult != null && LockStayCheckinResult.STATUS_SKIPPED.equals(lockResult.getStatus()) + ? LOCK_STATUS_SKIPPED : LOCK_STATUS_FAILED)); + saveResult.setLockMessage(lockResult == null ? "门锁授权调用未返回结果" + : lockResult.getMessage()); + return saveResult; + } + + /** + * 教师退宿 (状态 -> 2 已退宿) + *

+ * 执行门锁退宿撤权、人脸删除,并在退宿成功后清空 {@code train_teacher.avatar}; + * 退宿失败(状态更新影响行数为 0)或异常时不清空头像。不涉及任何学员/参训数据。 + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 影响行数 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int checkOut(Long accommodationId, String operator) + { + TeacherAccommodationVO exist = trainTeacherAccommodationMapper.selectDetailByAccommodationId(accommodationId); + if (exist == null) + { + throw new ServiceException(AccommodationErrorCode.ACCOMMODATION_NOT_FOUND_MESSAGE + ": " + accommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) + { + // 幂等退宿:仍执行人脸删除,保证重复调用结果一致 + // 头像清空只在首次退宿成功(下方 rows > 0)时执行,此处不再重复清空,避免覆盖退宿后新上传的头像 + deleteTeacherFaceOnCheckOut(exist); + return 1; + } + int rows = trainTeacherAccommodationMapper.updateAccommodationStatus(accommodationId, + TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); + if (rows > 0) + { + // 门锁退宿撤权 + callStayCheckout(exist.getLockApiBizNo()); + // 人脸库删除教师员工数据(employee_id 取 tId_teacher) + deleteTeacherFaceOnCheckOut(exist); + // 退宿成功(状态已置为已退宿)后,同步清空教师主档头像 train_teacher.avatar(置 NULL) + clearTeacherAvatarOnCheckOut(exist); + } + return rows; + } + + /** + * 退宿成功后清空教师头像(train_teacher.avatar 置 NULL)。 + *

+ * 触发条件:仅在 {@link #checkOut(Long, String)} 中状态更新成功(rows > 0)后调用, + * 退宿失败/异常时不会被调用,避免误清空。 + * 头像清空属附属动作:其失败仅记录日志,不回滚已完成的退宿结果,也不影响门锁/人脸处理。 + * + * @param accommodation 教师住宿安排(需包含教师工号 tId) + */ + private void clearTeacherAvatarOnCheckOut(TeacherAccommodationVO accommodation) + { + if (accommodation == null || StringUtils.isEmpty(accommodation.getTId())) + { + log.warn("[教师住宿] 退宿清空头像跳过:未取到教师工号, accommodationId={}", + accommodation == null ? null : accommodation.getAccommodationId()); + return; + } + try + { + int cleared = trainTeacherMapper.clearAvatarToNullByTId(accommodation.getTId()); + log.info("[教师住宿] 退宿同步清空教师头像完成, tId={}, rows={}", accommodation.getTId(), cleared); + } + catch (Exception ex) + { + log.error("[教师住宿] 退宿同步清空教师头像失败, tId={}, msg={}", accommodation.getTId(), ex.getMessage()); + } + } + + /** + * 生成门锁开放接口业务流水号 (64 位,对齐 lock_api_biz_no varchar(64)) + * + * @return 业务流水号 + */ + @Override + public String generateLockApiBizNo() + { + String bizNo = IdUtils.fastSimpleUUID() + IdUtils.fastSimpleUUID(); + if (bizNo.length() != TrainAccommodation.LOCK_API_BIZ_NO_MAX_LENGTH) + { + throw new ServiceException("门锁业务流水号长度非法: " + bizNo.length()); + } + return bizNo; + } + + /** + * 结束教师当前有效住宿:状态置为已退宿并撤销门锁入住授权 + * + * @param active 当前有效住宿 + * @param operator 操作人 + * @param remark 结束原因(写入日志,便于排查) + */ + private void endActiveAccommodation(TeacherAccommodationVO active, String operator, String remark) + { + int rows = trainTeacherAccommodationMapper.updateAccommodationStatus(active.getAccommodationId(), + TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); + if (rows <= 0) + { + throw new ServiceException("结束教师原住宿失败, accommodationId=" + active.getAccommodationId()); + } + log.info("[教师住宿] {},accommodationId={}, bizNo={}", remark, + active.getAccommodationId(), active.getLockApiBizNo()); + callStayCheckout(active.getLockApiBizNo()); + } + + /** + * 下发门锁入住授权:POST /openapi/iot/stay/checkin + *

+ * 人脸服务人员ID 使用 {@code tId + "_teacher"}(与删除人脸口径一致,且与学生 sId 隔离),房间号取宿舍房间号。 + * + * @param accommodation 新增的住宿安排 + * @param teacher 教师信息 + * @param dorm 宿舍信息 + * @param photoUrl 已通过存在性校验的人脸照片地址 + * @return 入住授权结果;缺少客户端或教师信息时返回 skipped + */ + private LockStayCheckinResult callStayCheckin(TrainAccommodation accommodation, TrainTeacher teacher, + TrainDormitoryInfo dorm, String photoUrl) + { + if (trainLockStayApiClient == null || accommodation == null || teacher == null || dorm == null) + { + return LockStayCheckinResult.skipped(accommodation == null ? null : accommodation.getLockApiBizNo(), + "缺少门禁客户端或教师/宿舍信息,已跳过入住授权"); + } + // 人脸服务人员ID:教师统一使用 tId_teacher(与学生 sId 隔离) + String employeeId = buildTeacherFaceEmployeeId(teacher.getTId()); + if (StringUtils.isEmpty(employeeId)) + { + return LockStayCheckinResult.skipped(accommodation.getLockApiBizNo(), + "教师工号为空,无法下发人脸/开锁授权"); + } + Date beginTime = DateUtils.getNowDate(); + Date endTime = Date.from(beginTime.toInstant().plus(LOCK_AUTH_VALID_DAYS, ChronoUnit.DAYS)); + LockStayCheckinResult result = trainLockStayApiClient.checkinWithResult(accommodation.getLockApiBizNo(), + employeeId, teacher.gettName(), dorm.getDormRoom(), beginTime, endTime, photoUrl); + log.info("[教师住宿] 门锁入住授权下发完成, employeeId={}, dormRoom={}, result={}", + employeeId, dorm.getDormRoom(), result); + return result; + } + + /** + * 构造教师在人脸服务中的人员ID:{@code tId + "_teacher"}。 + *

+ * 仅教师住宿分配场景(添加人脸授权 / 删除人脸)使用;学生住宿仍沿用 sId,此处不做任何影响。 + * + * @param tId 教师工号 (train_teacher.t_id) + * @return 拼接后的人员ID;tId 为空时返回 null(调用方需按拒绝处理) + */ + private String buildTeacherFaceEmployeeId(String tId) + { + if (StringUtils.isEmpty(tId) || tId.trim().isEmpty()) + { + return null; + } + return tId.trim() + TEACHER_FACE_EMPLOYEE_SUFFIX; + } + + /** + * 开锁授权前置校验:教师人脸(train_teacher.avatar)是否可用于门锁开锁授权。 + *

+ * 判断流程(任何一步不满足均拒绝开锁,并返回可读原因): + *

    + *
  1. 教师信息缺失 → 拒绝;
  2. + *
  3. avatar 为空/空白 → 拒绝(无人脸信息);
  4. + *
  5. avatar 为 http(s) 地址 → 视为可用(远程资源存在性由门锁侧拉取时判定);
  6. + *
  7. avatar 为 /profile/... 本地相对路径 → 校验文件真实存在、为普通文件、大小在 (0, 5MB] 内, + * 且解析后不得越出 profile 根目录(防路径穿越);
  8. + *
  9. 其它形式(data URI、非法路径等)→ 拒绝;
  10. + *
  11. 校验过程抛出 IO/路径异常 → 拒绝(Fail Closed,异常不得放行开锁)。
  12. + *
+ * + * @param teacher 教师信息 + * @return 校验结果(是否允许开锁 + 原因 + 可用照片地址) + */ + private TeacherFaceCheck validateTeacherFaceForLockAuth(TrainTeacher teacher) + { + if (teacher == null) + { + return TeacherFaceCheck.reject("教师信息不存在,已拒绝开锁授权"); + } + String avatar = teacher.getAvatar(); + if (StringUtils.isEmpty(avatar) || avatar.trim().isEmpty()) + { + return TeacherFaceCheck.reject("教师未维护人脸照片(avatar 为空),已拒绝开锁授权"); + } + String photoUrl = avatar.trim(); + // 远程地址:无法在本机校验文件,按可用处理,由门锁侧拉取时判定 + if (photoUrl.startsWith("http://") || photoUrl.startsWith("https://")) + { + return TeacherFaceCheck.pass(photoUrl); + } + // 本地地址:必须是若依上传资源前缀,且真实文件存在 + if (!photoUrl.startsWith(PROFILE_RESOURCE_PREFIX)) + { + log.warn("[教师住宿] 人脸照片地址不合法,拒绝开锁授权, avatar={}", photoUrl); + return TeacherFaceCheck.reject("教师人脸照片地址不合法(仅支持 /profile/ 上传路径或 http(s) 地址),已拒绝开锁授权"); + } + try + { + Path profileRoot = Paths.get(RuoYiConfig.getProfile()).toAbsolutePath().normalize(); + String relative = photoUrl.substring(PROFILE_RESOURCE_PREFIX.length()); + Path photoPath = profileRoot.resolve(relative).normalize(); + if (!photoPath.startsWith(profileRoot)) + { + log.warn("[教师住宿] 人脸照片路径越界,拒绝开锁授权, avatar={}", photoUrl); + return TeacherFaceCheck.reject("教师人脸照片路径非法,已拒绝开锁授权"); + } + if (!Files.isRegularFile(photoPath)) + { + log.warn("[教师住宿] 人脸照片文件不存在,拒绝开锁授权, path={}", photoPath); + return TeacherFaceCheck.reject("教师人脸照片文件不存在,已拒绝开锁授权(请重新上传教师人脸照片)"); + } + long size = Files.size(photoPath); + if (size <= 0 || size > FACE_IMAGE_MAX_BYTES) + { + log.warn("[教师住宿] 人脸照片大小非法,拒绝开锁授权, path={}, size={}", photoPath, size); + return TeacherFaceCheck.reject("教师人脸照片文件无效(空文件或超过 5MB),已拒绝开锁授权"); + } + return TeacherFaceCheck.pass(photoUrl); + } + catch (Exception ex) + { + // Fail Closed:校验异常(含路径非法、IO 异常、存储配置缺失等)一律拒绝开锁,避免异常导致越权授权 + log.error("[教师住宿] 人脸照片校验异常,拒绝开锁授权, avatar={}, msg={}", photoUrl, ex.getMessage()); + return TeacherFaceCheck.reject("教师人脸照片校验异常,已拒绝开锁授权(请检查照片存储配置)"); + } + } + + /** + * 教师人脸可用性校验结果 + */ + private static class TeacherFaceCheck + { + private final boolean allowed; + + private final String message; + + private final String photoUrl; + + private TeacherFaceCheck(boolean allowed, String message, String photoUrl) + { + this.allowed = allowed; + this.message = message; + this.photoUrl = photoUrl; + } + + private static TeacherFaceCheck pass(String photoUrl) + { + return new TeacherFaceCheck(true, "人脸照片可用", photoUrl); + } + + private static TeacherFaceCheck reject(String message) + { + return new TeacherFaceCheck(false, message, null); + } + + private boolean isAllowed() + { + return allowed; + } + + private String getMessage() + { + return message; + } + + private String getPhotoUrl() + { + return photoUrl; + } + } + + /** + * 办理门锁退宿撤权:POST /openapi/iot/stay/checkout + * + * @param bizNo 门锁业务流水号 + * @return 撤权结果;缺少客户端或流水号时返回 skipped + */ + private LockStayCheckinResult callStayCheckout(String bizNo) + { + if (trainLockStayApiClient == null) + { + return LockStayCheckinResult.skipped(bizNo, "缺少门禁客户端,已跳过退宿撤权"); + } + if (StringUtils.isEmpty(bizNo)) + { + log.warn("[教师住宿] 退宿撤权跳过:门锁业务流水号为空"); + return LockStayCheckinResult.skipped(bizNo, "门锁业务流水号为空,已跳过退宿撤权"); + } + LockStayCheckinResult result = trainLockStayApiClient.checkoutWithResult(bizNo); + log.info("[教师住宿] 门锁退宿撤权完成, bizNo={}, result={}", bizNo, result); + return result; + } + + /** + * 退宿时删除人脸库中的教师员工数据:调用 TrainFaceApiController#deleteEmployee(tId_teacher)。 + *

+ * ruoyi-train 不能反向依赖 ruoyi-admin,因此按 Bean 名称获取控制器后反射调用; + * employee_id 使用 {@code tId + "_teacher"}(与添加人脸时的口径一致,与学生 sId 隔离)。 + * 人脸删除失败仅记录日志,不回滚退宿事实。 + * + * @param accommodation 教师住宿安排 + */ + private void deleteTeacherFaceOnCheckOut(TeacherAccommodationVO accommodation) + { + if (accommodation == null) + { + return; + } + String employeeId = buildTeacherFaceEmployeeId(accommodation.getTId()); + if (StringUtils.isEmpty(employeeId)) + { + log.warn("[教师住宿] 退宿删除人脸跳过:未取到教师工号, accommodationId={}", + accommodation.getAccommodationId()); + return; + } + try + { + Object controller = SpringUtils.getBean("trainFaceApiController"); + Method deleteEmployee = ClassUtils.getUserClass(controller).getMethod("deleteEmployee", String.class); + Object result = deleteEmployee.invoke(controller, employeeId); + log.info("[教师住宿] 退宿同步删除人脸完成, employeeId={}, result={}", employeeId, result); + } + catch (Exception ex) + { + log.error("[教师住宿] 退宿同步删除人脸失败, employeeId={}, msg={}", employeeId, ex.getMessage()); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java index 17d626dab..a419d0e8f 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java @@ -23,6 +23,15 @@ import com.ruoyi.train.service.ITrainTeacherService; @Service public class TrainTeacherServiceImpl implements ITrainTeacherService { + /** 参训状态-待报到(新增教师默认值) */ + private static final int ENROLLMENT_STATUS_PENDING = 0; + + /** 参训状态-已完成(枚举取值上限) */ + private static final int ENROLLMENT_STATUS_COMPLETED = 4; + + /** 是否需要住宿-否(新增教师默认值,与 train_enrollment 口径一致) */ + private static final int ACCOMMODATION_NOT_REQUIRED = 0; + @Autowired private TrainTeacherMapper trainTeacherMapper; @@ -120,6 +129,18 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService { trainTeacher.setStatus(1); } + // 参训状态:未指定时默认「待报到(0)」,与 DB 默认值保持一致;显式传值需在 0~4 枚举范围内 + if (trainTeacher.getEnrollmentStatus() == null) + { + trainTeacher.setEnrollmentStatus(ENROLLMENT_STATUS_PENDING); + } + validateEnrollmentStatus(trainTeacher.getEnrollmentStatus()); + // 是否需要住宿:未指定时默认「否(0)」;库中该列不可空且不设默认值,故在此兜底 + if (trainTeacher.getAccommodationRequired() == null) + { + trainTeacher.setAccommodationRequired(ACCOMMODATION_NOT_REQUIRED); + } + validateAccommodationRequired(trainTeacher.getAccommodationRequired()); trainTeacher.setDelFlag("0"); trainTeacher.setCreateTime(DateUtils.getNowDate()); try @@ -166,6 +187,10 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService throw new ServiceException("教师手机号码已存在", 40001); } + // 4. 参训状态 / 是否需要住宿校验(未传则保持原值,不参与更新) + validateEnrollmentStatus(trainTeacher.getEnrollmentStatus()); + validateAccommodationRequired(trainTeacher.getAccommodationRequired()); + trainTeacher.setUpdateTime(DateUtils.getNowDate()); try { @@ -241,4 +266,40 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService // 4. 执行零引用物理删除 return trainTeacherMapper.deleteTrainTeacherRealByTId(tId); } + + /** + * 校验参训状态取值:0待报到、1已报到、2请假、3放弃、4已完成。 + *

为 null 时不校验(新增走默认值,修改表示不更新该字段)。

+ * + * @param enrollmentStatus 参训状态 + */ + private void validateEnrollmentStatus(Integer enrollmentStatus) + { + if (enrollmentStatus == null) + { + return; + } + if (enrollmentStatus < ENROLLMENT_STATUS_PENDING || enrollmentStatus > ENROLLMENT_STATUS_COMPLETED) + { + throw new ServiceException("参训状态取值非法,允许范围 0~4(0待报到、1已报到、2请假、3放弃、4已完成)"); + } + } + + /** + * 校验是否需要住宿取值:0 否、1 是(与 train_enrollment.accommodation_required 口径一致)。 + *

为 null 时不校验(新增走默认值 0,修改表示不更新该字段)。

+ * + * @param accommodationRequired 是否需要住宿 + */ + private void validateAccommodationRequired(Integer accommodationRequired) + { + if (accommodationRequired == null) + { + return; + } + if (accommodationRequired != 0 && accommodationRequired != 1) + { + throw new ServiceException("是否需要住宿取值非法,仅允许 0(否) 或 1(是)"); + } + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTheoryExamScoreServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTheoryExamScoreServiceImpl.java new file mode 100644 index 000000000..86c76b35e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTheoryExamScoreServiceImpl.java @@ -0,0 +1,367 @@ +package com.ruoyi.train.service.impl; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.bean.BeanUtils; +import com.ruoyi.train.domain.TrainTheoryExamScore; +import com.ruoyi.train.domain.dto.TrainTheoryExamScoreDTO; +import com.ruoyi.train.domain.vo.TheoryExamScoreExportVO; +import com.ruoyi.train.domain.vo.TrainTheoryExamScoreVO; +import com.ruoyi.train.mapper.TrainTheoryExamScoreMapper; +import com.ruoyi.train.service.ITrainTheoryExamScoreService; + +/** + * 班级学员理论考试成绩Service业务层处理 + * + * @author ruoyi + * @date 2026-11-06 + */ +@Service +public class TrainTheoryExamScoreServiceImpl implements ITrainTheoryExamScoreService +{ + /** 考试状态-正常 */ + private static final String EXAM_STATUS_NORMAL = "NORMAL"; + + /** 考试状态-缺考 */ + private static final String EXAM_STATUS_ABSENT = "ABSENT"; + + /** 考试状态-免修 */ + private static final String EXAM_STATUS_EXEMPT = "EXEMPT"; + + /** 成绩状态-已接收 */ + private static final String SCORE_STATUS_RECEIVED = "RECEIVED"; + + /** 成绩状态-已归档 */ + private static final String SCORE_STATUS_ARCHIVED = "ARCHIVED"; + + /** 成绩状态-已锁定 */ + private static final String SCORE_STATUS_LOCKED = "LOCKED"; + + /** 默认满分 */ + private static final BigDecimal DEFAULT_FULL_SCORE = new BigDecimal("100.00"); + + /** 未显式指定是否通过时的判定基准(60%) */ + private static final BigDecimal PASS_RATE = new BigDecimal("0.6"); + + /** 分数最大小数位(与 DECIMAL(7,2) 对应) */ + private static final int SCORE_MAX_SCALE = 2; + + @Autowired + private TrainTheoryExamScoreMapper trainTheoryExamScoreMapper; + + @Override + public List selectTrainTheoryExamScoreList(TrainTheoryExamScoreVO query) + { + return trainTheoryExamScoreMapper.selectTrainTheoryExamScoreList(query); + } + + @Override + public List selectTheoryExamScoreExportList(TrainTheoryExamScoreVO query) + { + return trainTheoryExamScoreMapper.selectTheoryExamScoreExportList(query); + } + + @Override + public TrainTheoryExamScoreVO selectTrainTheoryExamScoreDetail(Long theoryScoreId) + { + if (theoryScoreId == null) + { + throw new ServiceException("理论考试成绩主键不能为空"); + } + return trainTheoryExamScoreMapper.selectTrainTheoryExamScoreVoByTheoryScoreId(theoryScoreId); + } + + @Override + public TrainTheoryExamScore selectTrainTheoryExamScoreByTheoryScoreId(Long theoryScoreId) + { + return trainTheoryExamScoreMapper.selectTrainTheoryExamScoreByTheoryScoreId(theoryScoreId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int insertTrainTheoryExamScore(TrainTheoryExamScoreDTO dto) + { + TrainTheoryExamScore entity = buildAndValidate(dto, true); + entity.setDelFlag("0"); + entity.setCreateBy(SecurityUtils.getUsername()); + // 唯一性:同一参训档案 + 课程 + 考试次数不可重复 + checkUnique(entity); + // 最终成绩唯一:先清除同课程其他记录的最终标记,避免触发唯一索引 uk_theory_final + if (Integer.valueOf(1).equals(entity.getIsFinal())) + { + trainTheoryExamScoreMapper.clearOtherFinalFlag(entity.getPlanCourseId(), entity.getStudentId(), null); + } + return trainTheoryExamScoreMapper.insertTrainTheoryExamScore(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateTrainTheoryExamScore(TrainTheoryExamScoreDTO dto) + { + if (dto == null || dto.getTheoryScoreId() == null) + { + throw new ServiceException("成绩主键不能为空"); + } + TrainTheoryExamScore exist = trainTheoryExamScoreMapper + .selectTrainTheoryExamScoreByTheoryScoreId(dto.getTheoryScoreId()); + if (exist == null) + { + throw new ServiceException("理论考试成绩不存在:" + dto.getTheoryScoreId()); + } + TrainTheoryExamScore entity = buildAndValidate(dto, false); + entity.setTheoryScoreId(exist.getTheoryScoreId()); + entity.setUpdateBy(SecurityUtils.getUsername()); + // 已锁定成绩不允许修改分数相关内容(仅允许变更成绩状态,如解锁/归档) + if (SCORE_STATUS_LOCKED.equals(exist.getScoreStatus()) && scoreContentChanged(exist, entity)) + { + throw new ServiceException("该成绩已锁定,不允许修改分数相关信息;如需调整请先变更成绩状态"); + } + checkUnique(entity); + if (Integer.valueOf(1).equals(entity.getIsFinal())) + { + trainTheoryExamScoreMapper.clearOtherFinalFlag(entity.getPlanCourseId(), entity.getStudentId(), + entity.getTheoryScoreId()); + } + return trainTheoryExamScoreMapper.updateTrainTheoryExamScore(entity); + } + + @Override + public int deleteTrainTheoryExamScoreByTheoryScoreIds(Long[] theoryScoreIds) + { + if (theoryScoreIds == null || theoryScoreIds.length == 0) + { + throw new ServiceException("请选择要删除的理论考试成绩"); + } + return trainTheoryExamScoreMapper.deleteTrainTheoryExamScoreByTheoryScoreIds(theoryScoreIds); + } + + /** + * DTO 转实体并完成全部参数校验与默认值填充。 + * + * @param dto 请求参数 + * @param insert 是否新增场景 + * @return 校验通过的实体 + */ + private TrainTheoryExamScore buildAndValidate(TrainTheoryExamScoreDTO dto, boolean insert) + { + if (dto == null) + { + throw new ServiceException("理论考试成绩不能为空"); + } + // 1. 必填维度校验 + if (dto.getEnrollmentId() == null) + { + throw new ServiceException("参训档案ID不能为空"); + } + if (StringUtils.isBlank(dto.getPlanId())) + { + throw new ServiceException("培训班ID不能为空"); + } + if (dto.getPlanCourseId() == null) + { + throw new ServiceException("培训课程实例ID不能为空"); + } + if (StringUtils.isBlank(dto.getCId())) + { + throw new ServiceException("课程ID不能为空"); + } + if (StringUtils.isBlank(dto.getStudentId())) + { + throw new ServiceException("学员ID不能为空"); + } + + TrainTheoryExamScore entity = new TrainTheoryExamScore(); + BeanUtils.copyBeanProp(entity, dto); + + // 2. 考试状态默认值与枚举校验 + if (StringUtils.isBlank(entity.getExamStatus())) + { + entity.setExamStatus(EXAM_STATUS_NORMAL); + } + if (!EXAM_STATUS_NORMAL.equals(entity.getExamStatus()) && !EXAM_STATUS_ABSENT.equals(entity.getExamStatus()) + && !EXAM_STATUS_EXEMPT.equals(entity.getExamStatus())) + { + throw new ServiceException("考试状态取值非法,仅允许 NORMAL(正常)、ABSENT(缺考)、EXEMPT(免修)"); + } + + // 3. 成绩状态默认值与枚举校验 + if (StringUtils.isBlank(entity.getScoreStatus())) + { + entity.setScoreStatus(SCORE_STATUS_RECEIVED); + } + if (!SCORE_STATUS_RECEIVED.equals(entity.getScoreStatus()) && !SCORE_STATUS_ARCHIVED.equals(entity.getScoreStatus()) + && !SCORE_STATUS_LOCKED.equals(entity.getScoreStatus())) + { + throw new ServiceException("成绩状态取值非法,仅允许 RECEIVED(已接收)、ARCHIVED(已归档)、LOCKED(已锁定)"); + } + + // 4. 来源类型默认值与枚举校验 + if (entity.getSourceType() == null) + { + entity.setSourceType(1); + } + if (entity.getSourceType() < 1 || entity.getSourceType() > 3) + { + throw new ServiceException("成绩来源取值非法,仅允许 1(手工录入)、2(平台在线考试)、3(批量或外部导入)"); + } + + // 5. 满分默认值与校验 + if (entity.getFullScore() == null) + { + entity.setFullScore(DEFAULT_FULL_SCORE); + } + if (entity.getFullScore().compareTo(BigDecimal.ZERO) <= 0) + { + throw new ServiceException("满分必须大于0"); + } + + // 6. 补考标志与补考序号 + if (entity.getRetake() == null) + { + entity.setRetake(0); + } + if (entity.getRetake() != 0 && entity.getRetake() != 1) + { + throw new ServiceException("是否补考取值非法,仅允许 0(否) 或 1(是)"); + } + if (entity.getRetakeNo() == null) + { + entity.setRetakeNo(0); + } + if (entity.getRetakeNo() < 0) + { + throw new ServiceException("补考序号不能小于0"); + } + if (entity.getRetake() == 1 && entity.getRetakeNo() < 1) + { + throw new ServiceException("补考记录的补考序号必须大于等于1"); + } + if (entity.getRetake() == 0 && entity.getRetakeNo() != 0) + { + throw new ServiceException("正考记录的补考序号必须为0"); + } + + // 7. 是否最终成绩 + if (entity.getIsFinal() == null) + { + entity.setIsFinal(1); + } + if (entity.getIsFinal() != 0 && entity.getIsFinal() != 1) + { + throw new ServiceException("是否最终成绩取值非法,仅允许 0(否) 或 1(是)"); + } + + // 8. 考试时间与用时 + if (entity.getExamStartTime() != null && entity.getExamEndTime() != null + && !entity.getExamEndTime().after(entity.getExamStartTime())) + { + throw new ServiceException("考试结束时间必须晚于考试开始时间"); + } + if (entity.getDurationSeconds() != null && entity.getDurationSeconds() < 0) + { + throw new ServiceException("考试用时不能小于0秒"); + } + + // 9. 分数校验:正常考试必须有分数且在 0 ~ 满分之间,最多两位小数 + if (EXAM_STATUS_NORMAL.equals(entity.getExamStatus())) + { + if (entity.getScore() == null) + { + throw new ServiceException("正常考试的成绩不能为空"); + } + if (entity.getScore().scale() > SCORE_MAX_SCALE) + { + throw new ServiceException("成绩最多保留两位小数"); + } + if (entity.getScore().compareTo(BigDecimal.ZERO) < 0 + || entity.getScore().compareTo(entity.getFullScore()) > 0) + { + throw new ServiceException("成绩必须在 0 ~ " + entity.getFullScore().toPlainString() + " 之间"); + } + } + else + { + if (entity.getScore() != null) + { + throw new ServiceException("缺考或免修的成绩必须为空,请勿录入分数"); + } + } + + // 10. 是否通过:未指定时按 60% 及格线推算(缺考/免修未指定则记为未通过) + if (entity.getPassFlag() == null) + { + if (EXAM_STATUS_NORMAL.equals(entity.getExamStatus())) + { + BigDecimal passLine = entity.getFullScore().multiply(PASS_RATE); + entity.setPassFlag(entity.getScore().compareTo(passLine) >= 0 ? 1 : 0); + } + else + { + entity.setPassFlag(EXAM_STATUS_EXEMPT.equals(entity.getExamStatus()) ? 1 : 0); + } + } + if (entity.getPassFlag() != 0 && entity.getPassFlag() != 1) + { + throw new ServiceException("是否通过取值非法,仅允许 0(未通过) 或 1(通过)"); + } + + if (insert) + { + entity.setCreateTime(new Date()); + } + return entity; + } + + /** + * 唯一性校验:同一参训档案 + 课程 + 考试次数只允许一条有效记录。 + * + * @param entity 待保存实体 + */ + private void checkUnique(TrainTheoryExamScore entity) + { + int count = trainTheoryExamScoreMapper.countByUniqueKey(entity.getEnrollmentId(), entity.getPlanCourseId(), + entity.getRetakeNo(), entity.getTheoryScoreId()); + if (count > 0) + { + throw new ServiceException("该学员该课程的第 " + entity.getRetakeNo() + " 次考试成绩已存在,请勿重复录入"); + } + } + + /** + * 判断分数相关内容是否发生变化(用于已锁定成绩的修改拦截)。 + * + * @param exist 数据库现状 + * @param current 本次提交 + * @return true 表示分数相关内容已变更 + */ + private boolean scoreContentChanged(TrainTheoryExamScore exist, TrainTheoryExamScore current) + { + return decimalChanged(exist.getScore(), current.getScore()) + || decimalChanged(exist.getFullScore(), current.getFullScore()) + || !equalsSafe(exist.getExamStatus(), current.getExamStatus()) + || !equalsSafe(exist.getPassFlag(), current.getPassFlag()) + || !equalsSafe(exist.getRetake(), current.getRetake()) + || !equalsSafe(exist.getRetakeNo(), current.getRetakeNo()); + } + + private boolean decimalChanged(BigDecimal origin, BigDecimal current) + { + if (origin == null || current == null) + { + return origin != current; + } + return origin.compareTo(current) != 0; + } + + private boolean equalsSafe(Object origin, Object current) + { + return origin == null ? current == null : origin.equals(current); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTrainingSiteServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTrainingSiteServiceImpl.java index 77f721460..b39c16753 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTrainingSiteServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTrainingSiteServiceImpl.java @@ -1,6 +1,9 @@ package com.ruoyi.train.service.impl; +import java.util.ArrayList; import java.util.List; + +import com.ruoyi.train.domain.TrainClassroomInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.common.exception.ServiceException; @@ -47,6 +50,22 @@ public class TrainTrainingSiteServiceImpl implements ITrainTrainingSiteService return trainTrainingSiteMapper.selectTrainTrainingSiteList(trainTrainingSite); } + /** + * 查询某开班计划(planId)课表中用到的教室信息(按教室去重) + * + * @param planId 开班计划ID + * @return 教室信息集合 + */ + @Override + public List selectTrainTrainingSiteByPlanId(String planId) + { + if (StringUtils.isEmpty(planId)) + { + return new ArrayList<>(); + } + return trainTrainingSiteMapper.selectTrainTrainingSiteByPlanId(planId); + } + /** * 校验实训场地名称是否唯一 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml index 04c1079c1..4afbf550b 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml @@ -11,6 +11,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + @@ -19,7 +21,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + @@ -35,6 +37,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" a.bed_no, a.assigned_time, a.accommodation_status, + a.lock_api_biz_no, + a.lock_result, a.create_by, a.create_time, a.update_by, @@ -43,7 +47,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" e.student_id, s.s_name AS student_name, s.s_gender AS gender, - s.org_name, + s.dept_name, s.s_phone AS phone, e.plan_id, p.plan_name, @@ -56,9 +60,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" LEFT JOIN train_dormitory_info d ON a.dorm_id = d.dorm_id + + + WHERE a.enrollment_id = #{enrollmentId} + + AND a.occupant_type = 'student' ORDER BY a.assigned_time DESC LIMIT 1 + + + @@ -183,6 +218,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" bed_no, assigned_time, accommodation_status, + lock_api_biz_no, + lock_result, create_by, create_time, update_by, @@ -195,6 +232,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{bedNo}, #{assignedTime}, #{accommodationStatus}, + #{lockApiBizNo}, + #{lockResult}, #{createBy}, #{createTime}, #{updateBy}, @@ -210,6 +249,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" bed_no = #{bedNo}, assigned_time = #{assignedTime}, accommodation_status = #{accommodationStatus}, + lock_api_biz_no = #{lockApiBizNo}, + lock_result = #{lockResult}, update_by = #{updateBy}, update_time = #{updateTime}, remark = #{remark}, @@ -225,6 +266,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" WHERE accommodation_id = #{accommodationId} + + UPDATE train_accommodation + SET lock_api_biz_no = #{lockApiBizNo}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE accommodation_id = #{accommodationId} + + + + UPDATE train_accommodation + SET lock_result = #{lockResult}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE accommodation_id = #{accommodationId} + + DELETE FROM train_accommodation WHERE accommodation_id = #{accommodationId} diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml index 8afb643ea..b3973071f 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml @@ -32,11 +32,12 @@ SELECT - TO_CHAR(l.start_time, 'YYYY-MM-DD') AS stat_date, + DATE_FORMAT(l.start_time, '%Y-%m-%d') AS stat_date, SUM(CASE WHEN c.attendance_status = 1 THEN 1 ELSE 0 END) AS normal_count, SUM(CASE WHEN c.checkin_id IS NULL OR c.attendance_status != 1 THEN 1 ELSE 0 END) AS abnormal_count WHERE l.start_time <= CURRENT_TIMESTAMP - AND l.start_time >= CURRENT_DATE - INTERVAL '30' DAY - GROUP BY TO_CHAR(l.start_time, 'YYYY-MM-DD') + AND l.start_time >= CURRENT_DATE - INTERVAL 30 DAY + GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d') ORDER BY stat_date ASC @@ -120,11 +122,11 @@ @@ -140,7 +142,7 @@ SELECT COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name, count(1) AS usage_count, - ROUND(CAST(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0) AS numeric), 1) AS total_hours + ROUND(SUM(TIMESTAMPDIFF(SECOND, l.start_time, l.end_time ) /3600), 1) as total_hours FROM train_lesson l LEFT JOIN train_classroom_info cr ON cr.class_room_id = l.class_room_id LEFT JOIN train_training_site ts ON ts.site_id = l.site_id @@ -180,23 +182,23 @@ @@ -214,10 +216,10 @@ WHERE p.del_flag = '0' AND l.start_time <= CURRENT_TIMESTAMP - AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD') + AND l.start_time >= STR_TO_DATE(#{dto.startDate}, '%Y-%m-%d') - AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY + AND l.start_time < STR_TO_DATE(#{dto.endDate}, '%Y-%m-%d') + INTERVAL 1 DAY AND p.plan_id = #{dto.planId} @@ -244,10 +246,10 @@ WHERE crs.del_flag = '0' AND l.start_time <= CURRENT_TIMESTAMP - AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD') + AND l.start_time >= STR_TO_DATE(#{dto.startDate}, '%Y-%m-%d') - AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY + AND l.start_time < STR_TO_DATE(#{dto.endDate}, '%Y-%m-%d') + INTERVAL 1 DAY AND pc.plan_id = #{dto.planId} @@ -286,10 +288,10 @@ LEFT JOIN train_student_checkin c ON c.lesson_id = l.lesson_id AND c.enrollment_id = e.enrollment_id WHERE l.start_time <= CURRENT_TIMESTAMP - AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD') + AND l.start_time >= STR_TO_DATE(#{dto.startDate}, '%Y-%m-%d') - AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY + AND l.start_time < STR_TO_DATE(#{dto.endDate}, '%Y-%m-%d') + INTERVAL 1 DAY AND p.plan_id = #{dto.planId} @@ -317,7 +319,8 @@ s.s_birth AS birth, s.s_phone AS phone, s.s_email AS email, - s.org_name AS org_name, + s.dept_name AS dept_name, + s.avatar AS avatar, s.id_card_last4 AS id_card_last4, s.status AS status, CASE WHEN s.user_id IS NOT NULL THEN '已绑定' ELSE '未绑定' END AS user_binding_status @@ -345,7 +348,7 @@ + + + @@ -392,4 +420,85 @@ WHERE e.student_id = #{studentId} + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml index 1d6ecedcb..dc1bd0564 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml @@ -10,16 +10,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + + + @@ -28,11 +38,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + + + @@ -42,26 +62,32 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select plan_id, plan_name, start_time, end_time, report_deadline, train_address, + select plan_id, id, plan_name, start_time, end_time, report_deadline, report_starttime, train_address, recruit_num, apply_condition, as plan_status, del_flag, create_by, create_time, - update_by, update_time, remark + update_by, update_time, remark, + class_id, class_name, class_major, class_teacher, manager, + host_dept_id, total_class_hours, total_budget from train_class_plan @@ -94,6 +123,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" for update + + + + + + + update train_class_report + + report_way = #{reportWay}, + report_device_sn = #{reportDeviceSn}, + report_time = #{reportTime}, + verify_result = #{verifyResult}, + verify_note = #{verifyNote}, + remark = #{remark}, + + where report_id = #{reportId} + + + + + delete from train_class_report where report_id = #{reportId} + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassSeatMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassSeatMapper.xml new file mode 100644 index 000000000..4d7d2af54 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassSeatMapper.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select s.seat_id, s.plan_id, s.classroom_id, s.category, s.row_index, s.col_index, + s.seat_code, s.canceled, s.student_id, st.s_name as student_name, s.zone_id, + s.dept_detail, s.job, s.create_by, s.create_time, s.update_by, s.update_time, s.remark + from train_class_seat s + left join train_student st on st.s_id = s.student_id + + + + + + + + + + insert into train_class_seat + + plan_id, + classroom_id, + category, + row_index, + col_index, + seat_code, + canceled, + student_id, + zone_id, + dept_detail, + job, + create_by, + create_time, + remark, + + + #{planId}, + #{classroomId}, + #{category}, + #{rowIndex}, + #{colIndex}, + #{seatCode}, + #{canceled}, + #{studentId}, + #{zoneId}, + #{deptDetail}, + #{job}, + #{createBy}, + CURRENT_TIMESTAMP, + #{remark}, + + + + + update train_class_seat + + plan_id = #{planId}, + classroom_id = #{classroomId}, + category = #{category}, + row_index = #{rowIndex}, + col_index = #{colIndex}, + seat_code = #{seatCode}, + canceled = #{canceled}, + student_id = #{studentId}, + zone_id = #{zoneId}, + dept_detail = #{deptDetail}, + job = #{job}, + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP, + remark = #{remark}, + + where seat_id = #{seatId} + + + + delete from train_class_seat where seat_id = #{seatId} + + + + delete from train_class_seat where seat_id in + + #{seatId} + + + + + delete from train_class_seat + where plan_id = #{planId} and classroom_id = #{classroomId} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassroomInfoMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassroomInfoMapper.xml index d2b7abd40..b8b2b1ba6 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassroomInfoMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassroomInfoMapper.xml @@ -19,10 +19,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + - select class_room_id, room_name, room_location, capacity, room_status, del_flag, create_by, create_time, update_by, update_time, remark, seat_arrange, multimedia, projector from train_classroom_info + select class_room_id, room_name, room_location, capacity, room_status, del_flag, create_by, create_time, update_by, update_time, remark, seat_arrange, multimedia, projector, bind_board_id, id from train_classroom_info + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormitoryInfoMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormitoryInfoMapper.xml index 16c81ab45..08f326982 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormitoryInfoMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormitoryInfoMapper.xml @@ -21,11 +21,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + SELECT d.dorm_id, + d.id, d.dorm_building, d.dorm_floor, d.dorm_room, @@ -79,6 +81,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + @@ -197,4 +231,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{dormId} + + + UPDATE train_dormitory_info + SET lock_device_sn = #{lockDeviceSn}, + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + WHERE dorm_id = #{dormId} + \ No newline at end of file diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainElectronicBoardMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainElectronicBoardMapper.xml index 5b7961ac7..7b51ba09a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainElectronicBoardMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainElectronicBoardMapper.xml @@ -20,10 +20,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + - select board_id, board_sn, board_name, bind_classroom_id, board_ip, api_endpoint, support_checkin_type, board_status, last_heartbeat, del_flag, create_by, create_time, update_by, update_time, remark from train_electronic_board + select board_id, id, board_sn, board_name, bind_classroom_id, board_ip, api_endpoint, support_checkin_type, board_status, last_heartbeat, del_flag, create_by, create_time, update_by, update_time, remark from train_electronic_board + select batch_pk, app_id, batch_id, report_time, period_start, period_end, resend, + total, accepted, duplicated, unmatched, received_time, req_id, create_time + from train_exam_report_batch + where app_id = #{appId} and batch_id = #{batchId} + limit 1 + + + + insert into train_exam_report_batch ( + app_id, batch_id, report_time, period_start, period_end, resend, + total, accepted, duplicated, unmatched, received_time, req_id, create_time + ) values ( + #{appId}, #{batchId}, #{reportTime}, #{periodStart}, #{periodEnd}, #{resend}, + #{total}, #{accepted}, #{duplicated}, #{unmatched}, #{receivedTime}, #{reqId}, sysdate() + ) + + + + update train_exam_report_batch + set total = #{total}, accepted = #{accepted}, duplicated = #{duplicated}, unmatched = #{unmatched} + where batch_pk = #{batchPk} + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExamReportMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExamReportMapper.xml new file mode 100644 index 000000000..5f80f9993 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExamReportMapper.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select report_pk, report_id, app_id, batch_pk, batch_id, report_channel, biz_type, + student_id, student_name, id_card_masked, id_card_md5, matched_enrollment_id, matched_plan_id, + match_status, score_status, subject_code, subject_name, exam_start_time, exam_end_time, + duration_seconds, score, full_score, pass_flag, retake, retake_no, station, + content, content_size, content_md5, report_time, received_time, resend, + claim_by, claim_time, archive_time, lock_time, lock_reason, data_version, + remark, create_time, update_time + from train_exam_report + + + + + + + + + + + + insert into train_exam_report ( + report_id, app_id, batch_pk, batch_id, report_channel, biz_type, student_id, student_name, + id_card_masked, id_card_md5, matched_enrollment_id, matched_plan_id, match_status, score_status, + subject_code, subject_name, exam_start_time, exam_end_time, duration_seconds, score, full_score, + pass_flag, retake, retake_no, station, content, content_size, content_md5, + report_time, received_time, resend, remark, create_time + ) values ( + #{reportId}, #{appId}, #{batchPk}, #{batchId}, #{reportChannel}, #{bizType}, #{studentId}, #{studentName}, + #{idCardMasked}, #{idCardMd5}, #{matchedEnrollmentId}, #{matchedPlanId}, #{matchStatus}, #{scoreStatus}, + #{subjectCode}, #{subjectName}, #{examStartTime}, #{examEndTime}, #{durationSeconds}, #{score}, #{fullScore}, + #{passFlag}, #{retake}, #{retakeNo}, #{station}, #{content}, #{contentSize}, #{contentMd5}, + #{reportTime}, #{receivedTime}, #{resend}, #{remark}, sysdate() + ) + + + + update train_exam_report + + matched_enrollment_id = #{matchedEnrollmentId}, + matched_plan_id = #{matchedPlanId}, + match_status = #{matchStatus}, + score_status = #{scoreStatus}, + claim_by = #{claimBy}, + claim_time = #{claimTime}, + archive_time = #{archiveTime}, + lock_time = #{lockTime}, + lock_reason = #{lockReason}, + remark = #{remark}, + data_version = data_version + 1, + update_time = sysdate() + + where report_pk = #{reportPk} + + + + delete from train_exam_report where report_pk in + + #{reportPk} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExamSubjectMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExamSubjectMapper.xml new file mode 100644 index 000000000..adff476b6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExamSubjectMapper.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + select subject_id, subject_code, subject_name, plan_id, score_item_id, status, sort_no, + create_by, create_time, update_by, update_time, remark + from train_exam_subject + + + + + + + + + + insert into train_exam_subject ( + subject_code, subject_name, plan_id, score_item_id, status, sort_no, + create_by, create_time, remark + ) values ( + #{subjectCode}, #{subjectName}, #{planId}, #{scoreItemId}, #{status}, #{sortNo}, + #{createBy}, sysdate(), #{remark} + ) + + + + update train_exam_subject + + subject_code = #{subjectCode}, + subject_name = #{subjectName}, + plan_id = #{planId}, + score_item_id = #{scoreItemId}, + status = #{status}, + sort_no = #{sortNo}, + remark = #{remark}, + update_by = #{updateBy}, + update_time = sysdate() + + where subject_id = #{subjectId} + + + + delete from train_exam_subject where subject_id in + + #{subjectId} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml index 9e9044250..e041fadf2 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml @@ -49,8 +49,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + + + + + - select lesson_id, plan_course_id, t_id, start_time, end_time, venue_type, class_room_id, site_id, create_by, create_time, update_by, update_time, remark + select train_lesson.lesson_id, train_lesson.plan_course_id, train_lesson.t_id, train_lesson.start_time, train_lesson.end_time, train_lesson.venue_type, train_lesson.class_room_id, train_lesson.site_id, train_lesson.create_by, train_lesson.create_time, train_lesson.update_by, train_lesson.update_time, train_lesson.remark from train_lesson @@ -59,6 +71,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where lesson_id = #{lessonId} + + insert into train_lesson ( lesson_id, @@ -260,6 +279,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" l.remark from train_lesson l inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id + inner join train_class_plan p on pc.plan_id = p.plan_id and p.del_flag = '0' left join train_course c on pc.c_id = c.c_id left join train_teacher t on l.t_id = t.t_id left join train_classroom_info cr on l.class_room_id = cr.class_room_id @@ -294,6 +314,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" l.remark from train_lesson l inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id + inner join train_class_plan p on pc.plan_id = p.plan_id and p.del_flag = '0' left join train_course c on pc.c_id = c.c_id left join train_teacher t on l.t_id = t.t_id left join train_classroom_info cr on l.class_room_id = cr.class_room_id @@ -334,6 +355,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" l.remark from train_lesson l inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id + inner join train_class_plan p on pc.plan_id = p.plan_id and p.del_flag = '0' left join train_course c on pc.c_id = c.c_id left join train_teacher t on l.t_id = t.t_id left join train_classroom_info cr on l.class_room_id = cr.class_room_id @@ -347,7 +369,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id) where pc.plan_id in ( select plan_id from train_enrollment - where student_id = #{studentId} and enrollment_status in (0, 1, 4) + where student_id = #{studentId} ) and l.start_time >= #{startDate} @@ -394,4 +416,49 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select count(1) from train_lesson where t_id = #{tId} + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenApiLogMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenApiLogMapper.xml new file mode 100644 index 000000000..12c5808f6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenApiLogMapper.xml @@ -0,0 +1,11 @@ + + + + + + insert into train_open_api_log (req_id, app_id, api_path, http_method, biz_key, code, msg, cost_ms, client_ip, create_time) + values (#{reqId}, #{appId}, #{apiPath}, #{httpMethod}, #{bizKey}, #{code}, #{msg}, #{costMs}, #{clientIp}, sysdate()) + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenAppMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenAppMapper.xml new file mode 100644 index 000000000..269839ded --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenAppMapper.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + select app_id, app_name, app_type, org_name, app_secret_hash, app_secret_masked, + contact_name, contact_phone, rate_limit, realtime_enabled, status, + create_by, create_time, update_by, update_time, remark + from train_open_app + + + + + + + + insert into train_open_app ( + app_id, app_name, app_type, org_name, app_secret_hash, app_secret_masked, + contact_name, contact_phone, rate_limit, realtime_enabled, status, + create_by, create_time, remark + ) values ( + #{appId}, #{appName}, #{appType}, #{orgName}, #{appSecretHash}, #{appSecretMasked}, + #{contactName}, #{contactPhone}, #{rateLimit}, #{realtimeEnabled}, #{status}, + #{createBy}, sysdate(), #{remark} + ) + + + + update train_open_app + + app_name = #{appName}, + app_type = #{appType}, + org_name = #{orgName}, + app_secret_hash = #{appSecretHash}, + app_secret_masked = #{appSecretMasked}, + contact_name = #{contactName}, + contact_phone = #{contactPhone}, + rate_limit = #{rateLimit}, + realtime_enabled = #{realtimeEnabled}, + status = #{status}, + remark = #{remark}, + update_by = #{updateBy}, + update_time = sysdate() + + where app_id = #{appId} + + + + delete from train_open_app where app_id in + + #{appId} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenAppScopeMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenAppScopeMapper.xml new file mode 100644 index 000000000..585e65b40 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenAppScopeMapper.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + insert into train_open_app_scope (app_id, scope_type, scope_value, create_by, create_time) values + + (#{item.appId}, #{item.scopeType}, #{item.scopeValue}, #{item.createBy}, sysdate()) + + + + + delete from train_open_app_scope where app_id = #{appId} + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenStudentMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenStudentMapper.xml new file mode 100644 index 000000000..a292427c3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenStudentMapper.xml @@ -0,0 +1,51 @@ + + + + + + from train_enrollment e + inner join train_student s on s.s_id = e.student_id and s.del_flag = '0' and s.status = 1 + inner join train_class_plan p on p.plan_id = e.plan_id and p.del_flag = '0' + where e.enrollment_status in (1, 2) + + and e.plan_id = #{classId} + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenTokenMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenTokenMapper.xml new file mode 100644 index 000000000..b76050a1b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainOpenTokenMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + insert into train_open_token (app_id, token_hash, issued_time, expire_time, status, client_ip, user_agent, create_time) + values (#{appId}, #{tokenHash}, #{issuedTime}, #{expireTime}, #{status}, #{clientIp}, #{userAgent}, sysdate()) + + + + update train_open_token set status = 0 where app_id = #{appId} and status = 1 + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml new file mode 100644 index 000000000..7e30b644d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitSnapshotMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitSnapshotMapper.xml new file mode 100644 index 000000000..ef49e4ffc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitSnapshotMapper.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + insert into train_student_portrait ( + enrollment_id, attendance_rate, theory_score, practical_score, + total_study_hour, label_tag, portrait_content, generated_time, + create_by, create_time + ) values ( + #{enrollmentId}, #{attendanceRate}, #{theoryScore}, #{practicalScore}, + #{totalStudyHour}, #{labelTag}, #{portraitContent}, #{generatedTime}, + #{createBy}, CURRENT_TIMESTAMP + ) + + + + update train_student_portrait + set attendance_rate = #{attendanceRate}, + theory_score = #{theoryScore}, + practical_score = #{practicalScore}, + total_study_hour = #{totalStudyHour}, + label_tag = #{labelTag}, + portrait_content = #{portraitContent}, + generated_time = #{generatedTime}, + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + where portrait_id = #{portraitId} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainRegDataMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainRegDataMapper.xml new file mode 100644 index 000000000..58f73604e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainRegDataMapper.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + e.enrollment_status <> 3 + + + + + left join train_class_report r on r.report_id = ( + select r2.report_id + from train_class_report r2 + where r2.enrollment_id = e.enrollment_id + and r2.verify_result = 1 + order by r2.report_time desc, r2.report_id desc + limit 1 + ) + + + + + + + + + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainResourceMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainResourceMapper.xml index 396fc5f26..8a040f3ed 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainResourceMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainResourceMapper.xml @@ -17,10 +17,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + - select resource_id, resource_name, resource_type, resource_url, description, status, del_flag, create_by, create_time, update_by, update_time, remark from train_resource + select resource_id, id, resource_name, resource_type, resource_url, description, status, del_flag, create_by, create_time, update_by, update_time, remark from train_resource + SELECT + COUNT(1) AS total_count, + SUM(CASE WHEN s.biz_category = 'THEORY' THEN 1 ELSE 0 END) AS theory_count, + SUM(CASE WHEN s.biz_category = 'PRACTICE' THEN 1 ELSE 0 END) AS practice_count, + COUNT(DISTINCT s.student_id) AS student_count, + COUNT(DISTINCT s.plan_id) AS plan_count, + ROUND(AVG(), 1) AS avg_score_rate, + AS pass_rate, + MAX(s.score) AS max_score, + MIN(s.score) AS min_score + FROM ( ) s + + + + + + + + + + + + + + + + + + + + + + + CASE + WHEN s.score IS NULL OR s.full_score IS NULL OR s.full_score = 0 THEN 'UNSCORED' + WHEN s.score * 100 / s.full_score < 60 THEN 'R0' + WHEN s.score * 100 / s.full_score < 70 THEN 'R60' + WHEN s.score * 100 / s.full_score < 80 THEN 'R70' + WHEN s.score * 100 / s.full_score < 90 THEN 'R80' + ELSE 'R90' + END + + + + + + + + + + + + + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainSignageMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainSignageMapper.xml new file mode 100644 index 000000000..8532ca763 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainSignageMapper.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml index 85fddfb85..d08c7474c 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml @@ -7,6 +7,7 @@ + @@ -25,8 +26,9 @@ - select a.checkin_id, a.enrollment_id, a.lesson_id, e.plan_id, p.plan_name, - e.student_id, s.s_name as student_name, c.c_name as course_name, + select a.checkin_id, a.enrollment_id, a.lesson_id, e.plan_id, p.plan_name, p.class_name, + coalesce(a.student_id, e.student_id) as student_id, + s.s_name as student_name, c.c_name as course_name, a.board_id, b.board_sn, b.board_name, a.checkin_way, a.sign_in_time, a.sign_out_time, a.attendance_status, a.manual_flag, a.create_by, a.create_time, a.update_by, a.update_time, a.remark @@ -46,6 +48,8 @@ and a.lesson_id = #{lessonId} and e.plan_id = #{planId} and p.plan_name like concat('%', #{planName}, '%') + and p.class_name like concat('%', #{className}, '%') + and coalesce(a.student_id, e.student_id) = #{studentId} and s.s_name like concat('%', #{studentName}, '%') and c.c_name like concat('%', #{courseName}, '%') and a.checkin_way = #{checkinWay} @@ -63,11 +67,11 @@ insert into train_student_checkin ( - enrollment_id, lesson_id, board_id, checkin_way, + enrollment_id, lesson_id, student_id, board_id, checkin_way, sign_in_time, sign_out_time, attendance_status, manual_flag, create_by, create_time, update_by, update_time, remark ) values ( - #{enrollmentId}, #{lessonId}, #{boardId}, #{checkinWay}, + #{enrollmentId}, #{lessonId}, #{studentId}, #{boardId}, #{checkinWay}, #{signInTime}, #{signOutTime}, #{attendanceStatus}, #{manualFlag}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark} ) @@ -75,6 +79,7 @@ update train_student_checkin + student_id = #{studentId}, board_id = #{boardId}, checkin_way = #{checkinWay}, sign_in_time = #{signInTime}, @@ -91,7 +96,7 @@ diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml index 8a573b7ad..90a8bcdf0 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml @@ -11,7 +11,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + + @@ -28,13 +29,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + + + - select s_id, s_name, person_no, s_gender, s_birth, user_id, org_name, s_phone, s_email, + select s_id, s_name, person_no, s_gender, s_birth, user_id, dept_name, card_no, s_phone, s_email, id_card_ciphertext, id_card_hash, id_card_last4, face_ref, source_type, status, create_by, create_time, update_by, update_time, remark, del_flag, - avatar, duty_level + avatar, duty_level, extract_status, extract_time, extract_msg, + photo_md5, feature_data, feature_md5, feature_algo_version, feature_size, + id, dept_id from train_student @@ -47,17 +60,46 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and s_name like concat('%', #{sName}, '%') - - and org_name like concat('%', #{orgName}, '%') + + and dept_name like concat('%', #{deptName}, '%') + + + and card_no like concat('%', #{cardNo}, '%') and s_phone = #{sPhone} + + and person_no like concat('%', #{personNo}, '%') + and status = #{status} + + + and create_time >= #{beginCreateTime} + + + and create_time <= #{endCreateTime} + + + and dept_id = #{deptId} + + + + and exists ( + select 1 from train_enrollment e + where e.plan_id = #{planId} + and e.student_id = train_student.s_id + and (e.enrollment_status is null or e.enrollment_status <> 3) + ) + - order by create_time desc, s_id + order by create_time desc, id + + + + + + + + + - insert into train_student ( - s_id, s_name, person_no, s_gender, s_birth, user_id, org_name, s_phone, s_email, - id_card_ciphertext, id_card_hash, id_card_last4, face_ref, source_type, - status, create_by, create_time, update_by, update_time, remark, del_flag, - avatar, duty_level - ) values ( - #{sId}, #{sName}, #{personNo}, #{sGender}, #{sBirth}, #{userId}, #{orgName}, #{sPhone}, #{sEmail}, - #{idCardCiphertext}, #{idCardHash}, #{idCardLast4}, #{faceRef}, #{sourceType}, - #{status}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}, #{delFlag}, - #{avatar}, #{dutyLevel} - ) + insert into train_student + + s_id, + s_name, + person_no, + s_gender, + s_birth, + user_id, + dept_name, + card_no, + s_phone, + s_email, + id_card_ciphertext, + id_card_hash, + id_card_last4, + face_ref, + source_type, + status, + create_by, + create_time, + update_by, + update_time, + remark, + del_flag, + avatar, + duty_level, + extract_status, + extract_time, + extract_msg, + photo_md5, + feature_data, + feature_md5, + feature_algo_version, + feature_size, + dept_id, + + + #{sId}, + #{sName}, + #{personNo}, + #{sGender}, + #{sBirth}, + #{userId}, + #{deptName}, + #{cardNo}, + #{sPhone}, + #{sEmail}, + #{idCardCiphertext}, + #{idCardHash}, + #{idCardLast4}, + #{faceRef}, + #{sourceType}, + #{status}, + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, + #{remark}, + #{delFlag}, + #{avatar}, + #{dutyLevel}, + #{extractStatus}, + #{extractTime}, + #{extractMsg}, + #{photoMd5}, + #{featureData}, + #{featureMd5}, + #{featureAlgoVersion}, + #{featureSize}, + #{deptId}, + @@ -99,7 +222,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" person_no = #{personNo}, s_gender = #{sGender}, s_birth = #{sBirth}, - org_name = #{orgName}, + dept_name = #{deptName}, + card_no = #{cardNo}, s_phone = #{sPhone}, s_email = #{sEmail}, id_card_ciphertext = #{idCardCiphertext}, @@ -108,6 +232,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" face_ref = #{faceRef}, avatar = #{avatar}, duty_level = #{dutyLevel}, + extract_status = #{extractStatus}, + extract_time = #{extractTime}, + extract_msg = #{extractMsg}, + photo_md5 = #{photoMd5}, + feature_data = #{featureData}, + feature_md5 = #{featureMd5}, + feature_algo_version = #{featureAlgoVersion}, + feature_size = #{featureSize}, + dept_id = #{deptId}, remark = #{remark}, update_time = #{updateTime}, update_by = #{updateBy} @@ -141,4 +274,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from train_student where s_id = #{sId} + + + + update train_student + set avatar = '', + update_time = CURRENT_TIMESTAMP + where s_id = #{sId} + + + + + update train_student + set avatar = null, + update_time = CURRENT_TIMESTAMP + where s_id = #{sId} and del_flag = '0' + + + + update train_student + set extract_status = #{extractStatus}, extract_time = CURRENT_TIMESTAMP, + feature_data = #{featureData}, feature_md5 = #{featureMd5}, + feature_algo_version = #{featureAlgoVersion}, feature_size = #{featureSize}, + extract_msg = #{extractMsg} + where id = #{id} + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherAccommodationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherAccommodationMapper.xml new file mode 100644 index 000000000..c4db6cbcb --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherAccommodationMapper.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT + a.accommodation_id, + a.enrollment_id, + a.dorm_id, + a.bed_no, + a.assigned_time, + a.accommodation_status, + a.lock_api_biz_no, + a.lock_result, + a.create_by, + a.create_time, + a.update_by, + a.update_time, + a.remark, + t.t_id, + t.t_name, + t.person_no, + t.avatar, + t.t_gender, + d.dorm_building, + d.dorm_room, + d.lock_device_sn, + ld.device_id AS lock_device_id + FROM train_accommodation a + LEFT JOIN train_teacher t ON a.enrollment_id = t.id AND t.del_flag = '0' + LEFT JOIN train_dormitory_info d ON a.dorm_id = d.dorm_id + LEFT JOIN lock_device ld ON d.lock_device_sn = ld.device_sn + + + + + + + + + + + + + + + + + + + + + INSERT INTO train_accommodation ( + enrollment_id, + occupant_type, + dorm_id, + bed_no, + assigned_time, + accommodation_status, + lock_api_biz_no, + create_by, + create_time, + remark + ) VALUES ( + #{enrollmentId}, + 'teacher', + #{dormId}, + #{bedNo}, + #{assignedTime}, + #{accommodationStatus}, + #{lockApiBizNo}, + #{createBy}, + #{createTime}, + #{remark} + ) + + + + UPDATE train_accommodation + SET accommodation_status = #{accommodationStatus}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE accommodation_id = #{accommodationId} + AND occupant_type = 'teacher' + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml index 8122544aa..d3ed75e26 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml @@ -8,14 +8,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + - + + + + @@ -26,10 +30,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + - select t_id, t_name, person_no, t_gender, t_birth, user_id, org_name, job_title, t_phone, t_email, status, del_flag, create_by, create_time, update_by, update_time, remark, avatar, major, courses, start_teaching from train_teacher + select t_id, id, t_name, person_no, card_no, t_gender, t_birth, user_id, dept_name, job_title, t_phone, t_email, status, enrollment_status, join_time, accommodation_required, del_flag, create_by, create_time, update_by, update_time, remark, avatar, major, courses, start_teaching from train_teacher + + where theory_score_id = #{theoryScoreId} and del_flag = '0' + + + + + select t.theory_score_id, t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, t.subject_code, + t.exam_start_time, t.exam_end_time, t.duration_seconds, t.retake, t.retake_no, + t.exam_status, t.score, t.full_score, t.pass_flag, t.is_final, t.score_status, + t.source_type, t.source_system, t.source_event_id, t.score_item_id, t.remark, t.del_flag, + t.final_flag, t.create_by, t.create_time, t.update_by, t.update_time, + stu.s_name as student_name, + cp.plan_name as plan_name, + cr.c_name as course_name, + es.subject_name as subject_name, + si.item_name as score_item_name + from train_theory_exam_score t + left join train_student stu on stu.s_id = t.student_id + left join train_class_plan cp on cp.plan_id = t.plan_id + left join train_course cr on cr.c_id = t.c_id + left join train_exam_subject es on es.subject_code = t.subject_code + left join train_score_item si on si.score_item_id = t.score_item_id + + + + + + + + + + + + + update train_theory_exam_score + set is_final = 0, update_time = sysdate() + where del_flag = '0' and is_final = 1 + and plan_course_id = #{planCourseId} + and student_id = #{studentId} + and theory_score_id != #{excludeId} + + + + insert into train_theory_exam_score + (enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) + values + (#{enrollmentId}, #{planId}, #{planCourseId}, #{cId}, #{studentId}, #{subjectCode}, + #{examStartTime}, #{examEndTime}, #{durationSeconds}, #{retake}, #{retakeNo}, + #{examStatus}, #{score}, #{fullScore}, #{passFlag}, #{isFinal}, #{scoreStatus}, + #{sourceType}, #{sourceSystem}, #{sourceEventId}, #{scoreItemId}, #{remark}, + #{delFlag}, #{createBy}, sysdate()) + + + + update train_theory_exam_score + + enrollment_id = #{enrollmentId}, + plan_id = #{planId}, + plan_course_id = #{planCourseId}, + c_id = #{cId}, + student_id = #{studentId}, + subject_code = #{subjectCode}, + exam_start_time = #{examStartTime}, + exam_end_time = #{examEndTime}, + duration_seconds = #{durationSeconds}, + retake = #{retake}, + retake_no = #{retakeNo}, + exam_status = #{examStatus}, + + + score = null, + score = #{score}, + + full_score = #{fullScore}, + pass_flag = #{passFlag}, + is_final = #{isFinal}, + score_status = #{scoreStatus}, + source_type = #{sourceType}, + source_system = #{sourceSystem}, + source_event_id = #{sourceEventId}, + score_item_id = #{scoreItemId}, + remark = #{remark}, + update_by = #{updateBy}, + update_time = sysdate() + + where theory_score_id = #{theoryScoreId} and del_flag = '0' + + + + update train_theory_exam_score + set del_flag = '2', update_time = sysdate() + where theory_score_id in + + #{theoryScoreId} + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTrainingSiteMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTrainingSiteMapper.xml index 9e130adce..bb319c455 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTrainingSiteMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTrainingSiteMapper.xml @@ -19,10 +19,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + - select site_id, site_name, site_location, capacity, site_device, site_status, del_flag, create_by, create_time, update_by, update_time, remark, major, seat_arrange from train_training_site + select site_id, id, site_name, site_location, capacity, site_device, site_status, del_flag, create_by, create_time, update_by, update_time, remark, major, seat_arrange, bind_board_id from train_training_site + + insert into train_training_site @@ -69,6 +85,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" remark, major, seat_arrange, + bind_board_id, #{siteId}, @@ -85,6 +102,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{remark}, #{major}, #{seatArrange}, + #{bindBoardId}, @@ -102,6 +120,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" remark = #{remark}, major = #{major}, seat_arrange = #{seatArrange}, + bind_board_id = #{bindBoardId}, where site_id = #{siteId} @@ -116,4 +135,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{siteId} + + + update train_training_site + set bind_board_id = #{bindBoardId}, + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + where site_id = #{siteId} + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/domain/vo/StudentExportAndPlanFilterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/domain/vo/StudentExportAndPlanFilterTest.java new file mode 100644 index 000000000..c177f4422 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/domain/vo/StudentExportAndPlanFilterTest.java @@ -0,0 +1,142 @@ +package com.ruoyi.train.domain.vo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.junit.Test; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.poi.ExcelUtil; + +/** + * 学员台账导出:列定义必须来自带 @Excel 的导出 VO,且按班过滤条件需与列表口径一致。 + */ +public class StudentExportAndPlanFilterTest +{ + private static final int EXPORT_COLUMN_COUNT = 12; + + @Test + public void exportWritesHeaderAndDataCells() throws Exception + { + StudentItemVO item = new StudentItemVO(); + item.setsId("S0001"); + item.setsName("张三"); + item.setPersonNo("AA000001"); + item.setsGender("0"); + item.setsBirth(DateUtils.parseDate("1990-01-01")); + item.setDeptName("沈阳供电段"); + item.setsPhone("13800000000"); + item.setsEmail("zhangsan@example.com"); + item.setIdCardMasked("210102********1234"); + item.setCardNo("C0001"); + item.setStatus(1); + item.setSourceType(2); + + List rows = Collections.singletonList(StudentExportVO.from(item)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new ExcelUtil(StudentExportVO.class).exportExcel(responseOf(out), rows, "学员台账"); + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(out.toByteArray()))) + { + Sheet sheet = workbook.getSheetAt(0); + Row header = sheet.getRow(0); + assertNotNull("导出必须存在表头行", header); + assertEquals("导出列数量不符", EXPORT_COLUMN_COUNT, header.getLastCellNum()); + assertEquals("学员ID", header.getCell(0).getStringCellValue()); + assertEquals("学员姓名", header.getCell(1).getStringCellValue()); + assertEquals("身份证号(脱敏)", header.getCell(8).getStringCellValue()); + assertEquals("状态", header.getCell(10).getStringCellValue()); + assertEquals("来源", header.getCell(11).getStringCellValue()); + + Row data = sheet.getRow(1); + assertNotNull("导出必须写入数据行", data); + assertEquals(EXPORT_COLUMN_COUNT, data.getLastCellNum()); + assertEquals("S0001", data.getCell(0).getStringCellValue()); + assertEquals("张三", data.getCell(1).getStringCellValue()); + assertEquals("男", data.getCell(3).getStringCellValue()); + assertEquals("1990-01-01", data.getCell(4).getStringCellValue()); + assertEquals("210102********1234", data.getCell(8).getStringCellValue()); + assertEquals("正常", data.getCell(10).getStringCellValue()); + assertEquals("Excel批量导入", data.getCell(11).getStringCellValue()); + } + } + + /** + * 按班导出依赖 StudentQuery.planId + Mapper 的 exists 关联,缺失会退化为全量导出。 + */ + @Test + public void mapperSupportsPlanIdFilterWithoutBreakingDefaultQuery() throws Exception + { + String xml = readClasspath("/mapper/train/TrainStudentMapper.xml"); + assertTrue("Mapper 必须支持 planId 过滤", xml.contains("planId != null and planId != ''")); + assertTrue("按班过滤必须关联 train_enrollment", xml.contains("from train_enrollment e")); + assertTrue("按班过滤必须排除退训学员", + xml.contains("e.enrollment_status") && xml.contains("<> 3")); + assertTrue("默认查询仍必须保留 del_flag 过滤", xml.contains("del_flag = '0'")); + } + + private String readClasspath(String path) throws Exception + { + try (InputStream is = getClass().getResourceAsStream(path)) + { + assertNotNull("找不到资源: " + path, is); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = is.read(buffer)) != -1) + { + out.write(buffer, 0, len); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + } + + private HttpServletResponse responseOf(ByteArrayOutputStream out) + { + InvocationHandler handler = (proxy, method, args) -> { + if ("getOutputStream".equals(method.getName())) + { + return new ServletOutputStream() + { + @Override + public void write(int b) + { + out.write(b); + } + + @Override + public boolean isReady() + { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) + { + // 测试替身无需异步写出 + } + }; + } + return null; + }; + return (HttpServletResponse) Proxy.newProxyInstance( + HttpServletResponse.class.getClassLoader(), + new Class[] { HttpServletResponse.class }, + handler); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java index 28a093ecc..aed5ca2ea 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java @@ -181,7 +181,7 @@ public class Round5v4KingbaseIntegrationTest { String key=UUID.randomUUID().toString().replace("-","");marker="R5V4"+key.substring(0,16);person="P5"+key.substring(0,6);dormA=marker+"A";dormB=marker+"B"; String plan=marker+"P",student=marker+"S";Timestamp start=new Timestamp(System.currentTimeMillis()-86400000),end=new Timestamp(System.currentTimeMillis()+3*86400000L); jdbc.update("insert into public.train_class_plan(plan_id,plan_name,start_time,end_time,create_by) values(?,?,?,?,?)",plan,"Round5V4 Synthetic",start,end,"round5v4"); - jdbc.update("insert into public.train_student(s_id,s_name,person_no,org_name,create_by) values(?,?,?,?,?)",student,"Round5V4 Synthetic",person,"ROUND5V4_SYNTHETIC","round5v4"); + jdbc.update("insert into public.train_student(s_id,s_name,person_no,dept_name,create_by) values(?,?,?,?,?)",student,"Round5V4 Synthetic",person,"ROUND5V4_SYNTHETIC","round5v4"); enrollment=jdbc.queryForObject("insert into public.train_enrollment(plan_id,student_id,accommodation_required,create_by) values(?,?,1,'round5v4') returning enrollment_id",Long.class,plan,student); Long group=jdbc.queryForObject("insert into iotlocks.lock_group(group_code,group_name,create_by) values(?,?,'round5v4') returning group_id",Long.class,marker,"Round5V4 Synthetic"); Long gateway=jdbc.queryForObject("insert into iotlocks.lock_gateway(gateway_sn,group_id,create_by) values(?,?,'round5v4') returning gateway_id",Long.class,"G5"+key.substring(0,6),group); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainEnrollmentMapperContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainEnrollmentMapperContractTest.java index 04782fd6e..e3b3e34ce 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainEnrollmentMapperContractTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainEnrollmentMapperContractTest.java @@ -38,9 +38,11 @@ public class TrainEnrollmentMapperContractTest "train_dormitory_info" ); - // 2. 验证有效状态与逻辑删除过滤约束 + // 2. 验证"仅当前有效住宿"与逻辑删除过滤约束(方案A) + // 住宿关联按 enrollment 取一条,且必须限定 accommodation_status in (0, 1), + // 已退宿(2)属历史终态不得进入名单,因此断言子查询中的状态过滤。 assertContains(xml, - "a.accommodation_status in (0, 1)", + "a2.accommodation_status in (0, 1)", "d.del_flag = '0'" ); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java index 7793e2c91..ae50d1e84 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java @@ -785,6 +785,23 @@ public class AccommodationServiceTest return latest; } + @Override + public TrainAccommodation selectTrainAccommodationByLockApiBizNo(String lockApiBizNo) + { + if (lockApiBizNo == null || lockApiBizNo.trim().isEmpty()) + { + return null; + } + for (TrainAccommodation a : store.values()) + { + if (lockApiBizNo.trim().equals(a.getLockApiBizNo())) + { + return a; + } + } + return null; + } + @Override public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) { @@ -802,6 +819,12 @@ public class AccommodationServiceTest return null; } + @Override + public TrainAccommodation selectActiveAccommodationByStudentId(String sId) + { + return null; + } + @Override public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) { @@ -918,6 +941,34 @@ public class AccommodationServiceTest return 1; } + @Override + public int updateLockApiBizNo(Long accommodationId, String lockApiBizNo, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setLockApiBizNo(lockApiBizNo); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int updateLockResult(Long accommodationId, Integer lockResult, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setLockResult(lockResult); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + @Override public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java index 5b07e764b..3d35c2738 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java @@ -140,7 +140,7 @@ public class AttendanceActionContractTest vo.setStudentId(e.getStudentId() != null ? e.getStudentId() : "S_" + e.getEnrollmentId()); vo.setStudentName("学员_" + e.getEnrollmentId()); vo.setGender("男"); - vo.setOrgName("电务段"); + vo.setDeptName("电务段"); vo.setPhone("13812345678"); vo.setEnrollmentStatus(e.getEnrollmentStatus() != null ? e.getEnrollmentStatus() : 1); list.add(vo); @@ -253,6 +253,57 @@ public class AttendanceActionContractTest assertNotNull(result.getSignInTime()); } + /** 新增 student_id 列在 DTO -> Service -> 领域对象链路中完整落库 (以参训档案为准) */ + @Test + public void testSignIn_PersistsStudentIdFromEnrollment() + { + TrainEnrollment e = new TrainEnrollment(); + e.setEnrollmentId(203L); + e.setPlanId("PLAN_A"); + e.setEnrollmentStatus(1); + e.setStudentId("STU-203"); + enrollments.put(203L, e); + + AttendanceSignInDTO dto = new AttendanceSignInDTO(); + dto.setEnrollmentId(203L); + dto.setLessonId(1001L); + dto.setCheckinWay(1); + + TrainStudentCheckin result = appService.signIn(dto, "pad_device"); + + assertEquals("STU-203", result.getStudentId()); + assertNotNull(checkinsByEnrollLesson.get("203_1001")); + assertEquals("STU-203", checkinsByEnrollLesson.get("203_1001").getStudentId()); + } + + /** DTO 显式传入的 studentId 与参训档案不一致时必须拒绝,禁止串号落库 */ + @Test + public void testSignIn_RejectsStudentIdMismatch() + { + TrainEnrollment e = new TrainEnrollment(); + e.setEnrollmentId(204L); + e.setPlanId("PLAN_A"); + e.setEnrollmentStatus(1); + e.setStudentId("STU-204"); + enrollments.put(204L, e); + + AttendanceSignInDTO dto = new AttendanceSignInDTO(); + dto.setEnrollmentId(204L); + dto.setLessonId(1001L); + dto.setStudentId("STU-OTHER"); + + try + { + appService.signIn(dto, "pad_device"); + fail("studentId 与参训档案不一致时必须拒绝"); + } + catch (ServiceException ex) + { + assertEquals(AttendanceErrorCode.ATTENDANCE_STUDENT_MISMATCH, (int) ex.getCode()); + } + assertNull(checkinsByEnrollLesson.get("204_1001")); + } + @Test public void testSignIn_Duplicate_IdempotentRetainsOriginal() { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java index b7b6f8e42..aa29d8f4b 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java @@ -151,7 +151,7 @@ public class AttendanceMarkAbsenceContractTest vo.setStudentId("S_" + e.getEnrollmentId()); vo.setStudentName("学员_" + e.getEnrollmentId()); vo.setGender("男"); - vo.setOrgName("电务段"); + vo.setDeptName("电务段"); vo.setPhone("13812345678"); vo.setEnrollmentStatus(e.getEnrollmentStatus()); list.add(vo); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/BedOccupancyModelCTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/BedOccupancyModelCTest.java index 2570bb231..dbad2942c 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/BedOccupancyModelCTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/BedOccupancyModelCTest.java @@ -609,6 +609,23 @@ public class BedOccupancyModelCTest return null; } + @Override + public TrainAccommodation selectTrainAccommodationByLockApiBizNo(String lockApiBizNo) + { + if (lockApiBizNo == null || lockApiBizNo.trim().isEmpty()) + { + return null; + } + for (TrainAccommodation a : store.values()) + { + if (lockApiBizNo.trim().equals(a.getLockApiBizNo())) + { + return a; + } + } + return null; + } + @Override public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) { @@ -624,6 +641,12 @@ public class BedOccupancyModelCTest return null; } + @Override + public TrainAccommodation selectActiveAccommodationByStudentId(String sId) + { + return null; + } + @Override public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) { @@ -733,6 +756,34 @@ public class BedOccupancyModelCTest return 1; } + @Override + public synchronized int updateLockApiBizNo(Long accommodationId, String lockApiBizNo, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setLockApiBizNo(lockApiBizNo); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public synchronized int updateLockResult(Long accommodationId, Integer lockResult, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setLockResult(lockResult); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + @Override public synchronized int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java index 192866dd7..3ad726b4a 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java @@ -517,6 +517,23 @@ public class DormAuthAlignmentTest return null; } + @Override + public TrainAccommodation selectTrainAccommodationByLockApiBizNo(String lockApiBizNo) + { + if (lockApiBizNo == null || lockApiBizNo.trim().isEmpty()) + { + return null; + } + for (TrainAccommodation a : store.values()) + { + if (lockApiBizNo.trim().equals(a.getLockApiBizNo())) + { + return a; + } + } + return null; + } + @Override public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) { @@ -532,6 +549,12 @@ public class DormAuthAlignmentTest return null; } + @Override + public TrainAccommodation selectActiveAccommodationByStudentId(String sId) + { + return null; + } + @Override public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) { @@ -621,6 +644,34 @@ public class DormAuthAlignmentTest return 1; } + @Override + public int updateLockApiBizNo(Long accommodationId, String lockApiBizNo, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setLockApiBizNo(lockApiBizNo); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int updateLockResult(Long accommodationId, Integer lockResult, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setLockResult(lockResult); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + @Override public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java index 7a59e984c..9048c4de6 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java @@ -12,6 +12,8 @@ import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; + +import com.ruoyi.train.domain.vo.DormitoryLayoutRoomVO; import org.junit.Before; import org.junit.Test; import com.ruoyi.common.exception.ServiceException; @@ -224,7 +226,7 @@ public class DormitoryServiceTest occupant.setStudentId("stu-01"); occupant.setStudentName("张三"); occupant.setGender("0"); // 男 - occupant.setOrgName("上海机务段"); + occupant.setDeptName("上海机务段"); occupant.setBedNo("1号床"); occupant.setAssignedTime(new Date()); occupant.setAccommodationStatus(1); @@ -370,6 +372,19 @@ public class DormitoryServiceTest return null; } + @Override + public TrainDormitoryInfo selectByBuildingRoom(String dormBuilding, String dormRoom) + { + for (TrainDormitoryInfo d : store.values()) + { + if ("0".equals(d.getDelFlag()) && dormBuilding.equals(d.getDormBuilding()) && dormRoom.equals(d.getDormRoom())) + { + return d; + } + } + return null; + } + @Override public List selectOccupantsByDormId(String dormId) { @@ -390,6 +405,11 @@ public class DormitoryServiceTest return list; } + @Override + public List selectDormitoryLayoutRoomList() { + return List.of(); + } + @Override public int insertTrainDormitoryInfo(TrainDormitoryInfo trainDormitoryInfo) { @@ -428,5 +448,16 @@ public class DormitoryServiceTest } return dormIds.length; } + + @Override + public int updateLockDeviceSnByDormId(String dormId, String lockDeviceSn, String updateBy) + { + TrainDormitoryInfo d = store.get(dormId); + if (d != null) + { + d.setLockDeviceSn(lockDeviceSn); + } + return 1; + } } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java deleted file mode 100644 index b8d304c90..000000000 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java +++ /dev/null @@ -1,1251 +0,0 @@ -package com.ruoyi.train.service.impl; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.CyclicBarrier; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.locks.ReentrantLock; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.springframework.dao.DuplicateKeyException; -import com.ruoyi.common.exception.ServiceException; -import com.ruoyi.train.constant.EnrollmentErrorCode; -import com.ruoyi.train.constant.PlanErrorCode; -import com.ruoyi.train.constant.StudentErrorCode; -import com.ruoyi.train.domain.TrainClassPlan; -import com.ruoyi.train.domain.TrainEnrollment; -import com.ruoyi.train.domain.TrainStudent; -import com.ruoyi.train.domain.dto.EnrollmentAddDTO; -import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO; -import com.ruoyi.train.domain.query.EnrollmentQuery; -import com.ruoyi.train.domain.query.StudentQuery; -import com.ruoyi.train.domain.vo.BatchImportResultVO; -import com.ruoyi.train.domain.vo.EnrollmentDetailVO; -import com.ruoyi.train.domain.vo.EnrollmentItemVO; -import com.ruoyi.train.mapper.TrainClassPlanMapper; -import com.ruoyi.train.mapper.TrainEnrollmentMapper; -import com.ruoyi.train.mapper.TrainStudentMapper; -import com.ruoyi.train.service.ITrainEnrollmentService; - -/** - * ISSUE-03-01 参训人员管理核心单元测试。 - * 全面覆盖: - * 1. 正常加入 - * 2. 重复加入 - * 3. 并发 UNIQUE 冲突映射 - * 4. 超出招生人数上限 - * 5. 不存在 Plan - * 6. 计划状态已锁定 - * 7. 不存在 Student - * 8. 列表/查询(脱敏掩码) - * 9. 详情聚合 - * 10. 状态变化 (正常请假/放弃) - * 11. 非法状态变化阻断 - * 12. 允许删除 (开班前无业务数据) - * 13. 禁止删除 (已有考勤/成绩/报到流水时强阻断 40032) - * 14. Plan complete -> active enrollments completed - * 15. complete_time 写入 - * 16. 历史数据不删除 - * 17. 批量导入部分成功与失败行收集 - * 18. recruitNum=1 并发招生容量测试 (只能1条成功,另1条抛40031) - */ -public class EnrollmentAppServiceImplTest -{ - private InMemoryEnrollmentService enrollmentRepository; - private MockPlanMapper planMapper; - private MockStudentMapper studentMapper; - private MockTrainEnrollmentMapper mockEnrollmentMapper; - private EnrollmentAppServiceImpl appService; - - @Before - public void setUp() throws Exception - { - enrollmentRepository = new InMemoryEnrollmentService(); - planMapper = new MockPlanMapper(); - studentMapper = new MockStudentMapper(); - mockEnrollmentMapper = new MockTrainEnrollmentMapper(planMapper.planStore, enrollmentRepository); - appService = new EnrollmentAppServiceImpl(); - - // 反射注入私有属性 - setField(appService, "trainEnrollmentService", enrollmentRepository); - setField(appService, "trainEnrollmentMapper", mockEnrollmentMapper); - setField(appService, "planMapper", planMapper); - setField(appService, "studentMapper", studentMapper); - - // 初始化默认测试数据 - TrainClassPlan plan = new TrainClassPlan(); - plan.setPlanId("PLAN-001"); - plan.setPlanName("2026年第1期高速铁路信号检修专项培训班"); - plan.setRecruitNum(2L); // 招生上限设为 2 人 - plan.setPlanStatus(0); // 0:未开始 - plan.setDelFlag("0"); - planMapper.planStore.put("PLAN-001", plan); - - TrainStudent s1 = new TrainStudent(); - s1.setsId("S001"); - s1.setsName("张三"); - s1.setsGender("1"); - s1.setOrgName("沈阳电务段"); - s1.setsPhone("13800000001"); - s1.setIdCardLast4("1234"); - s1.setStatus(1); - s1.setDelFlag("0"); - studentMapper.studentStore.put("S001", s1); - - TrainStudent s2 = new TrainStudent(); - s2.setsId("S002"); - s2.setsName("李四"); - s2.setsGender("2"); - s2.setOrgName("大连电务段"); - s2.setsPhone("13800000002"); - s2.setIdCardLast4("5678"); - s2.setStatus(1); - s2.setDelFlag("0"); - studentMapper.studentStore.put("S002", s2); - - TrainStudent s3 = new TrainStudent(); - s3.setsId("S003"); - s3.setsName("王五"); - s3.setsGender("1"); - s3.setOrgName("锦州电务段"); - s3.setsPhone("13800000003"); - s3.setIdCardLast4("9012"); - s3.setStatus(1); - s3.setDelFlag("0"); - studentMapper.studentStore.put("S003", s3); - } - - @After - public void tearDown() - { - if (mockEnrollmentMapper != null) - { - mockEnrollmentMapper.clearLocks(); - } - } - - @Test - public void testAddEnrollmentSuccess() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); // 1:管理员录入 - dto.setAccommodationRequired(1); // 1:住宿 - dto.setRemark("正式入班"); - - Long enrollmentId = appService.addEnrollment("PLAN-001", dto, "admin"); - - assertNotNull("应成功生成 enrollmentId", enrollmentId); - TrainEnrollment saved = enrollmentRepository.selectTrainEnrollmentById(enrollmentId); - assertNotNull(saved); - assertEquals("PLAN-001", saved.getPlanId()); - assertEquals("S001", saved.getStudentId()); - assertEquals(Integer.valueOf(1), saved.getSourceType()); - assertEquals(Integer.valueOf(1), saved.getAccommodationRequired()); - assertEquals(Integer.valueOf(0), saved.getEnrollmentStatus()); // 初始状态必须为 0:待报到 - assertEquals("admin", saved.getCreateBy()); - assertNotNull(saved.getCreateTime()); - } - - @Test - public void testAddEnrollmentDuplicated() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(1); - - appService.addEnrollment("PLAN-001", dto, "admin"); - - // 再次添加相同学员,业务层前置查重拦截 40030 - assertError(EnrollmentErrorCode.ENROLLMENT_DUPLICATED, - () -> appService.addEnrollment("PLAN-001", dto, "admin")); - } - - @Test - public void testAddEnrollmentConcurrentUniqueConflict() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - - // 模拟底层发生并发穿透插入触发数据库 UNIQUE 约束 - enrollmentRepository.simulateDuplicateKey = true; - - assertError(EnrollmentErrorCode.ENROLLMENT_DUPLICATED, - () -> appService.addEnrollment("PLAN-001", dto, "admin")); - } - - @Test - public void testAddEnrollmentRecruitLimitExceeded() - { - EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); - dto1.setStudentId("S001"); - dto1.setSourceType(1); - dto1.setAccommodationRequired(1); - appService.addEnrollment("PLAN-001", dto1, "admin"); - - EnrollmentAddDTO dto2 = new EnrollmentAddDTO(); - dto2.setStudentId("S002"); - dto2.setSourceType(1); - dto2.setAccommodationRequired(0); - appService.addEnrollment("PLAN-001", dto2, "admin"); - - // PLAN-001 上限是 2 人,已有 2 人,加入第 3 人必须抛 40031 - EnrollmentAddDTO dto3 = new EnrollmentAddDTO(); - dto3.setStudentId("S003"); - dto3.setSourceType(1); - dto3.setAccommodationRequired(1); - - assertError(EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED, - () -> appService.addEnrollment("PLAN-001", dto3, "admin")); - } - - @Test - public void testAddEnrollmentPlanNotFound() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - - assertError(PlanErrorCode.PLAN_NOT_FOUND, - () -> appService.addEnrollment("PLAN-NON-EXIST", dto, "admin")); - } - - @Test - public void testAddEnrollmentPlanStatusLocked() - { - // 将计划设置为已结束(2) - planMapper.planStore.get("PLAN-001").setPlanStatus(2); - - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - - assertError(PlanErrorCode.PLAN_STATUS_LOCKED, - () -> appService.addEnrollment("PLAN-001", dto, "admin")); - } - - @Test - public void testAddEnrollmentStudentNotFound() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S999_NOT_EXIST"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - - assertError(StudentErrorCode.STUDENT_NOT_FOUND, - () -> appService.addEnrollment("PLAN-001", dto, "admin")); - } - - @Test - public void testAddNormalStudentEnrollmentSuccess() - { - // 1. 创建一个按正式流程得到的正常学员 (status=1) - TrainStudent normalStudent = new TrainStudent(); - normalStudent.setsId("S_NORMAL_01"); - normalStudent.setsName("赵六"); - normalStudent.setStatus(1); // 正常 - normalStudent.setDelFlag("0"); - studentMapper.studentStore.put("S_NORMAL_01", normalStudent); - - // 2. 添加到 Enrollment - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S_NORMAL_01"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - Long id = appService.addEnrollment("PLAN-001", dto, "admin"); - - // 3. 必须成功 - assertNotNull(id); - TrainEnrollment enrollment = enrollmentRepository.selectTrainEnrollmentById(id); - assertNotNull(enrollment); - assertEquals(Integer.valueOf(0), enrollment.getEnrollmentStatus()); // 待报到 - } - - @Test - public void testAddDisabledStudentEnrollmentRejected() - { - // 1. 创建一个停用学员 (status=0) - TrainStudent disabledStudent = new TrainStudent(); - disabledStudent.setsId("S_DISABLED_01"); - disabledStudent.setsName("钱七"); - disabledStudent.setStatus(0); // 停用 - disabledStudent.setDelFlag("0"); - studentMapper.studentStore.put("S_DISABLED_01", disabledStudent); - - // 2. 非正常/停用 Student 应按真实业务规则被拒绝 - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S_DISABLED_01"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - - assertError(StudentErrorCode.STUDENT_NOT_FOUND, - () -> appService.addEnrollment("PLAN-001", dto, "admin")); - } - - @Test - public void testAddNullStatusStudentEnrollmentRejected() - { - // 1. 创建一个 status 为 null 的学员 - TrainStudent nullStatusStudent = new TrainStudent(); - nullStatusStudent.setsId("S_NULL_STATUS_01"); - nullStatusStudent.setsName("孙八"); - nullStatusStudent.setStatus(null); // null 状态 - nullStatusStudent.setDelFlag("0"); - studentMapper.studentStore.put("S_NULL_STATUS_01", nullStatusStudent); - - // 2. status 为 null 的 Student 应按严格规则被拒绝 - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S_NULL_STATUS_01"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - - assertError(StudentErrorCode.STUDENT_NOT_FOUND, - () -> appService.addEnrollment("PLAN-001", dto, "admin")); - } - - @Test - public void testListEnrollmentsAndDetailWithMasking() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(1); - Long id = appService.addEnrollment("PLAN-001", dto, "admin"); - - List list = appService.listEnrollments("PLAN-001", new EnrollmentQuery()); - assertEquals(1, list.size()); - assertEquals("张三", list.get(0).getStudentName()); - assertEquals("**************1234", list.get(0).getIdCardMasked()); - - EnrollmentDetailVO detail = appService.getEnrollmentDetail(id); - assertEquals(id, detail.getEnrollmentId()); - assertEquals("PLAN-001", detail.getPlanId()); - assertEquals("2026年第1期高速铁路信号检修专项培训班", detail.getPlanName()); - assertEquals("S001", detail.getStudentId()); - assertEquals("张三", detail.getStudentName()); - assertEquals("**************1234", detail.getIdCardMasked()); - assertEquals(Integer.valueOf(0), detail.getEnrollmentStatus()); - } - - @Test - public void testUpdateEnrollmentStatusSuccess() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(1); - Long id = appService.addEnrollment("PLAN-001", dto, "admin"); - - EnrollmentStatusUpdateDTO update = new EnrollmentStatusUpdateDTO(); - update.setEnrollmentStatus(2); // 2:请假 - update.setRemark("因公请假3天"); - - appService.updateEnrollmentStatus(id, update, "admin"); - - TrainEnrollment saved = enrollmentRepository.selectTrainEnrollmentById(id); - assertEquals(Integer.valueOf(2), saved.getEnrollmentStatus()); - assertEquals("因公请假3天", saved.getRemark()); - } - - @Test - public void testUpdateEnrollmentStatusInvalid() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(1); - Long id = appService.addEnrollment("PLAN-001", dto, "admin"); - - EnrollmentStatusUpdateDTO invalid = new EnrollmentStatusUpdateDTO(); - invalid.setEnrollmentStatus(99); // 非法状态 - - try - { - appService.updateEnrollmentStatus(id, invalid, "admin"); - fail("传入非法状态应当抛出异常"); - } - catch (ServiceException e) - { - assertEquals(EnrollmentErrorCode.ENROLLMENT_STATUS_INVALID_MESSAGE, e.getMessage()); - } - } - - @Test - public void testRemoveEnrollmentAllowedBeforeClassWithoutData() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - Long id = appService.addEnrollment("PLAN-001", dto, "admin"); - - // 开班前无任何考勤、成绩等业务数据,允许删除 - appService.removeEnrollment(id, "admin"); - - assertNull("删除后记录应当不存在", enrollmentRepository.selectTrainEnrollmentById(id)); - } - - @Test - public void testRemoveEnrollmentForbiddenWhenHasBusinessData() - { - EnrollmentAddDTO dto = new EnrollmentAddDTO(); - dto.setStudentId("S001"); - dto.setSourceType(1); - dto.setAccommodationRequired(0); - Long id = appService.addEnrollment("PLAN-001", dto, "admin"); - - // 模拟已有考勤数据 - enrollmentRepository.mockAttendanceCount.put(id, 1); - assertError(EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA, - () -> appService.removeEnrollment(id, "admin")); - - // 恢复考勤,模拟已有成绩数据 - enrollmentRepository.mockAttendanceCount.put(id, 0); - enrollmentRepository.mockScoreCount.put(id, 1); - assertError(EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA, - () -> appService.removeEnrollment(id, "admin")); - - // 恢复成绩,模拟已有现场报到数据 - enrollmentRepository.mockScoreCount.put(id, 0); - enrollmentRepository.mockReportCount.put(id, 1); - assertError(EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA, - () -> appService.removeEnrollment(id, "admin")); - } - - @Test - public void testPlanCompleteUpdatesActiveEnrollmentsAndKeepsHistory() - { - // 准备 3 名学员:S001(已报到1且有报到单), S002(已报到1且有报到单), S003(放弃3) - // 临时调大容量 - planMapper.planStore.get("PLAN-001").setRecruitNum(10L); - - EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); - dto1.setStudentId("S001"); - dto1.setSourceType(1); - dto1.setAccommodationRequired(1); - Long id1 = appService.addEnrollment("PLAN-001", dto1, "admin"); - enrollmentRepository.selectTrainEnrollmentById(id1).setEnrollmentStatus(1); // 已报到 - enrollmentRepository.mockReportCount.put(id1, 1); // 报到单事实存在 - - EnrollmentAddDTO dto2 = new EnrollmentAddDTO(); - dto2.setStudentId("S002"); - dto2.setSourceType(1); - dto2.setAccommodationRequired(1); - Long id2 = appService.addEnrollment("PLAN-001", dto2, "admin"); - enrollmentRepository.selectTrainEnrollmentById(id2).setEnrollmentStatus(1); // 已报到 - enrollmentRepository.mockReportCount.put(id2, 1); // 报到单事实存在 - - EnrollmentAddDTO dto3 = new EnrollmentAddDTO(); - dto3.setStudentId("S003"); - dto3.setSourceType(1); - dto3.setAccommodationRequired(0); - Long id3 = appService.addEnrollment("PLAN-001", dto3, "admin"); - enrollmentRepository.selectTrainEnrollmentById(id3).setEnrollmentStatus(3); // 已放弃 - - // 为 S002 记录考勤和成绩历史数据 - enrollmentRepository.mockAttendanceCount.put(id2, 5); - enrollmentRepository.mockScoreCount.put(id2, 3); - - // 执行 Adapter 归档 - EnrollmentPlanCompletionAdapter adapter = new EnrollmentPlanCompletionAdapter(); - setField(adapter, "trainEnrollmentService", enrollmentRepository); - - Date completeTime = new Date(); - adapter.completeEnrollments("PLAN-001", "operator", completeTime); - - // 断言:S001、S002 处于已报到状态且有报到事实,被批量更新为 4:已完成,且写入 complete_time - TrainEnrollment e1 = enrollmentRepository.selectTrainEnrollmentById(id1); - assertEquals(Integer.valueOf(4), e1.getEnrollmentStatus()); - assertEquals(completeTime, e1.getCompleteTime()); - - TrainEnrollment e2 = enrollmentRepository.selectTrainEnrollmentById(id2); - assertEquals(Integer.valueOf(4), e2.getEnrollmentStatus()); - assertEquals(completeTime, e2.getCompleteTime()); - - // 断言:S003 已放弃,保持为 3:放弃,不被误刷为已完成 - TrainEnrollment e3 = enrollmentRepository.selectTrainEnrollmentById(id3); - assertEquals(Integer.valueOf(3), e3.getEnrollmentStatus()); - assertNull("已放弃学员不写入 complete_time", e3.getCompleteTime()); - - // 断言:历史考勤、成绩记录完好保留,严禁删除 - assertEquals(5, enrollmentRepository.countAttendanceByEnrollmentId(id2)); - assertEquals(3, enrollmentRepository.countScoreByEnrollmentId(id2)); - } - - @Test - public void testPlanCompleteBlockedWhenWaitingEnrollmentsExist() - { - planMapper.planStore.get("PLAN-001").setRecruitNum(10L); - - EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); - dto1.setStudentId("S001"); - dto1.setSourceType(1); - dto1.setAccommodationRequired(0); - Long id1 = appService.addEnrollment("PLAN-001", dto1, "admin"); - // S001 保持待报到状态 (0) - - EnrollmentPlanCompletionAdapter adapter = new EnrollmentPlanCompletionAdapter(); - setField(adapter, "trainEnrollmentService", enrollmentRepository); - - try - { - adapter.completeEnrollments("PLAN-001", "operator", new Date()); - fail("存在待报到学员时结训必须被拦截"); - } - catch (ServiceException e) - { - assertTrue(e.getMessage().contains("WAITING_REPORT_ENROLLMENTS_EXIST")); - } - } - - @Test - public void testBatchImportProcess() - { - TrainClassPlan plan = planMapper.planStore.get("PLAN-001"); - plan.setRecruitNum(2L); // 限制 2 人 - - List batchList = new ArrayList<>(); - - // 第 1 条: 正常学员 S001 - EnrollmentAddDTO d1 = new EnrollmentAddDTO(); - d1.setStudentId("S001"); - d1.setSourceType(2); - d1.setAccommodationRequired(1); - batchList.add(d1); - - // 第 2 条: 学员不存在且 autoCreateStudent=false - EnrollmentAddDTO d2 = new EnrollmentAddDTO(); - d2.setStudentId("S_UNKNOWN"); - d2.setSourceType(2); - d2.setAccommodationRequired(0); - batchList.add(d2); - - // 第 3 条: 重复学员 S001 - EnrollmentAddDTO d3 = new EnrollmentAddDTO(); - d3.setStudentId("S001"); - d3.setSourceType(2); - d3.setAccommodationRequired(1); - batchList.add(d3); - - // 第 4 条: 正常学员 S002 - EnrollmentAddDTO d4 = new EnrollmentAddDTO(); - d4.setStudentId("S002"); - d4.setSourceType(2); - d4.setAccommodationRequired(0); - batchList.add(d4); - - // 第 5 条: 超出人数上限的学员 S003 - EnrollmentAddDTO d5 = new EnrollmentAddDTO(); - d5.setStudentId("S003"); - d5.setSourceType(2); - d5.setAccommodationRequired(1); - batchList.add(d5); - - BatchImportResultVO result = appService.processBatchImport(plan, batchList, false, "admin"); - - assertEquals(Integer.valueOf(5), result.getTotalCount()); - assertEquals(Integer.valueOf(2), result.getSuccessCount()); // S001, S002 成功 - assertEquals(Integer.valueOf(3), result.getFailureCount()); // S_UNKNOWN, 重复S001, 超限S003 失败 - assertEquals(3, result.getErrorList().size()); - assertTrue(result.getErrorList().get(0).contains("学员档案不存在")); - assertTrue(result.getErrorList().get(1).contains("学员已在参训名单中")); - assertTrue(result.getErrorList().get(2).contains("计划已达到招生上限")); - } - - /** - * 并发测试 (ISSUE-03-01 招生容量并发边界): - * recruitNum=1,两个不同 student (S001, S002) 并发加入同一 Plan, - * 最终只能 1 条成功,另一条必须抛出 40031 (RECRUIT_LIMIT_EXCEEDED)。 - */ - @Test - public void testConcurrentEnrollmentRecruitLimit() throws Exception - { - String planId = "PLAN-CONCUR-LIMIT"; - TrainClassPlan plan = new TrainClassPlan(); - plan.setPlanId(planId); - plan.setPlanName("2026年并发容量限制测试班"); - plan.setRecruitNum(1L); // 招生上限为 1 人 - plan.setPlanStatus(0); // 0:未开始 - plan.setDelFlag("0"); - planMapper.planStore.put(planId, plan); - - EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); - dto1.setStudentId("S001"); - dto1.setSourceType(1); - dto1.setAccommodationRequired(0); - - EnrollmentAddDTO dto2 = new EnrollmentAddDTO(); - dto2.setStudentId("S002"); - dto2.setSourceType(1); - dto2.setAccommodationRequired(0); - - ExecutorService executor = Executors.newFixedThreadPool(2); - CyclicBarrier barrier = new CyclicBarrier(2); - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger limitExceededCount = new AtomicInteger(0); - AtomicReference unexpectedError = new AtomicReference<>(); - CountDownLatch finishLatch = new CountDownLatch(2); - - Runnable task1 = () -> { - try - { - barrier.await(); - try - { - appService.addEnrollment(planId, dto1, "admin-1"); - successCount.incrementAndGet(); - } - finally - { - mockEnrollmentMapper.releasePlanLock(planId); - } - } - catch (ServiceException se) - { - if (Integer.valueOf(EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED).equals(se.getCode())) - { - limitExceededCount.incrementAndGet(); - } - else - { - unexpectedError.set(se); - } - } - catch (Throwable t) - { - unexpectedError.set(t); - } - finally - { - finishLatch.countDown(); - } - }; - - Runnable task2 = () -> { - try - { - barrier.await(); - try - { - appService.addEnrollment(planId, dto2, "admin-2"); - successCount.incrementAndGet(); - } - finally - { - mockEnrollmentMapper.releasePlanLock(planId); - } - } - catch (ServiceException se) - { - if (Integer.valueOf(EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED).equals(se.getCode())) - { - limitExceededCount.incrementAndGet(); - } - else - { - unexpectedError.set(se); - } - } - catch (Throwable t) - { - unexpectedError.set(t); - } - finally - { - finishLatch.countDown(); - } - }; - - executor.submit(task1); - executor.submit(task2); - - boolean finished = finishLatch.await(5, TimeUnit.SECONDS); - executor.shutdown(); - - assertTrue("并发测试应在5秒内执行完毕", finished); - if (unexpectedError.get() != null) - { - fail("并发测试遇到非预期异常: " + unexpectedError.get().getMessage()); - } - - assertEquals("最终只能有且仅有1条记录成功加入", 1, successCount.get()); - assertEquals("另一条并发请求必须被40031拦截", 1, limitExceededCount.get()); - assertEquals("最终该计划参训人数必须为1", 1, enrollmentRepository.countEnrollmentsByPlanId(planId)); - } - - private void assertError(int code, ThrowingAction action) - { - try - { - action.run(); - fail("Expected ServiceException code " + code); - } - catch (ServiceException e) - { - assertEquals(Integer.valueOf(code), e.getCode()); - assertTrue(e.getMessage() != null && !e.getMessage().isEmpty()); - } - } - - private interface ThrowingAction - { - void run(); - } - - private static void setField(Object target, String fieldName, Object value) - { - try - { - Field field = target.getClass().getDeclaredField(fieldName); - field.setAccessible(true); - field.set(target, value); - } - catch (Exception e) - { - throw new RuntimeException(e); - } - } - - /** 内存存储模拟单表服务 */ - private static class InMemoryEnrollmentService implements ITrainEnrollmentService - { - private final AtomicLong idGenerator = new AtomicLong(1000); - private final Map store = new ConcurrentHashMap<>(); - private final Map mockAttendanceCount = new HashMap<>(); - private final Map mockScoreCount = new HashMap<>(); - private final Map mockReportCount = new HashMap<>(); - private final Map mockAccommodationCount = new HashMap<>(); - private final Map mockStudyCount = new HashMap<>(); - private boolean simulateDuplicateKey = false; - - @Override - public TrainEnrollment selectTrainEnrollmentById(Long enrollmentId) - { - return store.get(enrollmentId); - } - - @Override - public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId) - { - return store.get(enrollmentId); - } - - @Override - public List selectEnrollmentsForUpdateByPlanId(String planId) - { - List list = new ArrayList<>(); - for (TrainEnrollment e : store.values()) - { - if (e.getPlanId().equals(planId)) - { - list.add(e); - } - } - list.sort(java.util.Comparator.comparing(TrainEnrollment::getEnrollmentId)); - return list; - } - - @Override - public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId) - { - for (TrainEnrollment e : store.values()) - { - if (e.getPlanId().equals(planId) && e.getStudentId().equals(studentId)) - { - return e; - } - } - return null; - } - - @Override - public List selectEnrollmentItemList(EnrollmentQuery query) - { - List result = new ArrayList<>(); - for (TrainEnrollment e : store.values()) - { - if (query.getPlanId() != null && !e.getPlanId().equals(query.getPlanId())) - { - continue; - } - if (query.getEnrollmentStatus() != null && !e.getEnrollmentStatus().equals(query.getEnrollmentStatus())) - { - continue; - } - EnrollmentItemVO vo = new EnrollmentItemVO(); - vo.setEnrollmentId(e.getEnrollmentId()); - vo.setPlanId(e.getPlanId()); - vo.setStudentId(e.getStudentId()); - vo.setSourceType(e.getSourceType()); - vo.setEnrollmentStatus(e.getEnrollmentStatus()); - vo.setAccommodationRequired(e.getAccommodationRequired()); - vo.setStudentName("张三"); - vo.setIdCardMasked("1234"); - result.add(vo); - } - return result; - } - - @Override - public int countEnrollmentsByPlanId(String planId) - { - int count = 0; - for (TrainEnrollment e : store.values()) - { - if (e.getPlanId().equals(planId)) - { - count++; - } - } - return count; - } - - @Override - public int insertTrainEnrollment(TrainEnrollment trainEnrollment) - { - if (simulateDuplicateKey) - { - throw new DuplicateKeyException("Duplicate entry 'PLAN-001-S001' for key 'uq_enrollment'"); - } - long id = idGenerator.incrementAndGet(); - trainEnrollment.setEnrollmentId(id); - store.put(id, trainEnrollment); - return 1; - } - - @Override - public int batchInsertTrainEnrollment(List list) - { - for (TrainEnrollment e : list) - { - insertTrainEnrollment(e); - } - return list.size(); - } - - @Override - public int updateTrainEnrollment(TrainEnrollment trainEnrollment) - { - store.put(trainEnrollment.getEnrollmentId(), trainEnrollment); - return 1; - } - - @Override - public int updateEnrollmentStatus(Long enrollmentId, Integer enrollmentStatus, String remark, String operator, Date updateTime) - { - TrainEnrollment e = store.get(enrollmentId); - if (e != null) - { - e.setEnrollmentStatus(enrollmentStatus); - if (remark != null) - { - e.setRemark(remark); - } - e.setUpdateBy(operator); - e.setUpdateTime(updateTime); - return 1; - } - return 0; - } - - @Override - public int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime) - { - int rows = 0; - for (TrainEnrollment e : store.values()) - { - if (e.getPlanId().equals(planId) && (e.getEnrollmentStatus() == 0 || e.getEnrollmentStatus() == 1)) - { - e.setEnrollmentStatus(4); - e.setCompleteTime(completeTime); - e.setUpdateBy(operator); - e.setUpdateTime(completeTime); - rows++; - } - } - return rows; - } - - @Override - public int deleteTrainEnrollmentById(Long enrollmentId) - { - return store.remove(enrollmentId) != null ? 1 : 0; - } - - @Override - public int countAttendanceByEnrollmentId(Long enrollmentId) - { - return mockAttendanceCount.getOrDefault(enrollmentId, 0); - } - - @Override - public int countScoreByEnrollmentId(Long enrollmentId) - { - return mockScoreCount.getOrDefault(enrollmentId, 0); - } - - @Override - public int countReportByEnrollmentId(Long enrollmentId) - { - return mockReportCount.getOrDefault(enrollmentId, 0); - } - - @Override - public int countAccommodationByEnrollmentId(Long enrollmentId) - { - return mockAccommodationCount.getOrDefault(enrollmentId, 0); - } - - @Override - public int countStudyDurationByEnrollmentId(Long enrollmentId) - { - return mockStudyCount.getOrDefault(enrollmentId, 0); - } - - @Override - public java.util.List> selectEnrollmentCompletionStatsByPlanId(String planId) - { - java.util.List> list = new java.util.ArrayList<>(); - for (TrainEnrollment e : store.values()) - { - if (e.getPlanId().equals(planId)) - { - java.util.Map map = new java.util.HashMap<>(); - map.put("enrollmentId", e.getEnrollmentId()); - map.put("enrollmentStatus", e.getEnrollmentStatus()); - map.put("hasReport", mockReportCount.getOrDefault(e.getEnrollmentId(), 0) > 0 ? 1 : 0); - list.add(map); - } - } - return list; - } - } - - private static class MockPlanMapper implements TrainClassPlanMapper - { - private final Map planStore = new HashMap<>(); - - @Override - public TrainClassPlan selectTrainClassPlanByPlanId(String planId) - { - return planStore.get(planId); - } - - @Override - public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId) - { - return planStore.get(planId); - } - - @Override - public List selectTrainClassPlanList(com.ruoyi.train.domain.query.PlanQuery query) - { - return Collections.emptyList(); - } - - @Override - public int countTrainClassPlanByName(String planName, String excludePlanId) - { - return 0; - } - - @Override - public int insertTrainClassPlan(TrainClassPlan plan) - { - planStore.put(plan.getPlanId(), plan); - return 1; - } - - @Override - public int updateTrainClassPlan(TrainClassPlan plan) - { - planStore.put(plan.getPlanId(), plan); - return 1; - } - - @Override - public int updateTrainClassPlanStatus(TrainClassPlan plan) - { - TrainClassPlan exist = planStore.get(plan.getPlanId()); - if (exist != null) - { - exist.setPlanStatus(plan.getPlanStatus()); - } - return 1; - } - } - - private static class MockStudentMapper implements TrainStudentMapper - { - private final Map studentStore = new HashMap<>(); - - @Override - public TrainStudent selectTrainStudentBySId(String sId) - { - return studentStore.get(sId); - } - - @Override - public List selectTrainStudentsBySIds(java.util.Collection sIds) - { - List list = new ArrayList<>(); - if (sIds != null) - { - for (String sId : sIds) - { - TrainStudent s = studentStore.get(sId); - if (s != null) - { - list.add(s); - } - } - } - return list; - } - - @Override - public List selectTrainStudentList(StudentQuery query) - { - return new ArrayList<>(studentStore.values()); - } - - @Override - public Integer selectOneByIdCardHash(String idCardHash) - { - return null; - } - - @Override - public int insertTrainStudent(TrainStudent student) - { - studentStore.put(student.getsId(), student); - return 1; - } - - @Override - public int updateTrainStudent(TrainStudent trainStudent) { - return 0; - } - - @Override - public TrainStudent selectTrainStudentByUserId(Long userId) - { - return null; - } - - @Override - public int countByUserId(Long userId) - { - return 0; - } - - @Override - public int bindUserId(String sId, Long userId) - { - return 0; - } - - @Override - public int unbindUserId(String sId) - { - return 0; - } - - @Override - public int deleteTrainStudentBySId(String sId) - { - return studentStore.remove(sId) != null ? 1 : 0; - } - } - - private static class MockTrainEnrollmentMapper implements TrainEnrollmentMapper - { - private final Map planStore; - private final InMemoryEnrollmentService enrollmentService; - private final Map planLocks = new ConcurrentHashMap<>(); - - public MockTrainEnrollmentMapper(Map planStore, InMemoryEnrollmentService enrollmentService) - { - this.planStore = planStore; - this.enrollmentService = enrollmentService; - } - - @Override - public TrainClassPlan selectPlanForUpdate(String planId) - { - ReentrantLock lock = planLocks.computeIfAbsent(planId, k -> new ReentrantLock()); - lock.lock(); - return planStore.get(planId); - } - - public void releasePlanLock(String planId) - { - ReentrantLock lock = planLocks.get(planId); - if (lock != null && lock.isHeldByCurrentThread()) - { - lock.unlock(); - } - } - - public void clearLocks() - { - planLocks.clear(); - } - - @Override - public TrainEnrollment selectTrainEnrollmentById(Long enrollmentId) - { - return enrollmentService.selectTrainEnrollmentById(enrollmentId); - } - - @Override - public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId) - { - return enrollmentService.selectTrainEnrollmentByIdForUpdate(enrollmentId); - } - - @Override - public List selectEnrollmentsForUpdateByPlanId(String planId) - { - return enrollmentService.selectEnrollmentsForUpdateByPlanId(planId); - } - - @Override - public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId) - { - return enrollmentService.selectTrainEnrollmentByPlanIdAndStudentId(planId, studentId); - } - - @Override - public List selectEnrollmentItemList(EnrollmentQuery query) - { - return enrollmentService.selectEnrollmentItemList(query); - } - - @Override - public int countEnrollmentsByPlanId(String planId) - { - return enrollmentService.countEnrollmentsByPlanId(planId); - } - - @Override - public int insertTrainEnrollment(TrainEnrollment trainEnrollment) - { - return enrollmentService.insertTrainEnrollment(trainEnrollment); - } - - @Override - public int batchInsertTrainEnrollment(List list) - { - return enrollmentService.batchInsertTrainEnrollment(list); - } - - @Override - public int updateTrainEnrollment(TrainEnrollment trainEnrollment) - { - return enrollmentService.updateTrainEnrollment(trainEnrollment); - } - - @Override - public int updateEnrollmentStatus(Long enrollmentId, Integer enrollmentStatus, String remark, String operator, Date updateTime) - { - return enrollmentService.updateEnrollmentStatus(enrollmentId, enrollmentStatus, remark, operator, updateTime); - } - - @Override - public int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime) - { - return enrollmentService.completeActiveEnrollmentsByPlanId(planId, operator, completeTime); - } - - @Override - public int deleteTrainEnrollmentById(Long enrollmentId) - { - return enrollmentService.deleteTrainEnrollmentById(enrollmentId); - } - - @Override - public int countAttendanceByEnrollmentId(Long enrollmentId) - { - return enrollmentService.countAttendanceByEnrollmentId(enrollmentId); - } - - @Override - public int countScoreByEnrollmentId(Long enrollmentId) - { - return enrollmentService.countScoreByEnrollmentId(enrollmentId); - } - - @Override - public int countReportByEnrollmentId(Long enrollmentId) - { - return enrollmentService.countReportByEnrollmentId(enrollmentId); - } - - @Override - public int countAccommodationByEnrollmentId(Long enrollmentId) - { - return enrollmentService.countAccommodationByEnrollmentId(enrollmentId); - } - - @Override - public int countStudyDurationByEnrollmentId(Long enrollmentId) - { - return enrollmentService.countStudyDurationByEnrollmentId(enrollmentId); - } - - @Override - public java.util.List> selectEnrollmentCompletionStatsByPlanId(String planId) - { - return enrollmentService.selectEnrollmentCompletionStatsByPlanId(planId); - } - - @Override - public int confirmEnrollmentReport(Long enrollmentId, Date reportTime, String operator, Date updateTime) - { - return 1; - } - - @Override - public List selectEnrollmentsByStudentId(String studentId) - { - return Collections.emptyList(); - } - - @Override - public int countEnrollmentsByStudentId(String studentId) - { - return 0; - } - - @Override - public int countActiveBusinessByStudentId(String studentId) - { - return 0; - } - } -} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentImportTemplateTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentImportTemplateTest.java new file mode 100644 index 000000000..b790da791 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentImportTemplateTest.java @@ -0,0 +1,170 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.junit.Test; +import com.ruoyi.train.domain.dto.EnrollmentAddDTO; + +/** + * 参训人员导入模板:结构、示例行、与既有导入解析规则的兼容性。 + */ +public class EnrollmentImportTemplateTest +{ + @Test + public void templateExposesHeadersGuideSheetAndSampleRow() throws Exception + { + byte[] content = writeTemplate(); + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) + { + assertEquals(2, workbook.getNumberOfSheets()); + assertEquals("参训人员导入", workbook.getSheetAt(0).getSheetName()); + assertEquals("填写说明", workbook.getSheetAt(1).getSheetName()); + + Sheet data = workbook.getSheetAt(0); + assertHeader(data, 0, "学员ID"); + assertHeader(data, 1, "参训来源"); + assertHeader(data, 2, "住宿需求"); + assertHeader(data, 3, "备注"); + + Row sample = data.getRow(1); + assertNotNull(sample); + assertEquals(EnrollmentImportTemplateWriter.SAMPLE_STUDENT_ID, sample.getCell(0).getStringCellValue()); + assertEquals("2", sample.getCell(1).getStringCellValue()); + assertEquals("0", sample.getCell(2).getStringCellValue()); + assertEquals(EnrollmentImportTemplateWriter.SAMPLE_REMARK, sample.getCell(3).getStringCellValue()); + // 表头需带规则批注,便于直接查看取值要求 + assertNotNull(data.getRow(0).getCell(0).getCellComment()); + + // 说明表:逐列规则(列序/表头/必填/取值格式/字段说明) + Sheet guide = workbook.getSheetAt(1); + assertEquals("列序", guide.getRow(2).getCell(0).getStringCellValue()); + assertEquals("表头", guide.getRow(2).getCell(1).getStringCellValue()); + assertEquals("必填", guide.getRow(2).getCell(2).getStringCellValue()); + assertEquals("取值 / 格式要求", guide.getRow(2).getCell(3).getStringCellValue()); + assertEquals("学员ID", guide.getRow(3).getCell(1).getStringCellValue()); + assertEquals("是", guide.getRow(3).getCell(2).getStringCellValue()); + assertTrue(guide.getRow(3).getCell(3).getStringCellValue().contains("≤32")); + assertEquals("否", guide.getRow(4).getCell(2).getStringCellValue()); + assertTrue(guide.getRow(4).getCell(3).getStringCellValue().contains("1=管理员录入")); + } + } + + @Test + public void sampleRowIsSkippedByImportParser() throws Exception + { + byte[] content = writeTemplate(); + + List rows = parse(content); + + assertTrue("未改动的示例行不应被解析为待导入数据", rows.isEmpty()); + } + + @Test + public void editedSampleRowAndManualRowAreParsed() throws Exception + { + byte[] content = writeTemplate(); + byte[] edited; + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) + { + Sheet data = workbook.getSheetAt(0); + // 用户直接在示例行改写:学号保留、备注改为真实内容 + data.getRow(1).getCell(3).setCellValue("张三"); + Row manual = data.createRow(2); + manual.createCell(0).setCellValue("AA000123"); + manual.createCell(1).setCellValue("1"); + manual.createCell(2).setCellValue("1"); + manual.createCell(3).setCellValue("李四"); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) + { + workbook.write(out); + edited = out.toByteArray(); + } + } + + List rows = parse(edited); + + assertEquals(2, rows.size()); + assertEquals(EnrollmentImportTemplateWriter.SAMPLE_STUDENT_ID, rows.get(0).getStudentId()); + assertEquals("张三", rows.get(0).getRemark()); + assertEquals(Integer.valueOf(2), rows.get(0).getSourceType()); + assertEquals(Integer.valueOf(0), rows.get(0).getAccommodationRequired()); + assertEquals("AA000123", rows.get(1).getStudentId()); + assertEquals(Integer.valueOf(1), rows.get(1).getSourceType()); + assertEquals(Integer.valueOf(1), rows.get(1).getAccommodationRequired()); + } + + private void assertHeader(Sheet sheet, int column, String expected) + { + assertEquals(expected, sheet.getRow(0).getCell(column).getStringCellValue()); + } + + /** + * 调用真实导入解析方法,确保模板与解析规则始终对齐。 + */ + @SuppressWarnings("unchecked") + private List parse(byte[] content) throws Exception + { + EnrollmentAppServiceImpl service = new EnrollmentAppServiceImpl(); + Method method = EnrollmentAppServiceImpl.class.getDeclaredMethod("parseExcelToDTOList", InputStream.class); + method.setAccessible(true); + return (List) method.invoke(service, new ByteArrayInputStream(content)); + } + + private byte[] writeTemplate() throws Exception + { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + EnrollmentImportTemplateWriter.write(responseOf(out)); + return out.toByteArray(); + } + + private HttpServletResponse responseOf(ByteArrayOutputStream out) + { + InvocationHandler handler = (proxy, method, args) -> { + if ("getOutputStream".equals(method.getName())) + { + return new ServletOutputStream() + { + @Override + public void write(int b) + { + out.write(b); + } + + @Override + public boolean isReady() + { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) + { + // 测试替身无需异步写出 + } + }; + } + return null; + }; + return (HttpServletResponse) Proxy.newProxyInstance( + HttpServletResponse.class.getClassLoader(), + new Class[] { HttpServletResponse.class }, + handler); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java index b416c4826..65e8f54b3 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java @@ -33,6 +33,8 @@ public class LegacyAnalyticsV2ServiceTest } public int insertTrainClassReport(TrainClassReport report) { return 0; } public TrainClassReport selectLatestReportByStudentId(String studentId) { return null; } + public int updateTrainClassReport(TrainClassReport report) { return 0; } + public int deleteTrainClassReportByReportId(Long reportId) { return 0; } }); assertEquals(0, service.selectTrainClassReportList(new TrainClassReport()).size()); TrainClassReport query = new TrainClassReport(); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageCAttendanceSemanticsConservationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageCAttendanceSemanticsConservationTest.java index 646c43fd0..882068f03 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageCAttendanceSemanticsConservationTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageCAttendanceSemanticsConservationTest.java @@ -365,6 +365,11 @@ public class PackageCAttendanceSemanticsConservationTest public Long countStudentExpectedAttendance(String studentId) { return 10L; } + public List selectStudentAttendanceDetails(String studentId) + { + return null; + } + public Map selectStudentAccommodationAuth(String studentId) { return Collections.emptyMap(); @@ -374,5 +379,20 @@ public class PackageCAttendanceSemanticsConservationTest { return 0L; } + + public StudentScoreStatVo selectStudentTheoryScoreStats(String studentId) + { + return null; + } + + public StudentScoreStatVo selectStudentPracticeScoreStats(String studentId) + { + return null; + } + + public List selectStudentExamScoreItems(String studentId) + { + return null; + } } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java index 109afdd44..a66151768 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java @@ -345,6 +345,12 @@ public class PlanAppServiceImplTest return listResult; } + @Override + public List selectClassPlanListByStudentId(String studentId) + { + return listResult; + } + @Override public boolean existsByPlanName(String planName, String excludePlanId) { @@ -394,6 +400,20 @@ public class PlanAppServiceImplTest return 1; } + @Override + public int logicalDeleteByPlanId(String planId, String operator) + { + TrainClassPlan existing = store.get(planId); + if (existing == null) + { + return 0; + } + existing.setDelFlag("2"); + existing.setUpdateBy(operator); + existing.setUpdateTime(new Date()); + return 1; + } + /** Mirrors the mapper's read-only effectivePlanStatusPolicy for this JVM fake. */ private TrainClassPlan effectiveView(TrainClassPlan source) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java index 840619253..d96ddc684 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java @@ -467,6 +467,25 @@ public class PlanCourseTest { return 1; } + + @Override + public List selectClassPlanListByStudentId(String studentId) + { + return Collections.emptyList(); + } + + @Override + public int logicalDeleteByPlanId(String planId, String operator) + { + TrainClassPlan existing = store.get(planId); + if (existing != null) + { + existing.setDelFlag("2"); + existing.setUpdateBy(operator); + existing.setUpdateTime(new java.util.Date()); + } + return existing != null ? 1 : 0; + } } private class InMemoryPlanCourseService implements ITrainPlanCourseService diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java index 95fd7c3cc..9c24b3cf5 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java @@ -293,6 +293,25 @@ public class PlanEnrollmentCompletionIntegrationTest } return 1; } + + @Override + public List selectClassPlanListByStudentId(String studentId) + { + return Collections.emptyList(); + } + + @Override + public int logicalDeleteByPlanId(String planId, String operator) + { + TrainClassPlan existing = planStore.get(planId); + if (existing != null) + { + existing.setDelFlag("2"); + existing.setUpdateBy(operator); + existing.setUpdateTime(new java.util.Date()); + } + return existing != null ? 1 : 0; + } } private static class MemoryEnrollmentService implements ITrainEnrollmentService @@ -348,6 +367,29 @@ public class PlanEnrollmentCompletionIntegrationTest return Collections.emptyList(); } + @Override + public List selectEnrollmentsByStudentId(String studentId) + { + List list = new ArrayList<>(); + if (studentId == null) + { + return list; + } + for (TrainEnrollment e : enrollmentStore.values()) + { + if (studentId.equals(e.getStudentId())) + { + EnrollmentItemVO vo = new EnrollmentItemVO(); + vo.setEnrollmentId(e.getEnrollmentId()); + vo.setStudentId(e.getStudentId()); + vo.setEnrollmentStatus(e.getEnrollmentStatus()); + vo.setJoinTime(e.getJoinTime()); + list.add(vo); + } + } + return list; + } + @Override public int countEnrollmentsByPlanId(String planId) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PortraitCalculatorTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PortraitCalculatorTest.java new file mode 100644 index 000000000..8eada9159 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PortraitCalculatorTest.java @@ -0,0 +1,245 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.util.Arrays; +import org.junit.Test; +import com.ruoyi.train.domain.analysis.Student360Vo; +import com.ruoyi.train.domain.analysis.StudentPortraitVo; + +/** + * 画像算分器纯逻辑测试:口径、阈值、互斥与降级。 + */ +public class PortraitCalculatorTest +{ + @Test + public void excellentStudentIsBenchmarkWithFullAttendanceTag() + { + PortraitRawData raw = healthyRaw(); + + StudentPortraitVo vo = PortraitCalculator.build(raw); + + assertEquals("BENCHMARK", vo.getLayer().getCode()); + assertEquals("标杆型", vo.getLayer().getName()); + assertEquals("低", vo.getRisk().getLevel()); + assertEquals(Integer.valueOf(0), vo.getRisk().getScore()); + assertTrue(vo.getRisk().getRules().isEmpty()); + assertEquals("高活跃", vo.getActivity().getLevel()); + assertEquals(Integer.valueOf(100), vo.getActivity().getScore()); + assertTrue(containsTag(vo, "全勤")); + assertTrue(containsTag(vo, "高投入")); + assertTrue(containsTag(vo, "已完成")); + + StudentPortraitVo.Dimension attendance = dimension(vo, "attendance"); + assertTrue(attendance.getAvailable()); + assertEquals(Integer.valueOf(100), attendance.getScore()); + StudentPortraitVo.Dimension score = dimension(vo, "score"); + assertEquals(Integer.valueOf(85), score.getScore()); + assertEquals("优", score.getLevel()); + assertEquals(Integer.valueOf(100), vo.getMeta().getCompleteness()); + assertEquals(PortraitCalculator.METRIC_VERSION, vo.getMeta().getMetricVersion()); + } + + @Test + public void abandonedEnrollmentOverridesLayerAndTags() + { + PortraitRawData raw = healthyRaw(); + raw.enrollmentStatus = 3; + + StudentPortraitVo vo = PortraitCalculator.build(raw); + + assertEquals("RISK", vo.getLayer().getCode()); + assertEquals("高风险型", vo.getLayer().getName()); + // 单条 R1 权重 40 → 风险分 40(中),但命中"高"等级规则 → 分层仍归为高风险型 + assertEquals(Integer.valueOf(40), vo.getRisk().getScore()); + assertEquals("中", vo.getRisk().getLevel()); + assertEquals(1, vo.getRisk().getRules().size()); + assertEquals("R1", vo.getRisk().getRules().get(0).getCode()); + assertEquals("risk", vo.getTags().get(0).getGroup()); + assertTrue(containsTag(vo, "流失预警")); + } + + @Test + public void absentAndSilentStudentTriggersAttendanceRisk() + { + PortraitRawData raw = new PortraitRawData(); + raw.planId = "P1"; + raw.enrollmentId = 9L; + raw.expectedLessons = 10; + raw.presentLessons = 5; + raw.normalLessons = 5; + raw.absentLessons = 5; + raw.maxConsecutiveAbsence = 3; + raw.courseCount = 4; + raw.totalLessons = 10; + raw.doneLessons = 6; + raw.profileFilledFields = 4; + raw.joinTime = new java.util.Date(); + + StudentPortraitVo vo = PortraitCalculator.build(raw); + + // R3 连续缺勤(30) + R4 出勤不足(20) + R7 学习沉默(15) = 65 → 高 + assertEquals(Integer.valueOf(65), vo.getRisk().getScore()); + assertEquals("高", vo.getRisk().getLevel()); + assertTrue(hasRule(vo, "R3")); + assertTrue(hasRule(vo, "R4")); + assertTrue(containsTag(vo, "出勤预警")); + assertEquals("沉默", vo.getActivity().getLevel()); + assertFalse(dimension(vo, "study").getAvailable()); + assertNull(dimension(vo, "study").getScore()); + assertTrue(vo.getMeta().getWarnings().size() >= 1); + } + + @Test + public void emptyDataDegradesWithoutFakeScores() + { + PortraitRawData raw = new PortraitRawData(); + + StudentPortraitVo vo = PortraitCalculator.build(raw); + + assertEquals("UNKNOWN", vo.getLayer().getCode()); + assertEquals("数据不足", vo.getLayer().getName()); + assertNull(dimension(vo, "attendance").getScore()); + assertNull(dimension(vo, "score").getScore()); + assertNull(vo.getActivity().getScore()); + assertEquals("数据不足", vo.getActivity().getLevel()); + assertEquals(0, vo.getRisk().getScore().intValue()); + assertNotNull(vo.getClassCompare()); + assertFalse(vo.getClassCompare().getSufficient()); + assertTrue(vo.getTrend().getWeekly().isEmpty()); + assertTrue(vo.getMeta().getCompleteness() < 100); + } + + @Test + public void tagsAreCappedAtEightAndRiskFirst() + { + PortraitRawData raw = new PortraitRawData(); + raw.expectedLessons = 10; + raw.presentLessons = 3; + raw.normalLessons = 2; + raw.lateLessons = 1; + raw.absentLessons = 7; + raw.leaveLessons = 3; + raw.proxySignCount = 2; + raw.manualCheckinCount = 2; + raw.maxConsecutiveAbsence = 4; + raw.courseCount = 4; + raw.totalLessons = 10; + raw.doneLessons = 2; + raw.profileFilledFields = 1; + raw.accountBound = false; + raw.extractStatus = "0"; + raw.faceSyncStatus = 2; + raw.theoryAbsentCount = 1; + raw.practiceRetakeCount = 1; + raw.failedCourseCount = 1; + raw.weakCourseNames.add("接触网检修"); + + StudentPortraitVo vo = PortraitCalculator.build(raw); + + assertTrue(vo.getTags().size() <= 8); + assertEquals("risk", vo.getTags().get(0).getGroup()); + assertFalse(vo.getTags().stream().anyMatch(t -> "全勤".equals(t.getName()))); + } + + @Test + public void classPercentileUsesSameCohort() + { + PortraitRawData raw = healthyRaw(); + raw.classAttendanceRates = Arrays.asList(new BigDecimal("50"), new BigDecimal("80"), + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("100"), + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("100"), + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("100")); + + StudentPortraitVo vo = PortraitCalculator.build(raw); + + StudentPortraitVo.ClassCompare.CompareItem attendance = compareItem(vo, "attendance"); + assertTrue(attendance.getAvailable()); + assertEquals(Integer.valueOf(100), attendance.getPercentile()); + // 11 个样本:50、80、100×9 → 均值 93.6 + assertEquals(0, new BigDecimal("93.6").compareTo(attendance.getAvg())); + assertTrue(vo.getClassCompare().getSufficient()); + } + + @Test + public void portraitNodeIsOptionalOnStudent360() + { + Student360Vo vo = new Student360Vo(); + assertNull(vo.getPortrait()); + vo.setPortrait(PortraitCalculator.build(healthyRaw())); + assertNotNull(vo.getPortrait()); + // 既有字段语义不受影响 + vo.setStudentName("张三"); + assertEquals("张三", vo.getStudentName()); + } + + /* ---------------------------------------------------------------- */ + + private PortraitRawData healthyRaw() + { + PortraitRawData raw = new PortraitRawData(); + raw.studentId = "S0001"; + raw.enrollmentId = 1L; + raw.planId = "P1"; + raw.planName = "2026年第一期"; + raw.enrollmentStatus = 1; + raw.joinTime = new java.util.Date(); + raw.courseCount = 4; + raw.plannedHours = new BigDecimal("40"); + raw.expectedLessons = 10; + raw.presentLessons = 10; + raw.normalLessons = 10; + raw.totalLessons = 10; + raw.doneLessons = 10; + raw.studyRecordCount = 20; + raw.studySeconds = 40L * 3600; + raw.studyActiveDays = 8; + raw.studyMaxConsecutiveDays = 5; + raw.learnedResourceCount = 8; + raw.courseResourceCount = 8; + raw.theoryCount = 4; + raw.theoryScoredCount = 4; + raw.theoryAvg = new BigDecimal("88"); + raw.theoryPassCount = 4; + raw.practiceCount = 1; + raw.practiceAvg = new BigDecimal("90"); + raw.practicePassCount = 1; + raw.weightedScore = new BigDecimal("510"); + raw.weightSum = new BigDecimal("600"); + raw.examCompletedCourses = 4; + raw.profileFilledFields = 6; + raw.accountBound = true; + raw.extractStatus = "1"; + raw.cardNo = "C0001"; + raw.faceSyncStatus = 1; + raw.teacherCount = 3; + return raw; + } + + private boolean containsTag(StudentPortraitVo vo, String name) + { + return vo.getTags().stream().anyMatch(t -> name.equals(t.getName())); + } + + private boolean hasRule(StudentPortraitVo vo, String code) + { + return vo.getRisk().getRules().stream().anyMatch(r -> code.equals(r.getCode())); + } + + private StudentPortraitVo.Dimension dimension(StudentPortraitVo vo, String code) + { + return vo.getDimensions().stream().filter(d -> code.equals(d.getCode())).findFirst().orElseThrow( + () -> new AssertionError("缺少维度 " + code)); + } + + private StudentPortraitVo.ClassCompare.CompareItem compareItem(StudentPortraitVo vo, String key) + { + return vo.getClassCompare().getItems().stream().filter(i -> key.equals(i.getKey())).findFirst().orElseThrow( + () -> new AssertionError("缺少对比项 " + key)); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java index 3c99cde82..09a48722b 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java @@ -111,7 +111,7 @@ public class ReportTerminalServiceTest s1.setsId("S101"); s1.setsName("张三"); s1.setsGender("男"); - s1.setOrgName("沈阳机务段"); + s1.setDeptName("沈阳机务段"); s1.setsPhone("13800000001"); s1.setIdCardLast4("1234"); s1.setStatus(1); @@ -139,7 +139,7 @@ public class ReportTerminalServiceTest vo1.setStudentId("S101"); vo1.setStudentName("张三"); vo1.setGender("男"); - vo1.setOrgName("沈阳机务段"); + vo1.setDeptName("沈阳机务段"); vo1.setEnrollmentStatus(0); vo1.setAccommodationRequired(1); List s1List = new ArrayList<>(); @@ -448,7 +448,7 @@ public class ReportTerminalServiceTest assertNotNull("查询结果不能为 null", preview); assertEquals("S101", preview.getStudentId()); assertEquals("张三", preview.getStudentName()); - assertEquals("沈阳机务段", preview.getOrgName()); + assertEquals("沈阳机务段", preview.getDeptName()); assertEquals("PLAN-2026-01", preview.getPlanId()); assertEquals(Long.valueOf(1001L), preview.getEnrollmentId()); assertEquals(Integer.valueOf(0), preview.getEnrollmentStatus()); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/Round5vAccommodationAtomicityTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/Round5vAccommodationAtomicityTest.java index 6dc180e00..c013f35b1 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/Round5vAccommodationAtomicityTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/Round5vAccommodationAtomicityTest.java @@ -56,4 +56,48 @@ public class Round5vAccommodationAtomicityTest { Transactional annotation = TrainAccommodationServiceImpl.class.getMethod("checkIn",Long.class,String.class).getAnnotation(Transactional.class); assertNotNull(annotation); assertTrue(Arrays.asList(annotation.rollbackFor()).contains(Exception.class)); } + + /** + * 调房目标宿舍未绑定智能门锁:跳过门锁授权桥接,住宿关系(撤旧建新)照常生效。 + */ + @Test public void transferSkipsLockAuthWhenTargetRoomHasNoLock() throws Exception { + com.ruoyi.common.exception.ServiceException unbound = new com.ruoyi.common.exception.ServiceException( + com.ruoyi.train.constant.AccommodationErrorCode.LOCK_DEVICE_UNBOUND_MARK + + "(lock_device_sn),无法下发门锁授权 (宿舍: 1001)"); + TrainAccommodation newAcc = serviceWithBridgeException(unbound).transfer(1L, "QA_UNBOUND", "1", "qa-test"); + assertNotNull("未绑定门锁的调房必须成功,不得抛错", newAcc); + assertEquals(Long.valueOf(2L), newAcc.getAccommodationId()); + } + + /** + * 其它门锁桥接异常(如设备不存在/停用)仍须向上抛出,保持原事务回滚语义。 + */ + @Test public void transferStillPropagatesOtherLockBridgeErrors() throws Exception { + try { + serviceWithBridgeException(new com.ruoyi.common.exception.ServiceException("IoT门锁设备不存在: LOCK_X")) + .transfer(1L, "QA_B", "1", "qa-test"); + fail("非未绑定门锁的桥接异常必须继续抛出"); + } catch (com.ruoyi.common.exception.ServiceException expected) { + assertTrue(expected.getMessage().contains("IoT门锁设备不存在")); + } + } + + private TrainAccommodationServiceImpl serviceWithBridgeException(RuntimeException bridgeException) throws Exception { + TrainAccommodationServiceImpl service = new TrainAccommodationServiceImpl() { + @Override public int insertTrainAccommodation(TrainAccommodation value) { + value.setAccommodationId(2L); return 1; + } + }; + TrainAccommodation old = new TrainAccommodation(); old.setAccommodationId(1L); + old.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); old.setDormId("QA_A"); old.setEnrollmentId(3L); + inject(service,"trainAccommodationMapper",proxy(TrainAccommodationMapper.class,(p,m,a)-> { + if (m.getName().equals("selectTrainAccommodationByAccommodationId")) return old; + if (m.getReturnType()==int.class) return 1; + return null; + })); + inject(service,"trainDormitoryInfoMapper",proxy(TrainDormitoryInfoMapper.class,(p,m,a)->null)); + service.setAuthEngine("iot"); + service.setIotLockAuthBridgeService(proxy(ITrainLockAuthBridgeService.class,(p,m,a)-> { throw bridgeException; })); + return service; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java index 2eaedc034..247110e8a 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java @@ -23,6 +23,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; + +import com.ruoyi.train.domain.vo.*; import org.junit.Before; import org.junit.Test; import com.ruoyi.common.exception.ServiceException; @@ -38,11 +40,6 @@ import com.ruoyi.train.domain.dto.LessonCreateDTO; import com.ruoyi.train.domain.dto.LessonConflictCheckDTO; import com.ruoyi.train.domain.dto.LessonUpdateDTO; import com.ruoyi.train.domain.query.PlanQuery; -import com.ruoyi.train.domain.vo.ConflictItemVO; -import com.ruoyi.train.domain.vo.LessonConflictVO; -import com.ruoyi.train.domain.vo.PlanItemVO; -import com.ruoyi.train.domain.vo.PlanTimetableVO; -import com.ruoyi.train.domain.vo.TimetableLessonItemVO; import com.ruoyi.train.mapper.TrainClassPlanMapper; import com.ruoyi.train.mapper.TrainLessonMapper; import com.ruoyi.train.mapper.TrainPlanCourseMapper; @@ -852,6 +849,18 @@ public class SchedulingAppServiceTest return lessons.get(lessonId); } + @Override + public List selectTrainLessonListByPlanId(String planId) + { + return Collections.emptyList(); + } + + @Override + public Long selectFirstLessonIdByPlanAndCourse(String planId, String courseName) + { + return null; + } + @Override public synchronized int insertTrainLesson(TrainLesson trainLesson) { @@ -1057,6 +1066,11 @@ public class SchedulingAppServiceTest return Collections.emptyList(); } + @Override + public List selectLessonScheduleByTime(String day, String courseType) { + return List.of(); + } + @Override public int countTeacherInPlan(String tId, String planId) { @@ -1141,6 +1155,32 @@ public class SchedulingAppServiceTest plans.put(trainClassPlan.getPlanId(), trainClassPlan); return 1; } + + @Override + public List selectClassPlanListByStudentId(String studentId) + { + return Collections.emptyList(); + } + + @Override + public int logicalDeleteByPlanId(String planId, String operator) + { + TrainClassPlan existing = plans.get(planId); + if (existing != null) + { + existing.setDelFlag("2"); + existing.setUpdateBy(operator); + existing.setUpdateTime(new java.util.Date()); + } + return existing != null ? 1 : 0; + } + + @Override + public int updateReportStartByPlanId(TrainClassPlan trainClassPlan) + { + plans.put(trainClassPlan.getPlanId(), trainClassPlan); + return 1; + } } /** diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingTeacherStatusAndOptionsTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingTeacherStatusAndOptionsTest.java index f27af670d..023e415a8 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingTeacherStatusAndOptionsTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingTeacherStatusAndOptionsTest.java @@ -14,6 +14,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; + +import com.ruoyi.train.domain.vo.*; import org.junit.Before; import org.junit.Test; import com.ruoyi.common.exception.ServiceException; @@ -27,10 +29,6 @@ import com.ruoyi.train.domain.TrainTrainingSite; import com.ruoyi.train.domain.dto.LessonCreateDTO; import com.ruoyi.train.domain.dto.LessonUpdateDTO; import com.ruoyi.train.domain.query.PlanQuery; -import com.ruoyi.train.domain.vo.ConflictItemVO; -import com.ruoyi.train.domain.vo.PlanCourseItemVO; -import com.ruoyi.train.domain.vo.PlanItemVO; -import com.ruoyi.train.domain.vo.TimetableLessonItemVO; import com.ruoyi.train.mapper.TrainClassPlanMapper; import com.ruoyi.train.mapper.TrainClassroomInfoMapper; import com.ruoyi.train.mapper.TrainLessonMapper; @@ -273,7 +271,7 @@ public class SchedulingTeacherStatusAndOptionsTest assertEquals(3, firstTeacher.size()); assertTrue(firstTeacher.containsKey("tId")); assertTrue(firstTeacher.containsKey("tName")); - assertTrue(firstTeacher.containsKey("orgName")); + assertTrue(firstTeacher.containsKey("deptName")); assertFalse(firstTeacher.containsKey("tPhone")); assertFalse(firstTeacher.containsKey("tEmail")); @@ -289,7 +287,7 @@ public class SchedulingTeacherStatusAndOptionsTest TrainTeacher teacher = new TrainTeacher(); teacher.settId(tId); teacher.settName(tName); - teacher.setOrgName("动车运用所"); + teacher.setDeptName("动车运用所"); teacher.settPhone("13800000000"); teacher.settEmail("teacher@example.com"); teacher.setStatus(status); @@ -339,6 +337,18 @@ public class SchedulingTeacherStatusAndOptionsTest return lessons.get(lessonId); } + @Override + public List selectTrainLessonListByPlanId(String planId) + { + return Collections.emptyList(); + } + + @Override + public Long selectFirstLessonIdByPlanAndCourse(String planId, String courseName) + { + return null; + } + @Override public int insertTrainLesson(TrainLesson trainLesson) { @@ -430,6 +440,11 @@ public class SchedulingTeacherStatusAndOptionsTest return Collections.emptyList(); } + @Override + public List selectLessonScheduleByTime(String day, String courseType) { + return List.of(); + } + @Override public int countTeacherInPlan(String tId, String planId) { @@ -506,6 +521,32 @@ public class SchedulingTeacherStatusAndOptionsTest plans.put(trainClassPlan.getPlanId(), trainClassPlan); return 1; } + + @Override + public List selectClassPlanListByStudentId(String studentId) + { + return Collections.emptyList(); + } + + @Override + public int logicalDeleteByPlanId(String planId, String operator) + { + TrainClassPlan existing = plans.get(planId); + if (existing != null) + { + existing.setDelFlag("2"); + existing.setUpdateBy(operator); + existing.setUpdateTime(new java.util.Date()); + } + return existing != null ? 1 : 0; + } + + @Override + public int updateReportStartByPlanId(TrainClassPlan trainClassPlan) + { + plans.put(trainClassPlan.getPlanId(), trainClassPlan); + return 1; + } } /** @@ -642,6 +683,11 @@ public class SchedulingTeacherStatusAndOptionsTest return 1; } + @Override + public int clearAvatarToNullByTId(String tId) { + return 0; + } + @Override public TrainTeacher checkPhoneUnique(String tPhone) { @@ -704,6 +750,12 @@ public class SchedulingTeacherStatusAndOptionsTest return null; } + @Override + public List selectTrainClassroomInfoByPlanId(String planId) + { + return new ArrayList<>(); + } + @Override public List selectTrainClassroomInfoList(TrainClassroomInfo query) { @@ -748,6 +800,12 @@ public class SchedulingTeacherStatusAndOptionsTest { return classRoomIds == null ? 0 : classRoomIds.length; } + + @Override + public int updateBindBoardIdByClassRoomId(String classRoomId, String bindBoardId, String updateBy) + { + return 1; + } } /** @@ -763,6 +821,12 @@ public class SchedulingTeacherStatusAndOptionsTest return null; } + @Override + public List selectTrainTrainingSiteByPlanId(String planId) + { + return new ArrayList<>(); + } + @Override public List selectTrainTrainingSiteList(TrainTrainingSite query) { @@ -807,5 +871,11 @@ public class SchedulingTeacherStatusAndOptionsTest { return siteIds == null ? 0 : siteIds.length; } + + @Override + public int updateBindBoardIdBySiteId(String siteId, String bindBoardId, String updateBy) + { + return 1; + } } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/StudentImportServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/StudentImportServiceTest.java new file mode 100644 index 000000000..6d3a03ac8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/StudentImportServiceTest.java @@ -0,0 +1,218 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.Test; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +import com.ruoyi.train.domain.vo.BatchImportResultVO; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.ITrainStudentService; + +/** + * 学员批量导入:文件类型、模板表头、逐行校验与失败明细。 + */ +public class StudentImportServiceTest +{ + private static final String[] HEADERS = { + "学员姓名", "人员编号", "性别", "出生日期", "所属单位", + "联系电话", "邮箱", "身份证号", "门禁卡号", "备注" + }; + + @Test + public void importsValidRowAndCollectsFailedRows() throws Exception + { + String[][] rows = { + { "张三", "", "男", "1990-01-01", "沈阳供电段", "13800000000", "", "210102199001011234", "", "首批" }, + { "李四", "", "女", "1991-02-02", "", "12345", "", "", "", "" } + }; + StudentImportServiceImpl service = newService(); + + BatchImportResultVO result = service.importStudents(file("学员.xlsx", buildWorkbook(HEADERS, rows)), "tester"); + + assertEquals(Integer.valueOf(2), result.getTotalCount()); + assertEquals(Integer.valueOf(1), result.getSuccessCount()); + assertEquals(Integer.valueOf(1), result.getFailureCount()); + assertEquals(1, result.getErrorList().size()); + assertTrue(result.getErrorList().get(0).startsWith("第 3 行")); + assertTrue(result.getErrorList().get(0).contains("联系电话")); + } + + @Test + public void rejectsTemplateWithMissingColumns() throws Exception + { + String[] brokenHeaders = { "学员姓名", "人员编号", "性别", "出生日期", "所属单位", "联系电话", "身份证号", "门禁卡号", "备注" }; + String[][] rows = { { "张三", "", "男", "", "", "", "", "", "" } }; + StudentImportServiceImpl service = newService(); + + try + { + service.importStudents(file("学员.xlsx", buildWorkbook(brokenHeaders, rows)), "tester"); + throw new AssertionError("模板缺列时必须拒绝导入"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("缺少列")); + assertTrue(e.getMessage().contains("邮箱")); + } + } + + @Test + public void rejectsNonExcelFile() throws Exception + { + StudentImportServiceImpl service = newService(); + try + { + service.importStudents(file("学员.txt", "姓名,电话".getBytes(StandardCharsets.UTF_8)), "tester"); + throw new AssertionError("非 Excel 文件必须被拒绝"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("仅支持")); + } + } + + @Test + public void templateRowsExposeSampleData() + { + StudentImportServiceImpl service = new StudentImportServiceImpl(); + assertEquals(1, service.templateRows().size()); + assertEquals("张三", service.templateRows().get(0).getName()); + } + + /** + * 构造被测服务:建档接口用动态代理替身,避免依赖数据库。 + */ + private StudentImportServiceImpl newService() throws Exception + { + ITrainStudentService studentService = (ITrainStudentService) Proxy.newProxyInstance( + ITrainStudentService.class.getClassLoader(), + new Class[] { ITrainStudentService.class }, + (proxy, method, args) -> { + if ("createStudent".equals(method.getName())) + { + assertTrue(args[0] instanceof StudentCreateDTO); + return "SID-IMPORTED"; + } + return null; + }); + TrainStudentMapper studentMapper = (TrainStudentMapper) Proxy.newProxyInstance( + TrainStudentMapper.class.getClassLoader(), + new Class[] { TrainStudentMapper.class }, + (proxy, method, args) -> { + if ("countByPersonNo".equals(method.getName())) + { + return 0; + } + return null; + }); + StudentImportServiceImpl service = new StudentImportServiceImpl(); + inject(service, "trainStudentService", studentService); + inject(service, "trainStudentMapper", studentMapper); + return service; + } + + private void inject(Object target, String fieldName, Object value) throws Exception + { + Field field = StudentImportServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private byte[] buildWorkbook(String[] headers, String[][] rows) throws Exception + { + try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) + { + Sheet sheet = workbook.createSheet("Sheet1"); + Row header = sheet.createRow(0); + for (int i = 0; i < headers.length; i++) + { + header.createCell(i).setCellValue(headers[i]); + } + for (int r = 0; r < rows.length; r++) + { + Row row = sheet.createRow(r + 1); + for (int c = 0; c < rows[r].length; c++) + { + row.createCell(c).setCellValue(rows[r][c]); + } + } + workbook.write(out); + return out.toByteArray(); + } + } + + private MultipartFile file(String fileName, byte[] content) + { + return new MultipartFile() + { + @Override + public String getName() + { + return "file"; + } + + @Override + public String getOriginalFilename() + { + return fileName; + } + + @Override + public String getContentType() + { + return "application/octet-stream"; + } + + @Override + public boolean isEmpty() + { + return content.length == 0; + } + + @Override + public long getSize() + { + return content.length; + } + + @Override + public byte[] getBytes() + { + return content; + } + + @Override + public InputStream getInputStream() + { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) + { + throw new UnsupportedOperationException("测试替身不支持写入磁盘"); + } + + @Override + public void transferTo(Path dest) + { + throw new UnsupportedOperationException("测试替身不支持写入磁盘"); + } + }; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java index 29e0f97c2..c9a913ee2 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java @@ -424,7 +424,7 @@ public class TrainAnalysisServiceTest map.put("birth", new Date()); map.put("phone", "13800000000"); map.put("email", "zhangsan@example.com"); - map.put("org_name", "房建公寓段"); + map.put("dept_name", "房建公寓段"); map.put("id_card_last4", "1234"); map.put("user_binding_status", "已绑定"); map.put("status", 1); @@ -463,6 +463,11 @@ public class TrainAnalysisServiceTest public Long countStudentExpectedAttendance(String studentId) { return 10L; } + public List selectStudentAttendanceDetails(String studentId) + { + return null; + } + public Map selectStudentAccommodationAuth(String studentId) { Map map = new HashMap<>(); @@ -481,5 +486,20 @@ public class TrainAnalysisServiceTest { return 0L; } + + public StudentScoreStatVo selectStudentTheoryScoreStats(String studentId) + { + return null; + } + + public StudentScoreStatVo selectStudentPracticeScoreStats(String studentId) + { + return null; + } + + public List selectStudentExamScoreItems(String studentId) + { + return null; + } } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java deleted file mode 100644 index 62123429f..000000000 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java +++ /dev/null @@ -1,345 +0,0 @@ -package com.ruoyi.train.service.impl; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.fail; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import com.alibaba.fastjson2.JSON; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ruoyi.common.exception.ServiceException; -import com.ruoyi.train.constant.StudentErrorCode; -import com.ruoyi.train.domain.TrainStudent; -import com.ruoyi.train.domain.dto.StudentCreateDTO; -import com.ruoyi.train.domain.query.StudentQuery; -import com.ruoyi.train.domain.vo.StudentDetailVO; -import com.ruoyi.train.domain.vo.StudentItemVO; -import com.ruoyi.train.mapper.TrainStudentMapper; -import com.ruoyi.train.security.JceStudentIdentityProtection; -import com.ruoyi.train.security.StudentIdentityProtection; -import org.junit.Before; -import org.junit.Test; -import org.springframework.dao.DuplicateKeyException; - -/** ISSUE-01-02 学员主档安全业务测试。 */ -public class TrainStudentServiceImplTest -{ - private static final String ID_CARD = "110101199001011234"; - - private InMemoryStudentMapper mapper; - private StudentIdentityProtection protection; - private TrainStudentServiceImpl service; - - @Before - public void setUp() - { - byte[] encryptionKey = new byte[32]; - byte[] hashKey = new byte[32]; - Arrays.fill(encryptionKey, (byte) 0x31); - Arrays.fill(hashKey, (byte) 0x52); - protection = new JceStudentIdentityProtection( - Base64.getEncoder().encodeToString(encryptionKey), - Base64.getEncoder().encodeToString(hashKey)); - mapper = new InMemoryStudentMapper(); - service = new TrainStudentServiceImpl(mapper, protection); - } - - @Test - public void createShouldPersistOnlyProtectedIdentityMaterial() - { - String id = service.createStudent(validCreate()); - TrainStudent saved = mapper.store.get(id); - - assertNotNull(saved); - assertNotEquals(ID_CARD, saved.getIdCardCiphertext()); - assertFalse(saved.getIdCardCiphertext().contains(ID_CARD)); - assertEquals(ID_CARD, protection.decrypt(saved.getIdCardCiphertext())); - assertEquals(protection.deterministicHash(ID_CARD), saved.getIdCardHash()); - assertEquals("1234", saved.getIdCardLast4()); - assertEquals(Integer.valueOf(1), saved.getSourceType()); - assertEquals(Integer.valueOf(1), saved.getStatus()); - assertEquals("0", saved.getDelFlag()); - } - - @Test - public void duplicateIdentityHashShouldBeBlockedWithFrozenCode() - { - service.createStudent(validCreate()); - assertError(StudentErrorCode.STUDENT_IDCARD_DUPLICATED, - () -> service.createStudent(validCreate())); - assertEquals(1, mapper.store.size()); - } - - @Test - public void databaseUniqueRaceShouldAlsoMapToDuplicateCode() - { - mapper.failNextInsertAsDuplicate = true; - assertError(StudentErrorCode.STUDENT_IDCARD_DUPLICATED, - () -> service.createStudent(validCreate())); - assertEquals(0, mapper.store.size()); - } - - @Test - public void listAndDetailShouldExposeMaskedIdentityOnly() - { - String id = service.createStudent(validCreate()); - - List list = service.selectStudentList(new StudentQuery()); - StudentDetailVO detail = service.selectStudentDetail(id); - String listJson = JSON.toJSONString(list); - String detailJson = JSON.toJSONString(detail); - - assertEquals(1, list.size()); - assertEquals("110101********1234", list.get(0).getIdCardMasked()); - assertEquals("1234", list.get(0).getIdCardLast4()); - assertEquals("110101********1234", detail.getIdCardMasked()); - assertEquals("1234", detail.getIdCardLast4()); - assertFalse(listJson.contains(ID_CARD)); - assertFalse(detailJson.contains(ID_CARD)); - assertFalse(listJson.contains("idCardCiphertext")); - assertFalse(detailJson.contains("idCardHash")); - } - - @Test - public void canonicalizedXxShouldBeDuplicateBlocked() - { - StudentCreateDTO dto1 = validCreate(); - dto1.setIdCard("11010119900101123x"); - service.createStudent(dto1); - - StudentCreateDTO dto2 = validCreate(); - dto2.setIdCard(" 11010119900101123X "); - assertError(StudentErrorCode.STUDENT_IDCARD_DUPLICATED, - () -> service.createStudent(dto2)); - } - - @Test - public void missingStudentShouldReturnFrozenNotFoundCode() - { - assertError(StudentErrorCode.STUDENT_NOT_FOUND, - () -> service.selectStudentDetail("missing")); - } - - @Test - public void logsAndDtoStringShouldNotContainPlainIdentity() - { - StudentCreateDTO dto = validCreate(); - String id = service.createStudent(dto); - TrainStudent saved = mapper.store.get(id); - - assertFalse(dto.toString().contains(ID_CARD)); - assertFalse(saved.toString().contains(ID_CARD)); - assertFalse(saved.toString().contains(saved.getIdCardCiphertext())); - assertFalse(saved.toString().contains(saved.getIdCardHash())); - assertNull(JSON.parseObject(JSON.toJSONString(dto)).get("idCard")); - } - - @Test - public void domainJacksonSerializationShouldNotLeakSensitiveFields() throws Exception - { - StudentCreateDTO dto = validCreate(); - String id = service.createStudent(dto); - TrainStudent saved = mapper.store.get(id); - - ObjectMapper objectMapper = new ObjectMapper(); - String json = objectMapper.writeValueAsString(saved); - - assertFalse("Jackson序列化不得泄漏身份证密文", json.contains("idCardCiphertext")); - assertFalse("Jackson序列化不得泄漏身份证哈希", json.contains("idCardHash")); - assertFalse("Jackson序列化不得泄漏身份证明文", json.contains(ID_CARD)); - assertEquals("1234", objectMapper.readTree(json).get("idCardLast4").asText()); - } - - @Test - public void testCreateStudentWithoutIdCard() - { - StudentCreateDTO dto = validCreate(); - dto.setIdCard(null); - String id = service.createStudent(dto); - TrainStudent saved = mapper.store.get(id); - - assertNotNull(saved); - assertNull(saved.getIdCardCiphertext()); - assertNull(saved.getIdCardHash()); - assertNull(saved.getIdCardLast4()); - } - - @Test - public void testCreateMultipleStudentsWithoutIdCard() - { - StudentCreateDTO dto1 = validCreate(); - dto1.setsName("学员A"); - dto1.setIdCard(null); - String id1 = service.createStudent(dto1); - - StudentCreateDTO dto2 = validCreate(); - dto2.setsName("学员B"); - dto2.setIdCard(null); - String id2 = service.createStudent(dto2); - - assertNotNull(id1); - assertNotNull(id2); - assertNotEquals(id1, id2); - assertNull(mapper.store.get(id1).getIdCardHash()); - assertNull(mapper.store.get(id2).getIdCardHash()); - assertEquals(2, mapper.store.size()); - } - - @Test - public void testBlankIdCardNormalizedToNull() - { - StudentCreateDTO dtoEmpty = validCreate(); - dtoEmpty.setsName("学员空串"); - dtoEmpty.setIdCard(""); - String idEmpty = service.createStudent(dtoEmpty); - TrainStudent savedEmpty = mapper.store.get(idEmpty); - - assertNotNull(savedEmpty); - assertNull(savedEmpty.getIdCardCiphertext()); - assertNull(savedEmpty.getIdCardHash()); - assertNull(savedEmpty.getIdCardLast4()); - - StudentCreateDTO dtoSpaces = validCreate(); - dtoSpaces.setsName("学员空格"); - dtoSpaces.setIdCard(" "); - String idSpaces = service.createStudent(dtoSpaces); - TrainStudent savedSpaces = mapper.store.get(idSpaces); - - assertNotNull(savedSpaces); - assertNull(savedSpaces.getIdCardCiphertext()); - assertNull(savedSpaces.getIdCardHash()); - assertNull(savedSpaces.getIdCardLast4()); - } - - private StudentCreateDTO validCreate() - { - StudentCreateDTO dto = new StudentCreateDTO(); - dto.setsName("张三"); - dto.setsGender("男"); - dto.setOrgName("信号段"); - dto.setsPhone("13800000001"); - dto.setsEmail("student@example.com"); - dto.setIdCard(ID_CARD); - dto.setFaceRef("face/object/001"); - dto.setRemark("P0测试"); - return dto; - } - - private void assertError(int expectedCode, Runnable action) - { - try - { - action.run(); - fail("应抛出 ServiceException"); - } - catch (ServiceException e) - { - assertEquals(Integer.valueOf(expectedCode), e.getCode()); - assertFalse("异常不得包含身份证明文", String.valueOf(e.getMessage()).contains(ID_CARD)); - } - } - - private static final class InMemoryStudentMapper implements TrainStudentMapper - { - private final Map store = new LinkedHashMap<>(); - private boolean failNextInsertAsDuplicate; - - @Override - public TrainStudent selectTrainStudentBySId(String sId) - { - return store.get(sId); - } - - @Override - public List selectTrainStudentsBySIds(java.util.Collection sIds) - { - List list = new ArrayList<>(); - if (sIds != null) - { - for (String sId : sIds) - { - TrainStudent s = store.get(sId); - if (s != null) - { - list.add(s); - } - } - } - return list; - } - - @Override - public List selectTrainStudentList(StudentQuery query) - { - return new ArrayList<>(store.values()); - } - - @Override - public Integer selectOneByIdCardHash(String idCardHash) - { - for (TrainStudent student : store.values()) - { - if (idCardHash.equals(student.getIdCardHash())) - { - return 1; - } - } - return null; - } - - @Override - public int insertTrainStudent(TrainStudent student) - { - if (failNextInsertAsDuplicate) - { - failNextInsertAsDuplicate = false; - throw new DuplicateKeyException("simulated unique constraint"); - } - store.put(student.getsId(), student); - return 1; - } - - @Override - public int updateTrainStudent(TrainStudent trainStudent) { - return 0; - } - - @Override - public TrainStudent selectTrainStudentByUserId(Long userId) - { - return null; - } - - @Override - public int countByUserId(Long userId) - { - return 0; - } - - @Override - public int bindUserId(String sId, Long userId) - { - return 0; - } - - @Override - public int unbindUserId(String sId) - { - return 0; - } - - @Override - public int deleteTrainStudentBySId(String sId) - { - return store.remove(sId) != null ? 1 : 0; - } - } -} diff --git a/RuoYi-Vue/ruoyi-ui/vue.config.js b/RuoYi-Vue/ruoyi-ui/vue.config.js index f06088719..7ac415989 100644 --- a/RuoYi-Vue/ruoyi-ui/vue.config.js +++ b/RuoYi-Vue/ruoyi-ui/vue.config.js @@ -42,8 +42,10 @@ module.exports = { ['^' + process.env.VUE_APP_BASE_API]: '' } }, - // springdoc proxy - '^/v3/api-docs/(.*)': { + // springdoc 文档端点代理:swagger-ui 内部以根绝对路径 /v3/api-docs 拉取文档, + // 必须代理到后端;否则请求会落到前端 dev server,被当作 SPA 路由返回 index.html 导致 YAMLException。 + // 注意:用字符串前缀(http-proxy-middleware 新版不再支持正则 key)。 + '/v3/api-docs': { target: baseUrl, changeOrigin: true } diff --git a/RuoYi-Vue/sql/demo/002_demo_master_data.sql b/RuoYi-Vue/sql/demo/002_demo_master_data.sql index 14ba4352c..f75bf1696 100644 --- a/RuoYi-Vue/sql/demo/002_demo_master_data.sql +++ b/RuoYi-Vue/sql/demo/002_demo_master_data.sql @@ -6,8 +6,8 @@ BEGIN; -- 1. 教师主档 -UPDATE public.train_teacher SET org_name = '沈阳房建公寓段安全培训科', job_title = '首席技师/教授级高工', t_phone = '18800000000', t_email = 'zhaodm@sict.railway.cn', status = 1, del_flag = '0' WHERE t_id = 'T001'; -INSERT INTO public.train_teacher (t_id, t_name, t_gender, t_birth, org_name, job_title, t_phone, t_email, status, del_flag, user_id, create_by, create_time, remark) +UPDATE public.train_teacher SET dept_name = '沈阳房建公寓段安全培训科', job_title = '首席技师/教授级高工', t_phone = '18800000000', t_email = 'zhaodm@sict.railway.cn', status = 1, del_flag = '0' WHERE t_id = 'T001'; +INSERT INTO public.train_teacher (t_id, t_name, t_gender, t_birth, dept_name, job_title, t_phone, t_email, status, del_flag, user_id, create_by, create_time, remark) VALUES ('T101', '崔宏伟', '0', '1982-05-14', '沈阳房建公寓段安全培训科', '高级工程师', '18800000011', 'cuihw@sict.railway.cn', 1, '0', 11, 'admin', CURRENT_TIMESTAMP, '演示主讲教师(绑定账号demo_teacher)'), ('T102', '张建国', '0', '1985-11-20', '沈阳房建公寓段技术科', '副高级工程师', '18800000012', 'zhangjg@sict.railway.cn', 1, '0', 12, 'admin', CURRENT_TIMESTAMP, '技术专家讲师(绑定账号demo_teacher2)'), @@ -16,7 +16,7 @@ VALUES ('T105', '陈广林', '0', '1976-12-09', '大连项目部', '特级技师', '18800000015', 'chengl@sict.railway.cn', 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '防汛抢险实训专家'), ('T106', '马志强', '0', '1990-07-18', '丹东项目部', '工程师', '18800000016', 'mazhq@sict.railway.cn', 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '电气防火与智能化安全监控讲师') ON CONFLICT (t_id) DO UPDATE SET - t_name = EXCLUDED.t_name, org_name = EXCLUDED.org_name, job_title = EXCLUDED.job_title, user_id = EXCLUDED.user_id, status = EXCLUDED.status, del_flag = '0'; + t_name = EXCLUDED.t_name, dept_name = EXCLUDED.dept_name, job_title = EXCLUDED.job_title, user_id = EXCLUDED.user_id, status = EXCLUDED.status, del_flag = '0'; -- 2. 课程标准库 INSERT INTO public.train_course (c_id, c_name, c_code, c_type, c_duration, teaching_objective, course_content, status, del_flag, create_by, create_time, remark) @@ -86,7 +86,7 @@ ON CONFLICT (dorm_id) DO UPDATE SET dorm_building = EXCLUDED.dorm_building, dorm_floor = EXCLUDED.dorm_floor, dorm_room = EXCLUDED.dorm_room, capacity = EXCLUDED.capacity, dorm_status = EXCLUDED.dorm_status, del_flag = '0'; -- 7. 学员主档 (35名) -INSERT INTO public.train_student (s_id, s_name, s_gender, s_birth, org_name, s_phone, s_email, id_card_hash, id_card_last4, source_type, status, del_flag, user_id, create_by, create_time, remark) +INSERT INTO public.train_student (s_id, s_name, s_gender, s_birth, dept_name, s_phone, s_email, id_card_hash, id_card_last4, source_type, status, del_flag, user_id, create_by, create_time, remark) VALUES ('S101', '李强', '0', '1990-01-01', '沈阳房建公寓段沈阳车间', '18800000101', 's101@sict.railway.cn', '57ecc1436745329b620ccbfaebbed22cda4ba9b33ec44de468d2e3acd61f63c2', '1000', 1, 1, '0', 21, 'admin', CURRENT_TIMESTAMP, '演示学员样本1'), ('S102', '王芳', '1', '1991-02-02', '沈阳房建公寓段大连车间', '18800000102', 's102@sict.railway.cn', 'f6d29eac73f7ea39158b909683cc36f50a2ea9298be8a453bf8922488e499b29', '1001', 1, 1, '0', 22, 'admin', CURRENT_TIMESTAMP, '演示学员样本2'), @@ -124,7 +124,7 @@ VALUES ('S134', '吕梁', '1', '1991-10-06', '沈阳房建公寓段安全培训科', '18800000134', 's134@sict.railway.cn', '65a78d3839aef33f4059ea214e8faac94125c6c82cfa3001580fa208430b0ad3', '1033', 1, 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '演示学员样本34'), ('S135', '卢伟', '0', '1992-11-07', '沈阳房建公寓段技术科', '18800000135', 's135@sict.railway.cn', '2df5ae45334850de9e4ba87333f2388e2b71f5d75ead96fdd7c8734dd73f7147', '1034', 1, 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '演示学员样本35') ON CONFLICT (s_id) DO UPDATE SET - s_name = EXCLUDED.s_name, org_name = EXCLUDED.org_name, s_phone = EXCLUDED.s_phone, s_email = EXCLUDED.s_email, + s_name = EXCLUDED.s_name, dept_name = EXCLUDED.dept_name, s_phone = EXCLUDED.s_phone, s_email = EXCLUDED.s_email, id_card_hash = EXCLUDED.id_card_hash, id_card_last4 = EXCLUDED.id_card_last4, user_id = EXCLUDED.user_id, status = EXCLUDED.status, del_flag = '0'; COMMIT; diff --git a/RuoYi-Vue/sql/demo/005_demo_theory_exam_score.sql b/RuoYi-Vue/sql/demo/005_demo_theory_exam_score.sql new file mode 100644 index 000000000..605c7ca79 --- /dev/null +++ b/RuoYi-Vue/sql/demo/005_demo_theory_exam_score.sql @@ -0,0 +1,390 @@ +-- ============================================================================== +-- RailwaySystem 演示/测试数据: 005_demo_theory_exam_score.sql +-- 目标表: train_theory_exam_score(班级学员理论考试成绩表,先执行 026 建表脚本) +-- 数据库: MySQL 5.7+(脚本使用 INSERT ... SELECT / INSERT IGNORE / 派生表,5.7 完全兼容) +-- ============================================================================== +-- 一、依赖的既有数据(脚本不新建,全部从库中实时读取,保证外键与业务一致性) +-- train_student(s_id) —— 学员主档 +-- train_class_plan(plan_id) —— 开班计划 +-- train_plan_course(plan_course_id) —— 培训班所开课程实例(本脚本的核心关联依据) +-- train_enrollment(enrollment_id) —— 参训档案(学员 × 班级) +-- train_course(c_id) —— 课程主档(由 train_plan_course 带出) +-- train_exam_subject(subject_code) —— 科目字典(本脚本不写入,subject_code 置 NULL,避免无谓依赖) +-- train_score_item(score_item_id) —— 综合成绩项(本脚本不写入,score_item_id 置 NULL) +-- +-- 二、生成逻辑 +-- · 每一行 = 一个 (参训档案 enrollment_id, 班级课程 plan_course_id, 考试次数 retake_no) 组合, +-- 因此天然满足唯一键 uk_theory_enroll_course_retake; +-- · 同一学员同一班级若存在多条参训档案,取 MIN(enrollment_id) 去重, +-- 避免触发 uk_theory_final(每课程每学员仅一条最终成绩); +-- · 使用 ORDER BY + LIMIT/OFFSET 把"学员×课程"笛卡尔集切成互不重叠的区间, +-- 分别构造正常、满分、零分、缺考、免修、补考、锁定/归档等场景,区间之间不会撞唯一键; +-- · 使用 INSERT IGNORE,重复执行不会报错也不会产生重复数据; +-- · 若库中可用的"学员×课程"组合不足,靠后的场景会少插或插 0 行(不报错), +-- 执行前可先跑"第 0 步"的体检 SQL 查看可用组合数。 +-- +-- 三、字段写入约定 +-- enrollment_id / plan_id / student_id ← train_enrollment +-- plan_course_id / c_id ← train_plan_course +-- exam_start_time / exam_end_time ← NOW() 回溯 1~30 天,开始时间比结束早 90 分钟 +-- duration_seconds ← 5400(90 分钟) +-- retake / retake_no ← 正考 0/0;补考 1/1、1/2 +-- exam_status ← NORMAL 正常 / ABSENT 缺考 / EXEMPT 免修 +-- score ← 正常考试 0~满分;缺考/免修必须为 NULL(CHECK 约束要求) +-- full_score ← 100.00 +-- pass_flag ← 60 分及格(与后端 60% 默认判定一致) +-- is_final ← 正考/补考最终成绩为 1,过程性补考为 0 +-- score_status ← RECEIVED / ARCHIVED / LOCKED +-- source_type ← 1 手工录入 / 3 批量或外部导入(演示来源差异) +-- del_flag ← '0' +-- create_by ← 'test-data'(便于识别与清理) +-- ============================================================================== + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------------------------ +-- 第 0 步:体检(只读,不影响数据) +-- 查看当前可用的"学员 × 课程"组合数量,决定各场景实际能插入多少行 +-- ------------------------------------------------------------------------------ +SELECT COUNT(*) AS available_student_course_pairs +FROM ( + SELECT e.student_id, pc.plan_course_id + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id +) p; + +-- ============================================================================== +-- 场景 1:正常分布(正考 0 次补考、成绩 60~100、通过、最终成绩) +-- 覆盖区间:偏移 0 ~ 11,共 12 行 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0, + 'NORMAL', + CAST(60 + MOD(CRC32(CONCAT(t.enrollment_id, '-', t.plan_course_id)), 41) AS DECIMAL(7,2)), + 100.00, 1, 1, 'RECEIVED', + 1, NULL, NULL, NULL, '测试数据-正常通过(60~100分)', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 12 OFFSET 0 +) t; + +-- ============================================================================== +-- 场景 2:边界值-满分(score = full_score = 100.00) +-- 覆盖区间:偏移 12 ~ 15,共 4 行 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0, + 'NORMAL', CAST(100.00 AS DECIMAL(7,2)), 100.00, 1, 1, 'RECEIVED', + 1, NULL, NULL, NULL, '测试数据-边界值:满分100', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 4 OFFSET 12 +) t; + +-- ============================================================================== +-- 场景 3:边界值-零分(score = 0.00,未通过) +-- 覆盖区间:偏移 16 ~ 19,共 4 行 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0, + 'NORMAL', CAST(0.00 AS DECIMAL(7,2)), 100.00, 0, 1, 'RECEIVED', + 1, NULL, NULL, NULL, '测试数据-边界值:零分', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 4 OFFSET 16 +) t; + +-- ============================================================================== +-- 场景 4:缺考(ABSENT,score 必须为 NULL,pass_flag=0,仍作为该课程最终成绩) +-- 覆盖区间:偏移 20 ~ 23,共 4 行 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, NULL, 0, 0, + 'ABSENT', NULL, 100.00, 0, 1, 'RECEIVED', + 1, NULL, NULL, NULL, '测试数据-缺考(无成绩)', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 4 OFFSET 20 +) t; + +-- ============================================================================== +-- 场景 5:免修(EXEMPT,score 为 NULL,按通过计) +-- 覆盖区间:偏移 24 ~ 26,共 3 行 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + NULL, NULL, NULL, 0, 0, + 'EXEMPT', NULL, 100.00, 1, 1, 'ARCHIVED', + 1, NULL, NULL, NULL, '测试数据-免修(不计分,计通过)', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL 7 DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 3 OFFSET 24 +) t; + +-- ============================================================================== +-- 场景 6:补考过程记录(第 1 次补考,45 分未通过,is_final=0) +-- 覆盖区间:偏移 27 ~ 32,共 6 行;与场景 7 使用同一区间,形成"正考/补考"两次成绩 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 1, 1, + 'NORMAL', CAST(45.00 AS DECIMAL(7,2)), 100.00, 0, 0, 'RECEIVED', + 1, NULL, NULL, NULL, '测试数据-补考第1次未通过(非最终成绩)', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL 15 DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 6 OFFSET 27 +) t; + +-- ============================================================================== +-- 场景 7:补考后最终成绩(第 2 次补考,65~95 分通过,is_final=1) +-- 覆盖区间:与场景 6 相同(偏移 27 ~ 32),retake_no=2,因此不违反唯一键 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 4800, 1, 2, + 'NORMAL', + CAST(65 + MOD(CRC32(CONCAT('retake-', t.enrollment_id, '-', t.plan_course_id)), 31) AS DECIMAL(7,2)), + 100.00, 1, 1, 'RECEIVED', + 1, NULL, NULL, NULL, '测试数据-补考第2次通过(最终成绩)', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL 7 DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 6 OFFSET 27 +) t; + +-- ============================================================================== +-- 场景 8:已锁定成绩(LOCKED,禁止再改分数;来源为手工录入) +-- 覆盖区间:偏移 33 ~ 34,共 2 行 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0, + 'NORMAL', CAST(88.00 AS DECIMAL(7,2)), 100.00, 1, 1, 'LOCKED', + 1, NULL, NULL, NULL, '测试数据-已锁定成绩', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL 10 DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 2 OFFSET 33 +) t; + +-- ============================================================================== +-- 场景 9:外部批量导入 + 已归档(source_type=3,带导入幂等键) +-- 覆盖区间:偏移 35 ~ 36,共 2 行 +-- ============================================================================== +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL, + DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0, + 'NORMAL', CAST(72.50 AS DECIMAL(7,2)), 100.00, 1, 1, 'ARCHIVED', + 3, 'TMS-EXAM', CONCAT('IMP-', t.enrollment_id, '-', t.plan_course_id), NULL, + '测试数据-外部导入并归档', + '0', 'test-data', NOW() +FROM ( + SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id, + DATE_SUB(NOW(), INTERVAL 3 DAY) AS end_time + FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY student_id, plan_id) e + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_class_plan cp ON cp.plan_id = e.plan_id + ORDER BY e.enrollment_id, pc.plan_course_id + LIMIT 2 OFFSET 35 +) t; + +-- ============================================================================== +-- 第 10 步:结果校验(只读) +-- 10.1 场景分布统计 +-- 10.2 关联一致性检查(均应为 0 行) +-- 10.3 唯一性检查(均应为 0 行) +-- 10.4 分数与状态合法性检查(均应为 0 行) +-- ============================================================================== +-- 10.1 场景分布 +SELECT exam_status, score_status, retake, is_final, COUNT(*) AS row_count +FROM train_theory_exam_score +WHERE create_by = 'test-data' +GROUP BY exam_status, score_status, retake, is_final +ORDER BY exam_status, score_status, retake, is_final; + +-- 10.2 关联一致性:学员 / 班级计划 / 课程实例 / 课程主档 是否都能对上 +SELECT t.theory_score_id, '学员不存在' AS problem +FROM train_theory_exam_score t +LEFT JOIN train_student s ON s.s_id = t.student_id +WHERE t.create_by = 'test-data' AND s.s_id IS NULL +UNION ALL +SELECT t.theory_score_id, '班级计划不存在' +FROM train_theory_exam_score t +LEFT JOIN train_class_plan cp ON cp.plan_id = t.plan_id +WHERE t.create_by = 'test-data' AND cp.plan_id IS NULL +UNION ALL +SELECT t.theory_score_id, '课程实例不存在或与计划/课程不匹配' +FROM train_theory_exam_score t +LEFT JOIN train_plan_course pc + ON pc.plan_course_id = t.plan_course_id + AND pc.plan_id = t.plan_id + AND pc.c_id = t.c_id +WHERE t.create_by = 'test-data' AND pc.plan_course_id IS NULL +UNION ALL +SELECT t.theory_score_id, '参训档案与学员/班级不匹配' +FROM train_theory_exam_score t +LEFT JOIN train_enrollment e + ON e.enrollment_id = t.enrollment_id + AND e.student_id = t.student_id + AND e.plan_id = t.plan_id +WHERE t.create_by = 'test-data' AND e.enrollment_id IS NULL; + +-- 10.3 唯一性:同一参训档案 + 课程 + 考试次数重复 +SELECT enrollment_id, plan_course_id, retake_no, COUNT(*) AS cnt +FROM train_theory_exam_score +WHERE del_flag = '0' +GROUP BY enrollment_id, plan_course_id, retake_no +HAVING COUNT(*) > 1; + +-- 10.3.1 唯一性:同一课程同一学员出现多条最终成绩 +SELECT plan_course_id, student_id, COUNT(*) AS final_cnt +FROM train_theory_exam_score +WHERE del_flag = '0' AND is_final = 1 +GROUP BY plan_course_id, student_id +HAVING COUNT(*) > 1; + +-- 10.4 合法性:缺考/免修携带分数、正常考试无分数、分数越界 +SELECT theory_score_id, exam_status, score, full_score, '分数状态不匹配' AS problem +FROM train_theory_exam_score +WHERE create_by = 'test-data' + AND ( + (exam_status = 'NORMAL' AND score IS NULL) + OR (exam_status IN ('ABSENT', 'EXEMPT') AND score IS NOT NULL) + OR (score IS NOT NULL AND (score < 0 OR score > full_score)) + ); + +-- ============================================================================== +-- 清理(如需删除本脚本产生的全部数据,取消注释后执行) +-- 注意:逻辑删除会释放 uk_theory_final 的占用(生成列 final_flag 随 del_flag 变为 NULL) +-- ============================================================================== +-- DELETE FROM train_theory_exam_score WHERE create_by = 'test-data'; + +-- ============================================================================== +-- 附:无法通过 DB 约束落库的"异常场景"(仅作验证说明,脚本故意不插入) +-- 1) 重复录入:(enrollment_id, plan_course_id, retake_no) 重复 +-- → 被 uk_theory_enroll_course_retake 拦截(INSERT IGNORE 也会被忽略) +-- 2) 同一课程同一学员两条 is_final=1 +-- → 被 uk_theory_final(生成列 final_flag)拦截 +-- 3) 缺考/免修携带分数,或正常考试分数为空 +-- → 被 chk_theory_score_filled / chk_theory_score_range 拦截(MySQL 8.0 强制,5.7 由应用层校验) +-- 4) 考试结束时间早于开始时间 +-- → 被 chk_theory_exam_time 拦截 +-- 5) 引用不存在的学员/课程 +-- → 被外键 fk_theory_student / fk_theory_plan_course 拦截 +-- ============================================================================== diff --git a/RuoYi-Vue/sql/demo/006_demo_theory_score_test01.sql b/RuoYi-Vue/sql/demo/006_demo_theory_score_test01.sql new file mode 100644 index 000000000..41c6a35b4 --- /dev/null +++ b/RuoYi-Vue/sql/demo/006_demo_theory_score_test01.sql @@ -0,0 +1,359 @@ +-- ============================================================================== +-- RailwaySystem 演示数据: 006_demo_theory_score_test01.sql(关系型重建版) +-- 用途: 为“测试01班”构建一套完整的班级—学员—选课—理论成绩关联模型,并填充演示数据 +-- 数据库: MySQL 5.7+(无 CTE / 窗口函数 / CHECK 约束;全部为普通表 + 外键,5.7 完全兼容) +-- 字符集: utf8mb4 / utf8mb4_general_ci(支持中文班级名与姓名) +-- +-- 数据模型(7 张表,演示专用,前缀 demo_,不触碰正式业务表 train_*): +-- +-- demo_class_plan 开班计划(1) ──┬── demo_plan_course 计划开设课程(N) +-- │ │ +-- demo_course 课程字典(1) ──┘ │ +-- │ │ +-- demo_student 学生主档(1) ── demo_class_member 班级成员(N) [计划 × 学生] +-- │ +-- ├── demo_student_course 学生选课 [计划 × 学生 × 课程] +-- │ │ +-- └── demo_theory_score 理论成绩 +-- [计划 × 学生 × 课程 × 考试类型] +-- +-- 关联一致性由数据库外键强制保证: +-- 1) 班级成员必须属于已存在的开班计划与已存在的学生; +-- 2) 学生选课必须是“该班成员 + 该班已开设课程”的交集(双外键); +-- 3) 理论成绩必须落在学生已选的课程上(外键指向 demo_student_course), +-- 因此不可能出现“成绩挂在未选课/非本班/未开课程上”的脏数据。 +-- +-- 数据规模:1 个开班计划、3 门课程、24 名学生、24 条班级成员、 +-- 39 条选课记录、39 条正考成绩 + 2 条补考 = 41 条成绩 +-- 边界/异常:满分 100、零分 0、及格线 60、59.5 差 0.5 分、缺考(成绩 NULL)、补考提升 +-- +-- 可重复执行:脚本开头统一 DROP(仅 demo_ 前缀表),如需保留请注释 DROP 段 +-- ============================================================================== + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------------------------ +-- 0. 清理旧演示表(按外键依赖倒序) +-- ------------------------------------------------------------------------------ +SET FOREIGN_KEY_CHECKS = 0; +DROP TABLE IF EXISTS `demo_theory_score`; +DROP TABLE IF EXISTS `demo_student_course`; +DROP TABLE IF EXISTS `demo_class_member`; +DROP TABLE IF EXISTS `demo_student`; +DROP TABLE IF EXISTS `demo_plan_course`; +DROP TABLE IF EXISTS `demo_course`; +DROP TABLE IF EXISTS `demo_class_plan`; +SET FOREIGN_KEY_CHECKS = 1; + +-- ------------------------------------------------------------------------------ +-- 1. 开班计划 +-- ------------------------------------------------------------------------------ +CREATE TABLE `demo_class_plan` ( + `plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID', + `plan_name` VARCHAR(150) NOT NULL COMMENT '开班计划名称', + `class_name` VARCHAR(50) NOT NULL COMMENT '班级名称', + `start_time` DATETIME NOT NULL COMMENT '开班时间', + `end_time` DATETIME NOT NULL COMMENT '结班时间', + `plan_status` TINYINT NOT NULL DEFAULT 1 COMMENT '计划状态(1进行中 0已结束)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`plan_id`), + UNIQUE KEY `uk_plan_class_name` (`class_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='开班计划演示表'; + +INSERT INTO `demo_class_plan` (`plan_id`, `plan_name`, `class_name`, `start_time`, `end_time`, `plan_status`) VALUES +('PLAN-TEST01', '2026年接触网安全理论培训班(测试01班)', '测试01班', '2026-10-08 08:30:00', '2026-12-31 18:00:00', 1); + +-- ------------------------------------------------------------------------------ +-- 2. 课程字典 +-- ------------------------------------------------------------------------------ +CREATE TABLE `demo_course` ( + `c_id` VARCHAR(32) NOT NULL COMMENT '课程ID', + `c_name` VARCHAR(100) NOT NULL COMMENT '课程名称', + `c_type` VARCHAR(30) DEFAULT NULL COMMENT '课程类型', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`c_id`), + UNIQUE KEY `uk_course_name` (`c_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='课程字典演示表'; + +INSERT INTO `demo_course` (`c_id`, `c_name`, `c_type`) VALUES +('C01', '接触网安全理论', '理论'), +('C02', '铁路电力安全规程', '理论'), +('C03', '应急处置与救援', '理论'); + +-- ------------------------------------------------------------------------------ +-- 3. 开班计划课程(计划开设了哪些课) +-- ------------------------------------------------------------------------------ +CREATE TABLE `demo_plan_course` ( + `plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID', + `c_id` VARCHAR(32) NOT NULL COMMENT '课程ID', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排课顺序', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`plan_id`, `c_id`), + KEY `idx_plan_course_course` (`c_id`), + CONSTRAINT `fk_plan_course_plan` FOREIGN KEY (`plan_id`) REFERENCES `demo_class_plan` (`plan_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_plan_course_course` FOREIGN KEY (`c_id`) REFERENCES `demo_course` (`c_id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='开班计划课程演示表'; + +INSERT INTO `demo_plan_course` (`plan_id`, `c_id`, `sort_no`) VALUES +('PLAN-TEST01', 'C01', 1), +('PLAN-TEST01', 'C02', 2), +('PLAN-TEST01', 'C03', 3); + +-- ------------------------------------------------------------------------------ +-- 4. 学生主档(学号 + 姓名) +-- ------------------------------------------------------------------------------ +CREATE TABLE `demo_student` ( + `student_no` VARCHAR(32) NOT NULL COMMENT '学号', + `student_name` VARCHAR(50) NOT NULL COMMENT '姓名', + `gender` VARCHAR(4) DEFAULT NULL COMMENT '性别', + `dept_name` VARCHAR(100) DEFAULT NULL COMMENT '所属单位', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`student_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='学生主档演示表'; + +INSERT INTO `demo_student` (`student_no`, `student_name`, `gender`, `dept_name`) VALUES +('TEST01-001', '张伟', '男', '供电段'), +('TEST01-002', '王芳', '女', '供电段'), +('TEST01-003', '李强', '男', '供电段'), +('TEST01-004', '刘洋', '男', '电务段'), +('TEST01-005', '陈静', '女', '电务段'), +('TEST01-006', '杨帆', '男', '电务段'), +('TEST01-007', '赵敏', '女', '工务段'), +('TEST01-008', '黄磊', '男', '工务段'), +('TEST01-009', '周涛', '男', '工务段'), +('TEST01-010', '吴倩', '女', '供电段'), +('TEST01-011', '徐鹏', '男', '供电段'), +('TEST01-012', '孙丽', '女', '供电段'), +('TEST01-013', '马超', '男', '电务段'), +('TEST01-014', '朱婷', '女', '电务段'), +('TEST01-015', '胡军', '男', '电务段'), +('TEST01-016', '郭鹏', '男', '工务段'), +('TEST01-017', '何梅', '女', '工务段'), +('TEST01-018', '高翔', '男', '工务段'), +('TEST01-019', '林芳', '女', '供电段'), +('TEST01-020', '罗文', '男', '供电段'), +('TEST01-021', '梁静', '女', '供电段'), +('TEST01-022', '宋波', '男', '电务段'), +('TEST01-023', '谢娜', '女', '电务段'), +('TEST01-024', '韩雪', '女', '工务段'); + +-- ------------------------------------------------------------------------------ +-- 5. 班级成员(计划 × 学生,明确“谁在这个班”) +-- ------------------------------------------------------------------------------ +CREATE TABLE `demo_class_member` ( + `plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID', + `student_no` VARCHAR(32) NOT NULL COMMENT '学号', + `join_time` DATETIME NOT NULL COMMENT '入班时间', + `member_status` TINYINT NOT NULL DEFAULT 1 COMMENT '成员状态(1在班 0已退班)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`plan_id`, `student_no`), + KEY `idx_member_student` (`student_no`), + CONSTRAINT `fk_member_plan` FOREIGN KEY (`plan_id`) REFERENCES `demo_class_plan` (`plan_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_member_student` FOREIGN KEY (`student_no`) REFERENCES `demo_student` (`student_no`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='班级成员演示表'; + +INSERT INTO `demo_class_member` (`plan_id`, `student_no`, `join_time`, `member_status`) +SELECT 'PLAN-TEST01', `student_no`, '2026-10-08 09:00:00', 1 +FROM `demo_student`; + +-- ------------------------------------------------------------------------------ +-- 6. 学生选课(计划 × 学生 × 课程;必须同时是班级成员且课程为计划已开设课程) +-- 选课规则:全部 24 人必选 C01;002~013 选 C02;003/009/015 选 C03(共 39 条) +-- ------------------------------------------------------------------------------ +CREATE TABLE `demo_student_course` ( + `plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID', + `student_no` VARCHAR(32) NOT NULL COMMENT '学号', + `c_id` VARCHAR(32) NOT NULL COMMENT '课程ID', + `select_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', + PRIMARY KEY (`plan_id`, `student_no`, `c_id`), + KEY `idx_sc_course` (`plan_id`, `c_id`), + CONSTRAINT `fk_sc_member` FOREIGN KEY (`plan_id`, `student_no`) REFERENCES `demo_class_member` (`plan_id`, `student_no`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_sc_plan_course` FOREIGN KEY (`plan_id`, `c_id`) REFERENCES `demo_plan_course` (`plan_id`, `c_id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='学生选课演示表'; + +-- 6.1 全员必选 C01 +INSERT INTO `demo_student_course` (`plan_id`, `student_no`, `c_id`) +SELECT 'PLAN-TEST01', `student_no`, 'C01' +FROM `demo_student`; + +-- 6.2 002~013 加选 C02 +INSERT INTO `demo_student_course` (`plan_id`, `student_no`, `c_id`) +SELECT 'PLAN-TEST01', `student_no`, 'C02' +FROM `demo_student` +WHERE `student_no` BETWEEN 'TEST01-002' AND 'TEST01-013'; + +-- 6.3 003/009/015 加选 C03 +INSERT INTO `demo_student_course` (`plan_id`, `student_no`, `c_id`) VALUES +('PLAN-TEST01', 'TEST01-003', 'C03'), +('PLAN-TEST01', 'TEST01-009', 'C03'), +('PLAN-TEST01', 'TEST01-015', 'C03'); + +-- ------------------------------------------------------------------------------ +-- 7. 理论成绩(计划 × 学生 × 课程 × 考试类型) +-- 外键指向选课表,确保“成绩只能落在已选课程上”;唯一键防重复录入 +-- ------------------------------------------------------------------------------ +CREATE TABLE `demo_theory_score` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID', + `student_no` VARCHAR(32) NOT NULL COMMENT '学号', + `c_id` VARCHAR(32) NOT NULL COMMENT '课程ID', + `exam_type` VARCHAR(16) NOT NULL DEFAULT '正考' COMMENT '考试类型(正考/补考)', + `exam_status` VARCHAR(16) NOT NULL DEFAULT '正常' COMMENT '考试状态(正常/缺考/免修)', + `score` DECIMAL(5,2) DEFAULT NULL COMMENT '理论成绩(0-100,缺考为NULL)', + `full_score` DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '满分', + `pass_flag` TINYINT NOT NULL DEFAULT 0 COMMENT '是否通过(1通过 0未通过)', + `exam_time` DATETIME DEFAULT NULL COMMENT '考试时间', + `remark` VARCHAR(200) DEFAULT NULL COMMENT '备注(边界/异常样本)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_score_member_course_exam` (`plan_id`, `student_no`, `c_id`, `exam_type`), + KEY `idx_score_student` (`student_no`), + KEY `idx_score_course` (`plan_id`, `c_id`), + CONSTRAINT `fk_score_student_course` FOREIGN KEY (`plan_id`, `student_no`, `c_id`) + REFERENCES `demo_student_course` (`plan_id`, `student_no`, `c_id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='学生理论成绩演示表(测试01班)'; + +-- 7.1 正考成绩:按“学生 × 已选课程”自动生成,保证与选课/班级/计划完全一致 +-- 分数规则:001=100(满分)、013=0(零分)、019=59.5(差0.5分)、021=60(及格线)、 +-- 024 的 C01 为缺考(NULL),其余 45~100 之间按学号+课程确定性分布 +INSERT INTO `demo_theory_score` +(`plan_id`, `student_no`, `c_id`, `exam_type`, `exam_status`, `score`, `full_score`, `pass_flag`, `exam_time`, `remark`) +SELECT t.plan_id, + t.student_no, + t.c_id, + '正考', + t.exam_status, + t.score, + 100.00, + CASE WHEN t.score IS NOT NULL AND t.score >= 60 THEN 1 ELSE 0 END, + CASE t.c_id + WHEN 'C01' THEN '2026-10-20 09:00:00' + WHEN 'C02' THEN '2026-10-22 09:00:00' + ELSE '2026-10-24 14:00:00' + END, + t.remark +FROM ( + SELECT sc.plan_id, + sc.student_no, + sc.c_id, + CASE + WHEN sc.student_no = 'TEST01-024' AND sc.c_id = 'C01' THEN NULL + WHEN sc.student_no = 'TEST01-001' THEN 100.00 + WHEN sc.student_no = 'TEST01-013' THEN 0.00 + WHEN sc.student_no = 'TEST01-019' THEN 59.50 + WHEN sc.student_no = 'TEST01-021' THEN 60.00 + ELSE CAST(45 + MOD(CRC32(CONCAT(sc.student_no, '-', sc.c_id)), 56) AS DECIMAL(5,2)) + END AS score, + CASE + WHEN sc.student_no = 'TEST01-024' AND sc.c_id = 'C01' THEN '缺考' + ELSE '正常' + END AS exam_status, + CASE + WHEN sc.student_no = 'TEST01-001' THEN '边界值:满分' + WHEN sc.student_no = 'TEST01-013' THEN '边界值:零分' + WHEN sc.student_no = 'TEST01-019' THEN '边界值:差0.5分未通过' + WHEN sc.student_no = 'TEST01-021' THEN '边界值:及格线' + WHEN sc.student_no = 'TEST01-024' AND sc.c_id = 'C01' THEN '异常样本:缺考无成绩' + ELSE NULL + END AS remark + FROM `demo_student_course` sc +) t; + +-- 7.2 补考成绩:019、022 的 C01 正考未通过后补考通过(与正考类型不同,不违反唯一键) +INSERT INTO `demo_theory_score` +(`plan_id`, `student_no`, `c_id`, `exam_type`, `exam_status`, `score`, `full_score`, `pass_flag`, `exam_time`, `remark`) +VALUES +('PLAN-TEST01', 'TEST01-019', 'C01', '补考', '正常', 72.00, 100.00, 1, '2026-11-03 14:00:00', '异常样本:补考成绩提升'), +('PLAN-TEST01', 'TEST01-022', 'C01', '补考', '正常', 78.00, 100.00, 1, '2026-11-03 14:00:00', '异常样本:正考未通过后补考通过'); + +-- ============================================================================== +-- 8. 结果校验(只读) +-- ============================================================================== + +-- 8.1 数据规模总览 +SELECT '开班计划' AS table_name, COUNT(*) AS row_count FROM `demo_class_plan` +UNION ALL SELECT '课程', COUNT(*) FROM `demo_course` +UNION ALL SELECT '计划课程', COUNT(*) FROM `demo_plan_course` +UNION ALL SELECT '学生', COUNT(*) FROM `demo_student` +UNION ALL SELECT '班级成员', COUNT(*) FROM `demo_class_member` +UNION ALL SELECT '学生选课', COUNT(*) FROM `demo_student_course` +UNION ALL SELECT '理论成绩', COUNT(*) FROM `demo_theory_score`; + +-- 8.2 班级成员与选课明细(含学生姓名、课程名、班级名) +SELECT p.class_name, m.student_no, s.student_name, c.c_name, sc.select_time +FROM `demo_class_member` m +JOIN `demo_class_plan` p ON p.plan_id = m.plan_id +JOIN `demo_student` s ON s.student_no = m.student_no +JOIN `demo_student_course` sc ON sc.plan_id = m.plan_id AND sc.student_no = m.student_no +JOIN `demo_course` c ON c.c_id = sc.c_id +ORDER BY m.student_no, c.c_id; + +-- 8.3 学生成绩明细(正考/补考、含班级与课程) +SELECT p.class_name, s.student_no, s.student_name, c.c_name, + t.exam_type, t.exam_status, t.score, t.full_score, t.pass_flag, t.exam_time, t.remark +FROM `demo_theory_score` t +JOIN `demo_student` s ON s.student_no = t.student_no +JOIN `demo_course` c ON c.c_id = t.c_id +JOIN `demo_class_plan` p ON p.plan_id = t.plan_id +ORDER BY t.student_no, t.c_id, t.exam_type; + +-- 8.4 课程维度统计:参考人数、平均分、最高分、最低分、及格率 +SELECT c.c_name, + COUNT(t.score) AS scored_count, + ROUND(AVG(t.score), 2) AS avg_score, + MAX(t.score) AS max_score, + MIN(t.score) AS min_score, + ROUND(SUM(t.pass_flag) / COUNT(*) * 100, 2) AS pass_rate_percent +FROM `demo_theory_score` t +JOIN `demo_course` c ON c.c_id = t.c_id +WHERE t.exam_type = '正考' +GROUP BY c.c_name; + +-- 8.5 关联一致性自检(三项均应返回 0 行) +-- a) 成绩存在、但对应选课关系不存在(外键已保护,此处为双重验证) +SELECT t.id, '成绩缺少选课关系' AS problem +FROM `demo_theory_score` t +LEFT JOIN `demo_student_course` sc + ON sc.plan_id = t.plan_id AND sc.student_no = t.student_no AND sc.c_id = t.c_id +WHERE sc.student_no IS NULL +-- b) 班级成员不在学生主档 / 不在开班计划 +UNION ALL +SELECT m.student_no, '班级成员缺少学生或计划' +FROM `demo_class_member` m +LEFT JOIN `demo_student` s ON s.student_no = m.student_no +LEFT JOIN `demo_class_plan` p ON p.plan_id = m.plan_id +WHERE s.student_no IS NULL OR p.plan_id IS NULL +-- c) 选课不是本班已开设课程 +UNION ALL +SELECT sc.student_no, '选课不在计划课程内' +FROM `demo_student_course` sc +LEFT JOIN `demo_plan_course` pc ON pc.plan_id = sc.plan_id AND pc.c_id = sc.c_id +WHERE pc.c_id IS NULL; + +-- 8.6 成绩合法性自检(应返回 0 行) +SELECT id, student_no, c_id, exam_type, exam_status, score, full_score, pass_flag +FROM `demo_theory_score` +WHERE (score IS NOT NULL AND (score < 0 OR score > 100 OR score > full_score)) + OR (exam_status = '正常' AND score IS NULL) + OR (score IS NOT NULL AND pass_flag <> IF(score >= 60, 1, 0)); + +-- ------------------------------------------------------------------------------ +-- 9. 清理(如需删除全部演示数据,取消注释执行;按依赖倒序删除) +-- ------------------------------------------------------------------------------ +-- SET FOREIGN_KEY_CHECKS = 0; +-- DROP TABLE IF EXISTS `demo_theory_score`; +-- DROP TABLE IF EXISTS `demo_student_course`; +-- DROP TABLE IF EXISTS `demo_class_member`; +-- DROP TABLE IF EXISTS `demo_student`; +-- DROP TABLE IF EXISTS `demo_plan_course`; +-- DROP TABLE IF EXISTS `demo_course`; +-- DROP TABLE IF EXISTS `demo_class_plan`; +-- SET FOREIGN_KEY_CHECKS = 1; diff --git a/RuoYi-Vue/sql/demo/007_demo_theory_score_kbjh2026_11.sql b/RuoYi-Vue/sql/demo/007_demo_theory_score_kbjh2026_11.sql new file mode 100644 index 000000000..8f4d50b9a --- /dev/null +++ b/RuoYi-Vue/sql/demo/007_demo_theory_score_kbjh2026_11.sql @@ -0,0 +1,330 @@ +-- ============================================================================== +-- RailwaySystem 测试数据: 007_demo_theory_score_kbjh2026_11.sql +-- 目标表: train_theory_exam_score(班级学员理论考试成绩表,需先执行 026 建表脚本) +-- 指定班级: 开班计划名称 = 'KBJH2026-11现场测试开班' +-- 数据库: MySQL 5.7+(仅使用派生表 / 临时表 / INSERT...SELECT / INSERT IGNORE,未用 CTE、窗口函数) +-- ============================================================================== +-- 一、依赖表结构与字段说明(均需已存在,本脚本不建业务表) +-- ------------------------------------------------------------------------------ +-- 1) train_student(学员主档,本脚本只读) +-- s_id VARCHAR(32) PK 学员ID +-- s_name VARCHAR(50) 姓名(仅用于结果核对,不参与写入) +-- del_flag CHAR(1) 删除标志(0存在;若该列不存在请忽略相关注释) +-- +-- 2) train_class_plan(开班计划,本脚本只读) +-- plan_id VARCHAR(32) PK 开班计划ID +-- plan_name VARCHAR(150) 计划名称 —— 本脚本按 'KBJH2026-11现场测试开班' 过滤 +-- +-- 3) train_plan_course(班级开设课程,本脚本只读) +-- plan_course_id BIGINT PK 培训课程实例ID(成绩表的课程维度键) +-- plan_id VARCHAR(32) 所属开班计划 +-- c_id VARCHAR(32) 课程主档ID +-- UNIQUE (plan_id, c_id) +-- +-- 4) train_enrollment(参训档案,本脚本只读) +-- enrollment_id BIGINT PK 参训档案ID(学员 × 班级唯一事实) +-- plan_id VARCHAR(32) 开班计划ID +-- student_id VARCHAR(32) 学员ID +-- —— 无 del_flag 列 +-- +-- 5) train_theory_exam_score(写入目标,字段含义见 026 脚本) +-- enrollment_id / plan_id / plan_course_id / c_id / student_id —— 维度 +-- exam_start_time / exam_end_time / duration_seconds —— 考试过程 +-- retake / retake_no —— 正考(0/0) / 补考(1/1..n) +-- exam_status (NORMAL/ABSENT/EXEMPT) —— 考试状态 +-- score DECIMAL(7,2) NULL / full_score / pass_flag —— 成绩 +-- is_final (1/0) —— 是否该课程最终成绩 +-- score_status (RECEIVED/ARCHIVED/LOCKED) —— 成绩状态 +-- source_type (1/2/3) / source_system / source_event_id —— 来源 +-- del_flag ('0'/'2') +-- +-- 二、生成逻辑 +-- · 事实源 = 该计划下“学员 × 该班已开设课程”的笛卡尔组合: +-- train_enrollment (按 plan_id + student_id 取 MIN(enrollment_id) 去重) +-- JOIN train_class_plan (plan_name = 'KBJH2026-11现场测试开班') +-- JOIN train_plan_course (pc.plan_id = e.plan_id) +-- JOIN train_student (保证学员主档存在) +-- 由此保证 student_id / plan_id / plan_course_id / c_id 四者同源一致; +-- · 组合先落到临时表 tmp_pair,避免重复计算,也保证同一组合只生成一次; +-- · 再从 tmp_pair 中按 (student_id, plan_course_id) 升序取前 8 个组合, +-- 分别打上 FULL / ZERO / ABSENT / PASS_LINE / NEAR_LINE / EXEMPT / RETAKE1 / RETAKE2 场景标签, +-- 确保边界与异常样本**一定存在**(组合数 ≥ 8 时全部覆盖,不足时自动降级); +-- · 其余组合按成绩 45~100 的确定性分布生成(CRC32(学号+课程) 取模); +-- · 补考:对 RETAKE1 / RETAKE2 两个组合额外生成一条补考记录(retake=1, retake_no=1, is_final=1), +-- 其正考记录 is_final=0,既展示“补考后成为最终成绩”,又不违反 uk_theory_final; +-- · 全程 INSERT IGNORE:重复执行不报错、不产生重复数据(不会掩盖外键错误)。 +-- +-- 三、数据量 +-- 正考行数 = 该计划“学员数 × 开设课程数”(本脚本生成全部组合) +-- 补考行数 = 2(RETAKE1、RETAKE2 两个组合) +-- 例如 20 名学员 × 3 门课程 → 60 条正考 + 2 条补考 = 62 行 +-- 若该计划不存在或无学员/无课程,则插入 0 行(第 0 步的体检查询可确认) +-- ============================================================================== + +SET NAMES utf8mb4; + +-- 目标班级(如需换班,只改这一行) +SET @plan_name = 'KBJH2026-11现场测试开班'; + +-- ------------------------------------------------------------------------------ +-- 第 0 步:体检(只读) +-- 0.1 计划是否存在 +-- 0.2 该计划的学员数 / 课程数 / 可生成组合数 +-- ------------------------------------------------------------------------------ +SELECT plan_id, plan_name, class_name, start_time, end_time, plan_status +FROM train_class_plan +WHERE plan_name = @plan_name; + +SELECT COUNT(*) AS available_pairs +FROM ( + SELECT e.plan_id, e.student_id, pc.plan_course_id + FROM (SELECT plan_id, student_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY plan_id, student_id) e + JOIN train_class_plan cp ON cp.plan_id = e.plan_id AND cp.plan_name = @plan_name + JOIN train_plan_course pc ON pc.plan_id = e.plan_id + JOIN train_student s ON s.s_id = e.student_id +) p; + +-- ------------------------------------------------------------------------------ +-- 第 1 步:构建“学员 × 课程”组合临时表 +-- ------------------------------------------------------------------------------ +DROP TEMPORARY TABLE IF EXISTS tmp_pair; +CREATE TEMPORARY TABLE tmp_pair ( + enrollment_id BIGINT NOT NULL, + plan_id VARCHAR(32) NOT NULL, + student_id VARCHAR(32) NOT NULL, + plan_course_id BIGINT NOT NULL, + c_id VARCHAR(32) NOT NULL, + PRIMARY KEY (plan_course_id, student_id), + KEY idx_tmp_pair_student (student_id) +) ENGINE=InnoDB; + +INSERT INTO tmp_pair (enrollment_id, plan_id, student_id, plan_course_id, c_id) +SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id +FROM (SELECT plan_id, student_id, MIN(enrollment_id) AS enrollment_id + FROM train_enrollment + GROUP BY plan_id, student_id) e +JOIN train_class_plan cp ON cp.plan_id = e.plan_id AND cp.plan_name = @plan_name +JOIN train_plan_course pc ON pc.plan_id = e.plan_id +JOIN train_course c ON c.c_id = pc.c_id +JOIN train_student s ON s.s_id = e.student_id; + +-- ------------------------------------------------------------------------------ +-- 第 2 步:为前 8 个组合打场景标签(边界 / 异常样本) +-- FULL 满分 100 +-- ZERO 零分 0 +-- ABSENT 缺考(score = NULL) +-- PASS_LINE 及格线 60 +-- NEAR_LINE 59.5(差 0.5 分未通过) +-- EXEMPT 免修(score = NULL,计通过) +-- RETAKE1 正考 45 分未通过,后续补考 72 分通过 +-- RETAKE2 正考 50 分未通过,后续补考 78 分通过 +-- ------------------------------------------------------------------------------ +DROP TEMPORARY TABLE IF EXISTS tmp_seed; +CREATE TEMPORARY TABLE tmp_seed ( + student_id VARCHAR(32) NOT NULL, + plan_course_id BIGINT NOT NULL, + scenario VARCHAR(16) NOT NULL, + PRIMARY KEY (student_id, plan_course_id) +) ENGINE=InnoDB; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'FULL' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 0; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'ZERO' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 1; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'ABSENT' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 2; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'PASS_LINE' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 3; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'NEAR_LINE' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 4; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'EXEMPT' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 5; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'RETAKE1' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 6; + +INSERT INTO tmp_seed (student_id, plan_course_id, scenario) +SELECT student_id, plan_course_id, 'RETAKE2' +FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 7; + +-- ------------------------------------------------------------------------------ +-- 第 3 步:生成正考成绩(覆盖该计划全部“学员 × 课程”组合) +-- RETAKE1 / RETAKE2 组合的 is_final = 0(其最终成绩由补考记录承担) +-- ------------------------------------------------------------------------------ +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT x.enrollment_id, x.plan_id, x.plan_course_id, x.c_id, x.student_id, NULL, + DATE_SUB(x.end_time, INTERVAL 90 MINUTE), x.end_time, 5400, 0, 0, + x.exam_status, x.score, 100.00, + CASE + WHEN x.scenario = 'EXEMPT' THEN 1 -- 免修不计分,按通过计 + WHEN x.score IS NOT NULL AND x.score >= 60 THEN 1 + ELSE 0 + END, + CASE WHEN x.scenario IN ('RETAKE1', 'RETAKE2') THEN 0 ELSE 1 END, + 'RECEIVED', + 1, NULL, NULL, NULL, + CASE x.scenario + WHEN 'FULL' THEN '测试数据-边界值:满分100' + WHEN 'ZERO' THEN '测试数据-边界值:零分0' + WHEN 'ABSENT' THEN '测试数据-异常:缺考无成绩' + WHEN 'PASS_LINE' THEN '测试数据-边界值:及格线60' + WHEN 'NEAR_LINE' THEN '测试数据-边界值:59.5差0.5分未通过' + WHEN 'EXEMPT' THEN '测试数据-异常:免修不计分' + WHEN 'RETAKE1' THEN '测试数据-异常:正考45分未通过(待补考)' + WHEN 'RETAKE2' THEN '测试数据-异常:正考50分未通过(待补考)' + ELSE NULL + END, + '0', 'test-data', NOW() +FROM ( + SELECT p.enrollment_id, p.plan_id, p.plan_course_id, p.c_id, p.student_id, + COALESCE(sd.scenario, 'NORMAL') AS scenario, + CASE COALESCE(sd.scenario, 'NORMAL') + WHEN 'FULL' THEN CAST(100.00 AS DECIMAL(7,2)) + WHEN 'ZERO' THEN CAST(0.00 AS DECIMAL(7,2)) + WHEN 'ABSENT' THEN NULL + WHEN 'EXEMPT' THEN NULL + WHEN 'PASS_LINE' THEN CAST(60.00 AS DECIMAL(7,2)) + WHEN 'NEAR_LINE' THEN CAST(59.50 AS DECIMAL(7,2)) + WHEN 'RETAKE1' THEN CAST(45.00 AS DECIMAL(7,2)) + WHEN 'RETAKE2' THEN CAST(50.00 AS DECIMAL(7,2)) + ELSE CAST(45 + MOD(CRC32(CONCAT(p.student_id, '-', p.plan_course_id)), 56) AS DECIMAL(7,2)) + END AS score, + CASE WHEN COALESCE(sd.scenario, 'NORMAL') IN ('ABSENT', 'EXEMPT') THEN + CASE WHEN sd.scenario = 'EXEMPT' THEN 'EXEMPT' ELSE 'ABSENT' END + ELSE 'NORMAL' + END AS exam_status, + DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(p.student_id, '-', p.plan_course_id)), 20) + 1) DAY) AS end_time + FROM tmp_pair p + LEFT JOIN tmp_seed sd + ON sd.student_id = p.student_id AND sd.plan_course_id = p.plan_course_id +) x; + +-- ------------------------------------------------------------------------------ +-- 第 4 步:生成补考成绩(RETAKE1 → 72 分,RETAKE2 → 78 分,均为最终成绩) +-- ------------------------------------------------------------------------------ +INSERT IGNORE INTO train_theory_exam_score +(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code, + exam_start_time, exam_end_time, duration_seconds, retake, retake_no, + exam_status, score, full_score, pass_flag, is_final, score_status, + source_type, source_system, source_event_id, score_item_id, remark, + del_flag, create_by, create_time) +SELECT p.enrollment_id, p.plan_id, p.plan_course_id, p.c_id, p.student_id, NULL, + DATE_SUB(NOW(), INTERVAL 3 DAY), + DATE_ADD(DATE_SUB(NOW(), INTERVAL 3 DAY), INTERVAL 90 MINUTE), + 5400, 1, 1, + 'NORMAL', + CASE sd.scenario WHEN 'RETAKE1' THEN CAST(72.00 AS DECIMAL(7,2)) ELSE CAST(78.00 AS DECIMAL(7,2)) END, + 100.00, 1, 1, 'RECEIVED', + 1, NULL, NULL, NULL, '测试数据-异常:补考通过(最终成绩)', + '0', 'test-data', NOW() +FROM tmp_pair p +JOIN tmp_seed sd + ON sd.student_id = p.student_id AND sd.plan_course_id = p.plan_course_id +WHERE sd.scenario IN ('RETAKE1', 'RETAKE2'); + +-- ============================================================================== +-- 第 5 步:结果校验(只读) +-- ============================================================================== +-- 5.1 该计划成绩总量与场景分布 +SELECT t.exam_status, t.retake, t.is_final, COUNT(*) AS row_count +FROM train_theory_exam_score t +JOIN train_class_plan cp ON cp.plan_id = t.plan_id +WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data' +GROUP BY t.exam_status, t.retake, t.is_final +ORDER BY t.exam_status, t.retake, t.is_final; + +-- 5.2 成绩明细(含学员姓名、课程名、计划名) +SELECT cp.plan_name, s.s_id, s.s_name, c.c_name, + t.retake, t.retake_no, t.exam_status, t.score, t.full_score, t.pass_flag, + t.is_final, t.exam_end_time, t.remark +FROM train_theory_exam_score t +JOIN train_class_plan cp ON cp.plan_id = t.plan_id +JOIN train_student s ON s.s_id = t.student_id +JOIN train_course c ON c.c_id = t.c_id +WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data' +ORDER BY s.s_id, c.c_id, t.retake_no; + +-- 5.3 关联一致性自检(学员 / 课程实例 / 参训档案三项,均应 0 行) +SELECT t.theory_score_id, '学员不存在' AS problem +FROM train_theory_exam_score t +JOIN train_class_plan cp ON cp.plan_id = t.plan_id +LEFT JOIN train_student s ON s.s_id = t.student_id +WHERE cp.plan_name = @plan_name AND s.s_id IS NULL +UNION ALL +SELECT t.theory_score_id, '课程实例与计划/课程不匹配' +FROM train_theory_exam_score t +JOIN train_class_plan cp ON cp.plan_id = t.plan_id +LEFT JOIN train_plan_course pc + ON pc.plan_course_id = t.plan_course_id + AND pc.plan_id = t.plan_id + AND pc.c_id = t.c_id +WHERE cp.plan_name = @plan_name AND pc.plan_course_id IS NULL +UNION ALL +SELECT t.theory_score_id, '参训档案与学员/班级不匹配' +FROM train_theory_exam_score t +JOIN train_class_plan cp ON cp.plan_id = t.plan_id +LEFT JOIN train_enrollment e + ON e.enrollment_id = t.enrollment_id + AND e.student_id = t.student_id + AND e.plan_id = t.plan_id +WHERE cp.plan_name = @plan_name AND e.enrollment_id IS NULL; + +-- 5.4 唯一性自检(均应为 0 行) +SELECT enrollment_id, plan_course_id, retake_no, COUNT(*) AS cnt +FROM train_theory_exam_score +WHERE del_flag = '0' +GROUP BY enrollment_id, plan_course_id, retake_no +HAVING COUNT(*) > 1; + +SELECT plan_course_id, student_id, COUNT(*) AS final_cnt +FROM train_theory_exam_score +WHERE del_flag = '0' AND is_final = 1 +GROUP BY plan_course_id, student_id +HAVING COUNT(*) > 1; + +-- 5.5 成绩合法性自检(应 0 行) +SELECT t.theory_score_id, t.exam_status, t.score, t.full_score, t.pass_flag +FROM train_theory_exam_score t +JOIN train_class_plan cp ON cp.plan_id = t.plan_id +WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data' + AND ( + (t.exam_status = 'NORMAL' AND t.score IS NULL) + OR (t.exam_status IN ('ABSENT', 'EXEMPT') AND t.score IS NOT NULL) + OR (t.score IS NOT NULL AND (t.score < 0 OR t.score > t.full_score)) + OR (t.score IS NOT NULL AND t.pass_flag <> IF(t.score >= 60, 1, 0)) + ); + +-- ------------------------------------------------------------------------------ +-- 第 6 步:清理(仅删除本脚本为该计划生成的数据,取消注释后执行) +-- ------------------------------------------------------------------------------ +-- DELETE t FROM train_theory_exam_score t +-- JOIN train_class_plan cp ON cp.plan_id = t.plan_id +-- WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data'; + +-- ============================================================================== +-- 附:无法落库的异常场景(DB 约束拦截,脚本故意不插,可用于负向验证) +-- 1) 同一 (enrollment_id, plan_course_id, retake_no) 重复 → uk_theory_enroll_course_retake +-- 2) 同一课程同一学员两条 is_final = 1 → uk_theory_final(生成列 final_flag) +-- 3) 缺考/免修带分数、正常考试无分数 → chk_theory_score_filled(MySQL 8.0 强制) +-- 4) 考试结束时间早于开始时间 → chk_theory_exam_time +-- 5) 引用不存在的学员/课程/计划 → 外键拦截 +-- ============================================================================== diff --git a/RuoYi-Vue/sql/train/checkin_terminal_data.sql b/RuoYi-Vue/sql/train/checkin_terminal_data.sql new file mode 100644 index 000000000..91e15f32e --- /dev/null +++ b/RuoYi-Vue/sql/train/checkin_terminal_data.sql @@ -0,0 +1,93 @@ +-- ============================================================ +-- 报到一体机 train_checkin_terminal 样例数据(10 条) +-- 说明:terminal_id / terminal_sn 唯一;date 格式 MySQL 5.7 兼容 +-- 执行:SOURCE /path/to/sql/train/checkin_terminal_data.sql; +-- ============================================================ + +INSERT INTO train_checkin_terminal ( + terminal_id, terminal_sn, terminal_name, bind_dept_id, install_location, + support_checkin_type, support_modules, support_print, support_broadcast, + contact_phone, welcome_title, org_name, screen_orientation, idle_return_seconds, + help_text, terminal_ip, api_endpoint, terminal_status, last_heartbeat, + del_flag, create_by, create_time, update_by, update_time, remark +) VALUES +( + 'CKT-10001', 'SNCKT000001', '基地大厅1号报到机', 'DEPT-BASE-01', '1号楼大厅入口', + 'ID_CARD,FACE', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y', + '0571-88001001', '欢迎使用自助报到终端', '铁路培训基地(华东)', 'PORTRAIT', 30, + '请刷身份证或人脸识别完成报到;刷卡时请将证件平放感应区;如需帮助请联系现场工作人员。', + '192.168.10.21', 'http://192.168.10.21:8080/api', 1, '2026-10-24 09:35:12', + '0', 'admin', '2026-10-20 10:00:00', 'admin', '2026-10-24 09:35:12', '主入口主力设备' +), +( + 'CKT-10002', 'SNCKT000002', '基地食堂门口报到机', 'DEPT-BASE-01', '1号楼食堂门口', + 'ID_CARD,FACE,STUDENT_NO', 'CHECKIN,INQUIRY,STATS', 'N', 'Y', + '0571-88001002', '欢迎使用自助报到终端', '铁路培训基地(华东)', 'PORTRAIT', 45, + '支持身份证、人脸及学号三种方式;打印功能暂未开放。', + '192.168.10.22', 'http://192.168.10.22:8080/api', 1, '2026-10-24 09:31:40', + '0', 'admin', '2026-10-20 10:05:00', 'admin', '2026-10-23 14:20:00', '食堂侧分流设备' +), +( + 'CKT-10003', 'SNCKT000003', '教学楼A报到机', 'DEPT-BASE-02', '教学楼A一层大厅', + 'ID_CARD', 'CHECKIN,STATS', 'Y', 'N', + '0571-88002001', '欢迎使用自助报到终端', '铁路培训基地(华北)', 'PORTRAIT', 60, + '本机仅支持身份证刷卡报到,请携带有效证件。', + '192.168.20.31', 'http://192.168.20.31:8080/api', 0, '2026-10-23 18:02:11', + '0', 'admin', '2026-10-19 16:40:00', 'admin', '2026-10-23 18:02:11', '夜间已关机' +), +( + 'CKT-10004', 'SNCKT000004', '教学楼B报到机', 'DEPT-BASE-02', '教学楼B一层大厅', + 'FACE,STUDENT_NO', 'CHECKIN,INQUIRY', 'N', 'Y', + '0571-88002002', '欢迎使用自助报到终端', '铁路培训基地(华北)', 'PORTRAIT', 30, + '人脸报到请正对摄像头;也可输入学号报到。', + '192.168.20.32', 'http://192.168.20.32:8080/api', 1, '2026-10-24 09:40:55', + '0', 'admin', '2026-10-19 16:45:00', 'admin', '2026-10-24 08:10:00', '' +), +( + 'CKT-10005', 'SNCKT000005', '宿舍区报到机', 'DEPT-BASE-03', '学员宿舍楼1栋大厅', + 'ID_CARD,FACE', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y', + '0571-88003001', '欢迎回到培训基地', '铁路培训基地(西南)', 'PORTRAIT', 30, + '报到后请留意显示屏上的班级与宿舍安排;打印凭条请于报到完成后领取。', + '192.168.30.41', 'http://192.168.30.41:8080/api', 1, '2026-10-24 09:28:03', + '0', 'admin', '2026-10-18 09:00:00', 'admin', '2026-10-24 09:28:03', '宿舍区综合设备' +), +( + 'CKT-10006', 'SNCKT000006', '西门访客报到机', 'DEPT-BASE-03', '基地西大门', + 'ID_CARD', 'CHECKIN', 'N', 'N', + '0571-88003002', '欢迎使用自助报到终端', '铁路培训基地(西南)', 'LANDSCAPE', 30, + '西门临时报到点,仅支持身份证刷卡。', + '192.168.30.42', 'http://192.168.30.42:8080/api', 0, NULL, + '0', 'admin', '2026-10-17 11:20:00', 'admin', '2026-10-21 09:00:00', '横屏临时点位' +), +( + 'CKT-10007', 'SNCKT000007', '实训车间报到机', 'DEPT-BASE-04', '实训车间入口', + 'ID_CARD,FACE,STUDENT_NO', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y', + '0571-88004001', '欢迎进入实训车间', '铁路培训基地(东北)', 'PORTRAIT', 30, + '实训前请先完成报到;设备支持打印实训安排汇总。', + '192.168.40.51', 'http://192.168.40.51:8080/api', 1, '2026-10-24 09:15:47', + '0', 'admin', '2026-10-16 14:00:00', 'admin', '2026-10-24 07:50:00', '' +), +( + 'CKT-10008', 'SNCKT000008', '报告厅报到机', 'DEPT-BASE-04', '综合报告厅前厅', + 'FACE', 'CHECKIN,STATS', 'N', 'Y', + '0571-88004002', '欢迎参加开班典礼', '铁路培训基地(东北)', 'PORTRAIT', 45, + '大型活动报到请使用人脸识别,快速通行。', + '192.168.40.52', 'http://192.168.40.52:8080/api', 1, '2026-10-24 08:55:30', + '0', 'admin', '2026-10-16 14:10:00', 'admin', '2026-10-24 08:55:30', '典礼临时启用' +), +( + 'CKT-10009', 'SNCKT000009', '行政楼报到机', 'DEPT-BASE-01', '行政楼一层大厅', + 'ID_CARD,STUDENT_NO', 'CHECKIN,INQUIRY', 'Y', 'N', + '0571-88001003', '欢迎使用自助报到终端', '铁路培训基地(华东)', 'PORTRAIT', 30, + '行政楼报到点,支持刷卡与学号;打印功能开放。', + '192.168.10.23', 'http://192.168.10.23:8080/api', 0, '2026-10-22 17:40:09', + '0', 'admin', '2026-10-15 10:30:00', 'admin', '2026-10-22 17:40:09', '行政办公区' +), +( + 'CKT-10010', 'SNCKT000010', '机动备用报到机', 'DEPT-BASE-02', '设备库房(备用)', + 'ID_CARD,FACE,STUDENT_NO', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y', + '0571-88002003', '欢迎使用自助报到终端', '铁路培训基地(华北)', 'PORTRAIT', 30, + '备用设备,按需部署;全功能配置。', + '192.168.20.33', 'http://192.168.20.33:8080/api', 0, NULL, + '0', 'admin', '2026-10-14 09:00:00', 'admin', '2026-10-14 09:00:00', '仓库待启用' +); diff --git a/RuoYi-Vue/sql/train/checkin_terminal_menu.sql b/RuoYi-Vue/sql/train/checkin_terminal_menu.sql new file mode 100644 index 000000000..9f499e04a --- /dev/null +++ b/RuoYi-Vue/sql/train/checkin_terminal_menu.sql @@ -0,0 +1,29 @@ +-- ============================================================ +-- 报到一体机管理 菜单(挂在“设备管理”目录下,排序 4) +-- 前置条件:sys_menu 中已存在菜单名为「设备管理」的目录(menu_type='M') +-- 说明:超级管理员(role_id=1) 无需单独授权即可看到菜单; +-- 其他角色需将下列 perms 授权后按钮才可见。 +-- ============================================================ + +-- 1) 定位「设备管理」目录,作为父菜单 +SET @parentDev := (SELECT menu_id FROM sys_menu WHERE menu_name = '设备管理' LIMIT 1); + +-- 2) 新增菜单(C 类型) +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_time, update_time) +VALUES + ('报到一体机管理', @parentDev, 4, 'checkinTerminal', 'device/checkinTerminal/index', '', 'CheckinTerminal', + '0', '0', 'C', '0', '0', 'device:checkinterminal:list', 'monitor', NOW(), NOW()); + +-- 3) 记录新菜单的 id,作为按钮权限的父级 +SET @parentMenu := LAST_INSERT_ID(); + +-- 4) 新增按钮级权限(F 类型):新增 / 修改 / 删除 +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_time, update_time) +VALUES + ('报到一体机管理-新增', @parentMenu, 1, '#', '', '', '', '0', '0', 'F', '0', '0', 'device:checkinterminal:add', '#', NOW(), NOW()), + ('报到一体机管理-修改', @parentMenu, 2, '#', '', '', '', '0', '0', 'F', '0', '0', 'device:checkinterminal:edit', '#', NOW(), NOW()), + ('报到一体机管理-删除', @parentMenu, 3, '#', '', '', '', '0', '0', 'F', '0', '0', 'device:checkinterminal:remove', '#', NOW(), NOW()); diff --git a/RuoYi-Vue/sql/train/v2/003_alter_master_tables.sql b/RuoYi-Vue/sql/train/v2/003_alter_master_tables.sql index 464a5894b..5dacbca78 100644 --- a/RuoYi-Vue/sql/train/v2/003_alter_master_tables.sql +++ b/RuoYi-Vue/sql/train/v2/003_alter_master_tables.sql @@ -44,7 +44,7 @@ ALTER TABLE train_electronic_board DROP CONSTRAINT IF EXISTS fk_board_plan; -- ============================================================================== ALTER TABLE train_teacher ADD COLUMN user_id BIGINT, - ADD COLUMN org_name VARCHAR(100), + ADD COLUMN dept_name VARCHAR(100), ADD COLUMN job_title VARCHAR(50), ADD COLUMN status SMALLINT DEFAULT 1, ADD COLUMN create_by VARCHAR(64), @@ -58,7 +58,7 @@ ALTER TABLE train_teacher ALTER COLUMN remark TYPE VARCHAR(500); COMMENT ON COLUMN train_teacher.user_id IS 'sys_user登录账号逻辑关联ID'; -COMMENT ON COLUMN train_teacher.org_name IS '所属单位/部门名称 (由旧 t_department 迁移)'; +COMMENT ON COLUMN train_teacher.dept_name IS '所属单位/部门名称 (由旧 t_department 迁移)'; COMMENT ON COLUMN train_teacher.job_title IS '职务/职称 (由旧 t_title 迁移)'; COMMENT ON COLUMN train_teacher.status IS '状态 (1:正常, 0:停用)'; COMMENT ON COLUMN train_teacher.del_flag IS '删除标志 (0:存在, 2:删除)'; @@ -76,7 +76,8 @@ ALTER TABLE train_student -- 4.2 增加企业培训新主档字段、加密字段与公共审计字段 ALTER TABLE train_student ADD COLUMN user_id BIGINT, - ADD COLUMN org_name VARCHAR(100), + ADD COLUMN dept_name VARCHAR(100), + ADD COLUMN card_no VARCHAR(50), ADD COLUMN s_email VARCHAR(100), ADD COLUMN id_card_ciphertext TEXT, ADD COLUMN id_card_hash VARCHAR(128), @@ -94,7 +95,7 @@ ALTER TABLE train_student ALTER COLUMN remark TYPE VARCHAR(500); COMMENT ON COLUMN train_student.user_id IS 'sys_user账号逻辑关联'; -COMMENT ON COLUMN train_student.org_name IS '所属单位/部门'; +COMMENT ON COLUMN train_student.dept_name IS '所属单位/部门'; COMMENT ON COLUMN train_student.id_card_ciphertext IS '身份证可逆加密密文'; COMMENT ON COLUMN train_student.id_card_hash IS '身份证精确匹配Hash (防重)'; COMMENT ON COLUMN train_student.id_card_last4 IS '身份证后四位展示'; diff --git a/RuoYi-Vue/sql/train/v2/006_indexes.sql b/RuoYi-Vue/sql/train/v2/006_indexes.sql index 06dce72ae..701b7f339 100644 --- a/RuoYi-Vue/sql/train/v2/006_indexes.sql +++ b/RuoYi-Vue/sql/train/v2/006_indexes.sql @@ -19,7 +19,7 @@ CREATE INDEX idx_teacher_name ON train_teacher (t_name); -- 2. train_student 学员表索引 CREATE INDEX idx_student_name ON train_student (s_name); -CREATE INDEX idx_student_org ON train_student (org_name); +CREATE INDEX idx_student_org ON train_student (dept_name); CREATE INDEX idx_student_user_id ON train_student (user_id); -- 3. train_course 课程表索引 diff --git a/RuoYi-Vue/sql/train/v2/007_dicts.sql b/RuoYi-Vue/sql/train/v2/007_dicts.sql index 6c3699fb0..5d954d65f 100644 --- a/RuoYi-Vue/sql/train/v2/007_dicts.sql +++ b/RuoYi-Vue/sql/train/v2/007_dicts.sql @@ -332,4 +332,29 @@ INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_cl SELECT 4, 'Excel批量导入', '4', 'train_score_source', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '线下成绩表格批量导入' WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_score_source' AND dict_value = '4'); +-- 17. train_class_major +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '培训专业', 'train_class_major', '0', 'admin', CURRENT_TIMESTAMP, '培训班所属专业分类 (对应 train_class_plan.class_major)' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_class_major'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, '接触网工', '1', 'train_class_major', 'primary', 'Y', '0', 'admin', CURRENT_TIMESTAMP, '接触网检修专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '1'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '电力线路工', '2', 'train_class_major', 'primary', 'N', '0', 'admin', CURRENT_TIMESTAMP, '电力线路检修专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '2'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 3, '信号工', '3', 'train_class_major', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '信号设备维护专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '3'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 4, '通信工', '4', 'train_class_major', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '通信设备维护专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '4'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 5, '机车司机', '5', 'train_class_major', 'info', 'N', '0', 'admin', CURRENT_TIMESTAMP, '机车驾驶专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '5'); + COMMIT; diff --git a/RuoYi-Vue/sql/train/v2/008_migrate_teacher.sql b/RuoYi-Vue/sql/train/v2/008_migrate_teacher.sql index 2bc2ed61d..a8d499570 100644 --- a/RuoYi-Vue/sql/train/v2/008_migrate_teacher.sql +++ b/RuoYi-Vue/sql/train/v2/008_migrate_teacher.sql @@ -6,7 +6,7 @@ -- 设计依据: RailwaySystem 数据库 V2 字段设计与 Kingbase DDL 规范 V1.0 及 Round 1 审查要求 -- 安全规范: -- 1. 严禁在脚本中出现真实教师姓名、电话、身份证、邮箱等明文数据! --- 2. 完全采用纯 DML 数据映射: t_department -> org_name, t_title -> job_title, create_user -> create_by +-- 2. 完全采用纯 DML 数据映射: t_department -> dept_name, t_title -> job_title, create_user -> create_by -- 3. 执行后验证迁移完整性,再将 status 与 del_flag 设为 NOT NULL 并安全移除旧列 -- ============================================================================== @@ -15,7 +15,7 @@ BEGIN; -- 步骤 1: 数据平滑映射 (仅更新新字段为空的行,保证幂等) UPDATE train_teacher SET - org_name = COALESCE(org_name, t_department), + dept_name = COALESCE(dept_name, t_department), job_title = COALESCE(job_title, t_title), create_by = COALESCE(create_by, create_user), status = COALESCE(status, 1), @@ -31,7 +31,7 @@ BEGIN SELECT count(*) INTO v_total_cnt FROM train_teacher; SELECT count(*) INTO v_unmigrated_cnt FROM train_teacher - WHERE org_name IS NULL OR status IS NULL OR del_flag IS NULL; + WHERE dept_name IS NULL OR status IS NULL OR del_flag IS NULL; IF v_total_cnt <> 1 THEN RAISE EXCEPTION '>>> [008-FATAL] 教师数据校验异常: 期望精确 1 条数据,实际检测到 % 条', v_total_cnt; diff --git a/RuoYi-Vue/sql/train/v2/010_verify.sql b/RuoYi-Vue/sql/train/v2/010_verify.sql index c3194dcf8..9345c5829 100644 --- a/RuoYi-Vue/sql/train/v2/010_verify.sql +++ b/RuoYi-Vue/sql/train/v2/010_verify.sql @@ -90,7 +90,7 @@ BEGIN SELECT count(*) INTO v_cnt FROM train_teacher - WHERE org_name IS NULL OR status IS NULL OR del_flag IS NULL; + WHERE dept_name IS NULL OR status IS NULL OR del_flag IS NULL; IF v_cnt > 0 THEN RAISE EXCEPTION '>>> [Verify-FATAL] train_teacher 记录中新主档字段存在非法 NULL 值'; END IF; diff --git a/RuoYi-Vue/sql/train/v2/015_train_test_data.sql b/RuoYi-Vue/sql/train/v2/015_train_test_data.sql new file mode 100644 index 000000000..9d8eba78e --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/015_train_test_data.sql @@ -0,0 +1,90 @@ +-- ============================================================ +-- 阅读机/听书机 7 张业务表 - 测试数据 +-- 来源:阅读机数据表+接口设计-v1.md +-- ============================================================ +-- 前置说明: +-- 1) 本文件仅包含 INSERT 测试数据;建表 DDL 请按设计文档执行 +-- (其中 train_book_download_record 的 DDL 见 patch/014_train_book_download_record.sql,其余 6 张表按文档“一、数据库表设计”创建)。 +-- 2) 逻辑外键依赖:train_user_session / 各阅读进度 / 上报日志 / 下载回执 中的 user_id=10001 +-- 需先在 sys_user 中存在该学员账号(RuoYi 内置用户体系,本文件不含 sys_user 数据)。 +-- 3) 设备 device_id、图书 book_id/book_version、会话 session_id、上报 record_id 均保持自洽, +-- 可直接用于验证“登录 / 阅读上报 / 进度冲突 / 下架过滤 / 下载回执”等接口。 +-- 4) device_secret_digest 为 SHA256 十六进制占位值(64 位),真实环境请替换为对应设备密钥的 SHA256 摘要。 +-- ============================================================ + +-- ---------------------------------------------------------- +-- 1) train_iot_device 读书机设备档案表 +-- ---------------------------------------------------------- +INSERT INTO `train_iot_device` + (`device_id`, `device_secret_digest`, `device_status`, `firmware_version`, `app_version`, `bind_org_id`, `bind_class_id`, `heartbeat_interval`, `last_login_time`, `last_heartbeat_time`) +VALUES + ('READER-LIB-01', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', 1, '1.4.2', '2.1.0', 'ORG-001', 'CLASS-01,CLASS-02', 60, '2026-10-20 09:00:00', '2026-10-20 09:30:00'), + ('READER-LIB-02', '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', 1, '1.4.0', '2.0.5', 'ORG-001', 'CLASS-03', 60, '2026-10-19 08:00:00', '2026-10-20 08:50:00'), + ('READER-LIB-03', '688787d8ff144c502c7f5cffaafe2cc588d86079f9de88304c26b0cb99ce91c6', 2, '1.3.1', '1.9.0', 'ORG-002', 'CLASS-05', 120, NULL, '2026-10-15 10:00:00'); + +-- ---------------------------------------------------------- +-- 2) train_book_snapshot 图书快照表 +-- is_on_shelf: 1 上架 / 0 下架(下架仅禁止新下载,仍可上报阅读) +-- ---------------------------------------------------------- +INSERT INTO `train_book_snapshot` + (`book_id`, `book_title`, `book_category`, `book_summary`, `book_version`, `file_size`, `file_md5`, `download_url`, `is_on_shelf`, `publish_time`) +VALUES + ('BK-00012', '铁路房建施工安全手册', 'SAFETY', '房建施工安全作业规范与事故案例', 'BV-3', 2048000, 'c4ca4238a0b923820dcc509a6f75849b', '/books/BK-00012/BV-3.pdf', 1, '2025-10-18 00:00:00'), + ('BK-00012', '铁路房建施工安全手册', 'SAFETY', '房建施工安全作业规范与事故案例(旧版)', 'BV-2', 1980000, 'b6d8a532a0f1c2e3d4f5a6b7c8d9e0f10', '/books/BK-00012/BV-2.pdf', 0, '2025-06-01 00:00:00'), + ('BK-00013', '铁路电力安全工作规程', 'REGULATION', '电气化铁路安全工作规程要点', 'BV-2', 3072000, 'a1b2c3d4e5f60718293a4b5c6d7e8f90a', '/books/BK-00013/BV-2.pdf', 1, '2025-09-10 00:00:00'), + ('BK-00014', '高铁客运服务英语', 'LANGUAGE', '高铁乘务日常英语会话', 'BV-1', 1536000, 'f0e1d2c3b4a5968778695a4b3c2d1e0f1', '/books/BK-00014/BV-1.pdf', 1, '2025-08-20 00:00:00'), + ('BK-00015', '铁路线路维修规则', 'REGULATION', '普速铁路线路维修规则', 'BV-1', 4096000, '112233445566778899aabbccddeeff00', '/books/BK-00015/BV-1.pdf', 0, '2025-05-15 00:00:00'); + +-- ---------------------------------------------------------- +-- 3) train_user_session 学员业务会话表 +-- session_status: 1 ACTIVE正常在线 / 2 REPLACED被替换仅补交 / 3 LOGOUT已退出 +-- 下面第 2 条模拟“被新登录顶替”的旧会话,用于验证冲突进度(CONFLICT_SAVED) +-- ---------------------------------------------------------- +INSERT INTO `train_user_session` + (`user_id`, `session_id`, `device_id`, `session_status`, `login_time`, `replace_time`, `logout_time`, `ruoyi_token_key`) +VALUES + (10001, 'SES-20261020-0001', 'READER-LIB-01', 1, '2026-10-20 09:00:00', NULL, NULL, 'login_tokens:token-active-001'), + (10001, 'SES-20261019-0001', 'READER-LIB-02', 2, '2026-10-19 08:00:00', '2026-10-20 09:00:05', NULL, 'login_tokens:token-replaced-001'); + +-- ---------------------------------------------------------- +-- 4) train_reading_current_progress 学员主续读进度 +-- (user_id, book_id, mode) 唯一;mode: read 阅读 / audio 听书 +-- ---------------------------------------------------------- +INSERT INTO `train_reading_current_progress` + (`user_id`, `book_id`, `mode`, `book_version`, `page_no`, `sentence_index`, `progress`, `source_record_id`, `source_session_id`) +VALUES + (10001, 'BK-00012', 'read', 'BV-3', 46, NULL, 0.35, 'RD-20261020-0001', 'SES-20261020-0001'), + (10001, 'BK-00012', 'audio', 'BV-3', 120, 88, 0.50, 'RD-20261020-0002', 'SES-20261020-0001'), + (10001, 'BK-00013', 'read', 'BV-2', 30, NULL, 0.20, 'RD-20261020-0003', 'SES-20261020-0001'); + +-- ---------------------------------------------------------- +-- 5) train_reading_alternate_progress 备选冲突续读进度 +-- 来自被 REPLACED 的旧设备会话提交,不覆盖主进度,供学员手动选择跳转 +-- ---------------------------------------------------------- +INSERT INTO `train_reading_alternate_progress` + (`user_id`, `book_id`, `mode`, `book_version`, `page_no`, `sentence_index`, `progress`, `source_record_id`, `source_session_id`) +VALUES + (10001, 'BK-00012', 'read', 'BV-2', 22, NULL, 0.18, 'RD-20261019-0001', 'SES-20261019-0001'); + +-- ---------------------------------------------------------- +-- 6) train_reading_duration_log 阅读上报幂等日志 +-- result_code: ACCEPTED / DUPLICATE / CONFLICT_SAVED / REJECTED +-- (user_id, record_id) 唯一,record_id 重复将返回 DUPLICATE +-- ---------------------------------------------------------- +INSERT INTO `train_reading_duration_log` + (`device_id`, `user_id`, `session_id`, `record_id`, `book_id`, `book_version`, `mode`, `record_type`, `duration_sec`, `page_from`, `page_to`, `progress`, `start_time`, `end_time`, `receive_time`, `result_code`, `remark`) +VALUES + ('READER-LIB-01', 10001, 'SES-20261020-0001', 'RD-20261020-0001', 'BK-00012', 'BV-3', 'read', 'END', 1420, 12, 46, 0.35, '2026-10-20 08:30:00', '2026-10-20 09:00:00', '2026-10-20 09:00:01', 'ACCEPTED', NULL), + ('READER-LIB-01', 10001, 'SES-20261020-0001', 'RD-20261020-0002', 'BK-00012', 'BV-3', 'audio', 'PROGRESS', 600, 100, 120, 0.50, '2026-10-20 09:00:00', '2026-10-20 09:10:00', '2026-10-20 09:10:01', 'ACCEPTED', NULL), + ('READER-LIB-02', 10001, 'SES-20261019-0001', 'RD-20261019-0001', 'BK-00012', 'BV-2', 'read', 'END', 980, 5, 22, 0.18, '2026-10-19 19:45:00', '2026-10-19 20:00:00', '2026-10-19 20:00:01', 'CONFLICT_SAVED', 'REPLACED会话补交,已存备选进度'); + +-- ---------------------------------------------------------- +-- 7) train_book_download_record 图书下载回执记录表 +-- result: SUCCESS / FAIL +-- ---------------------------------------------------------- +INSERT INTO `train_book_download_record` + (`user_id`, `device_id`, `book_id`, `book_version`, `file_md5`, `result`, `err_msg`, `download_time`) +VALUES + (10001, 'READER-LIB-01', 'BK-00012', 'BV-3', 'c4ca4238a0b923820dcc509a6f75849b', 'SUCCESS', NULL, '2026-10-20 08:00:00'), + (10001, 'READER-LIB-01', 'BK-00013', 'BV-2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90a', 'SUCCESS', NULL, '2026-10-20 08:05:00'), + (10001, 'READER-LIB-03', 'BK-00014', 'BV-1', 'f0e1d2c3b4a5968778695a4b3c2d1e0f1', 'FAIL', '存储空间不足', '2026-10-15 09:00:00'); diff --git a/RuoYi-Vue/sql/train/v2/016_api_test_data.md b/RuoYi-Vue/sql/train/v2/016_api_test_data.md new file mode 100644 index 000000000..99f7f6120 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/016_api_test_data.md @@ -0,0 +1,289 @@ +# 阅读机/听书机 接口测试数据手册 + +> 配套文件:`阅读机数据表+接口设计-v1.md`、`015_train_test_data.sql` +> 本文档中的「请求示例」全部使用 `015_train_test_data.sql` 已插入的真实数据,可直接照抄测试。 +> +> 基础地址:`http://localhost:8096/api/iot/v1`(端口按你实际改) +> 数据前提:已执行 `015_train_test_data.sql`,且 `sys_user` 中存在 `userId=10001`、`status='0'`(正常)。 + +--- + +## 0. 鉴权与测试顺序 + +| 接口 | 鉴权 | +|---|---| +| `POST /device/login` | 免鉴权(permitAll) | +| `GET /book/list` | 免鉴权(已放开) | +| `GET /book/download` | 免鉴权(已放开) | +| 其余 6 个接口 | 需 `Authorization: Bearer `(来自登录返回) | + +> **登录接口有强制设备签名(`DeviceHmacFilter`)**:必须带 `X-Signature` + `X-Nonce` 请求头,且 body 的 `timestamp` 距今 ≤5 分钟,否则报「缺少设备签名头 / 签名校验失败 / 时间戳超时」。 +> 本地联调不想算签名时,把 `application.yml` 的 `iot.hmac.enabled` 改为 `false`(重启后端)即可跳过验签;生产环境务必保持 `true`。 +> 其余接口(Bearer 鉴权)不受该 Filter 影响。 + +**推荐顺序**:登录 → 复制 `data.accessToken` → 在 Swagger 点 Authorize 填 `Bearer `(或 curl 带 `-H 'Authorization: Bearer '`)→ 测其余接口。 + +--- + +## 1. 设备学员登录 `POST /api/iot/v1/device/login` + +请求(取自 015 设备 `READER-LIB-01` + 学员 `10001`): + +```bash +curl -X POST 'http://localhost:8096/api/iot/v1/device/login' \ + -H 'Content-Type: application/json' \ + -d '{ + "deviceId":"READER-LIB-01", + "userId":10001, + "firmware":"1.4.2", + "appVersion":"2.1.0", + "timestamp":1767620000000, + "nonce":"a1b2c3d4e5f6" + }' +``` + +预期响应: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "accessToken": "", + "sessionId": "<本次新建的业务会话ID>", + "heartbeatInterval": 60 + } +} +``` + +--- + +## 2. 设备注销 `POST /api/iot/v1/device/logout`(需 token) + +```bash +curl -X POST 'http://localhost:8096/api/iot/v1/device/logout' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"deviceId":"READER-LIB-01","reason":"REBOOT","timestamp":1767621000000,"nonce":"f6e5d4c3b2a1"}' +``` + +预期:`{"data":{"result":true,"tokenRevoked":true}}` + +--- + +## 3. 设备心跳 `POST /api/iot/v1/device/heartbeat`(需 token) + +```bash +curl -X POST 'http://localhost:8096/api/iot/v1/device/heartbeat' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"deviceId":"READER-LIB-01","status":"ONLINE","metrics":{"cpu":20,"mem":30,"diskFree":10240},"configVersion":"CV-17","personVersion":"PV-226118-7","timestamp":1767622000000,"nonce":"aa11bb22cc33"}' +``` + +预期:`{"data":{"nextInterval":60,"commands":[]}}` + +--- + +## 4. 阅读记录单条上报 `POST /api/iot/v1/reading/record`(需 token) + +**新 recordId(正常 ACCEPTED)** —— 用上架书 `BK-00012/BV-3`: + +```bash +curl -X POST 'http://localhost:8096/api/iot/v1/reading/record' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "deviceId":"READER-LIB-01", + "recordId":"RD-TEST-0001", + "bookId":"BK-00012", + "bookVersion":"BV-3", + "mode":"read", + "recordType":"END", + "startTime":1767620000000, + "endTime":1767622400000, + "durationSec":1420, + "pageFrom":12, + "pageTo":46, + "progress":0.35 + }' +``` + +预期:`{"data":{"result":"ACCEPTED","studyMinutes":23,"totalMinutes":<累计/60>}}` + +> 重复发送同一个 `recordId=RD-TEST-0001` → 返回 `{"result":"DUPLICATE",...}`(幂等)。 + +--- + +## 5. 阅读记录批量上报 `POST /api/iot/v1/reading/record/batch`(需 token) + +```bash +curl -X POST 'http://localhost:8096/api/iot/v1/reading/record/batch' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "deviceId":"READER-LIB-01", + "batchId":"BATCH-RD-001", + "timestamp":1767622500000, + "nonce":"b2b3c4d5e6f7", + "records":[ + {"recordId":"RD-TEST-0002","bookId":"BK-00013","bookVersion":"BV-2","mode":"read","recordType":"END","startTime":1767620000000,"endTime":1767622400000,"durationSec":980,"pageFrom":5,"pageTo":22,"progress":0.18} + ] + }' +``` + +预期:`{"data":{"itemResults":[{"recordId":"RD-TEST-0002","result":"ACCEPTED"}]}}` + +--- + +## 6. 获取学员阅读汇总与续读进度 `GET /api/iot/v1/reading/progress`(需 token) + +```bash +curl 'http://localhost:8096/api/iot/v1/reading/progress?deviceId=READER-LIB-01' \ + -H 'Authorization: Bearer ' +``` + +预期(基于 015 已插入的进度/上报数据;需存在一个 `ACTIVE` 会话,登录后即有): + +```json +{ + "data": { + "userId": 10001, + "totalReadSeconds": 2400, + "totalAudioSeconds": 600, + "bookRecords": [ + { + "bookId": "BK-00012", "bookVersion": "BV-3", + "mainProgress": {"mode":"read","pageNo":46,"sentenceIndex":null,"progress":0.35,"updateTime":1767622400000}, + "alternateProgressList": [ + {"mode":"read","bookVersion":"BV-2","pageNo":22,"sentenceIndex":null,"progress":0.18,"createTime":1767610000000} + ] + }, + { + "bookId": "BK-00012", "bookVersion": "BV-3", + "mainProgress": {"mode":"audio","pageNo":120,"sentenceIndex":88,"progress":0.50,"updateTime":1767622500000}, + "alternateProgressList": [] + }, + { + "bookId": "BK-00013", "bookVersion": "BV-2", + "mainProgress": {"mode":"read","pageNo":30,"sentenceIndex":null,"progress":0.20,"updateTime":1767622500000}, + "alternateProgressList": [] + } + ] + } +} +``` + +> `totalReadSeconds`=1420+980(两条 read 上报累加);`totalAudioSeconds`=600(audio 上报)。 + +--- + +## 7. 获取可阅读书籍列表 `GET /api/iot/v1/book/list`(免鉴权) + +```bash +# 全部上架书 +curl 'http://localhost:8096/api/iot/v1/book/list?deviceId=READER-LIB-01' +# 按分类过滤(只返回上架) +curl 'http://localhost:8096/api/iot/v1/book/list?deviceId=READER-LIB-01&bookCategory=SAFETY' +``` + +预期(`bookCategory` 省略时 `total=3`,只返回 `is_on_shelf=1` 的书;下架的 `BK-00012/BV-2`、`BK-00015` 不返回): + +```json +{ + "data": { + "total": 3, + "list": [ + {"bookId":"BK-00012","bookTitle":"铁路房建施工安全手册","bookCategory":"SAFETY","bookVersion":"BV-3","fileSize":2048000,"fileMd5":"c4ca4238a0b923820dcc509a6f75849b","isOnShelf":true}, + {"bookId":"BK-00013","bookTitle":"铁路电力安全工作规程","bookCategory":"REGULATION","bookVersion":"BV-2","fileSize":3072000,"fileMd5":"a1b2c3d4e5f60718293a4b5c6d7e8f90a","isOnShelf":true}, + {"bookId":"BK-00014","bookTitle":"高铁客运服务英语","bookCategory":"LANGUAGE","bookVersion":"BV-1","fileSize":1536000,"fileMd5":"f0e1d2c3b4a5968778695a4b3c2d1e0f1","isOnShelf":true} + ] + } +} +``` + +> `bookCategory=SAFETY` 时 `total=1`(仅 `BK-00012/BV-3`)。 + +--- + +## 8. PDF 书籍下载 `GET /api/iot/v1/book/download`(免鉴权) + +```bash +# 上架书(正常) +curl -i 'http://localhost:8096/api/iot/v1/book/download?deviceId=READER-LIB-01&bookId=BK-00012&bookVersion=BV-3' +# 支持断点续传 +curl -i -H 'Range: bytes=0-1023' 'http://localhost:8096/api/iot/v1/book/download?deviceId=READER-LIB-01&bookId=BK-00012&bookVersion=BV-3' +``` + +- 上架书:HTTP 200(或 Range 时 206),响应头带 `Accept-Ranges: bytes`、`Content-MD5`。 +- 下架书:`bookId=BK-00012&bookVersion=BV-2`(is_on_shelf=0)→ 业务报错 `{"code":500,"msg":"图书已下架,禁止下载"}`。 +- 不存在的版本:`bookId=BK-00015&bookVersion=BV-9` → `图书或版本不存在`。 + +> ⚠️ `download_url` 支持两种形态: +> ① **相对路径**(如正式数据的 `/profile/upload/2026/10/21/铁路房建施工安全手册_20261021085854A001.pdf`,或测试用的 `/books/BK-00012/BV-3.pdf`)——后端自动在前面补上「后端 base URL(`scheme+host+port`,如 `http://localhost:8096`)」转成可访问的 http(s) 绝对地址后再流式返回; +> ② **完整 http(s) 远程地址**(如 OSS)——直接拉取。 +> 两种都会自动加 `Content-Type: application/pdf` 与 `Content-Disposition`(下载头),保证浏览器可访问。 +> 相对路径由后端自身静态资源处理器提供文件:`/profile/**` 指向 RuoYi 上传根目录(文件须真实存在于该磁盘目录),`/books/**` 指向 `classpath:/static/books/`(测试 PDF 已放此处)。文件不存在则返回「文件不存在」。 + +--- + +## 9. 下载完成回执上报 `POST /api/iot/v1/book/download/ack`(需 token) + +```bash +curl -X POST 'http://localhost:8096/api/iot/v1/book/download/ack' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "deviceId":"READER-LIB-01", + "bookId":"BK-00012", + "bookVersion":"BV-3", + "fileMd5":"c4ca4238a0b923820dcc509a6f75849b", + "result":"SUCCESS", + "errMsg":"", + "timestamp":1767630000000, + "nonce":"cc55dd66ee77" + }' +``` + +预期:`{"data":{"deviceReady":true,"recordId":<自增主键>}}` + +> `result` 可取 `SUCCESS`/`FAIL`(缺省 SUCCESS);落库 `train_book_download_record`。 + +--- + +## 10. 边界 / 异常用例矩阵(均基于 015 数据) + +| 用例 | 操作 | 预期 | +|---|---|---| +| 禁用设备登录 | `device/login` 用 `deviceId=READER-LIB-03`(status=2) | `msg:"设备已禁用"` | +| 重复上报幂等 | `reading/record` 用已存在的 `recordId=RD-20261020-0001` | `result:"DUPLICATE"` | +| 冲突进度(REPLACED 仅补交) | 用 `deviceId=READER-LIB-02`(其会话 `SES-20261019-0001` 是 REPLACED)发 `reading/record` 新 recordId | `result:"CONFLICT_SAVED"`,写入 `train_reading_alternate_progress` | +| 下架书被过滤 | `book/list` 不含 `BK-00012/BV-2`、`BK-00015` | `total=3`(仅上架) | +| 下架书禁止下载 | `book/download?bookId=BK-00012&bookVersion=BV-2` | 业务错误「图书已下架,禁止下载」 | +| REPLACED 禁止进度查询 | `reading/progress?deviceId=READER-LIB-02`(需先有一个有效 token) | `msg:"当前会话状态不允许查询进度(被替换设备仅允许补交上报)"` | +| 版本强绑定 | 进度查询中 `BK-00012` 的 `read` 主进度 `BV-3` 与备选 `BV-2` 不混用页码 | 各自返回对应版本页码 | + +--- + +## 11. 已插入测试数据速查(源自 015) + +**设备 `train_iot_device`** +- `READER-LIB-01` status=1(正常)、`READER-LIB-02` status=1、`READER-LIB-03` status=2(禁用) + +**图书 `train_book_snapshot`**(is_on_shelf) +- `BK-00012/BV-3` SAFETY 上架 md5=`c4ca4238a0b923820dcc509a6f75849b` +- `BK-00012/BV-2` SAFETY **下架** +- `BK-00013/BV-2` REGULATION 上架 md5=`a1b2c3d4e5f60718293a4b5c6d7e8f90a` +- `BK-00014/BV-1` LANGUAGE 上架 md5=`f0e1d2c3b4a5968778695a4b3c2d1e0f1` +- `BK-00015/BV-1` REGULATION **下架** + +**会话 `train_user_session`** +- `SES-20261020-0001` user=10001 device=READER-LIB-01 status=1 ACTIVE +- `SES-20261019-0001` user=10001 device=READER-LIB-02 status=2 REPLACED(用于冲突用例) + +**主进度 `train_reading_current_progress`**:10001 的 `BK-00012/read/BV-3`(46,0.35)、`BK-00012/audio/BV-3`(120,88,0.50)、`BK-00013/read/BV-2`(30,0.20) + +**备选进度 `train_reading_alternate_progress`**:10001 `BK-00012/read/BV-2`(22,0.18) + +**上报日志 `train_reading_duration_log`**:`RD-20261020-0001`(ACCEPTED)、`RD-20261020-0002`(ACCEPTED)、`RD-20261019-0001`(CONFLICT_SAVED) + +**下载回执 `train_book_download_record`**:`BK-00012/BV-3` SUCCESS、`BK-00013/BV-2` SUCCESS、`BK-00014/BV-1` FAIL(errMsg=存储空间不足) diff --git a/RuoYi-Vue/sql/train/v2/017_api_postman_collection.json b/RuoYi-Vue/sql/train/v2/017_api_postman_collection.json new file mode 100644 index 000000000..95130a99d --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/017_api_postman_collection.json @@ -0,0 +1,144 @@ +{ + "info": { + "name": "阅读机-听书机接口 (v1)", + "description": "配套 015_train_test_data.sql 与 016_api_test_data.md。\nbaseUrl 默认 http://localhost:8096/api/iot/v1(端口按实际改)。\n\n【鉴权】先发‘1.设备学员登录’,其 Tests 脚本会自动把 data.accessToken 存入集合变量 accessToken;其余需鉴权接口通过 Authorization: Bearer {{accessToken}} 使用。\n\n【免鉴权】book/list、book/download 已放开,可直接测。\n\n【下载】book/download 读取 train_book_snapshot.download_url(已指向本地 D:/tmp/books//.pdf 占位文件),请用 Send and Download 保存为文件;支持 Range 断点续传。下架书(BK-00012/BV-2、BK-00015)会返回业务错误。", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:8096/api/iot/v1" }, + { "key": "accessToken", "value": "" } + ], + "item": [ + { + "name": "1.设备学员登录 POST /device/login", + "request": { + "method": "POST", + "url": "{{baseUrl}}/device/login", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"userId\": 10001\n}" } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var secrets = {", + " \"READER-LIB-01\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",", + " \"READER-LIB-02\": \"60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752\",", + " \"READER-LIB-03\": \"688787d8ff144c502c7f5cffaafe2cc588d86079f9de88304c26b0cb99ce91c6\"", + "};", + "var body = JSON.parse(pm.request.body.raw);", + "var secret = secrets[body.deviceId];", + "body.timestamp = Date.now();", + "var raw = JSON.stringify(body);", + "pm.request.body.raw = raw;", + "var nonce = CryptoJS.lib.WordArray.random(16).toString();", + "var stringToSign = \"POST\\n/api/iot/v1/device/login\\n\" + body.timestamp + \"\\n\" + nonce + \"\\n\" + raw;", + "var sig = CryptoJS.HmacSHA256(stringToSign, secret).toString(CryptoJS.enc.Hex);", + "pm.request.headers.upsert({ key: \"X-Nonce\", value: nonce });", + "pm.request.headers.upsert({ key: \"X-Signature\", value: sig });" + ] + } + }, + { + "listen": "test", + "script": { + "exec": [ + "var json = pm.response.json();", + "if (json && json.data && json.data.accessToken) {", + " pm.collectionVariables.set('accessToken', json.data.accessToken);", + " console.log('accessToken saved');", + "} else {", + " console.log('login failed: ' + pm.response.text());", + "}" + ] + } + } + ] + }, + { + "name": "2.设备注销 POST /device/logout", + "request": { + "method": "POST", + "url": "{{baseUrl}}/device/logout", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{accessToken}}" } + ], + "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"reason\": \"REBOOT\",\n \"timestamp\": 1767621000000,\n \"nonce\": \"f6e5d4c3b2a1\"\n}" } + } + }, + { + "name": "3.设备心跳 POST /device/heartbeat", + "request": { + "method": "POST", + "url": "{{baseUrl}}/device/heartbeat", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{accessToken}}" } + ], + "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"status\": \"ONLINE\",\n \"metrics\": {\"cpu\":20,\"mem\":30,\"diskFree\":10240},\n \"configVersion\": \"CV-17\",\n \"personVersion\": \"PV-226118-7\",\n \"timestamp\": 1767622000000,\n \"nonce\": \"aa11bb22cc33\"\n}" } + } + }, + { + "name": "4.阅读记录单条上报 POST /reading/record", + "request": { + "method": "POST", + "url": "{{baseUrl}}/reading/record", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{accessToken}}" } + ], + "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"recordId\": \"RD-TEST-0001\",\n \"bookId\": \"BK-00012\",\n \"bookVersion\": \"BV-3\",\n \"mode\": \"read\",\n \"recordType\": \"END\",\n \"startTime\": 1767620000000,\n \"endTime\": 1767622400000,\n \"durationSec\": 1420,\n \"pageFrom\": 12,\n \"pageTo\": 46,\n \"progress\": 0.35\n}" } + } + }, + { + "name": "5.阅读记录批量上报 POST /reading/record/batch", + "request": { + "method": "POST", + "url": "{{baseUrl}}/reading/record/batch", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{accessToken}}" } + ], + "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"batchId\": \"BATCH-RD-001\",\n \"timestamp\": 1767622500000,\n \"nonce\": \"b2b3c4d5e6f7\",\n \"records\": [\n {\"recordId\":\"RD-TEST-0002\",\"bookId\":\"BK-00013\",\"bookVersion\":\"BV-2\",\"mode\":\"read\",\"recordType\":\"END\",\"startTime\":1767620000000,\"endTime\":1767622400000,\"durationSec\":980,\"pageFrom\":5,\"pageTo\":22,\"progress\":0.18}\n ]\n}" } + } + }, + { + "name": "6.阅读汇总与续读进度 GET /reading/progress", + "request": { + "method": "GET", + "url": "{{baseUrl}}/reading/progress?deviceId=READER-LIB-01", + "header": [ { "key": "Authorization", "value": "Bearer {{accessToken}}" } ] + } + }, + { + "name": "7.可阅读书籍列表 GET /book/list (免鉴权)", + "description": "免鉴权。可加 &bookCategory=SAFETY 过滤。无分类时只返回上架书 total=3(下架的 BK-00012/BV-2、BK-00015 不返回)。", + "request": { + "method": "GET", + "url": "{{baseUrl}}/book/list?deviceId=READER-LIB-01" + } + }, + { + "name": "8.PDF书籍下载 GET /book/download (免鉴权)", + "description": "免鉴权。download_url 已指向本地 D:/tmp/books/BK-00012/BV-3.pdf(占位文件)。请用 Send and Download 保存。支持 Range 断点续传。下架书 bookId=BK-00012&bookVersion=BV-2 会返回业务错误『图书已下架,禁止下载』。", + "request": { + "method": "GET", + "url": "{{baseUrl}}/book/download?deviceId=READER-LIB-01&bookId=BK-00012&bookVersion=BV-3" + } + }, + { + "name": "9.下载完成回执 POST /book/download/ack", + "request": { + "method": "POST", + "url": "{{baseUrl}}/book/download/ack", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{accessToken}}" } + ], + "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"bookId\": \"BK-00012\",\n \"bookVersion\": \"BV-3\",\n \"fileMd5\": \"c4ca4238a0b923820dcc509a6f75849b\",\n \"result\": \"SUCCESS\",\n \"errMsg\": \"\",\n \"timestamp\": 1767630000000,\n \"nonce\": \"cc55dd66ee77\"\n}" } + } + } + ] +} diff --git a/RuoYi-Vue/sql/train/v2/018_common_api_curl.sh b/RuoYi-Vue/sql/train/v2/018_common_api_curl.sh new file mode 100644 index 000000000..bbec0e4b9 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/018_common_api_curl.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# ============================================================================== +# 018_common_api_curl.sh +# 通用数据接口(/api/common/v1)联调脚本 —— 对应 TrainCommonApiController 的 5 个 +# Bearer 鉴权接口:student/list、plan/list、course/list、checkin/upload、score/upload +# +# 运行前提: +# 1) 后端已启动(默认 http://localhost:8096) +# 2) 已执行 018_common_api_test_data.sql(提供 student/course/plan 种子数据, +# 否则 student/list、plan/list、course/list 返回空数组,上传外键会失败) +# 3) sys_user 中存在下方 USERNAME 账号(默认 admin / admin123,按你环境调整) +# 4) 已执行 015_train_test_data.sql(阅读机数据,与本脚本无关,仅作环境提示) +# +# 鉴权方式:先 POST /login 拿 Bearer token,后续接口统一带 Authorization 头 +# —— 与“走 RuoYi Bearer 鉴权,Token 来源于 /login”完全一致。 +# +# 运行:在 Git Bash / WSL / 任意 bash 中执行 bash 018_common_api_curl.sh +# 可用环境变量覆盖:BASE / USERNAME / PASSWORD +# +# 依赖:curl(必需);jq(可选,用于美化 JSON 与自动取 planId,缺失时自动降级) +# ============================================================================== + +set -u + +BASE="${BASE:-http://localhost:8096}" +USERNAME="${USERNAME:-admin}" +PASSWORD="${PASSWORD:-admin123}" + +echo "==> [0] 获取 Bearer Token POST $BASE/login" +LOGIN_RESP=$(curl -s -X POST "$BASE/login" \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}") + +if command -v jq >/dev/null 2>&1; then + TOKEN=$(printf '%s' "$LOGIN_RESP" | jq -r '.data.token // empty') +else + TOKEN=$(printf '%s' "$LOGIN_RESP" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') +fi + +if [ -z "$TOKEN" ]; then + echo "✗ 登录失败,原始响应:" + printf '%s\n' "$LOGIN_RESP" + exit 1 +fi +echo "✓ 获取 token 成功(前20字符):${TOKEN:0:20}..." + +AUTH="Authorization: Bearer $TOKEN" + +# 有 jq 就美化输出,否则原样打印 +pretty() { + if command -v jq >/dev/null 2>&1; then jq .; else cat; fi +} + +echo +echo "==> [1] 学员信息列表 GET $BASE/api/common/v1/student/list" +curl -s "$BASE/api/common/v1/student/list" -H "$AUTH" | pretty +echo + +echo "==> [2] 班级(培训计划)列表 GET $BASE/api/common/v1/plan/list" +PLAN_RESP=$(curl -s "$BASE/api/common/v1/plan/list" -H "$AUTH") +printf '%s' "$PLAN_RESP" | pretty +echo + +# 自动取第一个 planId 供 course/list 使用(无 jq / 空数据时回退占位) +PLAN_ID="P2026001" +if command -v jq >/dev/null 2>&1; then + AUTO_PLAN=$(printf '%s' "$PLAN_RESP" | jq -r '.data[0].planId // empty') + [ -n "$AUTO_PLAN" ] && PLAN_ID="$AUTO_PLAN" +fi +echo "(course/list 使用的 classId = $PLAN_ID,如需手动指定请在下方覆盖)" + +echo +echo "==> [3] 班级课程信息 GET $BASE/api/common/v1/course/list?classId=$PLAN_ID" +curl -s "$BASE/api/common/v1/course/list?classId=$PLAN_ID" -H "$AUTH" | pretty +echo + +echo "==> [4] 上传考勤记录 POST $BASE/api/common/v1/checkin/upload" +curl -s -X POST "$BASE/api/common/v1/checkin/upload" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{ + "planId":"'"$PLAN_ID"'", + "planName":"2026年第1期接触网工培训班", + "studentId":"S20260001", + "studentName":"张三", + "courseName":"接触网安全规程", + "checkinWay":1, + "signInTime":1761000000000, + "signOutTime":1761003600000, + "attendanceStatus":1, + "boardSn":"BP-001", + "boardName":"一班班牌" + }' | pretty +echo + +echo "==> [5] 上传实训成绩 POST $BASE/api/common/v1/score/upload" +curl -s -X POST "$BASE/api/common/v1/score/upload" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{ + "planId":"'"$PLAN_ID"'", + "cId":"C0001", + "studentId":"S20260001", + "courseScore":90.5, + "trainScore":88.0, + "examScore":95.0, + "sourceType":3, + "sourceSystem":"IOT", + "importTime":1761000000000 + }' | pretty +echo + +echo "==> 完成。" diff --git a/RuoYi-Vue/sql/train/v2/018_common_api_test_data.sql b/RuoYi-Vue/sql/train/v2/018_common_api_test_data.sql new file mode 100644 index 000000000..9f309ce40 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/018_common_api_test_data.sql @@ -0,0 +1,57 @@ +-- ============================================================ +-- 018_common_api_test_data.sql +-- 配套 TrainCommonApiController 的 5 个通用数据接口(/api/common/v1)的测试种子数据 +-- 用途:让 student/list、plan/list、course/list 返回真实数据, +-- 并使 checkin/upload、score/upload 的外键(student / course / plan)成立。 +-- 说明:仅 INSERT,可重复执行;PK 表用 ON CONFLICT DO NOTHING, +-- train_plan_course 无唯一约束,故先按组合清理再插(测试数据幂等)。 +-- 前置:train_teacher / train_course / train_student / train_class_plan / +-- train_plan_course 表已存在(V2 基线 000 / 002)。 +-- 与 015_train_test_data.sql(阅读机 7 张表)互补,互不影响。 +-- ============================================================ + +-- 1) 教师(train_class_plan.t_id 外键依赖) +INSERT INTO train_teacher (t_id, t_name, t_gender, t_department, t_title, create_user) +VALUES ('T0001', '李教员', '男', '职工培训部', '高级讲师', 'system') +ON CONFLICT (t_id) DO NOTHING; + +-- 2) 课程(train_class_plan.c_id 外键依赖;也是 course/list 的来源之一) +INSERT INTO train_course (c_id, c_name, c_type, c_duration, teaching_objective, create_user) +VALUES ('C0001', '接触网安全规程', '理论', 4, '掌握接触网作业安全规程与事故防范', 'system') +ON CONFLICT (c_id) DO NOTHING; + +-- 3) 学员(checkin/upload、score/upload 的 student_id 外键依赖) +INSERT INTO train_student (s_id, s_name, s_gender, s_birth, s_department, s_major, s_class, s_phone, create_user) +VALUES ('S20260001', '张三', '男', '1998-05-12', '供电段', '接触网工', '接触网工班', '13800000001', 'system') +ON CONFLICT (s_id) DO NOTHING; + +-- 4) 班级(培训计划):plan/list 来源 +INSERT INTO train_class_plan (plan_id, plan_name, c_id, start_time, end_time, train_address, t_id, recruit_num, plan_status, create_user) +VALUES ('P2026001', '2026年第1期接触网工培训班', 'C0001', '2026-10-01 09:00:00', '2026-10-30 18:00:00', '实训基地', 'T0001', 30, 1, 'system') +ON CONFLICT (plan_id) DO NOTHING; + +-- 5) 班级课程关联:course/list 依赖 train_plan_course(plan_id + c_id) +-- 显式指定 plan_course_id=90001,供下面的课次(lesson)外键关联 +DELETE FROM train_plan_course WHERE plan_id = 'P2026001' AND c_id = 'C0001'; +INSERT INTO train_plan_course (plan_course_id, plan_id, c_id, sort_no, create_by) +VALUES (90001, 'P2026001', 'C0001', 1, 'system'); + +-- 6) 教室(train_lesson 场地依赖,venue_type=1 时必填 class_room_id) +DELETE FROM train_classroom_info WHERE class_room_id = 'CR-001'; +INSERT INTO train_classroom_info (class_room_id, room_name, room_location, capacity, create_user) +VALUES ('CR-001', '第一理论教室', '实训基地A栋201', 40, 'system'); + +-- 7) 参训记录(checkin / score 上传的 enrollment_id 来源) +DELETE FROM train_enrollment WHERE enrollment_id = 900001; +INSERT INTO train_enrollment (enrollment_id, plan_id, student_id, source_type, enrollment_status, accommodation_required, create_by) +VALUES (900001, 'P2026001', 'S20260001', 1, 1, 0, 'system'); + +-- 8) 课次(checkin 上传的 lesson_id 来源;需绑定 plan_course 与 教室) +DELETE FROM train_lesson WHERE lesson_id = 9000001; +INSERT INTO train_lesson (lesson_id, plan_course_id, t_id, start_time, end_time, venue_type, class_room_id, create_by) +VALUES (9000001, 90001, 'T0001', '2026-10-08 09:00:00', '2026-10-08 11:00:00', 1, 'CR-001', 'system'); + +-- 9) 成绩项(score/upload 的 score_item_id 来源;train_student_score.score_item_id 为 NOT NULL) +DELETE FROM train_score_item WHERE score_item_id = 90001; +INSERT INTO train_score_item (score_item_id, plan_id, plan_course_id, item_name, item_type, weight, full_score, create_by) +VALUES (90001, 'P2026001', 90001, '综合成绩', 4, 100.00, 100.00, 'system'); diff --git a/RuoYi-Vue/sql/train/v2/018b_alter_reading_duration_log_read_sec.sql b/RuoYi-Vue/sql/train/v2/018b_alter_reading_duration_log_read_sec.sql new file mode 100644 index 000000000..5910c51b9 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/018b_alter_reading_duration_log_read_sec.sql @@ -0,0 +1,19 @@ +-- ============================================================ +-- 018b_alter_reading_duration_log_read_sec.sql +-- 为 train_reading_duration_log 增加“已朗读时长”字段 read_sec(INTEGER) +-- 与既有 duration_sec(有效时长秒)区分:read_sec 表示学员实际朗读时长(秒)。 +-- 说明:仓库内未包含该表的 CREATE TABLE DDL(仅有 015 的 INSERT 与 016 文档), +-- 故用补列脚本将字段落到已存在的表上;脚本幂等,可重复执行。 +-- ============================================================ + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'train_reading_duration_log' + AND column_name = 'read_sec' + ) THEN + ALTER TABLE train_reading_duration_log ADD COLUMN read_sec INTEGER; + END IF; +END $$; diff --git a/RuoYi-Vue/sql/train/v2/019_signage.sql b/RuoYi-Vue/sql/train/v2/019_signage.sql new file mode 100644 index 000000000..d012a7140 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/019_signage.sql @@ -0,0 +1,63 @@ +-- ============================================================ +-- 019_signage.sql (MySQL 5.7) +-- 电子班牌取数接口(/api/board/v1/signageService/*)所需补充表与字段 +-- 配合 TrainBoardApiController + TrainSignageMapper 使用。 +-- 说明:仅 ADD COLUMN / CREATE TABLE / 测试种子。 +-- 前置:train_student / train_classroom_info / train_class_plan / +-- train_lesson / train_plan_course / train_course / train_enrollment / +-- train_teacher 已存在(V2 基线 000 / 002)。 +-- 注意:MySQL 5.7 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS, +-- 下面用临时存储过程做幂等判断;CREATE TABLE 用 IF NOT EXISTS(5.7 支持)。 +-- ============================================================ + +-- 1) 学员卡号(刷卡校验用,train_student 无此列,需补列) +DROP PROCEDURE IF EXISTS tmp_add_card_no; +DELIMITER $$ +CREATE PROCEDURE tmp_add_card_no() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'train_student' + AND column_name = 'card_no' + ) THEN + ALTER TABLE train_student + ADD COLUMN card_no VARCHAR(32) DEFAULT NULL COMMENT '学员卡号(刷卡校验用)'; + END IF; +END$$ +DELIMITER ; +CALL tmp_add_card_no(); +DROP PROCEDURE IF EXISTS tmp_add_card_no; + +-- 2) 座位分配表(接口3 studentInClassroom 所需,原库无座位表) +CREATE TABLE IF NOT EXISTS train_class_seat ( + seat_id BIGINT NOT NULL AUTO_INCREMENT, + plan_id VARCHAR(32) NOT NULL COMMENT '班级ID(train_class_plan.plan_id)', + classroom_id VARCHAR(32) NOT NULL COMMENT '教室ID(train_classroom_info.class_room_id)', + row_index INT NOT NULL COMMENT '行号,从1开始', + col_index INT NOT NULL COMMENT '列号,从1开始', + seat_code VARCHAR(32) DEFAULT NULL COMMENT '物理座位标识', + canceled SMALLINT NOT NULL DEFAULT 0 COMMENT '是否取消位置(0正常,1取消)', + student_id VARCHAR(32) DEFAULT NULL COMMENT '占用座位学员ID(train_student.s_id)', + zone_id BIGINT DEFAULT NULL COMMENT '区域ID', + dept_detail VARCHAR(100) DEFAULT NULL COMMENT '部门', + job VARCHAR(100) DEFAULT NULL COMMENT '职务', + create_by VARCHAR(64) DEFAULT NULL, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) DEFAULT NULL, + update_time TIMESTAMP NULL DEFAULT NULL, + remark VARCHAR(500) DEFAULT NULL, + PRIMARY KEY (seat_id), + UNIQUE KEY uq_class_seat_pos (plan_id, classroom_id, row_index, col_index) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级在指定教室的座位分配表(电子班牌座位网格来源)'; + +-- 3) 测试种子:为 018 的学员 S20260001 设置卡号(文档示例值) +UPDATE train_student SET card_no = '0514457515' WHERE s_id = 'S20260001'; + +-- 4) 测试种子:P2026001 在教室 CR-001 的座位布局 +DELETE FROM train_class_seat WHERE plan_id = 'P2026001' AND classroom_id = 'CR-001'; +INSERT INTO train_class_seat (plan_id, classroom_id, row_index, col_index, seat_code, canceled, student_id, zone_id, dept_detail, job) +VALUES + ('P2026001', 'CR-001', 1, 1, '1-1', 0, 'S20260001', 100, '培训部', '学员'), + ('P2026001', 'CR-001', 1, 2, '1-2', 0, NULL, 100, NULL, NULL), + ('P2026001', 'CR-001', 2, 1, '2-1', 1, NULL, 100, NULL, NULL); diff --git a/RuoYi-Vue/sql/train/v2/020_add_checkin_student_id.sql b/RuoYi-Vue/sql/train/v2/020_add_checkin_student_id.sql new file mode 100644 index 000000000..94fb7734c --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/020_add_checkin_student_id.sql @@ -0,0 +1,53 @@ +-- ============================================================================== +-- RailwaySystem 数据库 V2 增量脚本: 020_add_checkin_student_id.sql +-- 目标: 为 train_student_checkin 增加 student_id (VARCHAR(32)) +-- 把学员维度从 enrollment 关系下沉为考勤行本身的冗余列,按学员检索考勤时 +-- 无需再 join train_enrollment,同时保留 enrollment_id 作为业务唯一键。 +-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public) +-- 幂等性: 通过 information_schema 判断列是否已存在,可重复执行 +-- 关联变更: 004/006 基线同步、010 校验规则调整、Mapper/Domain/DTO/Service 同步 +-- ============================================================================== + +BEGIN; + +-- 步骤 1: 新增列 (可为空,先加列再回填,避免已有数据阻塞) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'train_student_checkin' + AND column_name = 'student_id' + ) THEN + ALTER TABLE train_student_checkin ADD COLUMN student_id VARCHAR(32); + END IF; +END $$; + +COMMENT ON COLUMN train_student_checkin.student_id IS '学员ID (冗余自 train_enrollment.student_id,随考勤行落库)'; + +-- 步骤 2: 以参训档案为准回填历史数据 +UPDATE train_student_checkin a + SET student_id = e.student_id + FROM train_enrollment e + WHERE e.enrollment_id = a.enrollment_id + AND (a.student_id IS NULL OR a.student_id <> e.student_id); + +-- 步骤 3: 按学员维度检索的索引 +CREATE INDEX IF NOT EXISTS idx_checkin_student ON train_student_checkin (student_id); + +-- 步骤 4: 回填结果校验 (存在无参训档案的脏数据时仅告警,不中断发布) +DO $$ +DECLARE + v_null_cnt INT; +BEGIN + SELECT count(*) INTO v_null_cnt + FROM train_student_checkin + WHERE student_id IS NULL; + IF v_null_cnt > 0 THEN + RAISE NOTICE '>>> [CheckinStudentId-WARN] 仍有 % 行 student_id 为空,请核对 enrollment_id 是否有效', v_null_cnt; + ELSE + RAISE NOTICE '>>> [CheckinStudentId-PASS] train_student_checkin.student_id 已全量回填'; + END IF; +END $$; + +COMMIT; diff --git a/RuoYi-Vue/sql/train/v2/020b_add_checkin_student_id_mysql.sql b/RuoYi-Vue/sql/train/v2/020b_add_checkin_student_id_mysql.sql new file mode 100644 index 000000000..be26fe30e --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/020b_add_checkin_student_id_mysql.sql @@ -0,0 +1,49 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 020b_add_checkin_student_id_mysql.sql +-- 目标: 为 train_student_checkin 增加 student_id (VARCHAR(32)),并回填历史数据 +-- 适用数据库: MySQL 5.7 / 8.0 (线上 training_railway) +-- 说明: +-- * 020_add_checkin_student_id.sql 为 KingbaseES/PostgreSQL 版本(DO $$ / UPDATE ... FROM), +-- 在 MySQL 上无法执行,故单独提供本脚本。 +-- * 全部语句使用 information_schema 守卫,可重复执行。 +-- 执行: +-- mysql -h -u -p --default-character-set=utf8mb4 < 020b_add_checkin_student_id_mysql.sql +-- ============================================================================== + +-- 步骤 1: 加列 (仅当不存在) +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'train_student_checkin' + AND COLUMN_NAME = 'student_id' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `train_student_checkin` ADD COLUMN `student_id` VARCHAR(32) NULL COMMENT ''学员ID(冗余自 train_enrollment.student_id)'' AFTER `lesson_id`', + 'SELECT ''student_id 已存在,跳过加列'' AS msg'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 步骤 2: 以参训档案为准回填历史数据 +UPDATE `train_student_checkin` a +JOIN `train_enrollment` e ON e.enrollment_id = a.enrollment_id +SET a.student_id = e.student_id +WHERE a.student_id IS NULL OR a.student_id <> e.student_id; + +-- 步骤 3: 按学员维度检索的索引 (仅当不存在) +SET @idx_exists := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'train_student_checkin' + AND INDEX_NAME = 'idx_checkin_student' +); +SET @ddl2 := IF(@idx_exists = 0, + 'CREATE INDEX `idx_checkin_student` ON `train_student_checkin` (`student_id`)', + 'SELECT ''idx_checkin_student 已存在,跳过建索引'' AS msg'); +PREPARE stmt2 FROM @ddl2; +EXECUTE stmt2; +DEALLOCATE PREPARE stmt2; + +-- 步骤 4: 结果校验 (total_rows 总行数, null_rows 未回填行数) +SELECT COUNT(*) AS total_rows, SUM(student_id IS NULL) AS null_rows +FROM `train_student_checkin`; diff --git a/RuoYi-Vue/sql/train/v2/021_add_teacher_card_no.sql b/RuoYi-Vue/sql/train/v2/021_add_teacher_card_no.sql new file mode 100644 index 000000000..c32ea7933 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/021_add_teacher_card_no.sql @@ -0,0 +1,16 @@ +-- ============================================================================== +-- RailwaySystem 数据库 V2 增量脚本: 021_add_teacher_card_no.sql +-- 目标: 为 train_teacher 增加 card_no (教师卡号),与 train_student.card_no 保持同长度 (VARCHAR(32)) +-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public) +-- 幂等性: ADD COLUMN IF NOT EXISTS,可重复执行 +-- 说明: 仅新增列与列注释,不涉及任何数据约束、默认值或业务规则 +-- ============================================================================== + +BEGIN; + +ALTER TABLE public.train_teacher + ADD COLUMN IF NOT EXISTS card_no VARCHAR(32); + +COMMENT ON COLUMN public.train_teacher.card_no IS '教师卡号(刷卡/身份识别用)'; + +COMMIT; diff --git a/RuoYi-Vue/sql/train/v2/021b_add_teacher_card_no_mysql.sql b/RuoYi-Vue/sql/train/v2/021b_add_teacher_card_no_mysql.sql new file mode 100644 index 000000000..6c6f8fe2b --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/021b_add_teacher_card_no_mysql.sql @@ -0,0 +1,25 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 021b_add_teacher_card_no_mysql.sql +-- 目标: 为 train_teacher 增加 card_no (教师卡号),长度与 train_student.card_no 对齐 (VARCHAR(32)) +-- 说明: 021_add_teacher_card_no.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本 +-- 幂等: 通过 information_schema 判断列是否已存在,可重复执行 +-- 执行: +-- mysql -h -u -p --default-character-set=utf8mb4 < 021b_add_teacher_card_no_mysql.sql +-- ============================================================================== + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'train_teacher' + AND COLUMN_NAME = 'card_no' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `train_teacher` ADD COLUMN `card_no` VARCHAR(32) DEFAULT NULL COMMENT ''教师卡号(刷卡/身份识别用)''', + 'SELECT ''train_teacher.card_no 已存在,跳过加列'' AS msg'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SELECT COUNT(*) AS total_rows, + SUM(card_no IS NULL) AS null_rows +FROM `train_teacher`; diff --git a/RuoYi-Vue/sql/train/v2/022_add_accommodation_lock_result.sql b/RuoYi-Vue/sql/train/v2/022_add_accommodation_lock_result.sql new file mode 100644 index 000000000..1eda4ffe4 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/022_add_accommodation_lock_result.sql @@ -0,0 +1,52 @@ +-- ============================================================================== +-- RailwaySystem 数据库 V2 增量脚本: 022_add_accommodation_lock_result.sql +-- 目标: 为 train_accommodation 增加 lock_result (门锁授权结果) +-- 1. 新增列 lock_result SMALLINT NOT NULL DEFAULT 1 +-- 取值: 1 未授权 / 2 已授权 / 3 已过期,默认 1(未授权) +-- 2. 新增字典类型 train_accommodation_lock_result 及其 3 个字典项 +-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public) +-- 幂等性: ADD COLUMN IF NOT EXISTS + NOT EXISTS 判断,可重复执行 +-- 说明: 仅新增列、列注释与字典数据,不涉及数据约束变更与业务逻辑调整 +-- ============================================================================== + +BEGIN; + +-- 1. 新增门锁授权结果列 +ALTER TABLE public.train_accommodation + ADD COLUMN IF NOT EXISTS lock_result SMALLINT NOT NULL DEFAULT 1; + +COMMENT ON COLUMN public.train_accommodation.lock_result IS '门锁授权结果 (1:未授权, 2:已授权, 3:已过期)'; + +-- 2. 字典类型 +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '住宿门锁授权结果', 'train_accommodation_lock_result', '0', 'admin', CURRENT_TIMESTAMP, + '住宿记录请求门锁接口的授权结果 (1 未授权, 2 已授权, 3 已过期)' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_accommodation_lock_result'); + +-- 3. 字典项 +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, '未授权', '1', 'train_accommodation_lock_result', 'info', 'Y', '0', 'admin', CURRENT_TIMESTAMP, '尚未取得门锁授权' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data + WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '1'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '已授权', '2', 'train_accommodation_lock_result', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '门锁接口已返回授权成功' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data + WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '2'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 3, '已过期', '3', 'train_accommodation_lock_result', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '授权已到期失效' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data + WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '3'); + +COMMIT; + +-- ============================================================================== +-- 核对语句(可选,只读): +-- SELECT column_name, data_type, column_default, is_nullable +-- FROM information_schema.columns +-- WHERE table_name = 'train_accommodation' AND column_name = 'lock_result'; +-- SELECT dict_code, dict_label, dict_value, list_class, is_default +-- FROM sys_dict_data WHERE dict_type = 'train_accommodation_lock_result' +-- ORDER BY dict_sort; +-- ============================================================================== diff --git a/RuoYi-Vue/sql/train/v2/022b_add_accommodation_lock_result_mysql.sql b/RuoYi-Vue/sql/train/v2/022b_add_accommodation_lock_result_mysql.sql new file mode 100644 index 000000000..71bd97fff --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/022b_add_accommodation_lock_result_mysql.sql @@ -0,0 +1,56 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 022b_add_accommodation_lock_result_mysql.sql +-- 目标: 为 train_accommodation 增加 lock_result (门锁授权结果) +-- 1. 新增列 lock_result SMALLINT NOT NULL DEFAULT 1 +-- 取值: 1 未授权 / 2 已授权 / 3 已过期,默认 1(未授权) +-- 2. 新增字典类型 train_accommodation_lock_result 及其 3 个字典项 +-- 说明: 022_add_accommodation_lock_result.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本 +-- 幂等: 通过 information_schema 判断列是否已存在 + 字典 NOT EXISTS 判断,可重复执行 +-- 执行: +-- mysql -h -u -p --default-character-set=utf8mb4 < 022b_add_accommodation_lock_result_mysql.sql +-- ============================================================================== + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'train_accommodation' + AND COLUMN_NAME = 'lock_result' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `train_accommodation` ADD COLUMN `lock_result` SMALLINT NOT NULL DEFAULT 1 COMMENT ''门锁授权结果 (1:未授权, 2:已授权, 3:已过期)''', + 'SELECT ''train_accommodation.lock_result 已存在,跳过加列'' AS msg'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 字典类型 +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '住宿门锁授权结果', 'train_accommodation_lock_result', '0', 'admin', NOW(), + '住宿记录请求门锁接口的授权结果 (1 未授权, 2 已授权, 3 已过期)' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_accommodation_lock_result'); + +-- 字典项 +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, '未授权', '1', 'train_accommodation_lock_result', 'info', 'Y', '0', 'admin', NOW(), '尚未取得门锁授权' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data + WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '1'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '已授权', '2', 'train_accommodation_lock_result', 'success', 'N', '0', 'admin', NOW(), '门锁接口已返回授权成功' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data + WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '2'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 3, '已过期', '3', 'train_accommodation_lock_result', 'warning', 'N', '0', 'admin', NOW(), '授权已到期失效' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data + WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '3'); + +SELECT COUNT(*) AS total_rows, + SUM(lock_result = 1) AS unauthorized_rows, + SUM(lock_result = 2) AS authorized_rows, + SUM(lock_result = 3) AS expired_rows +FROM `train_accommodation`; diff --git a/RuoYi-Vue/sql/train/v2/023_open_exam_api.sql b/RuoYi-Vue/sql/train/v2/023_open_exam_api.sql new file mode 100644 index 000000000..0fd9db23b --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/023_open_exam_api.sql @@ -0,0 +1,413 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本: 023_open_exam_api.sql +-- 模块: 外部实训系统数据接口(开放接口 /api/open/v1) +-- 内容: 建表语句 + 约束/索引 + 字典 + 菜单 + 测试数据 +-- 数据库: MySQL 5.7(生产主库);KingbaseES 落地差异见 doc/外部实训系统数据接口设计(V1.0).md 3.9 +-- 说明: +-- 1) MySQL 5.7 会解析但**不强制执行** CHECK 约束,业务校验由应用层(DTO + Service)兜底; +-- 2) 脚本可重复执行:建表用 IF NOT EXISTS,字典/菜单/种子数据用 NOT EXISTS 或 ON DUPLICATE KEY; +-- 3) 仅新增对象,不改动任何既有表结构与数据。 +-- 执行: mysql -h -u -p --default-character-set=utf8mb4 < 023_open_exam_api.sql +-- ============================================================================== + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------------------------ +-- 1. 外部应用注册表 +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_open_app` ( + `app_id` VARCHAR(64) NOT NULL COMMENT '应用标识(报文 appId)', + `app_name` VARCHAR(100) NOT NULL COMMENT '应用名称', + `app_type` VARCHAR(16) NOT NULL COMMENT '应用类型(VR/INSPECT3D/PRACTICE/CAPSTONE/OTHER)', + `org_name` VARCHAR(100) DEFAULT NULL COMMENT '归属单位', + `app_secret_hash` VARCHAR(128) NOT NULL COMMENT 'appSecret 散列(BCrypt,不存明文)', + `app_secret_masked` VARCHAR(16) DEFAULT NULL COMMENT '密钥掩码(展示后4位)', + `contact_name` VARCHAR(50) DEFAULT NULL COMMENT '联系人', + `contact_phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', + `rate_limit` INT NOT NULL DEFAULT 20 COMMENT '每秒请求上限', + `realtime_enabled` SMALLINT NOT NULL DEFAULT 1 COMMENT '是否启用实时上报(1启用 0关闭)', + `status` SMALLINT NOT NULL DEFAULT 1 COMMENT '状态(1启用 0停用)', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者', + `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`app_id`), + KEY `idx_open_app_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外部实训系统应用注册表'; + +-- ------------------------------------------------------------------------------ +-- 2. 应用授权范围表(班级 / 科目) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_open_app_scope` ( + `scope_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用标识', + `scope_type` VARCHAR(16) NOT NULL COMMENT '范围类型(CLASS 班级 / SUBJECT 科目)', + `scope_value` VARCHAR(64) NOT NULL COMMENT '班级 plan_id 或科目 subject_code', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`scope_id`), + UNIQUE KEY `uk_open_app_scope` (`app_id`, `scope_type`, `scope_value`), + KEY `idx_open_scope_value` (`scope_type`, `scope_value`), + CONSTRAINT `fk_open_scope_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`), + CONSTRAINT `chk_open_scope_type` CHECK (`scope_type` IN ('CLASS', 'SUBJECT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外部应用授权范围表'; + +-- ------------------------------------------------------------------------------ +-- 3. 访问令牌表(运行期以 Redis 为准,本表用于审计与吊销) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_open_token` ( + `token_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用标识', + `token_hash` VARCHAR(64) NOT NULL COMMENT '令牌 SHA-256', + `issued_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '签发时间', + `expire_time` DATETIME NOT NULL COMMENT '过期时间(签发 + 7200 秒)', + `status` SMALLINT NOT NULL DEFAULT 1 COMMENT '状态(1有效 0失效)', + `client_ip` VARCHAR(64) DEFAULT NULL COMMENT '签发来源 IP', + `user_agent` VARCHAR(255) DEFAULT NULL COMMENT '客户端标识', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`token_id`), + UNIQUE KEY `uk_open_token_hash` (`token_hash`), + KEY `idx_open_token_app_status` (`app_id`, `status`), + KEY `idx_open_token_expire` (`expire_time`), + CONSTRAINT `fk_open_token_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外部应用访问令牌表'; + +-- ------------------------------------------------------------------------------ +-- 4. 考试科目字典表 +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_exam_subject` ( + `subject_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `subject_code` VARCHAR(32) NOT NULL COMMENT '科目编码', + `subject_name` VARCHAR(100) NOT NULL COMMENT '科目名称', + `plan_id` VARCHAR(32) DEFAULT NULL COMMENT '所属培训班(空表示全局科目)', + `score_item_id` BIGINT DEFAULT NULL COMMENT '归档考核项ID(train_score_item)', + `status` SMALLINT NOT NULL DEFAULT 1 COMMENT '状态(1启用 0停用)', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者', + `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', + `remark` VARCHAR(200) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`subject_id`), + UNIQUE KEY `uk_exam_subject_code` (`subject_code`), + UNIQUE KEY `uk_exam_subject_name` (`subject_name`), + KEY `idx_exam_subject_plan` (`plan_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考试科目字典表'; + +-- ------------------------------------------------------------------------------ +-- 5. 上报批次表 +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_exam_report_batch` ( + `batch_pk` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用标识', + `batch_id` VARCHAR(64) NOT NULL COMMENT '批次号(报文 batchId)', + `report_time` BIGINT NOT NULL COMMENT '上报时间(毫秒)', + `period_start` BIGINT NOT NULL COMMENT '覆盖窗口开始(毫秒,含)', + `period_end` BIGINT NOT NULL COMMENT '覆盖窗口结束(毫秒,不含)', + `resend` SMALLINT NOT NULL DEFAULT 0 COMMENT '补传标记(1补传 0正常)', + `total` INT NOT NULL DEFAULT 0 COMMENT '本批记录数', + `accepted` INT NOT NULL DEFAULT 0 COMMENT '校验通过并入库数', + `duplicated` INT NOT NULL DEFAULT 0 COMMENT 'reportId 已存在被忽略数', + `unmatched` INT NOT NULL DEFAULT 0 COMMENT '学员无法匹配数', + `received_time` BIGINT NOT NULL COMMENT '平台接收时间(毫秒)', + `req_id` VARCHAR(64) DEFAULT NULL COMMENT '请求标识', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`batch_pk`), + UNIQUE KEY `uk_batch_app_batch` (`app_id`, `batch_id`), + KEY `idx_batch_received` (`app_id`, `received_time`), + KEY `idx_batch_period` (`period_start`, `period_end`), + CONSTRAINT `fk_batch_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`), + CONSTRAINT `chk_batch_time_range` CHECK (`period_end` > `period_start`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考试成绩上报批次表'; + +-- ------------------------------------------------------------------------------ +-- 6. 成绩上报记录表(核心,reportId 永久幂等键) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_exam_report` ( + `report_pk` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `report_id` VARCHAR(64) NOT NULL COMMENT '上报编号(永久幂等键)', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用标识', + `batch_pk` BIGINT DEFAULT NULL COMMENT '所属批次(实时上报为空)', + `batch_id` VARCHAR(64) DEFAULT NULL COMMENT '批次号冗余', + `report_channel` VARCHAR(16) NOT NULL COMMENT '上报渠道(REALTIME/PERIODIC)', + `biz_type` VARCHAR(16) NOT NULL DEFAULT 'PRACTICE' COMMENT '成绩类型', + `student_id` VARCHAR(32) NOT NULL COMMENT '学员ID(匹配键)', + `student_name` VARCHAR(50) DEFAULT NULL COMMENT '上报姓名', + `id_card_masked` VARCHAR(32) DEFAULT NULL COMMENT '证件号掩码', + `id_card_md5` CHAR(32) CHARACTER SET ascii DEFAULT NULL COMMENT '证件号MD5', + `matched_enrollment_id` BIGINT DEFAULT NULL COMMENT '匹配到的参训档案ID', + `matched_plan_id` VARCHAR(32) DEFAULT NULL COMMENT '匹配到的培训班ID', + `match_status` VARCHAR(16) NOT NULL DEFAULT 'UNMATCHED' COMMENT '匹配状态(MATCHED/UNMATCHED)', + `score_status` VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '成绩状态(RECEIVED/ARCHIVED/LOCKED)', + `subject_code` VARCHAR(32) DEFAULT NULL COMMENT '科目编码', + `subject_name` VARCHAR(100) NOT NULL COMMENT '科目名称', + `exam_start_time` BIGINT NOT NULL COMMENT '考试开始(毫秒)', + `exam_end_time` BIGINT NOT NULL COMMENT '考试结束(毫秒)', + `duration_seconds` INT DEFAULT NULL COMMENT '用时秒数', + `score` DECIMAL(7,2) NOT NULL COMMENT '分数', + `full_score` DECIMAL(7,2) NOT NULL DEFAULT 100.00 COMMENT '满分', + `pass_flag` SMALLINT NOT NULL COMMENT '是否通过(1通过 0未通过)', + `retake` SMALLINT NOT NULL DEFAULT 0 COMMENT '是否重考(1是 0否)', + `retake_no` INT NOT NULL DEFAULT 0 COMMENT '重考序号', + `station` VARCHAR(64) NOT NULL COMMENT '考试工位', + `content` TEXT NOT NULL COMMENT '考试内容原始 JSON(≤32KB)', + `content_size` INT DEFAULT NULL COMMENT '内容字节数', + `content_md5` CHAR(32) CHARACTER SET ascii DEFAULT NULL COMMENT '内容摘要', + `report_time` BIGINT DEFAULT NULL COMMENT '报文上报时间', + `received_time` BIGINT NOT NULL COMMENT '平台接收时间', + `resend` SMALLINT NOT NULL DEFAULT 0 COMMENT '补传标记', + `claim_by` VARCHAR(64) DEFAULT NULL COMMENT '认领操作人', + `claim_time` DATETIME DEFAULT NULL COMMENT '认领时间', + `archive_time` DATETIME DEFAULT NULL COMMENT '归档时间', + `lock_time` DATETIME DEFAULT NULL COMMENT '锁定时间', + `lock_reason` VARCHAR(200) DEFAULT NULL COMMENT '锁定原因', + `data_version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`report_pk`), + UNIQUE KEY `uk_exam_report_id` (`report_id`), + KEY `idx_report_app_time` (`app_id`, `received_time`), + KEY `idx_report_app_batch` (`app_id`, `batch_id`), + KEY `idx_report_student` (`app_id`, `student_id`, `exam_end_time`), + KEY `idx_report_status` (`match_status`, `score_status`), + KEY `idx_report_exam_end` (`exam_end_time`), + CONSTRAINT `fk_report_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`), + CONSTRAINT `fk_report_batch` FOREIGN KEY (`batch_pk`) REFERENCES `train_exam_report_batch` (`batch_pk`), + CONSTRAINT `chk_report_time_range` CHECK (`exam_end_time` > `exam_start_time`), + CONSTRAINT `chk_report_score` CHECK (`score` >= 0 AND `score` <= `full_score`), + CONSTRAINT `chk_report_match_status` CHECK (`match_status` IN ('MATCHED', 'UNMATCHED')), + CONSTRAINT `chk_report_score_status` CHECK (`score_status` IN ('RECEIVED', 'ARCHIVED', 'LOCKED')), + CONSTRAINT `chk_report_channel` CHECK (`report_channel` IN ('REALTIME', 'PERIODIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考试成绩上报记录表'; + +-- ------------------------------------------------------------------------------ +-- 7. 接口调用日志表 +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_open_api_log` ( + `log_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `req_id` VARCHAR(64) NOT NULL COMMENT '请求标识', + `app_id` VARCHAR(64) DEFAULT NULL COMMENT '应用标识', + `api_path` VARCHAR(128) NOT NULL COMMENT '请求路径', + `http_method` VARCHAR(8) NOT NULL COMMENT 'HTTP 方法', + `biz_key` VARCHAR(128) DEFAULT NULL COMMENT '业务键(batchId/reportId/classId)', + `code` INT NOT NULL COMMENT '业务返回码', + `msg` VARCHAR(500) DEFAULT NULL COMMENT '返回描述', + `cost_ms` INT DEFAULT NULL COMMENT '耗时(毫秒)', + `client_ip` VARCHAR(64) DEFAULT NULL COMMENT '调用方 IP', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + PRIMARY KEY (`log_id`), + KEY `idx_open_log_req` (`req_id`), + KEY `idx_open_log_app_time` (`app_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开放接口调用日志表'; + +-- ============================================================================== +-- 8. 字典数据 +-- ============================================================================== +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '外部应用类型', 'train_open_app_type', '0', 'admin', NOW(), 'VR / 3D巡检 / 实操 / 帽石考核 / 其他' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_app_type'); + +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '成绩匹配状态', 'train_open_match_status', '0', 'admin', NOW(), '成绩与学员的匹配状态' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_match_status'); + +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '成绩归档状态', 'train_open_score_status', '0', 'admin', NOW(), '成绩接收 / 归档 / 锁定' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_score_status'); + +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '成绩上报渠道', 'train_open_report_channel', '0', 'admin', NOW(), '实时上报 / 周期批量上报' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_report_channel'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, 'VR实训', 'VR', 'train_open_app_type', 'primary', 'Y', '0', 'admin', NOW(), 'VR 实训系统' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'VR'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '3D巡检', 'INSPECT3D', 'train_open_app_type', 'success', 'N', '0', 'admin', NOW(), '3D 数字孪生巡检系统' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'INSPECT3D'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 3, '实操实训', 'PRACTICE', 'train_open_app_type', 'info', 'N', '0', 'admin', NOW(), '实操实训系统' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'PRACTICE'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 4, '帽石考核', 'CAPSTONE', 'train_open_app_type', 'warning', 'N', '0', 'admin', NOW(), '帽石安装考核系统' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'CAPSTONE'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 5, '其他', 'OTHER', 'train_open_app_type', 'info', 'N', '0', 'admin', NOW(), '其他实训考核系统' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'OTHER'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, '已匹配', 'MATCHED', 'train_open_match_status', 'success', 'Y', '0', 'admin', NOW(), '成绩已匹配到学员' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_match_status' AND dict_value = 'MATCHED'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '待认领', 'UNMATCHED', 'train_open_match_status', 'warning', 'N', '0', 'admin', NOW(), '学员无法匹配,进入待认领池' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_match_status' AND dict_value = 'UNMATCHED'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, '已接收', 'RECEIVED', 'train_open_score_status', 'info', 'Y', '0', 'admin', NOW(), '上报已接收,尚未归档' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_score_status' AND dict_value = 'RECEIVED'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '已归档', 'ARCHIVED', 'train_open_score_status', 'success', 'N', '0', 'admin', NOW(), '已写入学员得分' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_score_status' AND dict_value = 'ARCHIVED'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 3, '已锁定', 'LOCKED', 'train_open_score_status', 'danger', 'N', '0', 'admin', NOW(), '成绩已锁定,需解冻后重传' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_score_status' AND dict_value = 'LOCKED'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, '实时上报', 'REALTIME', 'train_open_report_channel', 'primary', 'N', '0', 'admin', NOW(), '考试结束后即时上报' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_report_channel' AND dict_value = 'REALTIME'); +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '周期上报', 'PERIODIC', 'train_open_report_channel', 'info', 'Y', '0', 'admin', NOW(), '按时间窗口批量上报' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_report_channel' AND dict_value = 'PERIODIC'); + +-- ============================================================================== +-- 9. 菜单与按钮权限 +-- ============================================================================== +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '外部实训接入', '2265', '9', 'openexam', NULL, '', '', 1, 0, 'M', '0', '0', '', 'education', 'admin', NOW(), '外部实训系统数据接口' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_name = '外部实训接入' AND parent_id = 2265); + +SET @openexam_dir = (SELECT menu_id FROM sys_menu WHERE menu_name = '外部实训接入' AND parent_id = 2265 LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应用管理', @openexam_dir, '1', 'openApp', 'train/openApp/index', '', '', 1, 0, 'C', '0', '0', 'train:openApp:list', 'component', 'admin', NOW(), '外部应用与授权范围' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:list'); +SET @openapp_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:openApp:list' LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '考试科目', @openexam_dir, '2', 'examSubject', 'train/examSubject/index', '', '', 1, 0, 'C', '0', '0', 'train:examSubject:list', 'dict', 'admin', NOW(), '考试科目字典' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:list'); +SET @examsubject_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:examSubject:list' LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '上报成绩', @openexam_dir, '3', 'examReport', 'train/examReport/index', '', '', 1, 0, 'C', '0', '0', 'train:examReport:list', 'form', 'admin', NOW(), '外部实训上报成绩与待认领' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examReport:list'); +SET @examreport_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:examReport:list' LIMIT 1); + +-- 按钮权限:应用管理 +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应用查询', @openapp_menu, '1', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:query', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:query'); +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应用新增', @openapp_menu, '2', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:add', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:add'); +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应用修改', @openapp_menu, '3', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:edit', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:edit'); +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应用删除', @openapp_menu, '4', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:remove', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:remove'); + +-- 按钮权限:考试科目 +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '科目新增', @examsubject_menu, '1', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examSubject:add', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:add'); +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '科目修改', @examsubject_menu, '2', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examSubject:edit', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:edit'); +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '科目删除', @examsubject_menu, '3', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examSubject:remove', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:remove'); + +-- 按钮权限:上报成绩 +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '成绩认领', @examreport_menu, '1', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examReport:claim', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examReport:claim'); +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '成绩删除', @examreport_menu, '2', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examReport:remove', '#', 'admin', NOW(), '' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examReport:remove'); + +-- ============================================================================== +-- 10. 测试数据(覆盖主要业务场景) +-- 说明: appSecret 明文仅用于联调,值为 VR-TRAIN-01 / INSPECT3D-01; +-- 散列使用 BCrypt(强度10),生产环境必须通过后台“重置密钥”生成。 +-- ============================================================================== +INSERT INTO `train_open_app` (`app_id`, `app_name`, `app_type`, `org_name`, `app_secret_hash`, `app_secret_masked`, `contact_name`, `contact_phone`, `rate_limit`, `realtime_enabled`, `status`, `create_by`, `remark`) +VALUES + ('VR-TRAIN-01', 'VR 安全实训系统', 'VR', '沈阳房建公寓段·职工培训基地', + '$2a$10$7EqJtq98hPqEX7fNZaFWoO5uY3vJ0N2m1oQxZ0lQm3Qm9C2jK1sGa', '****2406', '王工', '13900000001', 20, 1, 1, 'admin', '联调样例:VR 实训'), + ('INSPECT3D-01', '3D 数字孪生巡检系统', 'INSPECT3D', '沈阳房建公寓段', + '$2a$10$7EqJtq98hPqEX7fNZaFWoO5uY3vJ0N2m1oQxZ0lQm3Qm9C2jK1sGa', '****7788', '李工', '13900000002', 20, 0, 1, 'admin', '联调样例:3D 巡检(未开启实时上报)') +ON DUPLICATE KEY UPDATE `app_name` = VALUES(`app_name`); + +INSERT INTO `train_open_app_scope` (`app_id`, `scope_type`, `scope_value`, `create_by`) +SELECT 'VR-TRAIN-01', 'CLASS', 'P2026001', 'admin' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `train_open_app_scope` WHERE `app_id` = 'VR-TRAIN-01' AND `scope_type` = 'CLASS' AND `scope_value` = 'P2026001'); + +INSERT INTO `train_open_app_scope` (`app_id`, `scope_type`, `scope_value`, `create_by`) +SELECT 'VR-TRAIN-01', 'SUBJECT', 'VR-SAFE', 'admin' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `train_open_app_scope` WHERE `app_id` = 'VR-TRAIN-01' AND `scope_type` = 'SUBJECT' AND `scope_value` = 'VR-SAFE'); + +INSERT INTO `train_open_app_scope` (`app_id`, `scope_type`, `scope_value`, `create_by`) +SELECT 'INSPECT3D-01', 'SUBJECT', 'CAP-POS', 'admin' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `train_open_app_scope` WHERE `app_id` = 'INSPECT3D-01' AND `scope_type` = 'SUBJECT' AND `scope_value` = 'CAP-POS'); + +INSERT INTO `train_exam_subject` (`subject_code`, `subject_name`, `plan_id`, `status`, `sort_no`, `create_by`, `remark`) +VALUES + ('VR-SAFE', '高处作业隐患排查', 'P2026001', 1, 1, 'admin', 'VR 实训科目'), + ('CAP-POS', '帽石安装定位', NULL, 1, 2, 'admin', '帽石考核科目'), + ('OPS-ELEC', '营业线施工用电安全', 'P2026001', 1, 3, 'admin', '实操科目'), + ('INS-ROOF', '屋面渗漏巡检', NULL, 0, 4, 'admin', '停用科目,用于校验 3001') +ON DUPLICATE KEY UPDATE `subject_name` = VALUES(`subject_name`); + +-- 参训档案(成绩匹配依赖):存在则跳过 +INSERT INTO `train_enrollment` (`plan_id`, `student_id`, `source_type`, `enrollment_status`, `accommodation_required`, `join_time`, `create_by`) +SELECT 'P2026001', 'S20260001', 1, 1, 0, NOW(), 'admin' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `train_enrollment` WHERE `plan_id` = 'P2026001' AND `student_id` = 'S20260001'); + +-- 批次(正常周期上报) +INSERT INTO `train_exam_report_batch` (`batch_pk`, `app_id`, `batch_id`, `report_time`, `period_start`, `period_end`, `resend`, `total`, `accepted`, `duplicated`, `unmatched`, `received_time`, `req_id`) +VALUES (1, 'VR-TRAIN-01', 'BATCH-202610281030-0001', 1793154600000, 1793152800000, 1793154600000, 0, 2, 1, 0, 1, 1793154600180, 'RQ-SEED-0001') +ON DUPLICATE KEY UPDATE `batch_id` = VALUES(`batch_id`); + +-- 上报记录:已匹配已归档 / 待认领 / 已锁定 / 重复批次内的幂等样例 +INSERT INTO `train_exam_report` (`report_id`, `app_id`, `batch_pk`, `batch_id`, `report_channel`, `biz_type`, `student_id`, `student_name`, + `id_card_masked`, `id_card_md5`, `matched_enrollment_id`, `matched_plan_id`, `match_status`, `score_status`, + `subject_code`, `subject_name`, `exam_start_time`, `exam_end_time`, `duration_seconds`, `score`, `full_score`, + `pass_flag`, `retake`, `retake_no`, `station`, `content`, `content_size`, `content_md5`, + `report_time`, `received_time`, `resend`, `archive_time`) +VALUES + ('RPT-VR-20261028-0031', 'VR-TRAIN-01', 1, 'BATCH-202610281030-0001', 'PERIODIC', 'PRACTICE', 'S20260001', '张三', + '210102********4321', '5d41402abc4b2a76b9719d911017c592', + (SELECT enrollment_id FROM `train_enrollment` WHERE `student_id` = 'S20260001' ORDER BY join_time DESC LIMIT 1), + 'P2026001', 'MATCHED', 'ARCHIVED', + 'VR-SAFE', '高处作业隐患排查', 1793153580000, 1793154000000, 420, 86.50, 100.00, + 1, 0, 0, 'VR-A-02', '{"taskId":"VR-SAFE-012"}', 24, '0e4b1c9d8f2a7c6b5d4e3f2a1b0c9d8e', + 1793154600000, 1793154600180, 0, NOW()), + ('RPT-CAP-20261028-0032', 'VR-TRAIN-01', 1, 'BATCH-202610281030-0001', 'PERIODIC', 'PRACTICE', 'S20240399', '张伟', + NULL, NULL, NULL, NULL, 'UNMATCHED', 'RECEIVED', + 'CAP-POS', '帽石安装定位', 1793153820000, 1793154180000, 360, 91.00, 100.00, + 1, 1, 1, 'MS-A-03', '{"taskId":"CAP-07"}', 20, '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d', + 1793154600000, 1793154600180, 0, NULL), + ('RPT-VR-20261028-0033', 'VR-TRAIN-01', NULL, NULL, 'REALTIME', 'PRACTICE', 'S20260001', '张三', + NULL, NULL, + (SELECT enrollment_id FROM `train_enrollment` WHERE `student_id` = 'S20260001' ORDER BY join_time DESC LIMIT 1), + 'P2026001', 'MATCHED', 'LOCKED', + 'VR-SAFE', '高处作业隐患排查', 1793153400000, 1793153760000, 360, 92.00, 100.00, + 1, 0, 0, 'VR-A-03', '{"taskId":"VR-SAFE-012"}', 24, '2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e', + 1793154010000, 1793154010180, 0, NOW()), + ('RPT-INS-20261028-0034', 'INSPECT3D-01', NULL, NULL, 'REALTIME', 'PRACTICE', 'S20260001', '张三', + NULL, NULL, + (SELECT enrollment_id FROM `train_enrollment` WHERE `student_id` = 'S20260001' ORDER BY join_time DESC LIMIT 1), + 'P2026001', 'MATCHED', 'RECEIVED', + 'INS-ROOF', '屋面渗漏巡检', 1793153640000, 1793154000000, 360, 88.00, 100.00, + 1, 0, 0, 'INS-A-01', '{"taskId":"INS-01"}', 18, '3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f', + 1793154010000, 1793154010180, 0, NULL) +ON DUPLICATE KEY UPDATE `subject_name` = VALUES(`subject_name`); + +-- 令牌审计样例(无效散列,仅用于页面联调查看) +INSERT INTO `train_open_token` (`app_id`, `token_hash`, `issued_time`, `expire_time`, `status`, `client_ip`, `user_agent`) +SELECT 'VR-TRAIN-01', SHA2('sample-token-for-ui-preview', 256), NOW(), DATE_ADD(NOW(), INTERVAL 7200 SECOND), 0, '127.0.0.1', 'seed' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `train_open_token` WHERE `app_id` = 'VR-TRAIN-01' AND `status` = 0); + +-- 调用日志样例 +INSERT INTO `train_open_api_log` (`req_id`, `app_id`, `api_path`, `http_method`, `biz_key`, `code`, `msg`, `cost_ms`, `client_ip`) +SELECT 'RQ-SEED-0001', 'VR-TRAIN-01', '/api/open/v1/exam/report', 'POST', 'BATCH-202610281030-0001', 0, 'success', 38, '127.0.0.1' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `train_open_api_log` WHERE `req_id` = 'RQ-SEED-0001'); + + +-- 更新学生学号字段 2026-11-05 +UPDATE train_student +SET person_no = REPLACE(person_no, '学号:', '') +WHERE person_no LIKE '%学号:%'; \ No newline at end of file diff --git a/RuoYi-Vue/sql/train/v2/024_add_teacher_enrollment_status.sql b/RuoYi-Vue/sql/train/v2/024_add_teacher_enrollment_status.sql new file mode 100644 index 000000000..9dbf84de9 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/024_add_teacher_enrollment_status.sql @@ -0,0 +1,18 @@ +-- ============================================================================== +-- RailwaySystem 数据库 V2 增量脚本: 024_add_teacher_enrollment_status.sql +-- 目标: 为 train_teacher 增加 enrollment_status(参训状态) +-- 枚举: 0 待报到、1 已报到、2 请假、3 放弃、4 已完成(实际业务主要使用 0 / 1) +-- 说明: 新增教师默认“待报到(0)”,与 Java 层 TrainTeacherServiceImpl 的默认值保持一致 +-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public) +-- 幂等性: ADD COLUMN IF NOT EXISTS 可重复执行;已存在时仅重复写一次列注释 +-- 注意: 未额外添加 CHECK 约束,枚举范围由应用层校验(如需库级约束请另行评估存量数据) +-- ============================================================================== + +BEGIN; + +ALTER TABLE public.train_teacher + ADD COLUMN IF NOT EXISTS enrollment_status SMALLINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN public.train_teacher.enrollment_status IS '参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成)'; + +COMMIT; diff --git a/RuoYi-Vue/sql/train/v2/024b_add_teacher_enrollment_status_mysql.sql b/RuoYi-Vue/sql/train/v2/024b_add_teacher_enrollment_status_mysql.sql new file mode 100644 index 000000000..8bb944344 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/024b_add_teacher_enrollment_status_mysql.sql @@ -0,0 +1,27 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 024b_add_teacher_enrollment_status_mysql.sql +-- 目标: 为 train_teacher 增加 enrollment_status(参训状态) +-- 枚举: 0 待报到、1 已报到、2 请假、3 放弃、4 已完成(实际业务主要使用 0 / 1) +-- 说明: 024_add_teacher_enrollment_status.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本 +-- 幂等: 通过 information_schema 判断列是否已存在,可重复执行 +-- 执行: +-- mysql -h -u -p --default-character-set=utf8mb4 < 024b_add_teacher_enrollment_status_mysql.sql +-- ============================================================================== + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'train_teacher' + AND COLUMN_NAME = 'enrollment_status' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `train_teacher` ADD COLUMN `enrollment_status` SMALLINT NOT NULL DEFAULT 0 COMMENT ''参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成)''', + 'SELECT ''train_teacher.enrollment_status 已存在,跳过加列'' AS msg'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SELECT COUNT(*) AS total_rows, + SUM(enrollment_status = 0) AS pending_rows, + SUM(enrollment_status = 1) AS reported_rows +FROM `train_teacher`; diff --git a/RuoYi-Vue/sql/train/v2/025_add_teacher_join_time_accommodation.sql b/RuoYi-Vue/sql/train/v2/025_add_teacher_join_time_accommodation.sql new file mode 100644 index 000000000..c9c081737 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/025_add_teacher_join_time_accommodation.sql @@ -0,0 +1,33 @@ +-- ============================================================================== +-- RailwaySystem 数据库 V2 增量脚本: 025_add_teacher_join_time_accommodation.sql +-- 目标: 为 train_teacher 增加 join_time(报到时间)与 accommodation_required(是否需要住宿) +-- 定义来源: 与 train_enrollment 对应字段保持一致(见 002_create_new_tables.sql:84-85) +-- train_enrollment.join_time TIMESTAMP (可空,无默认值) +-- train_enrollment.accommodation_required SMALLINT NOT NULL (不可空,无默认值) +-- 兼容处理: 存量教师无该信息,先补 0(不需要住宿)再置为 NOT NULL;最终形态不带 DEFAULT, +-- 与 train_enrollment 的“业务选择型字段不设默认值”口径一致 +-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public) +-- 幂等性: ADD COLUMN IF NOT EXISTS + 幂等 UPDATE / SET NOT NULL,可重复执行 +-- ============================================================================== + +BEGIN; + +-- 1. 报到时间(可空,无默认值) +ALTER TABLE public.train_teacher + ADD COLUMN IF NOT EXISTS join_time TIMESTAMP; + +-- 2. 是否需要住宿(先允许为空以便加列,回填后再置为 NOT NULL,最终不留默认值) +ALTER TABLE public.train_teacher + ADD COLUMN IF NOT EXISTS accommodation_required SMALLINT; + +UPDATE public.train_teacher + SET accommodation_required = 0 + WHERE accommodation_required IS NULL; + +ALTER TABLE public.train_teacher + ALTER COLUMN accommodation_required SET NOT NULL; + +COMMENT ON COLUMN public.train_teacher.join_time IS '报到时间'; +COMMENT ON COLUMN public.train_teacher.accommodation_required IS '是否需要住宿 (0:否, 1:是)'; + +COMMIT; diff --git a/RuoYi-Vue/sql/train/v2/025b_add_teacher_join_time_accommodation_mysql.sql b/RuoYi-Vue/sql/train/v2/025b_add_teacher_join_time_accommodation_mysql.sql new file mode 100644 index 000000000..7f52561b0 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/025b_add_teacher_join_time_accommodation_mysql.sql @@ -0,0 +1,49 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 025b_add_teacher_join_time_accommodation_mysql.sql +-- 目标: 为 train_teacher 增加 join_time(报到时间)与 accommodation_required(是否需要住宿) +-- 定义来源: 与 train_enrollment 对应字段保持一致 +-- join_time DATETIME,可空,无默认值 +-- accommodation_required SMALLINT NOT NULL,无默认值(加列时临时带 DEFAULT 0 回填,随后去掉默认值) +-- 说明: 025_add_teacher_join_time_accommodation.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本 +-- 幂等: 通过 information_schema 判断列是否已存在,可重复执行 +-- 执行: +-- mysql -h -u -p --default-character-set=utf8mb4 < 025b_add_teacher_join_time_accommodation_mysql.sql +-- ============================================================================== + +-- 1. join_time(可空) +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'train_teacher' + AND COLUMN_NAME = 'join_time' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `train_teacher` ADD COLUMN `join_time` DATETIME NULL DEFAULT NULL COMMENT ''报到时间''', + 'SELECT ''train_teacher.join_time 已存在,跳过加列'' AS msg'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. accommodation_required(不可空;先带默认值加列以回填存量行) +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'train_teacher' + AND COLUMN_NAME = 'accommodation_required' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `train_teacher` ADD COLUMN `accommodation_required` SMALLINT NOT NULL DEFAULT 0 COMMENT ''是否需要住宿 (0:否, 1:是)''', + 'SELECT ''train_teacher.accommodation_required 已存在,跳过加列'' AS msg'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3. 回填并去掉默认值,与 train_enrollment 口径一致(业务选择型字段不设默认值) +UPDATE `train_teacher` SET `accommodation_required` = 0 WHERE `accommodation_required` IS NULL; +ALTER TABLE `train_teacher` + MODIFY COLUMN `accommodation_required` SMALLINT NOT NULL COMMENT '是否需要住宿 (0:否, 1:是)'; + +SELECT COUNT(*) AS total_rows, + SUM(accommodation_required = 1) AS need_accommodation_rows, + SUM(join_time IS NOT NULL) AS joined_rows +FROM `train_teacher`; diff --git a/RuoYi-Vue/sql/train/v2/026_train_theory_exam_score.sql b/RuoYi-Vue/sql/train/v2/026_train_theory_exam_score.sql new file mode 100644 index 000000000..af1826889 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/026_train_theory_exam_score.sql @@ -0,0 +1,144 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本: 026_train_theory_exam_score.sql +-- 模块: 班级学员理论考试成绩(与外部实训成绩汇总配套) +-- 内容: 单表建表语句(字段 + 主键 + 唯一约束 + 外键 + CHECK + 索引 + 字段注释) +-- 数据库: MySQL 5.7(生产主库);KingbaseES 差异见文末说明 +-- 依赖: 本脚本仅创建 1 张新表,其引用的父表在库中**必须已存在**(顺序不可颠倒): +-- train_student(s_id) +-- train_class_plan(plan_id) +-- train_course(c_id) +-- train_plan_course(plan_course_id) +-- train_enrollment(enrollment_id) +-- train_exam_subject(subject_code) -- 由 023_open_exam_api.sql 创建 +-- train_score_item(score_item_id) +-- 说明: +-- 1) 脚本可重复执行:建表用 IF NOT EXISTS;表已存在时不会重复创建(约束/索引随表创建一并生效) +-- 2) MySQL 5.7 会解析但**不强制执行** CHECK 约束,8.0 起强制执行;无论版本, +-- 应用层(DTO + Service)均须做同等校验兜底 +-- 3) 仅新增对象,不改动任何既有表结构与数据 +-- 4) 唯一键涉及 VARCHAR 列,父表对应列(s_id/plan_id/c_id/subject_code)的字符集与排序规则 +-- 必须与本表一致(utf8mb4 / utf8mb4_general_ci),否则外键创建会失败 +-- 执行: mysql -h -u -p --default-character-set=utf8mb4 < 026_train_theory_exam_score.sql +-- ============================================================================== + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------------------------ +-- 班级学员理论考试成绩表 +-- 用途: +-- 记录"某学员 × 某班级课程 × 某次考试"的理论考试成绩,是课程级理论成绩的权威明细。 +-- 与外部实训成绩上报表 train_exam_report(biz_type='PRACTICE')配套使用: +-- · 强关联:train_theory_exam_score.enrollment_id = train_exam_report.matched_enrollment_id +-- · 弱关联:student_id + subject_code(实训上报未匹配到学员时使用) +-- · 汇总桥接:score_item_id → train_score_item(item_type=1 理论), +-- 与实训成绩按权重合成后写入 train_student_score(综合成绩明细) +-- 粒度: enrollment_id + plan_course_id + retake_no 唯一(一行 = 一次考试) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `train_theory_exam_score` ( + -- ========== 主键与关联维度 ========== + `theory_score_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `enrollment_id` BIGINT NOT NULL COMMENT '参训档案ID(train_enrollment,学员×班级唯一事实,汇总首选关联键)', + `plan_id` VARCHAR(32) NOT NULL COMMENT '培训班ID(train_class_plan,冗余便于班级维度统计)', + `plan_course_id` BIGINT NOT NULL COMMENT '培训课程实例ID(train_plan_course,哪门课,课程维度统计键)', + `c_id` VARCHAR(32) NOT NULL COMMENT '课程主档ID(train_course,冗余支持跨班级横向对比)', + `student_id` VARCHAR(32) NOT NULL COMMENT '学员ID(train_student.s_id,与实训上报 student_id 对齐)', + `subject_code` VARCHAR(32) DEFAULT NULL COMMENT '理论科目编码(train_exam_subject,可与实训上报 subject_code 对齐;空表示不区分科目)', + + -- ========== 考试过程信息 ========== + `exam_start_time` DATETIME DEFAULT NULL COMMENT '考试开始时间(本地时间)', + `exam_end_time` DATETIME DEFAULT NULL COMMENT '考试结束时间(须晚于开始时间)', + `duration_seconds` INT DEFAULT NULL COMMENT '用时秒数(对齐 train_exam_report.duration_seconds)', + `retake` SMALLINT NOT NULL DEFAULT 0 COMMENT '是否补考(1是 0否,语义对齐 train_exam_report.retake)', + `retake_no` INT NOT NULL DEFAULT 0 COMMENT '补考序号(0正考,1..n 第n次补考,对齐 train_exam_report.retake_no)', + + -- ========== 成绩信息 ========== + `exam_status` VARCHAR(16) NOT NULL DEFAULT 'NORMAL' COMMENT '考试状态(NORMAL正常 / ABSENT缺考 / EXEMPT免修)', + `score` DECIMAL(7,2) DEFAULT NULL COMMENT '理论成绩(缺考/免修允许为空,避免以0分污染均值)', + `full_score` DECIMAL(7,2) NOT NULL DEFAULT 100.00 COMMENT '满分(默认100.00,对齐 train_exam_report.full_score)', + `pass_flag` SMALLINT NOT NULL DEFAULT 0 COMMENT '是否通过(1通过 0未通过;免修按通过、缺考按未通过)', + `is_final` SMALLINT NOT NULL DEFAULT 1 COMMENT '是否该学员该课程的最终成绩(1是 0否,汇总仅取最终成绩避免重复计算)', + `score_status` VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '成绩状态(RECEIVED已接收 / ARCHIVED已归档 / LOCKED已锁定,枚举对齐 train_exam_report.score_status)', + + -- ========== 来源、汇总桥接与审计 ========== + `source_type` SMALLINT NOT NULL DEFAULT 1 COMMENT '成绩来源(1手工录入 / 2平台在线考试 / 3批量或外部导入)', + `source_system` VARCHAR(100) DEFAULT NULL COMMENT '来源系统标识(对接外部题库/考试平台时使用)', + `source_event_id` VARCHAR(100) DEFAULT NULL COMMENT '外部事件唯一ID(导入幂等键,与 source_system 组成唯一约束)', + `score_item_id` BIGINT DEFAULT NULL COMMENT '关联综合成绩项(train_score_item,应指向 item_type=1 理论考试项)', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注(如缺考原因、免修依据)', + `del_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '删除标志(0存在 2删除),逻辑删除保留审计轨迹', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者', + `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', + + -- 最终成绩唯一性辅助列(MySQL 无部分唯一索引,用生成列模拟: + -- 仅"未删除且 is_final=1"的行取值为 1,其余为 NULL;MySQL 唯一索引允许多个 NULL) + `final_flag` TINYINT GENERATED ALWAYS AS (IF(`is_final` = 1 AND `del_flag` = '0', 1, NULL)) STORED COMMENT '最终成绩唯一约束辅助列(实现细节,业务不直接读写)', + + -- ========== 主键 ========== + PRIMARY KEY (`theory_score_id`), + + -- ========== 唯一约束 ========== + UNIQUE KEY `uk_theory_enroll_course_retake` (`enrollment_id`, `plan_course_id`, `retake_no`) + COMMENT '同一学员同一课程同一次考试唯一,防重复录入', + UNIQUE KEY `uk_theory_final` (`plan_course_id`, `student_id`, `final_flag`) + COMMENT '每学员每课程仅一条最终成绩(生成列模拟部分唯一索引)', + UNIQUE KEY `uk_theory_source_event` (`source_system`, `source_event_id`) + COMMENT '外部导入幂等(两列均为 NULL 时不冲突,不影响手工录入)', + + -- ========== 普通索引 ========== + KEY `idx_theory_plan_student` (`plan_id`, `student_id`) COMMENT '班级成绩单、按学员查全班成绩', + KEY `idx_theory_plan_course_final` (`plan_course_id`, `is_final`, `score_status`, `del_flag`) COMMENT '课程维度汇总(均分/及格率)', + KEY `idx_theory_student_subject` (`student_id`, `subject_code`, `exam_end_time`) COMMENT '与实训上报按"学员+科目"汇总', + KEY `idx_theory_score_item` (`score_item_id`) COMMENT '汇入 train_student_score 时反查', + KEY `idx_theory_exam_time` (`exam_end_time`) COMMENT '时间窗统计/归档任务扫描', + KEY `idx_theory_course` (`c_id`) COMMENT '外键索引:按课程主档横向统计', + KEY `idx_theory_subject` (`subject_code`) COMMENT '外键索引:按科目字典汇总', + + -- ========== 外键(引用完整性) ========== + CONSTRAINT `fk_theory_enrollment` FOREIGN KEY (`enrollment_id`) REFERENCES `train_enrollment` (`enrollment_id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `fk_theory_plan` FOREIGN KEY (`plan_id`) REFERENCES `train_class_plan` (`plan_id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `fk_theory_plan_course` FOREIGN KEY (`plan_course_id`) REFERENCES `train_plan_course` (`plan_course_id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `fk_theory_course` FOREIGN KEY (`c_id`) REFERENCES `train_course` (`c_id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `fk_theory_student` FOREIGN KEY (`student_id`) REFERENCES `train_student` (`s_id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `fk_theory_subject` FOREIGN KEY (`subject_code`) REFERENCES `train_exam_subject` (`subject_code`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `fk_theory_score_item` FOREIGN KEY (`score_item_id`) REFERENCES `train_score_item` (`score_item_id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + + -- ========== 取值与一致性约束(MySQL 8.0 起强制;5.7 由应用层兜底) ========== + CONSTRAINT `chk_theory_score_range` CHECK (`score` IS NULL OR (`score` >= 0 AND `score` <= `full_score`)), + CONSTRAINT `chk_theory_full_score` CHECK (`full_score` > 0), + CONSTRAINT `chk_theory_score_filled` CHECK (`exam_status` <> 'NORMAL' OR `score` IS NOT NULL), + CONSTRAINT `chk_theory_exam_time` CHECK (`exam_start_time` IS NULL OR `exam_end_time` IS NULL OR `exam_end_time` > `exam_start_time`), + CONSTRAINT `chk_theory_duration` CHECK (`duration_seconds` IS NULL OR `duration_seconds` >= 0), + CONSTRAINT `chk_theory_retake` CHECK (`retake` IN (0, 1)), + CONSTRAINT `chk_theory_retake_no` CHECK (`retake_no` >= 0), + CONSTRAINT `chk_theory_exam_status` CHECK (`exam_status` IN ('NORMAL', 'ABSENT', 'EXEMPT')), + CONSTRAINT `chk_theory_score_status` CHECK (`score_status` IN ('RECEIVED', 'ARCHIVED', 'LOCKED')), + CONSTRAINT `chk_theory_pass_flag` CHECK (`pass_flag` IN (0, 1)), + CONSTRAINT `chk_theory_is_final` CHECK (`is_final` IN (0, 1)), + CONSTRAINT `chk_theory_source_type` CHECK (`source_type` IN (1, 2, 3)), + CONSTRAINT `chk_theory_del_flag` CHECK (`del_flag` IN ('0', '2')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='班级学员理论考试成绩表(学员×班级课程×考试次数的理论成绩明细,与 train_exam_report 实训成绩汇总)'; + +-- ============================================================================== +-- 补充说明 +-- 1) 冗余维度一致性:enrollment_id / plan_id / student_id / c_id 之间存在派生关系 +-- (enrollment → plan + student;plan_course → plan + c_id)。外键仅保证各自引用有效, +-- 四者同源须由应用层(Service 写入前校验)或库内触发器保证,避免"A班学员成绩挂B班课程"。 +-- 2) 汇总统计口径(报表层,不在本脚本落地): +-- 仅统计 del_flag='0' AND is_final=1 AND score_status IN ('RECEIVED','ARCHIVED'); +-- 缺考(ABSENT)不计入平均分分母,免修(EXEMPT)计入通过率但不计分; +-- 补考取 is_final=1 的成绩,避免同课程多行重复计入。 +-- 3) 时间口径:本表为 DATETIME(本地时间),train_exam_report 为 BIGINT 毫秒; +-- 跨表汇总时统一转换为同一基准(建议毫秒整数)。 +-- 4) KingbaseES/PostgreSQL 落地差异: +-- · 主键改为 BIGINT GENERATED BY DEFAULT AS IDENTITY; +-- · 表/列注释改用 COMMENT ON TABLE / COMMENT ON COLUMN; +-- · 去掉 final_flag 生成列,改用部分唯一索引: +-- CREATE UNIQUE INDEX uk_theory_final ON train_theory_exam_score (plan_course_id, student_id) +-- WHERE is_final = 1 AND del_flag = '0'; +-- · CHECK 约束原生强制;DATETIME 对应 TIMESTAMP。 +-- 5) 回滚(如需): +-- DROP TABLE IF EXISTS `train_theory_exam_score`; +-- ============================================================================== diff --git a/RuoYi-Vue/sql/train/v2/028_score_analysis_menu.sql b/RuoYi-Vue/sql/train/v2/028_score_analysis_menu.sql new file mode 100644 index 000000000..f33c180a3 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/028_score_analysis_menu.sql @@ -0,0 +1,49 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本: 028_score_analysis_menu.sql +-- 模块: 成绩统计分析(理论成绩 + 实训上报成绩,多维度统计) +-- 内容: 菜单与按钮权限(复用「统计分析」父目录 2169),并授权给培训管理员角色 +-- 数据库: MySQL(生产主库);KingbaseES 请按 012_analytics_v1_permissions.sql 的写法转换 +-- 说明: +-- 1) 仅新增菜单/权限与角色授权,不改动任何既有表结构与数据; +-- 2) 脚本可重复执行:全部使用 NOT EXISTS 幂等判断; +-- 3) 页面组件路径: train/analysis/score/index +-- 4) 后端接口前缀: /train/analysis/score(TrainScoreAnalysisController) +-- 执行: mysql -h -u -p --default-character-set=utf8mb4 < 028_score_analysis_menu.sql +-- ============================================================================== + +SET NAMES utf8mb4; + +-- 父目录:统计分析(path=analysis,顶级菜单) +SET @analysis_parent = ( + SELECT menu_id FROM sys_menu WHERE path = 'scores' AND parent_id = 0 LIMIT 1 +); + +-- 1. 成绩分析页面菜单 +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '成绩分析', @analysis_parent, '2', 'score', 'train/analysis/score/index', '', '', 1, 0, + 'C', '0', '0', 'train:analysis:score', 'chart', 'admin', NOW(), '理论/实训成绩多维度统计分析' +FROM DUAL +WHERE @analysis_parent IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:analysis:score'); + +SET @score_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:analysis:score' LIMIT 1); + +-- 2. 成绩分析查询按钮权限 +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '成绩分析查询', @score_menu, '1', '#', '', '', '', 1, 0, + 'F', '0', '0', 'train:analysis:score:query', '#', 'admin', NOW(), '成绩统计分析数据查询' +FROM DUAL +WHERE @score_menu IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:analysis:score:query'); + +-- 3. 授权给培训管理员角色(role_id = 3,与考勤分析保持一致) +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 3, m.menu_id FROM sys_menu m +WHERE m.perms IN ('train:analysis:score', 'train:analysis:score:query') + AND NOT EXISTS ( + SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 3 AND rm.menu_id = m.menu_id + ); diff --git a/RuoYi-Vue/sql/train/v2/patch/014_train_book_download_record.sql b/RuoYi-Vue/sql/train/v2/patch/014_train_book_download_record.sql new file mode 100644 index 000000000..7d2376d9b --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/patch/014_train_book_download_record.sql @@ -0,0 +1,18 @@ +-- 图书下载回执记录表(设备端下载完成后回执落库) +CREATE TABLE IF NOT EXISTS `train_book_download_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `user_id` BIGINT DEFAULT NULL COMMENT '学员ID', + `device_id` VARCHAR(64) NOT NULL COMMENT '设备ID', + `book_id` VARCHAR(64) NOT NULL COMMENT '图书ID', + `book_version` VARCHAR(32) DEFAULT NULL COMMENT '图书版本', + `file_md5` VARCHAR(64) DEFAULT NULL COMMENT '文件MD5', + `result` VARCHAR(16) DEFAULT NULL COMMENT '下载结果:SUCCESS/FAIL', + `err_msg` VARCHAR(255) DEFAULT NULL COMMENT '错误信息', + `download_time` DATETIME DEFAULT NULL COMMENT '下载/回执时间', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_device_book` (`device_id`,`book_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书下载回执记录'; diff --git a/RuoYi-Vue/sql/train/v2/patch/016_teacher_accommodation.sql b/RuoYi-Vue/sql/train/v2/patch/016_teacher_accommodation.sql new file mode 100644 index 000000000..b1eb0a2c3 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/patch/016_teacher_accommodation.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- 教师住宿分配(复用 train_accommodation,与学生住宿逻辑隔离) +-- +-- 1. 新增 occupant_type 区分入住人员类型:student 学员 / teacher 教师 +-- - 存量数据全部为学员住宿,默认值 'student' 保证学生逻辑完全不变 +-- - 教师住宿记录 occupant_type = 'teacher',enrollment_id 存 train_teacher.id +-- 2. 删除 fk_accomm_enrollment 外键:教师住宿的 enrollment_id 不再指向 +-- train_enrollment(enrollment_id),改由业务层按 occupant_type 分别校验 +-- 3. 唯一索引由「全局活唯一(enrollment_id)」改为「按人员类型隔离的活唯一 +-- (occupant_type, enrollment_id)」,避免教师 id 与参训档案 id 数值碰撞 +-- +-- 适配数据库:Kingbase / PostgreSQL(train 模块以该语法为准) +-- ============================================================================ + +-- 1. 入住人员类型列 +ALTER TABLE train_accommodation ADD COLUMN occupant_type VARCHAR(16) NOT NULL DEFAULT 'student'; + +COMMENT ON COLUMN train_accommodation.occupant_type IS '入住人员类型:student 学员 / teacher 教师'; + +-- 2. 解除参训档案外键约束(教师住宿记录不指向 train_enrollment) +ALTER TABLE train_accommodation DROP CONSTRAINT IF EXISTS fk_accomm_enrollment; + +-- 3. 重建活跃唯一索引:按人员类型隔离 +DROP INDEX IF EXISTS uq_accommodation_enroll; + +CREATE UNIQUE INDEX uq_accommodation_occupant + ON train_accommodation (occupant_type, enrollment_id) + WHERE accommodation_status IN (0, 1); + +CREATE INDEX IF NOT EXISTS idx_accommodation_occupant + ON train_accommodation (occupant_type, enrollment_id); + +-- ============================================================================ +-- MySQL 兼容说明(如需在 MySQL 环境执行,替换为下列语句): +-- ALTER TABLE train_accommodation ADD COLUMN occupant_type VARCHAR(16) NOT NULL DEFAULT 'student' +-- COMMENT '入住人员类型:student 学员 / teacher 教师'; +-- ALTER TABLE train_accommodation DROP FOREIGN KEY fk_accomm_enrollment; +-- ALTER TABLE train_accommodation DROP INDEX uq_accommodation_enroll; +-- CREATE UNIQUE INDEX uq_accommodation_occupant +-- ON train_accommodation (occupant_type, enrollment_id, accommodation_status); +-- (MySQL 无部分索引,若需「仅活跃唯一」请使用生成列方案,教师模块不依赖该唯一性做业务判断) +-- ============================================================================ diff --git a/RuoYi-Vue/sql/train/v2/reconcile_mysql_5_7.sql b/RuoYi-Vue/sql/train/v2/reconcile_mysql_5_7.sql new file mode 100644 index 000000000..b14cff404 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/reconcile_mysql_5_7.sql @@ -0,0 +1,100 @@ +-- ============================================================================== +-- RailwaySystem V2 -> MySQL 5.7 结构对账脚本 (reconcile_mysql_5_7.sql) +-- 适用数据库: MySQL 5.7 (线上部署库) +-- 目标: 将线上 MySQL 表结构对齐 V2 基线,消除两类反复出现的错误: +-- 1) Field 'create_time' doesn't have a default value +-- 2) Unknown column 'xxx' in 'field list' (缺失列) +-- 说明: +-- * 本脚本只"设置默认值 / 按需补列",**不改列类型**,对已有默认值的列为幂等操作。 +-- * 使用 information_schema 守卫,列已存在/已有默认也不会报错,可重复执行。 +-- * train_class_report 的 alloc_* 列: 应用当前 mapper 仍在 SELECT,故补回; +-- 若后续按最终 V2 设计改为依赖 train_dorm_auth,应同步清理应用侧(见文件末尾说明)。 +-- 执行: mysql -u -p railwaysystem < reconcile_mysql_5_7.sql +-- ============================================================================== + +DELIMITER $$ + +-- 仅当列存在时设置默认值(列名/表名已存在即可,重复设置同值幂等) +DROP PROCEDURE IF EXISTS set_default_if_missing $$ +CREATE PROCEDURE set_default_if_missing( + IN p_tbl VARCHAR(64), IN p_col VARCHAR(64), IN p_def VARCHAR(255) +) +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = p_tbl AND column_name = p_col + ) THEN + SET @stmt = CONCAT('ALTER TABLE `', p_tbl, '` ALTER COLUMN `', p_col, '` SET DEFAULT ', p_def); + PREPARE s FROM @stmt; + EXECUTE s; + DEALLOCATE PREPARE s; + END IF; +END $$ + +-- 仅当列不存在时新增列 +DROP PROCEDURE IF EXISTS add_col_if_missing $$ +CREATE PROCEDURE add_col_if_missing( + IN p_tbl VARCHAR(64), IN p_col VARCHAR(64), IN p_spec VARCHAR(255) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = p_tbl AND column_name = p_col + ) THEN + SET @stmt = CONCAT('ALTER TABLE `', p_tbl, '` ADD COLUMN `', p_col, '` ', p_spec); + PREPARE s FROM @stmt; + EXECUTE s; + DEALLOCATE PREPARE s; + END IF; +END $$ + +DELIMITER ; + +-- ------------------------------------------------------------------------------ +-- 1) train_class_report: 补齐应用 mapper 仍在 SELECT 的 alloc_* 列 (若已加则跳过) +-- ------------------------------------------------------------------------------ +CALL add_col_if_missing('train_class_report', 'alloc_dorm_id', 'VARCHAR(32) NULL'); +CALL add_col_if_missing('train_class_report', 'alloc_classroom_id', 'VARCHAR(32) NULL'); +CALL add_col_if_missing('train_class_report', 'alloc_site_id', 'VARCHAR(32) NULL'); + +-- ------------------------------------------------------------------------------ +-- 2) 为所有 V2 业务表的 create_time 补默认值 (核心: 消除当前 'doesn''t have a default value') +-- V2 基线均为 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,线上 5.7 翻译时丢失。 +-- ------------------------------------------------------------------------------ +CALL set_default_if_missing('train_resource', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_course_resource', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_plan_course', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_enrollment', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_lesson', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_accommodation', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_score_item', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_student_score', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_class_report', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_dorm_auth', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_student_checkin', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_study_duration', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_student_portrait', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_electronic_board', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_classroom_info', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_training_site', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_dormitory_info', 'create_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_class_plan', 'create_time', 'CURRENT_TIMESTAMP'); + +-- ------------------------------------------------------------------------------ +-- 3) 其它 V2 中 NOT NULL 且带默认值、但线上可能丢失的列 +-- ------------------------------------------------------------------------------ +CALL set_default_if_missing('train_resource', 'status', '1'); +CALL set_default_if_missing('train_resource', 'del_flag', '''0'''); +CALL set_default_if_missing('train_enrollment', 'enrollment_status', '0'); +CALL set_default_if_missing('train_accommodation', 'assigned_time', 'CURRENT_TIMESTAMP'); +CALL set_default_if_missing('train_accommodation', 'accommodation_status', '0'); +CALL set_default_if_missing('train_score_item', 'item_type', '1'); +CALL set_default_if_missing('train_score_item', 'weight', '0.00'); +CALL set_default_if_missing('train_score_item', 'full_score', '100.00'); +CALL set_default_if_missing('train_student_score', 'import_time', 'CURRENT_TIMESTAMP'); + +-- ------------------------------------------------------------------------------ +-- 清理临时存储过程 +-- ------------------------------------------------------------------------------ +DROP PROCEDURE IF EXISTS set_default_if_missing; +DROP PROCEDURE IF EXISTS add_col_if_missing; diff --git a/build_ui/docs/portrait-render-diagnosis-20261108.md b/build_ui/docs/portrait-render-diagnosis-20261108.md new file mode 100644 index 000000000..27e422a4d --- /dev/null +++ b/build_ui/docs/portrait-render-diagnosis-20261108.md @@ -0,0 +1,278 @@ +# 学员个人画像页面「新模块不渲染」诊断报告 + +- 诊断日期:2026-11-08 +- 涉及页面:`src/views/train/portrait/index.vue`(个人画像 / 学员 360) +- 涉及接口:`GET /train/analysis/portrait/student-360/{studentId}` +- 涉及后端:`ruoyi-train` 模块(`StudentPortraitServiceImpl`、`TrainPortraitAnalyticsMapper`) +- 诊断方式:前端源码与编译产物核查、后端运行实例与编译产物时间比对、本地数据库(只读)表结构核对与 SQL 实测 + +--- + +## 一、结论摘要 + +1. **直接原因(P0)**:画像聚合第 9 条 SQL `selectDormContext` 使用了 V1 旧表结构字段(`train_dorm_auth.student_id / plan_id / dorm_id`),而当前库为 V2 结构,实测报错 `Unknown column 'da.dorm_id' in 'field list'`。 +2. 该异常在 `TrainAnalysisServiceImpl.getStudent360()` 中被 `catch` 吞掉,仅记录 `log.warn`,导致返回体里 **没有 `portrait` 节点**。 +3. 前端所有新增模块包在 `
` 中,`portrait` 为 null 时整块不渲染,页面只剩老 360 内容 —— 即“旧信息仍在、新图表不出现”。 +4. **次要原因(P1)**:`build_ui/dist` 为 2026-11-03 构建,产物中不存在任何新画像代码;若访问的是该静态站点,即使后端修好也只会看到旧页面。 +5. **非缺陷项(P2)**:即使 `portrait` 正常返回,各图表自身还有数据门槛(雷达需 ≥3 个可用维度等),数据不足时会显示空态文案,属预期降级。 + +--- + +## 二、从组件引入到实际渲染的完整链路 + +``` +源码 build_ui/src/views/train/portrait/* ✅ 新代码存在(render 树校验通过) + ↓ 构建 +dev server :1026(vue-cli-service serve) ✅ 从源码编译,随 HMR 更新 +dist/index.html(2026-11-03 构建) ❌ 早于本次改动,产物中搜不到 + "个人画像结论 / 个人能力雷达 / portrait-radar" + ↓ 路由(sys_menu → train/portrait/index) ✅ 页面可打开(旧内容可见) + ↓ 接口 getStudent360 +后端 :8096(2026-11-08 22:39:52 启动,class 22:39) ✅ 新代码已加载 + ↓ StudentPortraitServiceImpl.getPortrait() +第 9 条 SQL selectDormContext ❌ Unknown column 'da.dorm_id' + ↓ catch (Exception) 仅 log.warn ❌ 静默降级,portrait = null + ↓ JSON 响应无 portrait 字段 +前端
❌ 结论 / 三图 / 趋势 / 风险全部不渲染 + ↓ 各图表自身数据门槛 ⚠️ 数据不足时只显示空态 + ↓ echarts 容器宽高 ⚠️ 隐藏容器中初始化为 0×0 +``` + +链路断点唯一且确定:**后端画像聚合 SQL 抛错 → 异常被吞 → `portrait` 缺失 → 前端整块 `v-if` 不成立**。 + +--- + +## 三、断点定位与证据 + +### 3.1 前端:所有新模块挂在 `v-if="portrait"` 之下 + +`src/views/train/portrait/index.vue:99` + +```html + +
+``` + +该容器内依次包含:个人画像结论(综合分环、四项结论、标签、口径与数据截止)、三张图表卡(雷达 / 活跃度 / 目标达成)、七维画像明细、周趋势与跨班期、风险预警。`portrait` 为 null 时全部不产出 DOM。 + +### 3.2 后端:异常被吞,画像节点直接缺失 + +`RuoYi-Vue/ruoyi-train/.../service/impl/TrainAnalysisServiceImpl.java:510-520` + +```java +if (portraitService != null) +{ + try + { + vo.setPortrait(portraitService.getPortrait(studentId)); + } + catch (Exception e) + { + log.warn("学员画像扩展数据生成失败, studentId={}, reason={}", studentId, e.getMessage()); + } +} +``` + +`StudentPortraitServiceImpl.getPortrait()` 内部按顺序执行: + +```java +fillAttendance(raw); fillStudy(raw); fillScores(raw, studentId); +fillExamCompletion(raw); fillDorm(raw, studentId); +fillClassBaseline(raw); fillTrend(raw); +StudentPortraitVo portrait = PortraitCalculator.build(raw); +``` + +**任一 `fillXxx` 抛错都会使整个画像构建失败**,当前只有 `saveSnapshot` 做了单条容错。 + +### 3.3 触发异常的具体 SQL + +`RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml:137-150` + +```xml + +``` + +这是 V1 旧结构写法。V2 中门禁授权通过 `accommodation_id` 关联(可参考旧 mapper 的既有正确写法 `TrainAnalysisMapper.xml:395-413`): + +```xml +FROM train_accommodation a +JOIN train_enrollment e ON e.enrollment_id = a.enrollment_id +LEFT JOIN train_dormitory_info d ON d.dorm_id = a.dorm_id +LEFT JOIN train_dorm_auth auth ON auth.accommodation_id = a.accommodation_id +``` + +### 3.4 数据库层实证(本地库,只读) + +逐表逐列核对画像 SQL 依赖,结论如下: + +| 检查项 | 结果 | +|---|---| +| `train_theory_exam_score`(`is_final`/`plan_course_id`/`enrollment_id` 等) | 存在 | +| `train_student.extract_status`、`card_no`、`source_type`、`id_card_last4` | 存在 | +| `train_study_duration.resource_id`、`sync_time` | 存在 | +| `train_student_checkin.checkin_way`、`manual_flag` | 存在 | +| `train_course.c_duration`、`train_course_resource.c_id` | 存在 | +| `train_student_portrait`(快照表) | 存在 | +| **`train_dorm_auth.student_id / plan_id / dorm_id`** | **不存在** | + +`train_dorm_auth` 实际列为: +`auth_id, accommodation_id, credential_type, credential_no, auth_status, retry_count, last_sync_time, last_error, face_sync_status, create_time, update_time` + +直接执行该 SQL 的结果: + +``` +DORM_SQL_FAIL: Unknown column 'da.dorm_id' in 'field list' +``` + +即:**每打开一次画像页必然失败一次,与学员数据多少无关。** + +### 3.5 前端产物陈旧(次要原因) + +| 检查项 | 结果 | +|---|---| +| `build_ui/dist/index.html` 修改时间 | 2026-11-03(早于本次改动) | +| `dist` 中是否包含 `个人画像结论` / `个人能力雷达` / `portrait-radar` | 均未命中 | +| 前端 dev server | 存在,端口 1026,`vue-cli-service serve` | +| 端口 80 | 为 WAMP/Apache 默认站点,非本项目前端 | + +若浏览器访问的是 `dist` 打包站点,则新组件根本不在产物中,必须重新 `npm run build`。 + +### 3.6 旧信息为何仍在 + +- 基本信息、考勤记录/明细、培训履历、成绩、住宿门禁由**另一条链路**(`analysisMapper` 老查询)生成,与 `portrait` 无关,因此不受影响、始终有内容。 +- 老代码中“自主学习学时”本就是硬编码: + +`TrainAnalysisServiceImpl.java:463-465` + +```java +// 5. 自主学习状态 (严格不造假) +vo.setHasStudyData(false); +vo.setStudyStatusDesc("暂无自主学习记录 / 待外部平台数据同步"); +``` + +新卡片读取的是 `portrait.dimensions[study]`;`portrait` 为 null 时新逻辑不执行,于是“旧文案仍在、新指标未出现”。 + +--- + +## 四、各组件显示条件 + +| 组件 / 区块 | 渲染条件 | 不满足时的表现 | +| --- | --- | --- | +| 整个画像看板 | `profile.portrait != null` | 整块消失(当前状态) | +| 综合分环形 + 四项结论 | 同上 | 同上 | +| 标签行 | `visibleTags.length > 0`(已剔除 `advantage` 组) | 不显示标签行 | +| 个人能力雷达 `PortraitRadar` | 维度中 `available === true` 且 `score != null` 的 **≥3 个** | “暂无可用的维度评分” | +| 活跃度环形 `PortraitActivity` | `activity.parts` 中 `included === true` 且 `score != null` 的 **≥1 个** | “暂无活跃度分项数据” | +| 个人目标达成 | `goalItems.length > 0`(学时/课次/考核完成度/出勤率任一有值) | “暂无可对照的计划目标” | +| 周趋势 `PortraitWeekly` | `trend.weekly.length > 0` | “暂无周维度学习与出勤记录” | +| 跨班期对比 `PortraitPeriods` | `trend.periods.length > 0` | “暂无跨班期记录” | +| 风险预警 | `riskRules.length > 0` | 卡片整体不渲染 | + +**雷达最易凑不够 3 轴**:无课次→考勤不可用;无学习记录→学习投入不可用;无成绩→考核能力不可用;无课次→进度不可用。此时仅剩“身份与数据质量”“综合画像”,雷达退化为空态。属正常降级,不是渲染故障。 + +--- + +## 五、让新图表正常渲染的前提条件 + +### P0 必须满足(否则一定空白) + +1. **修复门禁 SQL**:`selectDormContext` 改为 V2 关联方式,并按 `enrollmentId` 查询。 +2. **后端生效**:修改 XML 后需重启 / 触发热加载(当前实例 22:39 启动)。 +3. **前端最新**: + - 走 `:1026` dev server 时硬刷新(Ctrl+F5); + - 走 `dist` 静态站点时必须重新 `npm run build`。 + +### P1 强烈建议 + +4. **逐条容错**:在 `StudentPortraitServiceImpl` 的每个 `fillXxx` 外层加 try/catch,失败时置空并写入 `meta.warnings`,避免“一条 SQL 坏掉 → 整块画像消失”。 + +### P2 数据层面(决定“有图”还是“空态”) + +5. 至少有 1 条参训记录(`train_enrollment`),否则返回空画像。 +6. 有课次(`train_lesson`)→ 考勤/进度维度可用,雷达才可能凑够 3 轴。 +7. 有学习时长(`train_study_duration`)→ 学习投入维度、周趋势学时柱有值。 +8. 有成绩(`train_theory_exam_score`,`is_final = 1` 且 `del_flag = '0'`)→ 考核维度、考核均分虚线有值。 +9. 有多期参训 → 跨班期对比有 ≥2 根柱(单期也可画,仅一根)。 + +### P3 渲染层面 + +10. echarts 容器需在可见状态下初始化;若被隐藏 tab / `v-show` 包裹,需重新触发 `resize`(组件已用 `ResizeObserver` 兜底)。 + +--- + +## 六、修复建议(未实施,待确认) + +### 6.1 门禁 SQL 改为 V2 结构 + +```xml + +``` + +同步调整: + +- `TrainPortraitAnalyticsMapper.selectDormContext(...)` 入参改为 `@Param("enrollmentId") Long enrollmentId`; +- `StudentPortraitServiceImpl.fillDorm(...)` 改为传入 `raw.enrollmentId`。 + +### 6.2 画像构建逐条容错(建议) + +```java +private void safeFill(String name, PortraitRawData raw, Runnable action) { + try { action.run(); } + catch (Exception e) { raw.warnings.add(name + " 数据获取失败,已按数据不足处理"); log.warn(...); } +} +``` + +调用侧:`safeFill("住宿门禁", raw, () -> fillDorm(raw, raw.enrollmentId));` —— 保证单个维度失败只降级,不隐藏整块画像。 + +--- + +## 七、快速自检步骤 + +1. F12 → Network → `student-360` 响应中搜索 `"portrait"`: + - 无该字段 → 后端断点(本文根因); + - 有但维度全空 → 数据问题,见第五节 P2。 +2. 后端日志搜索 `学员画像扩展数据生成失败`,若包含 `Unknown column 'da.dorm_id'`,即本文根因。 +3. 观察页面检索框:候选列表版 = 新前端;否则看的是旧产物(`dist`)。 + +--- + +## 八、附:本次核查使用的关键路径 + +| 用途 | 路径 | +| --- | --- | +| 画像页面 | `build_ui/src/views/train/portrait/index.vue` | +| 图表组件 | `build_ui/src/views/train/portrait/components/Portrait*.vue`、`chartMixin.js` | +| 画像接口 | `build_ui/src/api/train/analysis.js`(`getStudent360`) | +| 画像组装 | `RuoYi-Vue/ruoyi-train/.../service/impl/TrainAnalysisServiceImpl.java` | +| 画像计算 | `RuoYi-Vue/ruoyi-train/.../service/impl/StudentPortraitServiceImpl.java`、`PortraitCalculator.java` | +| 画像 SQL | `RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml` | +| 正确住宿/门禁关联参考 | `RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml` | +| 前端产物 | `build_ui/dist`(2026-11-03,陈旧) | diff --git a/build_ui/src/api/device/book.js b/build_ui/src/api/device/book.js new file mode 100644 index 000000000..57e9d49dc --- /dev/null +++ b/build_ui/src/api/device/book.js @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +// 图书快照 train_book_snapshot +export function listBookSnapshot(query) { + return request({ url: '/device/booksnapshot/list', method: 'get', params: query }) +} + +export function getBookSnapshot(id) { + return request({ url: '/device/booksnapshot/' + id, method: 'get' }) +} + +export function addBookSnapshot(data) { + return request({ url: '/device/booksnapshot', method: 'post', data }) +} + +export function updateBookSnapshot(data) { + return request({ url: '/device/booksnapshot', method: 'put', data }) +} + +export function delBookSnapshot(id) { + return request({ url: '/device/booksnapshot/' + id, method: 'delete' }) +} diff --git a/build_ui/src/api/device/checkinTerminal.js b/build_ui/src/api/device/checkinTerminal.js new file mode 100644 index 000000000..1800c80b1 --- /dev/null +++ b/build_ui/src/api/device/checkinTerminal.js @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 查询报到一体机列表 +export function listCheckinTerminal(query) { + return request({ url: '/device/checkinterminal/list', method: 'get', params: query }) +} + +// 查询报到一体机详情 +export function getCheckinTerminal(id) { + return request({ url: '/device/checkinterminal/' + id, method: 'get' }) +} + +// 新增报到一体机 +export function addCheckinTerminal(data) { + return request({ url: '/device/checkinterminal', method: 'post', data }) +} + +// 修改报到一体机 +export function updateCheckinTerminal(data) { + return request({ url: '/device/checkinterminal', method: 'put', data }) +} + +// 删除报到一体机 +export function delCheckinTerminal(id) { + return request({ url: '/device/checkinterminal/' + id, method: 'delete' }) +} diff --git a/build_ui/src/api/device/device.js b/build_ui/src/api/device/device.js new file mode 100644 index 000000000..cd3b8e044 --- /dev/null +++ b/build_ui/src/api/device/device.js @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +// 读书机设备档案 train_iot_device +export function listIotDevice(query) { + return request({ url: '/device/iotdevice/list', method: 'get', params: query }) +} + +export function getIotDevice(id) { + return request({ url: '/device/iotdevice/' + id, method: 'get' }) +} + +export function addIotDevice(data) { + return request({ url: '/device/iotdevice', method: 'post', data }) +} + +export function updateIotDevice(data) { + return request({ url: '/device/iotdevice', method: 'put', data }) +} + +export function delIotDevice(id) { + return request({ url: '/device/iotdevice/' + id, method: 'delete' }) +} diff --git a/build_ui/src/api/device/iot.js b/build_ui/src/api/device/iot.js new file mode 100644 index 000000000..77e3cf87c --- /dev/null +++ b/build_ui/src/api/device/iot.js @@ -0,0 +1,50 @@ +import request from '@/utils/request' + +// 阅读机 / 听书机 设备侧 IoT 接口(BasePath: /api/iot/v1) +// 说明:登录接口需 HMAC 签名校验(X-Signature / X-Nonce),不走 RuoYi TokenFilter; +// 其余接口需 Authorization: Bearer {ruoyi-accessToken}。具体签名头由 request 拦截器或调用方注入。 + +// 1. 设备学员登录 +export function deviceLogin(data) { + return request({ url: '/api/iot/v1/device/login', method: 'post', data }) +} + +// 2. 设备注销 +export function deviceLogout(data) { + return request({ url: '/api/iot/v1/device/logout', method: 'post', data }) +} + +// 3. 设备心跳 +export function deviceHeartbeat(data) { + return request({ url: '/api/iot/v1/device/heartbeat', method: 'post', data }) +} + +// 4. 阅读记录单条上报 +export function readingRecord(data) { + return request({ url: '/api/iot/v1/reading/record', method: 'post', data }) +} + +// 5. 阅读记录批量上报(断网补传,单批最多 200 条) +export function readingRecordBatch(data) { + return request({ url: '/api/iot/v1/reading/record/batch', method: 'post', data }) +} + +// 6. 获取学员阅读汇总与续读进度 +export function readingProgress(params) { + return request({ url: '/api/iot/v1/reading/progress', method: 'get', params }) +} + +// 7. 获取可阅读书籍列表(仅上架) +export function bookList(params) { + return request({ url: '/api/iot/v1/book/list', method: 'get', params }) +} + +// 8. PDF 书籍下载(支持 Range 断点续传) +export function bookDownload(params) { + return request({ url: '/api/iot/v1/book/download', method: 'get', params, responseType: 'blob' }) +} + +// 9. 下载完成回执上报 +export function bookDownloadAck(data) { + return request({ url: '/api/iot/v1/book/download/ack', method: 'post', data }) +} diff --git a/build_ui/src/api/device/readingAlternateProgress.js b/build_ui/src/api/device/readingAlternateProgress.js new file mode 100644 index 000000000..be4af2bac --- /dev/null +++ b/build_ui/src/api/device/readingAlternateProgress.js @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +// 备选冲突续读进度 train_reading_alternate_progress +export function listReadingAlternateProgress(query) { + return request({ url: '/device/readingalternateprogress/list', method: 'get', params: query }) +} + +export function getReadingAlternateProgress(id) { + return request({ url: '/device/readingalternateprogress/' + id, method: 'get' }) +} + +export function addReadingAlternateProgress(data) { + return request({ url: '/device/readingalternateprogress', method: 'post', data }) +} + +export function updateReadingAlternateProgress(data) { + return request({ url: '/device/readingalternateprogress', method: 'put', data }) +} + +export function delReadingAlternateProgress(id) { + return request({ url: '/device/readingalternateprogress/' + id, method: 'delete' }) +} diff --git a/build_ui/src/api/device/readingCurrentProgress.js b/build_ui/src/api/device/readingCurrentProgress.js new file mode 100644 index 000000000..42f4a1dc8 --- /dev/null +++ b/build_ui/src/api/device/readingCurrentProgress.js @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +// 学员主续读进度 train_reading_current_progress +export function listReadingCurrentProgress(query) { + return request({ url: '/device/readingcurrentprogress/list', method: 'get', params: query }) +} + +export function getReadingCurrentProgress(id) { + return request({ url: '/device/readingcurrentprogress/' + id, method: 'get' }) +} + +export function addReadingCurrentProgress(data) { + return request({ url: '/device/readingcurrentprogress', method: 'post', data }) +} + +export function updateReadingCurrentProgress(data) { + return request({ url: '/device/readingcurrentprogress', method: 'put', data }) +} + +export function delReadingCurrentProgress(id) { + return request({ url: '/device/readingcurrentprogress/' + id, method: 'delete' }) +} diff --git a/build_ui/src/api/device/readingDurationLog.js b/build_ui/src/api/device/readingDurationLog.js new file mode 100644 index 000000000..eccfeca84 --- /dev/null +++ b/build_ui/src/api/device/readingDurationLog.js @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +// 阅读上报幂等日志 train_reading_duration_log +export function listReadingDurationLog(query) { + return request({ url: '/device/readingdurationlog/list', method: 'get', params: query }) +} + +export function getReadingDurationLog(id) { + return request({ url: '/device/readingdurationlog/' + id, method: 'get' }) +} + +export function addReadingDurationLog(data) { + return request({ url: '/device/readingdurationlog', method: 'post', data }) +} + +export function updateReadingDurationLog(data) { + return request({ url: '/device/readingdurationlog', method: 'put', data }) +} + +export function delReadingDurationLog(id) { + return request({ url: '/device/readingdurationlog/' + id, method: 'delete' }) +} diff --git a/build_ui/src/api/device/session.js b/build_ui/src/api/device/session.js new file mode 100644 index 000000000..b4633daa3 --- /dev/null +++ b/build_ui/src/api/device/session.js @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +// 学员业务会话 train_user_session +export function listUserSession(query) { + return request({ url: '/device/usersession/list', method: 'get', params: query }) +} + +export function getUserSession(id) { + return request({ url: '/device/usersession/' + id, method: 'get' }) +} + +export function addUserSession(data) { + return request({ url: '/device/usersession', method: 'post', data }) +} + +export function updateUserSession(data) { + return request({ url: '/device/usersession', method: 'put', data }) +} + +export function delUserSession(id) { + return request({ url: '/device/usersession/' + id, method: 'delete' }) +} diff --git a/build_ui/src/api/iot/auth.js b/build_ui/src/api/iot/auth.js index 5fe42f431..4a3f88b7b 100644 --- a/build_ui/src/api/iot/auth.js +++ b/build_ui/src/api/iot/auth.js @@ -76,3 +76,12 @@ export function batchClear(authIds) { data: authIds }) } + +// 人工对账:将不确定态的授权标记为操作员确认的终态(CONFIRMED/REJECTED) +export function reconcileAuth(authId, outcome, updateBy) { + return request({ + url: '/lock/auth/' + authId + '/reconcile', + method: 'post', + params: { outcome, updateBy } + }) +} diff --git a/build_ui/src/api/train/analysis.js b/build_ui/src/api/train/analysis.js index 0ab0d2291..6ff052672 100644 --- a/build_ui/src/api/train/analysis.js +++ b/build_ui/src/api/train/analysis.js @@ -41,3 +41,38 @@ export function getStudent360(studentId) { method: 'get' }) } + +// 获取成绩统计分析总览 KPI(理论 + 实训上报) +export function getScoreSummary(params) { + return request({ + url: '/train/analysis/score/summary', + method: 'get', + params: params + }) +} + +// 获取成绩统计分析图表集合 +export function getScoreCharts(params) { + return request({ + url: '/train/analysis/score/charts', + method: 'get', + params: params + }) +} + +// 获取成绩统计指定维度的分组明细(支持图表/表格视图切换) +export function getScoreDimension(params) { + return request({ + url: '/train/analysis/score/dimension', + method: 'get', + params: params + }) +} + +// 获取成绩统计筛选项(培训班 / 课程 / 实训上报平台) +export function getScoreFilterOptions() { + return request({ + url: '/train/analysis/score/filters', + method: 'get' + }) +} diff --git a/build_ui/src/api/train/classroom.js b/build_ui/src/api/train/classroom.js index cd7ec961f..bb41b513a 100644 --- a/build_ui/src/api/train/classroom.js +++ b/build_ui/src/api/train/classroom.js @@ -17,6 +17,14 @@ export function getClassroom(classRoomId) { }) } +// 按培训计划查询教室信息列表 +export function listClassroomByPlan(planId) { + return request({ + url: '/train/classroom/plan/' + planId, + method: 'get' + }) +} + // 新增教室信息 export function addClassroom(data) { return request({ diff --git a/build_ui/src/api/train/dormitory.js b/build_ui/src/api/train/dormitory.js index bf42dfb94..a2041aeae 100644 --- a/build_ui/src/api/train/dormitory.js +++ b/build_ui/src/api/train/dormitory.js @@ -26,6 +26,23 @@ export function addDormitory(data) { }) } +// 修改宿舍信息 +export function updateDormitory(data) { + return request({ + url: '/train/dormitory/' + data.dormId, + method: 'put', + data: data + }) +} + +// 删除宿舍房间(支持批量,dormIds 为数组) +export function delDormitory(dormIds) { + return request({ + url: '/train/dormitory/' + (Array.isArray(dormIds) ? dormIds.join(',') : dormIds), + method: 'delete' + }) +} + // 获取楼栋、楼层级联下拉数据 (P0) export function getDormitoryOptions() { return request({ diff --git a/build_ui/src/api/train/enrollment.js b/build_ui/src/api/train/enrollment.js index 76974925e..fc7975c57 100644 --- a/build_ui/src/api/train/enrollment.js +++ b/build_ui/src/api/train/enrollment.js @@ -64,3 +64,13 @@ export function confirmReport(enrollmentId, data) { silentErrorCodes: [40051] }) } + +// 5.2 报到重置(TrainRegisterApiController#resetReport) +// 解绑人脸 → 清除门锁入住授权 → 参训状态重置为未报到(0),宿舍分配保留 +export function resetReport(planId, studentId) { + return request({ + url: '/api/register/v1/report/reset', + method: 'post', + params: { planId, studentId } + }) +} diff --git a/build_ui/src/api/train/examReport.js b/build_ui/src/api/train/examReport.js new file mode 100644 index 000000000..8a9c6fca2 --- /dev/null +++ b/build_ui/src/api/train/examReport.js @@ -0,0 +1,35 @@ +import request from '@/utils/request' + +// 查询外部实训上报成绩列表 +export function listExamReport(query) { + return request({ + url: '/train/examReport/list', + method: 'get', + params: query + }) +} + +// 查询上报成绩详情 +export function getExamReport(reportPk) { + return request({ + url: '/train/examReport/' + reportPk, + method: 'get' + }) +} + +// 认领待匹配成绩 +export function claimExamReport(data) { + return request({ + url: '/train/examReport/claim', + method: 'post', + data: data + }) +} + +// 删除上报成绩 +export function delExamReport(reportPks) { + return request({ + url: '/train/examReport/' + reportPks, + method: 'delete' + }) +} diff --git a/build_ui/src/api/train/examSubject.js b/build_ui/src/api/train/examSubject.js new file mode 100644 index 000000000..acfa49d4f --- /dev/null +++ b/build_ui/src/api/train/examSubject.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 查询考试科目列表 +export function listExamSubject(query) { + return request({ + url: '/train/examSubject/list', + method: 'get', + params: query + }) +} + +// 查询考试科目详细 +export function getExamSubject(subjectId) { + return request({ + url: '/train/examSubject/' + subjectId, + method: 'get' + }) +} + +// 新增考试科目 +export function addExamSubject(data) { + return request({ + url: '/train/examSubject', + method: 'post', + data: data + }) +} + +// 修改考试科目 +export function updateExamSubject(data) { + return request({ + url: '/train/examSubject', + method: 'put', + data: data + }) +} + +// 删除考试科目 +export function delExamSubject(subjectIds) { + return request({ + url: '/train/examSubject/' + subjectIds, + method: 'delete' + }) +} diff --git a/build_ui/src/api/train/historyRegData.js b/build_ui/src/api/train/historyRegData.js new file mode 100644 index 000000000..5d9123eaa --- /dev/null +++ b/build_ui/src/api/train/historyRegData.js @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 历史报到统计汇总(累计报到人次/平均报到率/平均到训耗时/最早最晚到达/到训时间分布) +export function getRegHistorySummary() { + return request({ + url: '/train/regdata/history/summary', + method: 'get' + }) +} + +// 各批次报到率趋势(近 N 个批次) +export function listBatchTrend() { + return request({ + url: '/train/regdata/history/batchTrend', + method: 'get' + }) +} + +// 分页查询各送培单位报到情况(按平均报到率降序) +export function listUnitReport(query) { + return request({ + url: '/train/regdata/history/units', + method: 'get', + params: query + }) +} diff --git a/build_ui/src/api/train/lockDevice.js b/build_ui/src/api/train/lockDevice.js new file mode 100644 index 000000000..4861d7a49 --- /dev/null +++ b/build_ui/src/api/train/lockDevice.js @@ -0,0 +1,10 @@ +import request from '@/utils/request' + +// 查询智能门锁设备列表(来自 ruoyi-iot 模块的 lock_device 表) +export function listLockDevice(query) { + return request({ + url: '/lock/device/list', + method: 'get', + params: query + }) +} diff --git a/build_ui/src/api/train/openApp.js b/build_ui/src/api/train/openApp.js new file mode 100644 index 000000000..514ee2b26 --- /dev/null +++ b/build_ui/src/api/train/openApp.js @@ -0,0 +1,69 @@ +import request from '@/utils/request' + +// 查询外部应用列表 +export function listOpenApp(query) { + return request({ + url: '/train/openApp/list', + method: 'get', + params: query + }) +} + +// 查询外部应用详细 +export function getOpenApp(appId) { + return request({ + url: '/train/openApp/' + appId, + method: 'get' + }) +} + +// 新增外部应用(返回一次性明文密钥) +export function addOpenApp(data) { + return request({ + url: '/train/openApp', + method: 'post', + data: data + }) +} + +// 修改外部应用 +export function updateOpenApp(data) { + return request({ + url: '/train/openApp', + method: 'put', + data: data + }) +} + +// 删除外部应用 +export function delOpenApp(appIds) { + return request({ + url: '/train/openApp/' + appIds, + method: 'delete' + }) +} + +// 重置应用密钥(返回一次性明文密钥) +export function resetOpenAppSecret(appId) { + return request({ + url: '/train/openApp/resetSecret/' + appId, + method: 'post' + }) +} + +// 查询应用授权范围 +export function listOpenAppScope(appId) { + return request({ + url: '/train/openApp/scope/' + appId, + method: 'get' + }) +} + +// 保存应用授权范围(全量覆盖) +export function saveOpenAppScope(appId, scopes) { + return request({ + url: '/train/openApp/scope/' + appId, + method: 'put', + data: scopes + }) +} diff --git a/build_ui/src/api/train/plan.js b/build_ui/src/api/train/plan.js index 4aca4cd70..e215def74 100644 --- a/build_ui/src/api/train/plan.js +++ b/build_ui/src/api/train/plan.js @@ -42,3 +42,11 @@ export function completePlan(planId) { method: 'post' }) } + +// 删除开班计划 +export function delPlan(planId) { + return request({ + url: '/train/plans/' + planId, + method: 'delete' + }) +} diff --git a/build_ui/src/api/train/regData.js b/build_ui/src/api/train/regData.js new file mode 100644 index 000000000..9df477f35 --- /dev/null +++ b/build_ui/src/api/train/regData.js @@ -0,0 +1,46 @@ +import request from '@/utils/request' + +// 查询有报到任务的班级(页面顶部班级标签) +export function listReportClasses(query) { + return request({ + url: '/train/regdata/classes', + method: 'get', + params: query + }) +} + +// 查询班级报到统计汇总(应到/已到/未到/报到率/报到时间分布) +export function getReportSummary(planId) { + return request({ + url: '/train/regdata/summary', + method: 'get', + params: { planId: planId } + }) +} + +// 分页查询未报到学员 +export function listNotReported(query) { + return request({ + url: '/train/regdata/notReported', + method: 'get', + params: query + }) +} + +// 分页查询报到名册 +export function listReportRoster(query) { + return request({ + url: '/train/regdata/roster', + method: 'get', + params: query + }) +} + +// 电话催促(studentIds 为空表示催促该班全部未报到学员) +export function urgeReport(data) { + return request({ + url: '/train/regdata/urge', + method: 'post', + data: data + }) +} diff --git a/build_ui/src/api/train/report.js b/build_ui/src/api/train/report.js index 01cb5b966..6f25d9815 100644 --- a/build_ui/src/api/train/report.js +++ b/build_ui/src/api/train/report.js @@ -14,3 +14,40 @@ export function getReport(reportId) { method: 'get' }) } + +// 第一步:查询即将开班的班级列表(含课程编码/专业、班主任、开课时间/教室、应到与待报到人数) +export function listRegisterClasses(query) { + return request({ + url: '/train/report/classes', + method: 'get', + params: query + }) +} + +// 第三步:查询可用的报到设备列表(电子班牌/报到终端等) +export function listRegisterDevices(query) { + return request({ + url: '/train/report/devices', + method: 'get', + params: query + }) +} + +// 查看报到任务列表 +export function listRegisterTasks(query) { + return request({ + url: '/train/report/tasks', + method: 'get', + params: query + }) +} + +// 启动班级报到:提交所选班级、报到起止时间与报到设备 +export function startClassRegister(data) { + return request({ + url: '/train/report/start', + method: 'post', + data: data + }) +} + diff --git a/build_ui/src/api/train/seat.js b/build_ui/src/api/train/seat.js new file mode 100644 index 000000000..9aa89afb9 --- /dev/null +++ b/build_ui/src/api/train/seat.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 查询班级座位列表 +export function listClassSeat(query) { + return request({ + url: '/train/classseat/list', + method: 'get', + params: query + }) +} + +// 查询班级座位详细 +export function getClassSeat(seatId) { + return request({ + url: '/train/classseat/' + seatId, + method: 'get' + }) +} + +// 新增班级座位 +export function addClassSeat(data) { + return request({ + url: '/train/classseat', + method: 'post', + data: data + }) +} + +// 修改班级座位 +export function updateClassSeat(data) { + return request({ + url: '/train/classseat/' + data.seatId, + method: 'put', + data: data + }) +} + +// 删除班级座位 +export function delClassSeat(seatIds) { + return request({ + url: '/train/classseat/' + seatIds, + method: 'delete' + }) +} + +// 整体保存座位布局(删除 (planId, classroomId) 下原有座位后批量写入) +export function saveClassSeatLayout(data) { + return request({ + url: '/train/classseat/layout', + method: 'post', + data: data + }) +} diff --git a/build_ui/src/api/train/site.js b/build_ui/src/api/train/site.js index 7f3dbfeb5..4d8f652f3 100644 --- a/build_ui/src/api/train/site.js +++ b/build_ui/src/api/train/site.js @@ -8,6 +8,13 @@ export function listSite(query) { params: query }) } +// 按培训计划查询实训场地列表 +export function listSiteByPlan(planId) { + return request({ + url: '/train/training-site/plan/' + planId, + method: 'get' + }) +} // 查询实训场地详细 export function getSite(siteId) { diff --git a/build_ui/src/api/train/student.js b/build_ui/src/api/train/student.js index 3a643c16e..65a1630b8 100644 --- a/build_ui/src/api/train/student.js +++ b/build_ui/src/api/train/student.js @@ -35,6 +35,14 @@ export function updateStudent(data) { }) } +// 生成唯一人员编号(AA + 6位数字) +export function genPersonNo() { + return request({ + url: '/train/student/gen-person-no', + method: 'get' + }) +} + // 查询可关联学员的系统用户候选列表 export function getStudentBindableUsers(keyword) { return request({ @@ -68,3 +76,49 @@ export function delStudent(sId) { method: 'delete' }) } + +// 触发特征提取(deviceId 指定目标门锁,取其维护的 algorithm_version) +export function extract(sId, deviceId) { + return request({ + url: '/train/student/' + sId + '/extract', + method: 'post', + params: { deviceId: deviceId } + }) +} + +// 触发特征提取(deviceId 指定目标门锁,取其维护的 algorithm_version) +export function extractStudent(sId, deviceId) { + return request({ + url: '/train/student/' + sId + '/extractStudent', + method: 'post', + params: { deviceId: deviceId } + }) +} + +// 学员批量导入(multipart/form-data,FormData 中的字段名必须是 file) +// 注意:request.js 里把 axios 默认头设置成了 application/json, +// axios 0.30 的 transformRequest 遇到「FormData + JSON 头」会把表单转成 JSON 字符串, +// 导致后端报 "Current request is not a multipart request"。 +// 因此这里必须显式声明 multipart/form-data;浏览器会自动补上 boundary。 +export function importStudentData(data) { + return request({ + url: '/train/student/importData', + method: 'post', + data: data, + // repeatSubmit:false 关闭重复提交拦截:FormData 被 JSON.stringify 后固定是 {}, + // 连续两次上传会被误判为重复提交。 + headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }, + timeout: 60000 + }) +} + +// 批量特征提取 +export function batchExtract(sIds, deviceId) { + return request({ + url: '/train/student/batch/extract', + method: 'post', + params: { deviceId: deviceId }, + data: sIds + }) +} + diff --git a/build_ui/src/api/train/teacherAccommodation.js b/build_ui/src/api/train/teacherAccommodation.js new file mode 100644 index 000000000..fef53d141 --- /dev/null +++ b/build_ui/src/api/train/teacherAccommodation.js @@ -0,0 +1,45 @@ +import request from '@/utils/request' + +// 批量查询教师当前住宿安排(教师列表页住宿列回显,teacherIds 以逗号分隔) +export function listTeacherAccommodation(teacherIds) { + return request({ + url: '/train/teacher/accommodation/list', + method: 'get', + params: { + teacherIds: Array.isArray(teacherIds) ? teacherIds.join(',') : teacherIds + } + }) +} + +// 获取教师当前住宿安排 +export function getTeacherAccommodation(teacherId) { + return request({ + url: '/train/teacher/accommodation/' + teacherId, + method: 'get' + }) +} + +// 获取教师住宿历史记录(含已退宿) +export function getTeacherAccommodationHistory(teacherId) { + return request({ + url: '/train/teacher/accommodation/' + teacherId + '/history', + method: 'get' + }) +} + +// 教师住宿分配 / 调整宿舍(教师住宿与班级无关) +export function assignTeacherAccommodation(data) { + return request({ + url: '/train/teacher/accommodation', + method: 'post', + data: data + }) +} + +// 教师退宿:后端执行门锁退宿撤权 + 人脸库删除(不清空教师头像) +export function checkOutTeacherAccommodation(accommodationId) { + return request({ + url: '/train/teacher/accommodation/' + accommodationId + '/checkout', + method: 'post' + }) +} diff --git a/build_ui/src/api/train/theoryExamScore.js b/build_ui/src/api/train/theoryExamScore.js new file mode 100644 index 000000000..6d443229a --- /dev/null +++ b/build_ui/src/api/train/theoryExamScore.js @@ -0,0 +1,55 @@ +import request from '@/utils/request' + +// 查询班级学员理论考试成绩列表 +export function listTheoryExamScore(query) { + return request({ + url: '/train/theoryExamScore/list', + method: 'get', + params: query + }) +} + +// 查询班级学员理论考试成绩详情 +export function getTheoryExamScore(theoryScoreId) { + return request({ + url: '/train/theoryExamScore/' + theoryScoreId, + method: 'get' + }) +} + +// 新增班级学员理论考试成绩 +export function addTheoryExamScore(data) { + return request({ + url: '/train/theoryExamScore', + method: 'post', + data: data + }) +} + +// 修改班级学员理论考试成绩 +export function updateTheoryExamScore(data) { + return request({ + url: '/train/theoryExamScore', + method: 'put', + data: data + }) +} + +// 删除班级学员理论考试成绩 +export function delTheoryExamScore(theoryScoreId) { + return request({ + url: '/train/theoryExamScore/' + theoryScoreId, + method: 'delete' + }) +} + +// 批量导入理论考试成绩(multipart:file + planId + planCourseId + importMode) +export function importTheoryExamScore(data) { + return request({ + url: '/train/theoryExamScore/importData', + method: 'post', + data: data, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000 + }) +} diff --git a/build_ui/src/api/train/timetable.js b/build_ui/src/api/train/timetable.js index 67100343d..133de35e1 100644 --- a/build_ui/src/api/train/timetable.js +++ b/build_ui/src/api/train/timetable.js @@ -35,10 +35,15 @@ export function removeLesson(lessonId) { } // 4.4 排课冲突检查接口 (UC-10) 【P0】 +// 该接口为幂等只读查询:自动预检(选择教师/时间/场地触发)与保存前复核 +// 可能在 1s 内以完全相同的入参重复调用,会被 request 拦截器判定为重复提交并 +// 抛出“数据正在处理,请勿重复提交”。此处关闭防重拦截(与 importStudentData 同理), +// 保存接口 addLesson/updateLesson 的防重提交能力不受影响。 export function checkLessonConflict(data) { return request({ url: '/train/lessons/check-conflict', method: 'post', + headers: { repeatSubmit: false }, data: data }) } diff --git a/build_ui/src/assets/images/login-background.jpg b/build_ui/src/assets/images/login-background.jpg index 8a89eb829..5f6988450 100644 Binary files a/build_ui/src/assets/images/login-background.jpg and b/build_ui/src/assets/images/login-background.jpg differ diff --git a/build_ui/src/assets/images/login-background1.jpg b/build_ui/src/assets/images/login-background1.jpg new file mode 100644 index 000000000..8a89eb829 Binary files /dev/null and b/build_ui/src/assets/images/login-background1.jpg differ diff --git a/build_ui/src/assets/images/login-background2.jpg b/build_ui/src/assets/images/login-background2.jpg new file mode 100644 index 000000000..cf1283713 Binary files /dev/null and b/build_ui/src/assets/images/login-background2.jpg differ diff --git a/build_ui/src/assets/images/login-background3.jpg b/build_ui/src/assets/images/login-background3.jpg new file mode 100644 index 000000000..e701f299b Binary files /dev/null and b/build_ui/src/assets/images/login-background3.jpg differ diff --git a/build_ui/src/assets/images/login-background4.jpg b/build_ui/src/assets/images/login-background4.jpg new file mode 100644 index 000000000..ffc0240ac Binary files /dev/null and b/build_ui/src/assets/images/login-background4.jpg differ diff --git a/build_ui/src/assets/images/login-background5.jpg b/build_ui/src/assets/images/login-background5.jpg new file mode 100644 index 000000000..5a19a6197 Binary files /dev/null and b/build_ui/src/assets/images/login-background5.jpg differ diff --git a/build_ui/src/assets/styles/ruoyi.scss b/build_ui/src/assets/styles/ruoyi.scss index cb1314d5a..8f99727db 100644 --- a/build_ui/src/assets/styles/ruoyi.scss +++ b/build_ui/src/assets/styles/ruoyi.scss @@ -482,3 +482,152 @@ position: relative; float: right; } + +/* 顶部静态布局样式 */ +.page-topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + .topbar-left { + display: flex; + align-items: baseline; + gap: 12px; + } + + .topbar-title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #1f2d3d; + } + + .topbar-sub { + font-size: 13px; + color: #8a94a6; + } +} + +.topbar-filter { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + background: #fff; + border: 1px solid #eef0f5; + border-radius: 10px; + padding: 16px 20px; + margin-bottom: 16px; + + .filter-item { + .filter-label { + font-size: 12px; + color: #8a94a6; + margin-bottom: 6px; + } + } + + .filter-keyword { + flex: 1; + min-width: 260px; + } + + .filter-buttons { + display: flex; + gap: 8px; + + .el-button + .el-button { + margin-left: 0; + } + } +} + +/* ==================== 通用详情弹窗 ==================== */ +/* 教室 / 宿舍 / 场地等实体详情统一采用与学员详情一致的描述列表风格 */ +.detail-dialog { + .el-dialog__body { + padding: 20px 24px 8px; + } + + /* 属性名列按名称长度自适应,禁止折行 */ + .el-descriptions-item__label { + white-space: nowrap; + word-break: keep-all; + } + + .el-descriptions-item__content { + word-break: break-word; + } +} + +/* 详情内容区:内容过高时滚动,避免弹窗超出屏幕 */ +.detail-dialog-body { + max-height: 62vh; + overflow-y: auto; + padding-right: 4px; +} + +/* 详情属性名标签 */ +.detail-dialog-label { + display: inline-block; + min-width: 64px; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + +/* 详情内分组标题 */ +.detail-dialog-section { + margin: 18px 0 8px; + font-size: 14px; + font-weight: 700; + color: #1f2d3d; +} + +/* 响应式:小屏下弹窗收窄、属性名允许折行 */ +@media (max-width: 900px) { + .detail-dialog { + width: 92% !important; + margin-top: 8vh !important; + } + + .detail-dialog .detail-dialog-label { + max-width: none; + white-space: normal; + } +} + +@media (max-width: 640px) { + .detail-dialog { + width: 96% !important; + } + + .detail-dialog .el-descriptions-item__label { + white-space: normal; + } + + .detail-dialog-body { + max-height: 68vh; + } +} + +/* 班级详情弹窗(只读):窄屏自适应,避免固定宽度溢出 */ +@media (max-width: 1200px) { + .class-detail-dialog { + width: 94% !important; + } +} + +@media (max-width: 768px) { + .class-detail-dialog { + width: 96% !important; + margin-top: 4vh !important; + } + + .class-detail-dialog .el-descriptions .el-descriptions__table { + table-layout: fixed; + } +} diff --git a/build_ui/src/assets/styles/variables.scss b/build_ui/src/assets/styles/variables.scss index 629200652..8b683471c 100644 --- a/build_ui/src/assets/styles/variables.scss +++ b/build_ui/src/assets/styles/variables.scss @@ -9,17 +9,17 @@ $yellow:#FEC171; $panGreen: #30B08F; // 默认菜单主题风格 -$base-menu-color: #bfcbd9; +$base-menu-color: #e8ecf3; $base-menu-color-active: #ffffff; -$base-menu-background: #1a1f2e; +$base-menu-background: #2c3e5d; $base-logo-title-color: #ffffff; $base-menu-light-color:rgba(0,0,0,.70); $base-menu-light-background:#ffffff; $base-logo-light-title-color: #001529; -$base-sub-menu-background: #141824; -$base-sub-menu-hover: rgba(255,255,255,.06); +$base-sub-menu-background: #243352; +$base-sub-menu-hover: rgba(255,255,255,.08); $base-sidebar-width: 200px; diff --git a/build_ui/src/config/index.js b/build_ui/src/config/index.js index 8a50b9f5c..f59fec01f 100644 --- a/build_ui/src/config/index.js +++ b/build_ui/src/config/index.js @@ -26,7 +26,7 @@ module.exports = { //target: "http://121.196.246.126:38085", //target: "http://10.1.191.245:8085", //changchun - target: "http://localhost:8090", + target: "http://127.0.0.1:8096", changeOrigin: true, pathRewriter: { '^/api': '/api', @@ -48,7 +48,7 @@ module.exports = { //apiUrl: 'http://121.196.246.126:38085/', - apiUrl: 'http://localhost:8090/', + apiUrl: 'http://127.0.0.1:8096/', //jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\', //jsonPath: '/opt/kg/apps/dist/files/', diff --git a/build_ui/src/config/index_230.js b/build_ui/src/config/index_230.js new file mode 100644 index 000000000..f09d2e932 --- /dev/null +++ b/build_ui/src/config/index_230.js @@ -0,0 +1,64 @@ +// see http://vuejs-templates.github.io/webpack for documentation. +var path = require('path') + +module.exports = { + build: { + env: require('./prod.env'), + index: path.resolve(__dirname, '../dist/index.html'), + assetsRoot: path.resolve(__dirname, '../dist'), + assetsSubDirectory: 'static', + assetsPublicPath: '/', + productionSourceMap: false, + // Gzip off by default as many popular static hosts such as + // Surge or Netlify already gzip all static assets for you. + // Before setting to `true`, make sure to: + // npm install --save-dev compression-webpack-plugin + productionGzip: false, + productionGzipExtensions: ['js', 'css'] + }, + dev: { + env: require('./dev.env'), + port: 8081, + assetsSubDirectory: 'static', + assetsPublicPath: '/', + proxyTable: { + '/api': { + + //target: "http://121.196.246.126:38085", + //target: "http://10.1.191.245:8085", //changchun + target: "http://192.168.1.230:8096", + changeOrigin: true, + pathRewriter: { + '^/api': '/api', + } + } + }, + // CSS Sourcemaps off by default because relative paths are "buggy" + // with this option, according to the CSS-Loader README + // (https://github.com/webpack/css-loader#sourcemaps) + // In our experience, they generally work as expected, + // just be aware of this issue when enabling this option. + cssSourceMap: false + }, + setting: { + neo4jUrl: 'bolt://localhost:7687/ ', + //neo4jUrl: 'bolt://121.196.246.126:7687/ ', + neo4jUserName: 'neo4j', + neo4jPassword: 'neo4j', + + + //apiUrl: 'http://121.196.246.126:38085/', + apiUrl: 'http://192.168.1.230:8096/', + + //jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\', + //jsonPath: '/opt/kg/apps/dist/files/', + jsonPath: '/opt/kg/apps/uploadfiles/', + + //dpApiUrl: 'http://121.196.246.126:38084/', + //dpApiUrl: 'http://10.1.191.245:8085/', + dpApiUrl: process.env.VUE_APP_DP_API_URL || '', + dpUsername: process.env.VUE_APP_DP_USERNAME || '', + dpPassword: process.env.VUE_APP_DP_PASSWORD || '', + + } +} diff --git a/build_ui/src/config/index_24.js b/build_ui/src/config/index_24.js new file mode 100644 index 000000000..96d030004 --- /dev/null +++ b/build_ui/src/config/index_24.js @@ -0,0 +1,64 @@ +// see http://vuejs-templates.github.io/webpack for documentation. +var path = require('path') + +module.exports = { + build: { + env: require('./prod.env'), + index: path.resolve(__dirname, '../dist/index.html'), + assetsRoot: path.resolve(__dirname, '../dist'), + assetsSubDirectory: 'static', + assetsPublicPath: '/', + productionSourceMap: false, + // Gzip off by default as many popular static hosts such as + // Surge or Netlify already gzip all static assets for you. + // Before setting to `true`, make sure to: + // npm install --save-dev compression-webpack-plugin + productionGzip: false, + productionGzipExtensions: ['js', 'css'] + }, + dev: { + env: require('./dev.env'), + port: 8081, + assetsSubDirectory: 'static', + assetsPublicPath: '/', + proxyTable: { + '/api': { + + //target: "http://121.196.246.126:38085", + //target: "http://10.1.191.245:8085", //changchun + target: "http://192.168.106.24:8096", + changeOrigin: true, + pathRewriter: { + '^/api': '/api', + } + } + }, + // CSS Sourcemaps off by default because relative paths are "buggy" + // with this option, according to the CSS-Loader README + // (https://github.com/webpack/css-loader#sourcemaps) + // In our experience, they generally work as expected, + // just be aware of this issue when enabling this option. + cssSourceMap: false + }, + setting: { + neo4jUrl: 'bolt://localhost:7687/ ', + //neo4jUrl: 'bolt://121.196.246.126:7687/ ', + neo4jUserName: 'neo4j', + neo4jPassword: 'neo4j', + + + //apiUrl: 'http://121.196.246.126:38085/', + apiUrl: 'http://192.168.106.24:8096/', + + //jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\', + //jsonPath: '/opt/kg/apps/dist/files/', + jsonPath: '/opt/kg/apps/uploadfiles/', + + //dpApiUrl: 'http://121.196.246.126:38084/', + //dpApiUrl: 'http://10.1.191.245:8085/', + dpApiUrl: process.env.VUE_APP_DP_API_URL || '', + dpUsername: process.env.VUE_APP_DP_USERNAME || '', + dpPassword: process.env.VUE_APP_DP_PASSWORD || '', + + } +} diff --git a/build_ui/src/config/index_localhost.js b/build_ui/src/config/index_localhost.js new file mode 100644 index 000000000..f59fec01f --- /dev/null +++ b/build_ui/src/config/index_localhost.js @@ -0,0 +1,64 @@ +// see http://vuejs-templates.github.io/webpack for documentation. +var path = require('path') + +module.exports = { + build: { + env: require('./prod.env'), + index: path.resolve(__dirname, '../dist/index.html'), + assetsRoot: path.resolve(__dirname, '../dist'), + assetsSubDirectory: 'static', + assetsPublicPath: '/', + productionSourceMap: false, + // Gzip off by default as many popular static hosts such as + // Surge or Netlify already gzip all static assets for you. + // Before setting to `true`, make sure to: + // npm install --save-dev compression-webpack-plugin + productionGzip: false, + productionGzipExtensions: ['js', 'css'] + }, + dev: { + env: require('./dev.env'), + port: 8081, + assetsSubDirectory: 'static', + assetsPublicPath: '/', + proxyTable: { + '/api': { + + //target: "http://121.196.246.126:38085", + //target: "http://10.1.191.245:8085", //changchun + target: "http://127.0.0.1:8096", + changeOrigin: true, + pathRewriter: { + '^/api': '/api', + } + } + }, + // CSS Sourcemaps off by default because relative paths are "buggy" + // with this option, according to the CSS-Loader README + // (https://github.com/webpack/css-loader#sourcemaps) + // In our experience, they generally work as expected, + // just be aware of this issue when enabling this option. + cssSourceMap: false + }, + setting: { + neo4jUrl: 'bolt://localhost:7687/ ', + //neo4jUrl: 'bolt://121.196.246.126:7687/ ', + neo4jUserName: 'neo4j', + neo4jPassword: 'neo4j', + + + //apiUrl: 'http://121.196.246.126:38085/', + apiUrl: 'http://127.0.0.1:8096/', + + //jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\', + //jsonPath: '/opt/kg/apps/dist/files/', + jsonPath: '/opt/kg/apps/uploadfiles/', + + //dpApiUrl: 'http://121.196.246.126:38084/', + //dpApiUrl: 'http://10.1.191.245:8085/', + dpApiUrl: process.env.VUE_APP_DP_API_URL || '', + dpUsername: process.env.VUE_APP_DP_USERNAME || '', + dpPassword: process.env.VUE_APP_DP_PASSWORD || '', + + } +} diff --git a/build_ui/src/layout/components/Navbar.vue b/build_ui/src/layout/components/Navbar.vue index ac2959d85..56bb64528 100644 --- a/build_ui/src/layout/components/Navbar.vue +++ b/build_ui/src/layout/components/Navbar.vue @@ -9,7 +9,7 @@
- + + diff --git a/build_ui/src/views/train/board/index.vue b/build_ui/src/views/train/board/index.vue index 6de02cac0..c9e84a1a4 100644 --- a/build_ui/src/views/train/board/index.vue +++ b/build_ui/src/views/train/board/index.vue @@ -1,5 +1,19 @@