feat(device): 一期设备域 I1-I5 全量实现(APP 接口/后台管理/OTA/MQTT/直播)

模块接入:
- 新增 ruoyi-device(org.dromara.device)并注册到根/ruoyi-modules/ruoyi-admin pom

APP 侧(/app/**,需登录,仅本人绑定设备):
- 账号闭环:短信/邮箱验证码注册登录、密码重置(不跨界返回散列)
- 设备:绑定/列表/详情/重命名/解绑(越权统一"设备不存在",防 IDOR)
- 眼罩开关(/app/device/{deviceNo}/hood,低电不对称拦截)
- 定位(/app/device/{deviceNo}/location)
- 直播(/app/stream 6 端点 + /app/device/{deviceNo}/live,自建 ZLMediaKit,设备主动推流)

后台(/device/**):
- 设备 CRUD/统计/删除/导出(ExcelBuilder)
- OTA(6 端点:升级包管理 + 批量下发,total==created+skipped+failed)

MQTT(T05):
- 遥测/事件/ack 监听、指令下发+回执(防串设备)、离线扫描、低电告警
- 报文身份仅来自 Topic,反查 device_id 防跨设备伪造

数据与脚本:
- script/sql/ry_device.sql(6 表/字典/权限字/菜单)、bm_patch.sql
- script/mqtt 设备模拟器(mqtt-simulate.js)

安全:
- MQTT broker 凭据外置为环境变量 MQTT_USERNAME/MQTT_PASSWORD,不落明文
- ZLM webhook 密钥外置为环境变量 ZLM_SECRET(未配置时 /zlm/hook/** 失败关闭),不落明文
This commit is contained in:
yuhaiming
2026-09-17 10:10:38 +08:00
parent 926426fe4f
commit 79b739fe02
142 changed files with 14627 additions and 7 deletions

View File

@@ -457,6 +457,13 @@
<version>${revision}</version> <version>${revision}</version>
</dependency> </dependency>
<!-- 设备模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-device</artifactId>
<version>${revision}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -98,6 +98,12 @@
<artifactId>ruoyi-demo</artifactId> <artifactId>ruoyi-demo</artifactId>
</dependency> </dependency>
<!-- 设备模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-device</artifactId>
</dependency>
<!-- 工作流模块 --> <!-- 工作流模块 -->
<dependency> <dependency>
<groupId>org.dromara</groupId> <groupId>org.dromara</groupId>

View File

@@ -14,7 +14,17 @@ server:
min: 8 min: 8
# 最大线程数 # 最大线程数
max: 256 max: 256
zlm:
host: ${ZLM_HOST:http://220.203.23.195:9090} # ZLMediaKit 地址
rtmp-host: ${ZLM_RTMP_HOST:rtmp://220.203.23.195:1935}
rtsp-host: ${ZLM_RTSP_HOST:rtsp://220.203.23.195:554}
webrtc-host: ${ZLM_WEBRTC_HOST:http://220.203.23.195:9090}
secret: ${ZLM_SECRET:} # ZLM webhook 密钥:不落明文,必须通过环境变量 ZLM_SECRET 注入;未配置时 /zlm/hook/** 回调一律拒绝(失败关闭)
app: live
schema: rtmp
push-protocol: rtmp
play-expire-seconds: 120
stream-timeout: 30 # 流超时秒数
captcha: captcha:
# 是否启用验证码校验 # 是否启用验证码校验
enable: true enable: true
@@ -111,6 +121,7 @@ security:
- /warm-flow-ui/config - /warm-flow-ui/config
- /snail-chat/** - /snail-chat/**
- /api/snail/chat/** - /api/snail/chat/**
- /zlm/hook/**
# MyBatisPlus配置 # MyBatisPlus配置
# https://baomidou.com/config/ # https://baomidou.com/config/
@@ -264,15 +275,17 @@ mqtt.client:
# 是否开启客户端,默认:true # 是否开启客户端,默认:true
enabled: true enabled: true
# 连接的服务端 ip ,默认:127.0.0.1 # 连接的服务端 ip ,默认:127.0.0.1
ip: ssl://www.mmipco.cn ip: 47.97.217.123
# 端口:默认:1883 # 端口:默认:1883
port: 8883 port: 1883
# 客户端名称 # 客户端名称
name: Mqtt-Client name: Mqtt-Client
# 客户端Id(非常重要,一般为设备 sn,不可重复) # 客户端Id(非常重要,一般为设备 sn,不可重复)
client-id: 000001 client-id: bird_monitor
username: admin # MQTT broker 账号/密码:不落明文,通过环境变量注入(避免凭据进入 git 历史)。
password: 61e6062129e9 # 联调前 export MQTT_USERNAME / MQTT_PASSWORD;未配置时取空值,broker 会拒绝认证。
username: ${MQTT_USERNAME:}
password: ${MQTT_PASSWORD:}
# 超时时间,单位:秒,默认:5秒 # 超时时间,单位:秒,默认:5秒
timeout: 5 timeout: 5
# 重连时间,默认 5000 毫秒 # 重连时间,默认 5000 毫秒
@@ -291,12 +304,35 @@ mqtt.client:
mqtt-executor-size: 2 mqtt-executor-size: 2
# 是否开启 ssl # 是否开启 ssl
ssl: ssl:
enabled: true enabled: false
keystore-path: keystore-path:
keystore-pass: keystore-pass:
truststore-path: truststore-path:
truststore-pass: 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 功能配置 --- # elasticsearch 功能配置
# 文档地址: https://www.easy-es.cn/ # 文档地址: https://www.easy-es.cn/
# 更改包名需要去 EasyEsConfiguration 修改包扫描(后续版本支持配置文件读取) # 更改包名需要去 EasyEsConfiguration 修改包扫描(后续版本支持配置文件读取)

View File

@@ -0,0 +1,137 @@
package org.dromara.system.api;
import org.dromara.system.api.domain.AppUserDTO;
/**
* 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);
}

View File

@@ -0,0 +1,68 @@
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;
}

View File

@@ -22,6 +22,7 @@
<module>ruoyi-system</module> <module>ruoyi-system</module>
<module>ruoyi-workflow</module> <module>ruoyi-workflow</module>
<module>ruoyi-ai</module> <module>ruoyi-ai</module>
<module>ruoyi-device</module>
</modules> </modules>
</project> </project>

View 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>

View File

@@ -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());
}
}

View File

@@ -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 = "";
}
}
}

View File

@@ -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)将因此拒绝启动。");
}
}

View File

@@ -0,0 +1,104 @@
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} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。
*/
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);
}

View File

@@ -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;
}

View File

@@ -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:";
}

View File

@@ -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 = "协议类型不合法";
}

View File

@@ -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;
}

View File

@@ -0,0 +1,221 @@
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>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]指令下发失败,已失败";
}

View File

@@ -0,0 +1,135 @@
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}。
*
* @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);
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,151 @@
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) {
appAuthService.sendSmsCode(bo);
return R.ok(AppMsgConstants.SMS_CODE_SENT);
}
/**
* 手机号注册。
*/
@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);
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,167 @@
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;
/**
* 最新经度
*/
private BigDecimal lng;
/**
* 最新纬度
*/
private BigDecimal lat;
/**
* 地址(设备上报)
*/
private String address;
/**
* 最后在线时间
*/
private LocalDateTime lastOnlineTime;
/**
* 备注
*/
private String remark;
/**
* 逻辑删除(0 存在 1 删除)
*/
@TableLogic
private String delFlag;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,166 @@
package org.dromara.device.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
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;
/**
* 最新经度
*/
private BigDecimal lng;
/**
* 最新纬度
*/
private BigDecimal lat;
/**
* 地址
*/
private String address;
/**
* 最后在线时间
*/
private LocalDateTime lastOnlineTime;
/**
* 备注
*/
private String remark;
/**
* 关键字(模糊匹配 deviceNo / name / imei,查询用)
*/
private String keyword;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,207 @@
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;
/**
* 最新经度
*/
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;
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>";
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,78 @@
package org.dromara.device.mqtt;
import org.dromara.device.mqtt.payload.AckPayload;
import org.dromara.device.mqtt.payload.EventPayload;
import org.dromara.device.mqtt.payload.ProvisionPayload;
import org.dromara.device.mqtt.payload.TelemetryPayload;
/**
* 上行报文处理端口。
* <p>
* <b>为什么单独抽这一层</b>:MQTT 监听器只负责「收包 → 解析 → 校验 → 转发」,
* 不掺业务;持久化与状态流转(写主表最新态、落遥测历史、生成告警、唤醒指令
* {@code CompletableFuture}、OTA 进度)属于业务层职责,由 I6(MQTT 生产化)落地实现。
* <p>
* 监听器通过 {@code ObjectProvider} <b>可选注入</b>本接口:未装配实现时只记录日志,
* 不阻断应用启动——这样 mqtt 包可以先合入而不必等业务实现到位。
* <p>
* <b>实现方必须遵守的约定</b>:
* <ul>
* <li>本方法运行在 mica-mqtt 的业务线程池(虚拟线程)上,<b>不得阻塞</b>超过毫秒级;
* 耗时的落库、HTTP 调用须转投线程池或消息队列;</li>
* <li>异常必须自行捕获,不得向监听器抛出(否则会污染 MQTT 客户端的消息处理链路);</li>
* <li>设备身份一律以参数 {@code deviceNo} 为准(取自 Topic),不得信任报文内字段。</li>
* </ul>
*
* @author Kou
* @date 2026-09-16
*/
public interface DeviceUplinkHandler {
/**
* 处理遥测上报。
* <p>
* 约定动作:{@code markOnline(deviceNo, now)}(status 由 0/2 → 1、刷新 last_online_time)
* + 整份覆盖主表最新态 + 异步 INSERT 遥测历史 + 按阈值生成/恢复低电告警。
*
* @param deviceNo 设备编号(取自 Topic)
* @param payload 遥测报文(已通过结构校验)
*/
void onTelemetry(String deviceNo, TelemetryPayload payload);
/**
* 处理设备事件。
* <p>
* 约定动作:{@code online}/{@code offline} 更新在线态与告警;
* {@code ota_progress} 更新 {@code bm_ota_task.progress};
* {@code low_battery_*} 仅作提前提示,须按 {@code deviceNo + alertType} 做去重窗口后
* 才允许落 {@code bm_device_alert}(见协议 §5.2)。
*
* @param deviceNo 设备编号(取自 Topic)
* @param payload 事件报文(已通过结构校验)
*/
void onEvent(String deviceNo, EventPayload payload);
/**
* 处理指令回执。
* <p>
* 约定动作:以 {@code msgId} 唤醒指令服务中注册的 {@code CompletableFuture};
* 仅当 {@link AckPayload#success()} 为真时更新设备状态字段(如 {@code hood_cover_on})。
*
* @param deviceNo 设备编号(取自 Topic)
* @param payload 回执报文(已通过结构校验)
*/
void onAck(String deviceNo, AckPayload payload);
/**
* 处理设备注册申请。
* <p>
* 约定动作:按 IMEI 回查已建档设备,成功则下发 {@code ProvisionAckPayload.success(...)},
* 未建档则下发失败(404)并记录为私拆/串货线索。
* <p>
* <b>待 Q1 决策</b>:若选择「出厂烧录 deviceNo」方案,本方法连同注册链路整体删除。
*
* @param payload 注册报文(已通过结构校验)
*/
void onRegister(ProvisionPayload payload);
}

View File

@@ -0,0 +1,227 @@
package org.dromara.device.mqtt;
/**
* 设备通信 Topic 常量与构造器。
* <p>
* 口径见《设备通信协议-一期.md》§3:后缀固定为 {@code bm/{deviceNo}/{业务}},
* {@code deviceNo} 形如 {@code BM202609160001}({@code BM} + {@code yyyyMMdd} + 4 位当日序号,14 位);
* 注册环节(设备尚不知自身 deviceNo)按 IMEI 寻址。
* <p>
* <b>约定</b>:
* <ul>
* <li>设备端<b>只允许订阅</b> {@code bm/{自身deviceNo}/command/#},禁止跨设备通配订阅;</li>
* <li>所有 Topic Retain 一律为 {@code false},在线态以心跳判定为准;</li>
* <li>新增业务只允许新增叶子节点,不得改变既有层级语义。</li>
* </ul>
*
* @author Kou
* @date 2026-09-16
*/
public final class MqttTopics {
private MqttTopics() {
}
/**
* Topic 根前缀
*/
public static final String ROOT = "bm";
/**
* 注册叶子(注册链路专用,非常规设备身份)
*/
public static final String PROVISION = "provision";
// ---------------- 服务端订阅用的通配 Topic ----------------
/**
* 上行遥测(服务端订阅)
*/
public static final String TELEMETRY_SUB = "bm/+/telemetry";
/**
* 上行事件(服务端订阅)
*/
public static final String EVENT_SUB = "bm/+/event";
/**
* 上行指令回执(服务端订阅)
*/
public static final String ACK_SUB = "bm/+/ack";
/**
* 上行设备注册(服务端订阅,待 Q1 决策:若采用出厂烧录方案可移除)
*/
public static final String PROVISION_REGISTER_SUB = "bm/provision/register";
// ---------------- 叶子后缀 ----------------
/**
* 遥测叶子
*/
private static final String LEAF_TELEMETRY = "telemetry";
/**
* 事件叶子
*/
private static final String LEAF_EVENT = "event";
/**
* 回执叶子
*/
private static final String LEAF_ACK = "ack";
/**
* 下行眼罩指令叶子
*/
private static final String LEAF_COMMAND_HOOD = "command/hood";
/**
* 下行 OTA 指令叶子
*/
private static final String LEAF_COMMAND_OTA = "command/ota";
/**
* 下行直播推流指令叶子
*/
private static final String LEAF_COMMAND_STREAM = "command/stream";
/**
* 下行注册应答前缀
*/
private static final String PROVISION_ACK_PREFIX = "bm/provision/ack/";
/**
* 设备编号正则:BM + 8 位日期 + 4 位序号
*/
private static final String DEVICE_NO_REGEX = "^BM\\d{12}$";
/**
* IMEI 正则:15 位数字
*/
private static final String IMEI_REGEX = "^\\d{15}$";
/**
* 构造遥测 Topic。
*
* @param deviceNo 设备编号
* @return {@code bm/{deviceNo}/telemetry}
*/
public static String telemetry(String deviceNo) {
return ROOT + "/" + deviceNo + "/" + LEAF_TELEMETRY;
}
/**
* 构造事件 Topic。
*
* @param deviceNo 设备编号
* @return {@code bm/{deviceNo}/event}
*/
public static String event(String deviceNo) {
return ROOT + "/" + deviceNo + "/" + LEAF_EVENT;
}
/**
* 构造回执 Topic。
*
* @param deviceNo 设备编号
* @return {@code bm/{deviceNo}/ack}
*/
public static String ack(String deviceNo) {
return ROOT + "/" + deviceNo + "/" + LEAF_ACK;
}
/**
* 构造眼罩指令 Topic(下行)。
*
* @param deviceNo 设备编号
* @return {@code bm/{deviceNo}/command/hood}
*/
public static String commandHood(String deviceNo) {
return ROOT + "/" + deviceNo + "/" + LEAF_COMMAND_HOOD;
}
/**
* 构造 OTA 指令 Topic(下行)。
*
* @param deviceNo 设备编号
* @return {@code bm/{deviceNo}/command/ota}
*/
public static String commandOta(String deviceNo) {
return ROOT + "/" + deviceNo + "/" + LEAF_COMMAND_OTA;
}
/**
* 构造直播推流指令 Topic(下行)。
*
* @param deviceNo 设备编号
* @return {@code bm/{deviceNo}/command/stream}
*/
public static String commandStream(String deviceNo) {
return ROOT + "/" + deviceNo + "/" + LEAF_COMMAND_STREAM;
}
/**
* 构造设备端应订阅的指令通配 Topic。
* <p>
* 仅用于设备侧实现与联调校验;服务端不使用该通配。
*
* @param deviceNo 设备编号
* @return {@code bm/{deviceNo}/command/#}
*/
public static String commandWildcard(String deviceNo) {
return ROOT + "/" + deviceNo + "/command/#";
}
/**
* 构造注册应答 Topic(下行,按 IMEI 寻址)。
*
* @param imei 设备 IMEI
* @return {@code bm/provision/ack/{imei}}
*/
public static String provisionAck(String imei) {
return PROVISION_ACK_PREFIX + imei;
}
/**
* 从上行 Topic 中解析设备编号。
* <p>
* 仅接受 3 段结构 {@code bm/{deviceNo}/{leaf}},且 {@code deviceNo} 必须符合
* {@code BM + yyyyMMdd + 4 位序号}。无法解析时返回 {@code null}(调用方丢弃该报文)。
* 该方法是「身份只来自 Topic、报文不含 deviceNo」这一防伪造约定的落点。
*
* @param topic 上行 Topic
* @return 设备编号;非法 Topic 返回 {@code null}
*/
public static String deviceNoOf(String topic) {
if (topic == null) {
return null;
}
String[] parts = topic.split("/");
if (parts.length != 3 || !ROOT.equals(parts[0]) || PROVISION.equals(parts[1])) {
return null;
}
return isValidDeviceNo(parts[1]) ? parts[1] : null;
}
/**
* 校验设备编号格式。
*
* @param deviceNo 设备编号
* @return 是否合法
*/
public static boolean isValidDeviceNo(String deviceNo) {
return deviceNo != null && deviceNo.matches(DEVICE_NO_REGEX);
}
/**
* 校验 IMEI 格式。
*
* @param imei 设备 IMEI
* @return 是否合法
*/
public static boolean isValidImei(String imei) {
return imei != null && imei.matches(IMEI_REGEX);
}
}

View File

@@ -0,0 +1,407 @@
package org.dromara.device.mqtt.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.device.constant.DeviceMqttConstants;
import org.dromara.device.domain.BmDevice;
import org.dromara.device.domain.BmOtaTask;
import org.dromara.device.enums.AlertTypeEnum;
import org.dromara.device.enums.DeviceStatusEnum;
import org.dromara.device.enums.HoodConnectedEnum;
import org.dromara.device.enums.HoodCoverEnum;
import org.dromara.device.enums.OtaTaskStatusEnum;
import org.dromara.device.mapper.BmDeviceMapper;
import org.dromara.device.mapper.BmOtaTaskMapper;
import org.dromara.device.mqtt.DeviceCommandPublisher;
import org.dromara.device.mqtt.DevicePendingAckRegistry;
import org.dromara.device.mqtt.DeviceUplinkHandler;
import org.dromara.device.mqtt.payload.AckPayload;
import org.dromara.device.mqtt.payload.EventPayload;
import org.dromara.device.mqtt.payload.EventType;
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.ProvisionPayload;
import org.dromara.device.mqtt.payload.TelemetryPayload;
import org.dromara.device.mqtt.support.DeviceAlertWriter;
import org.dromara.device.mqtt.support.DevicePresenceWriter;
import org.dromara.device.mqtt.support.DeviceTelemetryArchiver;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* {@link DeviceUplinkHandler} 的默认实现(I6 · MQTT 生产化)。
* <p>
* 只做「协议报文 → 业务事实」的翻译与编排,具体写入策略分别落在:
* 在线态迁移 {@link DevicePresenceWriter}、告警判定 {@link DeviceAlertWriter}、
* 遥测归档 {@link DeviceTelemetryArchiver}、指令回执关联 {@link DevicePendingAckRegistry}。
* 这样切换/替换任一策略都不需要改本类。
* <p>
* <b>贯穿全类的四条约定</b>:
* <ol>
* <li><b>身份只来自 Topic</b>:所有入口的 {@code deviceNo} 都由监听器从 Topic 解析后传入,
* 报文里没有设备号,因此伪造报文也无法冒充别的设备。</li>
* <li><b>未建档即丢弃</b>:{@code bm_device} 里查不到该 {@code deviceNo}(后台未建档 / 已逻辑删除)
* 时不落任何数据——无法归属的遥测没有存储价值,反而会污染统计。</li>
* <li><b>异常不外抛</b>:本类运行在 mica-mqtt 业务线程上,任何异常都就地消化并记日志,
* 避免污染 MQTT 客户端的消息处理链路。</li>
* <li><b>即时返回</b>:除设备最新态与告警(不可丢)外,其余耗时写入走异步;本类不做阻塞等待。</li>
* </ol>
*
* @author Kou
* @date 2026-09-16
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler {
private final BmDeviceMapper deviceMapper;
private final BmOtaTaskMapper otaTaskMapper;
private final DeviceAlertWriter alertWriter;
private final DevicePresenceWriter presenceWriter;
private final DeviceTelemetryArchiver telemetryArchiver;
private final DevicePendingAckRegistry ackRegistry;
private final DeviceCommandPublisher commandPublisher;
/**
* 处理遥测上报。
* <p>
* 三步:① 一条 UPDATE 完成「在线态回归 + 心跳刷新 + 最新态整份覆盖」(合并写入,避免每个字段一次 SQL);
* ② 异步归档历史;③ 按阈值判定头套/脚环低电。
* <p>
* <b>先写主表、后写告警</b>:若 UPDATE 影响行数为 0(设备在两次上报之间被删除),
* 则直接返回——否则会给一台已不存在的设备写告警。
*
* @param deviceNo 设备编号(取自 Topic)
* @param payload 遥测报文(已通过结构校验)
*/
@Override
public void onTelemetry(String deviceNo, TelemetryPayload payload) {
BmDevice device = findDevice(deviceNo);
if (device == null) {
return;
}
LocalDateTime receivedAt = LocalDateTime.now();
try {
int rows = deviceMapper.lambda()
.set(BmDevice::getStatus, DeviceStatusEnum.ONLINE.getCode())
.set(BmDevice::getLastOnlineTime, receivedAt)
.set(BmDevice::getHoodBattery, payload.getHoodBattery())
.set(BmDevice::getRingBattery, payload.getRingBattery())
.set(BmDevice::getSignalLevel, payload.getSignalLevel())
.set(BmDevice::getHoodConnected, hoodConnectedCode(payload.getHoodConnected()))
.set(BmDevice::getHoodCoverOn, hoodCoverCode(payload.getHoodCoverOn()))
// 定位三件套「有值才覆盖」:设备端解析失败时会上报 null,
// 若照直覆盖会把上一次的有效坐标抹掉,反而丢失轨迹信息
.setIfPresent(BmDevice::getLng, payload.getLng())
.setIfPresent(BmDevice::getLat, payload.getLat())
.setIfText(BmDevice::getAddress, payload.getAddress())
.set(BmDevice::getUpdateTime, receivedAt)
.eq(BmDevice::getDeviceId, device.getDeviceId())
.updateCount();
if (rows <= 0) {
log.warn("遥测丢弃:设备已不存在 => deviceNo: {}", deviceNo);
return;
}
telemetryArchiver.archive(device, payload, receivedAt);
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery());
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery());
} catch (Exception e) {
log.error("遥测处理异常 => deviceNo: {}", deviceNo, e);
}
}
/**
* 处理设备事件。
* <p>
* 各类型的落点见协议 §5.2。此处只做分发,判定逻辑都在下游组件里。
*
* @param deviceNo 设备编号(取自 Topic)
* @param payload 事件报文(已通过结构校验)
*/
@Override
public void onEvent(String deviceNo, EventPayload payload) {
EventType type = payload.resolveType();
if (type == null) {
// 监听器已按前向兼容丢弃未知类型,这里做一次兜底
return;
}
BmDevice device = findDevice(deviceNo);
if (device == null) {
return;
}
try {
switch (type) {
case ONLINE -> presenceWriter.markOnline(deviceNo);
case OFFLINE -> presenceWriter.markOffline(device);
case OTA_PROGRESS -> updateOtaProgress(device, payload);
case LOW_BATTERY_HOOD -> alertWriter.writeLowBatteryHint(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getValue());
case LOW_BATTERY_RING -> alertWriter.writeLowBatteryHint(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_RING, payload.getValue());
}
} catch (Exception e) {
log.error("事件处理异常 => deviceNo: {}, eventType: {}", deviceNo, payload.getEventType(), e);
}
}
/**
* 处理指令回执。
* <p>
* 回执是「指令成功」的唯一凭据(协议 §5.3)。流程刻意分成三步,顺序不可调换:
* <ol>
* <li>{@code peek} —— 按 {@code msgId} 取出下发时登记的上下文(此时<b>不</b>唤醒下发方);</li>
* <li>落定业务事实 —— 仅在成功时把指令期望状态写到设备上(OTA 则收尾任务);</li>
* <li>{@code settle} —— 唤醒等待中的下发方。</li>
* </ol>
* <b>为什么必须先落库再唤醒</b>:唤醒后 HTTP 线程会立刻把「成功」返回给 APP,
* 用户紧接着刷新详情页。若落库排在唤醒之后,这次刷新有概率读到旧值——
* 一个只在快网络下偶发、却极容易被当成「指令没生效」的竞态。
* <p>
* {@code settle} 放在 {@code finally}:落库抛异常也要唤醒,否则下发方会一直等到超时,
* 把「设备已执行、只是回写失败」误报成「指令下发失败」。
* <p>
* 失败回执不修改设备状态——眼罩没动,主表就该保持原样;等下一次遥测覆盖即可。
*
* @param deviceNo 设备编号(取自 Topic)
* @param payload 回执报文(已通过结构校验)
*/
@Override
public void onAck(String deviceNo, AckPayload payload) {
DevicePendingAckRegistry.PendingCommand pending = ackRegistry.peek(deviceNo, payload);
if (pending == null) {
return;
}
boolean success = payload.success();
log.info("指令回执 => deviceNo: {}, cmd: {}, msgId: {}, success: {}, code: {}, msg: {}",
deviceNo, pending.getCmd(), payload.getMsgId(), success, payload.getCode(), payload.getMsg());
try {
if (HoodCommandPayload.CMD.equals(pending.getCmd())) {
applyHoodResult(deviceNo, pending, success);
} else if (OtaCommandPayload.CMD.equals(pending.getCmd())) {
finishOtaTask(pending, payload, success);
} else {
log.warn("未知指令类型的回执,仅记录不处理 => deviceNo: {}, cmd: {}", deviceNo, pending.getCmd());
}
} catch (Exception e) {
log.error("回执处理异常 => deviceNo: {}, msgId: {}", deviceNo, payload.getMsgId(), e);
} finally {
ackRegistry.settle(pending, payload);
}
}
/**
* 处理设备注册申请。
* <p>
* 解决「Topic 以 {@code deviceNo} 寻址,而设备出厂只知道 IMEI」的引导问题(协议 §6)。
* 建档设备回 {@code deviceNo} 并顺势补全出厂字段;未建档设备回 404 并留痕——
* 这是「私拆、串货」最早的线索来源。
*
* @param payload 注册报文(已通过结构校验)
*/
@Override
public void onRegister(ProvisionPayload payload) {
String imei = payload.getImei();
try {
BmDevice device = deviceMapper.lambda()
.eq(BmDevice::getImei, imei)
.one();
if (device == null) {
log.warn("设备注册被拒(IMEI 未建档,需排查私拆/串货) => imei: {}, sn: {}, model: {}, fwVersion: {}",
imei, payload.getSn(), payload.getModel(), payload.getFwVersion());
commandPublisher.publishProvisionAck(imei, ProvisionAckPayload.fail(
imei, DeviceMqttConstants.PROVISION_CODE_NOT_REGISTERED, "device not registered"));
return;
}
completeProfile(device, payload);
commandPublisher.publishProvisionAck(imei,
ProvisionAckPayload.success(imei, device.getDeviceNo()));
log.info("设备注册成功 => imei: {}, deviceNo: {}", imei, device.getDeviceNo());
} catch (Exception e) {
log.error("注册处理异常 => imei: {}", imei, e);
}
}
// ---------------- 以下为内部实现 ----------------
/**
* 按设备编号查询建档设备。
* <p>
* 查不到(后台未建档 / 已逻辑删除)时记 WARN 并返回 {@code null},由调用方丢弃该报文。
*
* @param deviceNo 设备编号
* @return 设备实体;未建档返回 {@code null}
*/
private BmDevice findDevice(String deviceNo) {
BmDevice device = deviceMapper.lambda()
.eq(BmDevice::getDeviceNo, deviceNo)
.one();
if (device == null) {
log.warn("设备未建档或已删除,报文丢弃 => deviceNo: {}", deviceNo);
}
return device;
}
/**
* 眼罩指令回执成功后回写设备状态。
* <p>
* 回执报文只说明「成功」,不携带目标状态,因此期望值取自下发时登记的上下文
* ({@link DevicePendingAckRegistry.PendingCommand#getDesiredCoverOn()})。
* 本次写入是<b>乐观的即时反馈</b>,最终真相仍以下一包遥测为准(遥测整份覆盖)。
*
* @param deviceNo 设备编号
* @param pending 指令上下文
* @param success 回执是否成功
*/
private void applyHoodResult(String deviceNo, DevicePendingAckRegistry.PendingCommand pending, boolean success) {
if (!success || pending.getDesiredCoverOn() == null) {
return;
}
String coverCode = pending.getDesiredCoverOn()
? HoodCoverEnum.COVERED.getCode() : HoodCoverEnum.OPEN.getCode();
int rows = deviceMapper.lambda()
.set(BmDevice::getHoodCoverOn, coverCode)
.set(BmDevice::getUpdateTime, LocalDateTime.now())
.eq(BmDevice::getDeviceNo, deviceNo)
.updateCount();
if (rows <= 0) {
log.warn("眼罩状态回写失败:设备不存在 => deviceNo: {}", deviceNo);
}
}
/**
* 按 OTA 回执收尾任务。
* <p>
* 状态落点见协议 §12:{@code code = 0} → 成功(progress 100);其余 → 失败。
* 失败时保留最后一次进度事件写入的 {@code progress},便于定位卡在哪一步。
*
* @param pending 指令上下文(携带 taskId)
* @param payload 回执报文
* @param success 回执是否成功
*/
private void finishOtaTask(DevicePendingAckRegistry.PendingCommand pending, AckPayload payload, boolean success) {
Long taskId = pending.getOtaTaskId();
if (taskId == null) {
log.warn("OTA 回执缺少任务关联,仅记录不回写 => msgId: {}", payload.getMsgId());
return;
}
LocalDateTime now = LocalDateTime.now();
var chain = otaTaskMapper.lambda()
.set(BmOtaTask::getStatus, success
? OtaTaskStatusEnum.SUCCESS.getCode() : OtaTaskStatusEnum.FAILED.getCode())
.set(BmOtaTask::getResultMsg, payload.getMsg())
.set(BmOtaTask::getFinishTime, now)
.set(BmOtaTask::getUpdateTime, now);
if (success) {
chain.set(BmOtaTask::getProgress, 100);
}
int rows = chain.eq(BmOtaTask::getTaskId, taskId).updateCount();
if (rows <= 0) {
log.warn("OTA 任务回写失败:任务不存在 => taskId: {}", taskId);
}
}
/**
* 按事件更新 OTA 任务进度。
* <p>
* 只更新<b>进行中</b>的任务(待升级 / 升级中)。若任务已成功或失败,说明进度事件
* 与最终回执乱序到达,此时丢弃进度是有意为之——协议约定「进度事件不承载结果」,
* 不能让它把已终态的任务改回升级中。
*
* @param device 设备
* @param payload 事件报文
*/
private void updateOtaProgress(BmDevice device, EventPayload payload) {
Integer progress = payload.getValue();
if (progress == null || progress < 0 || progress > 100) {
log.warn("OTA 进度取值非法,已忽略 => deviceNo: {}, value: {}", device.getDeviceNo(), progress);
return;
}
int rows = otaTaskMapper.lambda()
.set(BmOtaTask::getProgress, progress)
.set(BmOtaTask::getUpdateTime, LocalDateTime.now())
.in(BmOtaTask::getStatus, OtaTaskStatusEnum.PENDING.getCode(), OtaTaskStatusEnum.UPGRADING.getCode())
.eq(BmOtaTask::getDeviceNo, device.getDeviceNo())
.updateCount();
if (rows <= 0) {
log.debug("无进行中的 OTA 任务,进度事件已忽略 => deviceNo: {}, progress: {}",
device.getDeviceNo(), progress);
return;
}
log.info("OTA 进度 => deviceNo: {}, version: {}, progress: {}",
device.getDeviceNo(), payload.getVersion(), progress);
}
/**
* 首次注册时补全建档字段。
* <p>
* <b>只补空字段,不覆盖已有值</b>:后台人工维护的数据优先于设备自报;设备自报里
* 唯一真正需要保持同步的是版本号,但那也应由 OTA 流程显式更新,避免注册报文
* 把后台刚改的状态悄悄改回去。
* <p>
* 用 {@code updateById} + 实体承载变更:MyBatis-Plus 默认空值策略为 NOT_NULL,
* 未被 set 的字段不会进入 SQL,因此天然满足「只更新变化字段」。
*
* @param device 已建档设备(含最新库值)
* @param payload 注册报文
*/
private void completeProfile(BmDevice device, ProvisionPayload payload) {
BmDevice update = new BmDevice();
update.setDeviceId(device.getDeviceId());
boolean changed = false;
if (StringUtils.isBlank(device.getSn()) && StringUtils.isNotBlank(payload.getSn())) {
update.setSn(payload.getSn());
changed = true;
}
if (StringUtils.isBlank(device.getModel()) && StringUtils.isNotBlank(payload.getModel())) {
update.setModel(payload.getModel());
changed = true;
}
if (StringUtils.isBlank(device.getHwVersion()) && StringUtils.isNotBlank(payload.getHwVersion())) {
update.setHwVersion(payload.getHwVersion());
changed = true;
}
if (StringUtils.isBlank(device.getFwVersion()) && StringUtils.isNotBlank(payload.getFwVersion())) {
update.setFwVersion(payload.getFwVersion());
changed = true;
}
if (StringUtils.isBlank(device.getMac()) && StringUtils.isNotBlank(payload.getMac())) {
update.setMac(payload.getMac());
changed = true;
}
if (!changed) {
return;
}
update.setUpdateTime(LocalDateTime.now());
int rows = deviceMapper.updateById(update);
log.info("设备注册补全建档字段 => deviceNo: {}, 影响行数: {}", device.getDeviceNo(), rows);
}
/**
* 头套连接状态:报文数字 → 字典编码。
*
* @param value 报文取值(0 断开 / 1 连接)
* @return 字典编码
*/
private static String hoodConnectedCode(Integer value) {
return value != null && value == 1
? HoodConnectedEnum.CONNECTED.getCode() : HoodConnectedEnum.DISCONNECTED.getCode();
}
/**
* 眼罩状态:报文数字 → 字典编码。
*
* @param value 报文取值(0 打开 / 1 遮盖)
* @return 字典编码
*/
private static String hoodCoverCode(Integer value) {
return value != null && value == 1
? HoodCoverEnum.COVERED.getCode() : HoodCoverEnum.OPEN.getCode();
}
}

View File

@@ -0,0 +1,104 @@
package org.dromara.device.mqtt.payload;
/**
* 指令回执码。
* <p>
* 口径见《设备通信协议-一期.md》§5.3,设备侧 {@code ack.code} 取值与本文一一对应。
*
* @author Kou
* @date 2026-09-16
*/
public enum AckCode {
/**
* 成功
*/
SUCCESS(0, "成功"),
/**
* 参数非法(越界、缺字段)
*/
INVALID_PARAM(1, "参数非法"),
/**
* 不支持的命令 / 事件
*/
UNSUPPORTED(2, "不支持"),
/**
* 执行失败(硬件、网络、校验失败)
*/
EXECUTE_FAILED(3, "执行失败"),
/**
* 设备忙(如正在执行 OTA)
*/
BUSY(4, "设备忙"),
/**
* 重复指令(同一 msgId 已执行,幂等)
*/
DUPLICATE(5, "重复指令");
/**
* 回执码
*/
private final int code;
/**
* 回执文案
*/
private final String label;
AckCode(int code, String label) {
this.code = code;
this.label = label;
}
/**
* 获取回执码。
*
* @return 回执码
*/
public int getCode() {
return code;
}
/**
* 获取回执文案。
*
* @return 回执文案
*/
public String getLabel() {
return label;
}
/**
* 按回执码反查枚举。
*
* @param code 回执码
* @return 匹配的枚举;未知码返回 {@code null}
*/
public static AckCode of(Integer code) {
if (code == null) {
return null;
}
for (AckCode item : values()) {
if (item.code == code) {
return item;
}
}
return null;
}
/**
* 判断回执码是否代表成功。
*
* @param code 回执码
* @return 是否成功(仅 {@link #SUCCESS} 为真)
*/
public static boolean isSuccess(Integer code) {
return code != null && code == SUCCESS.code;
}
}

View File

@@ -0,0 +1,101 @@
package org.dromara.device.mqtt.payload;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 指令回执报文(上行 {@code bm/{deviceNo}/ack},QoS 1)。
* <p>
* 口径见《设备通信协议-一期.md》§5.3。回执是「指令成功」的<b>唯一凭据</b>:
* telemetry 中眼罩状态变化不算成功(可能是物理手动操作)。
* 服务端以 {@code msgId} 关联下行指令上注册的 {@code CompletableFuture}。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AckPayload implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 协议版本(可选,一期固定 1)
*/
private Integer v;
/**
* 下行指令的 msgId,必须原样回传
*/
private String msgId;
/**
* 指令类型:hood / ota
*/
private String cmd;
/**
* 是否成功(等价于 code == 0)
*/
private Boolean ok;
/**
* 回执码,见 {@link AckCode}
*/
private Integer code;
/**
* 失败原因,≤ 64 字
*/
private String msg;
/**
* OTA 最终进度,成功时为 100
*/
private Integer progress;
/**
* 校验报文是否满足处理条件。
*
* @return 校验通过返回 {@code true}
*/
public boolean isValid() {
return invalidReason() == null;
}
/**
* 获取校验失败原因,用于日志定位。
*
* @return 失败原因;校验通过返回 {@code null}
*/
public String invalidReason() {
if (msgId == null || msgId.isBlank()) {
return "msgId 缺失(无法关联指令)";
}
if (cmd == null || cmd.isBlank()) {
return "cmd 缺失";
}
if (code == null && ok == null) {
return "code 与 ok 同时缺失";
}
return null;
}
/**
* 判定回执是否代表指令成功。
* <p>
* 优先以 {@code code} 为准(协议规定 {@code ok} 等价于 {@code code == 0}),
* {@code code} 缺失时回退到 {@code ok}。
*
* @return 是否成功
*/
public boolean success() {
if (code != null) {
return AckCode.isSuccess(code);
}
return Boolean.TRUE.equals(ok);
}
}

View File

@@ -0,0 +1,83 @@
package org.dromara.device.mqtt.payload;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 设备事件上报报文(上行 {@code bm/{deviceNo}/event},QoS 1)。
* <p>
* 口径见《设备通信协议-一期.md》§5.2。{@code eventType} 取值见 {@link EventType},
* 未知类型由服务端忽略并记日志(不视为异常)。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class EventPayload implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 协议版本(可选,一期固定 1)
*/
private Integer v;
/**
* 设备本地时间(epoch 毫秒)
*/
private Long ts;
/**
* 事件类型,见 {@link EventType}
*/
private String eventType;
/**
* 触发值(低电电量、进度百分比等)
*/
private Integer value;
/**
* 版本号(OTA 相关事件)
*/
private String version;
/**
* 附加原因,如 normal / lwt / low_battery
*/
private String reason;
/**
* 校验报文是否满足处理条件。
*
* @return 校验通过返回 {@code true}
*/
public boolean isValid() {
return invalidReason() == null;
}
/**
* 获取校验失败原因,用于日志定位。
*
* @return 失败原因;校验通过返回 {@code null}
*/
public String invalidReason() {
if (eventType == null || eventType.isBlank()) {
return "eventType 缺失";
}
return null;
}
/**
* 获取解析后的事件类型。
*
* @return 事件类型枚举;未知类型返回 {@code null}
*/
public EventType resolveType() {
return EventType.of(eventType);
}
}

View File

@@ -0,0 +1,110 @@
package org.dromara.device.mqtt.payload;
import org.dromara.device.enums.AlertTypeEnum;
/**
* 设备事件类型(协议词汇,对齐《设备通信协议-一期.md》§5.2)。
* <p>
* 与 {@link AlertTypeEnum} 的关系:本枚举表达「设备说了什么」,
* {@link AlertTypeEnum} 表达「落库成哪类告警」。二者在
* {@code offline} / {@code low_battery_hood} / {@code low_battery_ring} 上编码一致,
* 但<b>低电告警一律由服务端按 telemetry 阈值判定生成</b>(含 30 分钟去重窗口),
* 设备上报的低电事件仅作提前提示,不得直接写 {@code bm_device_alert}。
*
* @author Kou
* @date 2026-09-16
*/
public enum EventType {
/**
* 上线:连接成功且完成订阅后主动上报
*/
ONLINE("online", "上线", null),
/**
* 离线:正常下线前主动上报;异常断连由 Broker 代发 LWT
*/
OFFLINE("offline", "离线", AlertTypeEnum.OFFLINE),
/**
* 头套低电
*/
LOW_BATTERY_HOOD("low_battery_hood", "头套低电", AlertTypeEnum.LOW_BATTERY_HOOD),
/**
* 脚环低电
*/
LOW_BATTERY_RING("low_battery_ring", "脚环低电", AlertTypeEnum.LOW_BATTERY_RING),
/**
* OTA 进度(≤ 1 次/10s,只报进度不报结果)
*/
OTA_PROGRESS("ota_progress", "OTA 进度", null);
/**
* 类型编码
*/
private final String type;
/**
* 类型文案
*/
private final String label;
/**
* 对应的告警类型;无对应告警则为 {@code null}
*/
private final AlertTypeEnum alertType;
EventType(String type, String label, AlertTypeEnum alertType) {
this.type = type;
this.label = label;
this.alertType = alertType;
}
/**
* 获取类型编码。
*
* @return 类型编码
*/
public String getType() {
return type;
}
/**
* 获取类型文案。
*
* @return 类型文案
*/
public String getLabel() {
return label;
}
/**
* 获取对应的告警类型。
*
* @return 告警类型;无对应告警返回 {@code null}
*/
public AlertTypeEnum getAlertType() {
return alertType;
}
/**
* 按编码反查枚举。
*
* @param type 类型编码
* @return 匹配的枚举;未知编码返回 {@code null}(调用方按「未知事件」忽略并记日志)
*/
public static EventType of(String type) {
if (type == null) {
return null;
}
for (EventType item : values()) {
if (item.type.equals(type)) {
return item;
}
}
return null;
}
}

View File

@@ -0,0 +1,71 @@
package org.dromara.device.mqtt.payload;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 眼罩开关指令报文(下行 {@code bm/{deviceNo}/command/hood},QoS 1)。
* <p>
* 口径见《设备通信协议-一期.md》§7.1。{@code coverOn = true} 表示遮盖,
* 等价于上行 telemetry 的 {@code hoodCoverOn = 1}。
* 设备必须回 {@link AckPayload}(原样回传 {@code msgId}),服务端默认等待 5s
* (配置项 {@code device.mqtt.hood-timeout}),超时按「指令下发失败」返回。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class HoodCommandPayload implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 指令类型固定值
*/
public static final String CMD = "hood";
/**
* 协议版本(一期固定 1)
*/
private Integer v;
/**
* 指令唯一标识,用于回执关联与设备侧幂等
*/
private String msgId;
/**
* 指令类型,固定 {@value #CMD}
*/
private String cmd = CMD;
/**
* 是否遮盖:true 遮盖 / false 打开
*/
private Boolean coverOn;
/**
* 下发时间(epoch 毫秒)
*/
private Long ts;
/**
* 构造眼罩指令报文。
*
* @param msgId 指令唯一标识
* @param coverOn 是否遮盖
* @return 指令报文
*/
public static HoodCommandPayload of(String msgId, boolean coverOn) {
HoodCommandPayload payload = new HoodCommandPayload();
payload.setV(1);
payload.setMsgId(msgId);
payload.setCoverOn(coverOn);
payload.setTs(System.currentTimeMillis());
return payload;
}
}

View File

@@ -0,0 +1,117 @@
package org.dromara.device.mqtt.payload;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* OTA 升级指令报文(下行 {@code bm/{deviceNo}/command/ota},QoS 1)。
* <p>
* 口径见《设备通信协议-一期.md》§7.2。流程约定:
* <ol>
* <li>设备收到后立即回 {@code ack{code:0}} 表示<b>已受理</b>(不是已完成);</li>
* <li>升级期间周期上报 {@code event/ota_progress}(≤ 1 次/10s);</li>
* <li><b>最终结果以 {@code ack} 为准</b>(成功 progress=100,失败 code=3),进度事件不承载结果;</li>
* <li>{@code md5} 校验失败必须回 {@code code=3},{@code msg="md5 mismatch"}。</li>
* </ol>
* 一期整包下载,不做分片与断点续传。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class OtaCommandPayload implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 指令类型固定值
*/
public static final String CMD = "ota";
/**
* 协议版本(一期固定 1)
*/
private Integer v;
/**
* 指令唯一标识,用于回执关联与设备侧幂等
*/
private String msgId;
/**
* 指令类型,固定 {@value #CMD}
*/
private String cmd = CMD;
/**
* 目标版本号,与 bm_ota_package.version 一致
*/
private String version;
/**
* 升级包直链地址,有效期 ≥ 24h
*/
private String url;
/**
* 整包 MD5(设备必须校验)
*/
private String md5;
/**
* 升级包字节数,用于进度计算
*/
private Long size;
/**
* 更新说明,≤ 128 字
*/
private String changelog;
/**
* 下发时间(epoch 毫秒)
*/
private Long ts;
/**
* 构造 OTA 指令报文。
*
* @param msgId 指令唯一标识
* @param version 目标版本号
* @param url 升级包地址
* @param md5 整包 MD5
* @param size 升级包字节数
* @param changelog 更新说明
* @return 指令报文
*/
public static OtaCommandPayload of(String msgId, String version, String url,
String md5, Long size, String changelog) {
OtaCommandPayload payload = new OtaCommandPayload();
payload.setV(1);
payload.setMsgId(msgId);
payload.setVersion(version);
payload.setUrl(url);
payload.setMd5(md5);
payload.setSize(size);
payload.setChangelog(changelog);
payload.setTs(System.currentTimeMillis());
return payload;
}
/**
* 校验报文关键字段是否齐备,发布前自检。
*
* @return 校验通过返回 {@code true}
*/
public boolean isValid() {
return msgId != null && !msgId.isBlank()
&& version != null && !version.isBlank()
&& url != null && !url.isBlank()
&& md5 != null && !md5.isBlank()
&& size != null && size > 0;
}
}

View File

@@ -0,0 +1,97 @@
package org.dromara.device.mqtt.payload;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 设备注册应答报文(下行 {@code bm/provision/ack/{imei}},QoS 1)。
* <p>
* 口径见《设备通信协议-一期.md》§6.2。设备注册成功后需<b>持久化 deviceNo</b>,
* 后续重启不再重复注册;仅在收到 {@code ok=false, code=404} 或约定「重置」指令时才重新注册。
* <p>
* <b>待 Q1 决策</b>:若最终选择「出厂烧录 deviceNo」方案,本类与注册链路可整体删除。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class ProvisionAckPayload implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 协议版本(一期固定 1)
*/
private Integer v;
/**
* 设备 IMEI
*/
private String imei;
/**
* 是否注册成功
*/
private Boolean ok;
/**
* 结果码:0 成功 / 404 未建档(含私拆、串货线索)
*/
private Integer code;
/**
* 失败说明
*/
private String msg;
/**
* 分配或回查到的设备编号(成功时必填)
*/
private String deviceNo;
/**
* 服务端时间(epoch 毫秒),供设备做粗校准
*/
private Long ts;
/**
* 构造注册成功应答。
*
* @param imei 设备 IMEI
* @param deviceNo 设备编号
* @return 应答报文
*/
public static ProvisionAckPayload success(String imei, String deviceNo) {
ProvisionAckPayload payload = new ProvisionAckPayload();
payload.setV(1);
payload.setImei(imei);
payload.setOk(true);
payload.setCode(0);
payload.setDeviceNo(deviceNo);
payload.setTs(System.currentTimeMillis());
return payload;
}
/**
* 构造注册失败应答。
*
* @param imei 设备 IMEI
* @param code 结果码
* @param msg 失败说明
* @return 应答报文
*/
public static ProvisionAckPayload fail(String imei, int code, String msg) {
ProvisionAckPayload payload = new ProvisionAckPayload();
payload.setV(1);
payload.setImei(imei);
payload.setOk(false);
payload.setCode(code);
payload.setMsg(msg);
payload.setTs(System.currentTimeMillis());
return payload;
}
}

View File

@@ -0,0 +1,89 @@
package org.dromara.device.mqtt.payload;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 设备注册上报报文(上行 {@code bm/provision/register},QoS 1)。
* <p>
* 口径见《设备通信协议-一期.md》§6。解决的问题:Topic 以 {@code deviceNo} 寻址,
* 但设备出厂时只知道 IMEI / SN。
* <p>
* <b>待 Q1 决策</b>:若最终选择「出厂烧录 deviceNo」方案,本类与注册链路可整体删除。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class ProvisionPayload implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 协议版本(一期固定 1)
*/
private Integer v;
/**
* 设备本地时间(epoch 毫秒)
*/
private Long ts;
/**
* 设备 IMEI(15 位数字,出厂唯一)
*/
private String imei;
/**
* 出厂序列号
*/
private String sn;
/**
* 型号
*/
private String model;
/**
* 硬件版本
*/
private String hwVersion;
/**
* 固件版本
*/
private String fwVersion;
/**
* MAC 地址
*/
private String mac;
/**
* 校验报文是否满足处理条件。
*
* @return 校验通过返回 {@code true}
*/
public boolean isValid() {
return invalidReason() == null;
}
/**
* 获取校验失败原因,用于日志定位。
*
* @return 失败原因;校验通过返回 {@code null}
*/
public String invalidReason() {
if (imei == null || !imei.matches("^\\d{15}$")) {
return "imei 缺失或非 15 位数字";
}
if (sn == null || sn.isBlank()) {
return "sn 缺失";
}
return null;
}
}

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