chore(sync): 同步当前工作区全部变更(学员学号可编辑并留空自动生成等)
This commit is contained in:
1 parent
01e949093a
commit
f5e8d7f564
531 files changed
+72931
-2673
No files matched your search
@@ -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 协议与现有命令模型,仅在命令之上叠加调度层。
|
||||
@@ -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<T>`:`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<Long> 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<T>{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<Long> 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` |
|
||||
@@ -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` |
|
||||
@@ -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` 为本次返回条数,不是分页总数。
|
||||
File diff suppressed because it is too large.
Load diff
+10
-2
@@ -16,7 +16,7 @@
|
||||
<ruoyi.version>3.9.2</ruoyi.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<java.version>21</java.version>
|
||||
<java.version>17</java.version>
|
||||
<spring-boot.version>4.1.0</spring-boot.version>
|
||||
<mybatis-spring-boot.version>4.1.0</mybatis-spring-boot.version>
|
||||
<druid.version>1.2.28</druid.version>
|
||||
@@ -193,6 +193,12 @@
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-device</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 通用工具-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
@@ -212,6 +218,7 @@
|
||||
<module>ruoyi-common</module>
|
||||
<module>ruoyi-iot</module>
|
||||
<module>ruoyi-train</module>
|
||||
<module>ruoyi-device</module>
|
||||
</modules>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
@@ -223,7 +230,8 @@
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
<release>${java.version}</release>
|
||||
<source>${java.version}</source>
|
||||
<target>${java.version}</target>
|
||||
<encoding>${project.build.sourceEncoding}</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
<version>3.9.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-device</artifactId>
|
||||
<version>3.9.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -79,6 +85,11 @@
|
||||
<artifactId>junit-vintage-engine</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.web.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 开放接口统一响应(code 0 表示成功,与《外部实训系统数据接口规范》一致)
|
||||
*
|
||||
* <p>注意:不能复用 {@link com.ruoyi.common.core.domain.AjaxResult},其成功码为 200。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-11-05
|
||||
*/
|
||||
public class OpenApiResult extends LinkedHashMap<String, Object>
|
||||
{
|
||||
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<String, Object> page(long total, int pageNo, int pageSize, Object list)
|
||||
{
|
||||
Map<String, Object> data = new HashMap<>(4);
|
||||
data.put("total", total);
|
||||
data.put("pageNo", pageNo);
|
||||
data.put("pageSize", pageSize);
|
||||
data.put("list", list);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
+253
@@ -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. 班牌上报签到数据(新增接口,未改动既有接口)。
|
||||
* <p>
|
||||
* 请求方式: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<String, Object> 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. 查询本节课签到记录(新增接口,未改动既有接口)。
|
||||
* <p>
|
||||
* 按 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<TrainStudentCheckin> rows = trainStudentCheckinService.selectCheckinsByLessonId(lessonId);
|
||||
return wrapRows(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. 查询班级座位布局(新增接口,仅读取 train_class_seat 单表,不影响其它接口)。
|
||||
* <p>
|
||||
* 入参: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<String, Object> 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)。
|
||||
* <p>
|
||||
* 示例:{"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;
|
||||
}
|
||||
}
|
||||
+643
@@ -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<StudentItemVO> list = studentService.selectStudentList(new StudentQuery());
|
||||
return AjaxResult.success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 获取班级(培训计划)信息列表
|
||||
*/
|
||||
@Operation(summary = "获取班级信息列表", description = "从服务器获取开班计划(班级)列表,需 Bearer 鉴权。")
|
||||
@GetMapping("/plan/list")
|
||||
public AjaxResult planList()
|
||||
{
|
||||
List<PlanItemVO> 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<PlanCourseItemVO> 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<PlanItemVO> 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<ReadingRecordRequest> 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<Object> 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<ReadingRecordItemResult> 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<AlternateProgress> alternateProgressList;
|
||||
}
|
||||
|
||||
public static class ProgressData
|
||||
{
|
||||
public Long userId;
|
||||
public long totalReadSeconds;
|
||||
public long totalAudioSeconds;
|
||||
public List<BookRecord> 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<BookItem> 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;
|
||||
}
|
||||
}
|
||||
+424
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> payload)
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> employeeIds = (List<String>) payload.get("employee_ids");
|
||||
log.info("deleteEmployeesBatch 收到请求 body={}", payload);
|
||||
|
||||
if (!faceEnabled)
|
||||
{
|
||||
Map<String, Object> 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<String, Object> organized = organizeFaceImportFolder();
|
||||
String folderPath = (String) organized.get("folder_path");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> imported = (List<Map<String, Object>>) organized.get("imported");
|
||||
log.info("importEmployeesBatch 收到请求 body={}", imported);
|
||||
|
||||
if (!faceEnabled)
|
||||
{
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> data = new HashMap<>(1);
|
||||
data.put("deleted_count", 7);
|
||||
return AjaxResult.success("人脸服务未启用,返回模拟响应", data);
|
||||
}
|
||||
return doDelete("/employees");
|
||||
}
|
||||
|
||||
// ===================== 内部:真实调用人脸设备 =====================
|
||||
|
||||
private AjaxResult doPost(String path, Map<String, Object> body)
|
||||
{
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-API-Key", apiKey);
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<Map> 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<Void> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<Map> 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<String, Object> organizeFaceImportFolder()
|
||||
{
|
||||
Map<String, Object> result = new HashMap<>(4);
|
||||
List<Map<String, Object>> imported = new ArrayList<>();
|
||||
try
|
||||
{
|
||||
String folderPath = RuoYiConfig.getProfile() + File.separator + "face_import_" + System.currentTimeMillis();
|
||||
File folder = new File(folderPath);
|
||||
folder.mkdirs();
|
||||
List<StudentItemVO> 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<String, Object> 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<String, Object> buildMockAddEmployee(String employeeId, String name)
|
||||
{
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+534
@@ -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;
|
||||
|
||||
/**
|
||||
* 房间门禁开放服务代理控制器。
|
||||
*
|
||||
* <p>对接外部「对外开放房间门禁服务」(入住 / 退房 / 续住 / 开锁记录查询):
|
||||
* 基础地址取 {@code iot.face.lock_endpoint},每个请求均携带请求头
|
||||
* {@code X-API-Key}(值取 {@code iot.face.api_key}),并按文档的请求方法、路径、
|
||||
* 参数与统一返回结构({@code code/msg/requestId/data})进行转发与映射。
|
||||
*
|
||||
* <p>并发与异常:使用线程安全的 {@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<String> checkin(@RequestBody(required = false) Map<String, Object> 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<String> checkout(@RequestBody(required = false) Map<String, Object> body)
|
||||
{
|
||||
return forward("POST", PATH_CHECKOUT, body, "办理退房");
|
||||
}
|
||||
|
||||
// ==================== 接口三:办理续住 ====================
|
||||
|
||||
/**
|
||||
* 办理续住(POST /openapi/iot/stay/extend)。
|
||||
* 请求体支持 bizNo 或人员标识 + roomNo,及新的 endTime。
|
||||
*/
|
||||
@PostMapping("/stay/extend")
|
||||
public ResponseEntity<String> extend(@RequestBody(required = false) Map<String, Object> body)
|
||||
{
|
||||
return forward("POST", PATH_EXTEND, body, "办理续住");
|
||||
}
|
||||
|
||||
// ==================== 接口四:查询开锁记录 ====================
|
||||
|
||||
/**
|
||||
* 查询开锁记录(GET /openapi/iot/stay/unlock-records)。
|
||||
* 按文档 §7.2 映射查询参数;空值参数不下发。
|
||||
*/
|
||||
@GetMapping("/stay/unlock-records")
|
||||
public ResponseEntity<String> 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<String> pingRecords()
|
||||
{
|
||||
StringBuilder query = new StringBuilder();
|
||||
String path = query.length() == 0 ? PATH_TEST : PATH_TEST + "?" + query;
|
||||
return forward("GET", path, null, "测试接口");
|
||||
}
|
||||
|
||||
// ==================== 接口五:结果回调接收 ====================
|
||||
|
||||
/**
|
||||
* 接收门禁开放服务的结果回调(文档 §8.1)。
|
||||
*
|
||||
* <p>回调报文为 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)。
|
||||
*
|
||||
* <p>入参校验(不通过按文档 §3.2 返回 40001,HTTP 400):报文为空;eventId/eventType/bizNo/result 缺失;
|
||||
* eventType 不在三类事件内;result 不是 SUCCESS/FAILED;revision 缺失或非正整数。
|
||||
*
|
||||
* <p>校验通过后按 bizNo + eventType + result 回写 train_accommodation.lock_result:
|
||||
* STAY_CHECKIN_RESULT + SUCCESS → 2(已授权);STAY_CHECKOUT_RESULT + SUCCESS → 3(已过期);
|
||||
* 其它组合不改变该字段。bizNo 无对应住宿记录时只记录日志并按接收成功应答;记录存在但更新未生效/异常时
|
||||
* 返回 50002(HTTP 500),交由对方按回调重试策略重试,保证最终一致。
|
||||
*
|
||||
* <p>处理完成按文档 §8.1 的接收判定返回 HTTP 200 + {"code":0,...}(对方据此认为接收成功、不再重试);
|
||||
* 去重与重试策略由回调发起方(本系统 P2 回调任务表 lock_callback_task)负责。
|
||||
*
|
||||
* <p>路由与鉴权沿用本控制器既有约定:同属 {@code /api/lock/v1} 前缀,未额外声明权限注解,
|
||||
* 与 checkin/checkout/extend/unlock-records 保持一致的鉴权策略。
|
||||
*/
|
||||
@PostMapping("/stay/callback")
|
||||
public ResponseEntity<String> stayCallback(@RequestBody(required = false) Map<String, Object> 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<String, Object> 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<String, Object> 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<String> forward(String method, String path, Map<String, Object> 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<String> 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<String> error(int code, String msg)
|
||||
{
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+302
@@ -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 巡检 / 实操 / 帽石考核等系统)
|
||||
*
|
||||
* <p>鉴权:除 3.1 登录外均需 {@code Authorization: Bearer {accessToken}},由本控制器自行校验,
|
||||
* 并通过 {@code @Anonymous} 放行 Spring Security。</p>
|
||||
*
|
||||
* <p>响应:统一 {@link OpenApiResult},业务结果用 code 表达(HTTP 恒为 200)。</p>
|
||||
*
|
||||
* @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<String, Object> 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<String, Object> data = trainOpenTokenService.issueToken(app, IpUtils.getIpAddr(),
|
||||
request.getHeader("User-Agent"));
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<OpenExamReportItemDTO> 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);
|
||||
}
|
||||
}
|
||||
+949
@@ -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<ReadingRecordItemResult> 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<TrainReadingCurrentProgress> mains = currentProgressService.selectListByUserId(userId);
|
||||
List<BookRecord> 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<TrainReadingAlternateProgress> alts = alternateProgressService.selectListByUserBookMode(userId, cp.getBookId(), cp.getMode());
|
||||
List<AlternateProgress> 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<DeviceListItem> result = new ArrayList<>();
|
||||
|
||||
// 电子班牌
|
||||
List<TrainElectronicBoard> 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<TrainIotDevice> 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<TrainBookSnapshot> list = bookSnapshotService.selectOnShelfList(bookCategory);
|
||||
List<BookItem> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图书封面地址转换为设备可访问的绝对地址。
|
||||
* <p>
|
||||
* /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 百分号编码。
|
||||
* <p>
|
||||
* 仅处理 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 前缀。
|
||||
* <p>
|
||||
* 已带 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<ReadingRecordRequest> 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<Object> 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<ReadingRecordItemResult> 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<AlternateProgress> alternateProgressList;
|
||||
}
|
||||
|
||||
public static class ProgressData
|
||||
{
|
||||
public String userId;
|
||||
public long totalReadSeconds;
|
||||
public long totalAudioSeconds;
|
||||
public List<BookRecord> 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<BookItem> list;
|
||||
}
|
||||
|
||||
public static class DownloadAckData
|
||||
{
|
||||
public Boolean deviceReady;
|
||||
public Long recordId;
|
||||
}
|
||||
|
||||
public static class DeviceListItem
|
||||
{
|
||||
public String deviceId;
|
||||
public String deviceType;
|
||||
public String boardSn;
|
||||
}
|
||||
}
|
||||
+1411
File diff suppressed because it is too large.
Load diff
+86
@@ -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<TrainBookSnapshot> 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<TrainBookSnapshot> list = trainBookSnapshotService.selectTrainBookSnapshotList(trainBookSnapshot);
|
||||
ExcelUtil<TrainBookSnapshot> util = new ExcelUtil<TrainBookSnapshot>(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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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<TrainCheckinTerminal> 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<TrainCheckinTerminal> list = trainCheckinTerminalService.selectTrainCheckinTerminalList(trainCheckinTerminal);
|
||||
ExcelUtil<TrainCheckinTerminal> util = new ExcelUtil<TrainCheckinTerminal>(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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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<TrainIotDevice> 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<TrainIotDevice> list = trainIotDeviceService.selectTrainIotDeviceList(trainIotDevice);
|
||||
ExcelUtil<TrainIotDevice> util = new ExcelUtil<TrainIotDevice>(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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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<TrainReadingAlternateProgress> 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<TrainReadingAlternateProgress> list = trainReadingAlternateProgressService.selectTrainReadingAlternateProgressList(trainReadingAlternateProgress);
|
||||
ExcelUtil<TrainReadingAlternateProgress> util = new ExcelUtil<TrainReadingAlternateProgress>(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));
|
||||
}
|
||||
}
|
||||
+735
@@ -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<ReadingRecordItemResult> 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<TrainReadingCurrentProgress> mains = currentProgressService.selectListByUserId(userId);
|
||||
List<BookRecord> 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<TrainReadingAlternateProgress> alts = alternateProgressService.selectListByUserBookMode(userId, cp.getBookId(), cp.getMode());
|
||||
List<AlternateProgress> 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<TrainBookSnapshot> list = bookSnapshotService.selectOnShelfList(bookCategory);
|
||||
List<BookItem> 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<ReadingRecordRequest> 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<Object> 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<ReadingRecordItemResult> 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<AlternateProgress> alternateProgressList;
|
||||
}
|
||||
|
||||
public static class ProgressData
|
||||
{
|
||||
public Long userId;
|
||||
public long totalReadSeconds;
|
||||
public long totalAudioSeconds;
|
||||
public List<BookRecord> 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<BookItem> list;
|
||||
}
|
||||
|
||||
public static class DownloadAckData
|
||||
{
|
||||
public Boolean deviceReady;
|
||||
public Long recordId;
|
||||
}
|
||||
}
|
||||
+86
@@ -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<TrainReadingCurrentProgress> 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<TrainReadingCurrentProgress> list = trainReadingCurrentProgressService.selectTrainReadingCurrentProgressList(trainReadingCurrentProgress);
|
||||
ExcelUtil<TrainReadingCurrentProgress> util = new ExcelUtil<TrainReadingCurrentProgress>(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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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<TrainReadingDurationLog> 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<TrainReadingDurationLog> list = trainReadingDurationLogService.selectTrainReadingDurationLogList(trainReadingDurationLog);
|
||||
ExcelUtil<TrainReadingDurationLog> util = new ExcelUtil<TrainReadingDurationLog>(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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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<TrainUserSession> 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<TrainUserSession> list = trainUserSessionService.selectTrainUserSessionList(trainUserSession);
|
||||
ExcelUtil<TrainUserSession> util = new ExcelUtil<TrainUserSession>(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));
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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<String, Object> data = new HashMap<>(4);
|
||||
data.put("accommodationId", result.getAccommodationId());
|
||||
data.put("lockApiBizNo", result.getLockApiBizNo());
|
||||
data.put("lockCheckin", result.getLockCheckin());
|
||||
return AjaxResult.success(data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-5
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
+106
@@ -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));
|
||||
}
|
||||
}
|
||||
+10
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增教室信息
|
||||
*/
|
||||
|
||||
+31
@@ -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<DormOptionsVO> 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));
|
||||
}
|
||||
}
|
||||
+15
@@ -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)。
|
||||
*
|
||||
* <p>与 {@link #batchImportEnrollments} 使用同一套解析规则:xlsx 格式,第一个工作表按
|
||||
* A 学员ID / B 参训来源 / C 住宿需求 / D 备注 的列顺序解析;模板内含逐列填写规则说明与一行示例数据。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:enrollment:import')")
|
||||
@Log(title = "参训人员导入模板", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/enrollments/importTemplate")
|
||||
public void importTemplate(HttpServletResponse response)
|
||||
{
|
||||
enrollmentAppService.exportImportTemplate(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参训档案详情 (P0)。
|
||||
*/
|
||||
|
||||
+87
@@ -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<TrainExamReport> 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<String, Object> 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));
|
||||
}
|
||||
}
|
||||
+92
@@ -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<TrainExamSubject> 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));
|
||||
}
|
||||
}
|
||||
+130
@@ -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<TrainOpenApp> 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<TrainOpenAppScope> scopes)
|
||||
{
|
||||
return toAjax(trainOpenAppService.saveScopes(appId, scopes, SecurityUtils.getUsername()));
|
||||
}
|
||||
}
|
||||
+11
@@ -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();
|
||||
}
|
||||
}
|
||||
+252
@@ -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
|
||||
*
|
||||
* <p>路由前缀 /train/regdata,与前端 src/api/train/regData.js、
|
||||
* src/api/train/historyRegData.js 一一对应。</p>
|
||||
*
|
||||
* <p>错误语义遵循框架统一返回体:HTTP 状态固定 200,业务码在 body.code 中,
|
||||
* 400=参数缺失/非法,403=无权限,404=开班计划/报到记录不存在,409=重复报到。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/train/regdata")
|
||||
public class TrainRegDataController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
private ITrainRegDataService trainRegDataService;
|
||||
|
||||
/**
|
||||
* 查询有报到任务的班级标签。
|
||||
*
|
||||
* <p>GET /train/regdata/classes?pageNum=1&pageSize=100</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:list')")
|
||||
@GetMapping("/classes")
|
||||
public TableDataInfo classes()
|
||||
{
|
||||
startPage();
|
||||
List<RegClassVO> list = trainRegDataService.selectReportClasses();
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询班级报到统计汇总。
|
||||
*
|
||||
* <p>GET /train/regdata/summary?planId=xxx</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询未报到学员。
|
||||
*
|
||||
* <p>GET /train/regdata/notReported?planId=xxx&pageNum=1&pageSize=6</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:list')")
|
||||
@GetMapping("/notReported")
|
||||
public TableDataInfo notReported(@RequestParam(value = "planId", required = false) String planId)
|
||||
{
|
||||
// 先校验班级存在性,避免分页线程变量残留
|
||||
trainRegDataService.selectReportPlan(planId);
|
||||
startPage();
|
||||
List<RegRosterVO> list = trainRegDataService.selectNotReported(planId);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询报到名册(支持 studentName / reportTime 排序)。
|
||||
*
|
||||
* <p>GET /train/regdata/roster?planId=xxx&pageNum=1&pageSize=10&orderByColumn=reportTime&isAsc=desc</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:list')")
|
||||
@GetMapping("/roster")
|
||||
public TableDataInfo roster(@RequestParam(value = "planId", required = false) String planId)
|
||||
{
|
||||
trainRegDataService.selectReportPlan(planId);
|
||||
startPage();
|
||||
List<RegRosterVO> list = trainRegDataService.selectRoster(planId);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出报到名册。
|
||||
*
|
||||
* <p>POST /train/regdata/roster/export (form: planId)</p>
|
||||
*/
|
||||
@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<RegRosterVO> list = trainRegDataService.selectRoster(planId);
|
||||
ExcelUtil<RegRosterVO> util = new ExcelUtil<RegRosterVO>(RegRosterVO.class);
|
||||
util.exportExcel(response, list, "报到名册");
|
||||
}
|
||||
|
||||
/**
|
||||
* 催促未报到学员。
|
||||
*
|
||||
* <p>POST /train/regdata/urge,body {"planId":"xxx","studentIds":["s1"]},
|
||||
* studentIds 为空表示催促全班未报到学员。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单条报到记录。
|
||||
*
|
||||
* <p>GET /train/regdata/report/{reportId}</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:list')")
|
||||
@GetMapping("/report/{reportId}")
|
||||
public AjaxResult getReport(@PathVariable("reportId") Long reportId)
|
||||
{
|
||||
TrainClassReport report = trainRegDataService.selectReport(reportId);
|
||||
return success(report);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增报到记录。
|
||||
*
|
||||
* <p>POST /train/regdata/report,body 见 {@link RegReportSaveDTO}。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新报到记录。
|
||||
*
|
||||
* <p>PUT /train/regdata/report/{reportId},body 仅需传入待修改字段。</p>
|
||||
*/
|
||||
@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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除报到记录。
|
||||
*
|
||||
* <p>DELETE /train/regdata/report/{reportId}</p>
|
||||
*/
|
||||
@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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史报到统计汇总。
|
||||
*
|
||||
* <p>GET /train/regdata/history/summary</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:list')")
|
||||
@GetMapping("/history/summary")
|
||||
public AjaxResult historySummary()
|
||||
{
|
||||
RegHistorySummaryVO summary = trainRegDataService.selectHistorySummary();
|
||||
return success(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 各批次报到率趋势。
|
||||
*
|
||||
* <p>GET /train/regdata/history/batchTrend</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:list')")
|
||||
@GetMapping("/history/batchTrend")
|
||||
public TableDataInfo batchTrend()
|
||||
{
|
||||
List<RegBatchTrendVO> list = trainRegDataService.selectBatchTrend();
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询各送培单位报到情况(按平均报到率降序)。
|
||||
*
|
||||
* <p>GET /train/regdata/history/units?pageNum=1&pageSize=10</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:list')")
|
||||
@GetMapping("/history/units")
|
||||
public TableDataInfo historyUnits()
|
||||
{
|
||||
startPage();
|
||||
List<RegUnitVO> list = trainRegDataService.selectUnitReport();
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出历史报到统计报表。
|
||||
*
|
||||
* <p>POST /train/regdata/history/export</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:report:export')")
|
||||
@Log(title = "历史报到统计", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/history/export")
|
||||
public void exportHistory(HttpServletResponse response)
|
||||
{
|
||||
List<RegUnitVO> list = trainRegDataService.selectUnitReport();
|
||||
ExcelUtil<RegUnitVO> util = new ExcelUtil<RegUnitVO>(RegUnitVO.class);
|
||||
util.exportExcel(response, list, "历史报到统计");
|
||||
}
|
||||
}
|
||||
+70
@@ -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<String, Object> summary = scoreAnalysisService.getScoreSummary(filter);
|
||||
return success(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩统计图表集合
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:analysis:score')")
|
||||
@GetMapping("/charts")
|
||||
public AjaxResult getScoreCharts(ScoreAnalysisFilterDto filter)
|
||||
{
|
||||
Map<String, Object> charts = scoreAnalysisService.getScoreCharts(filter);
|
||||
return success(charts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定维度的分组统计明细(支持视图切换:图表 / 表格)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:analysis:score')")
|
||||
@GetMapping("/dimension")
|
||||
public AjaxResult getDimensionAnalysis(ScoreAnalysisFilterDto filter)
|
||||
{
|
||||
Map<String, Object> result = scoreAnalysisService.getDimensionAnalysis(filter);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选下拉选项(培训班 / 课程 / 实训上报平台)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:analysis:score')")
|
||||
@GetMapping("/filters")
|
||||
public AjaxResult getFilterOptions()
|
||||
{
|
||||
Map<String, Object> options = scoreAnalysisService.getFilterOptions();
|
||||
return success(options);
|
||||
}
|
||||
}
|
||||
+128
-3
@@ -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<StudentImportVO> rows = studentImportService.templateRows();
|
||||
ExcelUtil<StudentImportVO> util = new ExcelUtil<StudentImportVO>(StudentImportVO.class);
|
||||
util.exportExcel(response, rows, "学员导入模板");
|
||||
}
|
||||
|
||||
/**
|
||||
* 学员批量导入 (POST /train/student/importData)。
|
||||
*
|
||||
* <p>必须以 multipart/form-data 提交,文件字段名为 {@code file};
|
||||
* 若用 application/json 提交(例如 axios 把 FormData 序列化成 JSON),
|
||||
* 会因 consumes 不匹配直接返回 415,而不是抛出难以定位的
|
||||
* "Current request is not a multipart request"。</p>
|
||||
*
|
||||
* <p>返回体 data 为 { totalCount, successCount, failureCount, errorList }。</p>
|
||||
*/
|
||||
@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;为空时按该人员授权自动推断
|
||||
* <p>
|
||||
* 返回结果的 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)。
|
||||
*
|
||||
* <p>必须使用带 {@code @Excel} 的 {@link StudentExportVO}:ExcelUtil 只收集带该注解的字段,
|
||||
* 若直接导出列表 VO(无注解)会生成只有空行、没有表头与单元格的"空表"。</p>
|
||||
*
|
||||
* <p>筛选条件与列表页一致;传入 planId 时按培训班维度过滤(排除已退训学员)。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:student:list')")
|
||||
@Log(title = "学员台账", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/export")
|
||||
public void export(HttpServletResponse response, StudentQuery query)
|
||||
{
|
||||
List<StudentExportVO> list = trainStudentService.selectStudentExportList(query);
|
||||
ExcelUtil<StudentExportVO> util = new ExcelUtil<StudentExportVO>(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);
|
||||
}
|
||||
}
|
||||
+131
@@ -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
|
||||
* <p>
|
||||
* 与学生住宿(/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<Long> 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<String, Object> 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -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<TrainTeacher> list = trainTeacherService.selectTrainTeacherList(trainTeacher);
|
||||
ExcelUtil<TrainTeacher> util = new ExcelUtil<TrainTeacher>(TrainTeacher.class);
|
||||
util.exportExcel(response, list, "教师数据");
|
||||
}
|
||||
|
||||
private boolean isValidPhone(String phone)
|
||||
{
|
||||
return PHONE_PATTERN.matcher(phone).matches();
|
||||
|
||||
+107
@@ -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<TrainTheoryExamScoreVO> 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<TheoryExamScoreExportVO> list = trainTheoryExamScoreService.selectTheoryExamScoreExportList(query);
|
||||
ExcelUtil<TheoryExamScoreExportVO> util = new ExcelUtil<TheoryExamScoreExportVO>(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));
|
||||
}
|
||||
}
|
||||
+74
@@ -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(新增)
|
||||
*
|
||||
* <p>与既有 TrainTheoryExamScoreController 共用 /train/theoryExamScore 前缀,
|
||||
* 仅新增 importTemplate / importData 两个端点,不改动任何既有端点。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增实训场地
|
||||
*/
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceHmacFilter> deviceHmacFilterRegistration(
|
||||
ITrainIotDeviceService iotDeviceService,
|
||||
@Value("${iot.hmac.enabled:true}") boolean hmacEnabled)
|
||||
{
|
||||
DeviceHmacFilter filter = new DeviceHmacFilter();
|
||||
filter.setIotDeviceService(iotDeviceService);
|
||||
filter.setEnabled(hmacEnabled);
|
||||
FilterRegistrationBean<DeviceHmacFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
// 仅对设备登录接口做 HMAC 验签
|
||||
registration.addUrlPatterns("/api/iot/v1/device/login");
|
||||
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
registration.setName("deviceHmacFilter");
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
+373
-6
@@ -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<String, Long> studentUserMap;
|
||||
Map<String, TrainStudent> studentMap;
|
||||
if (!sIds.isEmpty())
|
||||
{
|
||||
List<TrainStudent> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增学员主档并自动创建登录账号。
|
||||
*
|
||||
* <p>以学员联系电话作为登录用户名、123456 作为初始密码创建系统用户,
|
||||
* 分配“培训学员”角色,并把生成的系统用户ID回写到 train_student.user_id。
|
||||
* 学员建档与账号创建在同一事务内完成,保证数据一致。</p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增教师主档并自动创建登录账号。
|
||||
*
|
||||
* <p>以教师联系电话作为登录用户名、123456 作为初始密码创建系统用户,
|
||||
* 分配“培训教师”角色,并把生成的系统用户ID回写到 train_teacher.user_id。
|
||||
* 教师建档与账号创建在同一事务内完成,保证数据一致。</p>
|
||||
*
|
||||
* @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;已绑定则同步基础信息。
|
||||
*
|
||||
* <p>无论新建还是已绑定,都会将教师的部门、邮箱、性别同步到关联登录用户 sys_user 的对应字段。</p>
|
||||
*
|
||||
* @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;已绑定则同步基础信息。
|
||||
*
|
||||
* <p>无论新建还是已绑定,都会将学员的部门、邮箱、性别同步到关联登录用户 sys_user 的对应字段。</p>
|
||||
*
|
||||
* @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<SysRole> roles = roleService.selectRoleAll();
|
||||
if (roles != null)
|
||||
{
|
||||
for (SysRole role : roles)
|
||||
{
|
||||
if (roleKey.equals(role.getRoleKey()))
|
||||
{
|
||||
return role.getRoleId();
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ServiceException("未找到角色(" + roleKey + "),无法分配");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将部门、邮箱、性别同步到关联登录用户 sys_user 的基础字段。
|
||||
*
|
||||
* <p>仅更新非空字段(dept_id 为空/0 不更新、email/sex 为 null 不更新),
|
||||
* 不会触碰密码、角色、岗位、状态等,避免覆盖系统用户管理的其他配置。</p>
|
||||
*
|
||||
* @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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- 日志存放路径 -->
|
||||
<property name="log.path" value="C:/Users/zfjia/work/projects/ruoyi/logs" />
|
||||
<property name="log.path" value="./logs" />
|
||||
<!-- 日志输出格式 -->
|
||||
<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>endobj
|
||||
4 0 obj<</Length 52>>stream
|
||||
BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000053 00000 n
|
||||
0000000105 00000 n
|
||||
0000000220 00000 n
|
||||
0000000317 00000 n
|
||||
trailer<</Size 6/Root 1 0 R>>
|
||||
startxref
|
||||
384
|
||||
%%EOF
|
||||
@@ -0,0 +1,21 @@
|
||||
%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>endobj
|
||||
4 0 obj<</Length 52>>stream
|
||||
BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000053 00000 n
|
||||
0000000105 00000 n
|
||||
0000000220 00000 n
|
||||
0000000317 00000 n
|
||||
trailer<</Size 6/Root 1 0 R>>
|
||||
startxref
|
||||
384
|
||||
%%EOF
|
||||
@@ -0,0 +1,21 @@
|
||||
%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>endobj
|
||||
4 0 obj<</Length 52>>stream
|
||||
BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000053 00000 n
|
||||
0000000105 00000 n
|
||||
0000000220 00000 n
|
||||
0000000317 00000 n
|
||||
trailer<</Size 6/Root 1 0 R>>
|
||||
startxref
|
||||
384
|
||||
%%EOF
|
||||
+151
@@ -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<String, Class<?>> 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<String, Class<?>> 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<String, Class<?>> 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<String, Class<?>> 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<String, Class<?>> 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"));
|
||||
}
|
||||
}
|
||||
+204
@@ -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<String, Object> data = (Map<String, Object>) 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<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
|
||||
assertEquals(1, data.get("total"));
|
||||
List<TrainStudentCheckin> rows = (List<TrainStudentCheckin>) 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> T proxy(Class<T> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+282
@@ -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<String, Object> 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<String> checkout(Map<String, Object> 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<String, Object> data = (Map<String, Object>) 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<String, Object> data = (Map<String, Object>) 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<String, Object> data = (Map<String, Object>) 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> T proxy(Class<T> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+10
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -87,7 +87,7 @@ public class StudentSelfServiceApiTest
|
||||
detail.setsName("李四");
|
||||
detail.setIdCardLast4("5678");
|
||||
detail.setIdCardMasked("****************5678");
|
||||
detail.setOrgName("供电段");
|
||||
detail.setDeptName("供电段");
|
||||
return detail;
|
||||
}
|
||||
return null;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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/... 形式的资源地址解析为服务器本地绝对路径。
|
||||
* <p>
|
||||
* 处理要点:
|
||||
* <ul>
|
||||
* <li>先去查询串/锚点,再做百分号解码,避免 %20、%E4%B8%AD 等编码后的空格/中文无法定位文件;</li>
|
||||
* <li>解码前把 '+' 转义为 %2B,防止文件名中的加号被当成空格;</li>
|
||||
* <li>使用 Paths.resolve().normalize() 而非字符串拼接,消除 "./"、重复分隔符、Windows 反斜杠混用;</li>
|
||||
* <li>校验结果仍位于上传根目录内,防止 ../ 越权读取。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
|
||||
@@ -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 入参解析与校验工具。
|
||||
*
|
||||
* <p>职责:把一个 JSON 字符串安全地解析为“字段名 → 值”的 Map,并在解析失败或字段校验失败时
|
||||
* 返回可直接回给调用方的中文错误提示,避免各业务方重复写 try/catch 与类型判断。</p>
|
||||
*
|
||||
* <p><b>类型处理约定</b></p>
|
||||
* <table border="1">
|
||||
* <tr><th>JSON 值</th><th>解析后 Java 类型</th></tr>
|
||||
* <tr><td>字符串 "abc"</td><td>{@link String}</td></tr>
|
||||
* <tr><td>整数值 1001</td><td>{@link Integer};超出 int 范围时为 {@link Long}</td></tr>
|
||||
* <tr><td>小数值 1.5</td><td>{@link BigDecimal}</td></tr>
|
||||
* <tr><td>布尔 true/false</td><td>{@link Boolean}</td></tr>
|
||||
* <tr><td>null</td><td>{@code null}</td></tr>
|
||||
* <tr><td>嵌套对象 {...}</td><td>{@link JSONObject}(原样保留,不做递归转换)</td></tr>
|
||||
* <tr><td>数组 [...]</td><td>{@link JSONArray}(原样保留)</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p><b>结构校验</b>:入参必须是非空白字符串,且解析结果的顶层必须是 JSON 对象;
|
||||
* 顶层数组、数字、字符串、布尔值、null 以及语法错误都会被拒绝并给出明确提示。</p>
|
||||
*
|
||||
* <p><b>字段校验(可选)</b>:传入 schema(字段名 → 期望类型)后,会校验字段存在且非 null,
|
||||
* 并按需转换类型(String / Integer / Long / Double / BigDecimal / Boolean)。</p>
|
||||
*
|
||||
* <p><b>返回值</b>:{@link JsonParamResult},包含 {@code success}、{@code message}、{@code data} 三部分:
|
||||
* 成功时 {@code data} 为字段 Map(保持 JSON 中出现顺序),失败时 {@code data} 为空 Map。</p>
|
||||
*
|
||||
* <p>示例:</p>
|
||||
* <pre>
|
||||
* JsonParamResult r = JsonParamUtils.parse("{\"studentId\":\"STU_1\",\"lessonId\":1001}");
|
||||
* if (!r.isSuccess()) { return AjaxResult.error(r.getMessage()); }
|
||||
* Long lessonId = (Long) r.get("lessonId");
|
||||
* </pre>
|
||||
*
|
||||
* @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<String, Class<?>> 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<String, Object> data = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> 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<String, Class<?>> 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<String, Object> data;
|
||||
|
||||
private JsonParamResult(boolean success, String message, Map<String, Object> data)
|
||||
{
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
this.data = data == null ? Collections.emptyMap() : data;
|
||||
}
|
||||
|
||||
static JsonParamResult ok(Map<String, Object> 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<String, Object> getData()
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字段值。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> 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 + "}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>ruoyi</artifactId>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<version>3.9.2</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>ruoyi-device</artifactId>
|
||||
|
||||
<description>
|
||||
设备模块
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- 通用工具(之前由根 pom 继承提供,现显式声明避免自引用) -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MyBatis持久层 -->
|
||||
<dependency>
|
||||
<groupId>org.mybatis.spring.boot</groupId>
|
||||
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 单元测试 -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.13.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
<version>2.21</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
<version>26.0.2</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -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<String, Object> 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<String, Object> getParams()
|
||||
{
|
||||
if (params == null)
|
||||
{
|
||||
params = new HashMap<>();
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
public void setParams(Map<String, Object> params)
|
||||
{
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
+151
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+371
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+164
@@ -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();
|
||||
}
|
||||
}
|
||||
+164
@@ -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();
|
||||
}
|
||||
}
|
||||
+277
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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<TrainBookDownloadRecord> selectTrainBookDownloadRecordList(TrainBookDownloadRecord record);
|
||||
|
||||
public int insertTrainBookDownloadRecord(TrainBookDownloadRecord record);
|
||||
|
||||
public int updateTrainBookDownloadRecord(TrainBookDownloadRecord record);
|
||||
|
||||
public int deleteTrainBookDownloadRecordById(Long id);
|
||||
|
||||
public int deleteTrainBookDownloadRecordByIds(Long[] ids);
|
||||
}
|
||||
+30
@@ -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<TrainBookSnapshot> 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<TrainBookSnapshot> selectOnShelfList(@Param("bookCategory") String bookCategory);
|
||||
|
||||
public TrainBookSnapshot selectByBookIdAndVersion(@Param("bookId") String bookId, @Param("bookVersion") String bookVersion);
|
||||
}
|
||||
+28
@@ -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<TrainCheckinTerminal> 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);
|
||||
}
|
||||
+31
@@ -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<TrainIotDevice> 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);
|
||||
}
|
||||
+28
@@ -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<TrainReadingAlternateProgress> 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<TrainReadingAlternateProgress> selectListByUserBookMode(@Param("userId") Long userId, @Param("bookId") String bookId, @Param("mode") String mode);
|
||||
}
|
||||
+30
@@ -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<TrainReadingCurrentProgress> 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<TrainReadingCurrentProgress> selectListByUserId(@Param("userId") Long userId);
|
||||
}
|
||||
+30
@@ -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<TrainReadingDurationLog> 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);
|
||||
}
|
||||
+33
@@ -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<TrainUserSession> 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);
|
||||
}
|
||||
+25
@@ -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<TrainBookDownloadRecord> selectTrainBookDownloadRecordList(TrainBookDownloadRecord record);
|
||||
|
||||
public int insertTrainBookDownloadRecord(TrainBookDownloadRecord record);
|
||||
|
||||
public int updateTrainBookDownloadRecord(TrainBookDownloadRecord record);
|
||||
|
||||
public int deleteTrainBookDownloadRecordById(Long id);
|
||||
|
||||
public int deleteTrainBookDownloadRecordByIds(Long[] ids);
|
||||
}
|
||||
+29
@@ -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<TrainBookSnapshot> 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<TrainBookSnapshot> selectOnShelfList(String bookCategory);
|
||||
|
||||
public TrainBookSnapshot selectByBookIdAndVersion(String bookId, String bookVersion);
|
||||
}
|
||||
+27
@@ -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<TrainCheckinTerminal> 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);
|
||||
}
|
||||
+30
@@ -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<TrainIotDevice> 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);
|
||||
}
|
||||
+27
@@ -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<TrainReadingAlternateProgress> 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<TrainReadingAlternateProgress> selectListByUserBookMode(Long userId, String bookId, String mode);
|
||||
}
|
||||
+29
@@ -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<TrainReadingCurrentProgress> 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<TrainReadingCurrentProgress> selectListByUserId(Long userId);
|
||||
}
|
||||
+29
@@ -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<TrainReadingDurationLog> 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);
|
||||
}
|
||||
+34
@@ -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<TrainUserSession> 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);
|
||||
}
|
||||
+57
@@ -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<TrainBookDownloadRecord> 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);
|
||||
}
|
||||
}
|
||||
+123
@@ -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<TrainBookSnapshot> 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<TrainBookSnapshot> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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<TrainCheckinTerminal> 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);
|
||||
}
|
||||
}
|
||||
+70
@@ -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<TrainIotDevice> 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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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<TrainReadingAlternateProgress> 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<TrainReadingAlternateProgress> selectListByUserBookMode(Long userId, String bookId, String mode)
|
||||
{
|
||||
return trainReadingAlternateProgressMapper.selectListByUserBookMode(userId, bookId, mode);
|
||||
}
|
||||
}
|
||||
+69
@@ -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<TrainReadingCurrentProgress> 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<TrainReadingCurrentProgress> selectListByUserId(Long userId)
|
||||
{
|
||||
return trainReadingCurrentProgressMapper.selectListByUserId(userId);
|
||||
}
|
||||
}
|
||||
+144
@@ -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<TrainReadingDurationLog> 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);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 531 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user