+ * 设计口径(迭代 I2 边界裁定 · 方案 A): + *
+ * 明文密码仅在本方法内部比对(BCrypt),比较结果以布尔值回传, + * 密码散列与明文均不跨越模块边界。 + * + * @param userId 用户主键 + * @param rawPassword 明文密码 + * @return true 匹配;false 不匹配或用户不存在 + */ + boolean matchesPassword(Long userId, String rawPassword); + + /** + * 以手机号注册一个 APP 用户。 + *
+ * 账号用户名取手机号;密码在本方法内部散列后落库。 + * + * @param phoneNumber 手机号 + * @param rawPassword 明文密码 + * @param nickName 昵称(为空时默认取手机号) + * @return 新建用户主键 + */ + Long registerByPhone(String phoneNumber, String rawPassword, String nickName); + + /** + * 按邮箱查询 APP 用户(仅匹配 {@code user_type = app_user} 且未逻辑删除的账号)。 + *
+ * 邮箱比对口径:调用方须先做「去空白 + 转小写」归一化(见 + * {@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 用户(邮箱注册入口)。 + *
+ * 不写手机号:本入口创建的账号 {@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})。账号须在后续流程中绑定手机号。 + *
+ * 密码在本方法内部散列后落库。 + * + * @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); + + /** + * 判断账号是否已绑定手机号。 + *
+ * 供设备域做「邮箱注册后仍须绑手机号」的服务端硬拦截使用 + * (未绑手机号的账号不允许绑定设备)。 + * + * @param userId 用户主键 + * @return true 已绑定非空手机号;false 未绑定或用户不存在 + */ + boolean isPhoneBound(Long userId); + + // ---------------- 后台管理端(APP 用户管理) ---------------- + + /** + * 分页查询 APP 用户列表(后台「APP 用户管理」用)。 + *
+ * 强制口径:实现侧固定附加 {@code user_type = app_user} 条件, + * 调用方无法通过 {@link AppUserQueryDTO} 覆盖该条件, + * 因此本方法恒不返回后台账号({@code sys_user})。 + *
+ * 数据权限:APP 用户注册时不写 {@code dept_id}(该列恒为 {@code NULL}),
+ * 不参与后台部门树,故实现侧刻意不加框架 {@code @DataPermission} 过滤——
+ * 否则按部门维度过滤会把全部 APP 用户过滤为空。
+ *
+ * @param query 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
+ * @return APP 用户分页结果(不含密码)
+ */
+ PageResult
+ * 同样强制 {@code user_type = app_user}:传入后台账号主键时返回 {@code null},
+ * 避免管理端越界读到系统账号信息。
+ *
+ * @param userId 用户主键
+ * @return APP 用户信息(不含密码);不存在或非 app_user 时返回 {@code null}
+ */
+ AppUserDTO selectAppUserById(Long userId);
+
+ /**
+ * 修改 APP 用户账号状态(停用 / 启用)。
+ *
+ * 与 {@link #disableUser(Long)} 的区别:本方法同时支持恢复启用,
+ * 供后台管理端做双向状态切换;{@code disableUser} 保持「注销前置」单向语义不变。
+ *
+ * 安全约束:实现侧限定 {@code user_type = app_user},
+ * 防止通过本方法改动后台账号状态。
+ *
+ * @param userId 用户主键
+ * @param status 目标状态({@code 0} 正常 / {@code 1} 停用)
+ * @return true 修改成功(含目标状态与当前一致的情形)
+ */
+ boolean updateStatus(Long userId, String status);
+
+}
diff --git a/ruoyi-api/src/main/java/org/dromara/system/api/domain/AppUserDTO.java b/ruoyi-api/src/main/java/org/dromara/system/api/domain/AppUserDTO.java
new file mode 100644
index 0000000..988082b
--- /dev/null
+++ b/ruoyi-api/src/main/java/org/dromara/system/api/domain/AppUserDTO.java
@@ -0,0 +1,75 @@
+package org.dromara.system.api.domain;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * APP 用户传输对象。
+ *
+ * 面向 APP 账号闭环的对外 DTO,刻意不包含密码字段,
+ * 以确保密码散列不会跨越模块边界(见 {@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;
+
+ /**
+ * 用户邮箱
+ *
+ * 仅邮箱注册的账号非空;手机号注册的账号为 {@code null}。
+ */
+ private String email;
+
+ /**
+ * 用户类型(固定为 app_user)
+ */
+ private String userType;
+
+ /**
+ * 账号状态(0 正常 1 停用)
+ */
+ private String status;
+
+ /**
+ * 创建时间(注册时间)
+ */
+ private LocalDateTime createTime;
+
+ /**
+ * 更新时间
+ *
+ * 供后台「APP 用户管理」展示「最近变更时间」(改密 / 换绑 / 停用等均会刷新该值)。
+ */
+ private LocalDateTime updateTime;
+
+}
diff --git a/ruoyi-api/src/main/java/org/dromara/system/api/domain/AppUserQueryDTO.java b/ruoyi-api/src/main/java/org/dromara/system/api/domain/AppUserQueryDTO.java
new file mode 100644
index 0000000..fcfc08e
--- /dev/null
+++ b/ruoyi-api/src/main/java/org/dromara/system/api/domain/AppUserQueryDTO.java
@@ -0,0 +1,69 @@
+package org.dromara.system.api.domain;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * APP 用户管理查询条件(后台管理端使用)。
+ *
+ * 为什么单独建一个 DTO 而不是复用 {@code SysUserBo} + {@code PageQuery}:
+ * {@code ruoyi-api} 模块仅依赖 {@code ruoyi-common-core}(见 {@code ruoyi-api/pom.xml}),
+ * 而 {@code PageQuery} 位于 {@code ruoyi-common-mybatis},在本模块不可见。
+ * 因此分页参数直接内联为 {@code pageNum} / {@code pageSize},由 {@code ruoyi-system} 侧
+ * 组装成框架 {@code Page},从而在不放松模块依赖边界的前提下完成跨模块分页契约。
+ *
+ * 日期区间用 {@link #params} 而非平铺字段:与框架既有约定保持一致——
+ * 前端 {@code utils/ruoyi.ts#addDateRange}(经 {@code useDateRangeQuery} hook 调用)
+ * 把区间写入 {@code params.beginTime} / {@code params.endTime},
+ * 后端 {@code betweenParams(column, params, "beginTime", "endTime")} 消费,
+ * 故此处必须保留同名的 {@code params} 载体,前端才能直接复用现成 hook。
+ *
+ * 不含 {@code userType}:本查询只为 APP 用户({@code user_type = app_user})服务,
+ * 该条件由实现侧强制附加、不可由调用方覆盖——避免调用方漏传而把后台账号一并查出。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+@NoArgsConstructor
+public class AppUserQueryDTO implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 关键词:模糊匹配 user_name / nick_name / phone_number / email(四字段 OR)。
+ */
+ private String keyword;
+
+ /**
+ * 账号状态(0 正常 1 停用);留空不参与过滤。
+ */
+ private String status;
+
+ /**
+ * 扩展查询参数。
+ *
+ * 约定键:{@code beginTime} / {@code endTime}——注册时间区间(闭区间)。
+ * 两者均非空时才生效(与框架 {@code betweenParams} 口径一致)。
+ * 注意:框架不做「日期补 23:59:59」的隐式处理,前端选「日期」而非「日期时间」时
+ * 需自行补全时分秒,否则会漏掉结束日当天数据。
+ */
+ private Map
+ * ⚠️ 这里是 {@link Integer#MAX_VALUE},字面语义是「查全部」,但**实际不会真的全表返回** ——
+ * {@code MybatisPlusConfig#paginationInnerInterceptor} 设了 {@code maxLimit = 1000},
+ * MyBatis-Plus 会把超限的 size 静默改写掉。原注释「默认查全部」有误导性,已订正。
+ *
+ * 另外 {@link #getFirstNum()} 用本值算偏移量,{@code (pageNum - 1) * MAX_VALUE}
+ * 在 {@code pageNum > 1} 时会**整型溢出** —— 该方法目前无调用方,但若要启用必须先收口。
*/
public static final int DEFAULT_PAGE_SIZE = Integer.MAX_VALUE;
diff --git a/ruoyi-modules/pom.xml b/ruoyi-modules/pom.xml
index 0c04167..9f8288d 100644
--- a/ruoyi-modules/pom.xml
+++ b/ruoyi-modules/pom.xml
@@ -22,6 +22,7 @@
+ * 背景:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码明文写入应用日志。
+ * 开发/联调期这是便利(SMTP 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。
+ *
+ * 策略:
+ *
+ * 全部字段均带默认值,未在 {@code application.yml} 显式配置时按默认值生效,因此本迭代无需改动任何 yml。
+ *
+ * 客户端身份默认值口径:与框架 {@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 记录,请同步此处配置。
+ *
+ * 已知偏差:{@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})。
+ *
+ * 框架中该请求头名即 {@code LoginHelper.CLIENT_KEY = "clientid"};APP 登录时本值会写入 token 扩展,
+ * 框架据此校验「请求头 clientid == 令牌扩展 clientid」({@code SecurityConfig:98-106}),
+ * 因此 APP 端每次请求须携带请求头 {@code clientid: app}。
+ *
+ * 注意(已知偏差,非笔误):本值 ≠ {@code sys_client.client_id}。
+ * 框架的 {@code sys_client.client_id = md5(client_key + client_secret)}(见 {@code SysClientServiceImpl}),
+ * 而本值当前为自建的 {@code "app"},即 {@code client_key},并非其 md5。
+ * 之所以自洽:本链路不查 {@code sys_client},token 扩展中的 clientid 由本配置直接写入,框架只做
+ * 「请求头 vs 令牌扩展」的自比对,故两端一致即可通过。
+ *
+ * 后果:后台对 {@code sys_client} 中 app 记录的编辑/停用({@code status}/{@code grant_type}/
+ * {@code access_path})不影响 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)。
+ *
+ * 生产安全(I2 遗留项收口):本项默认 true 仅服务开发/联调;生产环境(prod)不得为 true——
+ * {@link AppSmsProdGuard} 会在激活 profile 含 {@code prod} 且本值为 true 时拒绝启动,
+ * 以避免验证码明文落盘。生产部署请在 {@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 发信)。
+ *
+ * 凭据口径:{@link #user} 与 {@link #pass} 在代码与 {@code application.yml} 中均不预置真实值,
+ * 一律通过环境变量注入({@code DEVICE_APP_EMAIL_USER} / {@code DEVICE_APP_EMAIL_PASS}),
+ * 避免重蹈「明文 broker 凭据硬编码」的覆辙。
+ *
+ * 谷歌邮箱(Gmail)口径:主机固定 {@code smtp.gmail.com}、端口 465(SSL)。
+ * Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的
+ * 16 位应用专用密码(非账号登录密码),且 {@link #from} 必须等于 {@link #user},
+ * 否则 Gmail 会以「发件人与认证账号不一致」拒收。
+ *
+ * 网络前提(部署必读):{@code smtp.gmail.com} 需服务器可直连 Google。
+ * 国内机房通常不可达,此时表现为连接超时——故 {@link #connectionTimeout} 与 {@link #timeout}
+ * 默认设为较小值,让发信快速失败而不是把 HTTP 请求线程挂死。
+ */
+ @Data
+ public static class Email {
+
+ /**
+ * 是否走模拟发送(true 时仅打印日志不真实发信,便于无 SMTP 条件时联调)。
+ *
+ * 与短信 {@link Sms#isMock()} 同口径:默认 true 仅服务开发/联调。
+ * 生产环境(prod)不得为 true——{@link AppEmailProdGuard} 会在激活 profile 含 {@code prod}
+ * 且本值为 true 时拒绝启动,避免验证码明文落盘。
+ */
+ 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 位应用专用密码,由环境变量注入。
+ */
+ private String pass = "";
+
+ /**
+ * 发件人(须与 {@link #user} 一致,可写为 {@code 名称
+ * 若产品要求「只允许谷歌邮箱注册」,配置为 {@code gmail.com,googlemail.com} 即可,无需改代码。
+ */
+ private List
+ * 背景:{@code device.app.sms.mock} 默认 {@code true},会把短信验证码明文写入应用日志。
+ * 这在开发/联调期是便利(Q5 短信通道未定),但若生产环境误开 mock,等价于把验证码落盘,属安全风险。
+ *
+ * 策略:不修改默认值(开发仍需 mock),而是保证「生产环境无法带 mock 启动」——
+ *
+ * 全部字段带默认值,默认值即《设备通信协议-一期.md》给出的工程默认口径,
+ * 因此不配置也能跑;需要调参时只在 {@code application-*.yml} 覆盖对应项即可,无需改代码。
+ *
+ * 与 {@code AppProperties}(前缀 {@code device.app})的分工:本类只管「设备侧通信链路」的可调参数,
+ * APP 侧账号/短信/协议配置仍归 {@code AppProperties}。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "device.mqtt")
+public class DeviceMqttProperties {
+
+ /**
+ * 是否启用离线扫描。
+ *
+ * 关闭后不再自动置离线,设备将长期停留在「在线」——仅用于联调排障,生产必须为 {@code true}。
+ */
+ private boolean scanEnabled = true;
+
+ /**
+ * 离线扫描间隔。
+ *
+ * 采用 {@code fixedDelay} 语义(上一轮跑完再计时),因此扫描耗时不会造成任务堆积。
+ */
+ private Duration offlineScanInterval = Duration.ofSeconds(30);
+
+ /**
+ * 离线判定阈值:超过该时长无遥测即判离线。
+ *
+ * 协议默认 120s,对应「上报周期 60s × 2」的容错口径(见协议 §5.1、Q2)。
+ */
+ private Duration offlineThreshold = Duration.ofSeconds(120);
+
+ /**
+ * 单轮扫描的设备上限,防止异常情况下一次性拉爆内存。
+ *
+ * 命中数量达到该值时说明积压严重,日志会给出提示。
+ */
+ private int scanBatchSize = 500;
+
+ /**
+ * 低电告警触发阈值(≤ 该值触发)。
+ */
+ private int lowBatteryThreshold = 20;
+
+ /**
+ * 低电告警恢复阈值(≥ 该值恢复)。
+ *
+ * 与 {@link #lowBatteryThreshold} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。
+ *
+ * ⚠️ 引入设备级阈值后本字段的含义有所扩展:
+ *
+ * 闩锁本身无业务过期语义(低电态由恢复阈值解除),设置 TTL 只为兜底清理已注销设备的残留键;
+ * 若 TTL 到期时设备仍处于低电态,会补发一条告警——因此不宜设得太短。
+ */
+ private Duration lowBatteryLatchTtl = Duration.ofDays(30);
+
+ /**
+ * 告警去重窗口。
+ *
+ * 同一 {@code deviceNo + alertType} 在该窗口内只落一条告警。两个场景共用:
+ * 设备上报的低电事件(协议要求 30 分钟)与设备事件与服务端阈值判定的交叉去重。
+ */
+ private Duration alertDedupWindow = Duration.ofMinutes(30);
+
+ /**
+ * 眼罩指令回执等待超时。
+ *
+ * 超时即向 APP 返回「指令下发失败」({@code DeviceMsgConstants.COMMAND_SEND_FAILED})。
+ * 对应协议 §13 Q3:{@code coverOn} 下行后物理动作到位时长需硬件确认。
+ */
+ private Duration hoodTimeout = Duration.ofSeconds(5);
+
+ /**
+ * OTA 指令回执等待超时。
+ *
+ * 设备收到 {@code command/ota} 后应立即回 {@code ack} 表示已受理(协议 §7.2),
+ * 因此等待窗口短;超时即判该设备本次下发失败({@code DeviceMsgConstants.OTA_BATCH_FAIL_DISPATCH}),
+ * 任务落「失败」终态,不空悬。
+ */
+ private Duration otaTimeout = Duration.ofSeconds(10);
+
+ /**
+ * 待回执指令标记在 Redis 中的存活时长(仅用于事后定位,不参与正确性判定)。
+ */
+ private Duration pendingAckTtl = Duration.ofMinutes(10);
+
+ // ---------------- 低电阈值解析(设备级配置 → 生效值) ----------------
+
+ /**
+ * 解析生效的触发阈值:设备级配置优先,未配置则回落工程默认。
+ *
+ * 设备级阈值由 DDL 保证 {@code NOT NULL DEFAULT 20},正常路径下 {@code deviceThreshold}
+ * 不会是 null;保留兜底是为兼容「补丁执行前写入的历史行」等边界情形。
+ *
+ * 返回 0 表示对该设备关闭低电保护,而非「阈值取 0」——调用方必须显式判 0,
+ * 不能直接写 {@code battery <= resolveTriggerThreshold(...)},否则会退化成
+ * 「电量恰好归零才告警」。目前两个消费点(低电告警判定、遮盖指令拦截)都已按此约定处理。
+ *
+ * @param deviceThreshold 设备上配置的阈值(可为 null)
+ * @return 生效的触发阈值(电量 ≤ 该值即触发低电)
+ */
+ public int resolveTriggerThreshold(Integer deviceThreshold) {
+ return deviceThreshold == null ? lowBatteryThreshold : deviceThreshold;
+ }
+
+ /**
+ * 派生生效的恢复阈值:{@code 触发阈值 + 回差}。
+ *
+ * 回差取自 {@link #lowBatteryThreshold} 与 {@link #lowBatteryRecoverThreshold} 之差
+ * (工程默认为 {@code 25 - 20 = 5}),使设备级阈值天然沿用同一滞回口径。
+ *
+ * 回差下限保护为 1:若运维把两个默认值配成相等(回差为 0,等于没有滞回),
+ * 电量在阈值附近抖动时会反复告警/恢复,故强制至少留 1 个百分点。
+ *
+ * @param triggerThreshold 生效的触发阈值
+ * @return 生效的恢复阈值(严格大于 {@code triggerThreshold})
+ */
+ public int resolveRecoverThreshold(int triggerThreshold) {
+ return triggerThreshold + Math.max(1, lowBatteryRecoverThreshold - lowBatteryThreshold);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/ZlmProperties.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/ZlmProperties.java
new file mode 100644
index 0000000..1534cd4
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/ZlmProperties.java
@@ -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})。
+ *
+ * 设备侧推流到 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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppConstants.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppConstants.java
new file mode 100644
index 0000000..52949b3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppConstants.java
@@ -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)})。
+ *
+ * 采用宽松的通用校验:本地部分允许字母数字与常见符号,域名部分至少一个点。
+ * 不做「仅允许 gmail.com」的硬限制——域名白名单由
+ * {@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";
+
+ /**
+ * 场景:绑定手机(首次绑定,用于邮箱注册后补绑手机号的账号)。
+ *
+ * 与 {@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 加固后语义)。
+ *
+ * 注意:该阈值只限制「错误验证码」的提交速率,不使验证码失效——
+ * 正确验证码永远可校验成功。达到阈值后仅短暂拒绝「错误」尝试(冷却窗口 {@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)。
+ *
+ * 风格对齐 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 = 前缀 + 场景 + ":" + 邮箱。
+ *
+ * 与短信前缀 {@link #SMS_CODE_KEY_PREFIX} 刻意分家:同一场景下手机号与邮箱可能重名取值,
+ * 且两通道的验证码不应互相覆盖或互相销毁。
+ */
+ 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 = 前缀 + 邮箱。
+ *
+ * 与手机号注册锁 {@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:";
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppMsgConstants.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppMsgConstants.java
new file mode 100644
index 0000000..fa7c2e9
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppMsgConstants.java
@@ -0,0 +1,169 @@
+package org.dromara.device.constant;
+
+/**
+ * APP 端(C 端)提示文案常量。
+ *
+ * 取值口径:优先对齐《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 = "验证码已发送";
+
+ /**
+ * 验证码场景不合法。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String SMS_SCENE_INVALID = "验证码场景不合法";
+
+ /**
+ * 验证码发送过于频繁(60 秒内重复发送)。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String SMS_SEND_TOO_FREQUENT = "验证码发送过于频繁,请稍后再试";
+
+ /**
+ * 验证码短信发送失败。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String SMS_SEND_FAILED = "验证码短信发送失败";
+
+ /**
+ * 验证码错误尝试过于频繁(短冷却窗口内错误次数过多,稍后自动恢复)。
+ * 注意:此提示仅表示「错误尝试」被短暂限速,不表示验证码已失效;正确验证码仍可校验成功。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String SMS_VERIFY_TOO_MANY = "验证码错误次数过多,请稍后再试";
+
+ // ---------------- 邮箱验证码 ----------------
+
+ /**
+ * 邮箱验证码已发送(发送成功提示)。
+ */
+ public static final String EMAIL_CODE_SENT = "验证码已发送至邮箱";
+
+ /**
+ * 邮箱验证码场景不合法。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String EMAIL_SCENE_INVALID = "验证码场景不合法";
+
+ /**
+ * 邮箱验证码发送失败(SMTP 通道异常)。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String EMAIL_SEND_FAILED = "验证码邮件发送失败";
+
+ /**
+ * 邮箱域名不在允许列表内({@code device.app.email.allowed-domains} 非空时生效)。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String EMAIL_DOMAIN_NOT_ALLOWED = "该邮箱域名暂不支持注册";
+
+ /**
+ * 邮箱注册成功(提示继续绑定手机号)。
+ * 「注册后仍须绑手机号」为本迭代的账号规则,故注册成功文案直接给出下一步引导。
+ */
+ public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请绑定手机号";
+
+ /**
+ * 该账号尚未绑定手机号(绑定设备前的硬拦截文案)。
+ */
+ public static final String PHONE_NOT_BOUND = "请先绑定手机号";
+
+ // ---------------- 账号 ----------------
+
+ /**
+ * 注册成功。
+ */
+ public static final String REGISTER_SUCCESS = "注册成功";
+
+ /**
+ * 手机号或密码错误。
+ * TODO 阶段 7 按原型核对(原型「全局说明」未直接给出该句)。
+ */
+ public static final String PASSWORD_ERROR = "手机号或密码错误";
+
+ /**
+ * 邮箱或密码错误(邮箱登录通道专用)。
+ * 不复用 {@link #PASSWORD_ERROR} 的原因:该句含「手机号」字样,用于邮箱登录会误导用户。
+ */
+ public static final String EMAIL_PASSWORD_ERROR = "邮箱或密码错误";
+
+ /**
+ * 账号已被停用。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String ACCOUNT_DISABLED = "账号已被停用";
+
+ /**
+ * 登录已失效(会话不存在)。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String NOT_LOGIN = "登录已失效,请重新登录";
+
+ /**
+ * 退出成功。
+ */
+ public static final String LOGOUT_SUCCESS = "退出成功";
+
+ /**
+ * 注销成功。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String CANCEL_SUCCESS = "注销成功";
+
+ // ---------------- 个人中心 ----------------
+
+ /**
+ * 密码重置成功。
+ */
+ public static final String PASSWORD_RESET_SUCCESS = "密码重置成功";
+
+ /**
+ * 密码修改成功。
+ */
+ public static final String PASSWORD_UPDATE_SUCCESS = "密码修改成功";
+
+ /**
+ * 原密码错误。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String OLD_PASSWORD_ERROR = "原密码错误";
+
+ /**
+ * 新密码不能与原密码相同。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String NEW_PASSWORD_SAME = "新密码不能与原密码相同";
+
+ /**
+ * 手机号更换成功。
+ */
+ public static final String PHONE_UPDATE_SUCCESS = "手机号更换成功";
+
+ /**
+ * 新手机号与当前手机号相同(换绑时的精确提示,替代原先误报的「手机号已注册」)。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String PHONE_SAME_AS_CURRENT = "新手机号与当前手机号相同";
+
+ /**
+ * 协议类型不合法。
+ * TODO 阶段 7 按原型核对。
+ */
+ public static final String AGREEMENT_TYPE_INVALID = "协议类型不合法";
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/DeviceMqttConstants.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/DeviceMqttConstants.java
new file mode 100644
index 0000000..d1d7c7f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/DeviceMqttConstants.java
@@ -0,0 +1,77 @@
+package org.dromara.device.constant;
+
+/**
+ * 设备 MQTT 运行时常量。
+ *
+ * 只放「不随环境变化的键名与协议固定码」,阈值类可调参数见
+ * {@code org.dromara.device.config.DeviceMqttProperties}(配置前缀 {@code device.mqtt})。
+ *
+ * Redis Key 命名风格对齐 I1 取号锁 {@code bm:device:no:lock:*}:{@code bm:} 域名前缀 +
+ * 业务段 + 维度后缀,便于运维按前缀排查与清理。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public final class DeviceMqttConstants {
+
+ private DeviceMqttConstants() {
+ }
+
+ // ---------------- Redis Key ----------------
+
+ /**
+ * 离线扫描互斥锁。
+ *
+ * 每个实例都会起扫描任务,但同一时刻只允许一个实例真正扫描——否则多实例会
+ * 对同一批超时设备重复「置离线 + 写告警」。
+ */
+ public static final String ONLINE_SCAN_LOCK = "bm:mqtt:online-scan:lock";
+
+ /**
+ * 告警去重窗口占位,完整 Key:{@code bm:mqtt:alert:dedup:{deviceNo}:{alertType}}。
+ *
+ * 用于「设备上报的低电事件」与「服务端阈值判定」两条来源共用的去重窗口,
+ * 保证窗口内同一 {@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}}。
+ *
+ * 语义是「该设备该类电量当前处于低电态」:置位后不再重复告警,直到电量回升越过恢复阈值才清除。
+ * 与「去重窗口」的分工:窗口解决重复写库,闩锁解决「长时间低电被周期性重复告警」。
+ */
+ 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}。
+ *
+ * 该键不参与正确性判定(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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/DeviceMsgConstants.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/DeviceMsgConstants.java
new file mode 100644
index 0000000..e39a45d
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/DeviceMsgConstants.java
@@ -0,0 +1,235 @@
+package org.dromara.device.constant;
+
+/**
+ * 设备域提示文案常量
+ *
+ * 集中管理面向用户的提示语,取值口径对齐产品原型「全局说明」原句,后续迭代统一复用。
+ * 标注 {@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 = "手机号已注册";
+
+ /**
+ * 邮箱已注册
+ * 与 {@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)";
+
+ /**
+ * 设备编号建档后不可修改(后台编辑时校验)。
+ * {@code device_no} 由服务端建档时自动生成,是被 APP 绑定关系与运维话术引用的业务主展示键,
+ * 允许改号会造成「同一台设备两个编号」的口径混乱,故编辑入口显式拒绝。
+ */
+ public static final String DEVICE_NO_IMMUTABLE = "设备编号由系统自动生成,不支持修改";
+
+ /**
+ * 设备已被他人绑定
+ * TODO 阶段 7 按原型核对原句。
+ */
+ public static final String DEVICE_BOUND_BY_OTHERS = "设备已被绑定";
+
+ /**
+ * 重复绑定(已绑定给自己)
+ * TODO 阶段 7 按原型核对原句。
+ */
+ public static final String DEVICE_ALREADY_BOUND = "重复绑定";
+
+ /**
+ * 设备名称不合法(为空或超过 {@code AppConstants#DEVICE_NAME_MAX_LENGTH} 个字)。
+ * TODO 阶段 7 按原型核对原句(计划 §8.3 未给出该句)。
+ */
+ public static final String DEVICE_NAME_INVALID = "设备名称需为 1-20 个字";
+
+ /**
+ * 已绑定设备不可直接删除(后台批量删除时命中 {@code bind_user_id} 非空的设备)。
+ * 计划 §9.1:「已绑定需先解绑或二次确认」——本迭代按拒绝实现,文案即此句。
+ * 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 地址未配置
+ * 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 升级包不存在或未启用(批量建任务时整体拒绝)。
+ * TODO 阶段 7 按原型核对原句。
+ */
+ public static final String OTA_PACKAGE_UNAVAILABLE = "升级包不存在或未启用";
+
+ /**
+ * OTA 升级包的唯一约束冲突:同型号 + 同版本已存在。
+ * 对齐 DB 唯一键 {@code uk_model_version (model, version)}(不区分逻辑删除行)。
+ * TODO 阶段 7 按原型核对原句。
+ */
+ public static final String OTA_PACKAGE_EXIST = "该型号的升级包版本已存在";
+
+ /**
+ * OTA 升级包必填项缺失(适用型号 / 版本号为空)。
+ * TODO 阶段 7 按原型核对原句。
+ */
+ public static final String OTA_PACKAGE_MODEL_VERSION_REQUIRED = "适用型号与版本号不能为空";
+
+ /**
+ * OTA 升级任务不存在(编辑前置校验)。
+ * TODO 阶段 7 按原型核对原句。
+ */
+ public static final String OTA_TASK_NOT_EXIST = "升级任务不存在";
+
+ /**
+ * OTA 批量建任务明细:设备型号与升级包不匹配,计入 {@code skipped}。
+ * {@code %s} 为设备编号。TODO 阶段 7 按原型核对原句。
+ */
+ public static final String OTA_BATCH_SKIP_MODEL_MISMATCH = "设备[%s]型号与升级包不匹配,已跳过";
+
+ /**
+ * OTA 批量建任务明细:设备已是最新版本,计入 {@code skipped}。{@code %s} 为设备编号。
+ */
+ public static final String OTA_BATCH_SKIP_ALREADY_LATEST = "设备[%s]已是最新版本,已跳过";
+
+ /**
+ * OTA 批量建任务明细:设备已有进行中的同包升级任务,计入 {@code skipped}。{@code %s} 为设备编号。
+ */
+ public static final String OTA_BATCH_SKIP_DUPLICATE = "设备[%s]已有进行中的升级任务,已跳过";
+
+ /**
+ * OTA 批量建任务明细:设备不存在,计入 {@code failed}。{@code %s} 为设备主键。
+ */
+ public static final String OTA_BATCH_FAIL_DEVICE_NOT_EXIST = "设备[%s]不存在,已失败";
+
+ /**
+ * OTA 批量建任务明细:设备离线({@code status != 在线}),计入 {@code failed}。{@code %s} 为设备编号。
+ */
+ public static final String OTA_BATCH_FAIL_DEVICE_OFFLINE = "设备[%s]离线,已失败";
+
+ /**
+ * OTA 批量建任务明细:指令下发未被设备确认(发布失败 / 回执超时 / 回执拒绝),
+ * 计入 {@code failed}。{@code %s} 为设备编号。
+ */
+ public static final String OTA_BATCH_FAIL_DISPATCH = "设备[%s]指令下发失败,已失败";
+
+ // ---------------- APP 用户管理(后台) ----------------
+
+ /**
+ * 账号状态更新成功(停用 / 启用)。
+ */
+ public static final String USER_STATUS_SUCCESS = "状态更新成功";
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/AppUserAdminController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/AppUserAdminController.java
new file mode 100644
index 0000000..ebb4b26
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/AppUserAdminController.java
@@ -0,0 +1,150 @@
+package org.dromara.device.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.device.constant.AppMsgConstants;
+import org.dromara.device.constant.DeviceMsgConstants;
+import org.dromara.device.domain.bo.AppUserAdminQueryBo;
+import org.dromara.device.domain.bo.AppUserResetPwdBo;
+import org.dromara.device.domain.bo.AppUserStatusBo;
+import org.dromara.device.domain.vo.AppUserAdminVo;
+import org.dromara.device.domain.vo.AppUserDeviceVo;
+import org.dromara.device.service.IAppUserAdminService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 后台 APP 用户管理 Controller(前缀 {@code /device/app-user})。
+ *
+ * 与 APP 端的 {@code AppUserController}({@code /app/user})不是同一组接口:
+ *
+ * 接口清单:
+ *
+ * 权限字:{@code device:appuser:list}(列表) / {@code device:appuser:query}(详情与绑定设备) /
+ * {@code device:appuser:edit}(状态变更、重置密码、解绑设备)。
+ * 与 OTA 的做法一致,变更类复用单一 {@code edit} 权限字,避免权限字数量膨胀。
+ *
+ * 数据权限:APP 账号不属于后台组织架构({@code dept_id} 恒为 {@code NULL}),
+ * 故本组接口不参与框架的数据权限过滤,由权限字做粗粒度把关。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/device/app-user")
+public class AppUserAdminController extends BaseController {
+
+ private final IAppUserAdminService appUserAdminService;
+
+ /**
+ * 分页查询 APP 用户列表。
+ *
+ * 入参只用 {@link AppUserAdminQueryBo}(它已继承 {@code PageQuery}),
+ * 不再并列声明 {@code PageQuery}——否则两个分页参数会同时参与绑定、
+ * 且「谁生效」取决于绑定顺序,属易错设计。
+ *
+ * @param bo 查询条件与分页参数
+ * @return APP 用户分页结果(含绑定设备数)
+ */
+ @SaCheckPermission("device:appuser:list")
+ @GetMapping("/list")
+ public R
+ * 接口清单(架构 §7.2):
+ *
+ * ⚠️ 本模块没有数据权限(部门)隔离:{@code BmDeviceMapper} 未标注
+ * {@link org.dromara.common.mybatis.annotation.DataPermission},而框架的
+ * {@code PlusDataPermissionInterceptor} 必须靠该注解才会生成部门过滤条件 ——
+ * 因此任何持有 {@code device:device:list} 的账号看到的都是全量设备。
+ *
+ * 原注释写「数据权限由框架全局拦截器承担」与本模块实际情况不符(全模块 {@code grep DataPermission} 零命中),
+ * 会让人误以为已经隔离,故订正。若后续确需按部门隔离,必须显式给 Mapper 加 {@code @DataPermission},
+ * 不能指望「全局生效」。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/device")
+public class DeviceController extends BaseController {
+
+ private final IDeviceService deviceService;
+
+ /**
+ * 查询设备列表
+ */
+ @SaCheckPermission("device:device:list")
+ @GetMapping("/list")
+ public R
+ * 采用若依 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
+ * 接口清单(架构 §7.2、PRD §5 R-29~R-32):
+ *
+ * 返回 {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
+ * 本组接口为登录类,统一 {@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
+ * 令牌封装为 {@link AppTokenVo} 并置于响应 {@code data}(P3-1 修复)——避免 {@code R.ok(String)}
+ * 被解析为「msg 重载」而把令牌塞进 {@code msg}。{@code IAppAuthService#login} 仍返回令牌字符串(架构 §5 不变)。
+ */
+ @PostMapping("/login")
+ public R
+ * 令牌封装口径同 {@link #login}(P3-1 修复)。
+ */
+ @PostMapping("/sms-login")
+ public R
+ * 成功文案给出「请绑定手机号」引导:按本迭代账号规则,邮箱注册后须继续绑定手机号,
+ * 未绑手机号的账号无法绑定设备(服务端硬拦截,见 {@code IAppDeviceService#bind})。
+ */
+ @PostMapping("/email/register")
+ public R
+ * 令牌封装口径同 {@link #login}:置于响应 {@code data},避免 {@code R.ok(String)} 被解析为 msg 重载。
+ */
+ @PostMapping("/email/login")
+ public R
+ * 本组接口需登录态;当前用户取自登录标识 {@code app_user:{userId}}({@code LoginHelper.getUserId()}),
+ * 仅能操作本人绑定设备,不参与后台数据权限、不加 {@code @SaCheckPermission}(《开发计划》§12,与 I2 口径一致)。
+ *
+ * 接口清单(迭代 I3,架构 §7.1 #12–16):
+ *
+ * 本接口会阻塞约一个 RTT + 设备动作时间(最长 {@code device.mqtt.hood-timeout},默认 5s),
+ * 直到设备回执 {@code ack.ok=true} 才返回成功——协议 §8.2 的唯一成功凭据。
+ * 失败(离线 / 低电 / 超时 / 设备拒绝)统一以业务异常返回,前端按提示文案处理即可。
+ *
+ * @param deviceNo 设备编号(路径变量,回填至 BO)
+ * @param bo 眼罩入参(coverOn)
+ * @return 设备确认执行后的眼罩状态
+ */
+ @PutMapping("/{deviceNo}/hood")
+ public R
+ * 越权隔离与 {@link #detail} 完全同口径:非本人设备统一「设备不存在」。
+ * 设备从未上报定位时返回空 VO({@code lng}/{@code lat} 为 {@code null}),不抛异常——
+ * APP 需要一个可展示的空态(见 {@link LocationVo})。
+ *
+ * @param deviceNo 设备编号
+ * @return 定位视图对象(坐标可能为 {@code null})
+ */
+ @GetMapping("/{deviceNo}/location")
+ public R
+ * 需要设备已经在推、或随后调用 {@code POST /app/stream/start}。
+ * 路径写成 {@code /{deviceNo}/live},避免被 {@code GET /{deviceNo}} 吞掉 {@code /app/device/live}。
+ *
+ * @param deviceNo 设备编号
+ * @return 带 token 的播放地址
+ */
+ @GetMapping("/{deviceNo}/live")
+ public R
+ * 本组接口需登录态;当前用户取自 {@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
+ * 说明:本表不含任何直播字段(无 stream_url / play_url / live_*)。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("bm_device")
+public class BmDevice extends BaseEntity {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 主键(雪花,仅内部关联,不对外暴露)
+ */
+ @TableId(value = "device_id")
+ private Long deviceId;
+
+ /**
+ * 业务编号(唯一,对外主展示键)
+ */
+ private String deviceNo;
+
+ /**
+ * 绑定码 IMEI(唯一,APP 绑定键)
+ */
+ private String imei;
+
+ /**
+ * 用户侧名称
+ */
+ private String name;
+
+ /**
+ * 型号
+ */
+ private String model;
+
+ /**
+ * 出厂序列号
+ */
+ private String sn;
+
+ /**
+ * 硬件版本
+ */
+ private String hwVersion;
+
+ /**
+ * 软件版本
+ */
+ private String swVersion;
+
+ /**
+ * 固件版本
+ */
+ private String fwVersion;
+
+ /**
+ * 数据包版本
+ */
+ private String pkgVersion;
+
+ /**
+ * 设备 IP
+ */
+ private String ip;
+
+ /**
+ * MAC
+ */
+ private String mac;
+
+ /**
+ * WiFi SSID
+ */
+ private String wifiSsid;
+
+ /**
+ * 状态(0 未激活 1 在线 2 离线)
+ */
+ private String status;
+
+ /**
+ * 当前绑定 APP 用户
+ */
+ private Long bindUserId;
+
+ /**
+ * 最近一次绑定时间
+ */
+ private LocalDateTime bindTime;
+
+ /**
+ * 头套连接(0 断开 1 已连接)
+ */
+ private String hoodConnected;
+
+ /**
+ * 眼罩状态(0 打开 1 遮盖)
+ */
+ private String hoodCoverOn;
+
+ /**
+ * 信号等级(0 离线 1 弱 2 一般 3 强)
+ */
+ private Integer signalLevel;
+
+ /**
+ * 头套电量 0-100
+ */
+ private Integer hoodBattery;
+
+ /**
+ * 脚环电量 0-100
+ */
+ private Integer ringBattery;
+
+ /**
+ * 头套低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发,即对该设备关闭低电保护)
+ */
+ private Integer hoodBatteryThreshold;
+
+ /**
+ * 脚环低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发)
+ */
+ private Integer ringBatteryThreshold;
+
+ /**
+ * 最新经度
+ */
+ private BigDecimal lng;
+
+ /**
+ * 最新纬度
+ */
+ private BigDecimal lat;
+
+ /**
+ * 地址(设备上报)
+ */
+ private String address;
+
+ /**
+ * 最后在线时间
+ */
+ private LocalDateTime lastOnlineTime;
+
+ /**
+ * 备注
+ */
+ private String remark;
+
+ /**
+ * 逻辑删除(0 存在 1 删除)
+ */
+ @TableLogic
+ private String delFlag;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceAlert.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceAlert.java
new file mode 100644
index 0000000..259e21f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceAlert.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceBind.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceBind.java
new file mode 100644
index 0000000..b2cf889
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceBind.java
@@ -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
+ *
+ * 说明:流水表不设逻辑删除(禁止抹除历史)。
+ *
+ * @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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceTelemetry.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceTelemetry.java
new file mode 100644
index 0000000..22ee1d0
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDeviceTelemetry.java
@@ -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
+ *
+ * 说明:一期仅落库(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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmOtaPackage.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmOtaPackage.java
new file mode 100644
index 0000000..96176c1
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmOtaPackage.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmOtaTask.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmOtaTask.java
new file mode 100644
index 0000000..256490a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmOtaTask.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceBindBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceBindBo.java
new file mode 100644
index 0000000..6d6904c
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceBindBo.java
@@ -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})。
+ *
+ * 字段口径对齐《开发计划》§8.2:入参 {@code imei} + {@code name}(≤20)。
+ *
+ * {@code deviceNo} 由路径变量提供,Controller 回填到本 BO(与 {@code AppDeviceNameBo} 同一约定),
+ * 该字段不做 Bean Validation:Spring 在参数解析阶段完成 {@code @RequestBody} 校验,
+ * 彼时路径回填尚未发生、字段恒为 null。空值由服务层兜底,统一按「设备不存在」处理。
+ *
+ * {@code coverOn} 必须显式传值——不接受「缺省即打开」:眼罩是物理器械,
+ * 请求体漏字段时服务端必须报错而不是替用户猜一个动作。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+public class AppDeviceHoodBo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 设备编号(由 Controller 从路径变量 {@code {deviceNo}} 回填)。
+ * 不做 Bean Validation(见类注释);空值由服务层兜底。
+ */
+ private String deviceNo;
+
+ /**
+ * 是否遮盖:{@code true} 遮盖 / {@code false} 打开。
+ */
+ @NotNull(message = "眼罩状态不能为空")
+ private Boolean coverOn;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceNameBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceNameBo.java
new file mode 100644
index 0000000..6a33d81
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceNameBo.java
@@ -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})。
+ *
+ * {@code deviceNo} 由路径变量提供,Controller 会将其回填到本 BO 的 {@code deviceNo} 字段,
+ * 以保证服务层签名 {@code rename(AppDeviceNameBo bo, Long userId)} 自洽。
+ * 该字段不做 Bean Validation:Spring 在参数解析阶段即完成 {@code @RequestBody} 校验,
+ * 彼时路径回填尚未发生、字段恒为 null,若加校验会导致「按契约(body 仅 name)调用」恒失败;
+ * 空值改由服务层兜底,统一按「设备不存在」处理(与越权隔离同一文案)。
+ * {@code name} 按字符数限制 ≤ {@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}} 回填)。
+ * 不做 Bean Validation(见类注释);空值由 {@code AppDeviceServiceImpl#rename} 兜底。
+ */
+ private String deviceNo;
+
+ /**
+ * 新的用户侧设备名称(≤20 个字符)。
+ */
+ @NotBlank(message = "设备名称不能为空")
+ @Size(max = AppConstants.DEVICE_NAME_MAX_LENGTH, message = "设备名称不能超过 20 个字")
+ private String name;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailCodeBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailCodeBo.java
new file mode 100644
index 0000000..0051936
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailCodeBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailCodeLoginBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailCodeLoginBo.java
new file mode 100644
index 0000000..4e3e704
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailCodeLoginBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailLoginBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailLoginBo.java
new file mode 100644
index 0000000..9583ea7
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailLoginBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailRegisterBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailRegisterBo.java
new file mode 100644
index 0000000..b581309
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailRegisterBo.java
@@ -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 邮箱注册入参。
+ *
+ * 不含手机号:按本迭代账号规则「注册后仍须绑手机号」,邮箱注册先建号
+ * ({@code phone_number} 为 {@code NULL}),再由 APP 引导调用 {@code PUT /app/user/phone}
+ * 完成绑定;未绑手机号的账号无法绑定设备(服务端硬拦截)。
+ *
+ * 协议勾选态由 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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailResetPwdBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailResetPwdBo.java
new file mode 100644
index 0000000..01e8d2a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailResetPwdBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppLoginBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppLoginBo.java
new file mode 100644
index 0000000..885598f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppLoginBo.java
@@ -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 密码登录入参。
+ *
+ * 登录仅校验密码非空(历史密码可能不符合新密码规则,不在登录时做格式拦截)。
+ *
+ * @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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppPasswordBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppPasswordBo.java
new file mode 100644
index 0000000..cf26b64
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppPasswordBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppPhoneBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppPhoneBo.java
new file mode 100644
index 0000000..efa07c6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppPhoneBo.java
@@ -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 换绑手机入参。
+ *
+ * 验证码发送到新手机号(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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppRegisterBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppRegisterBo.java
new file mode 100644
index 0000000..2457cd0
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppRegisterBo.java
@@ -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 手机号注册入参。
+ *
+ * 协议勾选态由 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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppResetPwdBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppResetPwdBo.java
new file mode 100644
index 0000000..e8fdc68
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppResetPwdBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsCodeBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsCodeBo.java
new file mode 100644
index 0000000..2cd1fd6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsCodeBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsLoginBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsLoginBo.java
new file mode 100644
index 0000000..56d8c9e
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsLoginBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppStreamStartBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppStreamStartBo.java
new file mode 100644
index 0000000..f673a7b
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppStreamStartBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserAdminQueryBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserAdminQueryBo.java
new file mode 100644
index 0000000..4a2710f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserAdminQueryBo.java
@@ -0,0 +1,49 @@
+package org.dromara.device.domain.bo;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.page.PageQuery;
+
+import java.io.Serial;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * APP 用户管理查询入参({@code GET /device/app-user/list})。
+ *
+ * 仅面向 APP 用户({@code sys_user.user_type = app_user})——类型条件由服务端强制附加,
+ * 前端不需要也无法通过本入参覆盖。
+ *
+ * 分页字段({@code pageNum} / {@code pageSize} / {@code orderByColumn} / {@code isAsc})
+ * 继承自 {@link PageQuery},与框架其余列表页口径一致。
+ *
+ * 日期区间经 {@link #params} 传递:前端用 {@code useDateRangeQuery} hook
+ * (内部 {@code utils/ruoyi.ts#addDateRange})把区间写为 {@code params[beginTime]} /
+ * {@code params[endTime]},与框架既有约定一致。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AppUserAdminQueryBo extends PageQuery {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 关键词:模糊匹配账号 / 昵称 / 手机号 / 邮箱(四字段 OR)。
+ */
+ private String keyword;
+
+ /**
+ * 账号状态(0 正常 1 停用);留空查询全部。
+ */
+ private String status;
+
+ /**
+ * 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:注册时间区间)。
+ */
+ private Map
+ * 密码规则与 APP 端注册 / 改密完全同源({@link AppConstants#PASSWORD_REGEX}):
+ * 管理员在后台设的密码,必须同样满足 APP 端的登录前置要求,
+ * 否则会出现「后台设置成功、用户却因密码不合规被 APP 拒」的割裂体验。
+ *
+ * 不引入「确认密码」字段:二次输入属前端交互,服务端只需最终值。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+public class AppUserResetPwdBo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户主键
+ */
+ @NotNull(message = "用户主键不能为空")
+ private Long userId;
+
+ /**
+ * 新密码(8-20 位,含大小写字母和数字)
+ */
+ @NotBlank(message = "新密码不能为空")
+ @Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
+ private String newPassword;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserStatusBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserStatusBo.java
new file mode 100644
index 0000000..6e4535f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserStatusBo.java
@@ -0,0 +1,39 @@
+package org.dromara.device.domain.bo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * APP 用户状态变更入参({@code PUT /device/app-user/status})。
+ *
+ * {@code status} 用正则而非枚举校验:字典 {@code sys_user.status} 为 {@code varchar(1)},
+ * 取值仅 {@code 0}(正常)/ {@code 1}(停用),正则能同时挡住空串与超长值。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+public class AppUserStatusBo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户主键
+ */
+ @NotNull(message = "用户主键不能为空")
+ private Long userId;
+
+ /**
+ * 目标状态(0 正常 1 停用)
+ */
+ @NotBlank(message = "账号状态不能为空")
+ @Pattern(regexp = "^[01]$", message = "账号状态只能为 0(正常)或 1(停用)")
+ private String status;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/DeviceBindBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/DeviceBindBo.java
new file mode 100644
index 0000000..a1f5257
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/DeviceBindBo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/DeviceBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/DeviceBo.java
new file mode 100644
index 0000000..5b92d8b
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/DeviceBo.java
@@ -0,0 +1,186 @@
+package org.dromara.device.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.device.domain.BmDevice;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 设备主表业务对象 bm_device
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+@AutoMapper(target = BmDevice.class, reverseConvertGenerate = false)
+public class DeviceBo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 主键
+ */
+ @NotNull(message = "设备主键不能为空", groups = {EditGroup.class})
+ private Long deviceId;
+
+ /**
+ * 业务编号(留空时由服务端生成 BM+yyyyMMdd+4位序号)
+ */
+ private String deviceNo;
+
+ /**
+ * 绑定码 IMEI
+ */
+ @NotBlank(message = "IMEI 不能为空", groups = {AddGroup.class, EditGroup.class})
+ private String imei;
+
+ /**
+ * 用户侧名称
+ */
+ private String name;
+
+ /**
+ * 型号
+ */
+ private String model;
+
+ /**
+ * 出厂序列号
+ */
+ private String sn;
+
+ /**
+ * 硬件版本
+ */
+ private String hwVersion;
+
+ /**
+ * 软件版本
+ */
+ private String swVersion;
+
+ /**
+ * 固件版本
+ */
+ private String fwVersion;
+
+ /**
+ * 数据包版本
+ */
+ private String pkgVersion;
+
+ /**
+ * 设备 IP
+ */
+ private String ip;
+
+ /**
+ * MAC
+ */
+ private String mac;
+
+ /**
+ * WiFi SSID
+ */
+ private String wifiSsid;
+
+ /**
+ * 状态(0 未激活 1 在线 2 离线)
+ */
+ private String status;
+
+ /**
+ * 当前绑定 APP 用户
+ */
+ private Long bindUserId;
+
+ /**
+ * 最近一次绑定时间
+ */
+ private LocalDateTime bindTime;
+
+ /**
+ * 头套连接(0 断开 1 已连接)
+ */
+ private String hoodConnected;
+
+ /**
+ * 眼罩状态(0 打开 1 遮盖)
+ */
+ private String hoodCoverOn;
+
+ /**
+ * 信号等级(0 离线 1 弱 2 一般 3 强)
+ */
+ private Integer signalLevel;
+
+ /**
+ * 头套电量 0-100
+ */
+ private Integer hoodBattery;
+
+ /**
+ * 脚环电量 0-100
+ */
+ private Integer ringBattery;
+
+ /**
+ * 头套低电告警阈值 0-100(电量 ≤ 该值触发告警;0 表示不触发)
+ *
+ * 恢复阈值不在此配置:由服务端按「触发阈值 + 回差」派生
+ * (见 {@code DeviceMqttProperties#resolveRecoverThreshold(int)}),
+ * 避免出现「恢复阈值 ≤ 触发阈值」把设备卡死在告警态的配置。
+ */
+ @Min(value = 0, message = "头套低电阈值不能小于 0", groups = {AddGroup.class, EditGroup.class})
+ @Max(value = 100, message = "头套低电阈值不能大于 100", groups = {AddGroup.class, EditGroup.class})
+ private Integer hoodBatteryThreshold;
+
+ /**
+ * 脚环低电告警阈值 0-100(电量 ≤ 该值触发告警;0 表示不触发)
+ */
+ @Min(value = 0, message = "脚环低电阈值不能小于 0", groups = {AddGroup.class, EditGroup.class})
+ @Max(value = 100, message = "脚环低电阈值不能大于 100", groups = {AddGroup.class, EditGroup.class})
+ private Integer ringBatteryThreshold;
+
+ /**
+ * 最新经度
+ */
+ private BigDecimal lng;
+
+ /**
+ * 最新纬度
+ */
+ private BigDecimal lat;
+
+ /**
+ * 地址
+ */
+ private String address;
+
+ /**
+ * 最后在线时间
+ */
+ private LocalDateTime lastOnlineTime;
+
+ /**
+ * 备注
+ */
+ private String remark;
+
+ /**
+ * 关键字(模糊匹配 deviceNo / name / imei,查询用)
+ */
+ private String keyword;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaBatchBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaBatchBo.java
new file mode 100644
index 0000000..2ed1766
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaBatchBo.java
@@ -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)。
+ *
+ * 字段名严格对齐前端 {@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
+ * 内容来源为配置项 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppDeviceDetailVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppDeviceDetailVo.java
new file mode 100644
index 0000000..d507c81
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppDeviceDetailVo.java
@@ -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}})。
+ *
+ * 范围对齐《开发计划》§8.2「详情:坐标、眼罩、双电量、信号、头套连接、地址」:
+ * 在列表卡片字段基础上增补眼罩状态({@code hoodCoverOn}),用于详情页展示与后续眼罩开关联动。
+ *
+ * 字段严格对齐《开发计划》§8.2「列表卡片字段」,字段名保持一致,不新增未约定字段:
+ *
+ * 口径见《设备通信协议-一期.md》§8.2:只有收到 {@code ack.ok=true} 才返回成功,
+ * 响应即「设备已确认执行」后的最新眼罩状态,APP 可直接据此刷新按钮,不必再拉一次详情。
+ *
+ * 为什么返回状态而不是一句文案:眼罩是双向开关(打开 / 遮盖),
+ * 返回完整状态让 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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamPlayUrlsVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamPlayUrlsVo.java
new file mode 100644
index 0000000..57d0226
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamPlayUrlsVo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamPlayVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamPlayVo.java
new file mode 100644
index 0000000..2b3a7a1
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamPlayVo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamStatusVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamStatusVo.java
new file mode 100644
index 0000000..57f9b05
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppStreamStatusVo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppTokenVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppTokenVo.java
new file mode 100644
index 0000000..fca7df5
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppTokenVo.java
@@ -0,0 +1,43 @@
+package org.dromara.device.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * APP 登录令牌视图对象。
+ *
+ * 用于把访问令牌放在响应的 {@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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java
new file mode 100644
index 0000000..cf9b1a7
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java
@@ -0,0 +1,81 @@
+package org.dromara.device.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * APP 用户管理视图对象(后台「APP 用户管理」列表与详情共用)。
+ *
+ * 不脱敏:后台管理端与 APP 端口径不同——APP 端({@code AppUserProfileVo})对手机号 / 邮箱脱敏,
+ * 而管理端需要完整信息用于人工核对与客服定位,故此处保留明文,
+ * 与框架 {@code GET /system/user/list} 的 {@code SysUserVo} 口径一致。
+ *
+ * 不含密码:密码散列不跨模块边界(见 {@code AppUserApiService} 的模块边界约定),
+ * 本 VO 亦不透出任何密码相关字段。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+public class AppUserAdminVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户主键
+ */
+ private Long userId;
+
+ /**
+ * 账号(APP 账号为服务端派生的 {@code app_ + userId},非用户可见信息)
+ */
+ private String userName;
+
+ /**
+ * 昵称(手机号注册默认取手机号;邮箱注册默认取邮箱)
+ */
+ private String nickName;
+
+ /**
+ * 手机号(邮箱注册且尚未绑定时为 {@code null})
+ */
+ private String phoneNumber;
+
+ /**
+ * 邮箱(手机号注册的账号为 {@code null})
+ */
+ private String email;
+
+ /**
+ * 账号状态(0 正常 1 停用)
+ */
+ private String status;
+
+ /**
+ * 是否已绑定手机号。
+ *
+ * 供管理员识别「邮箱注册但未绑手机号」的账号——这类账号无法绑定设备
+ * (服务端硬拦截,见 {@code IAppDeviceService#bind}),是需要提醒用户补绑的对象。
+ */
+ private Boolean phoneBound;
+
+ /**
+ * 当前绑定设备数
+ */
+ private Long deviceCount;
+
+ /**
+ * 注册时间
+ */
+ private LocalDateTime createTime;
+
+ /**
+ * 最近更新时间
+ */
+ private LocalDateTime updateTime;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserDeviceVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserDeviceVo.java
new file mode 100644
index 0000000..689be10
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserDeviceVo.java
@@ -0,0 +1,77 @@
+package org.dromara.device.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * APP 用户「绑定设备」条目({@code GET /device/app-user/{userId}/devices})。
+ *
+ * 字段是对 {@code DeviceVo} 的裁剪:只保留管理端在「用户视角」下关心的身份与运行态信息,
+ * 不含 IP / MAC / 版本号等设备运维字段(那些在「设备列表」页看更合适)。
+ *
+ * IMEI 不脱敏:与后台设备列表口径一致(后台可见完整 IMEI 用于核对);
+ * APP 端({@code AppDeviceListVo})才做脱敏。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+public class AppUserDeviceVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 设备编号(对外主展示键)
+ */
+ private String deviceNo;
+
+ /**
+ * 用户侧设备名称
+ */
+ private String name;
+
+ /**
+ * 绑定码 IMEI
+ */
+ private String imei;
+
+ /**
+ * 型号
+ */
+ private String model;
+
+ /**
+ * 设备状态(0 未激活 1 在线 2 离线)
+ */
+ private String status;
+
+ /**
+ * 信号等级(0 离线 1 弱 2 一般 3 强)
+ */
+ private Integer signalLevel;
+
+ /**
+ * 头套电量 0-100
+ */
+ private Integer hoodBattery;
+
+ /**
+ * 脚环电量 0-100
+ */
+ private Integer ringBattery;
+
+ /**
+ * 最近一次绑定时间
+ */
+ private LocalDateTime bindTime;
+
+ /**
+ * 最后在线时间
+ */
+ private LocalDateTime lastOnlineTime;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserProfileVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserProfileVo.java
new file mode 100644
index 0000000..3e3c6df
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserProfileVo.java
@@ -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 个人中心视图对象。
+ *
+ * 手机号按「全局说明」要求脱敏后返回(如 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;
+
+ /**
+ * 手机号(脱敏)。
+ * 邮箱注册且尚未绑定手机号的账号为 {@code null}。
+ */
+ private String phoneNumber;
+
+ /**
+ * 用户邮箱(脱敏)。
+ * 手机号注册的账号为 {@code null}。
+ */
+ private String email;
+
+ /**
+ * 是否已绑定手机号。
+ *
+ * 供 APP 判断是否需强制引导绑手机号(账号规则:邮箱注册后仍须绑手机号,
+ * 未绑手机号的账号无法绑定设备)。
+ */
+ private Boolean phoneBound;
+
+ /**
+ * 用户类型(app_user)。
+ */
+ private String userType;
+
+ /**
+ * 注册时间。
+ */
+ private LocalDateTime createTime;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceBindVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceBindVo.java
new file mode 100644
index 0000000..6778140
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceBindVo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceStatsVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceStatsVo.java
new file mode 100644
index 0000000..db7712f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceStatsVo.java
@@ -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)。
+ *
+ * 口径裁定(I5 登记):字段名严格取架构 §6 时序图口径 {@code total / online / offline / inactive}
+ * (「未激活」用 {@code inactive}),不同时输出语义命名的 {@code notActivated}。
+ * 前端 {@code types.ts} 已同时声明两者并兼容取值,本接口只出 {@code inactive},
+ * 前端可安全删去冗余的 {@code notActivated}。
+ *
+ * 数值口径:{@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceVo.java
new file mode 100644
index 0000000..51fc8af
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceVo.java
@@ -0,0 +1,219 @@
+package org.dromara.device.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
+import org.apache.fesod.sheet.annotation.ExcelProperty;
+import org.apache.fesod.sheet.annotation.format.DateTimeFormat;
+import org.dromara.common.translation.annotation.Translation;
+import org.dromara.common.translation.constant.TransConstant;
+import org.dromara.device.domain.BmDevice;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 设备主表视图对象 bm_device
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+@ExcelIgnoreUnannotated
+@AutoMapper(target = BmDevice.class)
+public class DeviceVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 主键
+ */
+ @ExcelProperty(value = "设备主键")
+ private Long deviceId;
+
+ /**
+ * 业务编号
+ */
+ @ExcelProperty(value = "设备编号")
+ private String deviceNo;
+
+ /**
+ * IMEI
+ */
+ @ExcelProperty(value = "IMEI")
+ private String imei;
+
+ /**
+ * 用户侧名称
+ */
+ @ExcelProperty(value = "设备名称")
+ private String name;
+
+ /**
+ * 型号
+ */
+ @ExcelProperty(value = "型号")
+ private String model;
+
+ /**
+ * 出厂序列号
+ */
+ @ExcelProperty(value = "出厂序列号")
+ private String sn;
+
+ /**
+ * 硬件版本
+ */
+ @ExcelProperty(value = "硬件版本")
+ private String hwVersion;
+
+ /**
+ * 软件版本
+ */
+ @ExcelProperty(value = "软件版本")
+ private String swVersion;
+
+ /**
+ * 固件版本
+ */
+ @ExcelProperty(value = "固件版本")
+ private String fwVersion;
+
+ /**
+ * 数据包版本
+ */
+ @ExcelProperty(value = "数据包版本")
+ private String pkgVersion;
+
+ /**
+ * 设备 IP
+ */
+ @ExcelProperty(value = "设备IP")
+ private String ip;
+
+ /**
+ * MAC
+ */
+ @ExcelProperty(value = "MAC")
+ private String mac;
+
+ /**
+ * WiFi SSID
+ */
+ @ExcelProperty(value = "WiFi名称")
+ private String wifiSsid;
+
+ /**
+ * 状态(0 未激活 1 在线 2 离线)
+ */
+ @ExcelProperty(value = "状态")
+ private String status;
+
+ /**
+ * 当前绑定用户
+ */
+ private Long bindUserId;
+
+ /**
+ * 绑定用户账号
+ */
+ @Translation(type = TransConstant.USER_ID_TO_NAME, mapper = "bindUserId")
+ @ExcelProperty(value = "绑定用户")
+ private String bindUserName;
+
+ /**
+ * 最近一次绑定时间
+ */
+ @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
+ @ExcelProperty(value = "绑定时间")
+ private LocalDateTime bindTime;
+
+ /**
+ * 头套连接(0 断开 1 已连接)
+ */
+ @ExcelProperty(value = "头套连接")
+ private String hoodConnected;
+
+ /**
+ * 眼罩状态(0 打开 1 遮盖)
+ */
+ @ExcelProperty(value = "眼罩状态")
+ private String hoodCoverOn;
+
+ /**
+ * 信号等级(0 离线 1 弱 2 一般 3 强)
+ */
+ @ExcelProperty(value = "信号等级")
+ private Integer signalLevel;
+
+ /**
+ * 头套电量 0-100
+ */
+ @ExcelProperty(value = "头套电量")
+ private Integer hoodBattery;
+
+ /**
+ * 脚环电量 0-100
+ */
+ @ExcelProperty(value = "脚环电量")
+ private Integer ringBattery;
+
+ /**
+ * 头套低电告警阈值 0-100(设备级配置;0 表示不触发)
+ */
+ @ExcelProperty(value = "头套低电阈值")
+ private Integer hoodBatteryThreshold;
+
+ /**
+ * 脚环低电告警阈值 0-100(设备级配置;0 表示不触发)
+ */
+ @ExcelProperty(value = "脚环低电阈值")
+ private Integer ringBatteryThreshold;
+
+ /**
+ * 最新经度
+ */
+ private BigDecimal lng;
+
+ /**
+ * 最新纬度
+ */
+ private BigDecimal lat;
+
+ /**
+ * 地址
+ */
+ @ExcelProperty(value = "地址")
+ private String address;
+
+ /**
+ * 最后在线时间
+ */
+ @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
+ @ExcelProperty(value = "最后在线时间")
+ private LocalDateTime lastOnlineTime;
+
+ /**
+ * 备注
+ */
+ @ExcelProperty(value = "备注")
+ private String remark;
+
+ /**
+ * 创建时间
+ */
+ @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
+ @ExcelProperty(value = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 更新时间
+ */
+ @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
+ @ExcelProperty(value = "更新时间")
+ private LocalDateTime updateTime;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/LocationVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/LocationVo.java
new file mode 100644
index 0000000..5212e27
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/LocationVo.java
@@ -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)。
+ *
+ * 《开发计划》§8.2 对该接口的定义只有一句「最新点(给『查看地图』用)」,架构未给字段,由本条定稿:
+ * {@code deviceNo} + 定位三件套({@code lng} / {@code lat} / {@code address})+ {@code lastOnlineTime}。
+ *
+ * 空态取舍(重要):设备从未上报过定位(或设备端解析失败未带坐标)时,
+ * {@code lng} / {@code lat} 为 {@code null},本接口返回空 VO 而非抛异常。
+ * 理由是 APP 的「查看地图」需要一个可展示的空态(占位插画 /「暂无定位」文案);
+ * 若抛业务异常,前端只能弹一个 toast,无法进入地图页,体验割裂。坐标是否有值由前端判空决定展示分支。
+ *
+ * 越权隔离:与服务层 {@code detail} 完全同口径——非当前用户所属设备一律统一抛
+ * {@code DeviceMsgConstants.DEVICE_NOT_EXIST},本 VO 只在归属校验通过后才被填充。
+ *
+ * 本 VO 不含任何直播字段(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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaBatchResultVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaBatchResultVo.java
new file mode 100644
index 0000000..794713a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaBatchResultVo.java
@@ -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)。
+ *
+ * 字段名严格对齐前端 {@code types.ts} 的 {@code OtaBatchResultVO}:
+ * {@code total / created / skipped / failed / messages}。
+ *
+ * 不变式:{@code total == created + skipped + failed}——
+ * 每台(去重后的)设备必落入三者之一,实现里逐条维护该分类,杜绝「统计对不上账」。
+ *
+ * 与 {@link org.dromara.device.sms.AppSmsService} 的关系:本服务是其邮箱通道的平行实现,
+ * 而非抽象重构。原因是短信链路已在迭代 I2 实测通过,抽公共父类会把已验证行为重新暴露于变更风险;
+ * 两处刻意保持「同一套加固口径、两套独立代码」,代价是少量重复,收益是互不影响、可分别回归。
+ *
+ * 加固口径(与短信完全一致,逐条对齐):
+ *
+ * 防定向 DoS(与短信同口径):正确验证码永远可校验成功,失败仅作用于「错误尝试」,
+ * 绝不删除或失效验证码本身。因此攻击者即便知道受害者邮箱、连续提交错误码,
+ * 也无法让受害者的验证码失效。暴力破解防护仍成立:错误尝试按「场景 + 邮箱」做短窗口限速,
+ * 窗口到期自动自愈(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);
+ }
+
+ /**
+ * 邮箱地址归一化:去首尾空白 + 统一小写。
+ *
+ * 用于判重与 Redis key,避免「A@Gmail.com」与「a@gmail.com」被判为两个账号。
+ *
+ * 已知边界(未处理,如需请显式提出):Gmail 实际把 {@code a.b@gmail.com} 与
+ * {@code ab@gmail.com}、{@code a+tag@gmail.com} 视为同一邮箱。本方法不做点号/加号折叠,
+ * 故同一 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。
+ *
+ * 同步发送:与短信通道一致,在 HTTP 请求线程内完成。Gmail 的 TLS 握手通常 1–3 秒,
+ * 已通过 {@code device.app.email.connection-timeout} / {@code timeout} 限制上限,
+ * 保证 SMTP 不可达时快速失败而非挂死请求线程。若后续需要消除该耗时,可改为异步发送。
+ *
+ * @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 " 您正在使用智能飞禽物联平台,本次操作的验证码为: " + code + " 验证码 " + expiryMinutes + " 分钟内有效,请勿泄露给他人。 若非本人操作,请忽略本邮件。
+ * 对应 {@code bm_device_alert.alert_level}:{@code char(1)},DB 直存、无对应字典
+ * (与 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/AlertTypeEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/AlertTypeEnum.java
new file mode 100644
index 0000000..e1a7647
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/AlertTypeEnum.java
@@ -0,0 +1,43 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 告警类型枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/BindActionEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/BindActionEnum.java
new file mode 100644
index 0000000..910ba18
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/BindActionEnum.java
@@ -0,0 +1,43 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 绑定动作枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/DeviceStatusEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/DeviceStatusEnum.java
new file mode 100644
index 0000000..83606dc
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/DeviceStatusEnum.java
@@ -0,0 +1,43 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 设备状态枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/EmailSceneEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/EmailSceneEnum.java
new file mode 100644
index 0000000..127bba2
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/EmailSceneEnum.java
@@ -0,0 +1,67 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.dromara.common.core.utils.StringUtils;
+
+/**
+ * APP 邮箱验证码场景枚举。
+ *
+ * 场景编码复用 {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd),
+ * 与短信通道保持一致的语义;但由于 Redis key 前缀不同({@code app:email:*} 与 {@code app:sms:*}),
+ * 两个通道的验证码与限流互不影响。
+ *
+ * 为什么没有 rebind / bind:手机号的绑定与换绑必须由新手机号收到的短信验证码证明归属,
+ * 邮箱验证码无法证明手机号归属,故邮箱通道不提供这两个场景。
+ *
+ * @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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/HoodConnectedEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/HoodConnectedEnum.java
new file mode 100644
index 0000000..de5cfc7
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/HoodConnectedEnum.java
@@ -0,0 +1,38 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 头套连接枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/HoodCoverEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/HoodCoverEnum.java
new file mode 100644
index 0000000..20eccbc
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/HoodCoverEnum.java
@@ -0,0 +1,38 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 眼罩状态枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/OtaPackageStatusEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/OtaPackageStatusEnum.java
new file mode 100644
index 0000000..b9d9fd7
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/OtaPackageStatusEnum.java
@@ -0,0 +1,38 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * OTA 升级包状态枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/OtaTaskStatusEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/OtaTaskStatusEnum.java
new file mode 100644
index 0000000..01f640f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/OtaTaskStatusEnum.java
@@ -0,0 +1,48 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * OTA 任务状态枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SignalLevelEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SignalLevelEnum.java
new file mode 100644
index 0000000..948d2e3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SignalLevelEnum.java
@@ -0,0 +1,48 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 信号等级枚举
+ *
+ * 对应字典 {@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SmsSceneEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SmsSceneEnum.java
new file mode 100644
index 0000000..cbbbb2a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SmsSceneEnum.java
@@ -0,0 +1,73 @@
+package org.dromara.device.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.dromara.common.core.utils.StringUtils;
+
+/**
+ * APP 短信验证码场景枚举。
+ *
+ * 场景与 {@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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceAlertMapper.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceAlertMapper.java
new file mode 100644
index 0000000..841cba8
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceAlertMapper.java
@@ -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
+ * 说明:下列自定义查询均以 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
+ * 用作 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);
+
+ /**
+ * 查询心跳超时、应判定为离线的在网设备(离线扫描用)。
+ *
+ * 过滤口径:{@code del_flag = '0'} 且 {@code status = '1'} 且
+ * {@code last_online_time} 已存在但早于 {@code before}。
+ *
+ * 为什么要排除 {@code last_online_time IS NULL}:APP 绑定成功时会把 {@code status} 直接置为「在线」,
+ * 而 {@code last_online_time} 要等设备第一次上报遥测才写入。若把 NULL 也算作超时,
+ * 用户刚添加完设备就会被扫描判离线并生成一条离线告警——这是错误的事实。
+ * 因此「从未有过心跳」的设备不受心跳超时约束,其在线态由业务侧(绑定/解绑)决定;
+ * 一旦收到过遥测,后续就完整纳入心跳判定。
+ *
+ * 该方法只负责筛出候选,是否真的迁移到离线由
+ * {@code DevicePresenceWriter#markOffline} 的条件更新原子裁决(避免与 LWT 事件竞争重复告警)。
+ *
+ * @param before 心跳判定基准时间(通常为 {@code now - offlineThreshold})
+ * @param limit 单轮返回上限,防止积压时一次性拉爆内存
+ * @return 候选设备列表(仅填充 device_id / device_no / last_online_time,按心跳时间升序)
+ */
+ List
+ * 回执是「指令成功」的唯一凭据,见协议 §5.3。本类只做解析与转发,
+ * 由 {@link DeviceUplinkHandler#onAck} 实现按 {@code msgId} 唤醒指令服务中注册的
+ * {@code CompletableFuture},并仅在成功时更新设备状态字段。
+ *
+ * 重复回执是正常的(设备重发、QoS 1 重投),实现方需按 {@code msgId} 幂等处理。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceAckListener {
+
+ /**
+ * 上行处理端口(可选注入,未装配时仅记日志)
+ */
+ private final ObjectProvider
+ * 口径见《设备通信协议-一期.md》§7。职责边界:
+ *
+ * 采用 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);
+ }
+
+ /**
+ * 下发直播推流指令(开始 / 停止)。
+ *
+ * 直播接口不等待回执:流是否在线以 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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceEventListener.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceEventListener.java
new file mode 100644
index 0000000..ba15930
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceEventListener.java
@@ -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)。
+ *
+ * 承载 online / offline / low_battery_* / ota_progress,见协议 §5.2。
+ * 未知 {@code eventType} 属前向兼容场景(固件先行上报新事件),
+ * 只记 WARN 后丢弃,不视为异常、不回错。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceEventListener {
+
+ /**
+ * 上行处理端口(可选注入,未装配时仅记日志)
+ */
+ private final ObjectProvider
+ * 口径见《设备通信协议-一期.md》§8.2:{@code status = 1 且 last_online_time < now - 120s} → 置离线并写
+ * {@code offline} 告警(级别 2 严重);设备恢复上报遥测后由遥测路径自动回到在线(不写恢复告警)。
+ *
+ * 为什么不用 {@code @Scheduling} 注解:本项目的 {@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} 方法,本类其余逻辑不动。
+ *
+ * 多实例安全:每个实例都会起调度,因此扫描动作外挂了 Redisson 互斥锁
+ * {@link DeviceMqttConstants#ONLINE_SCAN_LOCK}——拿不到锁的实例本轮直接跳过。
+ * 锁用「无等待时间 + Redisson 看门狗续期」的形态({@code tryLock(0, unit)}),
+ * 扫描耗时超过锁租期也不会被别的实例插进来重复处理。
+ *
+ * 异常自愈:{@code ScheduledExecutorService} 有一个著名陷阱——任务抛出异常后会被
+ * 静默取消,之后永远不再执行。因此本类所有入口都包在 {@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;
+
+ /**
+ * 启动扫描调度。
+ *
+ * 首轮延迟一个扫描间隔,避免与服务启动、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);
+ }
+ }
+
+ /**
+ * 执行一轮离线扫描。
+ *
+ * 三级判定:先抢跨实例互斥锁 → 再按阈值批量筛候选 → 最后逐台用条件更新原子裁决是否迁移。
+ * 中间任何一步都不直接写告警,告警由 {@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
+ * 协议 §5.3 确立的唯一凭据原则——「只有 {@code ack.ok} 才代表指令成功」——落到实现上就是本类:
+ * 下发前 {@link #register} 换取一个 {@link CompletableFuture},下发后等待它;回执到达时
+ * {@link #peek} 取出上下文、{@link #settle} 负责唤醒。指令失败/成功不靠遥测里的状态位推断,
+ * 因为眼罩可能被人手动按动。
+ *
+ * 为什么回执处理要拆成 peek + settle 两步:{@code peek} 只取上下文不唤醒,调用方可以先把
+ * 回执代表的业务事实写进库(眼罩状态、OTA 任务终态),再{@code settle} 唤醒等待方。
+ * 这样「Future 完成」与「系统已落定」等价——否则 HTTP 线程可能先返回成功、而落库还排在后面,
+ * APP 紧接着刷新详情就会读到旧值。
+ *
+ * 为什么用「本实例内存 + Redis 标记」而不是纯 Redis:Future 本身不可序列化,
+ * 跨实例唤醒需要额外的通知通道(Redis pub/sub 或 MQTT 共享订阅)。当前部署下每个实例都订阅了
+ * {@code bm/+/ack},因此发指令的实例必然能收到自己那条回执,内存登记表足够且没有网络往返。
+ * Redis 里只留一条 {@code bm:mqtt:ack:pending:{msgId}} 标记(值为目标设备号),作用是让日志能区分
+ * 「本平台发出但已超时的指令」与「完全未识别的 msgId(伪造或历史残留)」。
+ *
+ * 内存安全:登记项由 {@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
+ * 调用顺序有硬性要求:必须在 {@code DeviceCommandPublisher} 发布之前调用。
+ * 否则设备回执可能先于登记到达,被 {@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
+ * 调用方应先用返回的上下文落定业务事实(写库),再调用 {@link #settle} 唤醒——
+ * 见类注释「为什么回执处理要拆成两步」。
+ *
+ * 四类不命中都会记日志并返回 {@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;
+ }
+
+ /**
+ * 唤醒等待中的下发方,标志这条指令的等待结束。
+ *
+ * 必须与 {@link #peek} 配对使用,且调用方应保证在 {@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;
+ }
+
+ /**
+ * 撤销一条已登记但未能发出的指令。
+ *
+ * 场景:{@link #register} 之后发布失败(MQTT 客户端未装配 / 被拒),此时回执永远不会来。
+ * 不撤销的话,Redis 里会留下一条「正在等回执」的假标记,让运维误判链路状态。
+ *
+ * 内存登记项无需在此清理: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);
+ }
+
+ /**
+ * 一条待回执指令的上下文。
+ *
+ * 记录「下发时想要什么」,使回执成功时能直接落到结果状态上——
+ * {@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
+ * 解决「Topic 以 deviceNo 寻址,而设备出厂只知道 IMEI/SN」的引导问题,见协议 §6。
+ * 注册结果由 {@link DeviceUplinkHandler#onRegister} 决定,经
+ * {@link DeviceCommandPublisher#publishProvisionAck} 下发到 {@code bm/provision/ack/{imei}}。
+ *
+ * 待 Q1 决策:若选择「出厂烧录 deviceNo」方案,本类连同注册链路整体删除。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceProvisionListener {
+
+ /**
+ * 上行处理端口(可选注入,未装配时仅记日志)
+ */
+ private final ObjectProvider
+ * 只做「解析 + 结构校验 + 转发」,业务处理见 {@link DeviceUplinkHandler}。
+ * 丢弃策略(见协议 §5.1):Topic 非法、JSON 解析失败、必填字段缺失或越界 → 整包丢弃并记 WARN,
+ * 不落库,避免脏数据;QoS 0 语义下丢弃是允许的(下一条遥测会整份覆盖最新态)。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceTelemetryListener {
+
+ /**
+ * 上行处理端口(可选注入,未装配时仅记日志)
+ */
+ private final ObjectProvider
+ * 为什么单独抽这一层:MQTT 监听器只负责「收包 → 解析 → 校验 → 转发」,
+ * 不掺业务;持久化与状态流转(写主表最新态、落遥测历史、生成告警、唤醒指令
+ * {@code CompletableFuture}、OTA 进度)属于业务层职责,由 I6(MQTT 生产化)落地实现。
+ *
+ * 监听器通过 {@code ObjectProvider} 可选注入本接口:未装配实现时只记录日志,
+ * 不阻断应用启动——这样 mqtt 包可以先合入而不必等业务实现到位。
+ *
+ * 实现方必须遵守的约定:
+ *
+ * 约定动作:{@code markOnline(deviceNo, now)}(status 由 0/2 → 1、刷新 last_online_time)
+ * + 整份覆盖主表最新态 + 异步 INSERT 遥测历史 + 按阈值生成/恢复低电告警。
+ *
+ * @param deviceNo 设备编号(取自 Topic)
+ * @param payload 遥测报文(已通过结构校验)
+ */
+ void onTelemetry(String deviceNo, TelemetryPayload payload);
+
+ /**
+ * 处理设备事件。
+ *
+ * 约定动作:{@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);
+
+ /**
+ * 处理指令回执。
+ *
+ * 约定动作:以 {@code msgId} 唤醒指令服务中注册的 {@code CompletableFuture};
+ * 仅当 {@link AckPayload#success()} 为真时更新设备状态字段(如 {@code hood_cover_on})。
+ *
+ * @param deviceNo 设备编号(取自 Topic)
+ * @param payload 回执报文(已通过结构校验)
+ */
+ void onAck(String deviceNo, AckPayload payload);
+
+ /**
+ * 处理设备注册申请。
+ *
+ * 约定动作:按 IMEI 回查已建档设备,成功则下发 {@code ProvisionAckPayload.success(...)},
+ * 未建档则下发失败(404)并记录为私拆/串货线索。
+ *
+ * 待 Q1 决策:若选择「出厂烧录 deviceNo」方案,本方法连同注册链路整体删除。
+ *
+ * @param payload 注册报文(已通过结构校验)
+ */
+ void onRegister(ProvisionPayload payload);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/MqttTopics.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/MqttTopics.java
new file mode 100644
index 0000000..9158fce
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/MqttTopics.java
@@ -0,0 +1,227 @@
+package org.dromara.device.mqtt;
+
+/**
+ * 设备通信 Topic 常量与构造器。
+ *
+ * 口径见《设备通信协议-一期.md》§3:后缀固定为 {@code bm/{deviceNo}/{业务}},
+ * {@code deviceNo} 形如 {@code BM202609160001}({@code BM} + {@code yyyyMMdd} + 4 位当日序号,14 位);
+ * 注册环节(设备尚不知自身 deviceNo)按 IMEI 寻址。
+ *
+ * 约定:
+ *
+ * 仅用于设备侧实现与联调校验;服务端不使用该通配。
+ *
+ * @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 中解析设备编号。
+ *
+ * 仅接受 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);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/impl/DefaultDeviceUplinkHandler.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/impl/DefaultDeviceUplinkHandler.java
new file mode 100644
index 0000000..0117a7c
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/impl/DefaultDeviceUplinkHandler.java
@@ -0,0 +1,408 @@
+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 生产化)。
+ *
+ * 只做「协议报文 → 业务事实」的翻译与编排,具体写入策略分别落在:
+ * 在线态迁移 {@link DevicePresenceWriter}、告警判定 {@link DeviceAlertWriter}、
+ * 遥测归档 {@link DeviceTelemetryArchiver}、指令回执关联 {@link DevicePendingAckRegistry}。
+ * 这样切换/替换任一策略都不需要改本类。
+ *
+ * 贯穿全类的四条约定:
+ *
+ * 三步:① 一条 UPDATE 完成「在线态回归 + 心跳刷新 + 最新态整份覆盖」(合并写入,避免每个字段一次 SQL);
+ * ② 异步归档历史;③ 按阈值判定头套/脚环低电。
+ *
+ * 先写主表、后写告警:若 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(), device.getHoodBatteryThreshold());
+ alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
+ AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery(), device.getRingBatteryThreshold());
+ } catch (Exception e) {
+ log.error("遥测处理异常 => deviceNo: {}", deviceNo, e);
+ }
+ }
+
+ /**
+ * 处理设备事件。
+ *
+ * 各类型的落点见协议 §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);
+ }
+ }
+
+ /**
+ * 处理指令回执。
+ *
+ * 回执是「指令成功」的唯一凭据(协议 §5.3)。流程刻意分成三步,顺序不可调换:
+ *
+ * {@code settle} 放在 {@code finally}:落库抛异常也要唤醒,否则下发方会一直等到超时,
+ * 把「设备已执行、只是回写失败」误报成「指令下发失败」。
+ *
+ * 失败回执不修改设备状态——眼罩没动,主表就该保持原样;等下一次遥测覆盖即可。
+ *
+ * @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);
+ }
+ }
+
+ /**
+ * 处理设备注册申请。
+ *
+ * 解决「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);
+ }
+ }
+
+ // ---------------- 以下为内部实现 ----------------
+
+ /**
+ * 按设备编号查询建档设备。
+ *
+ * 查不到(后台未建档 / 已逻辑删除)时记 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;
+ }
+
+ /**
+ * 眼罩指令回执成功后回写设备状态。
+ *
+ * 回执报文只说明「成功」,不携带目标状态,因此期望值取自下发时登记的上下文
+ * ({@link DevicePendingAckRegistry.PendingCommand#getDesiredCoverOn()})。
+ * 本次写入是乐观的即时反馈,最终真相仍以下一包遥测为准(遥测整份覆盖)。
+ *
+ * @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 回执收尾任务。
+ *
+ * 状态落点见协议 §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 任务进度。
+ *
+ * 只更新进行中的任务(待升级 / 升级中)。若任务已成功或失败,说明进度事件
+ * 与最终回执乱序到达,此时丢弃进度是有意为之——协议约定「进度事件不承载结果」,
+ * 不能让它把已终态的任务改回升级中。
+ *
+ * @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);
+ }
+
+ /**
+ * 首次注册时补全建档字段。
+ *
+ * 只补空字段,不覆盖已有值:后台人工维护的数据优先于设备自报;设备自报里
+ * 唯一真正需要保持同步的是版本号,但那也应由 OTA 流程显式更新,避免注册报文
+ * 把后台刚改的状态悄悄改回去。
+ *
+ * 用 {@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();
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/AckCode.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/AckCode.java
new file mode 100644
index 0000000..543641a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/AckCode.java
@@ -0,0 +1,104 @@
+package org.dromara.device.mqtt.payload;
+
+/**
+ * 指令回执码。
+ *
+ * 口径见《设备通信协议-一期.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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/AckPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/AckPayload.java
new file mode 100644
index 0000000..45c61c4
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/AckPayload.java
@@ -0,0 +1,96 @@
+package org.dromara.device.mqtt.payload;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 指令回执报文(上行 {@code bm/{deviceNo}/ack},QoS 1)。
+ *
+ * 口径见《设备通信协议-一期.md》§5.3。回执是「指令成功」的唯一凭据:
+ * telemetry 中眼罩状态变化不算成功(可能是物理手动操作)。
+ * 服务端以 {@code msgId} 关联下行指令上注册的 {@code CompletableFuture}。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+public class AckPayload implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 下行指令的 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;
+ }
+
+ /**
+ * 判定回执是否代表指令成功。
+ *
+ * 优先以 {@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);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/EventPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/EventPayload.java
new file mode 100644
index 0000000..da9ef81
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/EventPayload.java
@@ -0,0 +1,78 @@
+package org.dromara.device.mqtt.payload;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 设备事件上报报文(上行 {@code bm/{deviceNo}/event},QoS 1)。
+ *
+ * 口径见《设备通信协议-一期.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;
+
+ /**
+ * 设备本地时间(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);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/EventType.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/EventType.java
new file mode 100644
index 0000000..0a60c63
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/EventType.java
@@ -0,0 +1,110 @@
+package org.dromara.device.mqtt.payload;
+
+import org.dromara.device.enums.AlertTypeEnum;
+
+/**
+ * 设备事件类型(协议词汇,对齐《设备通信协议-一期.md》§5.2)。
+ *
+ * 与 {@link AlertTypeEnum} 的关系:本枚举表达「设备说了什么」,
+ * {@link AlertTypeEnum} 表达「落库成哪类告警」。二者在
+ * {@code offline} / {@code low_battery_hood} / {@code low_battery_ring} 上编码一致,
+ * 但低电告警一律由服务端按 telemetry 阈值判定生成(含 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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/HoodCommandPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/HoodCommandPayload.java
new file mode 100644
index 0000000..55dfbe1
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/HoodCommandPayload.java
@@ -0,0 +1,65 @@
+package org.dromara.device.mqtt.payload;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 眼罩开关指令报文(下行 {@code bm/{deviceNo}/command/hood},QoS 1)。
+ *
+ * 口径见《设备通信协议-一期.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";
+
+ /**
+ * 指令唯一标识,用于回执关联与设备侧幂等
+ */
+ 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.setMsgId(msgId);
+ payload.setCoverOn(coverOn);
+ payload.setTs(System.currentTimeMillis());
+ return payload;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/OtaCommandPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/OtaCommandPayload.java
new file mode 100644
index 0000000..a2104af
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/OtaCommandPayload.java
@@ -0,0 +1,111 @@
+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)。
+ *
+ * 口径见《设备通信协议-一期.md》§7.2。流程约定:
+ *
+ * 口径见《设备通信协议-一期.md》§6.2。设备注册成功后需持久化 deviceNo,
+ * 后续重启不再重复注册;仅在收到 {@code ok=false, code=404} 或约定「重置」指令时才重新注册。
+ *
+ * 待 Q1 决策:若最终选择「出厂烧录 deviceNo」方案,本类与注册链路可整体删除。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+public class ProvisionAckPayload implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 设备 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.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.setImei(imei);
+ payload.setOk(false);
+ payload.setCode(code);
+ payload.setMsg(msg);
+ payload.setTs(System.currentTimeMillis());
+ return payload;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/ProvisionPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/ProvisionPayload.java
new file mode 100644
index 0000000..db4be45
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/ProvisionPayload.java
@@ -0,0 +1,84 @@
+package org.dromara.device.mqtt.payload;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 设备注册上报报文(上行 {@code bm/provision/register},QoS 1)。
+ *
+ * 口径见《设备通信协议-一期.md》§6。解决的问题:Topic 以 {@code deviceNo} 寻址,
+ * 但设备出厂时只知道 IMEI / SN。
+ *
+ * 待 Q1 决策:若最终选择「出厂烧录 deviceNo」方案,本类与注册链路可整体删除。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+public class ProvisionPayload implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 设备本地时间(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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/StreamCommandPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/StreamCommandPayload.java
new file mode 100644
index 0000000..89cc298
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/StreamCommandPayload.java
@@ -0,0 +1,119 @@
+package org.dromara.device.mqtt.payload;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 直播推流指令报文(下行 {@code bm/{deviceNo}/command/stream},QoS 1)。
+ *
+ * 口径对齐 dm 的 {@code start_stream}/{@code stop_stream}:服务端通知设备向 ZLM 推 RTMP/RTSP,
+ * APP 接口不等待回执(流是否在线以 ZLM {@code getMediaInfo} 为准)。
+ * 设备可回 {@link AckPayload}({@code cmd=stream}),服务端仅记录。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+public class StreamCommandPayload implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 指令类型固定值
+ */
+ public static final String CMD = "stream";
+
+ /**
+ * 开始推流
+ */
+ public static final String ACTION_START = "start";
+
+ /**
+ * 停止推流
+ */
+ public static final String ACTION_STOP = "stop";
+
+ /**
+ * 指令唯一标识
+ */
+ private String msgId;
+
+ /**
+ * 指令类型,固定 {@value #CMD}
+ */
+ private String cmd = CMD;
+
+ /**
+ * {@link #ACTION_START} / {@link #ACTION_STOP}
+ */
+ private String action;
+
+ /**
+ * ZLM stream 名,与 {@code deviceNo} 相同
+ */
+ private String streamId;
+
+ /**
+ * ZLM app 名
+ */
+ private String app;
+
+ /**
+ * 推流协议:rtmp / rtsp
+ */
+ private String protocol;
+
+ /**
+ * 设备应推往的地址
+ */
+ private String pushUrl;
+
+ /**
+ * 下发时间(epoch 毫秒)
+ */
+ private Long ts;
+
+ /**
+ * 构造开始推流指令。
+ *
+ * @param msgId 指令唯一标识
+ * @param streamId 流名
+ * @param app ZLM app
+ * @param protocol 推流协议
+ * @param pushUrl 推流地址
+ * @return 指令报文
+ */
+ public static StreamCommandPayload start(String msgId, String streamId, String app,
+ String protocol, String pushUrl) {
+ return of(msgId, ACTION_START, streamId, app, protocol, pushUrl);
+ }
+
+ /**
+ * 构造停止推流指令。
+ *
+ * @param msgId 指令唯一标识
+ * @param streamId 流名
+ * @param app ZLM app
+ * @return 指令报文
+ */
+ public static StreamCommandPayload stop(String msgId, String streamId, String app) {
+ return of(msgId, ACTION_STOP, streamId, app, null, null);
+ }
+
+ private static StreamCommandPayload of(String msgId, String action, String streamId,
+ String app, String protocol, String pushUrl) {
+ StreamCommandPayload payload = new StreamCommandPayload();
+ payload.setMsgId(msgId);
+ payload.setAction(action);
+ payload.setStreamId(streamId);
+ payload.setApp(app);
+ payload.setProtocol(protocol);
+ payload.setPushUrl(pushUrl);
+ payload.setTs(System.currentTimeMillis());
+ return payload;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/TelemetryPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/TelemetryPayload.java
new file mode 100644
index 0000000..b5fb66a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/TelemetryPayload.java
@@ -0,0 +1,121 @@
+package org.dromara.device.mqtt.payload;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 遥测上报报文(上行 {@code bm/{deviceNo}/telemetry},QoS 0)。
+ *
+ * 口径见《设备通信协议-一期.md》§5.1。要点:
+ *
+ * 把「什么时候该告警」的策略收在一处,避免遥测、事件、离线扫描三条来源各写各的导致重复告警
+ * (协议 §11 明确要求:低电告警以服务端阈值判定为准,设备事件只作提前提示)。
+ *
+ * 两条策略:
+ *
+ * 与离线告警的区别:离线是「状态迁移」,由 {@code DevicePresenceWriter#markOffline} 的条件更新
+ * 保证一次迁移只触发一次,因此不走去重窗口——否则设备 30 分钟内掉线两次,第二次会被误吞。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceAlertWriter {
+
+ private final BmDeviceAlertMapper alertMapper;
+ private final DeviceMqttProperties properties;
+
+ /**
+ * 写一条离线告警(严重级)。
+ *
+ * 不经去重窗口:调用方({@code DevicePresenceWriter#markOffline})已用「status 条件更新
+ * 影响行数 > 0」保证同一次在线→离线只调用一次。
+ *
+ * @param deviceId 设备物理主键
+ * @param deviceNo 设备编号
+ * @return 是否成功落库
+ */
+ public boolean writeOffline(Long deviceId, String deviceNo) {
+ return insert(deviceId, deviceNo, AlertTypeEnum.OFFLINE, AlertLevelEnum.CRITICAL,
+ null, DeviceMsgConstants.DEVICE_OFFLINE);
+ }
+
+ /**
+ * 按设备级阈值判定并处理低电(含滞回与落库)。
+ *
+ * 这是低电告警的主路径:每一次遥测都调用,内部按滞回状态决定是否需要动作。
+ *
+ * 触发阈值取设备自身配置({@code bm_device.hood_battery_threshold} /
+ * {@code ring_battery_threshold}),恢复阈值由
+ * {@link DeviceMqttProperties#resolveRecoverThreshold(int)} 派生;两者统一由
+ * {@code DeviceMqttProperties} 解析,调用方只需把设备上配置的原始值传进来,
+ * 不要在调用处自行做 null 兜底 —— 否则「未配置时的默认口径」会出现第二份实现。
+ *
+ * ⚠️ 修改阈值不会清理已有闩锁,这是有意为之:若设备当前正处于低电态,
+ * 抬高阈值只会让它继续告警(已闩锁,不会重复落库);调低阈值则在下一次遥测
+ * 达到新的恢复阈值时自动解锁,无需人工干预。
+ *
+ * @param deviceId 设备物理主键
+ * @param deviceNo 设备编号
+ * @param type 告警类型(头套 / 脚环低电)
+ * @param battery 当前电量(0-100)
+ * @param deviceThreshold 设备上配置的触发阈值({@code null} 表示未配置,走工程默认)
+ */
+ public void applyLowBattery(Long deviceId, String deviceNo, AlertTypeEnum type,
+ Integer battery, Integer deviceThreshold) {
+ if (battery == null || !isLowBatteryType(type)) {
+ return;
+ }
+ int trigger = properties.resolveTriggerThreshold(deviceThreshold);
+ int recover = properties.resolveRecoverThreshold(trigger);
+ String latchKey = DeviceMqttConstants.BATTERY_LOW_PREFIX + deviceNo + ":" + type.getType();
+ boolean latched = Boolean.TRUE.equals(RedisUtils.getCacheObject(latchKey));
+ // trigger = 0 是「对该设备关闭低电保护」的显式开关,不是「阈值取 0」:
+ // 若照直写成 battery <= trigger,0 会退化成「电量恰好归零才告警」,与 DDL 注释、
+ // 表单提示里的「0=不触发」承诺不符。
+ if (trigger > 0 && battery <= trigger) {
+ if (latched) {
+ // 已处于低电态:不重复告警,也不刷新闩锁 TTL(TTL 只作残留清理兜底)
+ return;
+ }
+ // 先置闩锁再落库:即使落库被去重窗口挡住,也不会造成「每条遥测都重试落库」
+ RedisUtils.setCacheObject(latchKey, Boolean.TRUE, properties.getLowBatteryLatchTtl());
+ writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
+ String.valueOf(battery), lowBatteryContent(type, battery));
+ return;
+ }
+ if (battery >= recover && latched) {
+ RedisUtils.deleteObject(latchKey);
+ log.info("低电恢复 => deviceNo: {}, type: {}, battery: {}, recoverThreshold: {}",
+ deviceNo, type.getType(), battery, recover);
+ }
+ // 介于 (触发阈值, 恢复阈值) 之间:保持现状,这正是滞回区间的意义
+ }
+
+ /**
+ * 写入设备主动上报的低电事件(提前提示,非主路径)。
+ *
+ * 与 {@link #applyLowBattery} 共用去重窗口,因此两条来源不会双写;但不置闩锁——
+ * 设备侧阈值与服务端阈值可能不一致,闩锁只能由服务端阈值判定来管理。
+ *
+ * @param deviceId 设备物理主键
+ * @param deviceNo 设备编号
+ * @param type 告警类型
+ * @param battery 设备上报的触发值
+ * @return 是否成功落库
+ */
+ public boolean writeLowBatteryHint(Long deviceId, String deviceNo, AlertTypeEnum type, Integer battery) {
+ if (!isLowBatteryType(type)) {
+ return false;
+ }
+ return writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
+ battery == null ? null : String.valueOf(battery),
+ battery == null ? type.getLabel() : lowBatteryContent(type, battery));
+ }
+
+ /**
+ * 带去重窗口地写入一条告警。
+ *
+ * @param deviceId 设备物理主键
+ * @param deviceNo 设备编号
+ * @param type 告警类型
+ * @param level 告警级别
+ * @param value 触发值
+ * @param content 告警文案
+ * @return 是否成功落库(窗口内被去重或落库失败均返回 {@code false})
+ */
+ private boolean writeWithDedup(Long deviceId, String deviceNo, AlertTypeEnum type,
+ AlertLevelEnum level, String value, String content) {
+ String dedupKey = DeviceMqttConstants.ALERT_DEDUP_PREFIX + deviceNo + ":" + type.getType();
+ Duration window = properties.getAlertDedupWindow();
+ if (!RedisUtils.setObjectIfAbsent(dedupKey, LocalDateTime.now().toString(), window)) {
+ log.debug("告警命中去重窗口,已跳过 => deviceNo: {}, type: {}", deviceNo, type.getType());
+ return false;
+ }
+ if (insert(deviceId, deviceNo, type, level, value, content)) {
+ return true;
+ }
+ // 落库失败必须释放占位,否则整个窗口内这条告警再也补不上
+ RedisUtils.deleteObject(dedupKey);
+ return false;
+ }
+
+ /**
+ * 落库一条告警。
+ *
+ * @param deviceId 设备物理主键
+ * @param deviceNo 设备编号
+ * @param type 告警类型
+ * @param level 告警级别
+ * @param value 触发值
+ * @param content 告警文案
+ * @return 是否成功落库
+ */
+ private boolean insert(Long deviceId, String deviceNo, AlertTypeEnum type,
+ AlertLevelEnum level, String value, String content) {
+ try {
+ BmDeviceAlert alert = new BmDeviceAlert();
+ alert.setDeviceId(deviceId);
+ alert.setDeviceNo(deviceNo);
+ alert.setAlertType(type.getType());
+ alert.setAlertLevel(level.getCode());
+ alert.setAlertValue(value);
+ alert.setContent(content);
+ alert.setStatus(DeviceMqttConstants.ALERT_STATUS_UNHANDLED);
+ alert.setAlertTime(LocalDateTime.now());
+ alertMapper.insert(alert);
+ log.info("设备告警 => deviceNo: {}, type: {}, level: {}, value: {}",
+ deviceNo, type.getType(), level.getCode(), value);
+ return true;
+ } catch (RuntimeException e) {
+ // 告警落库失败不得中断遥测/事件处理链路,只记错误,由监控告警兜底
+ log.error("设备告警落库失败 => deviceNo: {}, type: {}", deviceNo, type.getType(), e);
+ return false;
+ }
+ }
+
+ /**
+ * 判断是否为低电类告警(避免被误用到离线等类型上)。
+ *
+ * @param type 告警类型
+ * @return 是否为低电类
+ */
+ private boolean isLowBatteryType(AlertTypeEnum type) {
+ return type == AlertTypeEnum.LOW_BATTERY_HOOD || type == AlertTypeEnum.LOW_BATTERY_RING;
+ }
+
+ /**
+ * 生成低电告警文案。
+ *
+ * @param type 告警类型
+ * @param battery 当前电量
+ * @return 告警文案
+ */
+ private String lowBatteryContent(AlertTypeEnum type, int battery) {
+ return type == AlertTypeEnum.LOW_BATTERY_HOOD
+ ? String.format(DeviceMsgConstants.ALERT_LOW_BATTERY_HOOD, battery)
+ : String.format(DeviceMsgConstants.ALERT_LOW_BATTERY_RING, battery);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DevicePresenceWriter.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DevicePresenceWriter.java
new file mode 100644
index 0000000..e392a3e
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DevicePresenceWriter.java
@@ -0,0 +1,93 @@
+package org.dromara.device.mqtt.support;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.device.domain.BmDevice;
+import org.dromara.device.enums.DeviceStatusEnum;
+import org.dromara.device.enums.SignalLevelEnum;
+import org.dromara.device.mapper.BmDeviceMapper;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/**
+ * 设备在线态迁移器:{@code bm_device.status} 在线/离线翻转的唯一入口。
+ *
+ * 为什么必须收在一处:离线有两个触发源——设备主动上报 {@code offline} 事件(含 Broker 代发 LWT)
+ * 与 30s 一次的离线扫描。二者可能同时命中同一台设备。如果各自「先查状态、再更新、再写告警」,
+ * 就会出现两次离线告警。本类的做法是把条件更新当作原子裁决:
+ * {@code where device_id = ? and status <> '2'},只有真正推动了状态迁移的那一次
+ * (影响行数 > 0)才继续写告警,天然幂等。
+ *
+ * 与之配套的是 {@code DeviceAlertWriter#writeOffline} 不走去重窗口——一次迁移只写一条,
+ * 靠的是这里的条件更新,而不是时间窗口。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DevicePresenceWriter {
+
+ private final BmDeviceMapper deviceMapper;
+ private final DeviceAlertWriter alertWriter;
+
+ /**
+ * 置在线并刷新心跳时间(用于设备主动上报的 {@code online} 事件)。
+ *
+ * 不做条件更新:心跳时间每次都必须刷新,否则设备明明在报,离线扫描却会把
+ * {@code last_online_time} 当陈旧值判它离线。状态是否真的发生变化只影响日志。
+ *
+ * 遥测路径不调用本方法——遥测更新本身就是一次心跳,会把在线态与心跳时间一并写入,
+ * 少一次 UPDATE(见 {@code DefaultDeviceUplinkHandler#onTelemetry})。
+ *
+ * @param deviceNo 设备编号
+ * @return 是否更新成功(设备不存在或已被逻辑删除时返回 {@code false})
+ */
+ public boolean markOnline(String deviceNo) {
+ LocalDateTime now = LocalDateTime.now();
+ int rows = deviceMapper.lambda()
+ .set(BmDevice::getStatus, DeviceStatusEnum.ONLINE.getCode())
+ .set(BmDevice::getLastOnlineTime, now)
+ .set(BmDevice::getUpdateTime, now)
+ .eq(BmDevice::getDeviceNo, deviceNo)
+ .updateCount();
+ if (rows <= 0) {
+ log.warn("设备置在线失败:设备不存在或已删除 => deviceNo: {}", deviceNo);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * 置离线并生成离线告警。
+ *
+ * 条件 {@code status <> '2'} 保证同一次在线→离线只生效一次:LWT 事件与离线扫描先后到达时,
+ * 后到的那次影响行数为 0,直接返回 {@code false},不会重复告警。
+ *
+ * 同时把 {@code signal_level} 置为 0(取值含义即「离线」,见字典 {@code bm_signal_level})——
+ * 否则设备掉线后列表里还会显示「信号强」,与离线状态自相矛盾。
+ *
+ * @param device 设备(至少需带 {@code deviceId} / {@code deviceNo})
+ * @return 是否发生了「在线/未激活 → 离线」的迁移
+ */
+ public boolean markOffline(BmDevice device) {
+ LocalDateTime now = LocalDateTime.now();
+ int rows = deviceMapper.lambda()
+ .set(BmDevice::getStatus, DeviceStatusEnum.OFFLINE.getCode())
+ .set(BmDevice::getSignalLevel, SignalLevelEnum.OFFLINE.getLevel())
+ .set(BmDevice::getUpdateTime, now)
+ .eq(BmDevice::getDeviceId, device.getDeviceId())
+ .ne(BmDevice::getStatus, DeviceStatusEnum.OFFLINE.getCode())
+ .updateCount();
+ if (rows <= 0) {
+ // 已是离线(重复事件、扫描与事件竞争、设备已被删除)——不视为异常
+ return false;
+ }
+ log.info("设备离线 => deviceNo: {}, lastOnlineTime: {}", device.getDeviceNo(), device.getLastOnlineTime());
+ alertWriter.writeOffline(device.getDeviceId(), device.getDeviceNo());
+ return true;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DeviceTelemetryArchiver.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DeviceTelemetryArchiver.java
new file mode 100644
index 0000000..b4efe00
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DeviceTelemetryArchiver.java
@@ -0,0 +1,79 @@
+package org.dromara.device.mqtt.support;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.device.domain.BmDevice;
+import org.dromara.device.domain.BmDeviceTelemetry;
+import org.dromara.device.mapper.BmDeviceTelemetryMapper;
+import org.dromara.device.mqtt.payload.TelemetryPayload;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/**
+ * 遥测历史归档器:把遥测写入 {@code bm_device_telemetry}(一期只 INSERT,不做轨迹回放)。
+ *
+ * 为什么异步:上行监听运行在 mica-mqtt 的业务线程池上,而该池只有
+ * {@code mqtt-executor-size}(当前 2)个线程。若把落库压在业务线程里,一次 DB 抖动就会让
+ * 「所有设备」的入站报文一起排队。遥测历史是 append-only 且允许丢失的数据(QoS 0 语义,
+ * 断连期间的历史本来就不补传,见协议 §9),因此放到 {@code @Async} 线程池执行,
+ * 主链路只保留设备最新态与告警这类「不可丢」的写入。
+ *
+ * 独立成类的原因:{@code @Async} 依赖 Spring 代理,同类内部自调用不会走代理。
+ * 放在这里由 {@code DefaultDeviceUplinkHandler} 跨 Bean 调用,异步才真正生效。
+ *
+ * 失败处理:只记 ERROR 不外抛——历史缺失不影响在线态判定与告警,
+ * 且下一包遥测会整份覆盖主表最新态,不会留下不一致状态。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceTelemetryArchiver {
+
+ private final BmDeviceTelemetryMapper telemetryMapper;
+
+ /**
+ * 归档一条遥测历史。
+ *
+ * @param device 设备(提供 {@code deviceId} / {@code deviceNo})
+ * @param payload 遥测报文
+ * @param receivedAt 服务端接收时间(协议约定 {@code report_time} 取服务端时间,不用设备 ts)
+ */
+ @Async
+ public void archive(BmDevice device, TelemetryPayload payload, LocalDateTime receivedAt) {
+ try {
+ BmDeviceTelemetry entity = new BmDeviceTelemetry();
+ entity.setDeviceId(device.getDeviceId());
+ entity.setDeviceNo(device.getDeviceNo());
+ entity.setHoodBattery(payload.getHoodBattery());
+ entity.setRingBattery(payload.getRingBattery());
+ entity.setSignalLevel(payload.getSignalLevel());
+ entity.setHoodConnected(toCode(payload.getHoodConnected()));
+ entity.setHoodCoverOn(toCode(payload.getHoodCoverOn()));
+ entity.setLng(payload.getLng());
+ entity.setLat(payload.getLat());
+ entity.setAddress(payload.getAddress());
+ entity.setReportTime(receivedAt);
+ telemetryMapper.insert(entity);
+ } catch (Exception e) {
+ log.error("遥测历史落库失败(不影响在线态与告警) => deviceNo: {}", device.getDeviceNo(), e);
+ }
+ }
+
+ /**
+ * 把报文的数字 0/1 转为库中字典编码 {@code '0'/'1'}。
+ *
+ * 协议约定上行用数字,落库用字符串(字典 {@code bm_hood_connected} / {@code bm_hood_cover})。
+ *
+ * @param value 报文取值
+ * @return 字典编码
+ */
+ private String toCode(Integer value) {
+ return value == null ? null : String.valueOf(value);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppAuthService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppAuthService.java
new file mode 100644
index 0000000..84f7f9d
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppAuthService.java
@@ -0,0 +1,107 @@
+package org.dromara.device.service;
+
+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;
+
+/**
+ * APP 账号服务(注册 / 登录 / 忘记密码 / 退出)。
+ *
+ * 接口契约遵循《02-架构设计-一期》§5.1:{@link #login(AppLoginBo)} 与 {@link #smsLogin(AppSmsLoginBo)}
+ * 返回访问令牌字符串。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IAppAuthService {
+
+ /**
+ * 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。
+ *
+ * @param bo 验证码入参(手机号 + 场景)
+ * @return mock 模式下返回验证码,真实短信模式返回 {@code null}
+ */
+ String sendSmsCode(AppSmsCodeBo bo);
+
+ /**
+ * 手机号注册。
+ *
+ * @param bo 注册入参
+ */
+ void register(AppRegisterBo bo);
+
+ /**
+ * 密码登录。
+ *
+ * @param bo 登录入参
+ * @return 访问令牌
+ */
+ String login(AppLoginBo bo);
+
+ /**
+ * 短信验证码登录。
+ *
+ * @param bo 短信登录入参
+ * @return 访问令牌
+ */
+ String smsLogin(AppSmsLoginBo bo);
+
+ /**
+ * 忘记密码(重置密码)。
+ *
+ * @param bo 重置入参
+ */
+ void resetPwd(AppResetPwdBo bo);
+
+ /**
+ * 退出登录(清除当前会话)。
+ */
+ void logout();
+
+ // ---------------- 邮箱通道(迭代:邮箱注册) ----------------
+
+ /**
+ * 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效)。
+ *
+ * @param bo 验证码入参(邮箱 + 场景)
+ */
+ void sendEmailCode(AppEmailCodeBo bo);
+
+ /**
+ * 邮箱注册(注册后账号尚无手机号,须按账号规则继续绑定手机号)。
+ *
+ * @param bo 注册入参
+ */
+ void registerByEmail(AppEmailRegisterBo bo);
+
+ /**
+ * 邮箱 + 密码登录。
+ *
+ * @param bo 登录入参
+ * @return 访问令牌
+ */
+ String emailLogin(AppEmailLoginBo bo);
+
+ /**
+ * 邮箱 + 验证码登录。
+ *
+ * @param bo 验证码登录入参
+ * @return 访问令牌
+ */
+ String emailCodeLogin(AppEmailCodeLoginBo bo);
+
+ /**
+ * 邮箱找回密码(重置密码)。
+ *
+ * @param bo 重置入参
+ */
+ void resetPwdByEmail(AppEmailResetPwdBo bo);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppDeviceService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppDeviceService.java
new file mode 100644
index 0000000..08ca9c3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppDeviceService.java
@@ -0,0 +1,116 @@
+package org.dromara.device.service;
+
+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.LocationVo;
+
+import java.util.List;
+
+/**
+ * APP 设备服务(列表 / 详情 / 绑定 / 重命名 / 解绑)。
+ *
+ * 越权隔离(本接口的核心约束):所有查询与操作均以「当前用户 {@code userId}」为强制过滤条件
+ * (SQL 层面 {@code bind_user_id = userId}),因此「设备不存在」「设备属于他人」「设备未绑定」三类情形
+ * 一律返回同一受控文案({@code DeviceMsgConstants.DEVICE_NOT_EXIST}),不泄露设备是否存在及其归属。
+ *
+ * 调用约定:{@code userId} 由 Controller 取自登录态({@code LoginHelper.getUserId()});
+ * 本层对 {@code userId == null} 统一按「未登录」拒绝。APP 端接口不参与后台数据权限(《开发计划》§12)。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IAppDeviceService {
+
+ /**
+ * 查询当前用户绑定的设备列表。
+ *
+ * @param userId 当前用户主键
+ * @return 设备卡片列表(无绑定时为空列表,由 APP 展示「请添加设备」)
+ */
+ List
+ * 三类失败规则(《开发计划》§8.2):未建档 → 「设备不存在」;已被他人绑定 → 「设备已被绑定」;
+ * 已绑定给自己 → 「重复绑定」。成功则写 {@code bind_user_id} / {@code name} / {@code status=在线} / {@code bind_time},
+ * 并落一条 {@code bind} 绑定流水。
+ *
+ * @param bo 绑定入参(imei + name)
+ * @param userId 当前用户主键
+ * @return 是否绑定成功
+ */
+ Boolean bind(AppDeviceBindBo bo, Long userId);
+
+ /**
+ * 重命名当前用户的某台设备。
+ *
+ * @param bo 重命名入参(deviceNo + name)
+ * @param userId 当前用户主键
+ * @return 是否重命名成功
+ */
+ Boolean rename(AppDeviceNameBo bo, Long userId);
+
+ /**
+ * 解绑当前用户的某台设备。
+ *
+ * 复用 {@link IDeviceBindService#unbind(String, Long)} 单一解绑入口:清空 {@code bind_user_id}/{@code bind_time}、
+ * 状态回归「未激活」、写 {@code unbind} 流水,并保留 {@code device_no}(《开发计划》§7.2 状态机)。
+ * 设备非当前用户所属(未建档 / 他人 / 未绑定)时返回统一受控文案。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户主键
+ * @return 是否解绑成功
+ */
+ Boolean unbind(String deviceNo, Long userId);
+
+ /**
+ * 下发眼罩开关指令({@code PUT /app/device/{deviceNo}/hood})。
+ *
+ * 本方法会阻塞直至收到设备回执或超时(默认 5s,配置项 {@code device.mqtt.hood-timeout})——
+ * 协议 §8.2 要求只有 {@code ack.ok=true} 才算成功,因此无法先返回再异步补状态。
+ *
+ * 校验顺序(先归属、后可操作):
+ *
+ * 越权隔离与 {@link #detail(String, Long)} 完全同口径:按 {@code device_no + bind_user_id = userId}
+ * 定位,未建档 / 属于他人 / 未绑定一律抛 {@code DeviceMsgConstants.DEVICE_NOT_EXIST}(同一句文案)。
+ *
+ * 空态:设备从未上报定位时返回空 VO({@code lng}/{@code lat} 为 {@code null}),
+ * 不抛异常——APP「查看地图」需要一个可展示的空态(见 {@link LocationVo})。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户主键
+ * @return 定位视图对象(坐标可能为 {@code null})
+ */
+ LocationVo location(String deviceNo, Long userId);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppStreamService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppStreamService.java
new file mode 100644
index 0000000..e9bbb3a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppStreamService.java
@@ -0,0 +1,71 @@
+package org.dromara.device.service;
+
+import org.dromara.device.domain.vo.AppStreamPlayVo;
+import org.dromara.device.domain.vo.AppStreamStatusVo;
+
+/**
+ * APP 直播服务(开始/停止推流、播放地址、状态)。
+ *
+ * 越权隔离与设备详情相同:按 {@code bind_user_id = 当前用户} 定位设备,
+ * 非本人一律「设备不存在」。WebHook 关流入口 {@link #closeZlmStream(String)} 不走用户鉴权,
+ * 由 ZLM secret 保证来源。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+public interface IAppStreamService {
+
+ /**
+ * 通知设备开始向 ZLM 推流,并返回带 token 的播放地址。
+ *
+ * 流已在线时不再重复下发 MQTT。设备离线时拒绝(与眼罩同一口径)。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户
+ * @return 播放信息
+ */
+ AppStreamPlayVo start(String deviceNo, Long userId);
+
+ /**
+ * 通知设备停止推流,并关闭 ZLM 在线流。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户
+ * @return 关闭后的状态(online=false)
+ */
+ AppStreamStatusVo stop(String deviceNo, Long userId);
+
+ /**
+ * 仅签发播放地址,不通知设备推流。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户
+ * @return 播放信息
+ */
+ AppStreamPlayVo playUrl(String deviceNo, Long userId);
+
+ /**
+ * 关闭 ZLM 上的流(不通知设备)。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户
+ */
+ void close(String deviceNo, Long userId);
+
+ /**
+ * 查询流是否在 ZLM 上在线。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户
+ * @return 状态
+ */
+ AppStreamStatusVo status(String deviceNo, Long userId);
+
+ /**
+ * 关闭 ZLM 流(WebHook 无读者回调使用,不做用户校验)。
+ *
+ * @param streamId 流名
+ */
+ void closeZlmStream(String streamId);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserAdminService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserAdminService.java
new file mode 100644
index 0000000..ec8405e
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserAdminService.java
@@ -0,0 +1,86 @@
+package org.dromara.device.service;
+
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.device.domain.bo.AppUserAdminQueryBo;
+import org.dromara.device.domain.bo.AppUserResetPwdBo;
+import org.dromara.device.domain.bo.AppUserStatusBo;
+import org.dromara.device.domain.vo.AppUserAdminVo;
+import org.dromara.device.domain.vo.AppUserDeviceVo;
+
+import java.util.List;
+
+/**
+ * APP 用户管理服务(后台管理端)。
+ *
+ * 与 APP 端服务的边界:{@code IAppUserService} 面向 C 端「本人操作本人」,用户主键取自登录态;
+ * 本接口面向后台「管理员操作他人」,用户主键由调用方显式传入,因此必须由权限字把关
+ * ({@code device:appuser:*}),且每个写操作都要自证目标确为 APP 账号。
+ *
+ * 越权防线(本接口的核心约束):APP 账号与后台账号同处 {@code sys_user} 表,
+ * 故所有写操作都强制附加 {@code user_type = app_user}:
+ *
+ * 密码不跨界:重置密码只把明文交给 {@code AppUserApiService},
+ * 散列在 {@code ruoyi-system} 内部完成后落库,本层不接触散列值。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+public interface IAppUserAdminService {
+
+ /**
+ * 分页查询 APP 用户列表(结果恒不含后台账号)。
+ *
+ * @param bo 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
+ * @return APP 用户分页结果(含绑定设备数)
+ */
+ PageResult
+ * 复用单一解绑入口 {@code IDeviceBindService#unbind}:清空 {@code bind_user_id}/{@code bind_time}、
+ * 状态回归「未激活」、写 {@code unbind} 流水,并保留 {@code device_no}。
+ *
+ * @param userId 用户主键
+ * @param deviceNo 设备编号
+ * @return true 解绑成功
+ */
+ Boolean unbindDevice(Long userId, String deviceNo);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserService.java
new file mode 100644
index 0000000..fe2b73f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserService.java
@@ -0,0 +1,53 @@
+package org.dromara.device.service;
+
+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;
+
+/**
+ * APP 个人中心服务(资料 / 改密 / 换绑 / 注销 / 协议)。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IAppUserService {
+
+ /**
+ * 查询当前登录用户资料(手机号脱敏)。
+ *
+ * @return 个人资料
+ */
+ AppUserProfileVo profile();
+
+ /**
+ * 修改密码(旧密码 + 新密码)。
+ *
+ * @param bo 改密入参
+ */
+ void updatePassword(AppPasswordBo bo);
+
+ /**
+ * 换绑手机号(新手机号 + 验证码)。
+ *
+ * @param bo 换绑入参
+ */
+ void rebindPhone(AppPhoneBo bo);
+
+ /**
+ * 注销账号:先解绑其下全部设备,再停用账号,最后按 loginId 全端下线。
+ *
+ * 「解绑全部设备 → 停用 → 全端下线」口径为迭代 I2 主理人裁定,待 PRD/计划 §13 最终确认。
+ * 链路非事务、各步幂等,中途失败可安全重试(P3-4)。
+ */
+ void cancelAccount();
+
+ /**
+ * 查询协议内容(URL + 版本 + 更新时间)。
+ *
+ * @param type 协议类型:user(用户协议)/ privacy(隐私政策)
+ * @return 协议内容
+ */
+ AppAgreementVo agreements(String type);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceBindService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceBindService.java
new file mode 100644
index 0000000..ca42087
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceBindService.java
@@ -0,0 +1,62 @@
+package org.dromara.device.service;
+
+import java.time.LocalDateTime;
+
+/**
+ * 设备绑定流水服务接口
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IDeviceBindService {
+
+ /**
+ * 记录一条绑定流水
+ *
+ * @param deviceId 设备物理主键
+ * @param deviceNo 设备编号(冗余)
+ * @param userId 操作 APP 用户
+ * @param imei IMEI(冗余快照)
+ * @param action 动作(bind / unbind / rebind)
+ */
+ void record(Long deviceId, String deviceNo, Long userId, String imei, String action);
+
+ /**
+ * 查询设备最近一次绑定时间
+ *
+ * @param deviceId 设备物理主键
+ * @return 最近一次绑定时间,无流水时返回 null
+ */
+ LocalDateTime latestBindTime(Long deviceId);
+
+ /**
+ * 解除单台设备对某 APP 用户的绑定(迭代 I3 的唯一解绑实现)。
+ *
+ * 约束与副作用:
+ *
+ * 对每台绑定到 {@code userId} 的设备复用 {@link #unbind(String, Long)}:清空 {@code bind_user_id} 与
+ * {@code bind_time}、状态回归「未激活」,并逐台写入一条 {@code unbind} 绑定流水;用于 APP 注销前置(先解绑再停用)。
+ *
+ * @param userId APP 用户主键
+ * @return 实际解绑的设备数
+ */
+ int unbindAllByUser(Long userId);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceCommandService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceCommandService.java
new file mode 100644
index 0000000..b74eb93
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceCommandService.java
@@ -0,0 +1,43 @@
+package org.dromara.device.service;
+
+import org.dromara.device.domain.BmDevice;
+import org.dromara.device.enums.HoodCoverEnum;
+
+/**
+ * 设备指令下发服务(下行指令的「发布 + 等回执 + 裁决」闭环)。
+ *
+ * 与 {@code DeviceCommandPublisher}(只负责发字节,不等结果)的分工:
+ * 本接口承载协议 §5.3 的不变量——只有 {@code ack.ok=true} 才算指令成功,
+ * 因此它同时承担三件事:前置拦截(§8.3)、回执等待(§10)、失败归一。
+ *
+ * 为什么不把归属校验放在这里:那属于调用方语境(APP 侧按 {@code bind_user_id} 隔离、
+ * 后台侧按数据权限隔离),不同入口规则不同。本层只处理「协议能不能发」,收到的一定是已授权的目标设备。
+ *
+ * 失败语义统一:发布失败、回执超时、回执 {@code ok=false} 三种情形对用户都表现为
+ * {@code DeviceMsgConstants.COMMAND_SEND_FAILED}(「指令下发失败」),差异只体现在服务端日志里——
+ * 用户无法据此采取不同动作,把内部原因暴露到客户端只会造成困惑,也给了探测链路状态的机会。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IDeviceCommandService {
+
+ /**
+ * 下发眼罩开关指令并等待设备回执。
+ *
+ * 前置拦截(协议 §8.3):
+ *
+ * 编号不可变:入参编号与库中现值不一致时抛
+ * {@link org.dromara.common.core.exception.ServiceException};未传或传空串则视为
+ * 「本次不改编号」,以库中现值回填。设备不存在(含已逻辑删除)同样抛出业务异常。
+ *
+ * @param bo 编辑业务对象({@code deviceId} 必填,见 {@code EditGroup} 校验组)
+ * @return 是否修改成功
+ */
+ Boolean updateByBo(DeviceBo bo);
+
+ /**
+ * 生成设备业务编号,规则:BM + yyyyMMdd + 4 位当日序号(如 BM202609160001)
+ *
+ * @return 设备业务编号
+ */
+ String generateDeviceNo();
+
+ /**
+ * 查询设备统计(顶部统计条,架构 §7.2 #2)。
+ *
+ * 口径:{@code total} = 未逻辑删除的设备总数;{@code online} / {@code offline} / {@code inactive}
+ * 分别为 {@code status = '1' / '2' / '0'} 的计数(见 {@link DeviceStatsVo})。
+ *
+ * @return 设备统计
+ */
+ DeviceStatsVo queryStats();
+
+ /**
+ * 批量删除设备(后台,架构 §7.2 #6)。
+ *
+ * 已绑定设备({@code bind_user_id} 非空)必须拒绝——计划 §9.1「已绑定需先解绑或二次确认」,
+ * 本迭代按拒绝实现,抛 {@code DeviceMsgConstants.DEVICE_BOUND_CANNOT_DELETE},避免直接抹掉用户的在管设备。
+ *
+ * 删除为逻辑删除(MyBatis-Plus {@code deleteByIds} → {@code del_flag = '1'})。
+ *
+ * @param ids 设备主键集合
+ * @return 是否删除成功
+ */
+ Boolean deleteByIds(Collection
+ * 升级包是 OTA 链路的「物料」:一次登记(可选上传到 OSS)、一个 {@code (model, version)} 唯一标识、
+ * 一个可停用开关。批量下发时只消费已启用的包(见 {@link IOtaTaskService#batchOta})。
+ *
+ * 唯一性口径对齐 DB 唯一键 {@code uk_model_version (model, version)}——该索引不区分逻辑删除行,
+ * 因此同型号同版本即使被逻辑删除也仍占用取值;实现侧对「已存在」与「唯一键冲突」两种情况都归一为
+ * {@code DeviceMsgConstants.OTA_PACKAGE_EXIST}。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IOtaPackageService {
+
+ /**
+ * 查询升级包详情。
+ *
+ * @param packageId 升级包主键
+ * @return 升级包视图对象;不存在返回 {@code null}
+ */
+ OtaPackageVo queryById(Long packageId);
+
+ /**
+ * 分页查询升级包列表。
+ *
+ * @param bo 查询条件(model / version / status / keyword)
+ * @param pageQuery 分页参数
+ * @return 分页结果
+ */
+ PageResult
+ * {@code status} 留空时默认「停用」({@code 0}),避免刚登记就被批量下发误用;
+ * 需要下发时再由后台显式启用。
+ *
+ * @param bo 新增业务对象
+ * @return 是否新增成功
+ */
+ Boolean insertByBo(OtaPackageBo bo);
+
+ /**
+ * 修改升级包(含启用 / 停用)。
+ *
+ * @param bo 编辑业务对象
+ * @return 是否修改成功
+ */
+ Boolean updateByBo(OtaPackageBo bo);
+
+ /**
+ * 批量删除升级包(逻辑删除)。
+ *
+ * @param ids 升级包主键集合
+ * @return 是否删除成功
+ */
+ Boolean deleteByIds(Collection
+ * 任务的生命周期与设备回执紧密耦合:创建即「待升级」({@code 0}),下发后被设备受理,
+ * 进度由 {@code event/ota_progress} 推进,终态由 {@code ack} 落定(见 I6 回执处理器)。
+ * 本接口只负责「查询」与「批量建任务 + 下发」两件事,终态回写由回执处理器承担。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IOtaTaskService {
+
+ /**
+ * 分页查询升级任务列表(升级记录)。
+ *
+ * @param bo 查询条件(deviceNo / version / status / keyword)
+ * @param pageQuery 分页参数
+ * @return 分页结果
+ */
+ PageResult
+ * 整体前置:目标升级包必须存在且已启用,否则整批拒绝({@code OTA_PACKAGE_UNAVAILABLE}),
+ * 不产生任何任务。
+ *
+ * 逐台处置(去重后每台设备恰好落入 {@code created / skipped / failed} 之一,
+ * 因此 {@code total == created + skipped + failed} 恒成立):
+ *
+ * 本方法不加 {@code @Transactional}:批量下发含逐台阻塞等待回执,不应把整批绑在一条长事务里;
+ * 单台任务的插入以自提交方式落库,失败也留痕(可追溯)。
+ *
+ * @param bo 批量入参({@code packageId} + {@code deviceIds})
+ * @return 批量结果({@code total / created / skipped / failed / messages})
+ */
+ OtaBatchResultVo batchOta(OtaBatchBo bo);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppAuthServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppAuthServiceImpl.java
new file mode 100644
index 0000000..1863ed3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppAuthServiceImpl.java
@@ -0,0 +1,320 @@
+package org.dromara.device.service.impl;
+
+import cn.dev33.satoken.exception.NotLoginException;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.stp.parameter.SaLoginParameter;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.constant.SystemConstants;
+import org.dromara.common.core.enums.UserType;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.redis.utils.RedisUtils;
+import org.dromara.common.satoken.utils.LoginHelper;
+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.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.email.AppEmailService;
+import org.dromara.device.service.IAppAuthService;
+import org.dromara.device.sms.AppSmsService;
+import org.dromara.system.api.AppUserApiService;
+import org.dromara.system.api.domain.AppUserDTO;
+import org.dromara.system.api.model.LoginUser;
+import org.redisson.api.RLock;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+/**
+ * APP 账号服务实现。
+ *
+ * 依赖口径:
+ *
+ * 并发保护(P2-2-2,双保险):{@code sys_user.phone_number} 已有 DB 唯一键
+ * {@code uk_sys_user_phone_number}(见 {@code script/sql/bm_patch.sql}),DB 唯一键才是最终防线;
+ * 本处的 Redisson 锁(key = {@code app:user:register:lock:{phone}},风格对齐 I1 取号锁
+ * {@code bm:device:no:lock:*})仅用于把并发冲突降到「几乎不触发」,避免把正常注册路径频繁打到 DB 唯一键。
+ *
+ * 本方法不加 {@code @Transactional}:与 I1 取号口径一致 —— 让 insert 以自动提交方式在锁释放前落库,
+ * 后继持锁线程方能读到前一笔。
+ *
+ * 兜底:万一仍撞 DB 唯一键(跨实例/锁服务异常等极端场景),捕获
+ * {@link DuplicateKeyException} 转为受控文案 {@code PHONE_REGISTERED},不向调用方裸抛 1062。
+ *
+ * @param bo 注册入参
+ */
+ @Override
+ public void register(AppRegisterBo bo) {
+ RLock lock = RedisUtils.getClient().getLock(AppConstants.REGISTER_LOCK_KEY_PREFIX + bo.getPhoneNumber());
+ lock.lock();
+ try {
+ if (appUserApiService.existsByPhone(bo.getPhoneNumber())) {
+ throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
+ }
+ appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_REGISTER, bo.getCode());
+ try {
+ appUserApiService.registerByPhone(bo.getPhoneNumber(), bo.getPassword(), bo.getNickName());
+ } catch (DuplicateKeyException e) {
+ // DB 唯一键 uk_sys_user_phone_number 冲突:并发同号注册的最终兜底
+ throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
+ }
+ } finally {
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+
+ /**
+ * 密码登录。
+ *
+ * @param bo 登录入参
+ * @return 访问令牌
+ */
+ @Override
+ public String login(AppLoginBo bo) {
+ AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
+ if (user == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ checkAccountEnabled(user);
+ if (!appUserApiService.matchesPassword(user.getUserId(), bo.getPassword())) {
+ throw new ServiceException(AppMsgConstants.PASSWORD_ERROR);
+ }
+ return doLogin(user);
+ }
+
+ /**
+ * 短信验证码登录。
+ *
+ * @param bo 短信登录入参
+ * @return 访问令牌
+ */
+ @Override
+ public String smsLogin(AppSmsLoginBo bo) {
+ AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
+ if (user == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ checkAccountEnabled(user);
+ appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_LOGIN, bo.getCode());
+ return doLogin(user);
+ }
+
+ /**
+ * 忘记密码(重置密码)。
+ *
+ * @param bo 重置入参
+ */
+ @Override
+ public void resetPwd(AppResetPwdBo bo) {
+ AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
+ if (user == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_RESET_PWD, bo.getCode());
+ appUserApiService.resetPassword(user.getUserId(), bo.getNewPassword());
+ }
+
+ /**
+ * 退出登录:仅清除当前会话(不等价于注销;注销见 {@code IAppUserService#cancelAccount()})。
+ */
+ @Override
+ public void logout() {
+ try {
+ StpUtil.logout();
+ } catch (NotLoginException ignored) {
+ // 未登录时退出视为幂等成功
+ }
+ }
+
+ /**
+ * 发送邮箱验证码。
+ *
+ * @param bo 验证码入参(邮箱 + 场景)
+ */
+ @Override
+ public void sendEmailCode(AppEmailCodeBo bo) {
+ appEmailService.sendCode(bo.getEmail(), bo.getScene());
+ }
+
+ /**
+ * 邮箱注册:先判重,再校验邮箱验证码,最后落库。
+ *
+ * 并发保护(与手机号注册同口径,双保险):{@code sys_user.email} 的唯一键
+ * {@code uk_sys_user_email}(见 {@code script/sql/bm_patch.sql} 补丁 2)才是最终防线;
+ * 本处的 Redisson 锁(key = {@code app:user:register:lock:email:{email}})仅用于把并发冲突
+ * 降到「几乎不触发」,避免把正常注册路径频繁打到 DB 唯一键。
+ *
+ * 邮箱归一化:判重、验证码键、落库值三者统一使用
+ * {@link AppEmailService#normalize(String)} 的结果(去空白 + 转小写),
+ * 避免「A@Gmail.com」与「a@gmail.com」各自建号。
+ *
+ * 本方法不加 {@code @Transactional}:与 {@code register} 口径一致——让 insert 以自动提交方式
+ * 在锁释放前落库,后继持锁线程方能读到前一笔。
+ *
+ * @param bo 注册入参
+ */
+ @Override
+ public void registerByEmail(AppEmailRegisterBo bo) {
+ String email = appEmailService.normalize(bo.getEmail());
+ RLock lock = RedisUtils.getClient().getLock(AppConstants.EMAIL_REGISTER_LOCK_KEY_PREFIX + email);
+ lock.lock();
+ try {
+ if (appUserApiService.existsByEmail(email)) {
+ throw new ServiceException(DeviceMsgConstants.EMAIL_REGISTERED);
+ }
+ appEmailService.verifyCode(email, AppConstants.SCENE_REGISTER, bo.getCode());
+ try {
+ appUserApiService.registerByEmail(email, bo.getPassword(), bo.getNickName());
+ } catch (DuplicateKeyException e) {
+ // DB 唯一键 uk_sys_user_email 冲突:并发同邮箱注册的最终兜底
+ throw new ServiceException(DeviceMsgConstants.EMAIL_REGISTERED);
+ }
+ } finally {
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+
+ /**
+ * 邮箱 + 密码登录。
+ *
+ * @param bo 登录入参
+ * @return 访问令牌
+ */
+ @Override
+ public String emailLogin(AppEmailLoginBo bo) {
+ AppUserDTO user = appUserApiService.selectByEmail(appEmailService.normalize(bo.getEmail()));
+ if (user == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ checkAccountEnabled(user);
+ if (!appUserApiService.matchesPassword(user.getUserId(), bo.getPassword())) {
+ throw new ServiceException(AppMsgConstants.EMAIL_PASSWORD_ERROR);
+ }
+ return doLogin(user);
+ }
+
+ /**
+ * 邮箱 + 验证码登录。
+ *
+ * 顺序口径与 {@link #smsLogin(AppSmsLoginBo)} 完全一致:先判账号存在与可用,再校验验证码。
+ *
+ * @param bo 验证码登录入参
+ * @return 访问令牌
+ */
+ @Override
+ public String emailCodeLogin(AppEmailCodeLoginBo bo) {
+ String email = appEmailService.normalize(bo.getEmail());
+ AppUserDTO user = appUserApiService.selectByEmail(email);
+ if (user == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ checkAccountEnabled(user);
+ appEmailService.verifyCode(email, AppConstants.SCENE_LOGIN, bo.getCode());
+ return doLogin(user);
+ }
+
+ /**
+ * 邮箱找回密码(重置密码)。
+ *
+ * @param bo 重置入参
+ */
+ @Override
+ public void resetPwdByEmail(AppEmailResetPwdBo bo) {
+ String email = appEmailService.normalize(bo.getEmail());
+ AppUserDTO user = appUserApiService.selectByEmail(email);
+ if (user == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ appEmailService.verifyCode(email, AppConstants.SCENE_RESET_PWD, bo.getCode());
+ appUserApiService.resetPassword(user.getUserId(), bo.getNewPassword());
+ }
+
+ /**
+ * 校验账号是否可用(停用账号禁止登录)。
+ *
+ * @param user APP 用户
+ */
+ private void checkAccountEnabled(AppUserDTO user) {
+ if (SystemConstants.DISABLE.equals(user.getStatus())) {
+ throw new ServiceException(AppMsgConstants.ACCOUNT_DISABLED);
+ }
+ }
+
+ /**
+ * 建立 APP 登录会话并返回访问令牌。
+ *
+ * 以 {@code app_user:{userId}} 作为登录标识;token 扩展中写入 clientid 与允许访问路径,
+ * 供框架 {@code SecurityConfig} 校验 {@code clientid} 头与「仅可访问 /app/**」。
+ *
+ * @param user APP 用户
+ * @return 访问令牌
+ */
+ private String doLogin(AppUserDTO user) {
+ AppProperties.Client client = appProperties.getClient();
+ LoginUser loginUser = new LoginUser();
+ loginUser.setUserId(user.getUserId());
+ loginUser.setUserType(UserType.APP_USER.getUserType());
+ loginUser.setUsername(user.getUserName());
+ loginUser.setNickname(user.getNickName());
+ loginUser.setClientKey(client.getClientId());
+ loginUser.setDeviceType(client.getDeviceType());
+
+ SaLoginParameter model = new SaLoginParameter();
+ model.setDeviceType(client.getDeviceType());
+ model.setTimeout(client.getTimeout());
+ model.setActiveTimeout(client.getActiveTimeout());
+ // 写入 token 扩展:clientid 供框架校验「请求头 clientid == 令牌扩展 clientid」(SecurityConfig:98-106);
+ // clientAccessPath 供框架校验「仅可访问 /app/**」(SecurityConfig:147-155)。
+ model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
+ model.setExtra(LoginHelper.CLIENT_ACCESS_PATH_KEY, client.getAccessPath());
+ LoginHelper.login(loginUser, model);
+ return StpUtil.getTokenValue();
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppDeviceServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppDeviceServiceImpl.java
new file mode 100644
index 0000000..25b72d4
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppDeviceServiceImpl.java
@@ -0,0 +1,413 @@
+package org.dromara.device.service.impl;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.DesensitizedUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.device.constant.AppConstants;
+import org.dromara.device.constant.AppMsgConstants;
+import org.dromara.device.constant.DeviceMsgConstants;
+import org.dromara.device.domain.BmDevice;
+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.LocationVo;
+import org.dromara.device.enums.BindActionEnum;
+import org.dromara.device.enums.DeviceStatusEnum;
+import org.dromara.device.enums.HoodConnectedEnum;
+import org.dromara.device.enums.HoodCoverEnum;
+import org.dromara.device.enums.SignalLevelEnum;
+import org.dromara.device.mapper.BmDeviceMapper;
+import org.dromara.device.service.IAppDeviceService;
+import org.dromara.device.service.IDeviceBindService;
+import org.dromara.device.service.IDeviceCommandService;
+import org.dromara.system.api.AppUserApiService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * APP 设备服务实现(列表 / 详情 / 绑定 / 重命名 / 解绑)。
+ *
+ *
+ * 前置硬约束(本迭代新增):账号须已绑定手机号。邮箱注册的账号在建号时
+ * {@code phone_number} 为空,按账号规则「注册后仍须绑手机号」,
+ * 此处服务端拦截而非依赖前端引导——否则「必须绑手机号」只是产品约定,
+ * 绕过 APP 直接调用接口即可跳过。
+ *
+ * @param bo 绑定入参(imei + name)
+ * @param userId 当前用户主键
+ * @return 是否绑定成功
+ */
+ @Override
+ public Boolean bind(AppDeviceBindBo bo, Long userId) {
+ requireUser(userId);
+ if (!appUserApiService.isPhoneBound(userId)) {
+ throw new ServiceException(AppMsgConstants.PHONE_NOT_BOUND);
+ }
+ validateName(bo.getName());
+ // 以 IMEI 定位建档设备(仅用于取得 device_id/device_no/imei;绑定归属由下方条件更新裁决,避免 TOCTOU)
+ BmDevice device = deviceMapper.lambda()
+ .eq(BmDevice::getImei, bo.getImei())
+ .one();
+ if (device == null) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ int rows = deviceMapper.lambda()
+ .set(BmDevice::getBindUserId, userId)
+ .set(BmDevice::getName, bo.getName())
+ .set(BmDevice::getStatus, DeviceStatusEnum.ONLINE.getCode())
+ .set(BmDevice::getBindTime, LocalDateTime.now())
+ .set(BmDevice::getUpdateTime, LocalDateTime.now())
+ .eq(BmDevice::getDeviceId, device.getDeviceId())
+ .isNull(BmDevice::getBindUserId)
+ .updateCount();
+ if (rows == 0) {
+ // 未抢到(并发双绑或已绑定):读最新归属给出精确文案;失败路径绝不写 bind 流水
+ BmDevice latest = deviceMapper.lambda()
+ .eq(BmDevice::getDeviceId, device.getDeviceId())
+ .one();
+ if (latest != null && userId.equals(latest.getBindUserId())) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_ALREADY_BOUND);
+ }
+ throw new ServiceException(DeviceMsgConstants.DEVICE_BOUND_BY_OTHERS);
+ }
+ // 绑定成功:落一条 bind 流水(device_no 冗余快照 + imei 冗余快照)
+ deviceBindService.record(device.getDeviceId(), device.getDeviceNo(), userId, device.getImei(),
+ BindActionEnum.BIND.getAction());
+ return Boolean.TRUE;
+ }
+
+ /**
+ * 重命名当前用户的某台设备(条件更新带 {@code bind_user_id} 条件,越权隔离 + 防 TOCTOU)。
+ *
+ * {@code bo.deviceNo} 由 Controller 从路径变量回填,故不对其做 Bean Validation(P1 修复);
+ * 此处兜底校验:为空/空白时按 {@link DeviceMsgConstants#DEVICE_NOT_EXIST} 处理,
+ * 与越权隔离保持同一文案(不新造「设备编号不能为空」这类会泄露校验细节的提示)。
+ *
+ * @param bo 重命名入参(deviceNo + name)
+ * @param userId 当前用户主键
+ * @return 是否重命名成功
+ */
+ @Override
+ public Boolean rename(AppDeviceNameBo bo, Long userId) {
+ requireUser(userId);
+ if (StringUtils.isBlank(bo.getDeviceNo())) {
+ // 路径回填字段的兜底(Bean Validation 不覆盖路径回填);统一文案,避免信息泄露
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ validateName(bo.getName());
+ int rows = deviceMapper.lambda()
+ .set(BmDevice::getName, bo.getName())
+ .set(BmDevice::getUpdateTime, LocalDateTime.now())
+ .eq(BmDevice::getDeviceNo, bo.getDeviceNo())
+ .eq(BmDevice::getBindUserId, userId)
+ .updateCount();
+ if (rows == 0) {
+ // 未建档 / 属于他人 / 未绑定 —— 统一文案,不泄露设备归属
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ return Boolean.TRUE;
+ }
+
+ /**
+ * 解绑当前用户的某台设备(复用 {@link IDeviceBindService#unbind(String, Long)} 单一入口)。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户主键
+ * @return 是否解绑成功
+ */
+ @Override
+ public Boolean unbind(String deviceNo, Long userId) {
+ requireUser(userId);
+ int rows = deviceBindService.unbind(deviceNo, userId);
+ if (rows <= 0) {
+ // 未建档 / 属于他人 / 未绑定 —— 统一文案,不泄露设备归属
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ return Boolean.TRUE;
+ }
+
+ /**
+ * 下发眼罩开关指令(归属校验 → 协议 §8.3 拦截 → 等待回执)。
+ *
+ * 本方法是阻塞式的:等待设备回执最长 {@code device.mqtt.hood-timeout}(默认 5s)。
+ * 这与其余 APP 接口的「快速返回」形态不同,原因是协议 §8.2 把 {@code ack.ok} 定为唯一成功凭据——
+ * 先返回「操作成功」再异步补状态,就会出现「APP 显示已遮盖、设备其实没动」的假成功。
+ *
+ * @param bo 眼罩入参(deviceNo + coverOn)
+ * @param userId 当前用户主键
+ * @return 设备确认执行后的眼罩状态
+ */
+ @Override
+ public AppHoodResultVo hood(AppDeviceHoodBo bo, Long userId) {
+ requireUser(userId);
+ // 归属判定必须早于任何协议校验:否则「已离线」等报错会先于「设备不存在」出现,
+ // 可被用来枚举 deviceNo 探测设备是否存在(与越权隔离同一考量)
+ BmDevice device = findOwnedDevice(bo.getDeviceNo(), userId);
+ HoodCoverEnum cover = deviceCommandService.sendHood(device, bo.getCoverOn());
+ AppHoodResultVo vo = new AppHoodResultVo();
+ vo.setDeviceNo(device.getDeviceNo());
+ vo.setHoodCoverOn(cover.getCode());
+ vo.setHoodCoverOnText(cover.getLabel());
+ return vo;
+ }
+
+ /**
+ * 查询当前用户某台设备的定位「最新点」。
+ *
+ * 归属校验复用 {@link #findOwnedDevice(String, Long)}(与 {@code detail} 同一入口),
+ * 非本人设备统一 {@link DeviceMsgConstants#DEVICE_NOT_EXIST}。
+ *
+ * 空态:设备从未上报定位时,主表 {@code lng}/{@code lat} 为 {@code null},
+ * 此处原样返回(不抛异常),由 APP 展示「暂无定位」空态。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户主键
+ * @return 定位视图对象(坐标可能为 {@code null})
+ */
+ @Override
+ public LocationVo location(String deviceNo, Long userId) {
+ requireUser(userId);
+ BmDevice device = findOwnedDevice(deviceNo, userId);
+ LocationVo vo = new LocationVo();
+ vo.setDeviceNo(device.getDeviceNo());
+ vo.setLng(device.getLng());
+ vo.setLat(device.getLat());
+ vo.setAddress(device.getAddress());
+ vo.setLastOnlineTime(device.getLastOnlineTime());
+ return vo;
+ }
+
+ /**
+ * 按设备编号查询当前用户名下的设备(越权隔离的唯一查询入口)。
+ *
+ * 三类情形 —— 设备未建档 / 属于他人 / 未绑定 —— 都命中 0 行,统一抛「设备不存在」,
+ * 调用方无法据此区分设备是否存在或归属他人。
+ *
+ * @param deviceNo 设备编号
+ * @param userId 当前用户主键
+ * @return 设备实体
+ */
+ private BmDevice findOwnedDevice(String deviceNo, Long userId) {
+ if (StringUtils.isBlank(deviceNo)) {
+ // Controller 从路径回填,理论上非空;兜底按「设备不存在」处理,不新造校验类文案
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ BmDevice device = deviceMapper.lambda()
+ .eq(BmDevice::getDeviceNo, deviceNo)
+ .eq(BmDevice::getBindUserId, userId)
+ .one();
+ if (device == null) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ return device;
+ }
+
+ /**
+ * 校验当前用户登录态,缺失时按「未登录」拒绝。
+ *
+ * @param userId 当前用户主键
+ */
+ private void requireUser(Long userId) {
+ if (userId == null) {
+ throw new ServiceException(AppMsgConstants.NOT_LOGIN);
+ }
+ }
+
+ /**
+ * 校验设备名称(非空且按字符数 ≤ {@link AppConstants#DEVICE_NAME_MAX_LENGTH})。
+ *
+ * @param name 设备名称
+ */
+ private void validateName(String name) {
+ if (StringUtils.isBlank(name) || name.length() > AppConstants.DEVICE_NAME_MAX_LENGTH) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NAME_INVALID);
+ }
+ }
+
+ /**
+ * IMEI 脱敏(前 4 + **** + 后 4)。
+ *
+ * @param imei 原始 IMEI
+ * @return 脱敏后 IMEI
+ */
+ private String maskImei(String imei) {
+ return DesensitizedUtils.mask(imei, IMEI_MASK_PREFIX_VISIBLE, IMEI_MASK_SUFFIX_VISIBLE, IMEI_MASK_LENGTH);
+ }
+
+ /**
+ * 信号等级 → 展示文案(字典 4 值:离线 / 弱 / 一般 / 强)。
+ *
+ * @param level 信号等级
+ * @return 文案;等级为 null 或未匹配时返回 null
+ */
+ private String signalText(Integer level) {
+ if (level == null) {
+ return null;
+ }
+ for (SignalLevelEnum item : SignalLevelEnum.values()) {
+ if (item.getLevel().equals(level)) {
+ return item.getLabel();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 头套连接 → 展示文案(已断开 / 已连接)。
+ *
+ * @param code 头套连接编码
+ * @return 文案;编码为 null 或未匹配时返回 null
+ */
+ private String hoodConnectedText(String code) {
+ if (code == null) {
+ return null;
+ }
+ for (HoodConnectedEnum item : HoodConnectedEnum.values()) {
+ if (item.getCode().equals(code)) {
+ return item.getLabel();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 实体 → 列表卡片视图。
+ *
+ * @param device 设备实体
+ * @return 列表卡片视图
+ */
+ private AppDeviceListVo toListVo(BmDevice device) {
+ AppDeviceListVo vo = new AppDeviceListVo();
+ vo.setDeviceNo(device.getDeviceNo());
+ vo.setName(device.getName());
+ vo.setImei(maskImei(device.getImei()));
+ vo.setModel(device.getModel());
+ vo.setStatus(device.getStatus());
+ vo.setSignalLevel(device.getSignalLevel());
+ vo.setSignalText(signalText(device.getSignalLevel()));
+ vo.setHoodConnected(device.getHoodConnected());
+ vo.setHoodConnectedText(hoodConnectedText(device.getHoodConnected()));
+ vo.setHoodBattery(device.getHoodBattery());
+ vo.setRingBattery(device.getRingBattery());
+ vo.setLng(device.getLng());
+ vo.setLat(device.getLat());
+ vo.setAddress(device.getAddress());
+ return vo;
+ }
+
+ /**
+ * 实体 → 详情视图。
+ *
+ * @param device 设备实体
+ * @return 详情视图
+ */
+ private AppDeviceDetailVo toDetailVo(BmDevice device) {
+ AppDeviceDetailVo vo = new AppDeviceDetailVo();
+ vo.setDeviceNo(device.getDeviceNo());
+ vo.setName(device.getName());
+ vo.setImei(maskImei(device.getImei()));
+ vo.setModel(device.getModel());
+ vo.setStatus(device.getStatus());
+ vo.setHoodCoverOn(device.getHoodCoverOn());
+ vo.setSignalLevel(device.getSignalLevel());
+ vo.setSignalText(signalText(device.getSignalLevel()));
+ vo.setHoodConnected(device.getHoodConnected());
+ vo.setHoodConnectedText(hoodConnectedText(device.getHoodConnected()));
+ vo.setHoodBattery(device.getHoodBattery());
+ vo.setRingBattery(device.getRingBattery());
+ vo.setLng(device.getLng());
+ vo.setLat(device.getLat());
+ vo.setAddress(device.getAddress());
+ return vo;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppStreamServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppStreamServiceImpl.java
new file mode 100644
index 0000000..b24bd73
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppStreamServiceImpl.java
@@ -0,0 +1,220 @@
+package org.dromara.device.service.impl;
+
+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.device.config.ZlmProperties;
+import org.dromara.device.constant.AppMsgConstants;
+import org.dromara.device.constant.DeviceMsgConstants;
+import org.dromara.device.domain.BmDevice;
+import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
+import org.dromara.device.domain.vo.AppStreamPlayVo;
+import org.dromara.device.domain.vo.AppStreamStatusVo;
+import org.dromara.device.enums.DeviceStatusEnum;
+import org.dromara.device.mapper.BmDeviceMapper;
+import org.dromara.device.mqtt.DeviceCommandPublisher;
+import org.dromara.device.mqtt.payload.StreamCommandPayload;
+import org.dromara.device.service.IAppStreamService;
+import org.dromara.device.zlm.stream.ZlmClient;
+import org.dromara.device.zlm.stream.ZlmPlayAuthService;
+import org.springframework.stereotype.Service;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * APP 直播服务实现。
+ *
+ * 流程对齐 dm:MQTT 通知设备推流到 ZLM → 查询 getMediaInfo → 签发带 token 的播放地址。
+ * 越权隔离对齐 I3:SQL {@code device_no + bind_user_id},命中 0 行统一「设备不存在」。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AppStreamServiceImpl implements IAppStreamService {
+
+ private final ZlmClient zlmClient;
+ private final ZlmProperties config;
+ private final ZlmPlayAuthService playAuthService;
+ private final DeviceCommandPublisher commandPublisher;
+ private final BmDeviceMapper deviceMapper;
+
+ @Override
+ public AppStreamPlayVo start(String deviceNo, Long userId) {
+ BmDevice device = requireOwnedDevice(deviceNo, userId);
+ if (!DeviceStatusEnum.ONLINE.getCode().equals(device.getStatus())) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE);
+ }
+ String streamId = normalizeStreamId(device.getDeviceNo());
+ if (!zlmClient.isStreamOnline(streamId)) {
+ StreamCommandPayload payload = StreamCommandPayload.start(
+ DeviceCommandPublisher.newMsgId(),
+ streamId,
+ config.getApp(),
+ config.getPushProtocol(),
+ buildPushUrl(streamId));
+ if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) {
+ throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED);
+ }
+ log.info("[ZLM] 已通知设备开始推流 deviceNo={} streamId={}", device.getDeviceNo(), streamId);
+ }
+ return buildPlayInfo(device.getDeviceNo(), userId);
+ }
+
+ @Override
+ public AppStreamStatusVo stop(String deviceNo, Long userId) {
+ BmDevice device = requireOwnedDevice(deviceNo, userId);
+ String streamId = normalizeStreamId(device.getDeviceNo());
+ StreamCommandPayload payload = StreamCommandPayload.stop(
+ DeviceCommandPublisher.newMsgId(), streamId, config.getApp());
+ commandPublisher.publishStream(device.getDeviceNo(), payload);
+ zlmClient.closeStreams(streamId);
+ return statusOf(device.getDeviceNo(), streamId, false);
+ }
+
+ @Override
+ public AppStreamPlayVo playUrl(String deviceNo, Long userId) {
+ return buildPlayInfo(deviceNo, userId);
+ }
+
+ @Override
+ public void close(String deviceNo, Long userId) {
+ BmDevice device = requireOwnedDevice(deviceNo, userId);
+ closeZlmStream(device.getDeviceNo());
+ }
+
+ @Override
+ public AppStreamStatusVo status(String deviceNo, Long userId) {
+ BmDevice device = requireOwnedDevice(deviceNo, userId);
+ String streamId = normalizeStreamId(device.getDeviceNo());
+ return statusOf(device.getDeviceNo(), streamId, zlmClient.isStreamOnline(streamId));
+ }
+
+ @Override
+ public void closeZlmStream(String streamId) {
+ streamId = normalizeStreamId(streamId);
+ zlmClient.delStreamProxy(streamId);
+ zlmClient.closeStreams(streamId);
+ }
+
+ private AppStreamPlayVo buildPlayInfo(String deviceNo, Long userId) {
+ BmDevice device = requireOwnedDevice(deviceNo, userId);
+ String streamId = normalizeStreamId(device.getDeviceNo());
+ ZlmPlayAuthService.PlayToken playToken = playAuthService.createToken(streamId);
+
+ AppStreamPlayVo vo = new AppStreamPlayVo();
+ vo.setDeviceNo(device.getDeviceNo());
+ vo.setStreamId(streamId);
+ vo.setApp(config.getApp());
+ vo.setOnline(zlmClient.isStreamOnline(streamId));
+ vo.setPushUrl(buildPushUrl(streamId));
+ vo.setPlayToken(playToken.token());
+ vo.setExpireAt(playToken.expireAt());
+ vo.setExpireSeconds(config.getPlayExpireSeconds());
+ vo.setPlayUrls(buildPlayUrls(streamId, playToken.token()));
+ return vo;
+ }
+
+ private AppStreamPlayUrlsVo buildPlayUrls(String streamId, String token) {
+ String base = trimTrailingSlash(config.getHost()) + "/" + config.getApp() + "/" + streamId;
+ AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo();
+ urls.setFlv(appendToken(base + ".live.flv", token));
+ urls.setHls(appendToken(base + "/hls.m3u8", token));
+ urls.setRtmp(appendToken(buildRtmpUrl(streamId), token));
+ urls.setRtsp(appendToken(buildRtspUrl(streamId), token));
+ urls.setWebrtc(appendToken(buildWebrtcUrl(streamId), token));
+ return urls;
+ }
+
+ private String buildPushUrl(String streamId) {
+ if ("rtsp".equalsIgnoreCase(config.getPushProtocol())) {
+ return buildRtspUrl(streamId);
+ }
+ return buildRtmpUrl(streamId);
+ }
+
+ private String buildRtmpUrl(String streamId) {
+ return toProtocol(resolveProtocolHost(config.getRtmpHost(), config.getHost(), "1935"), "rtmp")
+ + "/" + config.getApp() + "/" + streamId;
+ }
+
+ private String buildRtspUrl(String streamId) {
+ return toProtocol(resolveProtocolHost(config.getRtspHost(), config.getHost(), "554"), "rtsp")
+ + "/" + config.getApp() + "/" + streamId;
+ }
+
+ private String buildWebrtcUrl(String streamId) {
+ return toProtocol(resolveProtocolHost(config.getWebrtcHost(), config.getHost(), null), "webrtc")
+ + "/" + config.getApp() + "/" + streamId;
+ }
+
+ private String toProtocol(String host, String protocol) {
+ String url = trimTrailingSlash(host);
+ int index = url.indexOf("://");
+ return index > -1 ? protocol + url.substring(index) : protocol + "://" + url;
+ }
+
+ private String appendToken(String url, String token) {
+ String separator = url.contains("?") ? "&" : "?";
+ return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
+ }
+
+ private String trimTrailingSlash(String value) {
+ if (value == null || value.isBlank()) {
+ throw new ServiceException(DeviceMsgConstants.ZLM_NOT_CONFIGURED);
+ }
+ return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
+ }
+
+ private String resolveProtocolHost(String configuredHost, String fallbackHost, String defaultPort) {
+ if (configuredHost != null && !configuredHost.isBlank()) {
+ return configuredHost;
+ }
+ String host = trimTrailingSlash(fallbackHost);
+ int protocolIndex = host.indexOf("://");
+ String protocol = protocolIndex > -1 ? host.substring(0, protocolIndex + 3) : "";
+ String rest = protocolIndex > -1 ? host.substring(protocolIndex + 3) : host;
+ int slashIndex = rest.indexOf('/');
+ String authority = slashIndex > -1 ? rest.substring(0, slashIndex) : rest;
+ int colonIndex = authority.lastIndexOf(':');
+ String hostname = colonIndex > -1 ? authority.substring(0, colonIndex) : authority;
+ return defaultPort == null ? protocol + authority : protocol + hostname + ":" + defaultPort;
+ }
+
+ private String normalizeStreamId(String deviceNo) {
+ if (deviceNo == null || deviceNo.isBlank()) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ return deviceNo.trim();
+ }
+
+ private BmDevice requireOwnedDevice(String deviceNo, Long userId) {
+ if (userId == null) {
+ throw new ServiceException(AppMsgConstants.NOT_LOGIN);
+ }
+ if (StringUtils.isBlank(deviceNo)) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ BmDevice device = deviceMapper.lambda()
+ .eq(BmDevice::getDeviceNo, deviceNo.trim())
+ .eq(BmDevice::getBindUserId, userId)
+ .one();
+ if (device == null) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ return device;
+ }
+
+ private AppStreamStatusVo statusOf(String deviceNo, String streamId, boolean online) {
+ AppStreamStatusVo vo = new AppStreamStatusVo();
+ vo.setDeviceNo(deviceNo);
+ vo.setStreamId(streamId);
+ vo.setOnline(online);
+ return vo;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserAdminServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserAdminServiceImpl.java
new file mode 100644
index 0000000..cf9ba32
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserAdminServiceImpl.java
@@ -0,0 +1,224 @@
+package org.dromara.device.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.device.constant.DeviceMsgConstants;
+import org.dromara.device.domain.BmDevice;
+import org.dromara.device.domain.bo.AppUserAdminQueryBo;
+import org.dromara.device.domain.bo.AppUserResetPwdBo;
+import org.dromara.device.domain.bo.AppUserStatusBo;
+import org.dromara.device.domain.bo.DeviceBo;
+import org.dromara.device.domain.vo.AppUserAdminVo;
+import org.dromara.device.domain.vo.AppUserDeviceVo;
+import org.dromara.device.domain.vo.DeviceVo;
+import org.dromara.device.mapper.BmDeviceMapper;
+import org.dromara.device.service.IAppUserAdminService;
+import org.dromara.device.service.IDeviceBindService;
+import org.dromara.device.service.IDeviceService;
+import org.dromara.system.api.AppUserApiService;
+import org.dromara.system.api.domain.AppUserDTO;
+import org.dromara.system.api.domain.AppUserQueryDTO;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * APP 用户管理服务实现(后台管理端)。
+ *
+ * 账号数据经 {@link AppUserApiService} 获取(跨模块边界,密码不跨界);
+ * 设备数据直接复用设备域既有能力({@link IDeviceService} / {@link IDeviceBindService}),
+ * 不新增任何设备侧方法。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@RequiredArgsConstructor
+@Service
+public class AppUserAdminServiceImpl implements IAppUserAdminService {
+
+ private final AppUserApiService appUserApiService;
+ private final IDeviceService deviceService;
+ private final IDeviceBindService deviceBindService;
+ private final BmDeviceMapper deviceMapper;
+
+ /**
+ * 分页查询 APP 用户列表。
+ *
+ * 绑定设备数用一次额外查询批量补齐(而非逐行 count):先取本页全部 userId,
+ * 一次性查出这些用户的设备行再在内存里分组计数,避免 pageSize 次往返(N+1)。
+ * 只 select {@code bind_user_id} 一列,降低结果集体积。
+ */
+ @Override
+ public PageResult
+ * 「不存在」与「非 APP 账号」在此归为同一结果({@code null}):
+ * 后者属于越界访问,不应向外泄露「该主键其实是后台账号」这一信息。
+ */
+ @Override
+ public AppUserAdminVo queryById(Long userId) {
+ AppUserDTO user = appUserApiService.selectAppUserById(userId);
+ if (user == null) {
+ return null;
+ }
+ AppUserAdminVo vo = toVo(user);
+ vo.setDeviceCount(countDevicesByUser(List.of(userId)).getOrDefault(userId, 0L));
+ return vo;
+ }
+
+ /**
+ * 查询指定 APP 用户当前绑定的设备列表。
+ *
+ * 复用 {@link IDeviceService#queryList}——其底层已支持按 {@code bind_user_id} 精确过滤
+ * ({@code DeviceServiceImpl#buildQueryWrapper}),故无需新增设备侧查询方法。
+ *
+ * 目标不存在或非 APP 账号时返回空列表而非抛异常:与 {@link #queryById} 的「返回 {@code null}」
+ * 口径保持一致,避免前端出现「详情显示空态、设备列表却弹错误」的割裂表现。
+ * 写操作(解绑)则不适用该宽松口径——见 {@link #unbindDevice}。
+ */
+ @Override
+ public List
+ * {@code updateStatus} 返回 {@code false} 即表示目标不存在或非 APP 账号
+ * (类型条件由 {@code ruoyi-system} 侧强制附加),统一转「账号不存在」,
+ * 不向前端区分具体原因,避免被用来探测 user_id 归属。
+ */
+ @Override
+ public Boolean updateStatus(AppUserStatusBo bo) {
+ if (!appUserApiService.updateStatus(bo.getUserId(), bo.getStatus())) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ return true;
+ }
+
+ /**
+ * 重置账号密码(管理员代为设置)。
+ *
+ * 明文只在内存中从入参传递到 {@link AppUserApiService#resetPassword},
+ * 散列由 {@code ruoyi-system} 内部完成(BCrypt),本层不感知散列值。
+ */
+ @Override
+ public Boolean resetPassword(AppUserResetPwdBo bo) {
+ if (!appUserApiService.resetPassword(bo.getUserId(), bo.getNewPassword())) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ return true;
+ }
+
+ /**
+ * 解绑该用户名下的某台设备。
+ *
+ * 复用单一解绑入口:{@code IDeviceBindService#unbind} 内部以
+ * {@code device_no + bind_user_id = userId} 双重定位,因此「设备不属于该用户」
+ * 与「设备未绑定」都会得到影响行数 0,统一转「设备不存在」——
+ * 既保证管理员不会误操他人设备,也不泄露设备归属。
+ */
+ @Override
+ public Boolean unbindDevice(Long userId, String deviceNo) {
+ requireAppUser(userId);
+ if (StringUtils.isBlank(deviceNo)) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ if (deviceBindService.unbind(deviceNo, userId) <= 0) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ return true;
+ }
+
+ /**
+ * 校验目标确为存在的 APP 账号,否则抛「账号不存在」。
+ *
+ * 用于「列表 / 解绑」这类需要先确认主体存在的入口;
+ * 状态变更与重置密码则由跨模块方法的返回值兜底(类型条件已在其内部强制)。
+ *
+ * @param userId 用户主键
+ */
+ private void requireAppUser(Long userId) {
+ if (userId == null || appUserApiService.selectAppUserById(userId) == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ }
+
+ /**
+ * 批量统计这批用户各自的绑定设备数。
+ *
+ * 逻辑删除条件由 MyBatis-Plus 依据实体的 {@code @TableLogic} 自动附加,此处不手写 {@code del_flag}。
+ *
+ * @param userIds 用户主键集合
+ * @return userId → 设备数(无设备的用户不出现在 map 中)
+ */
+ private Map
+ * {@code phoneBound} 由服务端计算而非让前端判空:该语义后续可能变化
+ * (例如兼容历史 {@code ''} 空串),收敛在一处更易维护。
+ */
+ private AppUserAdminVo toVo(AppUserDTO user) {
+ AppUserAdminVo vo = BeanUtil.copyProperties(user, AppUserAdminVo.class);
+ vo.setPhoneBound(StringUtils.isNotBlank(user.getPhoneNumber()));
+ return vo;
+ }
+
+ /**
+ * 设备 VO → 用户视角的设备条目(字段为裁剪关系,同名自动映射)。
+ */
+ private AppUserDeviceVo toDeviceVo(DeviceVo device) {
+ return BeanUtil.copyProperties(device, AppUserDeviceVo.class);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserServiceImpl.java
new file mode 100644
index 0000000..7de3a44
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserServiceImpl.java
@@ -0,0 +1,199 @@
+package org.dromara.device.service.impl;
+
+import cn.dev33.satoken.exception.NotLoginException;
+import cn.dev33.satoken.stp.StpUtil;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.enums.UserType;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.DesensitizedUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.satoken.utils.LoginHelper;
+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.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.dromara.device.service.IDeviceBindService;
+import org.dromara.device.sms.AppSmsService;
+import org.dromara.system.api.AppUserApiService;
+import org.dromara.system.api.UserService;
+import org.dromara.system.api.domain.UserDTO;
+import org.springframework.stereotype.Service;
+
+/**
+ * APP 个人中心服务实现。
+ *
+ * 注销口径(迭代 I2 主理人裁定 + P3-4 补充):先经 {@link IDeviceBindService} 单一解绑入口解绑该用户下全部设备,
+ * 再停用账号,最后按 loginId 全端下线(清该账号名下全部会话,而非仅当前 token)。
+ *
+ * 注销链路非事务(P3-4 已知约束):三步为「解绑 → 停用 → 全端下线」,不置于同一事务——
+ * 依据《02-架构设计-一期》§10.4 规则 2,不得把 {@code IDeviceService.insertByBo()} 的取号链包裹进事务。
+ * 各步均为幂等设计,中途失败可安全重试(详见《05-交付说明-迭代I2》§4.4 与 §七):
+ *
+ * 场景自适应(本迭代新增):按当前账号是否已有手机号选择验证码场景——
+ *
+ * 先用「越权条件」定位待解绑设备({@code device_no + bind_user_id}),拿到 {@code device_id/imei} 供流水使用;
+ * 再以条件更新 {@code where device_id=? and bind_user_id=?} 真正解绑(带 {@code bind_user_id} 条件同时实现越权隔离与防并发)。
+ * 使用链式 {@code set(..., null)} 而非 {@code updateById},以确保 null 值能真正写入(避免被 MP 空值策略忽略)。
+ * 解绑保留 {@code device_no},仅回归「未激活」。
+ *
+ * @param deviceNo 设备编号
+ * @param userId APP 用户主键
+ * @return 实际影响行数(1 = 成功;0 = 未建档 / 属于他人 / 未绑定)
+ */
+ @Override
+ public int unbind(String deviceNo, Long userId) {
+ if (StringUtils.isBlank(deviceNo) || userId == null) {
+ return 0;
+ }
+ // 越权条件定位:仅当该编号设备确实绑定到 userId 时才继续(否则视为无可解绑,由上层转统一受控文案)
+ BmDevice device = deviceMapper.lambda()
+ .eq(BmDevice::getDeviceNo, deviceNo)
+ .eq(BmDevice::getBindUserId, userId)
+ .one();
+ if (device == null) {
+ return 0;
+ }
+ int rows = deviceMapper.lambda()
+ .set(BmDevice::getBindUserId, null)
+ .set(BmDevice::getBindTime, null)
+ .set(BmDevice::getStatus, DeviceStatusEnum.NOT_ACTIVATED.getCode())
+ .set(BmDevice::getUpdateTime, LocalDateTime.now())
+ .eq(BmDevice::getDeviceId, device.getDeviceId())
+ .eq(BmDevice::getBindUserId, userId)
+ .updateCount();
+ if (rows > 0) {
+ record(device.getDeviceId(), device.getDeviceNo(), userId, device.getImei(),
+ BindActionEnum.UNBIND.getAction());
+ }
+ return rows;
+ }
+
+ /**
+ * 解除某 APP 用户下全部设备的绑定。
+ *
+ * 复用 {@link #unbind(String, Long)} 单一实现(单台 = N 台的特例)。
+ *
+ * @param userId APP 用户主键
+ * @return 实际解绑的设备数
+ */
+ @Override
+ public int unbindAllByUser(Long userId) {
+ if (userId == null) {
+ return 0;
+ }
+ List
+ * 两条规则的差异:离线是双向硬拦(指令根本送不到,排队/补投只会造成「延迟的意外动作」);
+ * 低电只拦遮盖(见类注释「低电只拦遮盖」)。
+ *
+ * 低电阈值取该设备自己配置的 {@code hood_battery_threshold}(未配置回落工程默认),
+ * 与 {@code DeviceAlertWriter#applyLowBattery} 共用同一阈值口径 —— 刻意不拆成两套值,
+ * 否则会出现「已经判定低电并告警了,却仍能下发遮盖」的口径撕裂。
+ *
+ * @param device 目标设备
+ * @param deviceNo 设备编号(日志用)
+ * @param coverOn 是否遮盖
+ */
+ private void assertOperable(BmDevice device, String deviceNo, boolean coverOn) {
+ if (!DeviceStatusEnum.ONLINE.getCode().equals(device.getStatus())) {
+ log.info("设备非在线,拒绝下发 => deviceNo: {}, status: {}, coverOn: {}",
+ deviceNo, device.getStatus(), coverOn);
+ throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE);
+ }
+ Integer battery = device.getHoodBattery();
+ int trigger = properties.resolveTriggerThreshold(device.getHoodBatteryThreshold());
+ // trigger = 0 表示该设备关闭了低电保护(语义见 DeviceAlertWriter#applyLowBattery),
+ // 此时遮盖拦截必须同步失效 —— 否则拦截口径比告警口径更严,又成了两套真相。
+ if (coverOn && trigger > 0 && battery != null && battery <= trigger) {
+ // 头套即眼罩的供电体:电量过低时不发遮盖指令,避免设备动作一半掉电
+ log.info("头套低电,拒绝遮盖 => deviceNo: {}, hoodBattery: {}, threshold: {}",
+ deviceNo, battery, trigger);
+ throw new ServiceException(DeviceMsgConstants.LOW_BATTERY);
+ }
+ }
+
+ /**
+ * 等待回执,永不抛出、永不悬挂。
+ *
+ * @param future 登记时返回的 Future
+ * @param deviceNo 设备编号(日志用)
+ * @param msgId 指令标识(日志用)
+ * @return 回执报文;超时或中断返回 {@code null}
+ */
+ private AckPayload await(CompletableFuture
+ * 并发控制:以「按日」为粒度(key = {@code bm:device:no:lock:{yyyyMMdd}})加 Redisson {@link RLock},
+ * 使「取号 → 唯一性预检 → 插入(含提交)」整体落在**同一临界区**内串行执行。
+ * 之所以把 insert 也纳入临界区,是因为若仅在取号时加锁,线程 A 取号放锁后尚未提交时,
+ * 线程 B 读到的高水位仍不含 A 的行,会取到相同编号(QA 已复现该竞态)。
+ * 本方法**不加 `@Transactional`**:让 insert 以自动提交方式在锁释放前落库,
+ * 保证后继持锁线程能读到前一笔记录;否则提交发生在解锁之后,仍会重号。
+ *
+ * 兜底:即使临界区与自提交配合,跨实例时钟漂移、锁服务不可用或人为直连 DB 写入等
+ * 边界情形仍可能触发 DB 唯一键冲突;此处捕获 {@link DuplicateKeyException} 并转为受控
+ * {@link ServiceException},避免向调用方暴露裸的技术异常。
+ *
+ * @param bo 新增业务对象
+ * @return 是否新增成功
+ */
+ @Override
+ public Boolean insertByBo(DeviceBo bo) {
+ BmDevice add = MapstructUtils.convert(bo, BmDevice.class);
+ if (StringUtils.isBlank(add.getStatus())) {
+ add.setStatus(DeviceStatusEnum.NOT_ACTIVATED.getCode());
+ }
+ String datePart = LocalDate.now().format(DATE_FORMATTER);
+ RLock lock = RedisUtils.getClient().getLock(DEVICE_NO_LOCK_PREFIX + datePart);
+ lock.lock();
+ try {
+ // 临界区:取号 + 唯一性预检 + 插入
+ if (StringUtils.isBlank(add.getDeviceNo())) {
+ add.setDeviceNo(generateDeviceNoInternal(DEVICE_NO_PREFIX + datePart));
+ }
+ validEntityBeforeSave(add);
+ try {
+ boolean flag = deviceMapper.insert(add) > 0;
+ if (flag) {
+ bo.setDeviceId(add.getDeviceId());
+ bo.setDeviceNo(add.getDeviceNo());
+ }
+ return flag;
+ } catch (DuplicateKeyException e) {
+ throw new ServiceException(resolveDuplicateKeyMessage(e));
+ }
+ } finally {
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+
+ /**
+ * 修改设备(**不接受修改 {@code device_no}**)。
+ *
+ * 编号不可变:{@code device_no} 是业务主展示键,由建档时服务端自动生成,且被 APP 绑定关系
+ * 与运维话术引用;允许改号会产生「同一台设备两个编号」的口径混乱。因此:
+ *
+ * 存在性校验:{@code selectById} 受 {@code @TableLogic} 自动过滤,已逻辑删除的设备取不到,
+ * 此时抛 {@code DEVICE_NOT_EXIST},避免「编辑一条不存在的数据」静默返回成功。
+ *
+ * @param bo 编辑业务对象
+ * @return 是否修改成功
+ */
+ @Override
+ public Boolean updateByBo(DeviceBo bo) {
+ if (bo.getDeviceId() == null) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ BmDevice exist = deviceMapper.selectById(bo.getDeviceId());
+ if (exist == null) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ if (StringUtils.isNotBlank(bo.getDeviceNo()) && !bo.getDeviceNo().equals(exist.getDeviceNo())) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NO_IMMUTABLE);
+ }
+ BmDevice update = MapstructUtils.convert(bo, BmDevice.class);
+ // 兜底:无论入参传号与否(含空串),一律以库中现值回填,杜绝 NOT_NULL 策略把空串写库
+ update.setDeviceNo(exist.getDeviceNo());
+ validEntityBeforeSave(update);
+ return deviceMapper.updateById(update) > 0;
+ }
+
+ /**
+ * 生成设备业务编号,规则:BM + yyyyMMdd + 4 位当日序号(如 BM202609160001)。
+ *
+ * 本方法为「独立取号」入口,自带按日分布式锁(key = {@code bm:device:no:lock:{yyyyMMdd}});
+ * 建档路径({@link #insertByBo(DeviceBo)})为避免重复加锁,直接调用不加锁的内部方法
+ * {@link #generateDeviceNoInternal(String)},并由 {@code insertByBo} 自己的临界区统一保护。
+ *
+ * 取值依据:序号来自 {@link BmDeviceMapper#selectMaxDeviceNo(String)} 查询的「高水位」——
+ * 该查询为自定义 XML SQL,不附加逻辑删除条件,因此包含已逻辑删除的历史行;
+ * 新序号 = 高水位 + 1,严格大于表中任何既有编号(含已删除行)。
+ *
+ * 溢出行为:当日序号超过 {@value #MAX_SEQ}(4 位上限)时抛出
+ * {@link ServiceException},不扩位、不回绕。
+ *
+ * @return 设备业务编号
+ */
+ @Override
+ public String generateDeviceNo() {
+ String datePart = LocalDate.now().format(DATE_FORMATTER);
+ RLock lock = RedisUtils.getClient().getLock(DEVICE_NO_LOCK_PREFIX + datePart);
+ lock.lock();
+ try {
+ return generateDeviceNoInternal(DEVICE_NO_PREFIX + datePart);
+ } finally {
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+
+ /**
+ * 生成设备编号的内部实现(**不加锁**,须由调用方在临界区内调用)。
+ *
+ * @param prefix 设备编号前缀(如 BM20260916)
+ * @return 设备业务编号
+ */
+ private String generateDeviceNoInternal(String prefix) {
+ String maxDeviceNo = deviceMapper.selectMaxDeviceNo(prefix);
+ int seq = 1;
+ if (StringUtils.isNotBlank(maxDeviceNo) && maxDeviceNo.length() > prefix.length()) {
+ String seqStr = maxDeviceNo.substring(prefix.length());
+ try {
+ seq = Integer.parseInt(seqStr) + 1;
+ } catch (NumberFormatException ignored) {
+ // 历史数据存在非「4 位数字」后缀的异常编号时,退化为序号 1 起始(仍受临界区与 DB 唯一键保护)
+ seq = 1;
+ }
+ }
+ if (seq > MAX_SEQ) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NO_LIMIT);
+ }
+ return prefix + String.format("%04d", seq);
+ }
+
+ /**
+ * 保存前的唯一性校验(device_no / imei)。
+ *
+ * 口径与 DB 唯一键 {@code uk_device_no} / {@code uk_imei} 对齐:两者都**不区分逻辑删除行**,
+ * 因此这里改用自定义 XML 查询(忽略逻辑删除、包含已删除行)判断占用情况,
+ * 避免「已被逻辑删除的编号被判为可用、插入时才被 DB 拒绝」的错误路径。
+ *
+ * @param entity 设备实体
+ */
+ private void validEntityBeforeSave(BmDevice entity) {
+ Long deviceId = entity.getDeviceId();
+ if (StringUtils.isNotBlank(entity.getDeviceNo())) {
+ Long existId = deviceMapper.selectIdByDeviceNo(entity.getDeviceNo());
+ if (existId != null && !existId.equals(deviceId)) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NO_EXIST);
+ }
+ }
+ if (StringUtils.isNotBlank(entity.getImei())) {
+ Long existId = deviceMapper.selectIdByImei(entity.getImei());
+ if (existId != null && !existId.equals(deviceId)) {
+ throw new ServiceException(DeviceMsgConstants.IMEI_EXIST);
+ }
+ }
+ }
+
+ /**
+ * 将 DB 唯一键冲突异常转换为受控的业务提示文案。
+ *
+ * @param e 唯一键冲突异常
+ * @return 面向用户的提示文案
+ */
+ private String resolveDuplicateKeyMessage(DuplicateKeyException e) {
+ String rawMsg = e.getCause() == null ? e.getMessage() : e.getCause().getMessage();
+ if (rawMsg != null) {
+ if (rawMsg.contains("uk_device_no")) {
+ return DeviceMsgConstants.DEVICE_NO_EXIST;
+ }
+ if (rawMsg.contains("uk_imei")) {
+ return DeviceMsgConstants.IMEI_EXIST;
+ }
+ }
+ return DeviceMsgConstants.DEVICE_NO_OR_IMEI_EXIST;
+ }
+
+ /**
+ * 查询设备统计(顶部统计条)。
+ *
+ * 三条计数分别对应字典 {@code bm_device_status} 的三个取值,均受 MyBatis-Plus 逻辑删除自动过滤
+ * (只统计 {@code del_flag = '0'} 的行),因此 {@code online + offline + inactive == total} 恒成立。
+ *
+ * @return 设备统计
+ */
+ @Override
+ public DeviceStatsVo queryStats() {
+ DeviceStatsVo vo = new DeviceStatsVo();
+ vo.setTotal(deviceMapper.selectCount(Wrappers.
+ * 为什么先查「已绑定」再删:删除是逻辑删除、不可逆(对用户而言),
+ * 而已绑定设备的删除会让 APP 端出现「设备凭空消失」而仍占用物理 IMEI 的悬空态。
+ * 计划 §9.1 要求「已绑定需先解绑或二次确认」,本迭代取最保守语义——直接拒绝。
+ *
+ * 未加 {@code @Transactional}:单条逻辑删除语句自提交即可,无需事务(见架构 §10.4 规则 2)。
+ *
+ * @param ids 设备主键集合
+ * @return 是否删除成功
+ */
+ @Override
+ public Boolean deleteByIds(Collection
+ * 无复杂状态机,核心是「唯一性」与「默认值」两点:{@code (model, version)} 唯一、
+ * {@code status} 缺省为「停用」。唯一性采用「预检 + 兜底」双层——预检给出友好文案,
+ * DB 唯一键兜住预检与写入之间的并发窗口(以及同型号同版本已被逻辑删除的历史行)。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Service
+@RequiredArgsConstructor
+public class OtaPackageServiceImpl implements IOtaPackageService {
+
+ private final BmOtaPackageMapper otaPackageMapper;
+
+ /**
+ * 查询升级包详情。
+ *
+ * @param packageId 升级包主键
+ * @return 升级包视图对象;不存在返回 {@code null}
+ */
+ @Override
+ public OtaPackageVo queryById(Long packageId) {
+ return otaPackageMapper.selectVoById(packageId);
+ }
+
+ /**
+ * 分页查询升级包列表。
+ *
+ * @param bo 查询条件
+ * @param pageQuery 分页参数
+ * @return 分页结果
+ */
+ @Override
+ public PageResult
+ * 预检按 MyBatis-Plus 默认(仅 {@code del_flag = '0'})查询;对已被逻辑删除的同型号同版本行
+ * 预检查不到,但 DB 唯一键仍会拒绝,此时由调用侧捕获 {@link DuplicateKeyException} 归一。
+ *
+ * @param entity 待保存实体(新增时 {@code packageId} 为空;修改时用于排除自身)
+ */
+ private void validEntityBeforeSave(BmOtaPackage entity) {
+ if (StringUtils.isBlank(entity.getModel()) || StringUtils.isBlank(entity.getVersion())) {
+ throw new ServiceException(DeviceMsgConstants.OTA_PACKAGE_MODEL_VERSION_REQUIRED);
+ }
+ long conflict = otaPackageMapper.selectCount(Wrappers.
+ * 任务以「待升级」(0)入库且不加事务——即便随后的下发失败,任务记录也保留,
+ * 便于后台追溯「哪些设备收到了升级指令但未受理」。
+ *
+ * @param device 目标设备(已确认在线)
+ * @param pkg 升级包(已确认存在且启用)
+ * @param operatorId 操作人(后台用户主键,可能为 {@code null})
+ * @return 是否成功受理({@code ack.ok=true})
+ */
+ private boolean dispatch(BmDevice device, BmOtaPackage pkg, Long operatorId) {
+ LocalDateTime now = LocalDateTime.now();
+ BmOtaTask task = new BmOtaTask();
+ task.setPackageId(pkg.getPackageId());
+ task.setVersion(pkg.getVersion());
+ task.setDeviceId(device.getDeviceId());
+ task.setDeviceNo(device.getDeviceNo());
+ task.setStatus(OtaTaskStatusEnum.PENDING.getCode());
+ task.setProgress(0);
+ task.setOperatorId(operatorId);
+ task.setTimes(1);
+ task.setStartTime(now);
+ otaTaskMapper.insert(task);
+
+ String msgId = DeviceCommandPublisher.newMsgId();
+ OtaCommandPayload payload = OtaCommandPayload.of(msgId, pkg.getVersion(), pkg.getFileUrl(),
+ pkg.getMd5(), pkg.getFileSize(), pkg.getChangelog());
+ CompletableFuture
+ * 设计要点:
+ *
+ * 防定向 DoS(P2 加固):正确验证码永远可校验成功,失败仅作用于「错误尝试」,
+ * 绝不删除或失效验证码本身。因此攻击者即便知道受害者手机号、连续提交错误码,
+ * 也无法让受害者的验证码失效,更无法阻止受害者用收到的正确码通过。
+ *
+ * 暴力破解防护仍成立:错误尝试按「场景 + 手机号」做**短窗口限速**
+ * (窗口 {@link AppConstants#SMS_VERIFY_FAIL_WINDOW_SECONDS} 秒、窗口内最多
+ * {@link AppConstants#SMS_MAX_VERIFY_FAIL} 次),超限则短暂拒绝错误尝试,窗口到期自动自愈(Redis TTL)。
+ * 该限速不影响正确码:正确性判断在限速之前,故受害者不会被限速波及。
+ *
+ * @param phoneNumber 手机号
+ * @param scene 场景
+ * @param code 用户输入的验证码
+ */
+ public void verifyCode(String phoneNumber, String scene, String code) {
+ String codeKey = AppConstants.SMS_CODE_KEY_PREFIX + scene + ":" + phoneNumber;
+ 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.SMS_FAIL_KEY_PREFIX + scene + ":" + phoneNumber);
+ return;
+ }
+ // ② 错误码:仅对「错误尝试」做短窗口限速(绝不删除验证码)。
+ String failKey = AppConstants.SMS_FAIL_KEY_PREFIX + scene + ":" + phoneNumber;
+ 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);
+ }
+
+ /**
+ * 实际发送验证码:mock 模式打印日志,否则走 sms4j。
+ *
+ * @param phoneNumber 手机号
+ * @param code 验证码
+ */
+ private void send(String phoneNumber, String code) {
+ AppProperties.Sms sms = appProperties.getSms();
+ if (sms.isMock()) {
+ log.info("[APP短信-模拟发送] 手机号={},验证码={},有效期={}分钟(开发环境未接入短信通道,Q5)。",
+ phoneNumber, code, sms.getExpiryMinutes());
+ return;
+ }
+ try {
+ SmsBlend smsBlend = SmsFactory.getSmsBlend(sms.getBlend());
+ LinkedHashMap
+ * 需登录;仅能操作本人绑定设备。逻辑移植自 dm 的 ZLMediaKit 接入:
+ * 设备主动推流,禁止客户端自定义拉流源。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Slf4j
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/app/stream")
+public class AppStreamController extends BaseController {
+
+ private final IAppStreamService appStreamService;
+
+ /**
+ * 通知设备开始推流到 ZLMediaKit,并返回播放地址。
+ *
+ * @param bo 设备编号
+ * @return 播放信息
+ */
+ @PostMapping("/start")
+ @RateLimiter(key = "@streamRateLimitKey.control(#bo.deviceNo)", time = 60, count = 30,
+ limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT)
+ public R
+ * 全部 {@link SaIgnore},必须用 {@code zlm.secret} 校验来源,防止伪造
+ * {@code on_stream_none_reader} 关掉全部流。secret 未配置时一律拒绝。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Slf4j
+@RestController
+@SaIgnore
+@RequestMapping("/zlm/hook")
+@RequiredArgsConstructor
+public class ZlmHookController {
+
+ private final IAppStreamService appStreamService;
+ private final ZlmPlayAuthService playAuthService;
+ private final ZlmProperties zlmProperties;
+
+ /**
+ * 推流鉴权:secret 通过即允许(设备由 MQTT 受控推流,不开放任意 URL 拉流)。
+ *
+ * @param request HTTP 请求
+ * @param body ZLM 回调体
+ * @return ZLM 约定 {@code code=0} 允许
+ */
+ @PostMapping("/on_publish")
+ public Map
+ * 本实现为迭代 I2(边界方案 A)的纯新增:
+ *
+ * 防「永久无法登录」加固(P2-2-1):{@code sys_user.phone_number} 在框架表中为
+ * 非唯一索引({@code ry_vue.sql:106} 的 {@code idx_sys_user_phone}),并发注册或历史脏数据
+ * 可能造成同一手机号存在多条 app_user。若此处用 {@code one()},MyBatis-Plus 在命中多行时会抛
+ * {@code TooManyResultsException},将导致该手机号永久无法登录(且不可自愈)。
+ * 因此改用 {@code list()} + {@code orderByAsc(userId)} 取首条:
+ *
+ * user_name 形式唯一保证(P3-7):先经框架 {@link IdGeneratorUtil#nextLongId()} 预分配 user_id
+ * (与 MyBatis-Plus 主键生成器同源,见 {@code MybatisPlusConfig#idGenerator}),
+ * 令 {@code user_name = "app_" + user_id} 由全局唯一的主键确定性派生,
+ * 从而获得形式唯一保证(不依赖概率、不依赖重名检测):
+ *
+ * 与 {@link #selectByPhone(String)} 同口径:{@code sys_user.email} 在框架基线中无索引、
+ * 且在补丁 2 之前允许重复,故同样用 {@code list()} + {@code orderByAsc(userId)} 取首条,
+ * 避免 {@code TooManyResultsException} 造成「该邮箱永久无法登录」。
+ *
+ * @param email 邮箱地址(已归一化)
+ * @return APP 用户信息(不含密码);不存在时返回 {@code null}
+ */
+ @Override
+ public AppUserDTO selectByEmail(String email) {
+ if (StringUtils.isBlank(email)) {
+ return null;
+ }
+ List
+ * 为什么不能写 phoneNumber:{@code sys_user.phone_number} 上有唯一索引
+ * {@code uk_sys_user_phone_number},而 MySQL 唯一索引允许多个 NULL、不允许多个 ''。
+ * 本方法刻意不 set 手机号——MyBatis-Plus 全局 {@code insertStrategy: NOT_NULL} 会把该列
+ * 整个从 INSERT 中省略,因此实际落库值是列默认值;该默认值已由
+ * {@code script/sql/bm_patch.sql} 补丁 3 改为 NULL,从而多个邮箱账号可共存。
+ *
+ * user_name 口径与 {@link #registerByPhone(String, String, String)} 完全一致:
+ * 由预分配的雪花 {@code user_id} 确定性派生 {@code "app_" + userId},获得形式唯一保证。
+ *
+ * @param email 邮箱地址(已归一化)
+ * @param rawPassword 明文密码
+ * @param nickName 昵称(为空时默认取邮箱)
+ * @return 新建用户主键
+ */
+ @Override
+ public Long registerByEmail(String email, String rawPassword, String nickName) {
+ Long userId = IdGeneratorUtil.nextLongId();
+ SysUser user = new SysUser();
+ user.setUserId(userId);
+ user.setUserName(USER_NAME_PREFIX + userId);
+ user.setNickName(StringUtils.isBlank(nickName) ? email : nickName);
+ user.setEmail(email);
+ user.setUserType(UserType.APP_USER.getUserType());
+ user.setPassword(BCrypt.hashpw(rawPassword));
+ user.setStatus(SystemConstants.NORMAL);
+ user.setCreateBy(0L);
+ user.setUpdateBy(0L);
+ userMapper.insert(user);
+ return user.getUserId();
+ }
+
+ /**
+ * 重置用户密码(忘记密码场景);新密码在本方法内部散列后落库。
+ *
+ * 限定 {@code user_type = app_user}(2026-09-17 加固):本方法原先只按 {@code userId} 更新,
+ * 若被后台管理端以任意主键调用,即可重置后台账号密码——属越权写入。
+ * 现补上类型条件,使「重置密码」这一高危动作的爆炸半径限定在 APP 用户内。
+ *
+ * 对既有调用方无行为影响:三处调用(手机号找回 / 邮箱找回 / APP 内改密)的 {@code userId}
+ * 均取自 {@code selectByPhone} / {@code selectByEmail} / 登录态,本就恒为 app_user。
+ *
+ * @param userId 用户主键
+ * @param rawPassword 新明文密码
+ * @return true 修改成功
+ */
+ @Override
+ public boolean resetPassword(Long userId, String rawPassword) {
+ if (userId == null || StringUtils.isBlank(rawPassword)) {
+ return false;
+ }
+ return userMapper.lambda()
+ .set(SysUser::getPassword, BCrypt.hashpw(rawPassword))
+ .eq(SysUser::getUserId, userId)
+ .eq(SysUser::getUserType, UserType.APP_USER.getUserType())
+ .updateCount() > 0;
+ }
+
+ /**
+ * 换绑手机号。
+ *
+ * @param userId 用户主键
+ * @param newPhoneNumber 新手机号
+ * @return true 修改成功
+ */
+ @Override
+ public boolean updatePhone(Long userId, String newPhoneNumber) {
+ if (userId == null || StringUtils.isBlank(newPhoneNumber)) {
+ return false;
+ }
+ return userMapper.lambda()
+ .set(SysUser::getPhoneNumber, newPhoneNumber)
+ .eq(SysUser::getUserId, userId)
+ .updateCount() > 0;
+ }
+
+ /**
+ * 停用账号(注销前置:解绑设备后停用)。
+ *
+ * @param userId 用户主键
+ * @return true 停用成功
+ */
+ @Override
+ public boolean disableUser(Long userId) {
+ if (userId == null) {
+ return false;
+ }
+ return userMapper.lambda()
+ .set(SysUser::getStatus, SystemConstants.DISABLE)
+ .eq(SysUser::getUserId, userId)
+ .updateCount() > 0;
+ }
+
+ /**
+ * 判断账号是否已绑定手机号。
+ *
+ * 只取 {@code phone_number} 一列,避免无谓地把整行(含密码散列)读出来。
+ * 口径为 {@code isNotBlank}:兼容历史上可能存在的 {@code ''} 空串(补丁 3 之后新数据为 NULL)。
+ *
+ * @param userId 用户主键
+ * @return true 已绑定非空手机号;false 未绑定或用户不存在
+ */
+ @Override
+ public boolean isPhoneBound(Long userId) {
+ if (userId == null) {
+ return false;
+ }
+ SysUser user = userMapper.lambda()
+ .select(SysUser::getPhoneNumber)
+ .eq(SysUser::getUserId, userId)
+ .one();
+ return user != null && StringUtils.isNotBlank(user.getPhoneNumber());
+ }
+
+ // ---------------- 后台管理端(APP 用户管理) ----------------
+
+ /**
+ * 分页查询 APP 用户列表(后台「APP 用户管理」用)。
+ *
+ * 强制 {@code user_type = app_user}:该条件由本方法固定附加,
+ * {@link AppUserQueryDTO} 中也没有对应字段可覆盖,因此结果集恒不含后台账号。
+ *
+ * 为什么刻意不加 {@code @DataPermission}:APP 账号注册时不写 {@code dept_id}
+ * (见 {@link #registerByPhone}/{@link #registerByEmail}),该列恒为 {@code NULL};
+ * 若叠加框架按部门维度的数据权限过滤,会把全部 APP 用户过滤成空集。
+ * APP 用户本就游离于后台组织架构之外,故此处按「业务归属正确」优先。
+ *
+ * 排序:按注册时间倒序(新注册的排前面),与后台「看最新」的使用习惯一致。
+ *
+ * @param query 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
+ * @return APP 用户分页结果(不含密码)
+ */
+ @Override
+ public PageResult
+ * 强制 {@code user_type = app_user}:传入后台账号主键时返回 {@code null},
+ * 使管理端无法借该接口读到系统账号信息(越界读取防护)。
+ *
+ * @param userId 用户主键
+ * @return APP 用户信息(不含密码);不存在或非 app_user 时返回 {@code null}
+ */
+ @Override
+ public AppUserDTO selectAppUserById(Long userId) {
+ if (userId == null) {
+ return null;
+ }
+ SysUser user = userMapper.lambda()
+ .eq(SysUser::getUserId, userId)
+ .eq(SysUser::getUserType, UserType.APP_USER.getUserType())
+ .one();
+ return user == null ? null : BeanUtil.copyProperties(user, AppUserDTO.class);
+ }
+
+ /**
+ * 修改 APP 用户账号状态(停用 / 启用)。
+ *
+ * 为什么同时限定 {@code user_type}:本方法来自后台管理端(有登录态),
+ * 若不限定类型,构造任意 {@code userId} 即可停用后台账号——属于典型的越权写入。
+ * 加上该条件后,「非 APP 账号」的 update 影响行数为 0,天然被拒。
+ *
+ * 返回 {@code true} 表示「目标状态已生效」,包含「原本就是该状态」的情形
+ * ({@code updateCount} 为 0 但语义上无需变更),便于前端幂等重试。
+ *
+ * @param userId 用户主键
+ * @param status 目标状态({@code 0} 正常 / {@code 1} 停用)
+ * @return true 目标状态已生效;false 目标用户不存在或非 APP 账号
+ */
+ @Override
+ public boolean updateStatus(Long userId, String status) {
+ if (userId == null || StringUtils.isBlank(status)) {
+ return false;
+ }
+ // 先确认目标确为 APP 账号,避免「影响行数 0」被误判为「已是目标状态」
+ SysUser exist = userMapper.lambda()
+ .select(SysUser::getUserId, SysUser::getStatus)
+ .eq(SysUser::getUserId, userId)
+ .eq(SysUser::getUserType, UserType.APP_USER.getUserType())
+ .one();
+ if (exist == null) {
+ return false;
+ }
+ if (Objects.equals(exist.getStatus(), status)) {
+ return true;
+ }
+ return userMapper.lambda()
+ .set(SysUser::getStatus, status)
+ .eq(SysUser::getUserId, userId)
+ .eq(SysUser::getUserType, UserType.APP_USER.getUserType())
+ .updateCount() > 0;
+ }
+
+}
diff --git a/script/mqtt/.gitignore b/script/mqtt/.gitignore
new file mode 100644
index 0000000..c2658d7
--- /dev/null
+++ b/script/mqtt/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/script/mqtt/mqtt-simulate.js b/script/mqtt/mqtt-simulate.js
new file mode 100644
index 0000000..e178aae
--- /dev/null
+++ b/script/mqtt/mqtt-simulate.js
@@ -0,0 +1,639 @@
+#!/usr/bin/env node
+/**
+ * mqtt-simulate.js —— 设备侧模拟器(联调用,无需硬件)
+ *
+ * 把《设备通信协议-一期.md》附录 A 的手工 mosquitto 命令固化成一个可重复执行的进程,
+ * 让**下行闭环(§8.2)**与**前置拦截(§8.3)**在真机到位之前就能端到端验证。
+ *
+ * 它模拟的是「设备侧」行为,因此刻意做两件服务端做不到的事:
+ * 1. 收到 `command/hood` 后按需回执(或不回)——没有它,「只有 ack 才算成功」这条不变量无法被验证;
+ * 2. 自己维护本地 `hoodCoverOn`,ack 成功后才切换——用真机语义约束模拟器,避免模拟器比真机更宽松。
+ *
+ * 依赖:node >= 18 + mqtt 包(在本目录执行 `npm install`)
+ *
+ * 用法:
+ * node mqtt-simulate.js --device-no BM202609160001
+ * node mqtt-simulate.js --device-no BM202609160001 --scenario lowbat
+ * node mqtt-simulate.js --device-no BM202609160001 --reply none # 用例 5:5s 超时
+ * node mqtt-simulate.js --device-no BM202609160001 --reply reject # 失败分支
+ * node mqtt-simulate.js --scenario register --imei 861234567890123 # §6.2 注册
+ * node mqtt-simulate.js --dry-run # 只看报文,不连 broker
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+'use strict';
+
+const mqtt = require('mqtt');
+
+/** 设备编号格式:BM + 8 位日期 + 4 位序号(与 MqttTopics.DEVICE_NO_REGEX 同一口径) */
+const DEVICE_NO_REGEX = /^BM\d{12}$/;
+
+/** IMEI 格式:15 位数字(与 MqttTopics.IMEI_REGEX 同一口径) */
+const IMEI_REGEX = /^\d{15}$/;
+
+// 凭据不落明文:通过环境变量注入(export MQTT_BROKER / MQTT_USERNAME / MQTT_PASSWORD)
+const DEFAULTS = {
+ broker: process.env.MQTT_BROKER || 'mqtt://47.97.217.123:1883',
+ username: process.env.MQTT_USERNAME || '',
+ password: process.env.MQTT_PASSWORD || '',
+ scenario: 'online',
+ reply: 'auto',
+ interval: 60000,
+ count: 0,
+ deviceNo: '',
+ imei: '',
+ hoodCover: 0,
+ offlineAfter: 5000,
+ otaStepMs: 2000,
+ dryRun: false,
+};
+
+const USAGE = `
+设备侧 MQTT 模拟器
+
+必填(register 场景为 --imei):
+ --device-no 密码 默认 ${DEFAULTS.password}
+ --scenario
+ *
+ *
+ * @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());
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppProperties.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppProperties.java
new file mode 100644
index 0000000..1156ab9
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppProperties.java
@@ -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 端)配置项。
+ *
+ *
+ * 采用框架惯用的 {@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)将因此拒绝启动。");
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/DeviceMqttProperties.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/DeviceMqttProperties.java
new file mode 100644
index 0000000..0c52742
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/DeviceMqttProperties.java
@@ -0,0 +1,147 @@
+package org.dromara.device.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * 设备 MQTT 运行时配置(前缀 {@code device.mqtt})。
+ *
+ *
+ */
+ private int lowBatteryRecoverThreshold = 25;
+
+ /**
+ * 低电滞回闩锁的存活时长。
+ *
+ *
+ *
+ *
+ * > devices(@PathVariable("userId") Long userId) {
+ return R.ok(appUserAdminService.listDevices(userId));
+ }
+
+ /**
+ * 停用 / 启用账号。
+ *
+ * @param bo 状态变更入参(userId + status)
+ * @return 成功文案
+ */
+ @SaCheckPermission("device:appuser:edit")
+ @Log(title = "APP用户管理", businessType = BusinessType.UPDATE)
+ @PutMapping("/status")
+ public R
+ *
+ * OTA 相关接口见 {@link org.dromara.device.controller.OtaController}({@code /device/ota/**},I5)。
+ * 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log}。
+ *
+ *
+ * 后台接口一律 {@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
+ *
+ * 迭代 I4 增补:
+ *
+ *
+ * 开始/停止推流见 {@code /app/stream/**}。
+ * 迭代 I5 增补:
+ *
+ *
+ *
+ * @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() {
+ return R.ok(appDeviceService.listByUser(currentUserId()));
+ }
+
+ /**
+ * 设备详情(坐标、眼罩、双电量、信号、头套连接、地址)。
+ *
+ * @param deviceNo 设备编号
+ * @return 设备详情
+ */
+ @GetMapping("/{deviceNo}")
+ public R
+ *
+ *
+ * @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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceHoodBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceHoodBo.java
new file mode 100644
index 0000000..f2bba06
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppDeviceHoodBo.java
@@ -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})。
+ *
+ * 坐标:lng, lat, address
+ * 眼罩:hoodCoverOn
+ * 双电量:hoodBattery, ringBattery
+ * 信号:signalLevel, signalText
+ * 头套连接:hoodConnected, hoodConnectedText
+ * 身份:deviceNo, name, imei(脱敏), model, status
+ *
+ * 说明:{@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppDeviceListVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppDeviceListVo.java
new file mode 100644
index 0000000..9f1c345
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppDeviceListVo.java
@@ -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})。
+ *
+ * deviceNo, name, imei(脱敏), model,
+ * status, signalLevel, signalText, // 强 / 一般 / 弱 / 离线(字典 4 值)
+ * hoodConnected, hoodConnectedText, // 头套已连接 / 已断开
+ * hoodBattery, ringBattery,
+ * lng, lat, address
+ *
+ * 说明:{@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;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppHoodResultVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppHoodResultVo.java
new file mode 100644
index 0000000..fa3608b
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppHoodResultVo.java
@@ -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} 的响应体)。
+ *
+ *
+ * {@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
+ *
+ *
+ * @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);
+ }
+
+ /**
+ * 校验邮箱验证码(一次性使用,校验通过后立即失效)。
+ *
+ *
+ * {@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
+ *
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface DeviceUplinkHandler {
+
+ /**
+ * 处理遥测上报。
+ *
+ *
+ *
+ * @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。
+ *
+ *
+ *
+ * @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;
+
+ /**
+ * 处理遥测上报。
+ *
+ *
+ * 为什么必须先落库再唤醒:唤醒后 HTTP 线程会立刻把「成功」返回给 APP,
+ * 用户紧接着刷新详情页。若落库排在唤醒之后,这次刷新有概率读到旧值——
+ * 一个只在快网络下偶发、却极容易被当成「指令没生效」的竞态。
+ *
+ *
+ * 一期整包下载,不做分片与断点续传。
+ *
+ * @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";
+
+ /**
+ * 指令唯一标识,用于回执关联与设备侧幂等
+ */
+ 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.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;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/ProvisionAckPayload.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/ProvisionAckPayload.java
new file mode 100644
index 0000000..59fa1dd
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/payload/ProvisionAckPayload.java
@@ -0,0 +1,90 @@
+package org.dromara.device.mqtt.payload;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 设备注册应答报文(下行 {@code bm/provision/ack/{imei}},QoS 1)。
+ *
+ *
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Data
+public class TelemetryPayload implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 设备本地时间(epoch 毫秒)
+ */
+ private Long ts;
+
+ /**
+ * 头套电量 0-100
+ */
+ private Integer hoodBattery;
+
+ /**
+ * 脚环电量 0-100
+ */
+ private Integer ringBattery;
+
+ /**
+ * 信号等级 0 离线 1 弱 2 一般 3 强
+ */
+ private Integer signalLevel;
+
+ /**
+ * 头套连接 0 已断开 1 已连接(报文用数字,入库转 String '0'/'1')
+ */
+ private Integer hoodConnected;
+
+ /**
+ * 眼罩状态 0 打开 1 遮盖(报文用数字,入库转 String '0'/'1')
+ */
+ private Integer hoodCoverOn;
+
+ /**
+ * 经度 -180~180,小数位 ≤ 6
+ */
+ private BigDecimal lng;
+
+ /**
+ * 纬度 -90~90,小数位 ≤ 6
+ */
+ private BigDecimal lat;
+
+ /**
+ * 逆地理结果,≤ 64 字;解析失败可为空
+ */
+ private String address;
+
+ /**
+ * 校验报文是否满足落库条件。
+ *
+ * @return 校验通过返回 {@code true}
+ */
+ public boolean isValid() {
+ return invalidReason() == null;
+ }
+
+ /**
+ * 获取校验失败原因,用于日志定位。
+ *
+ * @return 失败原因;校验通过返回 {@code null}
+ */
+ public String invalidReason() {
+ if (hoodBattery == null || ringBattery == null || signalLevel == null
+ || hoodConnected == null || hoodCoverOn == null) {
+ return "必填状态字段缺失(hoodBattery/ringBattery/signalLevel/hoodConnected/hoodCoverOn)";
+ }
+ if (hoodBattery < 0 || hoodBattery > 100 || ringBattery < 0 || ringBattery > 100) {
+ return "电量越界";
+ }
+ if (signalLevel < 0 || signalLevel > 3) {
+ return "signalLevel 越界";
+ }
+ if (!isBinary(hoodConnected) || !isBinary(hoodCoverOn)) {
+ return "hoodConnected/hoodCoverOn 必须为 0 或 1";
+ }
+ if (lng != null && (lng.doubleValue() < -180D || lng.doubleValue() > 180D)) {
+ return "经度越界";
+ }
+ if (lat != null && (lat.doubleValue() < -90D || lat.doubleValue() > 90D)) {
+ return "纬度越界";
+ }
+ return null;
+ }
+
+ /**
+ * 判断取值是否为 0/1 二值。
+ *
+ * @param value 取值
+ * @return 是否合法
+ */
+ private static boolean isBinary(Integer value) {
+ return value != null && (value == 0 || value == 1);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DeviceAlertWriter.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DeviceAlertWriter.java
new file mode 100644
index 0000000..ddf54f3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/support/DeviceAlertWriter.java
@@ -0,0 +1,221 @@
+package org.dromara.device.mqtt.support;
+
+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.constant.DeviceMsgConstants;
+import org.dromara.device.domain.BmDeviceAlert;
+import org.dromara.device.enums.AlertLevelEnum;
+import org.dromara.device.enums.AlertTypeEnum;
+import org.dromara.device.mapper.BmDeviceAlertMapper;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+/**
+ * 设备告警写入器:{@code bm_device_alert} 的唯一写入入口。
+ *
+ *
+ *
+ *
+ * 为什么归属判定必须排在最前:否则「离线设备」的报错会先于「设备不存在」出现,
+ * 攻击者可用枚举 {@code deviceNo} 的方式,靠返回文案差异推断哪些编号真实存在。
+ *
+ * @param bo 眼罩入参(deviceNo + coverOn)
+ * @param userId 当前用户主键
+ * @return 设备确认执行后的眼罩状态
+ */
+ AppHoodResultVo hood(AppDeviceHoodBo bo, Long userId);
+
+ /**
+ * 查询当前用户某台设备的定位「最新点」({@code GET /app/device/{deviceNo}/location})。
+ *
+ *
+ *
+ *
+ * {@link #unbindAllByUser(Long)} 复用本方法(单台 = N 台的特例),保证解绑逻辑只有一处实现。
+ *
+ * @param deviceNo 设备编号
+ * @param userId APP 用户主键
+ * @return 实际影响行数(1 = 成功解绑;0 = 未建档 / 属于他人 / 未绑定,均视为无可解绑)
+ */
+ int unbind(String deviceNo, Long userId);
+
+ /**
+ * 解除某 APP 用户下全部设备的绑定。
+ *
+ *
+ * 后置:回执成功时设备主表的 {@code hood_cover_on} 已在本方法返回前由回执处理器写入
+ * (见 {@code DefaultDeviceUplinkHandler#onAck} 的先落库后唤醒约定),因此调用方可放心据此刷新界面。
+ *
+ * @param device 目标设备(调用方已完成归属校验)
+ * @param coverOn 是否遮盖:{@code true} 遮盖 / {@code false} 打开
+ * @return 设备确认执行后的眼罩状态
+ * @throws org.dromara.common.core.exception.ServiceException 前置拦截不通过,或指令未被设备确认执行
+ */
+ HoodCoverEnum sendHood(BmDevice device, boolean coverOn);
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceService.java
new file mode 100644
index 0000000..01a4145
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceService.java
@@ -0,0 +1,104 @@
+package org.dromara.device.service;
+
+import org.dromara.common.core.domain.PageResult;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.device.domain.bo.DeviceBo;
+import org.dromara.device.domain.vo.DeviceStatsVo;
+import org.dromara.device.domain.vo.DeviceVo;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 后台设备服务接口
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+public interface IDeviceService {
+
+ /**
+ * 查询设备详情
+ *
+ * @param deviceId 设备主键
+ * @return 设备视图对象
+ */
+ DeviceVo queryById(Long deviceId);
+
+ /**
+ * 分页查询设备列表
+ *
+ * @param bo 查询条件
+ * @param pageQuery 分页参数
+ * @return 分页结果
+ */
+ PageResult
+ *
+ * 判定优先级见上述顺序(业务跳过规则先于离线判定)。
+ *
+ *
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Service
+@RequiredArgsConstructor
+public class AppAuthServiceImpl implements IAppAuthService {
+
+ private final AppUserApiService appUserApiService;
+ private final AppSmsService appSmsService;
+ private final AppEmailService appEmailService;
+ private final AppProperties appProperties;
+
+ /**
+ * 发送短信验证码。
+ *
+ * @param bo 验证码入参(手机号 + 场景)
+ * @return mock 模式下返回验证码,真实短信模式返回 {@code null}
+ */
+ @Override
+ public String sendSmsCode(AppSmsCodeBo bo) {
+ return appSmsService.sendCode(bo.getPhoneNumber(), bo.getScene());
+ }
+
+ /**
+ * 手机号注册:先判重,再校验验证码,最后落库。
+ * 一、越权隔离(本实现的核心约束)
+ * 每一条「按编号定位设备」的读或写,SQL 层面都强制附加 {@code bind_user_id = 当前 userId} 条件
+ * ({@code list} 用 {@code eq(bindUserId, userId)};{@code detail} 用 {@code eq(deviceNo).eq(bindUserId)};
+ * {@code rename/unbind} 用条件更新 {@code where device_no=? and bind_user_id=?})。
+ * 因此三类情形 —— 设备未建档 / 设备属于他人 / 设备未绑定 —— 都会命中「0 行」,统一抛出
+ * {@link DeviceMsgConstants#DEVICE_NOT_EXIST},调用方无法据此枚举设备是否存在或归属他人,杜绝水平越权与信息泄露。
+ *
+ * 二、绑定防并发双绑(无 check-then-write)
+ * 绑定不采用「先查再写」(存在 TOCTOU 竞争窗口)。改为条件更新 + 影响行数判定:
+ * {@code update bm_device set bind_user_id=?, name=?, status=1, bind_time=now where device_id=? and bind_user_id is null}。
+ * 并发下由数据库行锁保证至多一笔 {@code updateCount()==1};其余得 0 行即「已被(他人)绑定」,
+ * 且不写任何 bind 流水(失败路径绝不产生成功流水)。
+ *
+ * 三、解绑单一入口复用
+ * 单台解绑委托给 {@link IDeviceBindService#unbind(String, Long)};注销场景的
+ * {@link IDeviceBindService#unbindAllByUser(Long)} 亦复用同一方法(单台 = N 台的特例),
+ * 避免解绑逻辑散落多处。
+ *
+ * 四、无外层事务
+ * 依据《02-架构设计-一期》§10.4 规则 2,本类各方法不加 {@code @Transactional}:
+ * 与 I1 取号口径一致,让条件更新/流水插入以自动提交方式尽快落库;失败路径通过「不写流水 + 影响行数判定」
+ * 保证一致性,而非依赖事务回滚。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Service
+@RequiredArgsConstructor
+public class AppDeviceServiceImpl implements IAppDeviceService {
+
+ /**
+ * IMEI 脱敏:前 4 位 + 固定 4 个星号 + 后 4 位(委托 {@link DesensitizedUtils#mask})。
+ */
+ private static final int IMEI_MASK_PREFIX_VISIBLE = 4;
+
+ /**
+ * IMEI 脱敏:可见后缀位数。
+ */
+ private static final int IMEI_MASK_SUFFIX_VISIBLE = 4;
+
+ /**
+ * IMEI 脱敏:中间星号固定长度。
+ */
+ private static final int IMEI_MASK_LENGTH = 4;
+
+ private final BmDeviceMapper deviceMapper;
+ private final IDeviceBindService deviceBindService;
+ private final IDeviceCommandService deviceCommandService;
+ private final AppUserApiService appUserApiService;
+
+ /**
+ * 查询当前用户绑定的设备列表(严格以 {@code bind_user_id = userId} 过滤)。
+ *
+ * @param userId 当前用户主键
+ * @return 设备卡片列表(按绑定时间倒序;无绑定时为空列表)
+ */
+ @Override
+ public List
+ *
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Service
+@RequiredArgsConstructor
+public class AppUserServiceImpl implements IAppUserService {
+
+ private final AppUserApiService appUserApiService;
+ private final UserService userService;
+ private final IDeviceBindService deviceBindService;
+ private final AppSmsService appSmsService;
+ private final AppProperties appProperties;
+
+ /**
+ * 查询当前登录用户资料(手机号与邮箱均脱敏)。
+ *
+ * @return 个人资料
+ */
+ @Override
+ public AppUserProfileVo profile() {
+ Long userId = currentUserId();
+ UserDTO user = userService.selectById(userId);
+ if (user == null) {
+ throw new ServiceException(AppMsgConstants.NOT_LOGIN);
+ }
+ AppUserProfileVo vo = new AppUserProfileVo();
+ vo.setUserName(user.getUserName());
+ vo.setNickName(user.getNickName());
+ vo.setPhoneNumber(DesensitizedUtils.mobilePhone(user.getPhoneNumber()));
+ vo.setEmail(DesensitizedUtils.email(user.getEmail()));
+ // 账号规则:邮箱注册的账号注册后仍须绑定手机号;未绑定时 APP 需据此强制引导,
+ // 且服务端已禁止未绑手机号的账号绑定设备(见 IAppDeviceService#bind)。
+ vo.setPhoneBound(StringUtils.isNotBlank(user.getPhoneNumber()));
+ vo.setUserType(user.getUserType());
+ vo.setCreateTime(user.getCreateTime());
+ return vo;
+ }
+
+ /**
+ * 修改密码:先校验旧密码,再校验新旧不同,最后落库。
+ *
+ * @param bo 改密入参
+ */
+ @Override
+ public void updatePassword(AppPasswordBo bo) {
+ Long userId = currentUserId();
+ if (!appUserApiService.matchesPassword(userId, bo.getOldPassword())) {
+ throw new ServiceException(AppMsgConstants.OLD_PASSWORD_ERROR);
+ }
+ if (appUserApiService.matchesPassword(userId, bo.getNewPassword())) {
+ throw new ServiceException(AppMsgConstants.NEW_PASSWORD_SAME);
+ }
+ appUserApiService.resetPassword(userId, bo.getNewPassword());
+ }
+
+ /**
+ * 绑定 / 换绑手机号:先拦截「新号==当前号」,再校验新号未被占用,再校验新号收到的验证码,最后落库。
+ *
+ *
+ * 两个场景的验证码缓存 key 互相隔离,故 APP 必须按账号当前状态请求对应场景的验证码,
+ * 用错场景会得到「验证码已过期」。这样划分是为了让「首次绑定」与「更换手机号」在审计与文案上可区分。
+ *
+ * @param bo 换绑入参
+ */
+ @Override
+ public void rebindPhone(AppPhoneBo bo) {
+ Long userId = currentUserId();
+ UserDTO current = userService.selectById(userId);
+ if (current == null) {
+ throw new ServiceException(AppMsgConstants.NOT_LOGIN);
+ }
+ boolean firstBind = StringUtils.isBlank(current.getPhoneNumber());
+ // P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」)
+ // 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。
+ if (!firstBind && StringUtils.equals(current.getPhoneNumber(), bo.getNewPhoneNumber())) {
+ throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT);
+ }
+ if (appUserApiService.existsByPhone(bo.getNewPhoneNumber())) {
+ throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
+ }
+ appSmsService.verifyCode(bo.getNewPhoneNumber(),
+ firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
+ appUserApiService.updatePhone(userId, bo.getNewPhoneNumber());
+ }
+
+ /**
+ * 注销账号:解绑全部设备 → 停用账号 → 按 loginId 全端下线。
+ */
+ @Override
+ public void cancelAccount() {
+ Long userId = currentUserId();
+ // 1. 先解绑其下全部设备(单一解绑入口)
+ deviceBindService.unbindAllByUser(userId);
+ // 2. 再停用账号(逻辑停用,不物理删除,保留审计与流水)
+ appUserApiService.disableUser(userId);
+ // 3. 清除会话 —— 必须「按 loginId 全端下线」,而非「按当前 token 清」。
+ // loginId = userType + ":" + userId(见 LoginUser#getLoginId),APP 登录标识恒为 app_user:{userId};
+ // StpUtil.logout(loginId) 一次调用即注销该账号名下「所有设备/所有会话」的 token。
+ // 反例:若用 StpUtil.logout()(仅清当前 token),其他设备上仍在有效期(默认 7 天)内的 token 会继续可用,
+ // 与该账号「已注销/已停用」语义冲突,故采用全端下线。停用(status=停用)另在登录入口兜底拦截再登录。
+ String loginId = UserType.APP_USER.getUserType() + ":" + userId;
+ try {
+ StpUtil.logout(loginId);
+ } catch (NotLoginException ignored) {
+ // 幂等:账号当前无有效会话时视为成功
+ }
+ }
+
+ /**
+ * 查询协议内容(URL + 版本 + 更新时间)。
+ *
+ * @param type 协议类型:user(用户协议)/ privacy(隐私政策)
+ * @return 协议内容
+ */
+ @Override
+ public AppAgreementVo agreements(String type) {
+ if (StringUtils.isBlank(type)) {
+ throw new ServiceException(AppMsgConstants.AGREEMENT_TYPE_INVALID);
+ }
+ AppProperties.Agreement agreement = appProperties.getAgreement();
+ AppProperties.Agreement.Item item;
+ String normalizedType;
+ if (StringUtils.equals(AppConstants.AGREEMENT_USER, type)) {
+ item = agreement.getUserAgreement();
+ normalizedType = AppConstants.AGREEMENT_USER;
+ } else if (StringUtils.equals(AppConstants.AGREEMENT_PRIVACY, type)) {
+ item = agreement.getPrivacyPolicy();
+ normalizedType = AppConstants.AGREEMENT_PRIVACY;
+ } else {
+ throw new ServiceException(AppMsgConstants.AGREEMENT_TYPE_INVALID);
+ }
+ AppAgreementVo vo = new AppAgreementVo();
+ vo.setType(normalizedType);
+ vo.setUrl(item.getUrl());
+ vo.setVersion(item.getVersion());
+ vo.setUpdateTime(item.getUpdateTime());
+ return vo;
+ }
+
+ /**
+ * 获取当前登录用户主键,未登录时抛出受控异常。
+ *
+ * @return 当前用户主键
+ */
+ private Long currentUserId() {
+ Long userId = LoginHelper.getUserId();
+ if (userId == null) {
+ throw new ServiceException(AppMsgConstants.NOT_LOGIN);
+ }
+ return userId;
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceBindServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceBindServiceImpl.java
new file mode 100644
index 0000000..0438db8
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceBindServiceImpl.java
@@ -0,0 +1,130 @@
+package org.dromara.device.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.device.domain.BmDevice;
+import org.dromara.device.domain.BmDeviceBind;
+import org.dromara.device.enums.BindActionEnum;
+import org.dromara.device.enums.DeviceStatusEnum;
+import org.dromara.device.mapper.BmDeviceBindMapper;
+import org.dromara.device.mapper.BmDeviceMapper;
+import org.dromara.device.service.IDeviceBindService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 设备绑定流水服务业务层处理
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@RequiredArgsConstructor
+@Service
+public class DeviceBindServiceImpl implements IDeviceBindService {
+
+ private final BmDeviceBindMapper deviceBindMapper;
+ private final BmDeviceMapper deviceMapper;
+
+ /**
+ * 记录一条绑定流水
+ *
+ * @param deviceId 设备物理主键
+ * @param deviceNo 设备编号(冗余)
+ * @param userId 操作 APP 用户
+ * @param imei IMEI(冗余快照)
+ * @param action 动作(bind / unbind / rebind)
+ */
+ @Override
+ public void record(Long deviceId, String deviceNo, Long userId, String imei, String action) {
+ BmDeviceBind bind = new BmDeviceBind();
+ bind.setDeviceId(deviceId);
+ bind.setDeviceNo(deviceNo);
+ bind.setUserId(userId);
+ bind.setImei(imei);
+ bind.setAction(StringUtils.isBlank(action) ? BindActionEnum.BIND.getAction() : action);
+ bind.setBindTime(LocalDateTime.now());
+ deviceBindMapper.insert(bind);
+ }
+
+ /**
+ * 查询设备最近一次绑定时间
+ *
+ * @param deviceId 设备物理主键
+ * @return 最近一次绑定时间,无流水时返回 null
+ */
+ @Override
+ public LocalDateTime latestBindTime(Long deviceId) {
+ BmDeviceBind bind = deviceBindMapper.selectOne(Wrappers.一、顺序即正确性:登记 → 发布 → 等待
+ * {@code register} 必须早于 {@code publishHood}。反过来的话,设备回执可能先于登记到达,
+ * 被登记表判为「未识别的 msgId」丢弃,调用方一路等到超时——一个只在快网络下偶发的竞态。
+ * 因此这里没有任何「先发再补登记」的优化空间。
+ *
+ * 二、等待有界,且兜底不依赖登记表
+ * 登记时已通过 {@code completeOnTimeout} 给 Future 设了完成时限,理论上 {@code join()} 必定返回。
+ * 本类仍用带超时的 {@code get}(时限 + {@value #AWAIT_GRACE_MILLIS}ms 宽限):
+ * 宽限不是为了等设备,而是为了在登记表兜底失效(如 JVM 长时间 STW 后延迟任务被推迟)时,
+ * HTTP 线程不会无限挂住。多等一瞬换取「永不悬挂」,这个交换在请求线程上是划算的。
+ *
+ * 三、低电只拦「遮盖」
+ * 低电拒绝是不对称的:只拦 {@code coverOn=true}。理由是「打开」属恢复方向,
+ * 低电时把它拦下等于把用户锁在遮盖态;而放行最坏的结果只是等 5s 收到「指令下发失败」,
+ * 不会造成不可逆后果。详见协议 §8.3。
+ *
+ * 四、超时不重试
+ * 眼罩是物理器械,超时后自动重试可能造成重复动作(用户以为失败、实际已执行)。
+ * 协议 §10 明确「不自动重试」,失败交回用户决定。OTA 的重试走另一条路径(后台手工重发)。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DeviceCommandServiceImpl implements IDeviceCommandService {
+
+ /**
+ * 回执等待的额外宽限(毫秒),用于兜底登记表的超时完成,避免请求线程被无限挂住。
+ */
+ private static final long AWAIT_GRACE_MILLIS = 500L;
+
+ private final DeviceMqttProperties properties;
+ private final DevicePendingAckRegistry ackRegistry;
+ private final DeviceCommandPublisher commandPublisher;
+
+ /**
+ * 下发眼罩开关指令并等待设备回执。
+ *
+ * @param device 目标设备(调用方已完成归属校验)
+ * @param coverOn 是否遮盖
+ * @return 设备确认执行后的眼罩状态
+ */
+ @Override
+ public HoodCoverEnum sendHood(BmDevice device, boolean coverOn) {
+ String deviceNo = device.getDeviceNo();
+ assertOperable(device, deviceNo, coverOn);
+
+ String msgId = DeviceCommandPublisher.newMsgId();
+ CompletableFuture
+ *
+ * 第二条是必需的兜底:MyBatis-Plus 全局 {@code updateStrategy = NOT_NULL},
+ * 而空串「非 null」——若不回填,空串会被当作有效值写入 {@code device_no},
+ * 既破坏业务主键、又可能撞唯一键 {@code uk_device_no}。
+ * 一、批量的「数得对上账」
+ * 去重后的设备集合即 {@code total},逐台处置时要么计 {@code created}、要么计 {@code skipped}、
+ * 要么计 {@code failed}——三者互斥且穷尽,故 {@code total == created + skipped + failed} 恒成立。
+ * 实现上不使用「先统一预检再统一写入」,而是逐台即时分类,避免中途异常导致计数错位。
+ *
+ * 二、下发的顺序即正确性:登记 → 发布 → 等待
+ * 与眼罩指令一致(复用 {@link DeviceCommandPublisher} + {@link DevicePendingAckRegistry}):
+ * 先登记 {@code CompletableFuture} 再发布,杜绝「回执先于登记到达」的竞态。
+ * 回执受理后,任务终态由 I6 的回执处理器({@code DefaultDeviceUplinkHandler#onAck})落定,
+ * 本类只在「未发出 / 超时」两种处理器不会触及的情形下兜底把任务置为失败。
+ *
+ * 三、为什么无 {@code @Transactional}
+ * 批量含逐台阻塞等待回执(最长 {@code device.mqtt.ota-timeout}),把整批绑进一条长事务会长时间占用
+ * 数据库连接与行锁,且部分成功语义也不该被整体回滚。故单台任务以自提交方式落库,失败亦留痕。
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OtaTaskServiceImpl implements IOtaTaskService {
+
+ /**
+ * 回执等待的额外宽限(毫秒),用于兜底登记表的超时完成,避免请求线程被无限挂住。
+ */
+ private static final long AWAIT_GRACE_MILLIS = 500L;
+
+ private final BmOtaTaskMapper otaTaskMapper;
+ private final BmOtaPackageMapper otaPackageMapper;
+ private final BmDeviceMapper deviceMapper;
+ private final DeviceCommandPublisher commandPublisher;
+ private final DevicePendingAckRegistry ackRegistry;
+ private final DeviceMqttProperties properties;
+
+ /**
+ * 分页查询升级任务列表(升级记录)。
+ *
+ * @param bo 查询条件
+ * @param pageQuery 分页参数
+ * @return 分页结果
+ */
+ @Override
+ public PageResult
+ *
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AppSmsService {
+
+ private final AppProperties appProperties;
+
+ /**
+ * 生成并按场景发送短信验证码(含 60 秒重发限流)。
+ *
+ * @param phoneNumber 手机号
+ * @param scene 场景(login / register / resetPwd / rebind)
+ * @return mock 模式下返回验证码供接口联调,真实短信模式返回 {@code null}
+ */
+ public String sendCode(String phoneNumber, String scene) {
+ if (!SmsSceneEnum.isValid(scene)) {
+ throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID);
+ }
+ AppProperties.Sms sms = appProperties.getSms();
+ // 60 秒重发限流:利用「不存在才写入」的原子性,写入成功即取得发送资格
+ String limitKey = AppConstants.SMS_LIMIT_KEY_PREFIX + scene + ":" + phoneNumber;
+ boolean acquired = RedisUtils.setObjectIfAbsent(limitKey, System.currentTimeMillis(),
+ Duration.ofSeconds(sms.getResendSeconds()));
+ if (!acquired) {
+ throw new ServiceException(AppMsgConstants.SMS_SEND_TOO_FREQUENT);
+ }
+ String code = RandomUtil.randomNumbers(sms.getCodeLength());
+ // 先落库缓存,再发送:即使发送异常,限流 key 已存在,避免短信通道故障时的刷量
+ String codeKey = AppConstants.SMS_CODE_KEY_PREFIX + scene + ":" + phoneNumber;
+ RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(sms.getExpiryMinutes()));
+ // 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算)
+ RedisUtils.deleteObject(AppConstants.SMS_FAIL_KEY_PREFIX + scene + ":" + phoneNumber);
+ send(phoneNumber, code);
+ // 仅模拟发送时向上返回验证码,避免真实短信模式通过接口泄露验证码
+ return sms.isMock() ? code : null;
+ }
+
+ /**
+ * 校验短信验证码(一次性使用,校验通过后立即失效)。
+ *
+ *
+ *
+ * @author Kou
+ * @date 2026-09-16
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class AppUserApiServiceImpl implements AppUserApiService {
+
+ /**
+ * APP 登录账号(user_name)前缀:APP 账号的 user_name = 前缀 + 预分配 user_id。
+ */
+ private static final String USER_NAME_PREFIX = "app_";
+
+ private final SysUserMapper userMapper;
+
+ /**
+ * 按手机号查询 APP 用户(仅 app_user)。
+ *
+ *
+ *
+ * @param phoneNumber 手机号
+ * @return APP 用户信息(不含密码);不存在时返回 {@code null}
+ */
+ @Override
+ public AppUserDTO selectByPhone(String phoneNumber) {
+ if (StringUtils.isBlank(phoneNumber)) {
+ return null;
+ }
+ List
+ *
+ * 说明:APP 登录仅依赖 {@code phone_number}(见 {@code selectByPhone}),不依赖 {@code user_name};
+ * 展示用昵称取 {@code nickName}(默认手机号),故 user_name 采用内部派生值不影响用户展示。
+ *
+ * @param phoneNumber 手机号
+ * @param rawPassword 明文密码
+ * @param nickName 昵称(为空时默认取手机号)
+ * @return 新建用户主键
+ */
+ @Override
+ public Long registerByPhone(String phoneNumber, String rawPassword, String nickName) {
+ Long userId = IdGeneratorUtil.nextLongId();
+ SysUser user = new SysUser();
+ user.setUserId(userId);
+ user.setUserName(USER_NAME_PREFIX + userId);
+ user.setNickName(StringUtils.isBlank(nickName) ? phoneNumber : nickName);
+ user.setPhoneNumber(phoneNumber);
+ user.setUserType(UserType.APP_USER.getUserType());
+ user.setPassword(BCrypt.hashpw(rawPassword));
+ user.setStatus(SystemConstants.NORMAL);
+ user.setCreateBy(0L);
+ user.setUpdateBy(0L);
+ userMapper.insert(user);
+ return user.getUserId();
+ }
+
+ /**
+ * 按邮箱查询 APP 用户(仅 app_user)。
+ *