Compare commits
3 Commits
926426fe4f
...
7bdc5c846d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bdc5c846d | ||
|
|
1cfb071dfb | ||
|
|
79b739fe02 |
7
pom.xml
7
pom.xml
@@ -457,6 +457,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 设备模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-device</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
@@ -98,6 +98,12 @@
|
||||
<artifactId>ruoyi-demo</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 设备模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-device</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 工作流模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
|
||||
@@ -206,6 +206,38 @@ mail:
|
||||
# Socket连接超时值,单位毫秒,缺省值不超时
|
||||
connectionTimeout: 0
|
||||
|
||||
--- # 设备域 · APP 端配置(前缀 device.app,对应 org.dromara.device.config.AppProperties)
|
||||
# 所有项在代码中均有默认值,此处显式列出仅为便于运维调参;整段删除不影响启动。
|
||||
device:
|
||||
app:
|
||||
# ---------------- APP 邮箱验证码(邮箱注册 / 邮箱登录 / 邮箱找回密码) ----------------
|
||||
email:
|
||||
# 是否仅打印日志而不真实发信:开发环境默认 true,便于在无 SMTP 条件下联调。
|
||||
# 【生产环境(prod)必须为 false】——否则应用拒绝启动,防止验证码明文写入日志。
|
||||
mock: true
|
||||
# 谷歌邮箱 Gmail SMTP
|
||||
host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com}
|
||||
# 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true
|
||||
port: ${DEVICE_APP_EMAIL_PORT:465}
|
||||
# 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件
|
||||
user: ${DEVICE_APP_EMAIL_USER:}
|
||||
# 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入
|
||||
pass: ${DEVICE_APP_EMAIL_PASS:}
|
||||
# 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收
|
||||
from: ${DEVICE_APP_EMAIL_FROM:}
|
||||
ssl-enable: true
|
||||
starttls-enable: false
|
||||
# smtp.gmail.com 在国内机房通常不可达,必须快速失败,避免挂死 HTTP 请求线程
|
||||
connection-timeout: 5000
|
||||
timeout: 10000
|
||||
# 验证码位数 / 有效期(分钟)/ 重发间隔(秒)
|
||||
code-length: 6
|
||||
expiry-minutes: 5
|
||||
resend-seconds: 60
|
||||
# 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。
|
||||
# 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码:
|
||||
# allowed-domains: gmail.com,googlemail.com
|
||||
|
||||
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
|
||||
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
|
||||
sms:
|
||||
|
||||
@@ -209,6 +209,39 @@ mail:
|
||||
# Socket连接超时值,单位毫秒,缺省值不超时
|
||||
connectionTimeout: 0
|
||||
|
||||
--- # 设备域 · APP 端配置(前缀 device.app,对应 org.dromara.device.config.AppProperties)
|
||||
# 所有项在代码中均有默认值,此处显式列出仅为便于运维调参;整段删除不影响启动。
|
||||
device:
|
||||
app:
|
||||
# ---------------- APP 邮箱验证码(邮箱注册 / 邮箱登录 / 邮箱找回密码) ----------------
|
||||
email:
|
||||
# 【必须为 false】生产环境开启 mock 会把邮箱验证码明文写入日志,
|
||||
# 此时 org.dromara.device.config.AppEmailProdGuard 将直接拒绝启动(属预期保护)。
|
||||
mock: false
|
||||
# 谷歌邮箱 Gmail SMTP
|
||||
host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com}
|
||||
# 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true
|
||||
port: ${DEVICE_APP_EMAIL_PORT:465}
|
||||
# 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件
|
||||
user: ${DEVICE_APP_EMAIL_USER:}
|
||||
# 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入
|
||||
pass: ${DEVICE_APP_EMAIL_PASS:}
|
||||
# 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收
|
||||
from: ${DEVICE_APP_EMAIL_FROM:}
|
||||
ssl-enable: true
|
||||
starttls-enable: false
|
||||
# smtp.gmail.com 在国内机房通常不可达:若部署在国内,请评估改用国内 SMTP,
|
||||
# 或确认服务器具备出海能力后再上线邮箱注册功能。
|
||||
connection-timeout: 5000
|
||||
timeout: 10000
|
||||
# 验证码位数 / 有效期(分钟)/ 重发间隔(秒)
|
||||
code-length: 6
|
||||
expiry-minutes: 5
|
||||
resend-seconds: 60
|
||||
# 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。
|
||||
# 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码:
|
||||
# allowed-domains: gmail.com,googlemail.com
|
||||
|
||||
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
|
||||
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
|
||||
sms:
|
||||
|
||||
@@ -14,7 +14,17 @@ server:
|
||||
min: 8
|
||||
# 最大线程数
|
||||
max: 256
|
||||
|
||||
zlm:
|
||||
host: ${ZLM_HOST:http://47.104.136.221:8090} # ZLMediaKit 地址
|
||||
rtmp-host: ${ZLM_RTMP_HOST:rtmp://47.104.136.221:1935}
|
||||
rtsp-host: ${ZLM_RTSP_HOST:rtsp://47.104.136.221:554}
|
||||
webrtc-host: ${ZLM_WEBRTC_HOST:http://47.104.136.221:8090}
|
||||
secret: ${ZLM_SECRET:D5QbzhEkO8X0cdxMnIwj5USsCIfSKcOq} # ZLM webhook 密钥:不落明文,必须通过环境变量 ZLM_SECRET 注入;未配置时 /zlm/hook/** 回调一律拒绝(失败关闭)
|
||||
app: live
|
||||
schema: rtmp
|
||||
push-protocol: rtmp
|
||||
play-expire-seconds: 120
|
||||
stream-timeout: 30 # 流超时秒数
|
||||
captcha:
|
||||
# 是否启用验证码校验
|
||||
enable: true
|
||||
@@ -111,6 +121,7 @@ security:
|
||||
- /warm-flow-ui/config
|
||||
- /snail-chat/**
|
||||
- /api/snail/chat/**
|
||||
- /zlm/hook/**
|
||||
|
||||
# MyBatisPlus配置
|
||||
# https://baomidou.com/config/
|
||||
@@ -264,15 +275,17 @@ mqtt.client:
|
||||
# 是否开启客户端,默认:true
|
||||
enabled: true
|
||||
# 连接的服务端 ip ,默认:127.0.0.1
|
||||
ip: ssl://www.mmipco.cn
|
||||
ip: 47.97.217.123
|
||||
# 端口:默认:1883
|
||||
port: 8883
|
||||
port: 1883
|
||||
# 客户端名称
|
||||
name: Mqtt-Client
|
||||
# 客户端Id(非常重要,一般为设备 sn,不可重复)
|
||||
client-id: 000001
|
||||
username: admin
|
||||
password: 61e6062129e9
|
||||
client-id: bird_monitor
|
||||
# MQTT broker 账号/密码:不落明文,通过环境变量注入(避免凭据进入 git 历史)。
|
||||
# 联调前 export MQTT_USERNAME / MQTT_PASSWORD;未配置时取空值,broker 会拒绝认证。
|
||||
username: ${MQTT_USERNAME:admin}
|
||||
password: ${MQTT_PASSWORD:61e6062129e9}
|
||||
# 超时时间,单位:秒,默认:5秒
|
||||
timeout: 5
|
||||
# 重连时间,默认 5000 毫秒
|
||||
@@ -291,12 +304,35 @@ mqtt.client:
|
||||
mqtt-executor-size: 2
|
||||
# 是否开启 ssl
|
||||
ssl:
|
||||
enabled: true
|
||||
enabled: false
|
||||
keystore-path:
|
||||
keystore-pass:
|
||||
truststore-path:
|
||||
truststore-pass:
|
||||
|
||||
--- # 设备域通信链路配置(《设备通信协议-一期.md》口径)
|
||||
# 全部项均有代码默认值,此处显式列出仅为便于运维调参;不需要调时整段删除也不影响启动。
|
||||
device.mqtt:
|
||||
# 离线扫描总开关。关闭后设备不会再被自动置离线,仅用于联调排障,生产必须为 true
|
||||
scan-enabled: true
|
||||
# 离线扫描间隔(上一轮跑完再计时,不会因扫描耗时堆积)
|
||||
offline-scan-interval: 30s
|
||||
# 离线判定阈值:超过该时长无遥测即判离线(协议 Q2:上报周期 60s × 2 容错)
|
||||
offline-threshold: 120s
|
||||
# 单轮扫描的设备上限,防止异常时一次性拉爆内存
|
||||
scan-batch-size: 500
|
||||
# 低电告警触发阈值(≤ 该值触发)与恢复阈值(≥ 该值恢复),两者之间的区间为滞回防抖区
|
||||
low-battery-threshold: 20
|
||||
low-battery-recover-threshold: 25
|
||||
# 低电滞回闩锁存活时长,仅作已注销设备的残留清理兜底
|
||||
low-battery-latch-ttl: 30d
|
||||
# 告警去重窗口:同一 设备+告警类型 在窗口内只落一条 bm_device_alert
|
||||
alert-dedup-window: 30m
|
||||
# 眼罩指令回执等待超时(协议 Q3 待硬件确认动作到位时长)
|
||||
hood-timeout: 5s
|
||||
# 待回执指令标记的 Redis TTL(仅用于事后定位「回执晚到」)
|
||||
pending-ack-ttl: 10m
|
||||
|
||||
--- # elasticsearch 功能配置
|
||||
# 文档地址: https://www.easy-es.cn/
|
||||
# 更改包名需要去 EasyEsConfiguration 修改包扫描(后续版本支持配置文件读取)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.dromara.system.api;
|
||||
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.system.api.domain.AppUserDTO;
|
||||
import org.dromara.system.api.domain.AppUserQueryDTO;
|
||||
|
||||
/**
|
||||
* APP 用户(C 端)领域服务对外暴露的 API。
|
||||
* <p>
|
||||
* 设计口径(迭代 I2 边界裁定 · 方案 A):
|
||||
* <ul>
|
||||
* <li>本接口仅面向设备域({@code ruoyi-device})等业务模块,用于 APP 账号闭环(注册/登录/改密/换绑/注销)。</li>
|
||||
* <li><b>密码绝不跨模块边界</b>:本接口不返回、不接收任何已加密的密码散列;
|
||||
* 校验密码由 {@link #matchesPassword(Long, String)} 在 {@code ruoyi-system} 内部完成并只回传布尔值,
|
||||
* 写入密码由 {@link #registerByPhone(String, String, String)} / {@link #resetPassword(Long, String)}
|
||||
* 在 {@code ruoyi-system} 内部完成散列(BCrypt)后落库。</li>
|
||||
* <li>本接口为<b>纯新增</b>,不改动既有 {@link UserService} 及 {@code ruoyi-system} 中任何既有方法/行为。</li>
|
||||
* <li>复用 {@code sys_user} 表,C 端账号 {@code user_type = app_user};不新增表、不新增字段。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface AppUserApiService {
|
||||
|
||||
/**
|
||||
* 按手机号查询 APP 用户(仅匹配 {@code user_type = app_user} 且未逻辑删除的账号)。
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @return APP 用户信息(不含密码);不存在时返回 {@code null}
|
||||
*/
|
||||
AppUserDTO selectByPhone(String phoneNumber);
|
||||
|
||||
/**
|
||||
* 判断手机号是否已被注册为 APP 用户(仅匹配 {@code user_type = app_user})。
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @return true 已注册;false 未注册
|
||||
*/
|
||||
boolean existsByPhone(String phoneNumber);
|
||||
|
||||
/**
|
||||
* 校验用户密码是否匹配。
|
||||
* <p>
|
||||
* 明文密码仅在本方法内部比对(BCrypt),比较结果以布尔值回传,
|
||||
* 密码散列与明文均<b>不</b>跨越模块边界。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param rawPassword 明文密码
|
||||
* @return true 匹配;false 不匹配或用户不存在
|
||||
*/
|
||||
boolean matchesPassword(Long userId, String rawPassword);
|
||||
|
||||
/**
|
||||
* 以手机号注册一个 APP 用户。
|
||||
* <p>
|
||||
* 账号用户名取手机号;密码在本方法内部散列后落库。
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @param rawPassword 明文密码
|
||||
* @param nickName 昵称(为空时默认取手机号)
|
||||
* @return 新建用户主键
|
||||
*/
|
||||
Long registerByPhone(String phoneNumber, String rawPassword, String nickName);
|
||||
|
||||
/**
|
||||
* 按邮箱查询 APP 用户(仅匹配 {@code user_type = app_user} 且未逻辑删除的账号)。
|
||||
* <p>
|
||||
* 邮箱比对口径:调用方须先做「去空白 + 转小写」归一化(见
|
||||
* {@code org.dromara.device.email.AppEmailService#normalize}),本方法按传入值精确匹配。
|
||||
*
|
||||
* @param email 邮箱地址(已归一化)
|
||||
* @return APP 用户信息(不含密码);不存在时返回 {@code null}
|
||||
*/
|
||||
AppUserDTO selectByEmail(String email);
|
||||
|
||||
/**
|
||||
* 判断邮箱是否已被注册为 APP 用户(仅匹配 {@code user_type = app_user})。
|
||||
*
|
||||
* @param email 邮箱地址(已归一化)
|
||||
* @return true 已注册;false 未注册
|
||||
*/
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
/**
|
||||
* 以邮箱注册一个 APP 用户(邮箱注册入口)。
|
||||
* <p>
|
||||
* <b>不写手机号</b>:本入口创建的账号 {@code phone_number} 为 {@code NULL}
|
||||
* (依赖 {@code script/sql/bm_patch.sql} 补丁 3 把该列默认值改为 NULL,
|
||||
* 否则 MyBatis-Plus 的 {@code insertStrategy: NOT_NULL} 会省略该列、落库为 {@code ''},
|
||||
* 导致第二个邮箱账号撞 {@code uk_sys_user_phone_number})。账号须在后续流程中绑定手机号。
|
||||
* <p>
|
||||
* 密码在本方法内部散列后落库。
|
||||
*
|
||||
* @param email 邮箱地址(已归一化)
|
||||
* @param rawPassword 明文密码
|
||||
* @param nickName 昵称(为空时默认取邮箱)
|
||||
* @return 新建用户主键
|
||||
*/
|
||||
Long registerByEmail(String email, String rawPassword, String nickName);
|
||||
|
||||
/**
|
||||
* 重置用户密码(忘记密码场景)。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param rawPassword 新明文密码
|
||||
* @return true 修改成功
|
||||
*/
|
||||
boolean resetPassword(Long userId, String rawPassword);
|
||||
|
||||
/**
|
||||
* 换绑手机号。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param newPhoneNumber 新手机号
|
||||
* @return true 修改成功
|
||||
*/
|
||||
boolean updatePhone(Long userId, String newPhoneNumber);
|
||||
|
||||
/**
|
||||
* 停用账号(注销前置:解绑设备后停用)。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return true 停用成功
|
||||
*/
|
||||
boolean disableUser(Long userId);
|
||||
|
||||
/**
|
||||
* 判断账号是否已绑定手机号。
|
||||
* <p>
|
||||
* 供设备域做「邮箱注册后仍须绑手机号」的服务端硬拦截使用
|
||||
* (未绑手机号的账号不允许绑定设备)。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return true 已绑定非空手机号;false 未绑定或用户不存在
|
||||
*/
|
||||
boolean isPhoneBound(Long userId);
|
||||
|
||||
// ---------------- 后台管理端(APP 用户管理) ----------------
|
||||
|
||||
/**
|
||||
* 分页查询 APP 用户列表(后台「APP 用户管理」用)。
|
||||
* <p>
|
||||
* <b>强制口径</b>:实现侧固定附加 {@code user_type = app_user} 条件,
|
||||
* 调用方<b>无法</b>通过 {@link AppUserQueryDTO} 覆盖该条件,
|
||||
* 因此本方法<b>恒不返回</b>后台账号({@code sys_user})。
|
||||
* <p>
|
||||
* <b>数据权限</b>:APP 用户注册时不写 {@code dept_id}(该列恒为 {@code NULL}),
|
||||
* 不参与后台部门树,故实现侧<b>刻意不加</b>框架 {@code @DataPermission} 过滤——
|
||||
* 否则按部门维度过滤会把全部 APP 用户过滤为空。
|
||||
*
|
||||
* @param query 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
|
||||
* @return APP 用户分页结果(不含密码)
|
||||
*/
|
||||
PageResult<AppUserDTO> selectAppUserPage(AppUserQueryDTO query);
|
||||
|
||||
/**
|
||||
* 按主键查询 APP 用户详情(后台「APP 用户管理」用)。
|
||||
* <p>
|
||||
* 同样强制 {@code user_type = app_user}:传入后台账号主键时返回 {@code null},
|
||||
* 避免管理端越界读到系统账号信息。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return APP 用户信息(不含密码);不存在或非 app_user 时返回 {@code null}
|
||||
*/
|
||||
AppUserDTO selectAppUserById(Long userId);
|
||||
|
||||
/**
|
||||
* 修改 APP 用户账号状态(停用 / 启用)。
|
||||
* <p>
|
||||
* 与 {@link #disableUser(Long)} 的区别:本方法同时支持<b>恢复启用</b>,
|
||||
* 供后台管理端做双向状态切换;{@code disableUser} 保持「注销前置」单向语义不变。
|
||||
* <p>
|
||||
* <b>安全约束</b>:实现侧限定 {@code user_type = app_user},
|
||||
* 防止通过本方法改动后台账号状态。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param status 目标状态({@code 0} 正常 / {@code 1} 停用)
|
||||
* @return true 修改成功(含目标状态与当前一致的情形)
|
||||
*/
|
||||
boolean updateStatus(Long userId, String status);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.dromara.system.api.domain;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 用户传输对象。
|
||||
* <p>
|
||||
* 面向 APP 账号闭环的对外 DTO,<b>刻意不包含密码字段</b>,
|
||||
* 以确保密码散列不会跨越模块边界(见 {@link org.dromara.system.api.AppUserApiService})。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class AppUserDTO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 用户账号(APP 账号取手机号)
|
||||
*/
|
||||
private String userName;
|
||||
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
private String nickName;
|
||||
|
||||
/**
|
||||
* 手机号码
|
||||
*/
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 用户邮箱
|
||||
* <p>
|
||||
* 仅邮箱注册的账号非空;手机号注册的账号为 {@code null}。
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 用户类型(固定为 app_user)
|
||||
*/
|
||||
private String userType;
|
||||
|
||||
/**
|
||||
* 账号状态(0 正常 1 停用)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 创建时间(注册时间)
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
* <p>
|
||||
* 供后台「APP 用户管理」展示「最近变更时间」(改密 / 换绑 / 停用等均会刷新该值)。
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.dromara.system.api.domain;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* APP 用户管理查询条件(后台管理端使用)。
|
||||
* <p>
|
||||
* <b>为什么单独建一个 DTO 而不是复用 {@code SysUserBo} + {@code PageQuery}</b>:
|
||||
* {@code ruoyi-api} 模块仅依赖 {@code ruoyi-common-core}(见 {@code ruoyi-api/pom.xml}),
|
||||
* 而 {@code PageQuery} 位于 {@code ruoyi-common-mybatis},<b>在本模块不可见</b>。
|
||||
* 因此分页参数直接内联为 {@code pageNum} / {@code pageSize},由 {@code ruoyi-system} 侧
|
||||
* 组装成框架 {@code Page},从而在不放松模块依赖边界的前提下完成跨模块分页契约。
|
||||
* <p>
|
||||
* <b>日期区间用 {@link #params} 而非平铺字段</b>:与框架既有约定保持一致——
|
||||
* 前端 {@code utils/ruoyi.ts#addDateRange}(经 {@code useDateRangeQuery} hook 调用)
|
||||
* 把区间写入 {@code params.beginTime} / {@code params.endTime},
|
||||
* 后端 {@code betweenParams(column, params, "beginTime", "endTime")} 消费,
|
||||
* 故此处必须保留同名的 {@code params} 载体,前端才能直接复用现成 hook。
|
||||
* <p>
|
||||
* <b>不含 {@code userType}</b>:本查询只为 APP 用户({@code user_type = app_user})服务,
|
||||
* 该条件由实现侧强制附加、不可由调用方覆盖——避免调用方漏传而把后台账号一并查出。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class AppUserQueryDTO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 关键词:模糊匹配 user_name / nick_name / phone_number / email(四字段 OR)。
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
/**
|
||||
* 账号状态(0 正常 1 停用);留空不参与过滤。
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 扩展查询参数。
|
||||
* <p>
|
||||
* 约定键:{@code beginTime} / {@code endTime}——注册时间区间(闭区间)。
|
||||
* 两者<b>均非空</b>时才生效(与框架 {@code betweenParams} 口径一致)。
|
||||
* 注意:框架<b>不做</b>「日期补 23:59:59」的隐式处理,前端选「日期」而非「日期时间」时
|
||||
* 需自行补全时分秒,否则会漏掉结束日当天数据。
|
||||
*/
|
||||
private Map<String, Object> params = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 页码(从 1 开始);为空时按 1 处理。
|
||||
*/
|
||||
private Integer pageNum;
|
||||
|
||||
/**
|
||||
* 每页条数;为空时按 10 处理。
|
||||
*/
|
||||
private Integer pageSize;
|
||||
|
||||
}
|
||||
@@ -77,6 +77,15 @@ public class MybatisPlusConfig {
|
||||
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
|
||||
// 分页合理化
|
||||
paginationInnerInterceptor.setOverflow(true);
|
||||
// 单页上限 —— 兜住两种「一个请求打爆数据库」的姿势:
|
||||
// 1) 不传 pageSize:PageQuery#build 会回落成 DEFAULT_PAGE_SIZE = Integer.MAX_VALUE,
|
||||
// 等于「不传就取全表」,且此前 maxLimit 未设,没有任何兜底;
|
||||
// 2) 显式传超大 pageSize(如 ?pageSize=999999999)。
|
||||
// 取值 1000 的依据:当前最大单页需求是 200(设备型号候选去重),
|
||||
// 需要全量的场景(Excel 导出)走的是不分页查询,不受本上限约束。
|
||||
// ⚠️ 副作用:超过 1000 的请求会被**静默改写**(MyBatis-Plus 不抛错)。
|
||||
// 因此**需要全量数据时请用不分页查询,不要靠「不传 pageSize」**。
|
||||
paginationInnerInterceptor.setMaxLimit(1000L);
|
||||
return paginationInnerInterceptor;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,14 @@ public class PageQuery implements Serializable {
|
||||
public static final int DEFAULT_PAGE_NUM = 1;
|
||||
|
||||
/**
|
||||
* 每页显示记录数 默认值 默认查全部
|
||||
* 每页显示记录数 默认值:**不传 pageSize 时回落值**。
|
||||
* <p>
|
||||
* ⚠️ 这里是 {@link Integer#MAX_VALUE},字面语义是「查全部」,但**实际不会真的全表返回** ——
|
||||
* {@code MybatisPlusConfig#paginationInnerInterceptor} 设了 {@code maxLimit = 1000},
|
||||
* MyBatis-Plus 会把超限的 size 静默改写掉。原注释「默认查全部」有误导性,已订正。
|
||||
* <p>
|
||||
* 另外 {@link #getFirstNum()} 用本值算偏移量,{@code (pageNum - 1) * MAX_VALUE}
|
||||
* 在 {@code pageNum > 1} 时会**整型溢出** —— 该方法目前无调用方,但若要启用必须先收口。
|
||||
*/
|
||||
public static final int DEFAULT_PAGE_SIZE = Integer.MAX_VALUE;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<module>ruoyi-system</module>
|
||||
<module>ruoyi-workflow</module>
|
||||
<module>ruoyi-ai</module>
|
||||
<module>ruoyi-device</module>
|
||||
</modules>
|
||||
|
||||
</project>
|
||||
|
||||
118
ruoyi-modules/ruoyi-device/pom.xml
Normal file
118
ruoyi-modules/ruoyi-device/pom.xml
Normal file
@@ -0,0 +1,118 @@
|
||||
<?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>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-modules</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>ruoyi-device</artifactId>
|
||||
|
||||
<description>
|
||||
设备模块(智能飞禽物联平台 · 设备域)
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<!-- 通用工具 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- api模块(复用 UserService 等) -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- web服务 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 数据库服务 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-mybatis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 安全模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 日志记录 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-log</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 缓存服务 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- excel -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-excel</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OSS功能模块(OTA 升级包上传) -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-oss</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 短信模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-sms</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 翻译模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-translation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 脱敏模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-sensitive</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 接口模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-doc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- mqtt模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-mqtt</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON 序列化(MQTT payload) -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-json</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 邮件模块(APP 邮箱验证码发信:MailBuilder + Gmail SMTP) -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.dromara.device.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* APP 邮箱验证码生产环境守卫(口径镜像 {@link AppSmsProdGuard})。
|
||||
* <p>
|
||||
* <b>背景</b>:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码<b>明文写入应用日志</b>。
|
||||
* 开发/联调期这是便利(SMTP 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。
|
||||
* <p>
|
||||
* <b>策略</b>:
|
||||
* <ul>
|
||||
* <li>激活 profile 含 {@code prod} 且 {@code mock=true} → 抛 {@link IllegalStateException},<b>拒绝启动</b>;</li>
|
||||
* <li>非 prod 且 {@code mock=true} → 打印 WARN,提示验证码将入日志;</li>
|
||||
* <li>prod 且 {@code mock=false} 但未配置发信账号/凭据 → 抛异常拒绝启动,
|
||||
* 避免「上线后才发现邮件发不出去」这类运行时才暴露的问题。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AppEmailProdGuard implements InitializingBean {
|
||||
|
||||
/**
|
||||
* 生产环境 profile 名(对应 {@code application-prod.yml})。
|
||||
*/
|
||||
private static final String PROD_PROFILE = "prod";
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final Environment environment;
|
||||
|
||||
/**
|
||||
* 校验「生产环境不得开启邮箱 mock」且「真实发信凭据已配置」,否则拒绝启动。
|
||||
*/
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
AppProperties.Email email = appProperties.getEmail();
|
||||
boolean prod = environment.matchesProfiles(PROD_PROFILE);
|
||||
if (email.isMock()) {
|
||||
if (prod) {
|
||||
throw new IllegalStateException(
|
||||
"生产环境(prod)禁止开启 device.app.email.mock(当前为 true)——该模式会把邮箱验证码明文写入日志。"
|
||||
+ "请在 application-prod.yml 中设置 device.app.email.mock=false 并配置 Gmail 发信账号与凭据后重启。");
|
||||
}
|
||||
log.warn("[APP邮箱] device.app.email.mock=true:验证码将写入应用日志,仅限开发/测试环境使用;"
|
||||
+ "生产环境(prod)将因此拒绝启动。");
|
||||
return;
|
||||
}
|
||||
// 已关闭 mock(走真实 SMTP):此处**只告警不阻断启动**。
|
||||
// 取舍说明:mock 开关误开会导致验证码明文落盘(安全风险)→ 必须拒绝启动;
|
||||
// 而凭据缺失只影响「邮件发不出去」这一个功能(可用性风险),若也拒绝启动,
|
||||
// 会让「邮箱功能未配置」升级为「整个服务起不来」,把一个局部问题放大成全站故障。
|
||||
// 故降级为醒目告警,把失败推迟到发信那一刻,并由 AppEmailService 返回受控文案。
|
||||
if (!email.isConfigured()) {
|
||||
log.warn("[APP邮箱] 已关闭 mock(要求真实发信)但发信账号或凭据未配置——"
|
||||
+ "邮箱验证码将无法送达!请通过环境变量 DEVICE_APP_EMAIL_USER / DEVICE_APP_EMAIL_PASS "
|
||||
+ "注入 Gmail 地址与 16 位应用专用密码。");
|
||||
return;
|
||||
}
|
||||
log.info("[APP邮箱] 真实发信已启用:host={}:{},from={},ssl={},starttls={},连接超时={}ms。",
|
||||
email.getHost(), email.getPort(), email.getFrom(), email.isSslEnable(),
|
||||
email.isStarttlsEnable(), email.getConnectionTimeout());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package org.dromara.device.config;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* APP 端(C 端)配置项。
|
||||
* <p>
|
||||
* 全部字段均带默认值,未在 {@code application.yml} 显式配置时按默认值生效,因此本迭代无需改动任何 yml。
|
||||
* <p>
|
||||
* <b>客户端身份默认值口径</b>:与框架 {@code sys_client} 中 {@code client_key = 'app'} 的记录保持镜像
|
||||
* ({@code device_type = 'android'}、{@code access_path = '/app/**'}、{@code timeout = 604800}、
|
||||
* {@code activeTimeout = 1800})。原因:设备域({@code ruoyi-device})不依赖 {@code ruoyi-system},
|
||||
* 无法读取 {@code sys_client};若运维调整了 {@code sys_client} 的 app 记录,请同步此处配置。
|
||||
* <p>
|
||||
* <b>已知偏差</b>:{@link Client#clientId} 的取值口径与 PC 端 {@code sys_client.client_id} 不同,
|
||||
* 详见 {@link Client#clientId} 字段注释与《05-交付说明-迭代I2》§七。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "device.app")
|
||||
public class AppProperties {
|
||||
|
||||
/**
|
||||
* APP 登录客户端身份(镜像 sys_client 的 app 记录)。
|
||||
*/
|
||||
private Client client = new Client();
|
||||
|
||||
/**
|
||||
* APP 短信验证码策略。
|
||||
*/
|
||||
private Sms sms = new Sms();
|
||||
|
||||
/**
|
||||
* APP 邮箱验证码策略(SMTP 发信)。
|
||||
*/
|
||||
private Email email = new Email();
|
||||
|
||||
/**
|
||||
* APP 协议(用户协议 / 隐私政策)内容来源。
|
||||
*/
|
||||
private Agreement agreement = new Agreement();
|
||||
|
||||
/**
|
||||
* 客户端身份配置。
|
||||
*/
|
||||
@Data
|
||||
public static class Client {
|
||||
|
||||
/**
|
||||
* APP 请求头 {@code clientid} 的取值(对应配置项 {@code device.app.client.client-id})。
|
||||
* <p>
|
||||
* 框架中该请求头名即 {@code LoginHelper.CLIENT_KEY = "clientid"};APP 登录时本值会写入 token 扩展,
|
||||
* 框架据此校验「请求头 clientid == 令牌扩展 clientid」({@code SecurityConfig:98-106}),
|
||||
* 因此 APP 端每次请求须携带请求头 {@code clientid: app}。
|
||||
* <p>
|
||||
* <b>注意(已知偏差,非笔误)</b>:本值 ≠ {@code sys_client.client_id}。
|
||||
* 框架的 {@code sys_client.client_id = md5(client_key + client_secret)}(见 {@code SysClientServiceImpl}),
|
||||
* 而本值当前为自建的 {@code "app"},即 {@code client_key},并非其 md5。
|
||||
* 之所以自洽:本链路<b>不查 {@code sys_client}</b>,token 扩展中的 clientid 由本配置直接写入,框架只做
|
||||
* 「请求头 vs 令牌扩展」的自比对,故两端一致即可通过。
|
||||
* <p>
|
||||
* <b>后果</b>:后台对 {@code sys_client} 中 app 记录的编辑/停用({@code status}/{@code grant_type}/
|
||||
* {@code access_path})<b>不影响</b> APP 链路,因为 APP 链路不读取该记录;如后续需与 PC 完全对齐,
|
||||
* 将本项配置为 app 记录的 {@code client_id}(md5)即可,代码无需改动。
|
||||
*/
|
||||
private String clientId = "app";
|
||||
|
||||
/**
|
||||
* 允许访问路径(写入 token extra,由框架 SecurityConfig 校验)。
|
||||
*/
|
||||
private String accessPath = "/app/**";
|
||||
|
||||
/**
|
||||
* 设备类型。
|
||||
*/
|
||||
private String deviceType = "android";
|
||||
|
||||
/**
|
||||
* token 固定超时时间(秒)。
|
||||
*/
|
||||
private Long timeout = 604800L;
|
||||
|
||||
/**
|
||||
* token 活跃超时时间(秒)。
|
||||
*/
|
||||
private Long activeTimeout = 1800L;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码策略配置。
|
||||
*/
|
||||
@Data
|
||||
public static class Sms {
|
||||
|
||||
/**
|
||||
* 是否走模拟发送(true 时仅打印日志,便于开发环境无短信通道时联调,见 Q5)。
|
||||
* <p>
|
||||
* <b>生产安全(I2 遗留项收口)</b>:本项默认 true 仅服务开发/联调;<b>生产环境(prod)不得为 true</b>——
|
||||
* {@link AppSmsProdGuard} 会在激活 profile 含 {@code prod} 且本值为 true 时<b>拒绝启动</b>,
|
||||
* 以避免验证码明文落盘。生产部署请在 {@code application-prod.yml} 置为 false 并配置真实通道。
|
||||
*/
|
||||
private boolean mock = true;
|
||||
|
||||
/**
|
||||
* sms4j 通道标识(mock=false 时使用)。
|
||||
*/
|
||||
private String blend = "config1";
|
||||
|
||||
/**
|
||||
* sms4j 模板 id(mock=false 时使用)。
|
||||
*/
|
||||
private String templateId = "";
|
||||
|
||||
/**
|
||||
* 验证码位数(APP 固定 6 位)。
|
||||
*/
|
||||
private int codeLength = 6;
|
||||
|
||||
/**
|
||||
* 验证码有效期(分钟)。
|
||||
*/
|
||||
private int expiryMinutes = 5;
|
||||
|
||||
/**
|
||||
* 重发间隔(秒),即 60 秒防刷。
|
||||
*/
|
||||
private int resendSeconds = 60;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱验证码策略配置(SMTP 发信)。
|
||||
* <p>
|
||||
* <b>凭据口径</b>:{@link #user} 与 {@link #pass} 在代码与 {@code application.yml} 中<b>均不预置真实值</b>,
|
||||
* 一律通过环境变量注入({@code DEVICE_APP_EMAIL_USER} / {@code DEVICE_APP_EMAIL_PASS}),
|
||||
* 避免重蹈「明文 broker 凭据硬编码」的覆辙。
|
||||
* <p>
|
||||
* <b>谷歌邮箱(Gmail)口径</b>:主机固定 {@code smtp.gmail.com}、端口 465(SSL)。
|
||||
* Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的
|
||||
* <b>16 位应用专用密码</b>(非账号登录密码),且 {@link #from} 必须等于 {@link #user},
|
||||
* 否则 Gmail 会以「发件人与认证账号不一致」拒收。
|
||||
* <p>
|
||||
* <b>网络前提(部署必读)</b>:{@code smtp.gmail.com} 需服务器可直连 Google。
|
||||
* 国内机房通常不可达,此时表现为连接超时——故 {@link #connectionTimeout} 与 {@link #timeout}
|
||||
* 默认设为较小值,让发信快速失败而不是把 HTTP 请求线程挂死。
|
||||
*/
|
||||
@Data
|
||||
public static class Email {
|
||||
|
||||
/**
|
||||
* 是否走模拟发送(true 时<b>仅打印日志不真实发信</b>,便于无 SMTP 条件时联调)。
|
||||
* <p>
|
||||
* 与短信 {@link Sms#isMock()} 同口径:默认 true 仅服务开发/联调。
|
||||
* <b>生产环境(prod)不得为 true</b>——{@link AppEmailProdGuard} 会在激活 profile 含 {@code prod}
|
||||
* 且本值为 true 时<b>拒绝启动</b>,避免验证码明文落盘。
|
||||
*/
|
||||
private boolean mock = true;
|
||||
|
||||
/**
|
||||
* Gmail SMTP 主机。
|
||||
*/
|
||||
private String host = "smtp.gmail.com";
|
||||
|
||||
/**
|
||||
* Gmail SMTP 端口:465(SSL)或 587(STARTTLS)。
|
||||
*/
|
||||
private int port = 465;
|
||||
|
||||
/**
|
||||
* 发信账号(Gmail 地址),由环境变量注入。
|
||||
*/
|
||||
private String user = "";
|
||||
|
||||
/**
|
||||
* 发信凭据:Gmail 的 16 位<b>应用专用密码</b>,由环境变量注入。
|
||||
*/
|
||||
private String pass = "";
|
||||
|
||||
/**
|
||||
* 发件人(须与 {@link #user} 一致,可写为 {@code 名称 <user@gmail.com>})。
|
||||
*/
|
||||
private String from = "";
|
||||
|
||||
/**
|
||||
* 使用 SSL 安全连接(465 端口用 true)。
|
||||
*/
|
||||
private boolean sslEnable = true;
|
||||
|
||||
/**
|
||||
* 使用 STARTTLS 升级连接(587 端口用 true,与 {@link #sslEnable} 互斥使用)。
|
||||
*/
|
||||
private boolean starttlsEnable = false;
|
||||
|
||||
/**
|
||||
* SMTP 连接超时(毫秒):避免 Gmail 不可达时长时间占用请求线程。
|
||||
*/
|
||||
private int connectionTimeout = 5000;
|
||||
|
||||
/**
|
||||
* SMTP 读写超时(毫秒)。
|
||||
*/
|
||||
private int timeout = 10000;
|
||||
|
||||
/**
|
||||
* 邮件主题。
|
||||
*/
|
||||
private String subject = "智能飞禽物联平台 验证码";
|
||||
|
||||
/**
|
||||
* 验证码位数(APP 固定 6 位)。
|
||||
*/
|
||||
private int codeLength = 6;
|
||||
|
||||
/**
|
||||
* 验证码有效期(分钟)。
|
||||
*/
|
||||
private int expiryMinutes = 5;
|
||||
|
||||
/**
|
||||
* 重发间隔(秒),即 60 秒防刷。
|
||||
*/
|
||||
private int resendSeconds = 60;
|
||||
|
||||
/**
|
||||
* 允许注册的邮箱域名白名单,<b>留空 = 不限制域名</b>。
|
||||
* <p>
|
||||
* 若产品要求「只允许谷歌邮箱注册」,配置为 {@code gmail.com,googlemail.com} 即可,无需改代码。
|
||||
*/
|
||||
private List<String> allowedDomains = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 判断是否已具备真实发信条件(发信账号与凭据均已配置)。
|
||||
*
|
||||
* @return true 已配置;false 未配置
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return StringUtils.isNotBlank(user) && StringUtils.isNotBlank(pass);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议配置。
|
||||
*/
|
||||
@Data
|
||||
public static class Agreement {
|
||||
|
||||
/**
|
||||
* 用户协议。
|
||||
*/
|
||||
private Item userAgreement = new Item();
|
||||
|
||||
/**
|
||||
* 隐私政策。
|
||||
*/
|
||||
private Item privacyPolicy = new Item();
|
||||
|
||||
/**
|
||||
* 单条协议内容(URL + 版本 + 更新时间)。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Item {
|
||||
|
||||
/**
|
||||
* 协议 URL(或富文本地址)。
|
||||
*/
|
||||
private String url = "";
|
||||
|
||||
/**
|
||||
* 协议版本。
|
||||
*/
|
||||
private String version = "v1.0";
|
||||
|
||||
/**
|
||||
* 协议更新时间(如 2026-01-01)。
|
||||
*/
|
||||
private String updateTime = "";
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.dromara.device.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* APP 短信生产环境守卫(I2 遗留项收口)。
|
||||
* <p>
|
||||
* <b>背景</b>:{@code device.app.sms.mock} 默认 {@code true},会把短信验证码<b>明文写入应用日志</b>。
|
||||
* 这在开发/联调期是便利(Q5 短信通道未定),但若生产环境误开 mock,等价于把验证码落盘,属安全风险。
|
||||
* <p>
|
||||
* <b>策略</b>:不修改默认值(开发仍需 mock),而是保证「<b>生产环境无法带 mock 启动</b>」——
|
||||
* <ul>
|
||||
* <li>当激活 profile 含 {@code prod} 且 {@code mock=true} → 抛 {@link IllegalStateException},<b>拒绝启动</b>,
|
||||
* 迫使运维显式关闭 mock 并配置真实通道;</li>
|
||||
* <li>非 prod 环境且 {@code mock=true} → 打印 WARN,提示验证码将入日志,仅限开发/测试。</li>
|
||||
* </ul>
|
||||
* 采用框架惯用的 {@link InitializingBean}(与本模块外 {@code AllUrlHandler} 等一致),在属性注入后、容器就绪前校验。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AppSmsProdGuard implements InitializingBean {
|
||||
|
||||
/**
|
||||
* 生产环境 profile 名(对应 {@code application-prod.yml})。
|
||||
*/
|
||||
private static final String PROD_PROFILE = "prod";
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final Environment environment;
|
||||
|
||||
/**
|
||||
* 校验「生产环境不得开启短信 mock」,否则拒绝启动。
|
||||
*/
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
if (!appProperties.getSms().isMock()) {
|
||||
// 已走真实通道,无需干预
|
||||
return;
|
||||
}
|
||||
if (environment.matchesProfiles(PROD_PROFILE)) {
|
||||
throw new IllegalStateException(
|
||||
"生产环境(prod)禁止开启 device.app.sms.mock(当前为 true)——该模式会把短信验证码明文写入日志。"
|
||||
+ "请在 application-prod.yml 中设置 device.app.sms.mock=false 并配置真实短信通道后重启。");
|
||||
}
|
||||
log.warn("[APP短信] device.app.sms.mock=true:验证码将写入应用日志,仅限开发/测试环境使用;"
|
||||
+ "生产环境(prod)将因此拒绝启动。");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package org.dromara.device.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 设备 MQTT 运行时配置(前缀 {@code device.mqtt})。
|
||||
* <p>
|
||||
* 全部字段带默认值,默认值即《设备通信协议-一期.md》给出的工程默认口径,
|
||||
* 因此<b>不配置也能跑</b>;需要调参时只在 {@code application-*.yml} 覆盖对应项即可,无需改代码。
|
||||
* <p>
|
||||
* 与 {@code AppProperties}(前缀 {@code device.app})的分工:本类只管「设备侧通信链路」的可调参数,
|
||||
* APP 侧账号/短信/协议配置仍归 {@code AppProperties}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "device.mqtt")
|
||||
public class DeviceMqttProperties {
|
||||
|
||||
/**
|
||||
* 是否启用离线扫描。
|
||||
* <p>
|
||||
* 关闭后不再自动置离线,设备将长期停留在「在线」——仅用于联调排障,生产必须为 {@code true}。
|
||||
*/
|
||||
private boolean scanEnabled = true;
|
||||
|
||||
/**
|
||||
* 离线扫描间隔。
|
||||
* <p>
|
||||
* 采用 {@code fixedDelay} 语义(上一轮跑完再计时),因此扫描耗时不会造成任务堆积。
|
||||
*/
|
||||
private Duration offlineScanInterval = Duration.ofSeconds(30);
|
||||
|
||||
/**
|
||||
* 离线判定阈值:超过该时长无遥测即判离线。
|
||||
* <p>
|
||||
* 协议默认 120s,对应「上报周期 60s × 2」的容错口径(见协议 §5.1、Q2)。
|
||||
*/
|
||||
private Duration offlineThreshold = Duration.ofSeconds(120);
|
||||
|
||||
/**
|
||||
* 单轮扫描的设备上限,防止异常情况下一次性拉爆内存。
|
||||
* <p>
|
||||
* 命中数量达到该值时说明积压严重,日志会给出提示。
|
||||
*/
|
||||
private int scanBatchSize = 500;
|
||||
|
||||
/**
|
||||
* 低电告警触发阈值(≤ 该值触发)。
|
||||
*/
|
||||
private int lowBatteryThreshold = 20;
|
||||
|
||||
/**
|
||||
* 低电告警恢复阈值(≥ 该值恢复)。
|
||||
* <p>
|
||||
* 与 {@link #lowBatteryThreshold} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。
|
||||
* <p>
|
||||
* ⚠️ 引入<b>设备级阈值</b>后本字段的含义有所扩展:
|
||||
* <ul>
|
||||
* <li>设备未配置 / 历史行 → 直接作为恢复阈值使用(与原行为完全一致);</li>
|
||||
* <li>设备已配置 → 仅取「本值 − {@link #lowBatteryThreshold}」的<b>差值</b>作为统一回差,
|
||||
* 由 {@link #resolveRecoverThreshold(int)} 派生该设备的恢复阈值 ——
|
||||
* 这样无需为设备级阈值新增配置项,滞回口径也不会各设备一套。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private int lowBatteryRecoverThreshold = 25;
|
||||
|
||||
/**
|
||||
* 低电滞回闩锁的存活时长。
|
||||
* <p>
|
||||
* 闩锁本身无业务过期语义(低电态由恢复阈值解除),设置 TTL 只为兜底清理已注销设备的残留键;
|
||||
* 若 TTL 到期时设备仍处于低电态,会补发一条告警——因此不宜设得太短。
|
||||
*/
|
||||
private Duration lowBatteryLatchTtl = Duration.ofDays(30);
|
||||
|
||||
/**
|
||||
* 告警去重窗口。
|
||||
* <p>
|
||||
* 同一 {@code deviceNo + alertType} 在该窗口内只落一条告警。两个场景共用:
|
||||
* 设备上报的低电事件(协议要求 30 分钟)与设备事件与服务端阈值判定的交叉去重。
|
||||
*/
|
||||
private Duration alertDedupWindow = Duration.ofMinutes(30);
|
||||
|
||||
/**
|
||||
* 眼罩指令回执等待超时。
|
||||
* <p>
|
||||
* 超时即向 APP 返回「指令下发失败」({@code DeviceMsgConstants.COMMAND_SEND_FAILED})。
|
||||
* 对应协议 §13 Q3:{@code coverOn} 下行后物理动作到位时长需硬件确认。
|
||||
*/
|
||||
private Duration hoodTimeout = Duration.ofSeconds(5);
|
||||
|
||||
/**
|
||||
* OTA 指令回执等待超时。
|
||||
* <p>
|
||||
* 设备收到 {@code command/ota} 后应立即回 {@code ack} 表示<b>已受理</b>(协议 §7.2),
|
||||
* 因此等待窗口短;超时即判该设备本次下发失败({@code DeviceMsgConstants.OTA_BATCH_FAIL_DISPATCH}),
|
||||
* 任务落「失败」终态,不空悬。
|
||||
*/
|
||||
private Duration otaTimeout = Duration.ofSeconds(10);
|
||||
|
||||
/**
|
||||
* 待回执指令标记在 Redis 中的存活时长(仅用于事后定位,不参与正确性判定)。
|
||||
*/
|
||||
private Duration pendingAckTtl = Duration.ofMinutes(10);
|
||||
|
||||
// ---------------- 低电阈值解析(设备级配置 → 生效值) ----------------
|
||||
|
||||
/**
|
||||
* 解析生效的<b>触发阈值</b>:设备级配置优先,未配置则回落工程默认。
|
||||
* <p>
|
||||
* 设备级阈值由 DDL 保证 {@code NOT NULL DEFAULT 20},正常路径下 {@code deviceThreshold}
|
||||
* 不会是 null;保留兜底是为兼容「补丁执行前写入的历史行」等边界情形。
|
||||
* <p>
|
||||
* <b>返回 0 表示对该设备关闭低电保护</b>,而非「阈值取 0」——调用方必须显式判 0,
|
||||
* 不能直接写 {@code battery <= resolveTriggerThreshold(...)},否则会退化成
|
||||
* 「电量恰好归零才告警」。目前两个消费点(低电告警判定、遮盖指令拦截)都已按此约定处理。
|
||||
*
|
||||
* @param deviceThreshold 设备上配置的阈值(可为 null)
|
||||
* @return 生效的触发阈值(电量 ≤ 该值即触发低电)
|
||||
*/
|
||||
public int resolveTriggerThreshold(Integer deviceThreshold) {
|
||||
return deviceThreshold == null ? lowBatteryThreshold : deviceThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生生效的<b>恢复阈值</b>:{@code 触发阈值 + 回差}。
|
||||
* <p>
|
||||
* 回差取自 {@link #lowBatteryThreshold} 与 {@link #lowBatteryRecoverThreshold} 之差
|
||||
* (工程默认为 {@code 25 - 20 = 5}),使设备级阈值天然沿用同一滞回口径。
|
||||
* <p>
|
||||
* 回差下限保护为 1:若运维把两个默认值配成相等(回差为 0,等于没有滞回),
|
||||
* 电量在阈值附近抖动时会反复告警/恢复,故强制至少留 1 个百分点。
|
||||
*
|
||||
* @param triggerThreshold 生效的触发阈值
|
||||
* @return 生效的恢复阈值(严格大于 {@code triggerThreshold})
|
||||
*/
|
||||
public int resolveRecoverThreshold(int triggerThreshold) {
|
||||
return triggerThreshold + Math.max(1, lowBatteryRecoverThreshold - lowBatteryThreshold);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.dromara.device.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* ZLMediaKit 接入配置(前缀 {@code zlm})。
|
||||
* <p>
|
||||
* 设备侧推流到 ZLM,APP 侧拉 HLS/FLV/WebRTC。地址与密钥一律走环境变量覆盖,
|
||||
* 未配置时直播接口返回「ZLM 地址未配置」,不阻断应用启动。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "zlm")
|
||||
public class ZlmProperties {
|
||||
|
||||
/**
|
||||
* ZLM HTTP API / HTTP-FLV / HLS 基址,例如 {@code http://127.0.0.1:80}
|
||||
*/
|
||||
private String host;
|
||||
|
||||
/**
|
||||
* RTMP 推拉流基址,未配则从 {@link #host} 推导 1935 端口
|
||||
*/
|
||||
private String rtmpHost;
|
||||
|
||||
/**
|
||||
* RTSP 推拉流基址,未配则从 {@link #host} 推导 554 端口
|
||||
*/
|
||||
private String rtspHost;
|
||||
|
||||
/**
|
||||
* WebRTC 播放基址,未配则回落 {@link #host}
|
||||
*/
|
||||
private String webrtcHost;
|
||||
|
||||
/**
|
||||
* ZLM REST / WebHook 共享密钥。WebHook 必须携带该值,未配置时拒绝回调。
|
||||
*/
|
||||
private String secret;
|
||||
|
||||
/**
|
||||
* 应用名(ZLM app),默认 {@code live}
|
||||
*/
|
||||
private String app = "live";
|
||||
|
||||
/**
|
||||
* 查询在线流时使用的协议,默认 {@code rtmp}
|
||||
*/
|
||||
private String schema = "rtmp";
|
||||
|
||||
/**
|
||||
* 设备推流协议:{@code rtmp} 或 {@code rtsp}
|
||||
*/
|
||||
private String pushProtocol = "rtmp";
|
||||
|
||||
/**
|
||||
* 播放 token 有效期(秒)
|
||||
*/
|
||||
private int playExpireSeconds = 120;
|
||||
|
||||
/**
|
||||
* 流超时秒数(配置占位,当前由 ZLM 自身 none_reader 回调关流)
|
||||
*/
|
||||
private int streamTimeout = 30;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.dromara.device.constant;
|
||||
|
||||
/**
|
||||
* APP 端(C 端)通用常量:校验正则、验证码场景、Redis Key 前缀、协议类型。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public final class AppConstants {
|
||||
|
||||
private AppConstants() {
|
||||
}
|
||||
|
||||
// ---------------- 校验正则 ----------------
|
||||
|
||||
/**
|
||||
* 手机号(中国大陆 11 位)。
|
||||
*/
|
||||
public static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
|
||||
|
||||
/**
|
||||
* 密码:8-20 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。
|
||||
*/
|
||||
public static final String PASSWORD_REGEX = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)\\S{8,20}$";
|
||||
|
||||
/**
|
||||
* 短信验证码:6 位数字。
|
||||
*/
|
||||
public static final String SMS_CODE_REGEX = "^\\d{6}$";
|
||||
|
||||
/**
|
||||
* 邮箱地址(长度上限对齐 {@code sys_user.email varchar(50)})。
|
||||
* <p>
|
||||
* 采用宽松的通用校验:本地部分允许字母数字与常见符号,域名部分至少一个点。
|
||||
* <b>不做「仅允许 gmail.com」的硬限制</b>——域名白名单由
|
||||
* {@code device.app.email.allowed-domains} 配置控制(留空即不限制),
|
||||
* 以便在不改代码的前提下切换为「只允许谷歌邮箱注册」。
|
||||
*/
|
||||
public static final String EMAIL_REGEX = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$";
|
||||
|
||||
/**
|
||||
* 设备 IMEI(绑定码):默认 15 位数字(Q1 口径,见《02-架构设计-一期》§6.1)。
|
||||
*/
|
||||
public static final String IMEI_REGEX = "^\\d{15}$";
|
||||
|
||||
/**
|
||||
* 设备名称最大字符数(按字符计数,中文按 1 字计,与《开发计划》§8.2「name ≤20」一致;
|
||||
* 注意不是字节数,故 {@code "测试设备一二三四五六七八九十"} 为 12 字而非 12 字节)。
|
||||
*/
|
||||
public static final int DEVICE_NAME_MAX_LENGTH = 20;
|
||||
|
||||
// ---------------- 验证码场景 ----------------
|
||||
|
||||
/**
|
||||
* 场景:登录。
|
||||
*/
|
||||
public static final String SCENE_LOGIN = "login";
|
||||
|
||||
/**
|
||||
* 场景:注册。
|
||||
*/
|
||||
public static final String SCENE_REGISTER = "register";
|
||||
|
||||
/**
|
||||
* 场景:忘记密码。
|
||||
*/
|
||||
public static final String SCENE_RESET_PWD = "resetPwd";
|
||||
|
||||
/**
|
||||
* 场景:换绑手机。
|
||||
*/
|
||||
public static final String SCENE_REBIND = "rebind";
|
||||
|
||||
/**
|
||||
* 场景:绑定手机(首次绑定,用于邮箱注册后补绑手机号的账号)。
|
||||
* <p>
|
||||
* 与 {@link #SCENE_REBIND} 分开的原因:两者语义与前置条件不同——{@code bind} 用于账号
|
||||
* 尚无手机号({@code phone_number IS NULL})的首次绑定,{@code rebind} 用于已有手机号的更换。
|
||||
* 缓存 key 按场景隔离,故两个场景的验证码互不覆盖。
|
||||
*/
|
||||
public static final String SCENE_BIND = "bind";
|
||||
|
||||
// ---------------- 协议类型 ----------------
|
||||
|
||||
/**
|
||||
* 协议类型:用户协议。
|
||||
*/
|
||||
public static final String AGREEMENT_USER = "user";
|
||||
|
||||
/**
|
||||
* 协议类型:隐私政策。
|
||||
*/
|
||||
public static final String AGREEMENT_PRIVACY = "privacy";
|
||||
|
||||
// ---------------- Redis Key 前缀 ----------------
|
||||
|
||||
/**
|
||||
* 验证码缓存 key 前缀,完整 key = 前缀 + 场景 + ":" + 手机号。
|
||||
*/
|
||||
public static final String SMS_CODE_KEY_PREFIX = "app:sms:code:";
|
||||
|
||||
/**
|
||||
* 验证码发送限流 key 前缀,完整 key = 前缀 + 场景 + ":" + 手机号。
|
||||
*/
|
||||
public static final String SMS_LIMIT_KEY_PREFIX = "app:sms:limit:";
|
||||
|
||||
/**
|
||||
* 验证码校验失败次数计数器 key 前缀,完整 key = 前缀 + 场景 + ":" + 手机号(P3-3 加固)。
|
||||
*/
|
||||
public static final String SMS_FAIL_KEY_PREFIX = "app:sms:fail:";
|
||||
|
||||
/**
|
||||
* 「错误尝试」限速窗口内允许的最大失败次数(P2 加固后语义)。
|
||||
* <p>
|
||||
* 注意:该阈值<b>只限制「错误验证码」的提交速率</b>,<b>不使验证码失效</b>——
|
||||
* 正确验证码永远可校验成功。达到阈值后仅短暂拒绝「错误」尝试(冷却窗口 {@link #SMS_VERIFY_FAIL_WINDOW_SECONDS} 秒),
|
||||
* 从而杜绝「攻击者用失败请求让受害者验证码失效」的定向 DoS。
|
||||
*/
|
||||
public static final int SMS_MAX_VERIFY_FAIL = 5;
|
||||
|
||||
/**
|
||||
* 「错误尝试」限速窗口(秒):窗口内失败次数超过 {@link #SMS_MAX_VERIFY_FAIL} 时短暂拒绝错误尝试,
|
||||
* 窗口到期自动自愈(Redis key TTL)。
|
||||
*/
|
||||
public static final int SMS_VERIFY_FAIL_WINDOW_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* 注册并发保护锁 key 前缀,完整 key = 前缀 + 手机号(P2-2-2)。
|
||||
* <p>
|
||||
* 风格对齐 I1 取号锁 {@code bm:device:no:lock:*},同属「按业务维度串行化」的 Redisson 锁。
|
||||
* 加此锁后,DB 唯一键 {@code uk_sys_user_phone_number} 才是最终防线,锁仅用于把并发冲突降到「几乎不触发」。
|
||||
*/
|
||||
public static final String REGISTER_LOCK_KEY_PREFIX = "app:user:register:lock:";
|
||||
|
||||
// ---------------- 邮箱验证码 Redis Key 前缀(与短信通道完全隔离) ----------------
|
||||
|
||||
/**
|
||||
* 邮箱验证码缓存 key 前缀,完整 key = 前缀 + 场景 + ":" + 邮箱。
|
||||
* <p>
|
||||
* 与短信前缀 {@link #SMS_CODE_KEY_PREFIX} <b>刻意分家</b>:同一场景下手机号与邮箱可能重名取值,
|
||||
* 且两通道的验证码不应互相覆盖或互相销毁。
|
||||
*/
|
||||
public static final String EMAIL_CODE_KEY_PREFIX = "app:email:code:";
|
||||
|
||||
/**
|
||||
* 邮箱验证码发送限流 key 前缀,完整 key = 前缀 + 场景 + ":" + 邮箱。
|
||||
*/
|
||||
public static final String EMAIL_LIMIT_KEY_PREFIX = "app:email:limit:";
|
||||
|
||||
/**
|
||||
* 邮箱验证码校验失败次数计数器 key 前缀,完整 key = 前缀 + 场景 + ":" + 邮箱。
|
||||
*/
|
||||
public static final String EMAIL_FAIL_KEY_PREFIX = "app:email:fail:";
|
||||
|
||||
/**
|
||||
* 邮箱注册并发保护锁 key 前缀,完整 key = 前缀 + 邮箱。
|
||||
* <p>
|
||||
* 与手机号注册锁 {@link #REGISTER_LOCK_KEY_PREFIX} 分家;最终防线同样是 DB 唯一键
|
||||
* {@code uk_sys_user_email}。
|
||||
*/
|
||||
public static final String EMAIL_REGISTER_LOCK_KEY_PREFIX = "app:user:register:lock:email:";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package org.dromara.device.constant;
|
||||
|
||||
/**
|
||||
* APP 端(C 端)提示文案常量。
|
||||
* <p>
|
||||
* 取值口径:优先对齐《01-PRD-一期》《开发计划》§8.3「全局说明」原句;
|
||||
* 原型未明确给出的文案(如密码错误、验证码过于频繁)标注 {@code TODO},
|
||||
* 待阶段 7(R-38)按原型逐条核对后统一修正。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public final class AppMsgConstants {
|
||||
|
||||
private AppMsgConstants() {
|
||||
}
|
||||
|
||||
// ---------------- 验证码 ----------------
|
||||
|
||||
/**
|
||||
* 验证码已发送(发送成功提示)。
|
||||
*/
|
||||
public static final String SMS_CODE_SENT = "验证码已发送";
|
||||
|
||||
/**
|
||||
* 验证码场景不合法。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String SMS_SCENE_INVALID = "验证码场景不合法";
|
||||
|
||||
/**
|
||||
* 验证码发送过于频繁(60 秒内重复发送)。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String SMS_SEND_TOO_FREQUENT = "验证码发送过于频繁,请稍后再试";
|
||||
|
||||
/**
|
||||
* 验证码短信发送失败。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String SMS_SEND_FAILED = "验证码短信发送失败";
|
||||
|
||||
/**
|
||||
* 验证码错误尝试过于频繁(短冷却窗口内错误次数过多,稍后自动恢复)。
|
||||
* <p>注意:此提示仅表示「错误尝试」被短暂限速,<b>不表示验证码已失效</b>;正确验证码仍可校验成功。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String SMS_VERIFY_TOO_MANY = "验证码错误次数过多,请稍后再试";
|
||||
|
||||
// ---------------- 邮箱验证码 ----------------
|
||||
|
||||
/**
|
||||
* 邮箱验证码已发送(发送成功提示)。
|
||||
*/
|
||||
public static final String EMAIL_CODE_SENT = "验证码已发送至邮箱";
|
||||
|
||||
/**
|
||||
* 邮箱验证码场景不合法。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String EMAIL_SCENE_INVALID = "验证码场景不合法";
|
||||
|
||||
/**
|
||||
* 邮箱验证码发送失败(SMTP 通道异常)。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String EMAIL_SEND_FAILED = "验证码邮件发送失败";
|
||||
|
||||
/**
|
||||
* 邮箱域名不在允许列表内({@code device.app.email.allowed-domains} 非空时生效)。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String EMAIL_DOMAIN_NOT_ALLOWED = "该邮箱域名暂不支持注册";
|
||||
|
||||
/**
|
||||
* 邮箱注册成功(提示继续绑定手机号)。
|
||||
* <p>「注册后仍须绑手机号」为本迭代的账号规则,故注册成功文案直接给出下一步引导。
|
||||
*/
|
||||
public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请绑定手机号";
|
||||
|
||||
/**
|
||||
* 该账号尚未绑定手机号(绑定设备前的硬拦截文案)。
|
||||
*/
|
||||
public static final String PHONE_NOT_BOUND = "请先绑定手机号";
|
||||
|
||||
// ---------------- 账号 ----------------
|
||||
|
||||
/**
|
||||
* 注册成功。
|
||||
*/
|
||||
public static final String REGISTER_SUCCESS = "注册成功";
|
||||
|
||||
/**
|
||||
* 手机号或密码错误。
|
||||
* <p>TODO 阶段 7 按原型核对(原型「全局说明」未直接给出该句)。
|
||||
*/
|
||||
public static final String PASSWORD_ERROR = "手机号或密码错误";
|
||||
|
||||
/**
|
||||
* 邮箱或密码错误(邮箱登录通道专用)。
|
||||
* <p>不复用 {@link #PASSWORD_ERROR} 的原因:该句含「手机号」字样,用于邮箱登录会误导用户。
|
||||
*/
|
||||
public static final String EMAIL_PASSWORD_ERROR = "邮箱或密码错误";
|
||||
|
||||
/**
|
||||
* 账号已被停用。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String ACCOUNT_DISABLED = "账号已被停用";
|
||||
|
||||
/**
|
||||
* 登录已失效(会话不存在)。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String NOT_LOGIN = "登录已失效,请重新登录";
|
||||
|
||||
/**
|
||||
* 退出成功。
|
||||
*/
|
||||
public static final String LOGOUT_SUCCESS = "退出成功";
|
||||
|
||||
/**
|
||||
* 注销成功。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String CANCEL_SUCCESS = "注销成功";
|
||||
|
||||
// ---------------- 个人中心 ----------------
|
||||
|
||||
/**
|
||||
* 密码重置成功。
|
||||
*/
|
||||
public static final String PASSWORD_RESET_SUCCESS = "密码重置成功";
|
||||
|
||||
/**
|
||||
* 密码修改成功。
|
||||
*/
|
||||
public static final String PASSWORD_UPDATE_SUCCESS = "密码修改成功";
|
||||
|
||||
/**
|
||||
* 原密码错误。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String OLD_PASSWORD_ERROR = "原密码错误";
|
||||
|
||||
/**
|
||||
* 新密码不能与原密码相同。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String NEW_PASSWORD_SAME = "新密码不能与原密码相同";
|
||||
|
||||
/**
|
||||
* 手机号更换成功。
|
||||
*/
|
||||
public static final String PHONE_UPDATE_SUCCESS = "手机号更换成功";
|
||||
|
||||
/**
|
||||
* 新手机号与当前手机号相同(换绑时的精确提示,替代原先误报的「手机号已注册」)。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String PHONE_SAME_AS_CURRENT = "新手机号与当前手机号相同";
|
||||
|
||||
/**
|
||||
* 协议类型不合法。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
*/
|
||||
public static final String AGREEMENT_TYPE_INVALID = "协议类型不合法";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.dromara.device.constant;
|
||||
|
||||
/**
|
||||
* 设备 MQTT 运行时常量。
|
||||
* <p>
|
||||
* 只放「不随环境变化的键名与协议固定码」,阈值类可调参数见
|
||||
* {@code org.dromara.device.config.DeviceMqttProperties}(配置前缀 {@code device.mqtt})。
|
||||
* <p>
|
||||
* Redis Key 命名风格对齐 I1 取号锁 {@code bm:device:no:lock:*}:{@code bm:} 域名前缀 +
|
||||
* 业务段 + 维度后缀,便于运维按前缀排查与清理。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public final class DeviceMqttConstants {
|
||||
|
||||
private DeviceMqttConstants() {
|
||||
}
|
||||
|
||||
// ---------------- Redis Key ----------------
|
||||
|
||||
/**
|
||||
* 离线扫描互斥锁。
|
||||
* <p>
|
||||
* 每个实例都会起扫描任务,但同一时刻只允许一个实例真正扫描——否则多实例会
|
||||
* 对同一批超时设备重复「置离线 + 写告警」。
|
||||
*/
|
||||
public static final String ONLINE_SCAN_LOCK = "bm:mqtt:online-scan:lock";
|
||||
|
||||
/**
|
||||
* 告警去重窗口占位,完整 Key:{@code bm:mqtt:alert:dedup:{deviceNo}:{alertType}}。
|
||||
* <p>
|
||||
* 用于「设备上报的低电事件」与「服务端阈值判定」两条来源共用的去重窗口,
|
||||
* 保证窗口内同一 {@code deviceNo + alertType} 只落一条 {@code bm_device_alert}。
|
||||
*/
|
||||
public static final String ALERT_DEDUP_PREFIX = "bm:mqtt:alert:dedup:";
|
||||
|
||||
/**
|
||||
* 低电滞回闩锁,完整 Key:{@code bm:mqtt:battery:low:{deviceNo}:{alertType}}。
|
||||
* <p>
|
||||
* 语义是「该设备该类电量当前处于低电态」:置位后不再重复告警,直到电量回升越过恢复阈值才清除。
|
||||
* 与「去重窗口」的分工:窗口解决重复写库,闩锁解决「长时间低电被周期性重复告警」。
|
||||
*/
|
||||
public static final String BATTERY_LOW_PREFIX = "bm:mqtt:battery:low:";
|
||||
|
||||
/**
|
||||
* ZLM 播放 token,完整 Key:{@code bm:zlm:play:token:{token}}。
|
||||
*/
|
||||
public static final String ZLM_PLAY_TOKEN_PREFIX = "bm:zlm:play:token:";
|
||||
|
||||
/**
|
||||
* 待回执指令标记,完整 Key:{@code bm:mqtt:ack:pending:{msgId}},值为目标 {@code deviceNo}。
|
||||
* <p>
|
||||
* 该键<b>不参与正确性判定</b>(Future 关联在本实例内存中完成),只作两件事:
|
||||
* 一是让「未识别的 msgId」与「本平台发出但已超时的 msgId」在日志里可区分;
|
||||
* 二是让运维能直接看到「当前还有哪些指令在等回执」。
|
||||
*/
|
||||
public static final String PENDING_ACK_PREFIX = "bm:mqtt:ack:pending:";
|
||||
|
||||
// ---------------- 协议固定码 ----------------
|
||||
|
||||
/**
|
||||
* 告警处理状态:未处理(一期只读展示,不做处理流程)
|
||||
*/
|
||||
public static final String ALERT_STATUS_UNHANDLED = "0";
|
||||
|
||||
/**
|
||||
* 注册结果码:成功
|
||||
*/
|
||||
public static final int PROVISION_CODE_OK = 0;
|
||||
|
||||
/**
|
||||
* 注册结果码:IMEI 未建档(含私拆、串货线索)
|
||||
*/
|
||||
public static final int PROVISION_CODE_NOT_REGISTERED = 404;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package org.dromara.device.constant;
|
||||
|
||||
/**
|
||||
* 设备域提示文案常量
|
||||
* <p>
|
||||
* 集中管理面向用户的提示语,取值口径对齐产品原型「全局说明」原句,后续迭代统一复用。
|
||||
* 标注 {@code TODO} 的条目需在阶段 7(R-38)按原型逐条核对。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public final class DeviceMsgConstants {
|
||||
|
||||
private DeviceMsgConstants() {
|
||||
}
|
||||
|
||||
// ---------------- 账号 / 验证码 ----------------
|
||||
|
||||
/**
|
||||
* 验证码错误
|
||||
*/
|
||||
public static final String SMS_CODE_ERROR = "验证码错误";
|
||||
|
||||
/**
|
||||
* 验证码已过期
|
||||
*/
|
||||
public static final String SMS_CODE_EXPIRED = "验证码已过期";
|
||||
|
||||
/**
|
||||
* 密码格式(8-20 位,含大小写字母和数字)
|
||||
*/
|
||||
public static final String PASSWORD_FORMAT = "密码需为 8-20 位,且包含大小写字母和数字";
|
||||
|
||||
/**
|
||||
* 账号不存在
|
||||
*/
|
||||
public static final String ACCOUNT_NOT_EXIST = "账号不存在";
|
||||
|
||||
/**
|
||||
* 手机号已注册
|
||||
*/
|
||||
public static final String PHONE_REGISTERED = "手机号已注册";
|
||||
|
||||
/**
|
||||
* 邮箱已注册
|
||||
* <p>与 {@link #PHONE_REGISTERED} 同口径:注册前置判重失败时的受控文案。
|
||||
*/
|
||||
public static final String EMAIL_REGISTERED = "邮箱已注册";
|
||||
|
||||
// ---------------- 设备绑定 / 解绑 / 重命名 ----------------
|
||||
|
||||
/**
|
||||
* 绑定成功
|
||||
*/
|
||||
public static final String BIND_SUCCESS = "添加成功";
|
||||
|
||||
/**
|
||||
* 解绑成功
|
||||
*/
|
||||
public static final String UNBIND_SUCCESS = "解绑成功";
|
||||
|
||||
/**
|
||||
* 重命名成功
|
||||
*/
|
||||
public static final String RENAME_SUCCESS = "修改成功";
|
||||
|
||||
/**
|
||||
* 设备不存在
|
||||
*/
|
||||
public static final String DEVICE_NOT_EXIST = "设备不存在";
|
||||
|
||||
/**
|
||||
* 设备编号已存在
|
||||
*/
|
||||
public static final String DEVICE_NO_EXIST = "设备编号已存在";
|
||||
|
||||
/**
|
||||
* IMEI 已存在
|
||||
*/
|
||||
public static final String IMEI_EXIST = "IMEI 已存在";
|
||||
|
||||
/**
|
||||
* 设备编号或 IMEI 已存在(唯一键冲突兜底,无法判定具体冲突列时使用)
|
||||
*/
|
||||
public static final String DEVICE_NO_OR_IMEI_EXIST = "设备编号或 IMEI 已存在";
|
||||
|
||||
/**
|
||||
* 当日设备编号已达上限
|
||||
*/
|
||||
public static final String DEVICE_NO_LIMIT = "当日设备编号已达上限(9999)";
|
||||
|
||||
/**
|
||||
* 设备编号建档后不可修改(后台编辑时校验)。
|
||||
* <p>{@code device_no} 由服务端建档时自动生成,是被 APP 绑定关系与运维话术引用的业务主展示键,
|
||||
* 允许改号会造成「同一台设备两个编号」的口径混乱,故编辑入口显式拒绝。
|
||||
*/
|
||||
public static final String DEVICE_NO_IMMUTABLE = "设备编号由系统自动生成,不支持修改";
|
||||
|
||||
/**
|
||||
* 设备已被他人绑定
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String DEVICE_BOUND_BY_OTHERS = "设备已被绑定";
|
||||
|
||||
/**
|
||||
* 重复绑定(已绑定给自己)
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String DEVICE_ALREADY_BOUND = "重复绑定";
|
||||
|
||||
/**
|
||||
* 设备名称不合法(为空或超过 {@code AppConstants#DEVICE_NAME_MAX_LENGTH} 个字)。
|
||||
* <p>TODO 阶段 7 按原型核对原句(计划 §8.3 未给出该句)。
|
||||
*/
|
||||
public static final String DEVICE_NAME_INVALID = "设备名称需为 1-20 个字";
|
||||
|
||||
/**
|
||||
* 已绑定设备不可直接删除(后台批量删除时命中 {@code bind_user_id} 非空的设备)。
|
||||
* <p>计划 §9.1:「已绑定需先解绑或二次确认」——本迭代按<b>拒绝</b>实现,文案即此句。
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String DEVICE_BOUND_CANNOT_DELETE = "设备已绑定,请先解绑后再删除";
|
||||
|
||||
// ---------------- 设备操作 ----------------
|
||||
|
||||
/**
|
||||
* 设备离线操作眼罩
|
||||
*/
|
||||
public static final String DEVICE_OFFLINE = "设备已离线";
|
||||
|
||||
/**
|
||||
* 低电
|
||||
*/
|
||||
public static final String LOW_BATTERY = "电量过低";
|
||||
|
||||
/**
|
||||
* 指令下发失败
|
||||
*/
|
||||
public static final String COMMAND_SEND_FAILED = "指令下发失败";
|
||||
|
||||
/**
|
||||
* ZLM 地址未配置
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String ZLM_NOT_CONFIGURED = "ZLM 地址未配置";
|
||||
|
||||
/**
|
||||
* 客户端自定义拉流已停用
|
||||
*/
|
||||
public static final String STREAM_CUSTOM_DISABLED = "客户端自定义视频源已停用";
|
||||
|
||||
/**
|
||||
* 直播操作过于频繁
|
||||
*/
|
||||
public static final String STREAM_TOO_FREQUENT = "操作过于频繁,请稍后再试";
|
||||
|
||||
// ---------------- 告警文案(bm_device_alert.content) ----------------
|
||||
|
||||
/**
|
||||
* 头套低电告警文案,{@code %d} 为触发时的电量。
|
||||
*/
|
||||
public static final String ALERT_LOW_BATTERY_HOOD = "头套电量过低(当前 %d%%)";
|
||||
|
||||
/**
|
||||
* 脚环低电告警文案,{@code %d} 为触发时的电量。
|
||||
*/
|
||||
public static final String ALERT_LOW_BATTERY_RING = "脚环电量过低(当前 %d%%)";
|
||||
|
||||
// ---------------- OTA 升级 ----------------
|
||||
|
||||
/**
|
||||
* OTA 升级包不存在或未启用(批量建任务时整体拒绝)。
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String OTA_PACKAGE_UNAVAILABLE = "升级包不存在或未启用";
|
||||
|
||||
/**
|
||||
* OTA 升级包的唯一约束冲突:同型号 + 同版本已存在。
|
||||
* <p>对齐 DB 唯一键 {@code uk_model_version (model, version)}(不区分逻辑删除行)。
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String OTA_PACKAGE_EXIST = "该型号的升级包版本已存在";
|
||||
|
||||
/**
|
||||
* OTA 升级包必填项缺失(适用型号 / 版本号为空)。
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String OTA_PACKAGE_MODEL_VERSION_REQUIRED = "适用型号与版本号不能为空";
|
||||
|
||||
/**
|
||||
* OTA 升级任务不存在(编辑前置校验)。
|
||||
* <p>TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String OTA_TASK_NOT_EXIST = "升级任务不存在";
|
||||
|
||||
/**
|
||||
* OTA 批量建任务明细:设备型号与升级包不匹配,计入 {@code skipped}。
|
||||
* <p>{@code %s} 为设备编号。TODO 阶段 7 按原型核对原句。
|
||||
*/
|
||||
public static final String OTA_BATCH_SKIP_MODEL_MISMATCH = "设备[%s]型号与升级包不匹配,已跳过";
|
||||
|
||||
/**
|
||||
* OTA 批量建任务明细:设备已是最新版本,计入 {@code skipped}。{@code %s} 为设备编号。
|
||||
*/
|
||||
public static final String OTA_BATCH_SKIP_ALREADY_LATEST = "设备[%s]已是最新版本,已跳过";
|
||||
|
||||
/**
|
||||
* OTA 批量建任务明细:设备已有进行中的同包升级任务,计入 {@code skipped}。{@code %s} 为设备编号。
|
||||
*/
|
||||
public static final String OTA_BATCH_SKIP_DUPLICATE = "设备[%s]已有进行中的升级任务,已跳过";
|
||||
|
||||
/**
|
||||
* OTA 批量建任务明细:设备不存在,计入 {@code failed}。{@code %s} 为设备主键。
|
||||
*/
|
||||
public static final String OTA_BATCH_FAIL_DEVICE_NOT_EXIST = "设备[%s]不存在,已失败";
|
||||
|
||||
/**
|
||||
* OTA 批量建任务明细:设备离线({@code status != 在线}),计入 {@code failed}。{@code %s} 为设备编号。
|
||||
*/
|
||||
public static final String OTA_BATCH_FAIL_DEVICE_OFFLINE = "设备[%s]离线,已失败";
|
||||
|
||||
/**
|
||||
* OTA 批量建任务明细:指令下发未被设备确认(发布失败 / 回执超时 / 回执拒绝),
|
||||
* 计入 {@code failed}。{@code %s} 为设备编号。
|
||||
*/
|
||||
public static final String OTA_BATCH_FAIL_DISPATCH = "设备[%s]指令下发失败,已失败";
|
||||
|
||||
// ---------------- APP 用户管理(后台) ----------------
|
||||
|
||||
/**
|
||||
* 账号状态更新成功(停用 / 启用)。
|
||||
*/
|
||||
public static final String USER_STATUS_SUCCESS = "状态更新成功";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package org.dromara.device.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
import org.dromara.device.domain.bo.AppUserAdminQueryBo;
|
||||
import org.dromara.device.domain.bo.AppUserResetPwdBo;
|
||||
import org.dromara.device.domain.bo.AppUserStatusBo;
|
||||
import org.dromara.device.domain.vo.AppUserAdminVo;
|
||||
import org.dromara.device.domain.vo.AppUserDeviceVo;
|
||||
import org.dromara.device.service.IAppUserAdminService;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台 APP 用户管理 Controller(前缀 {@code /device/app-user})。
|
||||
* <p>
|
||||
* 与 APP 端的 {@code AppUserController}({@code /app/user})<b>不是同一组接口</b>:
|
||||
* <ul>
|
||||
* <li>{@code /app/user/**}:C 端「本人操作本人」,令牌登录态,无权限字;</li>
|
||||
* <li>本类:后台「管理员操作 APP 账号」,<b>全部需权限字</b>,与后台设备管理同口径。</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 接口清单:
|
||||
* <ul>
|
||||
* <li>{@code GET /device/app-user/list} —— 分页列表(恒不含后台账号)</li>
|
||||
* <li>{@code GET /device/app-user/{userId}} —— 用户详情</li>
|
||||
* <li>{@code GET /device/app-user/{userId}/devices} —— 该用户绑定设备</li>
|
||||
* <li>{@code PUT /device/app-user/status} —— 停用 / 启用</li>
|
||||
* <li>{@code PUT /device/app-user/reset-pwd} —— 重置密码</li>
|
||||
* <li>{@code DELETE /device/app-user/{userId}/device/{deviceNo}} —— 解绑设备</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <b>权限字</b>:{@code device:appuser:list}(列表) / {@code device:appuser:query}(详情与绑定设备) /
|
||||
* {@code device:appuser:edit}(状态变更、重置密码、解绑设备)。
|
||||
* 与 OTA 的做法一致,变更类复用单一 {@code edit} 权限字,避免权限字数量膨胀。
|
||||
* <p>
|
||||
* <b>数据权限</b>:APP 账号不属于后台组织架构({@code dept_id} 恒为 {@code NULL}),
|
||||
* 故本组接口不参与框架的数据权限过滤,由权限字做粗粒度把关。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/device/app-user")
|
||||
public class AppUserAdminController extends BaseController {
|
||||
|
||||
private final IAppUserAdminService appUserAdminService;
|
||||
|
||||
/**
|
||||
* 分页查询 APP 用户列表。
|
||||
* <p>
|
||||
* 入参只用 {@link AppUserAdminQueryBo}(它已继承 {@code PageQuery}),
|
||||
* 不再并列声明 {@code PageQuery}——否则两个分页参数会同时参与绑定、
|
||||
* 且「谁生效」取决于绑定顺序,属易错设计。
|
||||
*
|
||||
* @param bo 查询条件与分页参数
|
||||
* @return APP 用户分页结果(含绑定设备数)
|
||||
*/
|
||||
@SaCheckPermission("device:appuser:list")
|
||||
@GetMapping("/list")
|
||||
public R<PageResult<AppUserAdminVo>> list(@Validated AppUserAdminQueryBo bo) {
|
||||
return R.ok(appUserAdminService.queryPageList(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 APP 用户详情。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return 用户详情;不存在或非 APP 账号时 {@code data} 为 {@code null}(前端按空态处理)
|
||||
*/
|
||||
@SaCheckPermission("device:appuser:query")
|
||||
@GetMapping("/{userId}")
|
||||
public R<AppUserAdminVo> getInfo(@PathVariable("userId") Long userId) {
|
||||
return R.ok(appUserAdminService.queryById(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定 APP 用户当前绑定的设备列表。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return 绑定设备列表(无绑定或用户无效时为空列表,不报错)
|
||||
*/
|
||||
@SaCheckPermission("device:appuser:query")
|
||||
@GetMapping("/{userId}/devices")
|
||||
public R<List<AppUserDeviceVo>> devices(@PathVariable("userId") Long userId) {
|
||||
return R.ok(appUserAdminService.listDevices(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用 / 启用账号。
|
||||
*
|
||||
* @param bo 状态变更入参(userId + status)
|
||||
* @return 成功文案
|
||||
*/
|
||||
@SaCheckPermission("device:appuser:edit")
|
||||
@Log(title = "APP用户管理", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/status")
|
||||
public R<Void> changeStatus(@Validated @RequestBody AppUserStatusBo bo) {
|
||||
appUserAdminService.updateStatus(bo);
|
||||
return R.ok(DeviceMsgConstants.USER_STATUS_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置账号密码(管理员代为设置)。
|
||||
*
|
||||
* @param bo 重置密码入参(userId + newPassword)
|
||||
* @return 成功文案
|
||||
*/
|
||||
@SaCheckPermission("device:appuser:edit")
|
||||
@Log(title = "APP用户管理", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/reset-pwd")
|
||||
public R<Void> resetPassword(@Validated @RequestBody AppUserResetPwdBo bo) {
|
||||
appUserAdminService.resetPassword(bo);
|
||||
return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑该用户名下的某台设备。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param deviceNo 设备编号
|
||||
* @return 成功文案
|
||||
*/
|
||||
@SaCheckPermission("device:appuser:edit")
|
||||
@Log(title = "APP用户管理", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{userId}/device/{deviceNo}")
|
||||
public R<Void> unbindDevice(@PathVariable("userId") Long userId,
|
||||
@PathVariable("deviceNo") String deviceNo) {
|
||||
appUserAdminService.unbindDevice(userId, deviceNo);
|
||||
return R.ok(DeviceMsgConstants.UNBIND_SUCCESS);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package org.dromara.device.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.core.validate.QueryGroup;
|
||||
import org.dromara.common.excel.utils.ExcelBuilder;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.domain.bo.DeviceBo;
|
||||
import org.dromara.device.domain.vo.DeviceStatsVo;
|
||||
import org.dromara.device.domain.vo.DeviceVo;
|
||||
import org.dromara.device.service.IDeviceService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台设备 Controller(前缀 {@code /device})。
|
||||
* <p>
|
||||
* 接口清单(架构 §7.2):
|
||||
* <ul>
|
||||
* <li>{@code GET /device/list} —— 分页列表(I1)</li>
|
||||
* <li>{@code GET /device/{deviceId}} —— 详情(I1)</li>
|
||||
* <li>{@code POST /device} —— 新增建档(I1)</li>
|
||||
* <li>{@code PUT /device} —— 修改(I1)</li>
|
||||
* <li>{@code GET /device/stats} —— 顶部统计(I5)</li>
|
||||
* <li>{@code DELETE /device/{ids}} —— 批量删除,已绑定拒绝(I5)</li>
|
||||
* <li>{@code POST /device/export} —— Excel 导出(I5)</li>
|
||||
* </ul>
|
||||
* OTA 相关接口见 {@link org.dromara.device.controller.OtaController}({@code /device/ota/**},I5)。
|
||||
* 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log}。
|
||||
* <p>
|
||||
* ⚠️ <b>本模块没有数据权限(部门)隔离</b>:{@code BmDeviceMapper} 未标注
|
||||
* {@link org.dromara.common.mybatis.annotation.DataPermission},而框架的
|
||||
* {@code PlusDataPermissionInterceptor} <b>必须靠该注解才会生成部门过滤条件</b> ——
|
||||
* 因此任何持有 {@code device:device:list} 的账号看到的都是<b>全量设备</b>。
|
||||
* <p>
|
||||
* 原注释写「数据权限由框架全局拦截器承担」与本模块实际情况不符(全模块 {@code grep DataPermission} 零命中),
|
||||
* 会让人误以为已经隔离,故订正。若后续确需按部门隔离,必须显式给 Mapper 加 {@code @DataPermission},
|
||||
* 不能指望「全局生效」。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/device")
|
||||
public class DeviceController extends BaseController {
|
||||
|
||||
private final IDeviceService deviceService;
|
||||
|
||||
/**
|
||||
* 查询设备列表
|
||||
*/
|
||||
@SaCheckPermission("device:device:list")
|
||||
@GetMapping("/list")
|
||||
public R<PageResult<DeviceVo>> list(@Validated(QueryGroup.class) DeviceBo bo, PageQuery pageQuery) {
|
||||
return R.ok(deviceService.queryPageList(bo, pageQuery));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备详细信息
|
||||
*
|
||||
* @param deviceId 设备主键
|
||||
*/
|
||||
@SaCheckPermission("device:device:query")
|
||||
@GetMapping("/{deviceId}")
|
||||
public R<DeviceVo> getInfo(@NotNull(message = "设备主键不能为空")
|
||||
@PathVariable("deviceId") Long deviceId) {
|
||||
return R.ok(deviceService.queryById(deviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增设备
|
||||
*/
|
||||
@SaCheckPermission("device:device:add")
|
||||
@Log(title = "设备管理", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public R<Void> add(@Validated(AddGroup.class) @RequestBody DeviceBo bo) {
|
||||
return toAjax(deviceService.insertByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改设备
|
||||
*/
|
||||
@SaCheckPermission("device:device:edit")
|
||||
@Log(title = "设备管理", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
public R<Void> edit(@Validated(EditGroup.class) @RequestBody DeviceBo bo) {
|
||||
return toAjax(deviceService.updateByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备统计(顶部统计条)。
|
||||
*
|
||||
* @return 统计({@code total / online / offline / inactive})
|
||||
*/
|
||||
@SaCheckPermission("device:device:list")
|
||||
@GetMapping("/stats")
|
||||
public R<DeviceStatsVo> stats() {
|
||||
return R.ok(deviceService.queryStats());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除设备(已绑定设备拒绝)。
|
||||
*
|
||||
* @param ids 设备主键数组(路径中以逗号分隔,如 {@code /device/1,2,3})
|
||||
*/
|
||||
@SaCheckPermission("device:device:remove")
|
||||
@Log(title = "设备管理", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@PathVariable("ids") Long[] ids) {
|
||||
return toAjax(deviceService.deleteByIds(Arrays.asList(ids)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出设备列表(与 {@link #list} 同筛选口径)。
|
||||
* <p>
|
||||
* 采用若依 6.X 的 {@link ExcelBuilder} 直接写出到响应流;入参为 {@link DeviceBo}
|
||||
* (复用列表筛选条件),以保证「顶部统计与筛选一致」。
|
||||
*
|
||||
* @param bo 查询条件(与 list 同口径)
|
||||
* @param response HTTP 响应(Excel 字节流)
|
||||
*/
|
||||
@SaCheckPermission("device:device:export")
|
||||
@Log(title = "设备管理", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/export")
|
||||
public void export(DeviceBo bo, HttpServletResponse response) {
|
||||
List<DeviceVo> list = deviceService.queryList(bo);
|
||||
ExcelBuilder.of(list, DeviceVo.class).sheetName("设备数据").toResponse(response);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package org.dromara.device.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.domain.bo.OtaBatchBo;
|
||||
import org.dromara.device.domain.bo.OtaPackageBo;
|
||||
import org.dromara.device.domain.bo.OtaTaskBo;
|
||||
import org.dromara.device.domain.vo.OtaBatchResultVo;
|
||||
import org.dromara.device.domain.vo.OtaPackageVo;
|
||||
import org.dromara.device.domain.vo.OtaTaskVo;
|
||||
import org.dromara.device.service.IOtaPackageService;
|
||||
import org.dromara.device.service.IOtaTaskService;
|
||||
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 java.util.Arrays;
|
||||
|
||||
/**
|
||||
* OTA 升级 Controller(前缀 {@code /device/ota})。
|
||||
* <p>
|
||||
* 接口清单(架构 §7.2、PRD §5 R-29~R-32):
|
||||
* <ul>
|
||||
* <li>{@code GET /device/ota/task/list} —— 升级任务列表(升级记录)</li>
|
||||
* <li>{@code GET /device/ota/package/list} —— 升级包列表</li>
|
||||
* <li>{@code POST /device/ota/package} —— 登记升级包</li>
|
||||
* <li>{@code PUT /device/ota/package} —— 修改升级包(启用 / 停用)</li>
|
||||
* <li>{@code DELETE /device/ota/package/{ids}} —— 删除升级包(逻辑删除)</li>
|
||||
* <li>{@code POST /device/ota/batch} —— 按勾选设备批量建任务并下发</li>
|
||||
* </ul>
|
||||
* 后台接口一律 {@code @SaCheckPermission}(数据权限由框架全局拦截器承担),变更类加 {@code @Log}。
|
||||
* 权限字与前端 {@code bird_monitor-ui/src/api/device/ota} 对齐:查询用 {@code device:ota:list},
|
||||
* 其余变更用 {@code device:ota:add}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/device/ota")
|
||||
public class OtaController extends BaseController {
|
||||
|
||||
private final IOtaPackageService otaPackageService;
|
||||
private final IOtaTaskService otaTaskService;
|
||||
|
||||
/**
|
||||
* 查询升级任务列表(升级记录)。
|
||||
*
|
||||
* @param bo 查询条件(deviceNo / version / status / keyword)
|
||||
* @param pageQuery 分页参数
|
||||
* @return 任务分页结果
|
||||
*/
|
||||
@SaCheckPermission("device:ota:list")
|
||||
@GetMapping("/task/list")
|
||||
public R<PageResult<OtaTaskVo>> taskList(OtaTaskBo bo, PageQuery pageQuery) {
|
||||
return R.ok(otaTaskService.queryPageList(bo, pageQuery));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询升级包列表。
|
||||
*
|
||||
* @param bo 查询条件(model / version / status / keyword)
|
||||
* @param pageQuery 分页参数
|
||||
* @return 升级包分页结果
|
||||
*/
|
||||
@SaCheckPermission("device:ota:list")
|
||||
@GetMapping("/package/list")
|
||||
public R<PageResult<OtaPackageVo>> packageList(OtaPackageBo bo, PageQuery pageQuery) {
|
||||
return R.ok(otaPackageService.queryPageList(bo, pageQuery));
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记 OTA 升级包。
|
||||
*
|
||||
* @param bo 升级包业务对象
|
||||
* @return 操作结果
|
||||
*/
|
||||
@SaCheckPermission("device:ota:add")
|
||||
@Log(title = "OTA升级包", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/package")
|
||||
public R<Void> addPackage(@RequestBody OtaPackageBo bo) {
|
||||
return toAjax(otaPackageService.insertByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 OTA 升级包(含启用 / 停用)。
|
||||
*
|
||||
* @param bo 升级包业务对象(含 packageId)
|
||||
* @return 操作结果
|
||||
*/
|
||||
@SaCheckPermission("device:ota:add")
|
||||
@Log(title = "OTA升级包", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/package")
|
||||
public R<Void> updatePackage(@RequestBody OtaPackageBo bo) {
|
||||
return toAjax(otaPackageService.updateByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OTA 升级包(逻辑删除)。
|
||||
*
|
||||
* @param ids 升级包主键数组(路径中以逗号分隔,如 {@code /device/ota/package/1,2,3})
|
||||
* @return 操作结果
|
||||
*/
|
||||
@SaCheckPermission("device:ota:add")
|
||||
@Log(title = "OTA升级包", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/package/{ids}")
|
||||
public R<Void> removePackage(@PathVariable("ids") Long[] ids) {
|
||||
return toAjax(otaPackageService.deleteByIds(Arrays.asList(ids)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按勾选设备批量创建升级任务并下发。
|
||||
* <p>
|
||||
* 返回 {total / created / skipped / failed / messages},其中 {@code total == created + skipped + failed}。
|
||||
* 逐台处置详情见 {@link IOtaTaskService#batchOta(OtaBatchBo)}。
|
||||
*
|
||||
* @param bo 批量入参(packageId + deviceIds)
|
||||
* @return 批量结果
|
||||
*/
|
||||
@SaCheckPermission("device:ota:add")
|
||||
@Log(title = "OTA升级任务", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/batch")
|
||||
public R<OtaBatchResultVo> batch(@Validated @RequestBody OtaBatchBo bo) {
|
||||
return R.ok(otaTaskService.batchOta(bo));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.dromara.device.controller.app;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.domain.bo.AppEmailCodeBo;
|
||||
import org.dromara.device.domain.bo.AppEmailCodeLoginBo;
|
||||
import org.dromara.device.domain.bo.AppEmailLoginBo;
|
||||
import org.dromara.device.domain.bo.AppEmailRegisterBo;
|
||||
import org.dromara.device.domain.bo.AppEmailResetPwdBo;
|
||||
import org.dromara.device.domain.bo.AppLoginBo;
|
||||
import org.dromara.device.domain.bo.AppRegisterBo;
|
||||
import org.dromara.device.domain.bo.AppResetPwdBo;
|
||||
import org.dromara.device.domain.bo.AppSmsCodeBo;
|
||||
import org.dromara.device.domain.bo.AppSmsLoginBo;
|
||||
import org.dromara.device.domain.vo.AppTokenVo;
|
||||
import org.dromara.device.service.IAppAuthService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
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;
|
||||
|
||||
/**
|
||||
* APP 账号 Controller(前缀 {@code /app/auth})。
|
||||
* <p>
|
||||
* 本组接口为登录类,统一 {@link SaIgnore} 放行(与框架 {@code AuthController} 同口径);
|
||||
* 业务鉴权由后续 {@code /app/user/**}、{@code /app/device/**} 承担。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@SaIgnore
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/app/auth")
|
||||
public class AppAuthController extends BaseController {
|
||||
|
||||
private final IAppAuthService appAuthService;
|
||||
|
||||
/**
|
||||
* 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。
|
||||
*/
|
||||
@PostMapping("/sms/code")
|
||||
public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) {
|
||||
String code = appAuthService.sendSmsCode(bo);
|
||||
// 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案
|
||||
return R.ok(code == null ? AppMsgConstants.SMS_CODE_SENT : code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号注册。
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public R<Void> register(@Validated @RequestBody AppRegisterBo bo) {
|
||||
appAuthService.register(bo);
|
||||
return R.ok(AppMsgConstants.REGISTER_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码登录。
|
||||
* <p>
|
||||
* 令牌封装为 {@link AppTokenVo} 并置于响应 {@code data}(P3-1 修复)——避免 {@code R.ok(String)}
|
||||
* 被解析为「msg 重载」而把令牌塞进 {@code msg}。{@code IAppAuthService#login} 仍返回令牌字符串(架构 §5 不变)。
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public R<AppTokenVo> login(@Validated @RequestBody AppLoginBo bo) {
|
||||
return R.ok(new AppTokenVo(appAuthService.login(bo)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码登录。
|
||||
* <p>
|
||||
* 令牌封装口径同 {@link #login}(P3-1 修复)。
|
||||
*/
|
||||
@PostMapping("/sms-login")
|
||||
public R<AppTokenVo> smsLogin(@Validated @RequestBody AppSmsLoginBo bo) {
|
||||
return R.ok(new AppTokenVo(appAuthService.smsLogin(bo)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 忘记密码(重置密码)。
|
||||
*/
|
||||
@PostMapping("/reset-pwd")
|
||||
public R<Void> resetPwd(@Validated @RequestBody AppResetPwdBo bo) {
|
||||
appAuthService.resetPwd(bo);
|
||||
return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录(清除当前会话)。
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public R<Void> logout() {
|
||||
appAuthService.logout();
|
||||
return R.ok(AppMsgConstants.LOGOUT_SUCCESS);
|
||||
}
|
||||
|
||||
// ---------------- 邮箱通道(迭代:邮箱注册) ----------------
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效)。
|
||||
*/
|
||||
@PostMapping("/email/code")
|
||||
public R<Void> sendEmailCode(@Validated @RequestBody AppEmailCodeBo bo) {
|
||||
appAuthService.sendEmailCode(bo);
|
||||
return R.ok(AppMsgConstants.EMAIL_CODE_SENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱注册。
|
||||
* <p>
|
||||
* 成功文案给出「请绑定手机号」引导:按本迭代账号规则,邮箱注册后须继续绑定手机号,
|
||||
* 未绑手机号的账号无法绑定设备(服务端硬拦截,见 {@code IAppDeviceService#bind})。
|
||||
*/
|
||||
@PostMapping("/email/register")
|
||||
public R<Void> emailRegister(@Validated @RequestBody AppEmailRegisterBo bo) {
|
||||
appAuthService.registerByEmail(bo);
|
||||
return R.ok(AppMsgConstants.EMAIL_REGISTER_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱 + 密码登录。
|
||||
* <p>
|
||||
* 令牌封装口径同 {@link #login}:置于响应 {@code data},避免 {@code R.ok(String)} 被解析为 msg 重载。
|
||||
*/
|
||||
@PostMapping("/email/login")
|
||||
public R<AppTokenVo> emailLogin(@Validated @RequestBody AppEmailLoginBo bo) {
|
||||
return R.ok(new AppTokenVo(appAuthService.emailLogin(bo)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱 + 验证码登录。
|
||||
*/
|
||||
@PostMapping("/email/code-login")
|
||||
public R<AppTokenVo> emailCodeLogin(@Validated @RequestBody AppEmailCodeLoginBo bo) {
|
||||
return R.ok(new AppTokenVo(appAuthService.emailCodeLogin(bo)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱找回密码(重置密码)。
|
||||
*/
|
||||
@PostMapping("/email/reset-pwd")
|
||||
public R<Void> emailResetPwd(@Validated @RequestBody AppEmailResetPwdBo bo) {
|
||||
appAuthService.resetPwdByEmail(bo);
|
||||
return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package org.dromara.device.controller.app;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
import org.dromara.device.domain.bo.AppDeviceBindBo;
|
||||
import org.dromara.device.domain.bo.AppDeviceHoodBo;
|
||||
import org.dromara.device.domain.bo.AppDeviceNameBo;
|
||||
import org.dromara.device.domain.vo.AppDeviceDetailVo;
|
||||
import org.dromara.device.domain.vo.AppDeviceListVo;
|
||||
import org.dromara.device.domain.vo.AppHoodResultVo;
|
||||
import org.dromara.device.domain.vo.AppStreamPlayVo;
|
||||
import org.dromara.device.domain.vo.LocationVo;
|
||||
import org.dromara.device.service.IAppDeviceService;
|
||||
import org.dromara.device.service.IAppStreamService;
|
||||
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 java.util.List;
|
||||
|
||||
/**
|
||||
* APP 设备 Controller(前缀 {@code /app/device})。
|
||||
* <p>
|
||||
* 本组接口需登录态;当前用户取自登录标识 {@code app_user:{userId}}({@code LoginHelper.getUserId()}),
|
||||
* 仅能操作本人绑定设备,<b>不参与后台数据权限、不加 {@code @SaCheckPermission}</b>(《开发计划》§12,与 I2 口径一致)。
|
||||
* <p>
|
||||
* 接口清单(迭代 I3,架构 §7.1 #12–16):
|
||||
* <ul>
|
||||
* <li>{@code GET /app/device/list} —— 当前用户绑定列表</li>
|
||||
* <li>{@code GET /app/device/{deviceNo}} —— 设备详情</li>
|
||||
* <li>{@code POST /app/device/bind} —— 绑定(imei + name)</li>
|
||||
* <li>{@code PUT /app/device/{deviceNo}/name} —— 重命名</li>
|
||||
* <li>{@code DELETE /app/device/{deviceNo}} —— 解绑</li>
|
||||
* </ul>
|
||||
* 迭代 I4 增补:
|
||||
* <ul>
|
||||
* <li>{@code PUT /app/device/{deviceNo}/hood} —— 眼罩开关(阻塞等待设备回执,协议 §8.2)</li>
|
||||
* <li>{@code GET /app/device/{deviceNo}/live} —— 直播播放地址(不主动通知设备推流)</li>
|
||||
* </ul>
|
||||
* 开始/停止推流见 {@code /app/stream/**}。
|
||||
* 迭代 I5 增补:
|
||||
* <ul>
|
||||
* <li>{@code GET /app/device/{deviceNo}/location} —— 定位最新点(架构 §7.1 #18)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/app/device")
|
||||
public class AppDeviceController extends BaseController {
|
||||
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final IAppStreamService appStreamService;
|
||||
|
||||
/**
|
||||
* 当前用户绑定设备列表。
|
||||
*
|
||||
* @return 设备卡片列表
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public R<List<AppDeviceListVo>> list() {
|
||||
return R.ok(appDeviceService.listByUser(currentUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备详情(坐标、眼罩、双电量、信号、头套连接、地址)。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return 设备详情
|
||||
*/
|
||||
@GetMapping("/{deviceNo}")
|
||||
public R<AppDeviceDetailVo> detail(@PathVariable("deviceNo") String deviceNo) {
|
||||
return R.ok(appDeviceService.detail(deviceNo, currentUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定设备(扫码 / 手动添加)。
|
||||
*
|
||||
* @param bo 绑定入参(imei + name)
|
||||
* @return 成功文案
|
||||
*/
|
||||
@PostMapping("/bind")
|
||||
public R<Void> bind(@Validated @RequestBody AppDeviceBindBo bo) {
|
||||
appDeviceService.bind(bo, currentUserId());
|
||||
return R.ok(DeviceMsgConstants.BIND_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名设备(设备编号取自路径)。
|
||||
*
|
||||
* @param deviceNo 设备编号(路径变量,回填至 BO)
|
||||
* @param bo 重命名入参(name)
|
||||
* @return 成功文案
|
||||
*/
|
||||
@PutMapping("/{deviceNo}/name")
|
||||
public R<Void> rename(@PathVariable("deviceNo") String deviceNo,
|
||||
@Validated @RequestBody AppDeviceNameBo bo) {
|
||||
bo.setDeviceNo(deviceNo);
|
||||
appDeviceService.rename(bo, currentUserId());
|
||||
return R.ok(DeviceMsgConstants.RENAME_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑设备(当前用户)。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return 成功文案
|
||||
*/
|
||||
@DeleteMapping("/{deviceNo}")
|
||||
public R<Void> unbind(@PathVariable("deviceNo") String deviceNo) {
|
||||
appDeviceService.unbind(deviceNo, currentUserId());
|
||||
return R.ok(DeviceMsgConstants.UNBIND_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 眼罩开关(遮盖 / 打开)。
|
||||
* <p>
|
||||
* <b>本接口会阻塞约一个 RTT + 设备动作时间</b>(最长 {@code device.mqtt.hood-timeout},默认 5s),
|
||||
* 直到设备回执 {@code ack.ok=true} 才返回成功——协议 §8.2 的唯一成功凭据。
|
||||
* 失败(离线 / 低电 / 超时 / 设备拒绝)统一以业务异常返回,前端按提示文案处理即可。
|
||||
*
|
||||
* @param deviceNo 设备编号(路径变量,回填至 BO)
|
||||
* @param bo 眼罩入参(coverOn)
|
||||
* @return 设备确认执行后的眼罩状态
|
||||
*/
|
||||
@PutMapping("/{deviceNo}/hood")
|
||||
public R<AppHoodResultVo> hood(@PathVariable("deviceNo") String deviceNo,
|
||||
@Validated @RequestBody AppDeviceHoodBo bo) {
|
||||
bo.setDeviceNo(deviceNo);
|
||||
return R.ok(appDeviceService.hood(bo, currentUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位最新点(给「查看地图」用,架构 §7.1 #18)。
|
||||
* <p>
|
||||
* 越权隔离与 {@link #detail} 完全同口径:非本人设备统一「设备不存在」。
|
||||
* 设备从未上报定位时返回<b>空 VO</b>({@code lng}/{@code lat} 为 {@code null}),<b>不抛异常</b>——
|
||||
* APP 需要一个可展示的空态(见 {@link LocationVo})。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return 定位视图对象(坐标可能为 {@code null})
|
||||
*/
|
||||
@GetMapping("/{deviceNo}/location")
|
||||
public R<LocationVo> location(@PathVariable("deviceNo") String deviceNo) {
|
||||
return R.ok(appDeviceService.location(deviceNo, currentUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取直播播放地址(不通知设备推流)。
|
||||
* <p>
|
||||
* 需要设备已经在推、或随后调用 {@code POST /app/stream/start}。
|
||||
* 路径写成 {@code /{deviceNo}/live},避免被 {@code GET /{deviceNo}} 吞掉 {@code /app/device/live}。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return 带 token 的播放地址
|
||||
*/
|
||||
@GetMapping("/{deviceNo}/live")
|
||||
public R<AppStreamPlayVo> live(@PathVariable("deviceNo") String deviceNo) {
|
||||
return R.ok(appStreamService.playUrl(deviceNo, currentUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户主键,未登录时抛出受控异常。
|
||||
*
|
||||
* @return 当前用户主键
|
||||
*/
|
||||
private Long currentUserId() {
|
||||
Long userId = LoginHelper.getUserId();
|
||||
if (userId == null) {
|
||||
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.dromara.device.controller.app;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.domain.bo.AppPasswordBo;
|
||||
import org.dromara.device.domain.bo.AppPhoneBo;
|
||||
import org.dromara.device.domain.vo.AppAgreementVo;
|
||||
import org.dromara.device.domain.vo.AppUserProfileVo;
|
||||
import org.dromara.device.service.IAppUserService;
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* APP 个人中心 Controller(前缀 {@code /app/user})。
|
||||
* <p>
|
||||
* 本组接口需登录态;当前用户取自 {@code LoginHelper.getUserId()}(登录标识 {@code app_user:{userId}}),
|
||||
* 仅能操作本人数据,不做后台数据权限。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/app/user")
|
||||
public class AppUserController extends BaseController {
|
||||
|
||||
private final IAppUserService appUserService;
|
||||
|
||||
/**
|
||||
* 获取当前用户资料(手机号脱敏)。
|
||||
*/
|
||||
@GetMapping("/profile")
|
||||
public R<AppUserProfileVo> profile() {
|
||||
return R.ok(appUserService.profile());
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(旧密码 + 新密码)。
|
||||
*/
|
||||
@PutMapping("/password")
|
||||
public R<Void> updatePassword(@Validated @RequestBody AppPasswordBo bo) {
|
||||
appUserService.updatePassword(bo);
|
||||
return R.ok(AppMsgConstants.PASSWORD_UPDATE_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 换绑手机号(新手机号 + 验证码)。
|
||||
*/
|
||||
@PutMapping("/phone")
|
||||
public R<Void> rebindPhone(@Validated @RequestBody AppPhoneBo bo) {
|
||||
appUserService.rebindPhone(bo);
|
||||
return R.ok(AppMsgConstants.PHONE_UPDATE_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销账号(先解绑全部设备,再停用账号)。
|
||||
*/
|
||||
@DeleteMapping("/account")
|
||||
public R<Void> cancelAccount() {
|
||||
appUserService.cancelAccount();
|
||||
return R.ok(AppMsgConstants.CANCEL_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询协议内容(URL + 版本 + 更新时间)。
|
||||
*
|
||||
* @param type 协议类型:user / privacy
|
||||
*/
|
||||
@GetMapping("/agreements")
|
||||
public R<AppAgreementVo> agreements(@RequestParam("type") String type) {
|
||||
return R.ok(appUserService.agreements(type));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备主表对象 bm_device
|
||||
* <p>
|
||||
* 说明:本表不含任何直播字段(无 stream_url / play_url / live_*)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("bm_device")
|
||||
public class BmDevice extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花,仅内部关联,不对外暴露)
|
||||
*/
|
||||
@TableId(value = "device_id")
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 业务编号(唯一,对外主展示键)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 绑定码 IMEI(唯一,APP 绑定键)
|
||||
*/
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 用户侧名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 型号
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 出厂序列号
|
||||
*/
|
||||
private String sn;
|
||||
|
||||
/**
|
||||
* 硬件版本
|
||||
*/
|
||||
private String hwVersion;
|
||||
|
||||
/**
|
||||
* 软件版本
|
||||
*/
|
||||
private String swVersion;
|
||||
|
||||
/**
|
||||
* 固件版本
|
||||
*/
|
||||
private String fwVersion;
|
||||
|
||||
/**
|
||||
* 数据包版本
|
||||
*/
|
||||
private String pkgVersion;
|
||||
|
||||
/**
|
||||
* 设备 IP
|
||||
*/
|
||||
private String ip;
|
||||
|
||||
/**
|
||||
* MAC
|
||||
*/
|
||||
private String mac;
|
||||
|
||||
/**
|
||||
* WiFi SSID
|
||||
*/
|
||||
private String wifiSsid;
|
||||
|
||||
/**
|
||||
* 状态(0 未激活 1 在线 2 离线)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 当前绑定 APP 用户
|
||||
*/
|
||||
private Long bindUserId;
|
||||
|
||||
/**
|
||||
* 最近一次绑定时间
|
||||
*/
|
||||
private LocalDateTime bindTime;
|
||||
|
||||
/**
|
||||
* 头套连接(0 断开 1 已连接)
|
||||
*/
|
||||
private String hoodConnected;
|
||||
|
||||
/**
|
||||
* 眼罩状态(0 打开 1 遮盖)
|
||||
*/
|
||||
private String hoodCoverOn;
|
||||
|
||||
/**
|
||||
* 信号等级(0 离线 1 弱 2 一般 3 强)
|
||||
*/
|
||||
private Integer signalLevel;
|
||||
|
||||
/**
|
||||
* 头套电量 0-100
|
||||
*/
|
||||
private Integer hoodBattery;
|
||||
|
||||
/**
|
||||
* 脚环电量 0-100
|
||||
*/
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 头套低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发,即对该设备关闭低电保护)
|
||||
*/
|
||||
private Integer hoodBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 脚环低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发)
|
||||
*/
|
||||
private Integer ringBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
private BigDecimal lng;
|
||||
|
||||
/**
|
||||
* 最新纬度
|
||||
*/
|
||||
private BigDecimal lat;
|
||||
|
||||
/**
|
||||
* 地址(设备上报)
|
||||
*/
|
||||
private String address;
|
||||
|
||||
/**
|
||||
* 最后在线时间
|
||||
*/
|
||||
private LocalDateTime lastOnlineTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 逻辑删除(0 存在 1 删除)
|
||||
*/
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备告警对象 bm_device_alert
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("bm_device_alert")
|
||||
public class BmDeviceAlert implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
@TableId(value = "alert_id")
|
||||
private Long alertId;
|
||||
|
||||
/**
|
||||
* 设备主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 告警类型(offline / low_battery_hood / low_battery_ring)
|
||||
*/
|
||||
private String alertType;
|
||||
|
||||
/**
|
||||
* 告警级别(0 提示 1 警告 2 严重)
|
||||
*/
|
||||
private String alertLevel;
|
||||
|
||||
/**
|
||||
* 触发值(如电量)
|
||||
*/
|
||||
private String alertValue;
|
||||
|
||||
/**
|
||||
* 告警文案
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 处理状态(0 未处理 1 已处理,一期只读展示)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 告警时间
|
||||
*/
|
||||
private LocalDateTime alertTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备绑定流水对象 bm_device_bind
|
||||
* <p>
|
||||
* 说明:流水表不设逻辑删除(禁止抹除历史)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("bm_device_bind")
|
||||
public class BmDeviceBind implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
@TableId(value = "bind_id")
|
||||
private Long bindId;
|
||||
|
||||
/**
|
||||
* 设备物理主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余,便于审计/后台展示)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 操作 APP 用户
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* IMEI(冗余快照)
|
||||
*/
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 动作(bind / unbind / rebind)
|
||||
*/
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 操作时间
|
||||
*/
|
||||
private LocalDateTime bindTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备遥测历史对象 bm_device_telemetry
|
||||
* <p>
|
||||
* 说明:一期仅落库(INSERT),不做轨迹回放;不含任何直播字段。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("bm_device_telemetry")
|
||||
public class BmDeviceTelemetry implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
@TableId(value = "telemetry_id")
|
||||
private Long telemetryId;
|
||||
|
||||
/**
|
||||
* 设备主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 头套电量 0-100
|
||||
*/
|
||||
private Integer hoodBattery;
|
||||
|
||||
/**
|
||||
* 脚环电量 0-100
|
||||
*/
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 信号等级(0 离线 1 弱 2 一般 3 强)
|
||||
*/
|
||||
private Integer signalLevel;
|
||||
|
||||
/**
|
||||
* 头套连接(0 断开 1 已连接)
|
||||
*/
|
||||
private String hoodConnected;
|
||||
|
||||
/**
|
||||
* 眼罩状态(0 打开 1 遮盖)
|
||||
*/
|
||||
private String hoodCoverOn;
|
||||
|
||||
/**
|
||||
* 经度
|
||||
*/
|
||||
private BigDecimal lng;
|
||||
|
||||
/**
|
||||
* 纬度
|
||||
*/
|
||||
private BigDecimal lat;
|
||||
|
||||
/**
|
||||
* 地址
|
||||
*/
|
||||
private String address;
|
||||
|
||||
/**
|
||||
* 设备上报时间
|
||||
*/
|
||||
private LocalDateTime reportTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* OTA 升级包对象 bm_ota_package
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("bm_ota_package")
|
||||
public class BmOtaPackage extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
@TableId(value = "package_id")
|
||||
private Long packageId;
|
||||
|
||||
/**
|
||||
* 适用型号
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 关联 sys_oss.oss_id
|
||||
*/
|
||||
private Long ossId;
|
||||
|
||||
/**
|
||||
* 文件 URL(冗余)
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 文件名
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 文件字节数
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 校验值 MD5
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* 更新说明
|
||||
*/
|
||||
private String changelog;
|
||||
|
||||
/**
|
||||
* 状态(0 停用 1 启用)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 逻辑删除(0 存在 1 删除)
|
||||
*/
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* OTA 升级任务对象 bm_ota_task
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("bm_ota_task")
|
||||
public class BmOtaTask extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
@TableId(value = "task_id")
|
||||
private Long taskId;
|
||||
|
||||
/**
|
||||
* 升级包主键
|
||||
*/
|
||||
private Long packageId;
|
||||
|
||||
/**
|
||||
* 版本号(冗余)
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 设备主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 状态(0 待升级 1 升级中 2 成功 3 失败)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 结果信息
|
||||
*/
|
||||
private String resultMsg;
|
||||
|
||||
/**
|
||||
* 进度 0-100
|
||||
*/
|
||||
private Integer progress;
|
||||
|
||||
/**
|
||||
* 操作人(后台用户)
|
||||
*/
|
||||
private Long operatorId;
|
||||
|
||||
/**
|
||||
* 下发次数
|
||||
*/
|
||||
private Integer times;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private LocalDateTime startTime;
|
||||
|
||||
/**
|
||||
* 完成时间
|
||||
*/
|
||||
private LocalDateTime finishTime;
|
||||
|
||||
/**
|
||||
* 逻辑删除(0 存在 1 删除)
|
||||
*/
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 设备绑定入参({@code POST /app/device/bind})。
|
||||
* <p>
|
||||
* 字段口径对齐《开发计划》§8.2:入参 {@code imei} + {@code name}(≤20)。
|
||||
* <ul>
|
||||
* <li>{@code imei}:设备绑定码,默认 15 位数字({@link AppConstants#IMEI_REGEX})。</li>
|
||||
* <li>{@code name}:用户侧设备名称,按<b>字符数</b>限制 ≤ {@link AppConstants#DEVICE_NAME_MAX_LENGTH},
|
||||
* 中文按 1 字计({@link Size} 以 {@link CharSequence#length()} 计,非 UTF-8 字节数)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppDeviceBindBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备 IMEI(绑定码,默认 15 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "IMEI 不能为空")
|
||||
@Pattern(regexp = AppConstants.IMEI_REGEX, message = "IMEI 格式不正确")
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 用户侧设备名称(≤20 个字符)。
|
||||
*/
|
||||
@NotBlank(message = "设备名称不能为空")
|
||||
@Size(max = AppConstants.DEVICE_NAME_MAX_LENGTH, message = "设备名称不能超过 20 个字")
|
||||
private String name;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 眼罩开关入参({@code PUT /app/device/{deviceNo}/hood})。
|
||||
* <p>
|
||||
* {@code deviceNo} 由路径变量提供,Controller 回填到本 BO(与 {@code AppDeviceNameBo} 同一约定),
|
||||
* <b>该字段不做 Bean Validation</b>:Spring 在参数解析阶段完成 {@code @RequestBody} 校验,
|
||||
* 彼时路径回填尚未发生、字段恒为 null。空值由服务层兜底,统一按「设备不存在」处理。
|
||||
* <p>
|
||||
* {@code coverOn} 必须显式传值——不接受「缺省即打开」:眼罩是物理器械,
|
||||
* 请求体漏字段时服务端必须报错而不是替用户猜一个动作。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppDeviceHoodBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号(由 Controller 从路径变量 {@code {deviceNo}} 回填)。
|
||||
* <p>不做 Bean Validation(见类注释);空值由服务层兜底。
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 是否遮盖:{@code true} 遮盖 / {@code false} 打开。
|
||||
*/
|
||||
@NotNull(message = "眼罩状态不能为空")
|
||||
private Boolean coverOn;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 设备重命名入参({@code PUT /app/device/{deviceNo}/name})。
|
||||
* <p>
|
||||
* {@code deviceNo} 由路径变量提供,Controller 会将其回填到本 BO 的 {@code deviceNo} 字段,
|
||||
* 以保证服务层签名 {@code rename(AppDeviceNameBo bo, Long userId)} 自洽。
|
||||
* <b>该字段不做 Bean Validation</b>:Spring 在参数解析阶段即完成 {@code @RequestBody} 校验,
|
||||
* 彼时路径回填尚未发生、字段恒为 null,若加校验会导致「按契约(body 仅 name)调用」恒失败;
|
||||
* 空值改由服务层兜底,统一按「设备不存在」处理(与越权隔离同一文案)。
|
||||
* {@code name} 按<b>字符数</b>限制 ≤ {@link AppConstants#DEVICE_NAME_MAX_LENGTH}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppDeviceNameBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号(由 Controller 从路径变量 {@code {deviceNo}} 回填)。
|
||||
* <p>不做 Bean Validation(见类注释);空值由 {@code AppDeviceServiceImpl#rename} 兜底。
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 新的用户侧设备名称(≤20 个字符)。
|
||||
*/
|
||||
@NotBlank(message = "设备名称不能为空")
|
||||
@Size(max = AppConstants.DEVICE_NAME_MAX_LENGTH, message = "设备名称不能超过 20 个字")
|
||||
private String name;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 发送邮箱验证码入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppEmailCodeBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 邮箱地址(长度上限对齐 {@code sys_user.email varchar(50)})。
|
||||
*/
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
|
||||
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 场景:register / login / resetPwd。
|
||||
*/
|
||||
@NotBlank(message = "验证码场景不能为空")
|
||||
private String scene;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 邮箱验证码登录入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppEmailCodeLoginBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 邮箱地址。
|
||||
*/
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
|
||||
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 邮箱验证码(6 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 邮箱密码登录入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppEmailLoginBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 邮箱地址。
|
||||
*/
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
|
||||
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 密码。
|
||||
*/
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.common.core.xss.Xss;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 邮箱注册入参。
|
||||
* <p>
|
||||
* <b>不含手机号</b>:按本迭代账号规则「注册后仍须绑手机号」,邮箱注册先建号
|
||||
* ({@code phone_number} 为 {@code NULL}),再由 APP 引导调用 {@code PUT /app/user/phone}
|
||||
* 完成绑定;未绑手机号的账号<b>无法绑定设备</b>(服务端硬拦截)。
|
||||
* <p>
|
||||
* 协议勾选态由 APP 前端把关,服务端不落库(按《开发计划》§8.1)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppEmailRegisterBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 邮箱地址。
|
||||
*/
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
|
||||
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 邮箱验证码(6 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 密码(8-20 位,含大小写字母和数字)。
|
||||
*/
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 昵称(选填,为空时默认取邮箱)。
|
||||
*/
|
||||
@Xss(message = "昵称不能包含脚本字符")
|
||||
@Size(max = 30, message = "昵称长度不能超过{max}个字符")
|
||||
private String nickName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 邮箱找回密码(重置密码)入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppEmailResetPwdBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 邮箱地址。
|
||||
*/
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
|
||||
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 邮箱验证码(6 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 新密码(8-20 位,含大小写字母和数字)。
|
||||
*/
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
|
||||
private String newPassword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 密码登录入参。
|
||||
* <p>
|
||||
* 登录仅校验密码非空(历史密码可能不符合新密码规则,不在登录时做格式拦截)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppLoginBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 密码。
|
||||
*/
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 修改密码入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppPasswordBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 原密码。
|
||||
*/
|
||||
@NotBlank(message = "原密码不能为空")
|
||||
private String oldPassword;
|
||||
|
||||
/**
|
||||
* 新密码(8-20 位,含大小写字母和数字)。
|
||||
*/
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
|
||||
private String newPassword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 换绑手机入参。
|
||||
* <p>
|
||||
* 验证码发送到<b>新手机号</b>(scene = rebind)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppPhoneBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 新手机号(大陆 11 位)。
|
||||
*/
|
||||
@NotBlank(message = "新手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
private String newPhoneNumber;
|
||||
|
||||
/**
|
||||
* 短信验证码(6 位数字,发送至新手机号)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.common.core.xss.Xss;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 手机号注册入参。
|
||||
* <p>
|
||||
* 协议勾选态由 APP 前端把关,服务端不落库(按《开发计划》§8.1)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppRegisterBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 短信验证码(6 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 密码(8-20 位,含大小写字母和数字)。
|
||||
*/
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 昵称(选填,为空时默认取手机号)。
|
||||
*/
|
||||
@Xss(message = "昵称不能包含脚本字符")
|
||||
@Size(max = 30, message = "昵称长度不能超过{max}个字符")
|
||||
private String nickName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 忘记密码(重置密码)入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppResetPwdBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 短信验证码(6 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 新密码(8-20 位,含大小写字母和数字)。
|
||||
*/
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
|
||||
private String newPassword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 发送短信验证码入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppSmsCodeBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 场景:login / register / resetPwd / rebind。
|
||||
*/
|
||||
@NotBlank(message = "验证码场景不能为空")
|
||||
private String scene;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 短信验证码登录入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppSmsLoginBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 短信验证码(6 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 开始推流入参({@code POST /app/stream/start})。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppStreamStartBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
@NotBlank(message = "设备编号不能为空")
|
||||
private String deviceNo;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* APP 用户管理查询入参({@code GET /device/app-user/list})。
|
||||
* <p>
|
||||
* 仅面向 APP 用户({@code sys_user.user_type = app_user})——类型条件由服务端强制附加,
|
||||
* 前端<b>不需要</b>也无法通过本入参覆盖。
|
||||
* <p>
|
||||
* 分页字段({@code pageNum} / {@code pageSize} / {@code orderByColumn} / {@code isAsc})
|
||||
* 继承自 {@link PageQuery},与框架其余列表页口径一致。
|
||||
* <p>
|
||||
* <b>日期区间经 {@link #params} 传递</b>:前端用 {@code useDateRangeQuery} hook
|
||||
* (内部 {@code utils/ruoyi.ts#addDateRange})把区间写为 {@code params[beginTime]} /
|
||||
* {@code params[endTime]},与框架既有约定一致。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AppUserAdminQueryBo extends PageQuery {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 关键词:模糊匹配账号 / 昵称 / 手机号 / 邮箱(四字段 OR)。
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
/**
|
||||
* 账号状态(0 正常 1 停用);留空查询全部。
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:注册时间区间)。
|
||||
*/
|
||||
private Map<String, Object> params = new HashMap<>();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 用户重置密码入参({@code PUT /device/app-user/reset-pwd})。
|
||||
* <p>
|
||||
* 密码规则与 APP 端注册 / 改密<b>完全同源</b>({@link AppConstants#PASSWORD_REGEX}):
|
||||
* 管理员在后台设的密码,必须同样满足 APP 端的登录前置要求,
|
||||
* 否则会出现「后台设置成功、用户却因密码不合规被 APP 拒」的割裂体验。
|
||||
* <p>
|
||||
* <b>不引入「确认密码」字段</b>:二次输入属前端交互,服务端只需最终值。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppUserResetPwdBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 用户主键
|
||||
*/
|
||||
@NotNull(message = "用户主键不能为空")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 新密码(8-20 位,含大小写字母和数字)
|
||||
*/
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
|
||||
private String newPassword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 用户状态变更入参({@code PUT /device/app-user/status})。
|
||||
* <p>
|
||||
* {@code status} 用正则而非枚举校验:字典 {@code sys_user.status} 为 {@code varchar(1)},
|
||||
* 取值仅 {@code 0}(正常)/ {@code 1}(停用),正则能同时挡住空串与超长值。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppUserStatusBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 用户主键
|
||||
*/
|
||||
@NotNull(message = "用户主键不能为空")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 目标状态(0 正常 1 停用)
|
||||
*/
|
||||
@NotBlank(message = "账号状态不能为空")
|
||||
@Pattern(regexp = "^[01]$", message = "账号状态只能为 0(正常)或 1(停用)")
|
||||
private String status;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.domain.BmDeviceBind;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备绑定流水业务对象 bm_device_bind
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = BmDeviceBind.class, reverseConvertGenerate = false)
|
||||
public class DeviceBindBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
private Long bindId;
|
||||
|
||||
/**
|
||||
* 设备物理主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 操作 APP 用户
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* IMEI(冗余快照)
|
||||
*/
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 动作(bind / unbind / rebind)
|
||||
*/
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 操作时间
|
||||
*/
|
||||
private LocalDateTime bindTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 关键字(模糊匹配 deviceNo / imei,查询用)
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.device.domain.BmDevice;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备主表业务对象 bm_device
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = BmDevice.class, reverseConvertGenerate = false)
|
||||
public class DeviceBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@NotNull(message = "设备主键不能为空", groups = {EditGroup.class})
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 业务编号(留空时由服务端生成 BM+yyyyMMdd+4位序号)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 绑定码 IMEI
|
||||
*/
|
||||
@NotBlank(message = "IMEI 不能为空", groups = {AddGroup.class, EditGroup.class})
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 用户侧名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 型号
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 出厂序列号
|
||||
*/
|
||||
private String sn;
|
||||
|
||||
/**
|
||||
* 硬件版本
|
||||
*/
|
||||
private String hwVersion;
|
||||
|
||||
/**
|
||||
* 软件版本
|
||||
*/
|
||||
private String swVersion;
|
||||
|
||||
/**
|
||||
* 固件版本
|
||||
*/
|
||||
private String fwVersion;
|
||||
|
||||
/**
|
||||
* 数据包版本
|
||||
*/
|
||||
private String pkgVersion;
|
||||
|
||||
/**
|
||||
* 设备 IP
|
||||
*/
|
||||
private String ip;
|
||||
|
||||
/**
|
||||
* MAC
|
||||
*/
|
||||
private String mac;
|
||||
|
||||
/**
|
||||
* WiFi SSID
|
||||
*/
|
||||
private String wifiSsid;
|
||||
|
||||
/**
|
||||
* 状态(0 未激活 1 在线 2 离线)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 当前绑定 APP 用户
|
||||
*/
|
||||
private Long bindUserId;
|
||||
|
||||
/**
|
||||
* 最近一次绑定时间
|
||||
*/
|
||||
private LocalDateTime bindTime;
|
||||
|
||||
/**
|
||||
* 头套连接(0 断开 1 已连接)
|
||||
*/
|
||||
private String hoodConnected;
|
||||
|
||||
/**
|
||||
* 眼罩状态(0 打开 1 遮盖)
|
||||
*/
|
||||
private String hoodCoverOn;
|
||||
|
||||
/**
|
||||
* 信号等级(0 离线 1 弱 2 一般 3 强)
|
||||
*/
|
||||
private Integer signalLevel;
|
||||
|
||||
/**
|
||||
* 头套电量 0-100
|
||||
*/
|
||||
private Integer hoodBattery;
|
||||
|
||||
/**
|
||||
* 脚环电量 0-100
|
||||
*/
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 头套低电告警阈值 0-100(电量 ≤ 该值触发告警;0 表示不触发)
|
||||
* <p>
|
||||
* 恢复阈值不在此配置:由服务端按「触发阈值 + 回差」派生
|
||||
* (见 {@code DeviceMqttProperties#resolveRecoverThreshold(int)}),
|
||||
* 避免出现「恢复阈值 ≤ 触发阈值」把设备卡死在告警态的配置。
|
||||
*/
|
||||
@Min(value = 0, message = "头套低电阈值不能小于 0", groups = {AddGroup.class, EditGroup.class})
|
||||
@Max(value = 100, message = "头套低电阈值不能大于 100", groups = {AddGroup.class, EditGroup.class})
|
||||
private Integer hoodBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 脚环低电告警阈值 0-100(电量 ≤ 该值触发告警;0 表示不触发)
|
||||
*/
|
||||
@Min(value = 0, message = "脚环低电阈值不能小于 0", groups = {AddGroup.class, EditGroup.class})
|
||||
@Max(value = 100, message = "脚环低电阈值不能大于 100", groups = {AddGroup.class, EditGroup.class})
|
||||
private Integer ringBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
private BigDecimal lng;
|
||||
|
||||
/**
|
||||
* 最新纬度
|
||||
*/
|
||||
private BigDecimal lat;
|
||||
|
||||
/**
|
||||
* 地址
|
||||
*/
|
||||
private String address;
|
||||
|
||||
/**
|
||||
* 最后在线时间
|
||||
*/
|
||||
private LocalDateTime lastOnlineTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 关键字(模糊匹配 deviceNo / name / imei,查询用)
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OTA 批量创建升级任务入参({@code POST /device/ota/batch},PRD §5 R-31)。
|
||||
* <p>
|
||||
* 字段名严格对齐前端 {@code types.ts} 的 {@code OtaBatchForm}({@code packageId} / {@code deviceIds})。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class OtaBatchBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 目标升级包主键(必填)
|
||||
*/
|
||||
@NotNull(message = "升级包不能为空")
|
||||
private Long packageId;
|
||||
|
||||
/**
|
||||
* 勾选的设备主键集合(必填非空)
|
||||
*/
|
||||
@NotEmpty(message = "请至少选择一台设备")
|
||||
private List<Long> deviceIds;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.domain.BmOtaPackage;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* OTA 升级包业务对象 bm_ota_package
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = BmOtaPackage.class, reverseConvertGenerate = false)
|
||||
public class OtaPackageBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
private Long packageId;
|
||||
|
||||
/**
|
||||
* 适用型号
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 关联 sys_oss.oss_id
|
||||
*/
|
||||
private Long ossId;
|
||||
|
||||
/**
|
||||
* 文件 URL(冗余)
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 文件名
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 文件字节数
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 校验值 MD5
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* 更新说明
|
||||
*/
|
||||
private String changelog;
|
||||
|
||||
/**
|
||||
* 状态(0 停用 1 启用)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 关键字(模糊匹配 model / version,查询用)
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.domain.BmOtaTask;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* OTA 升级任务业务对象 bm_ota_task
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = BmOtaTask.class, reverseConvertGenerate = false)
|
||||
public class OtaTaskBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
private Long taskId;
|
||||
|
||||
/**
|
||||
* 升级包主键
|
||||
*/
|
||||
private Long packageId;
|
||||
|
||||
/**
|
||||
* 版本号(冗余)
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 设备主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 状态(0 待升级 1 升级中 2 成功 3 失败)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 结果信息
|
||||
*/
|
||||
private String resultMsg;
|
||||
|
||||
/**
|
||||
* 进度 0-100
|
||||
*/
|
||||
private Integer progress;
|
||||
|
||||
/**
|
||||
* 操作人(后台用户)
|
||||
*/
|
||||
private Long operatorId;
|
||||
|
||||
/**
|
||||
* 下发次数
|
||||
*/
|
||||
private Integer times;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private LocalDateTime startTime;
|
||||
|
||||
/**
|
||||
* 完成时间
|
||||
*/
|
||||
private LocalDateTime finishTime;
|
||||
|
||||
/**
|
||||
* 关键字(模糊匹配 deviceNo / version,查询用)
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 协议(用户协议 / 隐私政策)视图对象。
|
||||
* <p>
|
||||
* 内容来源为配置项 {@code device.app.agreement.*}(见 {@code AppProperties.Agreement}),
|
||||
* 返回「URL + 版本 + 更新时间」,不落地协议勾选态。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppAgreementVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 协议类型:user(用户协议)/ privacy(隐私政策)。
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 协议 URL(或富文本地址)。
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 协议版本。
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 协议更新时间。
|
||||
*/
|
||||
private String updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* APP 设备详情视图对象({@code GET /app/device/{deviceNo}})。
|
||||
* <p>
|
||||
* 范围对齐《开发计划》§8.2「详情:坐标、眼罩、双电量、信号、头套连接、地址」:
|
||||
* 在列表卡片字段基础上<b>增补眼罩状态</b>({@code hoodCoverOn}),用于详情页展示与后续眼罩开关联动。
|
||||
* <pre>
|
||||
* 坐标:lng, lat, address
|
||||
* 眼罩:hoodCoverOn
|
||||
* 双电量:hoodBattery, ringBattery
|
||||
* 信号:signalLevel, signalText
|
||||
* 头套连接:hoodConnected, hoodConnectedText
|
||||
* 身份:deviceNo, name, imei(脱敏), model, status
|
||||
* </pre>
|
||||
* 说明:{@code signalText} / {@code hoodConnectedText} 为服务端派生的展示文案;
|
||||
* {@code imei} 复用 {@code DesensitizedUtils} 脱敏后返回。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppDeviceDetailVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号(对外主展示键)。
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 用户侧设备名称。
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* IMEI(脱敏,如 1234****5678)。
|
||||
*/
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 型号。
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 设备状态(0 未激活 1 在线 2 离线)。
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 眼罩状态(0 打开 1 遮盖)。
|
||||
*/
|
||||
private String hoodCoverOn;
|
||||
|
||||
/**
|
||||
* 信号等级(0 离线 1 弱 2 一般 3 强)。
|
||||
*/
|
||||
private Integer signalLevel;
|
||||
|
||||
/**
|
||||
* 信号文案(离线 / 弱 / 一般 / 强)。
|
||||
*/
|
||||
private String signalText;
|
||||
|
||||
/**
|
||||
* 头套连接(0 已断开 1 已连接)。
|
||||
*/
|
||||
private String hoodConnected;
|
||||
|
||||
/**
|
||||
* 头套连接文案(已断开 / 已连接)。
|
||||
*/
|
||||
private String hoodConnectedText;
|
||||
|
||||
/**
|
||||
* 头套电量 0-100。
|
||||
*/
|
||||
private Integer hoodBattery;
|
||||
|
||||
/**
|
||||
* 脚环电量 0-100。
|
||||
*/
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 最新经度。
|
||||
*/
|
||||
private BigDecimal lng;
|
||||
|
||||
/**
|
||||
* 最新纬度。
|
||||
*/
|
||||
private BigDecimal lat;
|
||||
|
||||
/**
|
||||
* 地址。
|
||||
*/
|
||||
private String address;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* APP 设备列表卡片视图对象({@code GET /app/device/list})。
|
||||
* <p>
|
||||
* 字段严格对齐《开发计划》§8.2「列表卡片字段」,字段名保持一致,不新增未约定字段:
|
||||
* <pre>
|
||||
* deviceNo, name, imei(脱敏), model,
|
||||
* status, signalLevel, signalText, // 强 / 一般 / 弱 / 离线(字典 4 值)
|
||||
* hoodConnected, hoodConnectedText, // 头套已连接 / 已断开
|
||||
* hoodBattery, ringBattery,
|
||||
* lng, lat, address
|
||||
* </pre>
|
||||
* 说明:{@code signalText} / {@code hoodConnectedText} 为服务端按枚举/字典派生的展示文案;
|
||||
* {@code imei} 复用 {@code DesensitizedUtils} 脱敏后返回(不外泄完整绑定码)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppDeviceListVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号(对外主展示键)。
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 用户侧设备名称。
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* IMEI(脱敏,如 1234****5678)。
|
||||
*/
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 型号。
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 设备状态(0 未激活 1 在线 2 离线)。
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 信号等级(0 离线 1 弱 2 一般 3 强)。
|
||||
*/
|
||||
private Integer signalLevel;
|
||||
|
||||
/**
|
||||
* 信号文案(离线 / 弱 / 一般 / 强)。
|
||||
*/
|
||||
private String signalText;
|
||||
|
||||
/**
|
||||
* 头套连接(0 已断开 1 已连接)。
|
||||
*/
|
||||
private String hoodConnected;
|
||||
|
||||
/**
|
||||
* 头套连接文案(已断开 / 已连接)。
|
||||
*/
|
||||
private String hoodConnectedText;
|
||||
|
||||
/**
|
||||
* 头套电量 0-100。
|
||||
*/
|
||||
private Integer hoodBattery;
|
||||
|
||||
/**
|
||||
* 脚环电量 0-100。
|
||||
*/
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 最新经度。
|
||||
*/
|
||||
private BigDecimal lng;
|
||||
|
||||
/**
|
||||
* 最新纬度。
|
||||
*/
|
||||
private BigDecimal lat;
|
||||
|
||||
/**
|
||||
* 地址。
|
||||
*/
|
||||
private String address;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 眼罩开关结果视图对象({@code PUT /app/device/{deviceNo}/hood} 的响应体)。
|
||||
* <p>
|
||||
* 口径见《设备通信协议-一期.md》§8.2:<b>只有收到 {@code ack.ok=true} 才返回成功</b>,
|
||||
* 响应即「设备已确认执行」后的最新眼罩状态,APP 可直接据此刷新按钮,不必再拉一次详情。
|
||||
* <p>
|
||||
* 为什么返回状态而不是一句文案:眼罩是双向开关(打开 / 遮盖),
|
||||
* 返回完整状态让 APP 无需依赖本地乐观更新,弱网下也不会出现「按钮与设备实际相反」。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppHoodResultVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号。
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 眼罩状态(0 打开 1 遮盖)。
|
||||
*/
|
||||
private String hoodCoverOn;
|
||||
|
||||
/**
|
||||
* 眼罩状态文案(打开 / 遮盖)。
|
||||
*/
|
||||
private String hoodCoverOnText;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 直播播放地址(带 token 查询参数)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppStreamPlayUrlsVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* HTTP-FLV
|
||||
*/
|
||||
private String flv;
|
||||
|
||||
/**
|
||||
* HLS
|
||||
*/
|
||||
private String hls;
|
||||
|
||||
/**
|
||||
* RTMP
|
||||
*/
|
||||
private String rtmp;
|
||||
|
||||
/**
|
||||
* RTSP
|
||||
*/
|
||||
private String rtsp;
|
||||
|
||||
/**
|
||||
* WebRTC
|
||||
*/
|
||||
private String webrtc;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 直播播放信息(开始推流 / 取播放地址)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppStreamPlayVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 流名(与 deviceNo 相同)
|
||||
*/
|
||||
private String streamId;
|
||||
|
||||
/**
|
||||
* ZLM app
|
||||
*/
|
||||
private String app;
|
||||
|
||||
/**
|
||||
* 当前是否已在 ZLM 上在线
|
||||
*/
|
||||
private Boolean online;
|
||||
|
||||
/**
|
||||
* 设备推流地址
|
||||
*/
|
||||
private String pushUrl;
|
||||
|
||||
/**
|
||||
* 播放鉴权 token
|
||||
*/
|
||||
private String playToken;
|
||||
|
||||
/**
|
||||
* token 过期时间(epoch 毫秒)
|
||||
*/
|
||||
private Long expireAt;
|
||||
|
||||
/**
|
||||
* token 有效期(秒)
|
||||
*/
|
||||
private Integer expireSeconds;
|
||||
|
||||
/**
|
||||
* 各协议播放地址(已附带 token)
|
||||
*/
|
||||
private AppStreamPlayUrlsVo playUrls;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 直播流状态。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppStreamStatusVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 流名
|
||||
*/
|
||||
private String streamId;
|
||||
|
||||
/**
|
||||
* 是否在线
|
||||
*/
|
||||
private Boolean online;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 登录令牌视图对象。
|
||||
* <p>
|
||||
* 用于把访问令牌放在响应的 {@code data} 中返回(P3-1 修复)。
|
||||
* 背景:{@code R} 同时提供 {@code ok(T data)} 与 {@code ok(String msg)} 两个重载,
|
||||
* 当入参为 {@link String} 时 Java 重载决议会选中 {@code ok(String msg)},导致令牌被塞进 {@code msg}、
|
||||
* {@code data} 为 null;APP 客户端约定从 {@code data} 取业务数据,故以本对象包一层,
|
||||
* 使 {@code R.ok(vo)} 命中 {@code ok(T data)} 重载,令牌落在 {@code data} 中。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppTokenVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 访问令牌(JWT)。
|
||||
*/
|
||||
private String token;
|
||||
|
||||
public AppTokenVo() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 以访问令牌构造。
|
||||
*
|
||||
* @param token 访问令牌
|
||||
*/
|
||||
public AppTokenVo(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 用户管理视图对象(后台「APP 用户管理」列表与详情共用)。
|
||||
* <p>
|
||||
* <b>不脱敏</b>:后台管理端与 APP 端口径不同——APP 端({@code AppUserProfileVo})对手机号 / 邮箱脱敏,
|
||||
* 而管理端需要完整信息用于人工核对与客服定位,故此处保留明文,
|
||||
* 与框架 {@code GET /system/user/list} 的 {@code SysUserVo} 口径一致。
|
||||
* <p>
|
||||
* <b>不含密码</b>:密码散列不跨模块边界(见 {@code AppUserApiService} 的模块边界约定),
|
||||
* 本 VO 亦不透出任何密码相关字段。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppUserAdminVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 用户主键
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 账号(APP 账号为服务端派生的 {@code app_ + userId},非用户可见信息)
|
||||
*/
|
||||
private String userName;
|
||||
|
||||
/**
|
||||
* 昵称(手机号注册默认取手机号;邮箱注册默认取邮箱)
|
||||
*/
|
||||
private String nickName;
|
||||
|
||||
/**
|
||||
* 手机号(邮箱注册且尚未绑定时为 {@code null})
|
||||
*/
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 邮箱(手机号注册的账号为 {@code null})
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 账号状态(0 正常 1 停用)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 是否已绑定手机号。
|
||||
* <p>
|
||||
* 供管理员识别「邮箱注册但未绑手机号」的账号——这类账号<b>无法绑定设备</b>
|
||||
* (服务端硬拦截,见 {@code IAppDeviceService#bind}),是需要提醒用户补绑的对象。
|
||||
*/
|
||||
private Boolean phoneBound;
|
||||
|
||||
/**
|
||||
* 当前绑定设备数
|
||||
*/
|
||||
private Long deviceCount;
|
||||
|
||||
/**
|
||||
* 注册时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 最近更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 用户「绑定设备」条目({@code GET /device/app-user/{userId}/devices})。
|
||||
* <p>
|
||||
* 字段是对 {@code DeviceVo} 的<b>裁剪</b>:只保留管理端在「用户视角」下关心的身份与运行态信息,
|
||||
* 不含 IP / MAC / 版本号等设备运维字段(那些在「设备列表」页看更合适)。
|
||||
* <p>
|
||||
* <b>IMEI 不脱敏</b>:与后台设备列表口径一致(后台可见完整 IMEI 用于核对);
|
||||
* APP 端({@code AppDeviceListVo})才做脱敏。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class AppUserDeviceVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号(对外主展示键)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 用户侧设备名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 绑定码 IMEI
|
||||
*/
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 型号
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 设备状态(0 未激活 1 在线 2 离线)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 信号等级(0 离线 1 弱 2 一般 3 强)
|
||||
*/
|
||||
private Integer signalLevel;
|
||||
|
||||
/**
|
||||
* 头套电量 0-100
|
||||
*/
|
||||
private Integer hoodBattery;
|
||||
|
||||
/**
|
||||
* 脚环电量 0-100
|
||||
*/
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 最近一次绑定时间
|
||||
*/
|
||||
private LocalDateTime bindTime;
|
||||
|
||||
/**
|
||||
* 最后在线时间
|
||||
*/
|
||||
private LocalDateTime lastOnlineTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 个人中心视图对象。
|
||||
* <p>
|
||||
* 手机号按「全局说明」要求脱敏后返回(如 138****8000)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class AppUserProfileVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 用户账号。
|
||||
*/
|
||||
private String userName;
|
||||
|
||||
/**
|
||||
* 用户昵称。
|
||||
*/
|
||||
private String nickName;
|
||||
|
||||
/**
|
||||
* 手机号(脱敏)。
|
||||
* <p>邮箱注册且尚未绑定手机号的账号为 {@code null}。
|
||||
*/
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 用户邮箱(脱敏)。
|
||||
* <p>手机号注册的账号为 {@code null}。
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 是否已绑定手机号。
|
||||
* <p>
|
||||
* 供 APP 判断是否需强制引导绑手机号(账号规则:邮箱注册后仍须绑手机号,
|
||||
* 未绑手机号的账号无法绑定设备)。
|
||||
*/
|
||||
private Boolean phoneBound;
|
||||
|
||||
/**
|
||||
* 用户类型(app_user)。
|
||||
*/
|
||||
private String userType;
|
||||
|
||||
/**
|
||||
* 注册时间。
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.domain.BmDeviceBind;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备绑定流水视图对象 bm_device_bind
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = BmDeviceBind.class)
|
||||
public class DeviceBindVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
private Long bindId;
|
||||
|
||||
/**
|
||||
* 设备物理主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 操作 APP 用户
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* IMEI(冗余快照)
|
||||
*/
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 动作(bind / unbind / rebind)
|
||||
*/
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 操作时间
|
||||
*/
|
||||
private LocalDateTime bindTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 设备统计视图对象({@code GET /device/stats},架构 §7.2 #2)。
|
||||
* <p>
|
||||
* <b>口径裁定(I5 登记)</b>:字段名严格取架构 §6 时序图口径 {@code total / online / offline / inactive}
|
||||
* (「未激活」用 {@code inactive}),<b>不</b>同时输出语义命名的 {@code notActivated}。
|
||||
* 前端 {@code types.ts} 已同时声明两者并兼容取值,本接口<b>只出 {@code inactive}</b>,
|
||||
* 前端可安全删去冗余的 {@code notActivated}。
|
||||
* <p>
|
||||
* <b>数值口径</b>:{@code total} = 未逻辑删除的设备总数;
|
||||
* {@code online} / {@code offline} / {@code inactive} 分别对应 {@code bm_device.status = '1' / '2' / '0'} 的计数。
|
||||
* 三者之和恒等于 {@code total}({@code status} 为三值字典,无其它取值)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class DeviceStatsVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备总数
|
||||
*/
|
||||
private Long total;
|
||||
|
||||
/**
|
||||
* 在线数({@code status = 1})
|
||||
*/
|
||||
private Long online;
|
||||
|
||||
/**
|
||||
* 离线数({@code status = 2})
|
||||
*/
|
||||
private Long offline;
|
||||
|
||||
/**
|
||||
* 未激活数({@code status = 0},架构 §6 序列图口径 {@code inactive})
|
||||
*/
|
||||
private Long inactive;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||
import org.apache.fesod.sheet.annotation.format.DateTimeFormat;
|
||||
import org.dromara.common.translation.annotation.Translation;
|
||||
import org.dromara.common.translation.constant.TransConstant;
|
||||
import org.dromara.device.domain.BmDevice;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备主表视图对象 bm_device
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = BmDevice.class)
|
||||
public class DeviceVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@ExcelProperty(value = "设备主键")
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 业务编号
|
||||
*/
|
||||
@ExcelProperty(value = "设备编号")
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* IMEI
|
||||
*/
|
||||
@ExcelProperty(value = "IMEI")
|
||||
private String imei;
|
||||
|
||||
/**
|
||||
* 用户侧名称
|
||||
*/
|
||||
@ExcelProperty(value = "设备名称")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 型号
|
||||
*/
|
||||
@ExcelProperty(value = "型号")
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 出厂序列号
|
||||
*/
|
||||
@ExcelProperty(value = "出厂序列号")
|
||||
private String sn;
|
||||
|
||||
/**
|
||||
* 硬件版本
|
||||
*/
|
||||
@ExcelProperty(value = "硬件版本")
|
||||
private String hwVersion;
|
||||
|
||||
/**
|
||||
* 软件版本
|
||||
*/
|
||||
@ExcelProperty(value = "软件版本")
|
||||
private String swVersion;
|
||||
|
||||
/**
|
||||
* 固件版本
|
||||
*/
|
||||
@ExcelProperty(value = "固件版本")
|
||||
private String fwVersion;
|
||||
|
||||
/**
|
||||
* 数据包版本
|
||||
*/
|
||||
@ExcelProperty(value = "数据包版本")
|
||||
private String pkgVersion;
|
||||
|
||||
/**
|
||||
* 设备 IP
|
||||
*/
|
||||
@ExcelProperty(value = "设备IP")
|
||||
private String ip;
|
||||
|
||||
/**
|
||||
* MAC
|
||||
*/
|
||||
@ExcelProperty(value = "MAC")
|
||||
private String mac;
|
||||
|
||||
/**
|
||||
* WiFi SSID
|
||||
*/
|
||||
@ExcelProperty(value = "WiFi名称")
|
||||
private String wifiSsid;
|
||||
|
||||
/**
|
||||
* 状态(0 未激活 1 在线 2 离线)
|
||||
*/
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 当前绑定用户
|
||||
*/
|
||||
private Long bindUserId;
|
||||
|
||||
/**
|
||||
* 绑定用户账号
|
||||
*/
|
||||
@Translation(type = TransConstant.USER_ID_TO_NAME, mapper = "bindUserId")
|
||||
@ExcelProperty(value = "绑定用户")
|
||||
private String bindUserName;
|
||||
|
||||
/**
|
||||
* 最近一次绑定时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "绑定时间")
|
||||
private LocalDateTime bindTime;
|
||||
|
||||
/**
|
||||
* 头套连接(0 断开 1 已连接)
|
||||
*/
|
||||
@ExcelProperty(value = "头套连接")
|
||||
private String hoodConnected;
|
||||
|
||||
/**
|
||||
* 眼罩状态(0 打开 1 遮盖)
|
||||
*/
|
||||
@ExcelProperty(value = "眼罩状态")
|
||||
private String hoodCoverOn;
|
||||
|
||||
/**
|
||||
* 信号等级(0 离线 1 弱 2 一般 3 强)
|
||||
*/
|
||||
@ExcelProperty(value = "信号等级")
|
||||
private Integer signalLevel;
|
||||
|
||||
/**
|
||||
* 头套电量 0-100
|
||||
*/
|
||||
@ExcelProperty(value = "头套电量")
|
||||
private Integer hoodBattery;
|
||||
|
||||
/**
|
||||
* 脚环电量 0-100
|
||||
*/
|
||||
@ExcelProperty(value = "脚环电量")
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 头套低电告警阈值 0-100(设备级配置;0 表示不触发)
|
||||
*/
|
||||
@ExcelProperty(value = "头套低电阈值")
|
||||
private Integer hoodBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 脚环低电告警阈值 0-100(设备级配置;0 表示不触发)
|
||||
*/
|
||||
@ExcelProperty(value = "脚环低电阈值")
|
||||
private Integer ringBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
private BigDecimal lng;
|
||||
|
||||
/**
|
||||
* 最新纬度
|
||||
*/
|
||||
private BigDecimal lat;
|
||||
|
||||
/**
|
||||
* 地址
|
||||
*/
|
||||
@ExcelProperty(value = "地址")
|
||||
private String address;
|
||||
|
||||
/**
|
||||
* 最后在线时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "最后在线时间")
|
||||
private LocalDateTime lastOnlineTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
@ExcelProperty(value = "备注")
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "更新时间")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 设备定位视图对象({@code GET /app/device/{deviceNo}/location},架构 §7.1 #18)。
|
||||
* <p>
|
||||
* 《开发计划》§8.2 对该接口的定义只有一句「最新点(给『查看地图』用)」,架构未给字段,由本条定稿:
|
||||
* {@code deviceNo} + 定位三件套({@code lng} / {@code lat} / {@code address})+ {@code lastOnlineTime}。
|
||||
* <p>
|
||||
* <b>空态取舍(重要)</b>:设备<b>从未上报过定位</b>(或设备端解析失败未带坐标)时,
|
||||
* {@code lng} / {@code lat} 为 {@code null},本接口返回<b>空 VO 而非抛异常</b>。
|
||||
* 理由是 APP 的「查看地图」需要一个可展示的<b>空态</b>(占位插画 /「暂无定位」文案);
|
||||
* 若抛业务异常,前端只能弹一个 toast,无法进入地图页,体验割裂。坐标是否有值由前端判空决定展示分支。
|
||||
* <p>
|
||||
* <b>越权隔离</b>:与服务层 {@code detail} 完全同口径——非当前用户所属设备一律统一抛
|
||||
* {@code DeviceMsgConstants.DEVICE_NOT_EXIST},本 VO 只在<b>归属校验通过后</b>才被填充。
|
||||
* <p>
|
||||
* 本 VO <b>不含任何直播字段</b>(D9)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class LocationVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 最新经度(从未上报为 {@code null})
|
||||
*/
|
||||
private BigDecimal lng;
|
||||
|
||||
/**
|
||||
* 最新纬度(从未上报为 {@code null})
|
||||
*/
|
||||
private BigDecimal lat;
|
||||
|
||||
/**
|
||||
* 地址(设备上报,可能为 {@code null})
|
||||
*/
|
||||
private String address;
|
||||
|
||||
/**
|
||||
* 最后在线时间(从未在线为 {@code null})
|
||||
*/
|
||||
private LocalDateTime lastOnlineTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OTA 批量创建升级任务结果({@code POST /device/ota/batch},PRD §5 R-31)。
|
||||
* <p>
|
||||
* 字段名严格对齐前端 {@code types.ts} 的 {@code OtaBatchResultVO}:
|
||||
* {@code total / created / skipped / failed / messages}。
|
||||
* <p>
|
||||
* <b>不变式</b>:{@code total == created + skipped + failed}——
|
||||
* 每台(去重后的)设备必落入三者之一,实现里逐条维护该分类,杜绝「统计对不上账」。
|
||||
* <ul>
|
||||
* <li>{@code skipped}:型号不匹配 / 已是最新版本 / 重复提交(同设备同包存在未终态任务)</li>
|
||||
* <li>{@code failed}:设备不存在 / 设备离线 / 指令下发未被设备确认</li>
|
||||
* <li>{@code created}:成功创建任务且已下发并被设备受理</li>
|
||||
* </ul>
|
||||
* {@code messages} 为逐台明细(人类可读),供前端弹窗展示。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
public class OtaBatchResultVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 提交总数(去重后的设备数)
|
||||
*/
|
||||
private Integer total = 0;
|
||||
|
||||
/**
|
||||
* 成功创建并已下发的任务数
|
||||
*/
|
||||
private Integer created = 0;
|
||||
|
||||
/**
|
||||
* 跳过数(型号不匹配 / 已是最新版本 / 重复提交)
|
||||
*/
|
||||
private Integer skipped = 0;
|
||||
|
||||
/**
|
||||
* 失败数(设备不存在 / 离线 / 下发失败)
|
||||
*/
|
||||
private Integer failed = 0;
|
||||
|
||||
/**
|
||||
* 明细信息
|
||||
*/
|
||||
private List<String> messages = new ArrayList<>();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||
import org.dromara.device.domain.BmOtaPackage;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* OTA 升级包视图对象 bm_ota_package
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = BmOtaPackage.class)
|
||||
public class OtaPackageVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@ExcelProperty(value = "升级包主键")
|
||||
private Long packageId;
|
||||
|
||||
/**
|
||||
* 适用型号
|
||||
*/
|
||||
@ExcelProperty(value = "适用型号")
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
@ExcelProperty(value = "版本号")
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 关联 sys_oss.oss_id
|
||||
*/
|
||||
private Long ossId;
|
||||
|
||||
/**
|
||||
* 文件 URL(冗余)
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 文件名
|
||||
*/
|
||||
@ExcelProperty(value = "文件名")
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 文件字节数
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 校验值 MD5
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* 更新说明
|
||||
*/
|
||||
@ExcelProperty(value = "更新说明")
|
||||
private String changelog;
|
||||
|
||||
/**
|
||||
* 状态(0 停用 1 启用)
|
||||
*/
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||
import org.apache.fesod.sheet.annotation.format.DateTimeFormat;
|
||||
import org.dromara.device.domain.BmOtaTask;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* OTA 升级任务视图对象 bm_ota_task
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = BmOtaTask.class)
|
||||
public class OtaTaskVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@ExcelProperty(value = "任务主键")
|
||||
private Long taskId;
|
||||
|
||||
/**
|
||||
* 升级包主键
|
||||
*/
|
||||
private Long packageId;
|
||||
|
||||
/**
|
||||
* 版本号(冗余)
|
||||
*/
|
||||
@ExcelProperty(value = "目标版本")
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 设备主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号(冗余)
|
||||
*/
|
||||
@ExcelProperty(value = "设备编号")
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 状态(0 待升级 1 升级中 2 成功 3 失败)
|
||||
*/
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 结果信息
|
||||
*/
|
||||
@ExcelProperty(value = "结果信息")
|
||||
private String resultMsg;
|
||||
|
||||
/**
|
||||
* 进度 0-100
|
||||
*/
|
||||
@ExcelProperty(value = "进度")
|
||||
private Integer progress;
|
||||
|
||||
/**
|
||||
* 操作人(后台用户)
|
||||
*/
|
||||
private Long operatorId;
|
||||
|
||||
/**
|
||||
* 下发次数
|
||||
*/
|
||||
private Integer times;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "开始时间")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
/**
|
||||
* 完成时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "完成时间")
|
||||
private LocalDateTime finishTime;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.dromara.device.email;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.extra.mail.MailAccount;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.mail.core.MailBuilder;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.device.config.AppProperties;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
import org.dromara.device.enums.EmailSceneEnum;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* APP 邮箱验证码服务:按场景「生成 → 60 秒限流 → 发送 → 缓存 → 校验」。
|
||||
* <p>
|
||||
* <b>与 {@link org.dromara.device.sms.AppSmsService} 的关系</b>:本服务是其邮箱通道的<b>平行实现</b>,
|
||||
* 而非抽象重构。原因是短信链路已在迭代 I2 实测通过,抽公共父类会把已验证行为重新暴露于变更风险;
|
||||
* 两处刻意保持「同一套加固口径、两套独立代码」,代价是少量重复,收益是互不影响、可分别回归。
|
||||
* <p>
|
||||
* <b>加固口径(与短信完全一致,逐条对齐)</b>:
|
||||
* <ul>
|
||||
* <li><b>场景化隔离</b>:缓存 key 与限流 key 均带场景,注册/登录/忘记密码互不覆盖;</li>
|
||||
* <li><b>先落缓存再发信</b>:即使 SMTP 异常,限流 key 已存在,避免通道故障时被刷量;</li>
|
||||
* <li><b>正确码优先放行</b>:校验时先判正确性再判限速,故攻击者无法用错误请求让受害者的验证码失效;</li>
|
||||
* <li><b>错误尝试短窗口限速</b>:仅对错误码计数并限速,<b>绝不删除或失效验证码本身</b>。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppEmailService {
|
||||
|
||||
private final AppProperties appProperties;
|
||||
|
||||
/**
|
||||
* 生成并按场景发送邮箱验证码(含 60 秒重发限流与域名白名单校验)。
|
||||
*
|
||||
* @param email 邮箱地址(大小写不敏感)
|
||||
* @param scene 场景(register / login / resetPwd)
|
||||
*/
|
||||
public void sendCode(String email, String scene) {
|
||||
if (!EmailSceneEnum.isValid(scene)) {
|
||||
throw new ServiceException(AppMsgConstants.EMAIL_SCENE_INVALID);
|
||||
}
|
||||
String to = normalize(email);
|
||||
verifyDomainAllowed(to);
|
||||
AppProperties.Email cfg = appProperties.getEmail();
|
||||
// 60 秒重发限流:利用「不存在才写入」的原子性,写入成功即取得发送资格
|
||||
String limitKey = AppConstants.EMAIL_LIMIT_KEY_PREFIX + scene + ":" + to;
|
||||
boolean acquired = RedisUtils.setObjectIfAbsent(limitKey, System.currentTimeMillis(),
|
||||
Duration.ofSeconds(cfg.getResendSeconds()));
|
||||
if (!acquired) {
|
||||
throw new ServiceException(AppMsgConstants.SMS_SEND_TOO_FREQUENT);
|
||||
}
|
||||
String code = RandomUtil.randomNumbers(cfg.getCodeLength());
|
||||
// 先落缓存,再发送:即使发信异常,限流 key 已存在,避免 SMTP 通道故障时的刷量
|
||||
String codeKey = AppConstants.EMAIL_CODE_KEY_PREFIX + scene + ":" + to;
|
||||
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(cfg.getExpiryMinutes()));
|
||||
// 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算)
|
||||
RedisUtils.deleteObject(AppConstants.EMAIL_FAIL_KEY_PREFIX + scene + ":" + to);
|
||||
send(to, scene, code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验邮箱验证码(一次性使用,校验通过后立即失效)。
|
||||
* <p>
|
||||
* <b>防定向 DoS(与短信同口径)</b>:<b>正确验证码永远可校验成功</b>,失败仅作用于「错误尝试」,
|
||||
* <b>绝不删除或失效验证码本身</b>。因此攻击者即便知道受害者邮箱、连续提交错误码,
|
||||
* 也无法让受害者的验证码失效。暴力破解防护仍成立:错误尝试按「场景 + 邮箱」做短窗口限速,
|
||||
* 窗口到期自动自愈(Redis TTL)。
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param scene 场景
|
||||
* @param code 用户输入的验证码
|
||||
*/
|
||||
public void verifyCode(String email, String scene, String code) {
|
||||
String to = normalize(email);
|
||||
String codeKey = AppConstants.EMAIL_CODE_KEY_PREFIX + scene + ":" + to;
|
||||
String cached = RedisUtils.getCacheObject(codeKey);
|
||||
if (StringUtils.isBlank(cached)) {
|
||||
throw new ServiceException(DeviceMsgConstants.SMS_CODE_EXPIRED);
|
||||
}
|
||||
// ① 先判「正确」:正确码永远放行(在限速之前),故攻击者无法阻断受害者;
|
||||
// 一次性使用,成功后清零失败计数。
|
||||
if (StringUtils.equals(cached, code)) {
|
||||
RedisUtils.deleteObject(codeKey);
|
||||
RedisUtils.deleteObject(AppConstants.EMAIL_FAIL_KEY_PREFIX + scene + ":" + to);
|
||||
return;
|
||||
}
|
||||
// ② 错误码:仅对「错误尝试」做短窗口限速(绝不删除验证码)。
|
||||
String failKey = AppConstants.EMAIL_FAIL_KEY_PREFIX + scene + ":" + to;
|
||||
long attempts = RedisUtils.incrAtomicValue(failKey);
|
||||
if (attempts == 1L) {
|
||||
// 固定窗口:仅在首次失败时设置 TTL,避免攻击者用持续请求刷新、延长窗口
|
||||
RedisUtils.expire(failKey, Duration.ofSeconds(AppConstants.SMS_VERIFY_FAIL_WINDOW_SECONDS));
|
||||
}
|
||||
if (attempts > AppConstants.SMS_MAX_VERIFY_FAIL) {
|
||||
throw new ServiceException(AppMsgConstants.SMS_VERIFY_TOO_MANY);
|
||||
}
|
||||
throw new ServiceException(DeviceMsgConstants.SMS_CODE_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱地址归一化:去首尾空白 + 统一小写。
|
||||
* <p>
|
||||
* 用于判重与 Redis key,<b>避免「A@Gmail.com」与「a@gmail.com」被判为两个账号</b>。
|
||||
* <p>
|
||||
* <b>已知边界(未处理,如需请显式提出)</b>:Gmail 实际把 {@code a.b@gmail.com} 与
|
||||
* {@code ab@gmail.com}、{@code a+tag@gmail.com} 视为同一邮箱。本方法<b>不做</b>点号/加号折叠,
|
||||
* 故同一 Gmail 邮箱理论上可注册多个账号。是否折叠属产品口径,需与「账号唯一性」规则一并裁定。
|
||||
*
|
||||
* @param email 原始邮箱
|
||||
* @return 归一化后的邮箱;入参为空时返回空串
|
||||
*/
|
||||
public String normalize(String email) {
|
||||
return StringUtils.isBlank(email) ? "" : email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验邮箱域名是否在白名单内(白名单为空则不限制)。
|
||||
*
|
||||
* @param email 已归一化的邮箱
|
||||
*/
|
||||
private void verifyDomainAllowed(String email) {
|
||||
AppProperties.Email cfg = appProperties.getEmail();
|
||||
if (cfg.getAllowedDomains() == null || cfg.getAllowedDomains().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int at = email.lastIndexOf('@');
|
||||
String domain = at < 0 ? "" : email.substring(at + 1);
|
||||
for (String allowed : cfg.getAllowedDomains()) {
|
||||
if (allowed != null && StringUtils.equalsIgnoreCase(domain, allowed.trim())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new ServiceException(AppMsgConstants.EMAIL_DOMAIN_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际发送验证码邮件:mock 模式打印日志,否则经 {@link MailBuilder} 走真实 SMTP。
|
||||
* <p>
|
||||
* <b>同步发送</b>:与短信通道一致,在 HTTP 请求线程内完成。Gmail 的 TLS 握手通常 1–3 秒,
|
||||
* 已通过 {@code device.app.email.connection-timeout} / {@code timeout} 限制上限,
|
||||
* 保证 SMTP 不可达时<b>快速失败</b>而非挂死请求线程。若后续需要消除该耗时,可改为异步发送。
|
||||
*
|
||||
* @param email 已归一化的收件邮箱
|
||||
* @param scene 场景(仅用于日志与文案)
|
||||
* @param code 验证码
|
||||
*/
|
||||
private void send(String email, String scene, String code) {
|
||||
AppProperties.Email cfg = appProperties.getEmail();
|
||||
if (cfg.isMock()) {
|
||||
log.info("[APP邮箱-模拟发送] 收件邮箱={},场景={},验证码={},有效期={}分钟(device.app.email.mock=true,未真实发信)。",
|
||||
email, scene, code, cfg.getExpiryMinutes());
|
||||
return;
|
||||
}
|
||||
String from = StringUtils.isBlank(cfg.getFrom()) ? cfg.getUser() : cfg.getFrom();
|
||||
try {
|
||||
MailAccount account = new MailAccount();
|
||||
account.setHost(cfg.getHost());
|
||||
account.setPort(cfg.getPort());
|
||||
account.setAuth(true);
|
||||
account.setUser(cfg.getUser());
|
||||
account.setPass(cfg.getPass());
|
||||
account.setFrom(from);
|
||||
account.setSslEnable(cfg.isSslEnable());
|
||||
account.setStarttlsEnable(cfg.isStarttlsEnable());
|
||||
account.setSocketFactoryPort(cfg.getPort());
|
||||
account.setConnectionTimeout(cfg.getConnectionTimeout());
|
||||
account.setTimeout(cfg.getTimeout());
|
||||
|
||||
MailBuilder.of(account)
|
||||
.to(email)
|
||||
.subject(cfg.getSubject())
|
||||
.html(buildHtml(code, cfg.getExpiryMinutes()))
|
||||
.send();
|
||||
log.info("[APP邮箱] 验证码已发送:收件邮箱={},场景={}", email, scene);
|
||||
} catch (Exception e) {
|
||||
// 不记录验证码本身,避免真实发信失败时把验证码写进日志
|
||||
log.error("APP 邮箱验证码发送异常 email={} host={}:{}(若为连接超时,请确认服务器可访问 smtp.gmail.com)",
|
||||
email, cfg.getHost(), cfg.getPort(), e);
|
||||
throw new ServiceException(AppMsgConstants.EMAIL_SEND_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造验证码邮件正文(纯 HTML,无外部资源,避免被邮件客户端拦截)。
|
||||
*
|
||||
* @param code 验证码
|
||||
* @param expiryMinutes 有效期(分钟)
|
||||
* @return HTML 正文
|
||||
*/
|
||||
private String buildHtml(String code, int expiryMinutes) {
|
||||
return "<div style=\"font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"
|
||||
+ "font-size:14px;line-height:1.6;color:#2c2c2a\">"
|
||||
+ "<p>您正在使用智能飞禽物联平台,本次操作的验证码为:</p>"
|
||||
+ "<p style=\"font-size:24px;font-weight:500;letter-spacing:4px;margin:16px 0\">" + code + "</p>"
|
||||
+ "<p>验证码 " + expiryMinutes + " 分钟内有效,请勿泄露给他人。</p>"
|
||||
+ "<p style=\"color:#5f5e5a\">若非本人操作,请忽略本邮件。</p>"
|
||||
+ "</div>";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 告警级别枚举
|
||||
* <p>
|
||||
* 对应 {@code bm_device_alert.alert_level}:{@code char(1)},<b>DB 直存、无对应字典</b>
|
||||
* (与 {@link AlertTypeEnum} 不同,后者有字典 {@code bm_alert_type})。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AlertLevelEnum {
|
||||
|
||||
/**
|
||||
* 提示
|
||||
*/
|
||||
TIP("0", "提示"),
|
||||
|
||||
/**
|
||||
* 警告
|
||||
*/
|
||||
WARN("1", "警告"),
|
||||
|
||||
/**
|
||||
* 严重
|
||||
*/
|
||||
CRITICAL("2", "严重");
|
||||
|
||||
/**
|
||||
* 级别编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 级别文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 告警类型枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_alert_type}:offline / low_battery_hood / low_battery_ring。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AlertTypeEnum {
|
||||
|
||||
/**
|
||||
* 离线
|
||||
*/
|
||||
OFFLINE("offline", "设备离线"),
|
||||
|
||||
/**
|
||||
* 头套低电
|
||||
*/
|
||||
LOW_BATTERY_HOOD("low_battery_hood", "头套电量过低"),
|
||||
|
||||
/**
|
||||
* 脚环低电
|
||||
*/
|
||||
LOW_BATTERY_RING("low_battery_ring", "脚环电量过低");
|
||||
|
||||
/**
|
||||
* 类型编码
|
||||
*/
|
||||
private final String type;
|
||||
|
||||
/**
|
||||
* 类型文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 绑定动作枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_bind_action}:bind / unbind / rebind。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum BindActionEnum {
|
||||
|
||||
/**
|
||||
* 绑定
|
||||
*/
|
||||
BIND("bind", "绑定"),
|
||||
|
||||
/**
|
||||
* 解绑
|
||||
*/
|
||||
UNBIND("unbind", "解绑"),
|
||||
|
||||
/**
|
||||
* 换绑
|
||||
*/
|
||||
REBIND("rebind", "换绑");
|
||||
|
||||
/**
|
||||
* 动作编码
|
||||
*/
|
||||
private final String action;
|
||||
|
||||
/**
|
||||
* 动作文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 设备状态枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_device_status}:0 未激活 / 1 在线 / 2 离线。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum DeviceStatusEnum {
|
||||
|
||||
/**
|
||||
* 未激活
|
||||
*/
|
||||
NOT_ACTIVATED("0", "未激活"),
|
||||
|
||||
/**
|
||||
* 在线
|
||||
*/
|
||||
ONLINE("1", "在线"),
|
||||
|
||||
/**
|
||||
* 离线
|
||||
*/
|
||||
OFFLINE("2", "离线");
|
||||
|
||||
/**
|
||||
* 状态编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 状态文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
|
||||
/**
|
||||
* APP 邮箱验证码场景枚举。
|
||||
* <p>
|
||||
* 场景编码<b>复用</b> {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd),
|
||||
* 与短信通道保持一致的语义;但由于 Redis key 前缀不同({@code app:email:*} 与 {@code app:sms:*}),
|
||||
* 两个通道的验证码与限流互不影响。
|
||||
* <p>
|
||||
* <b>为什么没有 rebind / bind</b>:手机号的绑定与换绑必须由<b>新手机号收到的短信验证码</b>证明归属,
|
||||
* 邮箱验证码无法证明手机号归属,故邮箱通道不提供这两个场景。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum EmailSceneEnum {
|
||||
|
||||
/**
|
||||
* 注册。
|
||||
*/
|
||||
REGISTER("register", "注册"),
|
||||
|
||||
/**
|
||||
* 登录。
|
||||
*/
|
||||
LOGIN("login", "登录"),
|
||||
|
||||
/**
|
||||
* 忘记密码。
|
||||
*/
|
||||
RESET_PWD("resetPwd", "忘记密码");
|
||||
|
||||
/**
|
||||
* 场景编码。
|
||||
*/
|
||||
private final String scene;
|
||||
|
||||
/**
|
||||
* 场景文案。
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
/**
|
||||
* 判断场景编码是否合法。
|
||||
*
|
||||
* @param scene 场景编码
|
||||
* @return true 合法;false 非法
|
||||
*/
|
||||
public static boolean isValid(String scene) {
|
||||
if (StringUtils.isBlank(scene)) {
|
||||
return false;
|
||||
}
|
||||
for (EmailSceneEnum value : values()) {
|
||||
if (StringUtils.equals(value.getScene(), scene)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 头套连接枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_hood_connected}:0 已断开 / 1 已连接。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum HoodConnectedEnum {
|
||||
|
||||
/**
|
||||
* 已断开
|
||||
*/
|
||||
DISCONNECTED("0", "已断开"),
|
||||
|
||||
/**
|
||||
* 已连接
|
||||
*/
|
||||
CONNECTED("1", "已连接");
|
||||
|
||||
/**
|
||||
* 连接编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 连接文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 眼罩状态枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_hood_cover}:0 打开 / 1 遮盖。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum HoodCoverEnum {
|
||||
|
||||
/**
|
||||
* 打开
|
||||
*/
|
||||
OPEN("0", "打开"),
|
||||
|
||||
/**
|
||||
* 遮盖
|
||||
*/
|
||||
COVERED("1", "遮盖");
|
||||
|
||||
/**
|
||||
* 状态编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 状态文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* OTA 升级包状态枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_ota_package_status}:0 停用 / 1 启用。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum OtaPackageStatusEnum {
|
||||
|
||||
/**
|
||||
* 停用
|
||||
*/
|
||||
DISABLED("0", "停用"),
|
||||
|
||||
/**
|
||||
* 启用
|
||||
*/
|
||||
ENABLED("1", "启用");
|
||||
|
||||
/**
|
||||
* 状态编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 状态文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* OTA 任务状态枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_ota_task_status}:0 待升级 / 1 升级中 / 2 成功 / 3 失败。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum OtaTaskStatusEnum {
|
||||
|
||||
/**
|
||||
* 待升级
|
||||
*/
|
||||
PENDING("0", "待升级"),
|
||||
|
||||
/**
|
||||
* 升级中
|
||||
*/
|
||||
UPGRADING("1", "升级中"),
|
||||
|
||||
/**
|
||||
* 成功
|
||||
*/
|
||||
SUCCESS("2", "成功"),
|
||||
|
||||
/**
|
||||
* 失败
|
||||
*/
|
||||
FAILED("3", "失败");
|
||||
|
||||
/**
|
||||
* 状态编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 状态文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 信号等级枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_signal_level}:0 离线 / 1 弱 / 2 一般 / 3 强。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum SignalLevelEnum {
|
||||
|
||||
/**
|
||||
* 离线
|
||||
*/
|
||||
OFFLINE(0, "离线"),
|
||||
|
||||
/**
|
||||
* 弱
|
||||
*/
|
||||
WEAK(1, "弱"),
|
||||
|
||||
/**
|
||||
* 一般
|
||||
*/
|
||||
NORMAL(2, "一般"),
|
||||
|
||||
/**
|
||||
* 强
|
||||
*/
|
||||
STRONG(3, "强");
|
||||
|
||||
/**
|
||||
* 信号等级值
|
||||
*/
|
||||
private final Integer level;
|
||||
|
||||
/**
|
||||
* 信号文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
|
||||
/**
|
||||
* APP 短信验证码场景枚举。
|
||||
* <p>
|
||||
* 场景与 {@code AppConstants.SCENE_*} 常量一一对应,用于「场景化 60s 限流」与
|
||||
* 「按场景隔离验证码缓存」(不同场景的验证码互不覆盖)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum SmsSceneEnum {
|
||||
|
||||
/**
|
||||
* 登录。
|
||||
*/
|
||||
LOGIN("login", "登录"),
|
||||
|
||||
/**
|
||||
* 注册。
|
||||
*/
|
||||
REGISTER("register", "注册"),
|
||||
|
||||
/**
|
||||
* 忘记密码。
|
||||
*/
|
||||
RESET_PWD("resetPwd", "忘记密码"),
|
||||
|
||||
/**
|
||||
* 换绑手机。
|
||||
*/
|
||||
REBIND("rebind", "换绑手机"),
|
||||
|
||||
/**
|
||||
* 绑定手机(首次绑定:邮箱注册的账号补绑手机号时使用)。
|
||||
*/
|
||||
BIND("bind", "绑定手机");
|
||||
|
||||
/**
|
||||
* 场景编码。
|
||||
*/
|
||||
private final String scene;
|
||||
|
||||
/**
|
||||
* 场景文案。
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
/**
|
||||
* 判断场景编码是否合法。
|
||||
*
|
||||
* @param scene 场景编码
|
||||
* @return true 合法;false 非法
|
||||
*/
|
||||
public static boolean isValid(String scene) {
|
||||
if (StringUtils.isBlank(scene)) {
|
||||
return false;
|
||||
}
|
||||
for (SmsSceneEnum value : values()) {
|
||||
if (StringUtils.equals(value.getScene(), scene)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmDeviceAlert;
|
||||
|
||||
/**
|
||||
* 设备告警 Mapper 接口
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface BmDeviceAlertMapper extends BaseMapperPlus<BmDeviceAlert, BmDeviceAlert> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmDeviceBind;
|
||||
import org.dromara.device.domain.vo.DeviceBindVo;
|
||||
|
||||
/**
|
||||
* 设备绑定流水 Mapper 接口
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface BmDeviceBindMapper extends BaseMapperPlus<BmDeviceBind, DeviceBindVo> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmDevice;
|
||||
import org.dromara.device.domain.vo.DeviceVo;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备主表 Mapper 接口
|
||||
* <p>
|
||||
* 说明:下列自定义查询均以 XML 实现,**不附加逻辑删除条件**(自定义 XML SQL 不受 MP 逻辑删除影响),
|
||||
* 用于与 DB 唯一键({@code uk_device_no} / {@code uk_imei},不区分逻辑删除行)保持同一口径。
|
||||
* {@link #selectStaleOnlineDevices(LocalDateTime, int)} 例外,它是运行态查询,显式带 {@code del_flag = '0'}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface BmDeviceMapper extends BaseMapperPlus<BmDevice, DeviceVo> {
|
||||
|
||||
/**
|
||||
* 查询指定前缀下已存在的最大设备编号(忽略逻辑删除,包含已删除行)。
|
||||
* <p>
|
||||
* 用作 device_no 取号的「高水位」,保证新编号大于任何既有编号(含已删除行)。
|
||||
*
|
||||
* @param prefix 设备编号前缀(如 BM20260916)
|
||||
* @return 前缀下的最大设备编号;无记录时返回 null
|
||||
*/
|
||||
String selectMaxDeviceNo(@Param("prefix") String prefix);
|
||||
|
||||
/**
|
||||
* 按设备编号查询已存在记录的物理主键(忽略逻辑删除,包含已删除行)。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return 记录的 device_id;不存在时返回 null
|
||||
*/
|
||||
Long selectIdByDeviceNo(@Param("deviceNo") String deviceNo);
|
||||
|
||||
/**
|
||||
* 按 IMEI 查询已存在记录的物理主键(忽略逻辑删除,包含已删除行)。
|
||||
*
|
||||
* @param imei IMEI
|
||||
* @return 记录的 device_id;不存在时返回 null
|
||||
*/
|
||||
Long selectIdByImei(@Param("imei") String imei);
|
||||
|
||||
/**
|
||||
* 查询心跳超时、应判定为离线的在网设备(离线扫描用)。
|
||||
* <p>
|
||||
* 过滤口径:{@code del_flag = '0'} 且 {@code status = '1'} 且
|
||||
* {@code last_online_time} 已存在但早于 {@code before}。
|
||||
* <p>
|
||||
* <b>为什么要排除 {@code last_online_time IS NULL}</b>:APP 绑定成功时会把 {@code status} 直接置为「在线」,
|
||||
* 而 {@code last_online_time} 要等设备第一次上报遥测才写入。若把 NULL 也算作超时,
|
||||
* 用户刚添加完设备就会被扫描判离线并生成一条离线告警——这是错误的事实。
|
||||
* 因此「从未有过心跳」的设备不受心跳超时约束,其在线态由业务侧(绑定/解绑)决定;
|
||||
* 一旦收到过遥测,后续就完整纳入心跳判定。
|
||||
* <p>
|
||||
* 该方法只负责<b>筛出候选</b>,是否真的迁移到离线由
|
||||
* {@code DevicePresenceWriter#markOffline} 的条件更新原子裁决(避免与 LWT 事件竞争重复告警)。
|
||||
*
|
||||
* @param before 心跳判定基准时间(通常为 {@code now - offlineThreshold})
|
||||
* @param limit 单轮返回上限,防止积压时一次性拉爆内存
|
||||
* @return 候选设备列表(仅填充 device_id / device_no / last_online_time,按心跳时间升序)
|
||||
*/
|
||||
List<BmDevice> selectStaleOnlineDevices(@Param("before") LocalDateTime before, @Param("limit") int limit);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmDeviceTelemetry;
|
||||
import org.dromara.device.domain.BmDeviceTelemetry;
|
||||
|
||||
/**
|
||||
* 设备遥测历史 Mapper 接口
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface BmDeviceTelemetryMapper extends BaseMapperPlus<BmDeviceTelemetry, BmDeviceTelemetry> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmOtaPackage;
|
||||
import org.dromara.device.domain.vo.OtaPackageVo;
|
||||
|
||||
/**
|
||||
* OTA 升级包 Mapper 接口
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface BmOtaPackageMapper extends BaseMapperPlus<BmOtaPackage, OtaPackageVo> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmOtaTask;
|
||||
import org.dromara.device.domain.vo.OtaTaskVo;
|
||||
|
||||
/**
|
||||
* OTA 升级任务 Mapper 接口
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface BmOtaTaskMapper extends BaseMapperPlus<BmOtaTask, OtaTaskVo> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.device.mqtt.payload.AckPayload;
|
||||
import org.dromara.mica.mqtt.codec.MqttQoS;
|
||||
import org.dromara.mica.mqtt.core.annotation.MqttClientSubscribe;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 指令回执上行监听({@code bm/+/ack},QoS 1)。
|
||||
* <p>
|
||||
* 回执是「指令成功」的唯一凭据,见协议 §5.3。本类只做解析与转发,
|
||||
* 由 {@link DeviceUplinkHandler#onAck} 实现按 {@code msgId} 唤醒指令服务中注册的
|
||||
* {@code CompletableFuture},并仅在成功时更新设备状态字段。
|
||||
* <p>
|
||||
* 重复回执是正常的(设备重发、QoS 1 重投),实现方需按 {@code msgId} 幂等处理。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceAckListener {
|
||||
|
||||
/**
|
||||
* 上行处理端口(可选注入,未装配时仅记日志)
|
||||
*/
|
||||
private final ObjectProvider<DeviceUplinkHandler> handlerProvider;
|
||||
|
||||
/**
|
||||
* 处理指令回执报文。
|
||||
*
|
||||
* @param topic 上行 Topic
|
||||
* @param payload 报文体(UTF-8 JSON)
|
||||
*/
|
||||
@MqttClientSubscribe(value = MqttTopics.ACK_SUB, qos = MqttQoS.QOS1)
|
||||
public void onMessage(String topic, byte[] payload) {
|
||||
String deviceNo = MqttTopics.deviceNoOf(topic);
|
||||
if (deviceNo == null) {
|
||||
log.warn("回执 Topic 非法,已丢弃 => topic: {}", topic);
|
||||
return;
|
||||
}
|
||||
AckPayload data;
|
||||
try {
|
||||
data = JsonUtils.parseObject(payload, AckPayload.class);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("回执报文解析失败,已丢弃 => deviceNo: {}, payload: {}",
|
||||
deviceNo, new String(payload, StandardCharsets.UTF_8), e);
|
||||
return;
|
||||
}
|
||||
String invalid = data == null ? "报文体为空" : data.invalidReason();
|
||||
if (invalid != null) {
|
||||
log.warn("回执报文校验失败,已丢弃 => deviceNo: {}, reason: {}", deviceNo, invalid);
|
||||
return;
|
||||
}
|
||||
DeviceUplinkHandler handler = handlerProvider.getIfAvailable();
|
||||
if (handler == null) {
|
||||
log.info("未装配 DeviceUplinkHandler,回执仅记录 => deviceNo: {}, msgId: {}, cmd: {}, success: {}",
|
||||
deviceNo, data.getMsgId(), data.getCmd(), data.success());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handler.onAck(deviceNo, data);
|
||||
} catch (Exception e) {
|
||||
log.error("回执处理异常 => deviceNo: {}, msgId: {}", deviceNo, data.getMsgId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.device.mqtt.payload.HoodCommandPayload;
|
||||
import org.dromara.device.mqtt.payload.OtaCommandPayload;
|
||||
import org.dromara.device.mqtt.payload.ProvisionAckPayload;
|
||||
import org.dromara.device.mqtt.payload.StreamCommandPayload;
|
||||
import org.dromara.mica.mqtt.codec.MqttQoS;
|
||||
import org.dromara.mica.mqtt.spring.client.MqttClientTemplate;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 下行指令发布器。
|
||||
* <p>
|
||||
* 口径见《设备通信协议-一期.md》§7。职责边界:
|
||||
* <ul>
|
||||
* <li>只做「拼 Topic + 序列化 + 发布 + 日志」,<b>不</b>注册/等待回执
|
||||
* (回执等待由指令服务基于 {@code msgId} 的 {@code CompletableFuture} 完成);</li>
|
||||
* <li>{@code msgId} 由调用方生成并先行注册 Future 再调用发布,避免「回执先于注册到达」的竞态;
|
||||
* 生成用 {@link #newMsgId()};</li>
|
||||
* <li>所有下行 QoS 固定 {@code QOS1},Retain 关闭。</li>
|
||||
* </ul>
|
||||
* {@link MqttClientTemplate} 通过 {@link ObjectProvider} 惰性获取:当
|
||||
* {@code mqtt.client.enabled=false} 时容器内无该 Bean,发布失败并记错误日志,但不影响启动。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceCommandPublisher {
|
||||
|
||||
/**
|
||||
* MQTT 客户端(惰性获取)
|
||||
*/
|
||||
private final ObjectProvider<MqttClientTemplate> clientProvider;
|
||||
|
||||
/**
|
||||
* 生成指令唯一标识。
|
||||
* <p>
|
||||
* 采用 UUIDv4 字符串(36 位),满足协议「长度 ≤ 64」约束,且与设备侧 16 字节随机值互不冲突。
|
||||
*
|
||||
* @return msgId
|
||||
*/
|
||||
public static String newMsgId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发眼罩开关指令。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param msgId 指令唯一标识(调用方生成)
|
||||
* @param coverOn 是否遮盖
|
||||
* @return 是否成功提交到 MQTT 客户端
|
||||
*/
|
||||
public boolean publishHood(String deviceNo, String msgId, boolean coverOn) {
|
||||
HoodCommandPayload payload = HoodCommandPayload.of(msgId, coverOn);
|
||||
return publish(MqttTopics.commandHood(deviceNo), payload, deviceNo, msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发 OTA 升级指令。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param msgId 指令唯一标识(调用方生成)
|
||||
* @param payload OTA 指令体(version/url/md5/size 齐备)
|
||||
* @return 是否成功提交到 MQTT 客户端
|
||||
*/
|
||||
public boolean publishOta(String deviceNo, String msgId, OtaCommandPayload payload) {
|
||||
if (!payload.isValid()) {
|
||||
log.error("OTA 指令字段不完整,拒绝下发 => deviceNo: {}", deviceNo);
|
||||
return false;
|
||||
}
|
||||
payload.setMsgId(msgId);
|
||||
return publish(MqttTopics.commandOta(deviceNo), payload, deviceNo, msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发直播推流指令(开始 / 停止)。
|
||||
* <p>
|
||||
* 直播接口不等待回执:流是否在线以 ZLM {@code getMediaInfo} 为准。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param payload 推流指令
|
||||
* @return 是否成功提交到 MQTT 客户端
|
||||
*/
|
||||
public boolean publishStream(String deviceNo, StreamCommandPayload payload) {
|
||||
return publish(MqttTopics.commandStream(deviceNo), payload, deviceNo, payload.getMsgId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发注册应答(按 IMEI 寻址,设备此时尚不知自身 deviceNo)。
|
||||
*
|
||||
* @param imei 设备 IMEI
|
||||
* @param payload 应答报文
|
||||
* @return 是否成功提交到 MQTT 客户端
|
||||
*/
|
||||
public boolean publishProvisionAck(String imei, ProvisionAckPayload payload) {
|
||||
return publish(MqttTopics.provisionAck(imei), payload, imei, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布下行报文。
|
||||
*
|
||||
* @param topic 目标 Topic
|
||||
* @param payload 报文对象(使用全局 Jackson 序列化,与接口口径一致)
|
||||
* @param identity 设备标识(deviceNo 或 imei),仅用于日志
|
||||
* @param msgId 指令标识,仅用于日志
|
||||
* @return 是否成功提交到 MQTT 客户端
|
||||
*/
|
||||
private boolean publish(String topic, Object payload, String identity, String msgId) {
|
||||
MqttClientTemplate client = clientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.error("MQTT 客户端未装配(检查 mqtt.client.enabled),下发失败 => topic: {}, msgId: {}",
|
||||
topic, msgId);
|
||||
return false;
|
||||
}
|
||||
String json = JsonUtils.toJsonString(payload);
|
||||
boolean ok = client.publish(topic, json.getBytes(StandardCharsets.UTF_8), MqttQoS.QOS1);
|
||||
if (ok) {
|
||||
log.info("MQTT 下行 => identity: {}, topic: {}, msgId: {}, payload: {}",
|
||||
identity, topic, msgId, json);
|
||||
} else {
|
||||
log.error("MQTT 下行失败 => identity: {}, topic: {}, msgId: {}, payload: {}",
|
||||
identity, topic, msgId, json);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.device.mqtt.payload.EventPayload;
|
||||
import org.dromara.mica.mqtt.codec.MqttQoS;
|
||||
import org.dromara.mica.mqtt.core.annotation.MqttClientSubscribe;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 设备事件上行监听({@code bm/+/event},QoS 1)。
|
||||
* <p>
|
||||
* 承载 online / offline / low_battery_* / ota_progress,见协议 §5.2。
|
||||
* 未知 {@code eventType} 属<b>前向兼容场景</b>(固件先行上报新事件),
|
||||
* 只记 WARN 后丢弃,不视为异常、不回错。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceEventListener {
|
||||
|
||||
/**
|
||||
* 上行处理端口(可选注入,未装配时仅记日志)
|
||||
*/
|
||||
private final ObjectProvider<DeviceUplinkHandler> handlerProvider;
|
||||
|
||||
/**
|
||||
* 处理设备事件报文。
|
||||
*
|
||||
* @param topic 上行 Topic
|
||||
* @param payload 报文体(UTF-8 JSON)
|
||||
*/
|
||||
@MqttClientSubscribe(value = MqttTopics.EVENT_SUB, qos = MqttQoS.QOS1)
|
||||
public void onMessage(String topic, byte[] payload) {
|
||||
String deviceNo = MqttTopics.deviceNoOf(topic);
|
||||
if (deviceNo == null) {
|
||||
log.warn("事件 Topic 非法,已丢弃 => topic: {}", topic);
|
||||
return;
|
||||
}
|
||||
EventPayload data;
|
||||
try {
|
||||
data = JsonUtils.parseObject(payload, EventPayload.class);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("事件报文解析失败,已丢弃 => deviceNo: {}, payload: {}",
|
||||
deviceNo, new String(payload, StandardCharsets.UTF_8), e);
|
||||
return;
|
||||
}
|
||||
String invalid = data == null ? "报文体为空" : data.invalidReason();
|
||||
if (invalid != null) {
|
||||
log.warn("事件报文校验失败,已丢弃 => deviceNo: {}, reason: {}", deviceNo, invalid);
|
||||
return;
|
||||
}
|
||||
if (data.resolveType() == null) {
|
||||
log.warn("未知事件类型,已忽略(前向兼容) => deviceNo: {}, eventType: {}",
|
||||
deviceNo, data.getEventType());
|
||||
return;
|
||||
}
|
||||
DeviceUplinkHandler handler = handlerProvider.getIfAvailable();
|
||||
if (handler == null) {
|
||||
log.debug("未装配 DeviceUplinkHandler,事件仅记录不处理 => deviceNo: {}, eventType: {}",
|
||||
deviceNo, data.getEventType());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handler.onEvent(deviceNo, data);
|
||||
} catch (Exception e) {
|
||||
log.error("事件处理异常 => deviceNo: {}, eventType: {}", deviceNo, data.getEventType(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.device.config.DeviceMqttProperties;
|
||||
import org.dromara.device.constant.DeviceMqttConstants;
|
||||
import org.dromara.device.domain.BmDevice;
|
||||
import org.dromara.device.mapper.BmDeviceMapper;
|
||||
import org.dromara.device.mqtt.support.DevicePresenceWriter;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 设备离线扫描:把「心跳超时」翻译成「离线状态 + 离线告警」。
|
||||
* <p>
|
||||
* 口径见《设备通信协议-一期.md》§8.2:{@code status = 1 且 last_online_time < now - 120s} → 置离线并写
|
||||
* {@code offline} 告警(级别 2 严重);设备恢复上报遥测后由遥测路径自动回到在线(不写恢复告警)。
|
||||
* <p>
|
||||
* <b>为什么不用 {@code @Scheduling} 注解</b>:本项目的 {@code @EnableScheduling} 挂在
|
||||
* {@code SnailJobConfig} 上,且带 {@code @ConditionalOnProperty(snail-job.enabled=true)}——
|
||||
* 而 dev/prod 当前都是 {@code snail-job.enabled=false}。用 {@code @Scheduled} 会「编译通过、启动正常、
|
||||
* 任务永远不跑」,属于最难发现的一类故障。这里改用 {@code ruoyi-common-core} 提供的全局
|
||||
* {@code scheduledExecutorService} 显式调度,不依赖任何开关;后续若统一迁到 SnailJob,
|
||||
* 只需把 {@link #scan()} 抽成一个 {@code @JobExecutor} 方法,本类其余逻辑不动。
|
||||
* <p>
|
||||
* <b>多实例安全</b>:每个实例都会起调度,因此扫描动作外挂了 Redisson 互斥锁
|
||||
* {@link DeviceMqttConstants#ONLINE_SCAN_LOCK}——拿不到锁的实例本轮直接跳过。
|
||||
* 锁用「无等待时间 + Redisson 看门狗续期」的形态({@code tryLock(0, unit)}),
|
||||
* 扫描耗时超过锁租期也不会被别的实例插进来重复处理。
|
||||
* <p>
|
||||
* <b>异常自愈</b>:{@code ScheduledExecutorService} 有一个著名陷阱——任务抛出异常后会被
|
||||
* <b>静默取消</b>,之后永远不再执行。因此本类所有入口都包在 {@link #scanSafely()} 里兜底捕获,
|
||||
* 任何一轮失败都只影响当轮,下一轮照常。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceOnlineMonitor {
|
||||
|
||||
private final DeviceMqttProperties properties;
|
||||
private final BmDeviceMapper deviceMapper;
|
||||
private final DevicePresenceWriter presenceWriter;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
|
||||
/**
|
||||
* 扫描任务句柄,用于应用关闭时取消
|
||||
*/
|
||||
private volatile ScheduledFuture<?> scanFuture;
|
||||
|
||||
/**
|
||||
* 启动扫描调度。
|
||||
* <p>
|
||||
* 首轮延迟一个扫描间隔,避免与服务启动、MQTT 连接建立抢资源(也避免启动瞬间把
|
||||
* 「尚未收到首包遥测」的设备全判离线)。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
if (!properties.isScanEnabled()) {
|
||||
log.warn("设备离线扫描已关闭(device.mqtt.scan-enabled=false),设备不会再被自动置离线");
|
||||
return;
|
||||
}
|
||||
long interval = properties.getOfflineScanInterval().toSeconds();
|
||||
scanFuture = scheduledExecutorService.scheduleWithFixedDelay(
|
||||
this::scanSafely, interval, interval, TimeUnit.SECONDS);
|
||||
log.info("设备离线扫描已启动 => 间隔: {}s, 离线阈值: {}s, 单轮上限: {} 台",
|
||||
interval, properties.getOfflineThreshold().toSeconds(), properties.getScanBatchSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止扫描调度。
|
||||
*/
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
if (scanFuture != null) {
|
||||
scanFuture.cancel(false);
|
||||
log.info("设备离线扫描已停止");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描入口(吞掉所有异常,保证调度不被取消)。
|
||||
*/
|
||||
private void scanSafely() {
|
||||
try {
|
||||
scan();
|
||||
} catch (Exception e) {
|
||||
log.error("设备离线扫描异常,本轮跳过", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一轮离线扫描。
|
||||
* <p>
|
||||
* 三级判定:先抢跨实例互斥锁 → 再按阈值批量筛候选 → 最后逐台用条件更新原子裁决是否迁移。
|
||||
* 中间任何一步都不直接写告警,告警由 {@link DevicePresenceWriter#markOffline} 在
|
||||
* 「确实发生了在线→离线迁移」时才写。
|
||||
*/
|
||||
void scan() {
|
||||
RLock lock = RedisUtils.getClient().getLock(DeviceMqttConstants.ONLINE_SCAN_LOCK);
|
||||
try {
|
||||
if (!lock.tryLock(0, TimeUnit.SECONDS)) {
|
||||
log.debug("设备离线扫描:其他实例正在扫描,本轮跳过");
|
||||
return;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
LocalDateTime before = LocalDateTime.now().minus(properties.getOfflineThreshold());
|
||||
int batchSize = properties.getScanBatchSize();
|
||||
List<BmDevice> candidates = deviceMapper.selectStaleOnlineDevices(before, batchSize);
|
||||
if (candidates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int offlineCount = 0;
|
||||
for (BmDevice device : candidates) {
|
||||
if (presenceWriter.markOffline(device)) {
|
||||
offlineCount++;
|
||||
}
|
||||
}
|
||||
log.info("设备离线扫描完成 => 候选: {} 台, 实际置离线: {} 台", candidates.size(), offlineCount);
|
||||
if (candidates.size() >= batchSize) {
|
||||
log.warn("设备离线扫描单轮命中已达上限 {},可能存在积压,请关注设备接入量与扫描间隔",
|
||||
batchSize);
|
||||
}
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.device.config.DeviceMqttProperties;
|
||||
import org.dromara.device.constant.DeviceMqttConstants;
|
||||
import org.dromara.device.mqtt.payload.AckPayload;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 待回执指令登记表:把下行指令与上行 {@code ack} 按 {@code msgId} 关联起来。
|
||||
* <p>
|
||||
* 协议 §5.3 确立的唯一凭据原则——「只有 {@code ack.ok} 才代表指令成功」——落到实现上就是本类:
|
||||
* 下发前 {@link #register} 换取一个 {@link CompletableFuture},下发后等待它;回执到达时
|
||||
* {@link #peek} 取出上下文、{@link #settle} 负责唤醒。<b>指令失败/成功不靠遥测里的状态位推断</b>,
|
||||
* 因为眼罩可能被人手动按动。
|
||||
* <p>
|
||||
* <b>为什么回执处理要拆成 peek + settle 两步</b>:{@code peek} 只取上下文不唤醒,调用方可以先把
|
||||
* 回执代表的业务事实写进库(眼罩状态、OTA 任务终态),<b>再</b>{@code settle} 唤醒等待方。
|
||||
* 这样「Future 完成」与「系统已落定」等价——否则 HTTP 线程可能先返回成功、而落库还排在后面,
|
||||
* APP 紧接着刷新详情就会读到旧值。
|
||||
* <p>
|
||||
* <b>为什么用「本实例内存 + Redis 标记」而不是纯 Redis</b>:Future 本身不可序列化,
|
||||
* 跨实例唤醒需要额外的通知通道(Redis pub/sub 或 MQTT 共享订阅)。当前部署下每个实例都订阅了
|
||||
* {@code bm/+/ack},因此发指令的实例必然能收到自己那条回执,内存登记表足够且没有网络往返。
|
||||
* Redis 里只留一条 {@code bm:mqtt:ack:pending:{msgId}} 标记(值为目标设备号),作用是让日志能区分
|
||||
* 「本平台发出但已超时的指令」与「完全未识别的 msgId(伪造或历史残留)」。
|
||||
* <p>
|
||||
* <b>内存安全</b>:登记项由 {@code completeOnTimeout} 兜底——无论回执是否到达,Future 都会在
|
||||
* 超时点完成,{@code whenComplete} 随即把登记项摘掉,因此不存在「设备永不回执导致 Map 无限增长」。
|
||||
* Redis 标记保留到 TTL 自然过期({@link DeviceMqttProperties#getPendingAckTtl()}),
|
||||
* 便于事后定位「回执晚到」。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DevicePendingAckRegistry {
|
||||
|
||||
private final DeviceMqttProperties properties;
|
||||
|
||||
/**
|
||||
* 本实例发起的待回执指令:{@code msgId -> 指令上下文}
|
||||
*/
|
||||
private final Map<String, PendingCommand> pendingCommands = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 登记一条待回执指令。
|
||||
* <p>
|
||||
* <b>调用顺序有硬性要求</b>:必须在 {@code DeviceCommandPublisher} 发布<b>之前</b>调用。
|
||||
* 否则设备回执可能先于登记到达,被 {@link #peek} 判为「未识别的 msgId」而丢弃,
|
||||
* 调用方一直等到超时——这是个只在快网络下偶发的竞态。
|
||||
*
|
||||
* @param msgId 指令唯一标识(由 {@code DeviceCommandPublisher#newMsgId()} 生成)
|
||||
* @param deviceNo 目标设备编号
|
||||
* @param cmd 指令类型({@code hood} / {@code ota})
|
||||
* @param desiredCoverOn 眼罩指令的期望状态(非眼罩指令传 {@code null})
|
||||
* @param otaTaskId OTA 指令关联的任务主键(非 OTA 指令传 {@code null})
|
||||
* @param timeout 回执等待超时
|
||||
* @return 回执完成时被填充的 Future;{@code null} 表示超时未收到回执
|
||||
*/
|
||||
public CompletableFuture<AckPayload> register(String msgId, String deviceNo, String cmd,
|
||||
Boolean desiredCoverOn, Long otaTaskId, Duration timeout) {
|
||||
PendingCommand pending = new PendingCommand(deviceNo, cmd, desiredCoverOn, otaTaskId);
|
||||
pendingCommands.put(msgId, pending);
|
||||
RedisUtils.setCacheObject(DeviceMqttConstants.PENDING_ACK_PREFIX + msgId, deviceNo,
|
||||
properties.getPendingAckTtl());
|
||||
pending.getFuture()
|
||||
.completeOnTimeout(null, timeout.toMillis(), TimeUnit.MILLISECONDS)
|
||||
.whenComplete((ack, ex) -> pendingCommands.remove(msgId, pending));
|
||||
log.info("指令已登记待回执 => deviceNo: {}, cmd: {}, msgId: {}, timeout: {}ms",
|
||||
deviceNo, cmd, msgId, timeout.toMillis());
|
||||
return pending.getFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出回执对应的指令上下文,<b>但不唤醒等待方</b>。
|
||||
* <p>
|
||||
* 调用方应先用返回的上下文落定业务事实(写库),再调用 {@link #settle} 唤醒——
|
||||
* 见类注释「为什么回执处理要拆成两步」。
|
||||
* <p>
|
||||
* 四类不命中都会记日志并返回 {@code null},由调用方直接忽略——回执是 QoS 1,重复投递是正常的。
|
||||
*
|
||||
* @param deviceNo 回执来源设备编号(取自 Topic,不可信报文内字段)
|
||||
* @param ack 回执报文
|
||||
* @return 命中的指令上下文;未命中返回 {@code null}
|
||||
*/
|
||||
public PendingCommand peek(String deviceNo, AckPayload ack) {
|
||||
String msgId = ack.getMsgId();
|
||||
PendingCommand pending = pendingCommands.get(msgId);
|
||||
if (pending == null) {
|
||||
if (RedisUtils.isExistsObject(DeviceMqttConstants.PENDING_ACK_PREFIX + msgId)) {
|
||||
log.info("回执晚到或重复,已忽略 => deviceNo: {}, msgId: {}", deviceNo, msgId);
|
||||
} else {
|
||||
log.warn("未识别的指令回执,已忽略 => deviceNo: {}, msgId: {}", deviceNo, msgId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!pending.getDeviceNo().equals(deviceNo)) {
|
||||
// 防串设备:A 设备的回执不得推进 B 设备的状态
|
||||
log.warn("回执来源与指令目标不一致,已忽略 => 指令设备: {}, 回执来源: {}, msgId: {}",
|
||||
pending.getDeviceNo(), deviceNo, msgId);
|
||||
return null;
|
||||
}
|
||||
if (pending.getFuture().isDone()) {
|
||||
log.info("回执到达时指令已结束(超时或重复),已忽略 => deviceNo: {}, msgId: {}", deviceNo, msgId);
|
||||
return null;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 唤醒等待中的下发方,标志这条指令的等待结束。
|
||||
* <p>
|
||||
* <b>必须与 {@link #peek} 配对使用</b>,且调用方应保证在 {@code settle} 之前已完成业务落库。
|
||||
* 建议放在 {@code finally} 中调用:即使落库抛异常也要唤醒,否则下发方会一直等到超时,
|
||||
* 把「设备已执行、只是回写失败」误报成「指令下发失败」。
|
||||
*
|
||||
* @param pending {@link #peek} 返回的指令上下文
|
||||
* @param ack 回执报文
|
||||
* @return 本次是否真正唤醒了等待方;{@code false} 表示已被超时提前完成(回执属于迟到)
|
||||
*/
|
||||
public boolean settle(PendingCommand pending, AckPayload ack) {
|
||||
boolean woken = pending.getFuture().complete(ack);
|
||||
if (!woken) {
|
||||
log.info("回执迟到,指令已按超时结束,仅落库不回传 => deviceNo: {}, msgId: {}",
|
||||
pending.getDeviceNo(), ack.getMsgId());
|
||||
}
|
||||
return woken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销一条已登记但未能发出的指令。
|
||||
* <p>
|
||||
* 场景:{@link #register} 之后发布失败(MQTT 客户端未装配 / 被拒),此时回执永远不会来。
|
||||
* 不撤销的话,Redis 里会留下一条「正在等回执」的假标记,让运维误判链路状态。
|
||||
* <p>
|
||||
* 内存登记项无需在此清理:Future 未完成,{@code completeOnTimeout} 到期后
|
||||
* {@code whenComplete} 会自行摘除(见类注释「内存安全」)。
|
||||
*
|
||||
* @param msgId 指令唯一标识
|
||||
*/
|
||||
public void abandon(String msgId) {
|
||||
RedisUtils.deleteObject(DeviceMqttConstants.PENDING_ACK_PREFIX + msgId);
|
||||
pendingCommands.remove(msgId);
|
||||
log.warn("指令未成功发出,已撤销待回执登记 => msgId: {}", msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条待回执指令的上下文。
|
||||
* <p>
|
||||
* 记录「下发时想要什么」,使回执成功时能直接落到结果状态上——
|
||||
* {@link AckPayload} 只说明成功与否,不携带眼罩目标状态。
|
||||
*/
|
||||
@Getter
|
||||
public static class PendingCommand {
|
||||
|
||||
/**
|
||||
* 目标设备编号
|
||||
*/
|
||||
private final String deviceNo;
|
||||
|
||||
/**
|
||||
* 指令类型({@code hood} / {@code ota})
|
||||
*/
|
||||
private final String cmd;
|
||||
|
||||
/**
|
||||
* 眼罩指令的期望状态(true 遮盖);非眼罩指令为 {@code null}
|
||||
*/
|
||||
private final Boolean desiredCoverOn;
|
||||
|
||||
/**
|
||||
* OTA 指令关联的任务主键;非 OTA 指令为 {@code null}
|
||||
*/
|
||||
private final Long otaTaskId;
|
||||
|
||||
/**
|
||||
* 回执完成信号
|
||||
*/
|
||||
private final CompletableFuture<AckPayload> future = new CompletableFuture<>();
|
||||
|
||||
/**
|
||||
* 构造指令上下文。
|
||||
*
|
||||
* @param deviceNo 目标设备编号
|
||||
* @param cmd 指令类型
|
||||
* @param desiredCoverOn 眼罩期望状态
|
||||
* @param otaTaskId OTA 任务主键
|
||||
*/
|
||||
public PendingCommand(String deviceNo, String cmd, Boolean desiredCoverOn, Long otaTaskId) {
|
||||
this.deviceNo = deviceNo;
|
||||
this.cmd = cmd;
|
||||
this.desiredCoverOn = desiredCoverOn;
|
||||
this.otaTaskId = otaTaskId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.device.mqtt.payload.ProvisionPayload;
|
||||
import org.dromara.mica.mqtt.codec.MqttQoS;
|
||||
import org.dromara.mica.mqtt.core.annotation.MqttClientSubscribe;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 设备注册上行监听({@code bm/provision/register},QoS 1)。
|
||||
* <p>
|
||||
* 解决「Topic 以 deviceNo 寻址,而设备出厂只知道 IMEI/SN」的引导问题,见协议 §6。
|
||||
* 注册结果由 {@link DeviceUplinkHandler#onRegister} 决定,经
|
||||
* {@link DeviceCommandPublisher#publishProvisionAck} 下发到 {@code bm/provision/ack/{imei}}。
|
||||
* <p>
|
||||
* <b>待 Q1 决策</b>:若选择「出厂烧录 deviceNo」方案,本类连同注册链路整体删除。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceProvisionListener {
|
||||
|
||||
/**
|
||||
* 上行处理端口(可选注入,未装配时仅记日志)
|
||||
*/
|
||||
private final ObjectProvider<DeviceUplinkHandler> handlerProvider;
|
||||
|
||||
/**
|
||||
* 处理设备注册报文。
|
||||
*
|
||||
* @param topic 上行 Topic
|
||||
* @param payload 报文体(UTF-8 JSON)
|
||||
*/
|
||||
@MqttClientSubscribe(value = MqttTopics.PROVISION_REGISTER_SUB, qos = MqttQoS.QOS1)
|
||||
public void onMessage(String topic, byte[] payload) {
|
||||
ProvisionPayload data;
|
||||
try {
|
||||
data = JsonUtils.parseObject(payload, ProvisionPayload.class);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("注册报文解析失败,已丢弃 => payload: {}",
|
||||
new String(payload, StandardCharsets.UTF_8), e);
|
||||
return;
|
||||
}
|
||||
String invalid = data == null ? "报文体为空" : data.invalidReason();
|
||||
if (invalid != null) {
|
||||
log.warn("注册报文校验失败,已丢弃 => reason: {}", invalid);
|
||||
return;
|
||||
}
|
||||
DeviceUplinkHandler handler = handlerProvider.getIfAvailable();
|
||||
if (handler == null) {
|
||||
log.info("未装配 DeviceUplinkHandler,注册仅记录 => imei: {}, sn: {}", data.getImei(), data.getSn());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handler.onRegister(data);
|
||||
} catch (Exception e) {
|
||||
log.error("注册处理异常 => imei: {}", data.getImei(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.device.mqtt.payload.TelemetryPayload;
|
||||
import org.dromara.mica.mqtt.codec.MqttQoS;
|
||||
import org.dromara.mica.mqtt.core.annotation.MqttClientSubscribe;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 遥测上行监听({@code bm/+/telemetry},QoS 0)。
|
||||
* <p>
|
||||
* 只做「解析 + 结构校验 + 转发」,业务处理见 {@link DeviceUplinkHandler}。
|
||||
* 丢弃策略(见协议 §5.1):Topic 非法、JSON 解析失败、必填字段缺失或越界 → 整包丢弃并记 WARN,
|
||||
* <b>不落库</b>,避免脏数据;QoS 0 语义下丢弃是允许的(下一条遥测会整份覆盖最新态)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceTelemetryListener {
|
||||
|
||||
/**
|
||||
* 上行处理端口(可选注入,未装配时仅记日志)
|
||||
*/
|
||||
private final ObjectProvider<DeviceUplinkHandler> handlerProvider;
|
||||
|
||||
/**
|
||||
* 处理遥测报文。
|
||||
*
|
||||
* @param topic 上行 Topic
|
||||
* @param payload 报文体(UTF-8 JSON)
|
||||
*/
|
||||
@MqttClientSubscribe(value = MqttTopics.TELEMETRY_SUB, qos = MqttQoS.QOS0)
|
||||
public void onMessage(String topic, byte[] payload) {
|
||||
String deviceNo = MqttTopics.deviceNoOf(topic);
|
||||
if (deviceNo == null) {
|
||||
log.warn("遥测 Topic 非法,已丢弃 => topic: {}", topic);
|
||||
return;
|
||||
}
|
||||
TelemetryPayload data;
|
||||
try {
|
||||
data = JsonUtils.parseObject(payload, TelemetryPayload.class);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("遥测报文解析失败,已丢弃 => deviceNo: {}, payload: {}",
|
||||
deviceNo, new String(payload, StandardCharsets.UTF_8), e);
|
||||
return;
|
||||
}
|
||||
if (data == null) {
|
||||
log.warn("遥测报文为空,已丢弃 => deviceNo: {}", deviceNo);
|
||||
return;
|
||||
}
|
||||
String invalid = data.invalidReason();
|
||||
if (invalid != null) {
|
||||
log.warn("遥测报文校验失败,已丢弃 => deviceNo: {}, reason: {}", deviceNo, invalid);
|
||||
return;
|
||||
}
|
||||
DeviceUplinkHandler handler = handlerProvider.getIfAvailable();
|
||||
if (handler == null) {
|
||||
log.debug("未装配 DeviceUplinkHandler,遥测仅记录不落库 => deviceNo: {}", deviceNo);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handler.onTelemetry(deviceNo, data);
|
||||
} catch (Exception e) {
|
||||
log.error("遥测处理异常 => deviceNo: {}", deviceNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user