diff --git a/pom.xml b/pom.xml index 699c78a..4ebd61c 100644 --- a/pom.xml +++ b/pom.xml @@ -473,6 +473,13 @@ ${revision} + + + org.dromara + ruoyi-device + ${revision} + + diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index a608da7..23cca79 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -110,6 +110,12 @@ ruoyi-demo + + + org.dromara + ruoyi-device + + org.dromara diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 0fcb417..f30c138 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -206,6 +206,38 @@ mail: # Socket连接超时值,单位毫秒,缺省值不超时 connectionTimeout: 0 +--- # 设备域 · APP 端配置(前缀 device.app,对应 org.dromara.device.config.AppProperties) +# 所有项在代码中均有默认值,此处显式列出仅为便于运维调参;整段删除不影响启动。 +device: + app: + # ---------------- APP 邮箱验证码(邮箱注册 / 邮箱登录 / 邮箱找回密码) ---------------- + email: + # 是否仅打印日志而不真实发信:开发环境默认 true,便于在无 SMTP 条件下联调。 + # 【生产环境(prod)必须为 false】——否则应用拒绝启动,防止验证码明文写入日志。 + mock: true + # 谷歌邮箱 Gmail SMTP + host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com} + # 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true + port: ${DEVICE_APP_EMAIL_PORT:465} + # 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件 + user: ${DEVICE_APP_EMAIL_USER:} + # 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入 + pass: ${DEVICE_APP_EMAIL_PASS:} + # 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收 + from: ${DEVICE_APP_EMAIL_FROM:} + ssl-enable: true + starttls-enable: false + # smtp.gmail.com 在国内机房通常不可达,必须快速失败,避免挂死 HTTP 请求线程 + connection-timeout: 5000 + timeout: 10000 + # 验证码位数 / 有效期(分钟)/ 重发间隔(秒) + code-length: 6 + expiry-minutes: 5 + resend-seconds: 60 + # 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。 + # 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码: + # allowed-domains: gmail.com,googlemail.com + --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 sms: diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index e581b18..74a3e79 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -209,6 +209,39 @@ mail: # Socket连接超时值,单位毫秒,缺省值不超时 connectionTimeout: 0 +--- # 设备域 · APP 端配置(前缀 device.app,对应 org.dromara.device.config.AppProperties) +# 所有项在代码中均有默认值,此处显式列出仅为便于运维调参;整段删除不影响启动。 +device: + app: + # ---------------- APP 邮箱验证码(邮箱注册 / 邮箱登录 / 邮箱找回密码) ---------------- + email: + # 【必须为 false】生产环境开启 mock 会把邮箱验证码明文写入日志, + # 此时 org.dromara.device.config.AppEmailProdGuard 将直接拒绝启动(属预期保护)。 + mock: false + # 谷歌邮箱 Gmail SMTP + host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com} + # 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true + port: ${DEVICE_APP_EMAIL_PORT:465} + # 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件 + user: ${DEVICE_APP_EMAIL_USER:} + # 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入 + pass: ${DEVICE_APP_EMAIL_PASS:} + # 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收 + from: ${DEVICE_APP_EMAIL_FROM:} + ssl-enable: true + starttls-enable: false + # smtp.gmail.com 在国内机房通常不可达:若部署在国内,请评估改用国内 SMTP, + # 或确认服务器具备出海能力后再上线邮箱注册功能。 + connection-timeout: 5000 + timeout: 10000 + # 验证码位数 / 有效期(分钟)/ 重发间隔(秒) + code-length: 6 + expiry-minutes: 5 + resend-seconds: 60 + # 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。 + # 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码: + # allowed-domains: gmail.com,googlemail.com + --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 sms: diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index cd363b5..5760717 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -14,7 +14,17 @@ server: min: 8 # 最大线程数 max: 256 - +zlm: + host: ${ZLM_HOST:http://47.104.136.221:8090} # ZLMediaKit 地址 + rtmp-host: ${ZLM_RTMP_HOST:rtmp://47.104.136.221:1935} + rtsp-host: ${ZLM_RTSP_HOST:rtsp://47.104.136.221:554} + webrtc-host: ${ZLM_WEBRTC_HOST:http://47.104.136.221:8090} + secret: ${ZLM_SECRET:D5QbzhEkO8X0cdxMnIwj5USsCIfSKcOq} # ZLM webhook 密钥:不落明文,必须通过环境变量 ZLM_SECRET 注入;未配置时 /zlm/hook/** 回调一律拒绝(失败关闭) + app: live + schema: rtmp + push-protocol: rtmp + play-expire-seconds: 120 + stream-timeout: 30 # 流超时秒数 captcha: # 是否启用验证码校验 enable: true @@ -111,6 +121,7 @@ security: - /warm-flow-ui/config - /snail-chat/** - /api/snail/chat/** + - /zlm/hook/** # MyBatisPlus配置 # https://baomidou.com/config/ @@ -264,15 +275,17 @@ mqtt.client: # 是否开启客户端,默认:true enabled: true # 连接的服务端 ip ,默认:127.0.0.1 - ip: 192.168.1.249 + ip: 47.97.217.123 # 端口:默认:1883 port: 1883 # 客户端名称 - name: Mqtt_bird_monitoring + name: Mqtt-Client # 客户端Id(非常重要,一般为设备 sn,不可重复) - client-id: Mqttbirdmonitoring001 -# username: admin -# password: 61e6062129e9 + client-id: bird_monitor + # MQTT broker 账号/密码:不落明文,通过环境变量注入(避免凭据进入 git 历史)。 + # 联调前 export MQTT_USERNAME / MQTT_PASSWORD;未配置时取空值,broker 会拒绝认证。 + username: ${MQTT_USERNAME:admin} + password: ${MQTT_PASSWORD:61e6062129e9} # 超时时间,单位:秒,默认:5秒 timeout: 5 # 重连时间,默认 5000 毫秒 @@ -297,6 +310,29 @@ mqtt.client: truststore-path: truststore-pass: +--- # 设备域通信链路配置(《设备通信协议-一期.md》口径) +# 全部项均有代码默认值,此处显式列出仅为便于运维调参;不需要调时整段删除也不影响启动。 +device.mqtt: + # 离线扫描总开关。关闭后设备不会再被自动置离线,仅用于联调排障,生产必须为 true + scan-enabled: true + # 离线扫描间隔(上一轮跑完再计时,不会因扫描耗时堆积) + offline-scan-interval: 30s + # 离线判定阈值:超过该时长无遥测即判离线(协议 Q2:上报周期 60s × 2 容错) + offline-threshold: 120s + # 单轮扫描的设备上限,防止异常时一次性拉爆内存 + scan-batch-size: 500 + # 低电告警触发阈值(≤ 该值触发)与恢复阈值(≥ 该值恢复),两者之间的区间为滞回防抖区 + low-battery-threshold: 20 + low-battery-recover-threshold: 25 + # 低电滞回闩锁存活时长,仅作已注销设备的残留清理兜底 + low-battery-latch-ttl: 30d + # 告警去重窗口:同一 设备+告警类型 在窗口内只落一条 bm_device_alert + alert-dedup-window: 30m + # 眼罩指令回执等待超时(协议 Q3 待硬件确认动作到位时长) + hood-timeout: 5s + # 待回执指令标记的 Redis TTL(仅用于事后定位「回执晚到」) + pending-ack-ttl: 10m + --- # elasticsearch 功能配置 # 文档地址: https://www.easy-es.cn/ # 更改包名需要去 EasyEsConfiguration 修改包扫描(后续版本支持配置文件读取) diff --git a/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java b/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java new file mode 100644 index 0000000..2f8e262 --- /dev/null +++ b/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java @@ -0,0 +1,183 @@ +package org.dromara.system.api; + +import org.dromara.common.core.domain.PageResult; +import org.dromara.system.api.domain.AppUserDTO; +import org.dromara.system.api.domain.AppUserQueryDTO; + +/** + * APP 用户(C 端)领域服务对外暴露的 API。 + *

+ * 设计口径(迭代 I2 边界裁定 · 方案 A): + *

+ * + * @author Kou + * @date 2026-09-16 + */ +public interface AppUserApiService { + + /** + * 按手机号查询 APP 用户(仅匹配 {@code user_type = app_user} 且未逻辑删除的账号)。 + * + * @param phoneNumber 手机号 + * @return APP 用户信息(不含密码);不存在时返回 {@code null} + */ + AppUserDTO selectByPhone(String phoneNumber); + + /** + * 判断手机号是否已被注册为 APP 用户(仅匹配 {@code user_type = app_user})。 + * + * @param phoneNumber 手机号 + * @return true 已注册;false 未注册 + */ + boolean existsByPhone(String phoneNumber); + + /** + * 校验用户密码是否匹配。 + *

+ * 明文密码仅在本方法内部比对(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 selectAppUserPage(AppUserQueryDTO query); + + /** + * 按主键查询 APP 用户详情(后台「APP 用户管理」用)。 + *

+ * 同样强制 {@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 params = new HashMap<>(); + + /** + * 页码(从 1 开始);为空时按 1 处理。 + */ + private Integer pageNum; + + /** + * 每页条数;为空时按 10 处理。 + */ + private Integer pageSize; + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java index 36992b4..5c1f5bf 100644 --- a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java @@ -77,6 +77,15 @@ public class MybatisPlusConfig { PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(); // 分页合理化 paginationInnerInterceptor.setOverflow(true); + // 单页上限 —— 兜住两种「一个请求打爆数据库」的姿势: + // 1) 不传 pageSize:PageQuery#build 会回落成 DEFAULT_PAGE_SIZE = Integer.MAX_VALUE, + // 等于「不传就取全表」,且此前 maxLimit 未设,没有任何兜底; + // 2) 显式传超大 pageSize(如 ?pageSize=999999999)。 + // 取值 1000 的依据:当前最大单页需求是 200(设备型号候选去重), + // 需要全量的场景(Excel 导出)走的是不分页查询,不受本上限约束。 + // ⚠️ 副作用:超过 1000 的请求会被**静默改写**(MyBatis-Plus 不抛错)。 + // 因此**需要全量数据时请用不分页查询,不要靠「不传 pageSize」**。 + paginationInnerInterceptor.setMaxLimit(1000L); return paginationInnerInterceptor; } diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java index df5af84..4a89ffb 100644 --- a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java @@ -54,7 +54,14 @@ public class PageQuery implements Serializable { public static final int DEFAULT_PAGE_NUM = 1; /** - * 每页显示记录数 默认值 默认查全部 + * 每页显示记录数 默认值:**不传 pageSize 时回落值**。 + *

+ * ⚠️ 这里是 {@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 @@ ruoyi-system ruoyi-workflow ruoyi-ai + ruoyi-device diff --git a/ruoyi-modules/ruoyi-device/pom.xml b/ruoyi-modules/ruoyi-device/pom.xml new file mode 100644 index 0000000..eeefd68 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/pom.xml @@ -0,0 +1,118 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + + ruoyi-device + + + 设备模块(智能飞禽物联平台 · 设备域) + + + + + + + org.dromara + ruoyi-common-core + + + + + org.dromara + ruoyi-api + + + + + org.dromara + ruoyi-common-web + + + + + org.dromara + ruoyi-common-mybatis + + + + + org.dromara + ruoyi-common-security + + + + + org.dromara + ruoyi-common-log + + + + + org.dromara + ruoyi-common-redis + + + + + org.dromara + ruoyi-common-excel + + + + + org.dromara + ruoyi-common-oss + + + + + org.dromara + ruoyi-common-sms + + + + + org.dromara + ruoyi-common-translation + + + + + org.dromara + ruoyi-common-sensitive + + + + + org.dromara + ruoyi-common-doc + + + + + org.dromara + ruoyi-common-mqtt + + + + + org.dromara + ruoyi-common-json + + + + + org.dromara + ruoyi-common-mail + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppEmailProdGuard.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppEmailProdGuard.java new file mode 100644 index 0000000..0ff6361 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppEmailProdGuard.java @@ -0,0 +1,72 @@ +package org.dromara.device.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * APP 邮箱验证码生产环境守卫(口径镜像 {@link AppSmsProdGuard})。 + *

+ * 背景:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码明文写入应用日志。 + * 开发/联调期这是便利(SMTP 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。 + *

+ * 策略: + *

+ * + * @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 端)配置项。 + *

+ * 全部字段均带默认值,未在 {@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 名称 })。 + */ + private String from = ""; + + /** + * 使用 SSL 安全连接(465 端口用 true)。 + */ + private boolean sslEnable = true; + + /** + * 使用 STARTTLS 升级连接(587 端口用 true,与 {@link #sslEnable} 互斥使用)。 + */ + private boolean starttlsEnable = false; + + /** + * SMTP 连接超时(毫秒):避免 Gmail 不可达时长时间占用请求线程。 + */ + private int connectionTimeout = 5000; + + /** + * SMTP 读写超时(毫秒)。 + */ + private int timeout = 10000; + + /** + * 邮件主题。 + */ + private String subject = "智能飞禽物联平台 验证码"; + + /** + * 验证码位数(APP 固定 6 位)。 + */ + private int codeLength = 6; + + /** + * 验证码有效期(分钟)。 + */ + private int expiryMinutes = 5; + + /** + * 重发间隔(秒),即 60 秒防刷。 + */ + private int resendSeconds = 60; + + /** + * 允许注册的邮箱域名白名单,留空 = 不限制域名。 + *

+ * 若产品要求「只允许谷歌邮箱注册」,配置为 {@code gmail.com,googlemail.com} 即可,无需改代码。 + */ + private List allowedDomains = new ArrayList<>(); + + /** + * 判断是否已具备真实发信条件(发信账号与凭据均已配置)。 + * + * @return true 已配置;false 未配置 + */ + public boolean isConfigured() { + return StringUtils.isNotBlank(user) && StringUtils.isNotBlank(pass); + } + + } + + /** + * 协议配置。 + */ + @Data + public static class Agreement { + + /** + * 用户协议。 + */ + private Item userAgreement = new Item(); + + /** + * 隐私政策。 + */ + private Item privacyPolicy = new Item(); + + /** + * 单条协议内容(URL + 版本 + 更新时间)。 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Item { + + /** + * 协议 URL(或富文本地址)。 + */ + private String url = ""; + + /** + * 协议版本。 + */ + private String version = "v1.0"; + + /** + * 协议更新时间(如 2026-01-01)。 + */ + private String updateTime = ""; + + } + + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppSmsProdGuard.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppSmsProdGuard.java new file mode 100644 index 0000000..730a97e --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppSmsProdGuard.java @@ -0,0 +1,57 @@ +package org.dromara.device.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * APP 短信生产环境守卫(I2 遗留项收口)。 + *

+ * 背景:{@code device.app.sms.mock} 默认 {@code true},会把短信验证码明文写入应用日志。 + * 这在开发/联调期是便利(Q5 短信通道未定),但若生产环境误开 mock,等价于把验证码落盘,属安全风险。 + *

+ * 策略:不修改默认值(开发仍需 mock),而是保证「生产环境无法带 mock 启动」—— + *

+ * 采用框架惯用的 {@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})。 + *

+ * 全部字段带默认值,默认值即《设备通信协议-一期.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} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。 + *

+ * ⚠️ 引入设备级阈值后本字段的含义有所扩展: + *

+ */ + private int lowBatteryRecoverThreshold = 25; + + /** + * 低电滞回闩锁的存活时长。 + *

+ * 闩锁本身无业务过期语义(低电态由恢复阈值解除),设置 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> list(@Validated AppUserAdminQueryBo bo) { + return R.ok(appUserAdminService.queryPageList(bo)); + } + + /** + * 查询 APP 用户详情。 + * + * @param userId 用户主键 + * @return 用户详情;不存在或非 APP 账号时 {@code data} 为 {@code null}(前端按空态处理) + */ + @SaCheckPermission("device:appuser:query") + @GetMapping("/{userId}") + public R getInfo(@PathVariable("userId") Long userId) { + return R.ok(appUserAdminService.queryById(userId)); + } + + /** + * 查询指定 APP 用户当前绑定的设备列表。 + * + * @param userId 用户主键 + * @return 绑定设备列表(无绑定或用户无效时为空列表,不报错) + */ + @SaCheckPermission("device:appuser:query") + @GetMapping("/{userId}/devices") + public R> 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 changeStatus(@Validated @RequestBody AppUserStatusBo bo) { + appUserAdminService.updateStatus(bo); + return R.ok(DeviceMsgConstants.USER_STATUS_SUCCESS); + } + + /** + * 重置账号密码(管理员代为设置)。 + * + * @param bo 重置密码入参(userId + newPassword) + * @return 成功文案 + */ + @SaCheckPermission("device:appuser:edit") + @Log(title = "APP用户管理", businessType = BusinessType.UPDATE) + @PutMapping("/reset-pwd") + public R resetPassword(@Validated @RequestBody AppUserResetPwdBo bo) { + appUserAdminService.resetPassword(bo); + return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS); + } + + /** + * 解绑该用户名下的某台设备。 + * + * @param userId 用户主键 + * @param deviceNo 设备编号 + * @return 成功文案 + */ + @SaCheckPermission("device:appuser:edit") + @Log(title = "APP用户管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{userId}/device/{deviceNo}") + public R unbindDevice(@PathVariable("userId") Long userId, + @PathVariable("deviceNo") String deviceNo) { + appUserAdminService.unbindDevice(userId, deviceNo); + return R.ok(DeviceMsgConstants.UNBIND_SUCCESS); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/DeviceController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/DeviceController.java new file mode 100644 index 0000000..e7c0358 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/DeviceController.java @@ -0,0 +1,144 @@ +package org.dromara.device.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.PageResult; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.core.validate.QueryGroup; +import org.dromara.common.excel.utils.ExcelBuilder; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.web.core.BaseController; +import org.dromara.device.domain.bo.DeviceBo; +import org.dromara.device.domain.vo.DeviceStatsVo; +import org.dromara.device.domain.vo.DeviceVo; +import org.dromara.device.service.IDeviceService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 后台设备 Controller(前缀 {@code /device})。 + *

+ * 接口清单(架构 §7.2): + *

+ * OTA 相关接口见 {@link org.dromara.device.controller.OtaController}({@code /device/ota/**},I5)。 + * 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log}。 + *

+ * ⚠️ 本模块没有数据权限(部门)隔离:{@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> list(@Validated(QueryGroup.class) DeviceBo bo, PageQuery pageQuery) { + return R.ok(deviceService.queryPageList(bo, pageQuery)); + } + + /** + * 获取设备详细信息 + * + * @param deviceId 设备主键 + */ + @SaCheckPermission("device:device:query") + @GetMapping("/{deviceId}") + public R getInfo(@NotNull(message = "设备主键不能为空") + @PathVariable("deviceId") Long deviceId) { + return R.ok(deviceService.queryById(deviceId)); + } + + /** + * 新增设备 + */ + @SaCheckPermission("device:device:add") + @Log(title = "设备管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated(AddGroup.class) @RequestBody DeviceBo bo) { + return toAjax(deviceService.insertByBo(bo)); + } + + /** + * 修改设备 + */ + @SaCheckPermission("device:device:edit") + @Log(title = "设备管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated(EditGroup.class) @RequestBody DeviceBo bo) { + return toAjax(deviceService.updateByBo(bo)); + } + + /** + * 设备统计(顶部统计条)。 + * + * @return 统计({@code total / online / offline / inactive}) + */ + @SaCheckPermission("device:device:list") + @GetMapping("/stats") + public R stats() { + return R.ok(deviceService.queryStats()); + } + + /** + * 批量删除设备(已绑定设备拒绝)。 + * + * @param ids 设备主键数组(路径中以逗号分隔,如 {@code /device/1,2,3}) + */ + @SaCheckPermission("device:device:remove") + @Log(title = "设备管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@PathVariable("ids") Long[] ids) { + return toAjax(deviceService.deleteByIds(Arrays.asList(ids))); + } + + /** + * 导出设备列表(与 {@link #list} 同筛选口径)。 + *

+ * 采用若依 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 list = deviceService.queryList(bo); + ExcelBuilder.of(list, DeviceVo.class).sheetName("设备数据").toResponse(response); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/OtaController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/OtaController.java new file mode 100644 index 0000000..ea4e026 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/OtaController.java @@ -0,0 +1,140 @@ +package org.dromara.device.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.PageResult; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.web.core.BaseController; +import org.dromara.device.domain.bo.OtaBatchBo; +import org.dromara.device.domain.bo.OtaPackageBo; +import org.dromara.device.domain.bo.OtaTaskBo; +import org.dromara.device.domain.vo.OtaBatchResultVo; +import org.dromara.device.domain.vo.OtaPackageVo; +import org.dromara.device.domain.vo.OtaTaskVo; +import org.dromara.device.service.IOtaPackageService; +import org.dromara.device.service.IOtaTaskService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; + +/** + * OTA 升级 Controller(前缀 {@code /device/ota})。 + *

+ * 接口清单(架构 §7.2、PRD §5 R-29~R-32): + *

+ * 后台接口一律 {@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> taskList(OtaTaskBo bo, PageQuery pageQuery) { + return R.ok(otaTaskService.queryPageList(bo, pageQuery)); + } + + /** + * 查询升级包列表。 + * + * @param bo 查询条件(model / version / status / keyword) + * @param pageQuery 分页参数 + * @return 升级包分页结果 + */ + @SaCheckPermission("device:ota:list") + @GetMapping("/package/list") + public R> packageList(OtaPackageBo bo, PageQuery pageQuery) { + return R.ok(otaPackageService.queryPageList(bo, pageQuery)); + } + + /** + * 登记 OTA 升级包。 + * + * @param bo 升级包业务对象 + * @return 操作结果 + */ + @SaCheckPermission("device:ota:add") + @Log(title = "OTA升级包", businessType = BusinessType.INSERT) + @PostMapping("/package") + public R addPackage(@RequestBody OtaPackageBo bo) { + return toAjax(otaPackageService.insertByBo(bo)); + } + + /** + * 修改 OTA 升级包(含启用 / 停用)。 + * + * @param bo 升级包业务对象(含 packageId) + * @return 操作结果 + */ + @SaCheckPermission("device:ota:add") + @Log(title = "OTA升级包", businessType = BusinessType.UPDATE) + @PutMapping("/package") + public R updatePackage(@RequestBody OtaPackageBo bo) { + return toAjax(otaPackageService.updateByBo(bo)); + } + + /** + * 删除 OTA 升级包(逻辑删除)。 + * + * @param ids 升级包主键数组(路径中以逗号分隔,如 {@code /device/ota/package/1,2,3}) + * @return 操作结果 + */ + @SaCheckPermission("device:ota:add") + @Log(title = "OTA升级包", businessType = BusinessType.DELETE) + @DeleteMapping("/package/{ids}") + public R removePackage(@PathVariable("ids") Long[] ids) { + return toAjax(otaPackageService.deleteByIds(Arrays.asList(ids))); + } + + /** + * 按勾选设备批量创建升级任务并下发。 + *

+ * 返回 {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 batch(@Validated @RequestBody OtaBatchBo bo) { + return R.ok(otaTaskService.batchOta(bo)); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java new file mode 100644 index 0000000..2b09905 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java @@ -0,0 +1,152 @@ +package org.dromara.device.controller.app; + +import cn.dev33.satoken.annotation.SaIgnore; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.device.constant.AppMsgConstants; +import org.dromara.device.domain.bo.AppEmailCodeBo; +import org.dromara.device.domain.bo.AppEmailCodeLoginBo; +import org.dromara.device.domain.bo.AppEmailLoginBo; +import org.dromara.device.domain.bo.AppEmailRegisterBo; +import org.dromara.device.domain.bo.AppEmailResetPwdBo; +import org.dromara.device.domain.bo.AppLoginBo; +import org.dromara.device.domain.bo.AppRegisterBo; +import org.dromara.device.domain.bo.AppResetPwdBo; +import org.dromara.device.domain.bo.AppSmsCodeBo; +import org.dromara.device.domain.bo.AppSmsLoginBo; +import org.dromara.device.domain.vo.AppTokenVo; +import org.dromara.device.service.IAppAuthService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * APP 账号 Controller(前缀 {@code /app/auth})。 + *

+ * 本组接口为登录类,统一 {@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 sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) { + String code = appAuthService.sendSmsCode(bo); + // 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案 + return R.ok(code == null ? AppMsgConstants.SMS_CODE_SENT : code); + } + + /** + * 手机号注册。 + */ + @PostMapping("/register") + public R register(@Validated @RequestBody AppRegisterBo bo) { + appAuthService.register(bo); + return R.ok(AppMsgConstants.REGISTER_SUCCESS); + } + + /** + * 密码登录。 + *

+ * 令牌封装为 {@link AppTokenVo} 并置于响应 {@code data}(P3-1 修复)——避免 {@code R.ok(String)} + * 被解析为「msg 重载」而把令牌塞进 {@code msg}。{@code IAppAuthService#login} 仍返回令牌字符串(架构 §5 不变)。 + */ + @PostMapping("/login") + public R login(@Validated @RequestBody AppLoginBo bo) { + return R.ok(new AppTokenVo(appAuthService.login(bo))); + } + + /** + * 短信验证码登录。 + *

+ * 令牌封装口径同 {@link #login}(P3-1 修复)。 + */ + @PostMapping("/sms-login") + public R smsLogin(@Validated @RequestBody AppSmsLoginBo bo) { + return R.ok(new AppTokenVo(appAuthService.smsLogin(bo))); + } + + /** + * 忘记密码(重置密码)。 + */ + @PostMapping("/reset-pwd") + public R resetPwd(@Validated @RequestBody AppResetPwdBo bo) { + appAuthService.resetPwd(bo); + return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS); + } + + /** + * 退出登录(清除当前会话)。 + */ + @PostMapping("/logout") + public R logout() { + appAuthService.logout(); + return R.ok(AppMsgConstants.LOGOUT_SUCCESS); + } + + // ---------------- 邮箱通道(迭代:邮箱注册) ---------------- + + /** + * 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效)。 + */ + @PostMapping("/email/code") + public R sendEmailCode(@Validated @RequestBody AppEmailCodeBo bo) { + appAuthService.sendEmailCode(bo); + return R.ok(AppMsgConstants.EMAIL_CODE_SENT); + } + + /** + * 邮箱注册。 + *

+ * 成功文案给出「请绑定手机号」引导:按本迭代账号规则,邮箱注册后须继续绑定手机号, + * 未绑手机号的账号无法绑定设备(服务端硬拦截,见 {@code IAppDeviceService#bind})。 + */ + @PostMapping("/email/register") + public R emailRegister(@Validated @RequestBody AppEmailRegisterBo bo) { + appAuthService.registerByEmail(bo); + return R.ok(AppMsgConstants.EMAIL_REGISTER_SUCCESS); + } + + /** + * 邮箱 + 密码登录。 + *

+ * 令牌封装口径同 {@link #login}:置于响应 {@code data},避免 {@code R.ok(String)} 被解析为 msg 重载。 + */ + @PostMapping("/email/login") + public R emailLogin(@Validated @RequestBody AppEmailLoginBo bo) { + return R.ok(new AppTokenVo(appAuthService.emailLogin(bo))); + } + + /** + * 邮箱 + 验证码登录。 + */ + @PostMapping("/email/code-login") + public R emailCodeLogin(@Validated @RequestBody AppEmailCodeLoginBo bo) { + return R.ok(new AppTokenVo(appAuthService.emailCodeLogin(bo))); + } + + /** + * 邮箱找回密码(重置密码)。 + */ + @PostMapping("/email/reset-pwd") + public R emailResetPwd(@Validated @RequestBody AppEmailResetPwdBo bo) { + appAuthService.resetPwdByEmail(bo); + return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppDeviceController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppDeviceController.java new file mode 100644 index 0000000..677daf0 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppDeviceController.java @@ -0,0 +1,189 @@ +package org.dromara.device.controller.app; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.device.constant.AppMsgConstants; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.bo.AppDeviceBindBo; +import org.dromara.device.domain.bo.AppDeviceHoodBo; +import org.dromara.device.domain.bo.AppDeviceNameBo; +import org.dromara.device.domain.vo.AppDeviceDetailVo; +import org.dromara.device.domain.vo.AppDeviceListVo; +import org.dromara.device.domain.vo.AppHoodResultVo; +import org.dromara.device.domain.vo.AppStreamPlayVo; +import org.dromara.device.domain.vo.LocationVo; +import org.dromara.device.service.IAppDeviceService; +import org.dromara.device.service.IAppStreamService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * APP 设备 Controller(前缀 {@code /app/device})。 + *

+ * 本组接口需登录态;当前用户取自登录标识 {@code app_user:{userId}}({@code LoginHelper.getUserId()}), + * 仅能操作本人绑定设备,不参与后台数据权限、不加 {@code @SaCheckPermission}(《开发计划》§12,与 I2 口径一致)。 + *

+ * 接口清单(迭代 I3,架构 §7.1 #12–16): + *

    + *
  • {@code GET /app/device/list} —— 当前用户绑定列表
  • + *
  • {@code GET /app/device/{deviceNo}} —— 设备详情
  • + *
  • {@code POST /app/device/bind} —— 绑定(imei + name)
  • + *
  • {@code PUT /app/device/{deviceNo}/name} —— 重命名
  • + *
  • {@code DELETE /app/device/{deviceNo}} —— 解绑
  • + *
+ * 迭代 I4 增补: + *
    + *
  • {@code PUT /app/device/{deviceNo}/hood} —— 眼罩开关(阻塞等待设备回执,协议 §8.2)
  • + *
  • {@code GET /app/device/{deviceNo}/live} —— 直播播放地址(不主动通知设备推流)
  • + *
+ * 开始/停止推流见 {@code /app/stream/**}。 + * 迭代 I5 增补: + *
    + *
  • {@code GET /app/device/{deviceNo}/location} —— 定位最新点(架构 §7.1 #18)
  • + *
+ * + * @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 detail(@PathVariable("deviceNo") String deviceNo) { + return R.ok(appDeviceService.detail(deviceNo, currentUserId())); + } + + /** + * 绑定设备(扫码 / 手动添加)。 + * + * @param bo 绑定入参(imei + name) + * @return 成功文案 + */ + @PostMapping("/bind") + public R bind(@Validated @RequestBody AppDeviceBindBo bo) { + appDeviceService.bind(bo, currentUserId()); + return R.ok(DeviceMsgConstants.BIND_SUCCESS); + } + + /** + * 重命名设备(设备编号取自路径)。 + * + * @param deviceNo 设备编号(路径变量,回填至 BO) + * @param bo 重命名入参(name) + * @return 成功文案 + */ + @PutMapping("/{deviceNo}/name") + public R rename(@PathVariable("deviceNo") String deviceNo, + @Validated @RequestBody AppDeviceNameBo bo) { + bo.setDeviceNo(deviceNo); + appDeviceService.rename(bo, currentUserId()); + return R.ok(DeviceMsgConstants.RENAME_SUCCESS); + } + + /** + * 解绑设备(当前用户)。 + * + * @param deviceNo 设备编号 + * @return 成功文案 + */ + @DeleteMapping("/{deviceNo}") + public R unbind(@PathVariable("deviceNo") String deviceNo) { + appDeviceService.unbind(deviceNo, currentUserId()); + return R.ok(DeviceMsgConstants.UNBIND_SUCCESS); + } + + /** + * 眼罩开关(遮盖 / 打开)。 + *

+ * 本接口会阻塞约一个 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 hood(@PathVariable("deviceNo") String deviceNo, + @Validated @RequestBody AppDeviceHoodBo bo) { + bo.setDeviceNo(deviceNo); + return R.ok(appDeviceService.hood(bo, currentUserId())); + } + + /** + * 定位最新点(给「查看地图」用,架构 §7.1 #18)。 + *

+ * 越权隔离与 {@link #detail} 完全同口径:非本人设备统一「设备不存在」。 + * 设备从未上报定位时返回空 VO({@code lng}/{@code lat} 为 {@code null}),不抛异常—— + * APP 需要一个可展示的空态(见 {@link LocationVo})。 + * + * @param deviceNo 设备编号 + * @return 定位视图对象(坐标可能为 {@code null}) + */ + @GetMapping("/{deviceNo}/location") + public R location(@PathVariable("deviceNo") String deviceNo) { + return R.ok(appDeviceService.location(deviceNo, currentUserId())); + } + + /** + * 获取直播播放地址(不通知设备推流)。 + *

+ * 需要设备已经在推、或随后调用 {@code POST /app/stream/start}。 + * 路径写成 {@code /{deviceNo}/live},避免被 {@code GET /{deviceNo}} 吞掉 {@code /app/device/live}。 + * + * @param deviceNo 设备编号 + * @return 带 token 的播放地址 + */ + @GetMapping("/{deviceNo}/live") + public R live(@PathVariable("deviceNo") String deviceNo) { + return R.ok(appStreamService.playUrl(deviceNo, currentUserId())); + } + + /** + * 获取当前登录用户主键,未登录时抛出受控异常。 + * + * @return 当前用户主键 + */ + private Long currentUserId() { + Long userId = LoginHelper.getUserId(); + if (userId == null) { + throw new ServiceException(AppMsgConstants.NOT_LOGIN); + } + return userId; + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java new file mode 100644 index 0000000..d57e98a --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java @@ -0,0 +1,83 @@ +package org.dromara.device.controller.app; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.device.constant.AppMsgConstants; +import org.dromara.device.domain.bo.AppPasswordBo; +import org.dromara.device.domain.bo.AppPhoneBo; +import org.dromara.device.domain.vo.AppAgreementVo; +import org.dromara.device.domain.vo.AppUserProfileVo; +import org.dromara.device.service.IAppUserService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * APP 个人中心 Controller(前缀 {@code /app/user})。 + *

+ * 本组接口需登录态;当前用户取自 {@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 profile() { + return R.ok(appUserService.profile()); + } + + /** + * 修改密码(旧密码 + 新密码)。 + */ + @PutMapping("/password") + public R updatePassword(@Validated @RequestBody AppPasswordBo bo) { + appUserService.updatePassword(bo); + return R.ok(AppMsgConstants.PASSWORD_UPDATE_SUCCESS); + } + + /** + * 换绑手机号(新手机号 + 验证码)。 + */ + @PutMapping("/phone") + public R rebindPhone(@Validated @RequestBody AppPhoneBo bo) { + appUserService.rebindPhone(bo); + return R.ok(AppMsgConstants.PHONE_UPDATE_SUCCESS); + } + + /** + * 注销账号(先解绑全部设备,再停用账号)。 + */ + @DeleteMapping("/account") + public R cancelAccount() { + appUserService.cancelAccount(); + return R.ok(AppMsgConstants.CANCEL_SUCCESS); + } + + /** + * 查询协议内容(URL + 版本 + 更新时间)。 + * + * @param type 协议类型:user / privacy + */ + @GetMapping("/agreements") + public R agreements(@RequestParam("type") String type) { + return R.ok(appUserService.agreements(type)); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDevice.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDevice.java new file mode 100644 index 0000000..ce858c6 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/BmDevice.java @@ -0,0 +1,177 @@ +package org.dromara.device.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 设备主表对象 bm_device + *

+ * 说明:本表不含任何直播字段(无 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 imei}:设备绑定码,默认 15 位数字({@link AppConstants#IMEI_REGEX})。
  • + *
  • {@code name}:用户侧设备名称,按字符数限制 ≤ {@link AppConstants#DEVICE_NAME_MAX_LENGTH}, + * 中文按 1 字计({@link Size} 以 {@link CharSequence#length()} 计,非 UTF-8 字节数)。
  • + *
+ * + * @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})。 + *

+ * {@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 params = new HashMap<>(); + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserResetPwdBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserResetPwdBo.java new file mode 100644 index 0000000..64297c8 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserResetPwdBo.java @@ -0,0 +1,44 @@ +package org.dromara.device.domain.bo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.dromara.device.constant.AppConstants; +import org.dromara.device.constant.DeviceMsgConstants; + +import java.io.Serial; +import java.io.Serializable; + +/** + * APP 用户重置密码入参({@code PUT /device/app-user/reset-pwd})。 + *

+ * 密码规则与 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 deviceIds; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaPackageBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaPackageBo.java new file mode 100644 index 0000000..dc0b3d4 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaPackageBo.java @@ -0,0 +1,83 @@ +package org.dromara.device.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.device.domain.BmOtaPackage; + +import java.io.Serial; +import java.io.Serializable; + +/** + * OTA 升级包业务对象 bm_ota_package + * + * @author Kou + * @date 2026-09-16 + */ +@Data +@AutoMapper(target = BmOtaPackage.class, reverseConvertGenerate = false) +public class OtaPackageBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + private Long packageId; + + /** + * 适用型号 + */ + private String model; + + /** + * 版本号 + */ + private String version; + + /** + * 关联 sys_oss.oss_id + */ + private Long ossId; + + /** + * 文件 URL(冗余) + */ + private String fileUrl; + + /** + * 文件名 + */ + private String fileName; + + /** + * 文件字节数 + */ + private Long fileSize; + + /** + * 校验值 MD5 + */ + private String md5; + + /** + * 更新说明 + */ + private String changelog; + + /** + * 状态(0 停用 1 启用) + */ + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 关键字(模糊匹配 model / version,查询用) + */ + private String keyword; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaTaskBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaTaskBo.java new file mode 100644 index 0000000..c2665d6 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/OtaTaskBo.java @@ -0,0 +1,89 @@ +package org.dromara.device.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.device.domain.BmOtaTask; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * OTA 升级任务业务对象 bm_ota_task + * + * @author Kou + * @date 2026-09-16 + */ +@Data +@AutoMapper(target = BmOtaTask.class, reverseConvertGenerate = false) +public class OtaTaskBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + private Long taskId; + + /** + * 升级包主键 + */ + private Long packageId; + + /** + * 版本号(冗余) + */ + private String version; + + /** + * 设备主键 + */ + private Long deviceId; + + /** + * 设备编号(冗余) + */ + private String deviceNo; + + /** + * 状态(0 待升级 1 升级中 2 成功 3 失败) + */ + private String status; + + /** + * 结果信息 + */ + private String resultMsg; + + /** + * 进度 0-100 + */ + private Integer progress; + + /** + * 操作人(后台用户) + */ + private Long operatorId; + + /** + * 下发次数 + */ + private Integer times; + + /** + * 开始时间 + */ + private LocalDateTime startTime; + + /** + * 完成时间 + */ + private LocalDateTime finishTime; + + /** + * 关键字(模糊匹配 deviceNo / version,查询用) + */ + private String keyword; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppAgreementVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppAgreementVo.java new file mode 100644 index 0000000..24dbfaa --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppAgreementVo.java @@ -0,0 +1,43 @@ +package org.dromara.device.domain.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * APP 协议(用户协议 / 隐私政策)视图对象。 + *

+ * 内容来源为配置项 {@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}),用于详情页展示与后续眼罩开关联动。 + *

+ * 坐标: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})。 + *

+ * 字段严格对齐《开发计划》§8.2「列表卡片字段」,字段名保持一致,不新增未约定字段: + *

+ * 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} 的响应体)。 + *

+ * 口径见《设备通信协议-一期.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}—— + * 每台(去重后的)设备必落入三者之一,实现里逐条维护该分类,杜绝「统计对不上账」。 + *

    + *
  • {@code skipped}:型号不匹配 / 已是最新版本 / 重复提交(同设备同包存在未终态任务)
  • + *
  • {@code failed}:设备不存在 / 设备离线 / 指令下发未被设备确认
  • + *
  • {@code created}:成功创建任务且已下发并被设备受理
  • + *
+ * {@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 messages = new ArrayList<>(); + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaPackageVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaPackageVo.java new file mode 100644 index 0000000..f76fa05 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaPackageVo.java @@ -0,0 +1,98 @@ +package org.dromara.device.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated; +import org.apache.fesod.sheet.annotation.ExcelProperty; +import org.dromara.device.domain.BmOtaPackage; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * OTA 升级包视图对象 bm_ota_package + * + * @author Kou + * @date 2026-09-16 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = BmOtaPackage.class) +public class OtaPackageVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "升级包主键") + private Long packageId; + + /** + * 适用型号 + */ + @ExcelProperty(value = "适用型号") + private String model; + + /** + * 版本号 + */ + @ExcelProperty(value = "版本号") + private String version; + + /** + * 关联 sys_oss.oss_id + */ + private Long ossId; + + /** + * 文件 URL(冗余) + */ + private String fileUrl; + + /** + * 文件名 + */ + @ExcelProperty(value = "文件名") + private String fileName; + + /** + * 文件字节数 + */ + private Long fileSize; + + /** + * 校验值 MD5 + */ + private String md5; + + /** + * 更新说明 + */ + @ExcelProperty(value = "更新说明") + private String changelog; + + /** + * 状态(0 停用 1 启用) + */ + @ExcelProperty(value = "状态") + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaTaskVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaTaskVo.java new file mode 100644 index 0000000..ca627e1 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/OtaTaskVo.java @@ -0,0 +1,108 @@ +package org.dromara.device.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated; +import org.apache.fesod.sheet.annotation.ExcelProperty; +import org.apache.fesod.sheet.annotation.format.DateTimeFormat; +import org.dromara.device.domain.BmOtaTask; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * OTA 升级任务视图对象 bm_ota_task + * + * @author Kou + * @date 2026-09-16 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = BmOtaTask.class) +public class OtaTaskVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "任务主键") + private Long taskId; + + /** + * 升级包主键 + */ + private Long packageId; + + /** + * 版本号(冗余) + */ + @ExcelProperty(value = "目标版本") + private String version; + + /** + * 设备主键 + */ + private Long deviceId; + + /** + * 设备编号(冗余) + */ + @ExcelProperty(value = "设备编号") + private String deviceNo; + + /** + * 状态(0 待升级 1 升级中 2 成功 3 失败) + */ + @ExcelProperty(value = "状态") + private String status; + + /** + * 结果信息 + */ + @ExcelProperty(value = "结果信息") + private String resultMsg; + + /** + * 进度 0-100 + */ + @ExcelProperty(value = "进度") + private Integer progress; + + /** + * 操作人(后台用户) + */ + private Long operatorId; + + /** + * 下发次数 + */ + private Integer times; + + /** + * 开始时间 + */ + @DateTimeFormat("yyyy-MM-dd HH:mm:ss") + @ExcelProperty(value = "开始时间") + private LocalDateTime startTime; + + /** + * 完成时间 + */ + @DateTimeFormat("yyyy-MM-dd HH:mm:ss") + @ExcelProperty(value = "完成时间") + private LocalDateTime finishTime; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java new file mode 100644 index 0000000..43381ed --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java @@ -0,0 +1,213 @@ +package org.dromara.device.email; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.extra.mail.MailAccount; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mail.core.MailBuilder; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.device.config.AppProperties; +import org.dromara.device.constant.AppConstants; +import org.dromara.device.constant.AppMsgConstants; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.enums.EmailSceneEnum; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +/** + * APP 邮箱验证码服务:按场景「生成 → 60 秒限流 → 发送 → 缓存 → 校验」。 + *

+ * 与 {@link org.dromara.device.sms.AppSmsService} 的关系:本服务是其邮箱通道的平行实现, + * 而非抽象重构。原因是短信链路已在迭代 I2 实测通过,抽公共父类会把已验证行为重新暴露于变更风险; + * 两处刻意保持「同一套加固口径、两套独立代码」,代价是少量重复,收益是互不影响、可分别回归。 + *

+ * 加固口径(与短信完全一致,逐条对齐): + *

    + *
  • 场景化隔离:缓存 key 与限流 key 均带场景,注册/登录/忘记密码互不覆盖;
  • + *
  • 先落缓存再发信:即使 SMTP 异常,限流 key 已存在,避免通道故障时被刷量;
  • + *
  • 正确码优先放行:校验时先判正确性再判限速,故攻击者无法用错误请求让受害者的验证码失效;
  • + *
  • 错误尝试短窗口限速:仅对错误码计数并限速,绝不删除或失效验证码本身。
  • + *
+ * + * @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); + } + + /** + * 校验邮箱验证码(一次性使用,校验通过后立即失效)。 + *

+ * 防定向 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 + " 分钟内有效,请勿泄露给他人。

" + + "

若非本人操作,请忽略本邮件。

" + + "
"; + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/AlertLevelEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/AlertLevelEnum.java new file mode 100644 index 0000000..6c77fdd --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/AlertLevelEnum.java @@ -0,0 +1,44 @@ +package org.dromara.device.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 告警级别枚举 + *

+ * 对应 {@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 { + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceBindMapper.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceBindMapper.java new file mode 100644 index 0000000..aff9c83 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceBindMapper.java @@ -0,0 +1,15 @@ +package org.dromara.device.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.device.domain.BmDeviceBind; +import org.dromara.device.domain.vo.DeviceBindVo; + +/** + * 设备绑定流水 Mapper 接口 + * + * @author Kou + * @date 2026-09-16 + */ +public interface BmDeviceBindMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceMapper.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceMapper.java new file mode 100644 index 0000000..ae79585 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceMapper.java @@ -0,0 +1,70 @@ +package org.dromara.device.mapper; + +import org.apache.ibatis.annotations.Param; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.device.domain.BmDevice; +import org.dromara.device.domain.vo.DeviceVo; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 设备主表 Mapper 接口 + *

+ * 说明:下列自定义查询均以 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 selectStaleOnlineDevices(@Param("before") LocalDateTime before, @Param("limit") int limit); + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceTelemetryMapper.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceTelemetryMapper.java new file mode 100644 index 0000000..9c83906 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmDeviceTelemetryMapper.java @@ -0,0 +1,15 @@ +package org.dromara.device.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.device.domain.BmDeviceTelemetry; +import org.dromara.device.domain.BmDeviceTelemetry; + +/** + * 设备遥测历史 Mapper 接口 + * + * @author Kou + * @date 2026-09-16 + */ +public interface BmDeviceTelemetryMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmOtaPackageMapper.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmOtaPackageMapper.java new file mode 100644 index 0000000..3d93140 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmOtaPackageMapper.java @@ -0,0 +1,15 @@ +package org.dromara.device.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.device.domain.BmOtaPackage; +import org.dromara.device.domain.vo.OtaPackageVo; + +/** + * OTA 升级包 Mapper 接口 + * + * @author Kou + * @date 2026-09-16 + */ +public interface BmOtaPackageMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmOtaTaskMapper.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmOtaTaskMapper.java new file mode 100644 index 0000000..9c90f63 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mapper/BmOtaTaskMapper.java @@ -0,0 +1,15 @@ +package org.dromara.device.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.device.domain.BmOtaTask; +import org.dromara.device.domain.vo.OtaTaskVo; + +/** + * OTA 升级任务 Mapper 接口 + * + * @author Kou + * @date 2026-09-16 + */ +public interface BmOtaTaskMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceAckListener.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceAckListener.java new file mode 100644 index 0000000..1487b24 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceAckListener.java @@ -0,0 +1,75 @@ +package org.dromara.device.mqtt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.device.mqtt.payload.AckPayload; +import org.dromara.mica.mqtt.codec.MqttQoS; +import org.dromara.mica.mqtt.core.annotation.MqttClientSubscribe; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +/** + * 指令回执上行监听({@code bm/+/ack},QoS 1)。 + *

+ * 回执是「指令成功」的唯一凭据,见协议 §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 handlerProvider; + + /** + * 处理指令回执报文。 + * + * @param topic 上行 Topic + * @param payload 报文体(UTF-8 JSON) + */ + @MqttClientSubscribe(value = MqttTopics.ACK_SUB, qos = MqttQoS.QOS1) + public void onMessage(String topic, byte[] payload) { + String deviceNo = MqttTopics.deviceNoOf(topic); + if (deviceNo == null) { + log.warn("回执 Topic 非法,已丢弃 => topic: {}", topic); + return; + } + AckPayload data; + try { + data = JsonUtils.parseObject(payload, AckPayload.class); + } catch (RuntimeException e) { + log.warn("回执报文解析失败,已丢弃 => deviceNo: {}, payload: {}", + deviceNo, new String(payload, StandardCharsets.UTF_8), e); + return; + } + String invalid = data == null ? "报文体为空" : data.invalidReason(); + if (invalid != null) { + log.warn("回执报文校验失败,已丢弃 => deviceNo: {}, reason: {}", deviceNo, invalid); + return; + } + DeviceUplinkHandler handler = handlerProvider.getIfAvailable(); + if (handler == null) { + log.info("未装配 DeviceUplinkHandler,回执仅记录 => deviceNo: {}, msgId: {}, cmd: {}, success: {}", + deviceNo, data.getMsgId(), data.getCmd(), data.success()); + return; + } + try { + handler.onAck(deviceNo, data); + } catch (Exception e) { + log.error("回执处理异常 => deviceNo: {}, msgId: {}", deviceNo, data.getMsgId(), e); + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceCommandPublisher.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceCommandPublisher.java new file mode 100644 index 0000000..f793b40 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceCommandPublisher.java @@ -0,0 +1,138 @@ +package org.dromara.device.mqtt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.device.mqtt.payload.HoodCommandPayload; +import org.dromara.device.mqtt.payload.OtaCommandPayload; +import org.dromara.device.mqtt.payload.ProvisionAckPayload; +import org.dromara.device.mqtt.payload.StreamCommandPayload; +import org.dromara.mica.mqtt.codec.MqttQoS; +import org.dromara.mica.mqtt.spring.client.MqttClientTemplate; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * 下行指令发布器。 + *

+ * 口径见《设备通信协议-一期.md》§7。职责边界: + *

    + *
  • 只做「拼 Topic + 序列化 + 发布 + 日志」,不注册/等待回执 + * (回执等待由指令服务基于 {@code msgId} 的 {@code CompletableFuture} 完成);
  • + *
  • {@code msgId} 由调用方生成并先行注册 Future 再调用发布,避免「回执先于注册到达」的竞态; + * 生成用 {@link #newMsgId()};
  • + *
  • 所有下行 QoS 固定 {@code QOS1},Retain 关闭。
  • + *
+ * {@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 clientProvider; + + /** + * 生成指令唯一标识。 + *

+ * 采用 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 handlerProvider; + + /** + * 处理设备事件报文。 + * + * @param topic 上行 Topic + * @param payload 报文体(UTF-8 JSON) + */ + @MqttClientSubscribe(value = MqttTopics.EVENT_SUB, qos = MqttQoS.QOS1) + public void onMessage(String topic, byte[] payload) { + String deviceNo = MqttTopics.deviceNoOf(topic); + if (deviceNo == null) { + log.warn("事件 Topic 非法,已丢弃 => topic: {}", topic); + return; + } + EventPayload data; + try { + data = JsonUtils.parseObject(payload, EventPayload.class); + } catch (RuntimeException e) { + log.warn("事件报文解析失败,已丢弃 => deviceNo: {}, payload: {}", + deviceNo, new String(payload, StandardCharsets.UTF_8), e); + return; + } + String invalid = data == null ? "报文体为空" : data.invalidReason(); + if (invalid != null) { + log.warn("事件报文校验失败,已丢弃 => deviceNo: {}, reason: {}", deviceNo, invalid); + return; + } + if (data.resolveType() == null) { + log.warn("未知事件类型,已忽略(前向兼容) => deviceNo: {}, eventType: {}", + deviceNo, data.getEventType()); + return; + } + DeviceUplinkHandler handler = handlerProvider.getIfAvailable(); + if (handler == null) { + log.debug("未装配 DeviceUplinkHandler,事件仅记录不处理 => deviceNo: {}, eventType: {}", + deviceNo, data.getEventType()); + return; + } + try { + handler.onEvent(deviceNo, data); + } catch (Exception e) { + log.error("事件处理异常 => deviceNo: {}, eventType: {}", deviceNo, data.getEventType(), e); + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceOnlineMonitor.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceOnlineMonitor.java new file mode 100644 index 0000000..41540b6 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceOnlineMonitor.java @@ -0,0 +1,146 @@ +package org.dromara.device.mqtt; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.device.config.DeviceMqttProperties; +import org.dromara.device.constant.DeviceMqttConstants; +import org.dromara.device.domain.BmDevice; +import org.dromara.device.mapper.BmDeviceMapper; +import org.dromara.device.mqtt.support.DevicePresenceWriter; +import org.redisson.api.RLock; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * 设备离线扫描:把「心跳超时」翻译成「离线状态 + 离线告警」。 + *

+ * 口径见《设备通信协议-一期.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 candidates = deviceMapper.selectStaleOnlineDevices(before, batchSize); + if (candidates.isEmpty()) { + return; + } + int offlineCount = 0; + for (BmDevice device : candidates) { + if (presenceWriter.markOffline(device)) { + offlineCount++; + } + } + log.info("设备离线扫描完成 => 候选: {} 台, 实际置离线: {} 台", candidates.size(), offlineCount); + if (candidates.size() >= batchSize) { + log.warn("设备离线扫描单轮命中已达上限 {},可能存在积压,请关注设备接入量与扫描间隔", + batchSize); + } + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DevicePendingAckRegistry.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DevicePendingAckRegistry.java new file mode 100644 index 0000000..2f5ded9 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DevicePendingAckRegistry.java @@ -0,0 +1,210 @@ +package org.dromara.device.mqtt; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.device.config.DeviceMqttProperties; +import org.dromara.device.constant.DeviceMqttConstants; +import org.dromara.device.mqtt.payload.AckPayload; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * 待回执指令登记表:把下行指令与上行 {@code ack} 按 {@code msgId} 关联起来。 + *

+ * 协议 §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 pendingCommands = new ConcurrentHashMap<>(); + + /** + * 登记一条待回执指令。 + *

+ * 调用顺序有硬性要求:必须在 {@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 register(String msgId, String deviceNo, String cmd, + Boolean desiredCoverOn, Long otaTaskId, Duration timeout) { + PendingCommand pending = new PendingCommand(deviceNo, cmd, desiredCoverOn, otaTaskId); + pendingCommands.put(msgId, pending); + RedisUtils.setCacheObject(DeviceMqttConstants.PENDING_ACK_PREFIX + msgId, deviceNo, + properties.getPendingAckTtl()); + pending.getFuture() + .completeOnTimeout(null, timeout.toMillis(), TimeUnit.MILLISECONDS) + .whenComplete((ack, ex) -> pendingCommands.remove(msgId, pending)); + log.info("指令已登记待回执 => deviceNo: {}, cmd: {}, msgId: {}, timeout: {}ms", + deviceNo, cmd, msgId, timeout.toMillis()); + return pending.getFuture(); + } + + /** + * 取出回执对应的指令上下文,但不唤醒等待方。 + *

+ * 调用方应先用返回的上下文落定业务事实(写库),再调用 {@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 future = new CompletableFuture<>(); + + /** + * 构造指令上下文。 + * + * @param deviceNo 目标设备编号 + * @param cmd 指令类型 + * @param desiredCoverOn 眼罩期望状态 + * @param otaTaskId OTA 任务主键 + */ + public PendingCommand(String deviceNo, String cmd, Boolean desiredCoverOn, Long otaTaskId) { + this.deviceNo = deviceNo; + this.cmd = cmd; + this.desiredCoverOn = desiredCoverOn; + this.otaTaskId = otaTaskId; + } + + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceProvisionListener.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceProvisionListener.java new file mode 100644 index 0000000..d51ae99 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceProvisionListener.java @@ -0,0 +1,69 @@ +package org.dromara.device.mqtt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.device.mqtt.payload.ProvisionPayload; +import org.dromara.mica.mqtt.codec.MqttQoS; +import org.dromara.mica.mqtt.core.annotation.MqttClientSubscribe; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +/** + * 设备注册上行监听({@code bm/provision/register},QoS 1)。 + *

+ * 解决「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 handlerProvider; + + /** + * 处理设备注册报文。 + * + * @param topic 上行 Topic + * @param payload 报文体(UTF-8 JSON) + */ + @MqttClientSubscribe(value = MqttTopics.PROVISION_REGISTER_SUB, qos = MqttQoS.QOS1) + public void onMessage(String topic, byte[] payload) { + ProvisionPayload data; + try { + data = JsonUtils.parseObject(payload, ProvisionPayload.class); + } catch (RuntimeException e) { + log.warn("注册报文解析失败,已丢弃 => payload: {}", + new String(payload, StandardCharsets.UTF_8), e); + return; + } + String invalid = data == null ? "报文体为空" : data.invalidReason(); + if (invalid != null) { + log.warn("注册报文校验失败,已丢弃 => reason: {}", invalid); + return; + } + DeviceUplinkHandler handler = handlerProvider.getIfAvailable(); + if (handler == null) { + log.info("未装配 DeviceUplinkHandler,注册仅记录 => imei: {}, sn: {}", data.getImei(), data.getSn()); + return; + } + try { + handler.onRegister(data); + } catch (Exception e) { + log.error("注册处理异常 => imei: {}", data.getImei(), e); + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceTelemetryListener.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceTelemetryListener.java new file mode 100644 index 0000000..d9b0328 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceTelemetryListener.java @@ -0,0 +1,76 @@ +package org.dromara.device.mqtt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.device.mqtt.payload.TelemetryPayload; +import org.dromara.mica.mqtt.codec.MqttQoS; +import org.dromara.mica.mqtt.core.annotation.MqttClientSubscribe; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +/** + * 遥测上行监听({@code bm/+/telemetry},QoS 0)。 + *

+ * 只做「解析 + 结构校验 + 转发」,业务处理见 {@link DeviceUplinkHandler}。 + * 丢弃策略(见协议 §5.1):Topic 非法、JSON 解析失败、必填字段缺失或越界 → 整包丢弃并记 WARN, + * 不落库,避免脏数据;QoS 0 语义下丢弃是允许的(下一条遥测会整份覆盖最新态)。 + * + * @author Kou + * @date 2026-09-16 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DeviceTelemetryListener { + + /** + * 上行处理端口(可选注入,未装配时仅记日志) + */ + private final ObjectProvider handlerProvider; + + /** + * 处理遥测报文。 + * + * @param topic 上行 Topic + * @param payload 报文体(UTF-8 JSON) + */ + @MqttClientSubscribe(value = MqttTopics.TELEMETRY_SUB, qos = MqttQoS.QOS0) + public void onMessage(String topic, byte[] payload) { + String deviceNo = MqttTopics.deviceNoOf(topic); + if (deviceNo == null) { + log.warn("遥测 Topic 非法,已丢弃 => topic: {}", topic); + return; + } + TelemetryPayload data; + try { + data = JsonUtils.parseObject(payload, TelemetryPayload.class); + } catch (RuntimeException e) { + log.warn("遥测报文解析失败,已丢弃 => deviceNo: {}, payload: {}", + deviceNo, new String(payload, StandardCharsets.UTF_8), e); + return; + } + if (data == null) { + log.warn("遥测报文为空,已丢弃 => deviceNo: {}", deviceNo); + return; + } + String invalid = data.invalidReason(); + if (invalid != null) { + log.warn("遥测报文校验失败,已丢弃 => deviceNo: {}, reason: {}", deviceNo, invalid); + return; + } + DeviceUplinkHandler handler = handlerProvider.getIfAvailable(); + if (handler == null) { + log.debug("未装配 DeviceUplinkHandler,遥测仅记录不落库 => deviceNo: {}", deviceNo); + return; + } + try { + handler.onTelemetry(deviceNo, data); + } catch (Exception e) { + log.error("遥测处理异常 => deviceNo: {}", deviceNo, e); + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceUplinkHandler.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceUplinkHandler.java new file mode 100644 index 0000000..1b392a4 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mqtt/DeviceUplinkHandler.java @@ -0,0 +1,78 @@ +package org.dromara.device.mqtt; + +import org.dromara.device.mqtt.payload.AckPayload; +import org.dromara.device.mqtt.payload.EventPayload; +import org.dromara.device.mqtt.payload.ProvisionPayload; +import org.dromara.device.mqtt.payload.TelemetryPayload; + +/** + * 上行报文处理端口。 + *

+ * 为什么单独抽这一层:MQTT 监听器只负责「收包 → 解析 → 校验 → 转发」, + * 不掺业务;持久化与状态流转(写主表最新态、落遥测历史、生成告警、唤醒指令 + * {@code CompletableFuture}、OTA 进度)属于业务层职责,由 I6(MQTT 生产化)落地实现。 + *

+ * 监听器通过 {@code ObjectProvider} 可选注入本接口:未装配实现时只记录日志, + * 不阻断应用启动——这样 mqtt 包可以先合入而不必等业务实现到位。 + *

+ * 实现方必须遵守的约定: + *

    + *
  • 本方法运行在 mica-mqtt 的业务线程池(虚拟线程)上,不得阻塞超过毫秒级; + * 耗时的落库、HTTP 调用须转投线程池或消息队列;
  • + *
  • 异常必须自行捕获,不得向监听器抛出(否则会污染 MQTT 客户端的消息处理链路);
  • + *
  • 设备身份一律以参数 {@code deviceNo} 为准(取自 Topic),不得信任报文内字段。
  • + *
+ * + * @author Kou + * @date 2026-09-16 + */ +public interface DeviceUplinkHandler { + + /** + * 处理遥测上报。 + *

+ * 约定动作:{@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 寻址。 + *

+ * 约定: + *

    + *
  • 设备端只允许订阅 {@code bm/{自身deviceNo}/command/#},禁止跨设备通配订阅;
  • + *
  • 所有 Topic Retain 一律为 {@code false},在线态以心跳判定为准;
  • + *
  • 新增业务只允许新增叶子节点,不得改变既有层级语义。
  • + *
+ * + * @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。 + *

+ * 仅用于设备侧实现与联调校验;服务端不使用该通配。 + * + * @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}。 + * 这样切换/替换任一策略都不需要改本类。 + *

+ * 贯穿全类的四条约定: + *

    + *
  1. 身份只来自 Topic:所有入口的 {@code deviceNo} 都由监听器从 Topic 解析后传入, + * 报文里没有设备号,因此伪造报文也无法冒充别的设备。
  2. + *
  3. 未建档即丢弃:{@code bm_device} 里查不到该 {@code deviceNo}(后台未建档 / 已逻辑删除) + * 时不落任何数据——无法归属的遥测没有存储价值,反而会污染统计。
  4. + *
  5. 异常不外抛:本类运行在 mica-mqtt 业务线程上,任何异常都就地消化并记日志, + * 避免污染 MQTT 客户端的消息处理链路。
  6. + *
  7. 即时返回:除设备最新态与告警(不可丢)外,其余耗时写入走异步;本类不做阻塞等待。
  8. + *
+ * + * @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; + + /** + * 处理遥测上报。 + *

+ * 三步:① 一条 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)。流程刻意分成三步,顺序不可调换: + *

    + *
  1. {@code peek} —— 按 {@code msgId} 取出下发时登记的上下文(此时不唤醒下发方);
  2. + *
  3. 落定业务事实 —— 仅在成功时把指令期望状态写到设备上(OTA 则收尾任务);
  4. + *
  5. {@code settle} —— 唤醒等待中的下发方。
  6. + *
+ * 为什么必须先落库再唤醒:唤醒后 HTTP 线程会立刻把「成功」返回给 APP, + * 用户紧接着刷新详情页。若落库排在唤醒之后,这次刷新有概率读到旧值—— + * 一个只在快网络下偶发、却极容易被当成「指令没生效」的竞态。 + *

+ * {@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。流程约定: + *

    + *
  1. 设备收到后立即回 {@code ack{code:0}} 表示已受理(不是已完成);
  2. + *
  3. 升级期间周期上报 {@code event/ota_progress}(≤ 1 次/10s);
  4. + *
  5. 最终结果以 {@code ack} 为准(成功 progress=100,失败 code=3),进度事件不承载结果;
  6. + *
  7. {@code md5} 校验失败必须回 {@code code=3},{@code msg="md5 mismatch"}。
  8. + *
+ * 一期整包下载,不做分片与断点续传。 + * + * @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)。 + *

+ * 口径见《设备通信协议-一期.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。要点: + *

    + *
  • 全量快照:6 个必填状态字段必须齐备,服务端整份覆盖主表最新态,不做增量合并;
  • + *
  • 必填字段缺失或越界 → 整包丢弃(不落库),避免脏数据;
  • + *
  • {@code ts} 为设备本地时间(epoch 毫秒),仅供诊断;落库 {@code report_time} 用服务端接收时间;
  • + *
  • 报文不含 deviceNo,设备身份取自 Topic(防跨设备伪造)。
  • + *
+ * + * @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} 的唯一写入入口。 + *

+ * 把「什么时候该告警」的策略收在一处,避免遥测、事件、离线扫描三条来源各写各的导致重复告警 + * (协议 §11 明确要求:低电告警以服务端阈值判定为准,设备事件只作提前提示)。 + *

+ * 两条策略: + *

    + *
  1. 去重窗口({@link DeviceMqttProperties#getAlertDedupWindow()}):同一 + * {@code deviceNo + alertType} 在窗口内只落一条。窗口占位用 + * {@link RedisUtils#setObjectIfAbsent} 一次完成「判重 + 占位」,天然并发安全——多个来源 + * 同时判定时只有一个能拿到占位。
  2. + *
  3. 低电滞回闩锁({@link DeviceMqttConstants#BATTERY_LOW_PREFIX}):设备长期停在低电量时, + * 仅靠去重窗口会「每 30 分钟再告一次」;闩锁记录「当前处于低电态」,只有电量回升越过恢复阈值 + * 才清除,从而做到「一次低电只告一次」。
  4. + *
+ *

+ * 与离线告警的区别:离线是「状态迁移」,由 {@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 listByUser(Long userId); + + /** + * 查询当前用户某台设备的详情(坐标、眼罩、双电量、信号、头套连接、地址)。 + * + * @param deviceNo 设备编号 + * @param userId 当前用户主键 + * @return 设备详情 + */ + AppDeviceDetailVo detail(String deviceNo, Long userId); + + /** + * 绑定设备(扫码 / 手动添加)。 + *

+ * 三类失败规则(《开发计划》§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} 才算成功,因此无法先返回再异步补状态。 + *

+ * 校验顺序(先归属、后可操作): + *

    + *
  1. 归属隔离:{@code bind_user_id = userId} 查不到 → 统一 {@code DEVICE_NOT_EXIST}(不泄露归属);
  2. + *
  3. 协议 §8.3 拦截:非在线 → {@code DEVICE_OFFLINE};低电遮盖 → {@code LOW_BATTERY};
  4. + *
  5. 下发并等待回执:未确认执行 → {@code COMMAND_SEND_FAILED}。
  6. + *
+ * 为什么归属判定必须排在最前:否则「离线设备」的报错会先于「设备不存在」出现, + * 攻击者可用枚举 {@code deviceNo} 的方式,靠返回文案差异推断哪些编号真实存在。 + * + * @param bo 眼罩入参(deviceNo + coverOn) + * @param userId 当前用户主键 + * @return 设备确认执行后的眼罩状态 + */ + AppHoodResultVo hood(AppDeviceHoodBo bo, Long userId); + + /** + * 查询当前用户某台设备的定位「最新点」({@code GET /app/device/{deviceNo}/location})。 + *

+ * 越权隔离与 {@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}: + *

    + *
  • 状态变更 / 重置密码:类型不匹配则影响行数为 0,等价于「目标不存在」;
  • + *
  • 解绑设备:沿用 {@code IDeviceBindService#unbind} 的归属校验({@code bind_user_id = userId})。
  • + *
+ *

+ * 密码不跨界:重置密码只把明文交给 {@code AppUserApiService}, + * 散列在 {@code ruoyi-system} 内部完成后落库,本层不接触散列值。 + * + * @author Kou + * @date 2026-09-17 + */ +public interface IAppUserAdminService { + + /** + * 分页查询 APP 用户列表(结果恒不含后台账号)。 + * + * @param bo 查询条件(关键词 / 状态 / 注册时间区间 / 分页) + * @return APP 用户分页结果(含绑定设备数) + */ + PageResult queryPageList(AppUserAdminQueryBo bo); + + /** + * 查询 APP 用户详情。 + * + * @param userId 用户主键 + * @return 用户详情;不存在或非 APP 账号时返回 {@code null} + */ + AppUserAdminVo queryById(Long userId); + + /** + * 查询指定 APP 用户当前绑定的设备列表。 + * + * @param userId 用户主键 + * @return 绑定设备列表(无绑定时为空列表) + */ + List listDevices(Long userId); + + /** + * 修改账号状态(停用 / 启用)。 + * + * @param bo 状态变更入参 + * @return true 目标状态已生效 + */ + Boolean updateStatus(AppUserStatusBo bo); + + /** + * 重置账号密码(管理员代为设置)。 + * + * @param bo 重置密码入参(新密码须满足 APP 端密码规则) + * @return true 重置成功 + */ + Boolean resetPassword(AppUserResetPwdBo bo); + + /** + * 解绑该用户名下的某台设备。 + *

+ * 复用单一解绑入口 {@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 where device_no=? and bind_user_id=?}, + * 仅当该设备确实绑定到 {@code userId} 时才生效;影响行数即结果。
  • + *
  • 清空 {@code bind_user_id} 与 {@code bind_time}、状态回归「未激活」;保留 {@code device_no} + * (《开发计划》§7.2 状态机:解绑后可凭原编号再次绑定)。
  • + *
  • 成功(影响行数 > 0)时写一条 {@code unbind} 绑定流水。
  • + *
+ * {@link #unbindAllByUser(Long)} 复用本方法(单台 = N 台的特例),保证解绑逻辑只有一处实现。 + * + * @param deviceNo 设备编号 + * @param userId APP 用户主键 + * @return 实际影响行数(1 = 成功解绑;0 = 未建档 / 属于他人 / 未绑定,均视为无可解绑) + */ + int unbind(String deviceNo, Long userId); + + /** + * 解除某 APP 用户下全部设备的绑定。 + *

+ * 对每台绑定到 {@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): + *

    + *
  • 设备非在线({@code status != 1})→ {@code DEVICE_OFFLINE},不下行、不排队、不补投;
  • + *
  • 头套电量 ≤ 低电阈值 → {@code LOW_BATTERY},仅拦「遮盖」方向。
  • + *
+ * 后置:回执成功时设备主表的 {@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 queryPageList(DeviceBo bo, PageQuery pageQuery); + + /** + * 查询符合条件的设备列表(导出用) + * + * @param bo 查询条件 + * @return 设备视图列表 + */ + List queryList(DeviceBo bo); + + /** + * 新增设备建档(device_no 留空时自动生成) + * + * @param bo 新增业务对象 + * @return 是否新增成功 + */ + Boolean insertByBo(DeviceBo bo); + + /** + * 修改设备(**不支持修改 {@code deviceNo}**)。 + *

+ * 编号不可变:入参编号与库中现值不一致时抛 + * {@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 ids); + + /** + * 校验并批量删除设备 + * + * @param ids 设备主键集合 + * @param isValid 是否执行删除前校验 + * @return 是否删除成功 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IOtaPackageService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IOtaPackageService.java new file mode 100644 index 0000000..7fb8a12 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IOtaPackageService.java @@ -0,0 +1,69 @@ +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.OtaPackageBo; +import org.dromara.device.domain.vo.OtaPackageVo; + +import java.util.Collection; + +/** + * OTA 升级包服务接口(后台,前缀 {@code /device/ota/package})。 + *

+ * 升级包是 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 queryPageList(OtaPackageBo bo, PageQuery pageQuery); + + /** + * 登记(新增)升级包。 + *

+ * {@code status} 留空时默认「停用」({@code 0}),避免刚登记就被批量下发误用; + * 需要下发时再由后台显式启用。 + * + * @param bo 新增业务对象 + * @return 是否新增成功 + */ + Boolean insertByBo(OtaPackageBo bo); + + /** + * 修改升级包(含启用 / 停用)。 + * + * @param bo 编辑业务对象 + * @return 是否修改成功 + */ + Boolean updateByBo(OtaPackageBo bo); + + /** + * 批量删除升级包(逻辑删除)。 + * + * @param ids 升级包主键集合 + * @return 是否删除成功 + */ + Boolean deleteByIds(Collection ids); + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IOtaTaskService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IOtaTaskService.java new file mode 100644 index 0000000..15a173c --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IOtaTaskService.java @@ -0,0 +1,60 @@ +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.OtaBatchBo; +import org.dromara.device.domain.bo.OtaTaskBo; +import org.dromara.device.domain.vo.OtaBatchResultVo; +import org.dromara.device.domain.vo.OtaTaskVo; + +/** + * OTA 升级任务服务接口(后台,前缀 {@code /device/ota/task}、{@code /device/ota/batch})。 + *

+ * 任务的生命周期与设备回执紧密耦合:创建即「待升级」({@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 queryPageList(OtaTaskBo bo, PageQuery pageQuery); + + /** + * 按勾选设备批量创建升级任务并逐台下发(PRD §5 R-31)。 + *

+ * 整体前置:目标升级包必须存在且已启用,否则整批拒绝({@code OTA_PACKAGE_UNAVAILABLE}), + * 不产生任何任务。 + *

+ * 逐台处置(去重后每台设备恰好落入 {@code created / skipped / failed} 之一, + * 因此 {@code total == created + skipped + failed} 恒成立): + *

    + *
  1. 设备不存在 → {@code failed}({@code OTA_BATCH_FAIL_DEVICE_NOT_EXIST});
  2. + *
  3. 型号与升级包不匹配 → {@code skipped}({@code OTA_BATCH_SKIP_MODEL_MISMATCH});
  4. + *
  5. 设备已是最新版本({@code fwVersion == package.version})→ {@code skipped} + * ({@code OTA_BATCH_SKIP_ALREADY_LATEST});
  6. + *
  7. 已存在同包进行中任务(待升级 / 升级中)→ {@code skipped}({@code OTA_BATCH_SKIP_DUPLICATE});
  8. + *
  9. 设备非在线 → {@code failed}({@code OTA_BATCH_FAIL_DEVICE_OFFLINE},不下行、不排队);
  10. + *
  11. 其余 → 建任务(待升级)→ 登记回执 → 下发 → 等待回执; + * 受理成功 → {@code created},发布失败 / 回执超时 / 设备拒绝 → {@code failed} + * ({@code OTA_BATCH_FAIL_DISPATCH})。
  12. + *
+ * 判定优先级见上述顺序(业务跳过规则先于离线判定)。 + *

+ * 本方法不加 {@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 账号服务实现。 + *

+ * 依赖口径: + *

    + *
  • 账号读写通过 {@link AppUserApiService}(边界方案 A),密码校验在 {@code ruoyi-system} 内完成,密码不跨界。
  • + *
  • 登录会话以 {@code app_user:{userId}} 作为 Sa-Token 登录标识(见 {@link LoginUser#getLoginId()}), + * 与后台 {@code sys_user:{userId}} 天然隔离。
  • + *
  • 客户端身份(请求头 {@code clientid} 取值 / 访问路径 / 设备类型 / 超时)取自 + * {@link AppProperties.Client},其默认值与 {@code sys_client} 的 app 记录保持镜像 + * (已知偏差详见 {@code AppProperties.Client#clientId} 注释与交付说明 §七)。
  • + *
+ * + * @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()); + } + + /** + * 手机号注册:先判重,再校验验证码,最后落库。 + *

+ * 并发保护(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 设备服务实现(列表 / 详情 / 绑定 / 重命名 / 解绑)。 + * + *

一、越权隔离(本实现的核心约束)

+ * 每一条「按编号定位设备」的读或写,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 listByUser(Long userId) { + requireUser(userId); + return deviceMapper.lambda() + .eq(BmDevice::getBindUserId, userId) + .orderByDesc(BmDevice::getBindTime) + .list() + .stream() + .map(this::toListVo) + .toList(); + } + + /** + * 查询当前用户某台设备的详情(越权隔离:非本人设备一律返回「设备不存在」)。 + * + * @param deviceNo 设备编号 + * @param userId 当前用户主键 + * @return 设备详情 + */ + @Override + public AppDeviceDetailVo detail(String deviceNo, Long userId) { + requireUser(userId); + return toDetailVo(findOwnedDevice(deviceNo, userId)); + } + + /** + * 绑定设备(条件更新防并发双绑;成功才写 bind 流水)。 + *

+ * 前置硬约束(本迭代新增):账号须已绑定手机号。邮箱注册的账号在建号时 + * {@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 queryPageList(AppUserAdminQueryBo bo) { + AppUserAdminQueryBo condition = bo == null ? new AppUserAdminQueryBo() : bo; + + AppUserQueryDTO query = new AppUserQueryDTO(); + query.setKeyword(condition.getKeyword()); + query.setStatus(condition.getStatus()); + query.setParams(condition.getParams() == null ? new HashMap<>() : condition.getParams()); + query.setPageNum(condition.getPageNum()); + query.setPageSize(condition.getPageSize()); + + PageResult page = appUserApiService.selectAppUserPage(query); + List rows = page.getRows().stream().map(this::toVo).toList(); + + if (!rows.isEmpty()) { + Map deviceCounts = countDevicesByUser( + rows.stream().map(AppUserAdminVo::getUserId).filter(Objects::nonNull).toList()); + rows.forEach(vo -> vo.setDeviceCount(deviceCounts.getOrDefault(vo.getUserId(), 0L))); + } + + return PageResult.build(rows, page.getTotal()); + } + + /** + * 查询 APP 用户详情。 + *

+ * 「不存在」与「非 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 listDevices(Long userId) { + if (userId == null || appUserApiService.selectAppUserById(userId) == null) { + return List.of(); + } + DeviceBo query = new DeviceBo(); + query.setBindUserId(userId); + return deviceService.queryList(query).stream().map(this::toDeviceVo).toList(); + } + + /** + * 修改账号状态(停用 / 启用)。 + *

+ * {@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 countDevicesByUser(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyMap(); + } + List devices = deviceMapper.selectList( + new LambdaQueryWrapper() + .select(BmDevice::getBindUserId) + .in(BmDevice::getBindUserId, userIds)); + return devices.stream() + .filter(device -> device.getBindUserId() != null) + .collect(Collectors.groupingBy(BmDevice::getBindUserId, Collectors.counting())); + } + + /** + * DTO → 管理端 VO,并派生「是否已绑手机号」。 + *

+ * {@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 与 §七): + *

    + *
  • 解绑成功、停用失败:设备已解绑且已写 unbind 流水,账号仍为可用(status=正常); + * 用户数据处于「未绑定设备但仍可登录」的中间态,重新发起注销即可(解绑对已解绑设备为幂等空操作)。
  • + *
  • 停用成功、全端下线失败:账号已停用,登录入口 {@code checkAccountEnabled} 会拦截再次登录; + * 残留 token 至多存活到其有效期结束,重试注销会再次执行全端下线(幂等)。
  • + *
+ * + * @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()); + } + + /** + * 绑定 / 换绑手机号:先拦截「新号==当前号」,再校验新号未被占用,再校验新号收到的验证码,最后落库。 + *

+ * 场景自适应(本迭代新增):按当前账号是否已有手机号选择验证码场景—— + *

    + *
  • 当前无手机号(邮箱注册的账号)→ 场景 {@link AppConstants#SCENE_BIND}「绑定手机」;
  • + *
  • 当前已有手机号 → 场景 {@link AppConstants#SCENE_REBIND}「换绑手机」(原有行为不变)。
  • + *
+ * 两个场景的验证码缓存 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.lambdaQuery() + .eq(BmDeviceBind::getDeviceId, deviceId) + .orderByDesc(BmDeviceBind::getBindTime) + .last("limit 1")); + return bind == null ? null : bind.getBindTime(); + } + + /** + * 解除单台设备对某用户的绑定(唯一解绑实现)。 + *

+ * 先用「越权条件」定位待解绑设备({@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 devices = deviceMapper.lambda() + .eq(BmDevice::getBindUserId, userId) + .list(); + int count = 0; + for (BmDevice device : devices) { + count += unbind(device.getDeviceNo(), userId); + } + return count; + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceCommandServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceCommandServiceImpl.java new file mode 100644 index 0000000..830090f --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceCommandServiceImpl.java @@ -0,0 +1,154 @@ +package org.dromara.device.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.device.config.DeviceMqttProperties; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.BmDevice; +import org.dromara.device.enums.DeviceStatusEnum; +import org.dromara.device.enums.HoodCoverEnum; +import org.dromara.device.mqtt.DeviceCommandPublisher; +import org.dromara.device.mqtt.DevicePendingAckRegistry; +import org.dromara.device.mqtt.payload.AckPayload; +import org.dromara.device.mqtt.payload.HoodCommandPayload; +import org.dromara.device.service.IDeviceCommandService; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * 设备指令下发服务实现(见 {@link IDeviceCommandService} 的契约说明)。 + * + *

一、顺序即正确性:登记 → 发布 → 等待

+ * {@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 future = ackRegistry.register(msgId, deviceNo, + HoodCommandPayload.CMD, coverOn, null, properties.getHoodTimeout()); + if (!commandPublisher.publishHood(deviceNo, msgId, coverOn)) { + // 未提交到 MQTT:回执永远不会来,撤销登记并立刻失败,不空等一个超时周期 + ackRegistry.abandon(msgId); + throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED); + } + + AckPayload ack = await(future, deviceNo, msgId); + if (ack == null) { + log.warn("眼罩指令超时未收到回执 => deviceNo: {}, msgId: {}, coverOn: {}, timeout: {}ms", + deviceNo, msgId, coverOn, properties.getHoodTimeout().toMillis()); + throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED); + } + if (!ack.success()) { + log.warn("眼罩指令被设备拒绝 => deviceNo: {}, msgId: {}, coverOn: {}, code: {}, msg: {}", + deviceNo, msgId, coverOn, ack.getCode(), ack.getMsg()); + throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED); + } + log.info("眼罩指令已确认执行 => deviceNo: {}, msgId: {}, coverOn: {}", deviceNo, msgId, coverOn); + return coverOn ? HoodCoverEnum.COVERED : HoodCoverEnum.OPEN; + } + + /** + * 下发前置拦截(协议 §8.3)。 + *

+ * 两条规则的差异:离线是双向硬拦(指令根本送不到,排队/补投只会造成「延迟的意外动作」); + * 低电只拦遮盖(见类注释「低电只拦遮盖」)。 + *

+ * 低电阈值取该设备自己配置的 {@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 future, String deviceNo, String msgId) { + long budget = properties.getHoodTimeout().toMillis() + AWAIT_GRACE_MILLIS; + try { + return future.get(budget, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + // 恢复中断标志:容器回收线程或客户端断连时会走这里 + Thread.currentThread().interrupt(); + log.warn("等待回执被中断 => deviceNo: {}, msgId: {}", deviceNo, msgId); + return null; + } catch (ExecutionException | TimeoutException e) { + log.warn("等待回执异常 => deviceNo: {}, msgId: {}", deviceNo, msgId, e); + return null; + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceServiceImpl.java new file mode 100644 index 0000000..6cf658f --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceServiceImpl.java @@ -0,0 +1,373 @@ +package org.dromara.device.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.PageResult; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.BmDevice; +import org.dromara.device.domain.bo.DeviceBo; +import org.dromara.device.domain.vo.DeviceStatsVo; +import org.dromara.device.domain.vo.DeviceVo; +import org.dromara.device.enums.DeviceStatusEnum; +import org.dromara.device.mapper.BmDeviceMapper; +import org.dromara.device.service.IDeviceService; +import org.redisson.api.RLock; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.List; + +/** + * 后台设备服务业务层处理 + * + * @author Kou + * @date 2026-09-16 + */ +@RequiredArgsConstructor +@Service +public class DeviceServiceImpl implements IDeviceService { + + /** + * 设备编号前缀 + */ + private static final String DEVICE_NO_PREFIX = "BM"; + + /** + * 日期格式 + */ + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + /** + * 当日序号上限(4 位) + */ + private static final int MAX_SEQ = 9999; + + /** + * 取号分布式锁 key 前缀(按日加锁) + */ + private static final String DEVICE_NO_LOCK_PREFIX = "bm:device:no:lock:"; + + private final BmDeviceMapper deviceMapper; + + /** + * 查询设备详情 + * + * @param deviceId 设备主键 + * @return 设备视图对象 + */ + @Override + public DeviceVo queryById(Long deviceId) { + return deviceMapper.selectVoById(deviceId); + } + + /** + * 分页查询设备列表 + * + * @param bo 查询条件 + * @param pageQuery 分页参数 + * @return 分页结果 + */ + @Override + public PageResult queryPageList(DeviceBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = deviceMapper.selectVoPage(pageQuery.build(), lqw); + return PageResult.build(result.getRecords(), result.getTotal()); + } + + /** + * 查询符合条件的设备列表(导出用) + * + * @param bo 查询条件 + * @return 设备视图列表 + */ + @Override + public List queryList(DeviceBo bo) { + return deviceMapper.selectVoList(buildQueryWrapper(bo)); + } + + /** + * 构建设备动态查询条件 + * + * @param bo 查询条件 + * @return 查询条件包装器 + */ + private LambdaQueryWrapper buildQueryWrapper(DeviceBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getDeviceNo()), BmDevice::getDeviceNo, bo.getDeviceNo()); + lqw.like(StringUtils.isNotBlank(bo.getImei()), BmDevice::getImei, bo.getImei()); + lqw.like(StringUtils.isNotBlank(bo.getName()), BmDevice::getName, bo.getName()); + lqw.eq(StringUtils.isNotBlank(bo.getModel()), BmDevice::getModel, bo.getModel()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmDevice::getStatus, bo.getStatus()); + lqw.eq(bo.getBindUserId() != null, BmDevice::getBindUserId, bo.getBindUserId()); + if (StringUtils.isNotBlank(bo.getKeyword())) { + String keyword = bo.getKeyword(); + lqw.and(w -> w.like(BmDevice::getDeviceNo, keyword) + .or().like(BmDevice::getName, keyword) + .or().like(BmDevice::getImei, keyword)); + } + lqw.orderByDesc(BmDevice::getCreateTime); + return lqw; + } + + /** + * 新增设备建档(device_no 留空时自动生成)。 + *

+ * 并发控制:以「按日」为粒度(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 绑定关系 + * 与运维话术引用;允许改号会产生「同一台设备两个编号」的口径混乱。因此: + *

    + *
  • 入参带了编号且与库中现值不一致 → 抛 {@link ServiceException} + * (显式拒绝,让调用方拿到明确反馈,而非静默忽略);
  • + *
  • 入参未带或带空串 → 语义是「本次不改编号」,一律以库中现值回填后再更新。
  • + *
+ * 第二条是必需的兜底:MyBatis-Plus 全局 {@code updateStrategy = NOT_NULL}, + * 而空串「非 null」——若不回填,空串会被当作有效值写入 {@code device_no}, + * 既破坏业务主键、又可能撞唯一键 {@code uk_device_no}。 + *

+ * 存在性校验:{@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.lambdaQuery())); + vo.setOnline(deviceMapper.selectCount(Wrappers.lambdaQuery() + .eq(BmDevice::getStatus, DeviceStatusEnum.ONLINE.getCode()))); + vo.setOffline(deviceMapper.selectCount(Wrappers.lambdaQuery() + .eq(BmDevice::getStatus, DeviceStatusEnum.OFFLINE.getCode()))); + vo.setInactive(deviceMapper.selectCount(Wrappers.lambdaQuery() + .eq(BmDevice::getStatus, DeviceStatusEnum.NOT_ACTIVATED.getCode()))); + return vo; + } + + /** + * 批量删除设备(已绑定设备拒绝)。 + *

+ * 为什么先查「已绑定」再删:删除是逻辑删除、不可逆(对用户而言), + * 而已绑定设备的删除会让 APP 端出现「设备凭空消失」而仍占用物理 IMEI 的悬空态。 + * 计划 §9.1 要求「已绑定需先解绑或二次确认」,本迭代取最保守语义——直接拒绝。 + *

+ * 未加 {@code @Transactional}:单条逻辑删除语句自提交即可,无需事务(见架构 §10.4 规则 2)。 + * + * @param ids 设备主键集合 + * @return 是否删除成功 + */ + @Override + public Boolean deleteByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return Boolean.FALSE; + } + long boundCount = deviceMapper.selectCount(Wrappers.lambdaQuery() + .in(BmDevice::getDeviceId, ids) + .isNotNull(BmDevice::getBindUserId)); + if (boundCount > 0) { + throw new ServiceException(DeviceMsgConstants.DEVICE_BOUND_CANNOT_DELETE); + } + return deviceMapper.deleteByIds(ids) > 0; + } + + /** + * 校验并批量删除设备 + * + * @param ids 设备主键集合 + * @param isValid 是否执行删除前校验 + * @return 是否删除成功 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (Boolean.TRUE.equals(isValid)) { + long count = deviceMapper.selectCount(Wrappers.lambdaQuery().in(BmDevice::getDeviceId, ids)); + if (count != ids.size()) { + throw new ServiceException("您没有删除权限!"); + } + } + return deviceMapper.deleteByIds(ids) > 0; + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/OtaPackageServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/OtaPackageServiceImpl.java new file mode 100644 index 0000000..f2c8a86 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/OtaPackageServiceImpl.java @@ -0,0 +1,163 @@ +package org.dromara.device.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.PageResult; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.BmOtaPackage; +import org.dromara.device.domain.bo.OtaPackageBo; +import org.dromara.device.domain.vo.OtaPackageVo; +import org.dromara.device.enums.OtaPackageStatusEnum; +import org.dromara.device.mapper.BmOtaPackageMapper; +import org.dromara.device.service.IOtaPackageService; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.util.Collection; + +/** + * OTA 升级包服务实现(见 {@link IOtaPackageService} 的契约说明)。 + *

+ * 无复杂状态机,核心是「唯一性」与「默认值」两点:{@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 queryPageList(OtaPackageBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = otaPackageMapper.selectVoPage(pageQuery.build(), lqw); + return PageResult.build(result.getRecords(), result.getTotal()); + } + + /** + * 构建升级包动态查询条件。 + * + * @param bo 查询条件 + * @return 查询条件包装器 + */ + private LambdaQueryWrapper buildQueryWrapper(OtaPackageBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getModel()), BmOtaPackage::getModel, bo.getModel()); + lqw.eq(StringUtils.isNotBlank(bo.getVersion()), BmOtaPackage::getVersion, bo.getVersion()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmOtaPackage::getStatus, bo.getStatus()); + if (StringUtils.isNotBlank(bo.getKeyword())) { + String keyword = bo.getKeyword(); + lqw.and(w -> w.like(BmOtaPackage::getModel, keyword) + .or().like(BmOtaPackage::getVersion, keyword)); + } + lqw.orderByDesc(BmOtaPackage::getCreateTime); + return lqw; + } + + /** + * 登记(新增)升级包。 + * + * @param bo 新增业务对象 + * @return 是否新增成功 + */ + @Override + public Boolean insertByBo(OtaPackageBo bo) { + BmOtaPackage add = MapstructUtils.convert(bo, BmOtaPackage.class); + if (StringUtils.isBlank(add.getStatus())) { + // 默认停用:刚登记的包不应被批量下发误选,需后台显式启用 + add.setStatus(OtaPackageStatusEnum.DISABLED.getCode()); + } + validEntityBeforeSave(add); + try { + boolean flag = otaPackageMapper.insert(add) > 0; + if (flag) { + bo.setPackageId(add.getPackageId()); + } + return flag; + } catch (DuplicateKeyException e) { + // 预检与写入之间的并发窗口 / 命中已被逻辑删除的同型号同版本历史行,统一归一 + throw new ServiceException(DeviceMsgConstants.OTA_PACKAGE_EXIST); + } + } + + /** + * 修改升级包。 + * + * @param bo 编辑业务对象 + * @return 是否修改成功 + */ + @Override + public Boolean updateByBo(OtaPackageBo bo) { + BmOtaPackage update = MapstructUtils.convert(bo, BmOtaPackage.class); + validEntityBeforeSave(update); + try { + return otaPackageMapper.updateById(update) > 0; + } catch (DuplicateKeyException e) { + throw new ServiceException(DeviceMsgConstants.OTA_PACKAGE_EXIST); + } + } + + /** + * 批量删除升级包(逻辑删除)。 + * + * @param ids 升级包主键集合 + * @return 是否删除成功 + */ + @Override + public Boolean deleteByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return Boolean.FALSE; + } + return otaPackageMapper.deleteByIds(ids) > 0; + } + + /** + * 保存前校验:必填项 + {@code (model, version)} 唯一性预检。 + *

+ * 预检按 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.lambdaQuery() + .eq(BmOtaPackage::getModel, entity.getModel()) + .eq(BmOtaPackage::getVersion, entity.getVersion()) + .ne(entity.getPackageId() != null, BmOtaPackage::getPackageId, entity.getPackageId())); + if (conflict > 0) { + throw new ServiceException(DeviceMsgConstants.OTA_PACKAGE_EXIST); + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/OtaTaskServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/OtaTaskServiceImpl.java new file mode 100644 index 0000000..cdd4708 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/OtaTaskServiceImpl.java @@ -0,0 +1,284 @@ +package org.dromara.device.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.PageResult; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.device.config.DeviceMqttProperties; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.BmDevice; +import org.dromara.device.domain.BmOtaPackage; +import org.dromara.device.domain.BmOtaTask; +import org.dromara.device.domain.bo.OtaBatchBo; +import org.dromara.device.domain.bo.OtaTaskBo; +import org.dromara.device.domain.vo.OtaBatchResultVo; +import org.dromara.device.domain.vo.OtaTaskVo; +import org.dromara.device.enums.DeviceStatusEnum; +import org.dromara.device.enums.OtaPackageStatusEnum; +import org.dromara.device.enums.OtaTaskStatusEnum; +import org.dromara.device.mapper.BmDeviceMapper; +import org.dromara.device.mapper.BmOtaPackageMapper; +import org.dromara.device.mapper.BmOtaTaskMapper; +import org.dromara.device.mqtt.DeviceCommandPublisher; +import org.dromara.device.mqtt.DevicePendingAckRegistry; +import org.dromara.device.mqtt.payload.AckPayload; +import org.dromara.device.mqtt.payload.OtaCommandPayload; +import org.dromara.device.service.IOtaTaskService; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * OTA 升级任务服务实现(见 {@link IOtaTaskService} 的契约说明)。 + * + *

一、批量的「数得对上账」

+ * 去重后的设备集合即 {@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 queryPageList(OtaTaskBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = otaTaskMapper.selectVoPage(pageQuery.build(), lqw); + return PageResult.build(result.getRecords(), result.getTotal()); + } + + /** + * 构建任务动态查询条件。 + * + * @param bo 查询条件 + * @return 查询条件包装器 + */ + private LambdaQueryWrapper buildQueryWrapper(OtaTaskBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getDeviceNo()), BmOtaTask::getDeviceNo, bo.getDeviceNo()); + lqw.like(StringUtils.isNotBlank(bo.getVersion()), BmOtaTask::getVersion, bo.getVersion()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmOtaTask::getStatus, bo.getStatus()); + if (StringUtils.isNotBlank(bo.getKeyword())) { + String keyword = bo.getKeyword(); + lqw.and(w -> w.like(BmOtaTask::getDeviceNo, keyword) + .or().like(BmOtaTask::getVersion, keyword)); + } + lqw.orderByDesc(BmOtaTask::getCreateTime); + return lqw; + } + + /** + * 按勾选设备批量创建升级任务并逐台下发。 + * + * @param bo 批量入参(packageId + deviceIds) + * @return 批量结果 + */ + @Override + public OtaBatchResultVo batchOta(OtaBatchBo bo) { + // 整体前置:升级包必须存在且启用,否则整批拒绝,不产生任何任务 + BmOtaPackage pkg = otaPackageMapper.selectById(bo.getPackageId()); + if (pkg == null || !OtaPackageStatusEnum.ENABLED.getCode().equals(pkg.getStatus())) { + throw new ServiceException(DeviceMsgConstants.OTA_PACKAGE_UNAVAILABLE); + } + + // 入参去重:同一设备重复勾选只计一次,保证 total 与后续分类可对账 + List deviceIds = bo.getDeviceIds().stream().distinct().toList(); + Long operatorId = LoginHelper.getUserId(); + OtaBatchResultVo result = new OtaBatchResultVo(); + result.setTotal(deviceIds.size()); + List messages = new ArrayList<>(); + + for (Long deviceId : deviceIds) { + BmDevice device = deviceMapper.selectById(deviceId); + if (device == null) { + result.setFailed(result.getFailed() + 1); + messages.add(String.format(DeviceMsgConstants.OTA_BATCH_FAIL_DEVICE_NOT_EXIST, deviceId)); + continue; + } + String deviceNo = device.getDeviceNo(); + // ① 型号不匹配 → 跳过(业务原因,不建任务) + if (StringUtils.isNotBlank(pkg.getModel()) && !pkg.getModel().equals(device.getModel())) { + result.setSkipped(result.getSkipped() + 1); + messages.add(String.format(DeviceMsgConstants.OTA_BATCH_SKIP_MODEL_MISMATCH, deviceNo)); + continue; + } + // ② 已是最新版本 → 跳过 + if (StringUtils.isNotBlank(device.getFwVersion()) + && device.getFwVersion().equals(pkg.getVersion())) { + result.setSkipped(result.getSkipped() + 1); + messages.add(String.format(DeviceMsgConstants.OTA_BATCH_SKIP_ALREADY_LATEST, deviceNo)); + continue; + } + // ③ 已存在同包进行中任务(待升级 / 升级中)→ 跳过 + long inProgress = otaTaskMapper.selectCount(Wrappers.lambdaQuery() + .eq(BmOtaTask::getDeviceId, deviceId) + .eq(BmOtaTask::getPackageId, pkg.getPackageId()) + .in(BmOtaTask::getStatus, + OtaTaskStatusEnum.PENDING.getCode(), OtaTaskStatusEnum.UPGRADING.getCode())); + if (inProgress > 0) { + result.setSkipped(result.getSkipped() + 1); + messages.add(String.format(DeviceMsgConstants.OTA_BATCH_SKIP_DUPLICATE, deviceNo)); + continue; + } + // ④ 设备非在线 → 失败(不下行、不排队、不补投) + if (!DeviceStatusEnum.ONLINE.getCode().equals(device.getStatus())) { + result.setFailed(result.getFailed() + 1); + messages.add(String.format(DeviceMsgConstants.OTA_BATCH_FAIL_DEVICE_OFFLINE, deviceNo)); + continue; + } + // ⑤ 建任务 + 登记 + 下发 + 等待回执 + if (dispatch(device, pkg, operatorId)) { + result.setCreated(result.getCreated() + 1); + } else { + result.setFailed(result.getFailed() + 1); + messages.add(String.format(DeviceMsgConstants.OTA_BATCH_FAIL_DISPATCH, deviceNo)); + } + } + result.setMessages(messages); + log.info("OTA 批量下发完成 => packageId: {}, total: {}, created: {}, skipped: {}, failed: {}", + pkg.getPackageId(), result.getTotal(), result.getCreated(), result.getSkipped(), result.getFailed()); + return result; + } + + /** + * 单台设备:建任务 → 登记回执 → 下发 → 等待回执。 + *

+ * 任务以「待升级」(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 future = ackRegistry.register(msgId, device.getDeviceNo(), + OtaCommandPayload.CMD, null, task.getTaskId(), properties.getOtaTimeout()); + if (!commandPublisher.publishOta(device.getDeviceNo(), msgId, payload)) { + // 未提交到 MQTT:回执永远不来,撤销登记、置任务失败,不空等一个超时周期 + ackRegistry.abandon(msgId); + markTaskFailed(task.getTaskId(), DeviceMsgConstants.COMMAND_SEND_FAILED); + log.warn("OTA 指令未提交到 MQTT => deviceNo: {}, taskId: {}", + device.getDeviceNo(), task.getTaskId()); + return false; + } + AckPayload ack = await(future, device.getDeviceNo(), msgId); + if (ack == null) { + // 回执处理器不会被触发(未收到 ack),由本类兜底置失败 + markTaskFailed(task.getTaskId(), DeviceMsgConstants.COMMAND_SEND_FAILED); + log.warn("OTA 指令超时未收到回执 => deviceNo: {}, taskId: {}, timeout: {}ms", + device.getDeviceNo(), task.getTaskId(), properties.getOtaTimeout().toMillis()); + return false; + } + if (!ack.success()) { + // 拒绝的回执:回执处理器已按 ack 落定任务失败态,此处不再重复回写 + log.warn("OTA 指令被设备拒绝 => deviceNo: {}, taskId: {}, code: {}, msg: {}", + device.getDeviceNo(), task.getTaskId(), ack.getCode(), ack.getMsg()); + return false; + } + log.info("OTA 指令已受理 => deviceNo: {}, taskId: {}, msgId: {}", + device.getDeviceNo(), task.getTaskId(), msgId); + return true; + } + + /** + * 兜底把任务置为失败终态(仅用于「指令根本未发出」与「回执超时」两种处理器不触及的情形)。 + * + * @param taskId 任务主键 + * @param resultMsg 失败原因(面向用户文案) + */ + private void markTaskFailed(Long taskId, String resultMsg) { + LocalDateTime now = LocalDateTime.now(); + otaTaskMapper.lambda() + .set(BmOtaTask::getStatus, OtaTaskStatusEnum.FAILED.getCode()) + .set(BmOtaTask::getResultMsg, resultMsg) + .set(BmOtaTask::getFinishTime, now) + .set(BmOtaTask::getUpdateTime, now) + .eq(BmOtaTask::getTaskId, taskId) + .updateCount(); + } + + /** + * 等待回执,永不抛出、永不悬挂。 + * + * @param future 登记时返回的 Future + * @param deviceNo 设备编号(日志用) + * @param msgId 指令标识(日志用) + * @return 回执报文;超时或中断返回 {@code null} + */ + private AckPayload await(CompletableFuture future, String deviceNo, String msgId) { + long budget = properties.getOtaTimeout().toMillis() + AWAIT_GRACE_MILLIS; + try { + return future.get(budget, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("等待 OTA 回执被中断 => deviceNo: {}, msgId: {}", deviceNo, msgId); + return null; + } catch (ExecutionException | TimeoutException e) { + log.warn("等待 OTA 回执异常 => deviceNo: {}, msgId: {}", deviceNo, msgId, e); + return null; + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java new file mode 100644 index 0000000..5418a5f --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java @@ -0,0 +1,146 @@ +package org.dromara.device.sms; + +import cn.hutool.core.util.RandomUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.device.config.AppProperties; +import org.dromara.device.constant.AppConstants; +import org.dromara.device.constant.AppMsgConstants; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.enums.SmsSceneEnum; +import org.dromara.sms4j.api.SmsBlend; +import org.dromara.sms4j.api.entity.SmsResponse; +import org.dromara.sms4j.core.factory.SmsFactory; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.LinkedHashMap; + +/** + * APP 短信验证码服务:按场景「生成 → 60 秒限流 → 发送 → 缓存 → 校验」。 + *

+ * 设计要点: + *

    + *
  • 场景化隔离:缓存 key 与限流 key 均带场景,登录/注册/忘记密码/换绑互不覆盖。
  • + *
  • 时长口径:验证码有效期 5 分钟、重发间隔 60 秒、位数 6 位,均可由 {@code device.app.sms.*} 配置覆盖。
  • + *
  • 短信通道开关点:{@code device.app.sms.mock}(默认 true)为 true 时仅打印日志,便于开发期(Q5 短信通道未定)联调; + * 置为 false 时走 sms4j 真实通道。
  • + *
  • 不触碰 PC 通道:本服务仅服务 {@code /app/**},与框架既有 {@code /resource/sms/code} 完全独立。
  • + *
+ * + * @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; + } + + /** + * 校验短信验证码(一次性使用,校验通过后立即失效)。 + *

+ * 防定向 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 params = new LinkedHashMap<>(1); + params.put("code", code); + SmsResponse response = smsBlend.sendMessage(phoneNumber, sms.getTemplateId(), params); + if (!response.isSuccess()) { + log.error("APP 短信验证码发送失败 phone={} response={}", phoneNumber, response); + throw new ServiceException(AppMsgConstants.SMS_SEND_FAILED); + } + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("APP 短信验证码发送异常 phone={}", phoneNumber, e); + throw new ServiceException(AppMsgConstants.SMS_SEND_FAILED); + } + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java new file mode 100644 index 0000000..07c6504 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java @@ -0,0 +1,126 @@ +package org.dromara.device.zlm.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.redis.annotation.RateLimiter; +import org.dromara.common.redis.enums.LimitType; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.device.constant.AppMsgConstants; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.bo.AppStreamStartBo; +import org.dromara.device.domain.vo.AppStreamPlayVo; +import org.dromara.device.domain.vo.AppStreamStatusVo; +import org.dromara.device.service.IAppStreamService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * APP 直播接口(前缀 {@code /app/stream})。 + *

+ * 需登录;仅能操作本人绑定设备。逻辑移植自 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 start(@Validated @RequestBody AppStreamStartBo bo) { + return R.ok(appStreamService.start(bo.getDeviceNo(), currentUserId())); + } + + /** + * 通知设备停止推流,并关闭 ZLM 在线流。 + * + * @param deviceNo 设备编号 + * @return 关闭后状态 + */ + @PostMapping("/stop/{deviceNo}") + @RateLimiter(key = "@streamRateLimitKey.control(#deviceNo)", time = 60, count = 20, + limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT) + public R stop(@PathVariable String deviceNo) { + return R.ok(appStreamService.stop(deviceNo, currentUserId())); + } + + /** + * 获取播放地址,不主动通知设备推流。 + * + * @param deviceNo 设备编号 + * @return 播放信息 + */ + @GetMapping("/play-url/{deviceNo}") + public R playUrl(@PathVariable String deviceNo) { + return R.ok(appStreamService.playUrl(deviceNo, currentUserId())); + } + + /** + * 旧版客户端自定义拉流入口已停用。 + * + * @return 失败 + */ + @PostMapping("/open") + @RateLimiter(key = "'stream:open'", time = 60, count = 20, + limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT) + public R open() { + log.warn("[STREAM] 已拒绝停用的客户端自定义拉流请求"); + throw new ServiceException(DeviceMsgConstants.STREAM_CUSTOM_DISABLED); + } + + /** + * 仅关闭 ZLM 流,不通知设备。 + * + * @param deviceNo 设备编号 + * @return 成功 + */ + @PostMapping("/close/{deviceNo}") + @RateLimiter(key = "@streamRateLimitKey.control(#deviceNo)", time = 60, count = 20, + limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT) + public R close(@PathVariable String deviceNo) { + appStreamService.close(deviceNo, currentUserId()); + return R.ok(); + } + + /** + * 查询流是否在线。 + * + * @param deviceNo 设备编号 + * @return 状态 + */ + @GetMapping("/status/{deviceNo}") + public R status(@PathVariable String deviceNo) { + return R.ok(appStreamService.status(deviceNo, currentUserId())); + } + + 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/zlm/controller/ZlmHookController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/ZlmHookController.java new file mode 100644 index 0000000..bbb8f8d --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/ZlmHookController.java @@ -0,0 +1,208 @@ +package org.dromara.device.zlm.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.device.config.ZlmProperties; +import org.dromara.device.service.IAppStreamService; +import org.dromara.device.zlm.stream.ZlmPlayAuthService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * ZLMediaKit WebHook 回调入口。 + *

+ * 全部 {@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 onPublish(HttpServletRequest request, @RequestBody Map body) { + if (!validateZlmSecret(request, body)) { + log.warn("[ZLM-HOOK] on_publish 未通过 secret 校验, IP={}", request.getRemoteAddr()); + return reject("unauthorized"); + } + log.info("[ZLM-HOOK] on_publish {}", body); + return ok(); + } + + /** + * 播放鉴权:secret + 播放 token(stream / IP 绑定)。 + * + * @param request HTTP 请求 + * @param body ZLM 回调体 + * @return 允许或拒绝 + */ + @PostMapping("/on_play") + public Map onPlay(HttpServletRequest request, @RequestBody Map body) { + if (!validateZlmSecret(request, body)) { + log.warn("[ZLM-HOOK] on_play 未通过 secret 校验, IP={}", request.getRemoteAddr()); + return reject("unauthorized"); + } + log.info("[ZLM-HOOK] on_play {}", body); + String streamId = asString(body.get("stream")); + String token = extractToken(body); + String clientIp = asString(body.get("ip")); + if (!playAuthService.validate(token, streamId, clientIp)) { + return reject("unauthorized"); + } + return ok(); + } + + /** + * 流注册/注销通知(仅记录)。 + * + * @param request HTTP 请求 + * @param body ZLM 回调体 + * @return 成功 + */ + @PostMapping("/on_stream_changed") + public Map onStreamChanged(HttpServletRequest request, @RequestBody Map body) { + if (!validateZlmSecret(request, body)) { + log.warn("[ZLM-HOOK] on_stream_changed 未通过 secret 校验, IP={}", request.getRemoteAddr()); + return reject("unauthorized"); + } + log.info("[ZLM-HOOK] on_stream_changed {}", body); + return ok(); + } + + /** + * 无观看者时关闭流。 + * + * @param request HTTP 请求 + * @param body ZLM 回调体 + * @return 成功 + */ + @PostMapping("/on_stream_none_reader") + public Map onStreamNoneReader(HttpServletRequest request, @RequestBody Map body) { + if (!validateZlmSecret(request, body)) { + log.warn("[ZLM-HOOK] on_stream_none_reader 未通过 secret 校验, IP={}", request.getRemoteAddr()); + return reject("unauthorized"); + } + log.info("[ZLM-HOOK] on_stream_none_reader {}", body); + Object stream = body.get("stream"); + if (stream instanceof String streamId && !streamId.isBlank()) { + appStreamService.closeZlmStream(streamId); + } + return ok(); + } + + /** + * 校验 ZLM webhook secret(body.secret 优先,其次 Header {@code X-ZLM-Secret})。 + * secret 未配置时拒绝,避免回调入口裸奔。 + * + * @param request HTTP 请求 + * @param body 请求体 + * @return 是否通过 + */ + private boolean validateZlmSecret(HttpServletRequest request, Map body) { + String zlmSecret = zlmProperties.getSecret(); + if (StringUtils.isBlank(zlmSecret)) { + log.warn("[ZLM-HOOK] zlm.secret 未配置,拒绝回调 IP={}", request.getRemoteAddr()); + return false; + } + + String bodySecret = asString(body.get("secret")); + if (zlmSecret.equals(bodySecret)) { + return true; + } + + String headerSecret = request.getHeader("X-ZLM-Secret"); + if (zlmSecret.equals(headerSecret)) { + return true; + } + + log.warn("[ZLM-HOOK] secret 校验失败, IP={}, bodySecret={}, headerSecret={}", + request.getRemoteAddr(), + StringUtils.isBlank(bodySecret) ? "空" : "不匹配", + StringUtils.isBlank(headerSecret) ? "空" : "不匹配"); + return false; + } + + private Map ok() { + Map result = new LinkedHashMap<>(); + result.put("code", 0); + result.put("msg", "success"); + return result; + } + + private Map reject(String msg) { + Map result = new LinkedHashMap<>(); + result.put("code", -1); + result.put("msg", msg); + return result; + } + + private String extractToken(Map body) { + String token = asString(body.get("token")); + if (StringUtils.isNotBlank(token)) { + return token; + } + + token = queryParam(asString(body.get("params")), "token"); + if (StringUtils.isNotBlank(token)) { + return token; + } + + String url = asString(body.get("url")); + int queryIndex = url.indexOf('?'); + return queryIndex > -1 ? queryParam(url.substring(queryIndex + 1), "token") : ""; + } + + private String queryParam(String query, String name) { + if (StringUtils.isBlank(query)) { + return ""; + } + String cleanQuery = query.startsWith("?") ? query.substring(1) : query; + for (String pair : cleanQuery.split("&")) { + if (StringUtils.isBlank(pair)) { + continue; + } + int index = pair.indexOf('='); + String key = index > -1 ? pair.substring(0, index) : pair; + if (Objects.equals(decode(key), name)) { + return index > -1 ? decode(pair.substring(index + 1)) : ""; + } + } + return ""; + } + + private String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private String asString(Object value) { + return value == null ? "" : Objects.toString(value, ""); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmClient.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmClient.java new file mode 100644 index 0000000..943aaef --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmClient.java @@ -0,0 +1,141 @@ +package org.dromara.device.zlm.stream; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.device.config.ZlmProperties; +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * ZLMediaKit REST API 客户端(查询在线、关流、删除代理)。 + * + * @author Kou + * @date 2026-09-17 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ZlmClient { + + private final ZlmProperties config; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + /** + * 删除代理流(设备主动推流场景通常无代理,失败仅记日志)。 + * + * @param streamId 流名 + * @return 是否成功 + */ + public boolean delStreamProxy(String streamId) { + try { + String url = apiUrl("/index/api/delStreamProxy") + + "?secret=" + encode(config.getSecret()) + + "&key=" + encode(config.getApp() + "/" + streamId); + JsonNode resp = get(url); + boolean ok = resp.path("code").asInt(-1) == 0; + log.info("[ZLM] 删除流 streamId={} ok={}", streamId, ok); + return ok; + } catch (Exception e) { + log.error("[ZLM] delStreamProxy 异常: {}", e.getMessage()); + return false; + } + } + + /** + * 查询流是否在线。 + * + * @param streamId 流名 + * @return 在线为 true + */ + public boolean isStreamOnline(String streamId) { + try { + JsonNode resp = getMediaInfo(streamId); + return resp.path("code").asInt(-1) == 0; + } catch (Exception e) { + return false; + } + } + + /** + * 查询媒体信息。 + * + * @param streamId 流名 + * @return ZLM JSON;失败时 {@code code=-1} + */ + public JsonNode getMediaInfo(String streamId) { + try { + String url = apiUrl("/index/api/getMediaInfo") + + "?secret=" + encode(config.getSecret()) + + "&schema=" + encode(config.getSchema()) + + "&vhost=__defaultVhost__" + + "&app=" + encode(config.getApp()) + + "&stream=" + encode(streamId); + return get(url); + } catch (Exception e) { + log.warn("[ZLM] getMediaInfo 异常 streamId={} msg={}", streamId, e.getMessage()); + return JsonUtils.getJsonMapper().createObjectNode().put("code", -1); + } + } + + /** + * 强制关闭在线流。 + * + * @param streamId 流名 + * @return 是否成功 + */ + public boolean closeStreams(String streamId) { + try { + String url = apiUrl("/index/api/close_streams") + + "?secret=" + encode(config.getSecret()) + + "&schema=" + encode(config.getSchema()) + + "&vhost=__defaultVhost__" + + "&app=" + encode(config.getApp()) + + "&stream=" + encode(streamId) + + "&force=1"; + JsonNode resp = get(url); + boolean ok = resp.path("code").asInt(-1) == 0; + log.info("[ZLM] 关闭在线流 streamId={} ok={} resp={}", streamId, ok, resp); + return ok; + } catch (Exception e) { + log.error("[ZLM] closeStreams 异常 streamId={} msg={}", streamId, e.getMessage()); + return false; + } + } + + private String apiUrl(String apiPath) { + String host = config.getHost() == null ? "" : config.getHost().trim(); + if (host.endsWith("/")) { + host = host.substring(0, host.length() - 1); + } + return host + apiPath; + } + + private JsonNode get(String urlStr) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(urlStr)) + .timeout(Duration.ofSeconds(10)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + String body = response.body() == null ? "{}" : response.body(); + return JsonUtils.getJsonMapper().readTree(body); + } + + private String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmPlayAuthService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmPlayAuthService.java new file mode 100644 index 0000000..bf077fb --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmPlayAuthService.java @@ -0,0 +1,115 @@ +package org.dromara.device.zlm.stream; + +import cn.hutool.core.util.IdUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.device.config.ZlmProperties; +import org.dromara.device.constant.DeviceMqttConstants; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * ZLM 播放鉴权 token(Redis 一次性凭证,绑定 stream + 签发 IP)。 + * + * @author Kou + * @date 2026-09-17 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ZlmPlayAuthService { + + private final ZlmProperties config; + + /** + * 为当前登录用户签发播放 token。 + * + * @param deviceNo 设备编号(同时作为 streamId) + * @return token 与过期时间 + */ + public PlayToken createToken(String deviceNo) { + String token = IdUtil.fastSimpleUUID(); + long expireAt = System.currentTimeMillis() + + Duration.ofSeconds(config.getPlayExpireSeconds()).toMillis(); + + Map payload = new LinkedHashMap<>(); + payload.put("deviceNo", deviceNo); + payload.put("userId", LoginHelper.getUserId()); + payload.put("clientIp", ServletUtils.getClientIP()); + payload.put("expireAt", expireAt); + + RedisUtils.setCacheObject(tokenKey(token), JsonUtils.toJsonString(payload), + Duration.ofSeconds(config.getPlayExpireSeconds())); + return new PlayToken(token, expireAt); + } + + /** + * 校验播放 token 是否匹配当前流与客户端 IP。 + * + * @param token 播放 token + * @param streamId 请求播放的流名 + * @param clientIp 播放端 IP(ZLM on_play 回调携带) + * @return 是否通过 + */ + public boolean validate(String token, String streamId, String clientIp) { + if (StringUtils.isBlank(token) || StringUtils.isBlank(streamId)) { + return false; + } + + String json = RedisUtils.getCacheObject(tokenKey(token)); + if (StringUtils.isBlank(json)) { + return false; + } + + Map payload; + try { + @SuppressWarnings("unchecked") + Map parsed = JsonUtils.parseObject(json, Map.class); + payload = parsed; + } catch (Exception e) { + log.warn("[ZLM-AUTH] token payload 解析失败 token={}", token, e); + return false; + } + if (payload == null) { + return false; + } + + String deviceNo = Objects.toString(payload.get("deviceNo"), ""); + if (!Objects.equals(deviceNo, streamId)) { + log.warn("[ZLM-AUTH] stream 不匹配 tokenStream={} requestStream={}", deviceNo, streamId); + return false; + } + + String issuedIp = Objects.toString(payload.get("clientIp"), ""); + if (StringUtils.isNotBlank(issuedIp) && StringUtils.isNotBlank(clientIp) + && !Objects.equals(issuedIp, clientIp)) { + log.warn("[ZLM-AUTH] ip 不匹配 tokenIp={} requestIp={} streamId={}", + issuedIp, clientIp, streamId); + return false; + } + return true; + } + + private String tokenKey(String token) { + return DeviceMqttConstants.ZLM_PLAY_TOKEN_PREFIX + token; + } + + /** + * 播放 token 视图。 + * + * @param token 凭证 + * @param expireAt 过期时间戳(epoch 毫秒) + */ + public record PlayToken(String token, long expireAt) { + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/support/StreamRateLimitKey.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/support/StreamRateLimitKey.java new file mode 100644 index 0000000..f8b0e68 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/support/StreamRateLimitKey.java @@ -0,0 +1,25 @@ +package org.dromara.device.zlm.support; + +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.stereotype.Component; + +/** + * 直播控制接口的限流键:用户 + 设备。 + * + * @author Kou + * @date 2026-09-17 + */ +@Component("streamRateLimitKey") +public class StreamRateLimitKey { + + /** + * 生成限流键。 + * + * @param deviceNo 设备编号 + * @return {@code userId:deviceNo} + */ + public String control(String deviceNo) { + return LoginHelper.getUserId() + ":" + deviceNo; + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceAlertMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceAlertMapper.xml new file mode 100644 index 0000000..0c0d987 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceAlertMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceBindMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceBindMapper.xml new file mode 100644 index 0000000..b8536d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceBindMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceMapper.xml new file mode 100644 index 0000000..815c98b --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceMapper.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceTelemetryMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceTelemetryMapper.xml new file mode 100644 index 0000000..ad4f5d1 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceTelemetryMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmOtaPackageMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmOtaPackageMapper.xml new file mode 100644 index 0000000..237611c --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmOtaPackageMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmOtaTaskMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmOtaTaskMapper.xml new file mode 100644 index 0000000..436916d --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmOtaTaskMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AppUserApiServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AppUserApiServiceImpl.java new file mode 100644 index 0000000..fbe53e1 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AppUserApiServiceImpl.java @@ -0,0 +1,432 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.crypto.digest.BCrypt; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.PageResult; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.query.QueryBuilder; +import org.dromara.common.mybatis.utils.IdGeneratorUtil; +import org.dromara.system.api.AppUserApiService; +import org.dromara.system.api.domain.AppUserDTO; +import org.dromara.system.api.domain.AppUserQueryDTO; +import org.dromara.system.domain.SysUser; +import org.dromara.system.mapper.SysUserMapper; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; + +/** + * APP 用户领域服务({@link AppUserApiService})实现。 + *

+ * 本实现为迭代 I2(边界方案 A)的纯新增: + *

    + *
  • 复用 {@link SysUserMapper} 与框架既有 BCrypt 口径(与 {@code SysUserController}、{@code SysProfileController}、{@code PasswordAuthStrategy} 一致)。
  • + *
  • 密码仅在 {@code ruoyi-system} 内部散列({@link BCrypt#hashpw(String)})与比对({@link BCrypt#checkpw(String, String)}), + * 不向调用方(设备域)暴露散列或明文。
  • + *
  • 不改动 {@code ruoyi-system} 既有任何类、方法与行为。
  • + *
+ * + * @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)。 + *

+ * 防「永久无法登录」加固(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)} 取首条: + *

    + *
  • 绝不抛 {@code TooManyResultsException};
  • + *
  • 结果确定(恒取 userId 最小者),可复现、可审计;
  • + *
  • 命中多行时打印告警,便于运维发现并人工清理。
  • + *
+ * + * @param phoneNumber 手机号 + * @return APP 用户信息(不含密码);不存在时返回 {@code null} + */ + @Override + public AppUserDTO selectByPhone(String phoneNumber) { + if (StringUtils.isBlank(phoneNumber)) { + return null; + } + List users = userMapper.lambda() + .eq(SysUser::getPhoneNumber, phoneNumber) + .eq(SysUser::getUserType, UserType.APP_USER.getUserType()) + .orderByAsc(SysUser::getUserId) + .list(); + if (users == null || users.isEmpty()) { + return null; + } + if (users.size() > 1) { + log.warn("手机号 {} 命中 {} 条 app_user 记录(应唯一),已按 userId 升序取首条 userId={};" + + "疑似并发注册或历史脏数据,建议人工核查清理。", + phoneNumber, users.size(), users.get(0).getUserId()); + } + return BeanUtil.copyProperties(users.get(0), AppUserDTO.class); + } + + /** + * 判断手机号是否已被注册为 APP 用户(仅 app_user)。 + * + * @param phoneNumber 手机号 + * @return true 已注册;false 未注册 + */ + @Override + public boolean existsByPhone(String phoneNumber) { + if (StringUtils.isBlank(phoneNumber)) { + return false; + } + return userMapper.lambda() + .eq(SysUser::getPhoneNumber, phoneNumber) + .eq(SysUser::getUserType, UserType.APP_USER.getUserType()) + .exists(); + } + + /** + * 校验用户密码是否匹配;明文密码仅在本方法内部比对。 + * + * @param userId 用户主键 + * @param rawPassword 明文密码 + * @return true 匹配;false 不匹配或用户不存在 + */ + @Override + public boolean matchesPassword(Long userId, String rawPassword) { + if (userId == null || StringUtils.isBlank(rawPassword)) { + return false; + } + SysUser user = userMapper.lambda() + .select(SysUser::getUserId, SysUser::getPassword) + .eq(SysUser::getUserId, userId) + .one(); + if (user == null || StringUtils.isBlank(user.getPassword())) { + return false; + } + return BCrypt.checkpw(rawPassword, user.getPassword()); + } + + /** + * 以手机号注册一个 APP 用户;密码在本方法内部散列后落库。 + *

+ * user_name 形式唯一保证(P3-7):先经框架 {@link IdGeneratorUtil#nextLongId()} 预分配 user_id + * (与 MyBatis-Plus 主键生成器同源,见 {@code MybatisPlusConfig#idGenerator}), + * 令 {@code user_name = "app_" + user_id} 由全局唯一的主键确定性派生, + * 从而获得形式唯一保证(不依赖概率、不依赖重名检测): + *

    + *
  • 两个 app_user 的 user_id 必不相同 ⇒ user_name 必不相同;
  • + *
  • 逻辑删除行的 user_id 亦互异 ⇒ 不存在「删除行同名」的漏检问题;
  • + *
  • 与后台 {@code sys_user} 的账号名冲突需其恰好命名为 {@code app_<雪花ID>},非自然产生。
  • + *
+ * 说明: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)。 + *

+ * 与 {@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 users = userMapper.lambda() + .eq(SysUser::getEmail, email) + .eq(SysUser::getUserType, UserType.APP_USER.getUserType()) + .orderByAsc(SysUser::getUserId) + .list(); + if (users == null || users.isEmpty()) { + return null; + } + if (users.size() > 1) { + log.warn("邮箱 {} 命中 {} 条 app_user 记录(应唯一),已按 userId 升序取首条 userId={};" + + "疑似并发注册或历史脏数据,建议人工核查清理。", + email, users.size(), users.get(0).getUserId()); + } + return BeanUtil.copyProperties(users.get(0), AppUserDTO.class); + } + + /** + * 判断邮箱是否已被注册为 APP 用户(仅 app_user)。 + * + * @param email 邮箱地址(已归一化) + * @return true 已注册;false 未注册 + */ + @Override + public boolean existsByEmail(String email) { + if (StringUtils.isBlank(email)) { + return false; + } + return userMapper.lambda() + .eq(SysUser::getEmail, email) + .eq(SysUser::getUserType, UserType.APP_USER.getUserType()) + .exists(); + } + + /** + * 以邮箱注册一个 APP 用户。 + *

+ * 为什么不能写 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 selectAppUserPage(AppUserQueryDTO query) { + AppUserQueryDTO condition = query == null ? new AppUserQueryDTO() : query; + int pageNum = (condition.getPageNum() == null || condition.getPageNum() < 1) ? 1 : condition.getPageNum(); + int pageSize = (condition.getPageSize() == null || condition.getPageSize() < 1) ? 10 : condition.getPageSize(); + + String keyword = condition.getKeyword(); + LambdaQueryWrapper wrapper = QueryBuilder.lambda(SysUser.class) + .eq(SysUser::getUserType, UserType.APP_USER.getUserType()) + .and(StringUtils.isNotBlank(keyword), w -> w + .like(SysUser::getUserName, keyword) + .or().like(SysUser::getNickName, keyword) + .or().like(SysUser::getPhoneNumber, keyword) + .or().like(SysUser::getEmail, keyword)) + .eqIfText(SysUser::getStatus, condition.getStatus()) + .betweenParams(SysUser::getCreateTime, condition.getParams(), "beginTime", "endTime") + .orderByDesc(SysUser::getCreateTime) + .build(); + + Page page = userMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); + List rows = page.getRecords().stream() + .map(user -> BeanUtil.copyProperties(user, AppUserDTO.class)) + .toList(); + return PageResult.build(rows, page.getTotal()); + } + + /** + * 按主键查询 APP 用户详情(后台「APP 用户管理」用)。 + *

+ * 强制 {@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 设备编号,形如 BM202609160001 + +可选: + --broker broker 地址 默认 ${DEFAULTS.broker} + --username 用户名 默认 ${DEFAULTS.username} + --password

密码 默认 ${DEFAULTS.password} + --scenario 设备状态场景 默认 online + online 周期遥测 + 自动回执(用例 1/4/6) + lowbat 低电遥测,验 §8.3 低电拦截与告警 + offline 发一条 offline 事件后退出(用例 2 · 主动下线路径) + silent 静默不再上报(用例 2 · 心跳超时路径,需等 120s) + register 走 §6.2 注册链路(配合 --imei) + --reply 对下行指令的回执方式 默认 auto + auto 正常回 ack{ok:true}(OTA 含进度事件) + none 不回执 —— 验 5s 超时(用例 5) + reject 回 ack{ok:false,code:3} —— 验失败分支 + --interval 遥测上报周期 默认 60000(协议 Q2 默认 60s) + --count 发 n 包遥测后退出 默认 0(不限) + --hood-cover <0|1> 本地眼罩初始状态 默认 0(打开) + --imei <15位> register 场景必填 + --offline-after offline 场景:上线后多久发 offline 事件 默认 5000 + --ota-step-ms OTA 进度事件间隔(真机为 10s,联调默认加速)默认 2000 + --dry-run 只打印将要发送的报文,不连接 broker + --help 显示本帮助 +`; + +// --------------------------------------------------------------------------- +// 参数解析 +// --------------------------------------------------------------------------- + +/** + * 解析命令行参数。 + * + * @param {string[]} argv process.argv.slice(2) + * @return {object} 合并默认值后的配置 + */ +function parseArgs(argv) { + const opts = { ...DEFAULTS }; + const map = { + '--broker': 'broker', + '--username': 'username', + '--password': 'password', + '--device-no': 'deviceNo', + '--scenario': 'scenario', + '--reply': 'reply', + '--imei': 'imei', + }; + const numMap = { + '--interval': 'interval', + '--count': 'count', + '--hood-cover': 'hoodCover', + '--offline-after': 'offlineAfter', + '--ota-step-ms': 'otaStepMs', + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') { + opts.help = true; + continue; + } + if (arg === '--dry-run') { + opts.dryRun = true; + continue; + } + if (map[arg]) { + opts[map[arg]] = requireValue(argv, ++i, arg); + continue; + } + if (numMap[arg]) { + const raw = requireValue(argv, ++i, arg); + const num = Number(raw); + if (!Number.isFinite(num) || num < 0) { + fail(`参数 ${arg} 需要非负数字,收到: ${raw}`); + } + opts[numMap[arg]] = num; + continue; + } + fail(`未知参数: ${arg}(用 --help 查看用法)`); + } + return opts; +} + +/** + * 取参数值,缺失时终止。 + * + * @param {string[]} argv 参数数组 + * @param {number} index 值所在下标 + * @param {string} name 参数名(报错用) + * @return {string} 参数值 + */ +function requireValue(argv, index, name) { + const value = argv[index]; + if (value === undefined || value.startsWith('--')) { + fail(`参数 ${name} 缺少取值`); + } + return value; +} + +/** + * 打印错误并退出。 + * + * @param {string} msg 错误信息 + */ +function fail(msg) { + console.error(`[错误] ${msg}`); + process.exit(2); +} + +// --------------------------------------------------------------------------- +// 报文构造 +// --------------------------------------------------------------------------- + +/** + * 构造上行 Topic 集合。 + * + * @param {string} deviceNo 设备编号 + * @return {object} Topic 集合 + */ +function topicsOf(deviceNo) { + return { + telemetry: `bm/${deviceNo}/telemetry`, + event: `bm/${deviceNo}/event`, + ack: `bm/${deviceNo}/ack`, + commandWildcard: `bm/${deviceNo}/command/#`, + provisionRegister: 'bm/provision/register', + provisionAck: (imei) => `bm/provision/ack/${imei}`, + }; +} + +/** + * 设备本地状态与遥测生成器。 + * + * 这里的「本地状态」是模拟器的核心:它只在**回执成功**后才切换眼罩状态, + * 从而使服务端 `hood_cover_on` 的回写路径能被真实地验证(而不是模拟器自己抢先改口)。 + */ +class SimulatedDevice { + + /** + * @param {string} scenario 场景名 + * @param {number} initialCover 眼罩初始状态 + */ + constructor(scenario, initialCover) { + this.scenario = scenario; + this.hoodCoverOn = initialCover; + this.hoodConnected = 1; + this.signalLevel = 3; + this.hoodBattery = scenario === 'lowbat' ? 12 : 86; + this.ringBattery = scenario === 'lowbat' ? 15 : 74; + this.ticks = 0; + } + + /** + * 生成一包遥测(§5.1:6 个必填字段全带,整份快照)。 + * + * @return {object} 遥测报文 + */ + telemetry() { + this.ticks += 1; + // lowbat 场景保持低电,用于稳定复现 §8.3 拦截与低电告警(不被滞回恢复掉) + if (this.scenario !== 'lowbat') { + this.drain(); + } + return { + v: 1, + ts: Date.now(), + hoodBattery: this.hoodBattery, + ringBattery: this.ringBattery, + signalLevel: this.signalLevel, + hoodConnected: this.hoodConnected, + hoodCoverOn: this.hoodCoverOn, + lng: 116.397128, + lat: 39.916527, + address: '北京市东城区东长安街', + }; + } + + /** + * 电量与信号缓慢衰减,让联调中的数值看起来像真实设备。 + */ + drain() { + if (this.ticks % 5 === 0 && this.hoodBattery > 30) { + this.hoodBattery -= 1; + } + if (this.ticks % 7 === 0 && this.ringBattery > 30) { + this.ringBattery -= 1; + } + } + +} + +/** + * 构造事件报文(§5.2)。 + * + * @param {string} eventType 事件类型 + * @param {object} extra 附加字段 + * @return {object} 事件报文 + */ +function eventPayload(eventType, extra) { + return { v: 1, ts: Date.now(), eventType, ...extra }; +} + +/** + * 构造回执报文(§5.3)。 + * + * @param {string} msgId 下行指令的 msgId,必须原样回传 + * @param {string} cmd 指令类型 + * @param {object} extra 附加字段(ok/code/msg/progress) + * @return {object} 回执报文 + */ +function ackPayload(msgId, cmd, extra) { + return { v: 1, msgId, cmd, ...extra }; +} + +// --------------------------------------------------------------------------- +// 日志 +// --------------------------------------------------------------------------- + +/** + * 带时间戳的日志。 + * + * @param {string} msg 日志内容 + */ +function log(msg) { + const ts = new Date().toISOString().replace('T', ' ').slice(0, 19); + console.log(`[${ts}] ${msg}`); +} + +/** + * 参数校验:先校验、后打印横幅,避免「先输出一段正常日志再报错」的观感。 + * + * @param {object} opts 配置 + */ +function validate(opts) { + if (opts.scenario === 'register') { + if (!IMEI_REGEX.test(opts.imei)) { + fail(`register 场景需要 --imei(15 位数字),收到: ${opts.imei || '(空)'}`); + } + return; + } + if (!DEVICE_NO_REGEX.test(opts.deviceNo)) { + fail(`--device-no 必须是 BM + 8 位日期 + 4 位序号(如 BM202609160001),收到: ${opts.deviceNo || '(空)'}`); + } +} + +/** + * 打印启动横幅:告诉联调的人「这次跑的是哪条用例」。 + * + * @param {object} opts 配置 + */ +function banner(opts) { + const cases = { + online: '用例 1(遥测落库)/ 4(指令+ack 成功)/ 6(重复 msgId)', + lowbat: '§8.3 低电拦截(遮盖被拒、打开放行)+ 低电告警', + offline: '用例 2 · 主动下线路径(offline 事件)', + silent: '用例 2 · 心跳超时路径(120s 后被扫描判离线)', + register: '§6.2 注册链路(含 code=404 未建档分支)', + }; + const identity = opts.scenario === 'register' ? `provision (imei=${opts.imei})` : `bm/${opts.deviceNo}`; + log('='.repeat(64)); + log(`设备模拟器启动 · 场景=${opts.scenario} · 回执=${opts.reply}`); + log(`覆盖:${cases[opts.scenario] || '(自定义)'}`); + log(`broker=${opts.broker} · topic 前缀=${identity}`); + log('='.repeat(64)); +} + +// --------------------------------------------------------------------------- +// register 场景(§6.2) +// --------------------------------------------------------------------------- + +/** + * 跑一次注册流程:上行 register,等 ack 后退出。 + * + * @param {object} opts 配置 + */ +function runRegister(opts) { + const imei = opts.imei; + const ackTopic = topicsOf('').provisionAck(imei); + const registerPayload = { + ts: Date.now(), + imei, + sn: `SN${imei.slice(-8)}`, + model: 'BM-H1', + hwVersion: '1.0', + fwVersion: '1.0.3', + mac: 'AC:1F:6B:00:11:22', + }; + if (opts.dryRun) { + log('[dry-run] 将订阅: ' + ackTopic); + log('[dry-run] 将上行: bm/provision/register ' + JSON.stringify(registerPayload)); + return; + } + const client = mqtt.connect(opts.broker, clientOptions(opts, `reg-${imei}`)); + const timer = setTimeout(() => { + log('注册应答超时(10s),请确认服务端已订阅 bm/provision/register'); + finish(client, 1); + }, 10000); + client.on('error', (err) => { + log(`MQTT 错误: ${err.message}`); + }); + client.on('connect', () => { + log('已连接 broker'); + client.subscribe(ackTopic, { qos: 1 }, (err) => { + if (err) { + log(`订阅失败: ${err.message}`); + finish(client, 1); + return; + } + log(`<< 已订阅 ${ackTopic}`); + client.publish('bm/provision/register', JSON.stringify(registerPayload), { qos: 1 }); + log(`>> ${JSON.stringify(registerPayload)}`); + }); + }); + client.on('message', (topic, buf) => { + const text = buf.toString('utf8'); + log(`<< ${topic} ${text}`); + const ack = safeParse(text); + if (ack && ack.ok === true) { + log(`注册成功,deviceNo=${ack.deviceNo}(设备端需持久化,重启不再注册)`); + } else if (ack && ack.code === 404) { + log('注册被拒:IMEI 未建档 —— 后台需先在 bm_device 录入该 IMEI(这是私拆/串货的线索)'); + } + clearTimeout(timer); + finish(client, 0); + }); +} + +// --------------------------------------------------------------------------- +// 设备场景 +// --------------------------------------------------------------------------- + +/** + * 跑设备场景。 + * + * @param {object} opts 配置 + */ +function runDevice(opts) { + const deviceNo = opts.deviceNo; + const device = new SimulatedDevice(opts.scenario, opts.hoodCover); + const t = topicsOf(deviceNo); + + if (opts.dryRun) { + log('[dry-run] 将订阅: ' + t.commandWildcard); + log('[dry-run] 将上行(connect 后): ' + t.event + ' ' + JSON.stringify(eventPayload('online', {}))); + log('[dry-run] 将上行(周期): ' + t.telemetry + ' ' + JSON.stringify(device.telemetry())); + log('[dry-run] 收到 hood 指令按 --reply=' + opts.reply + ' 处理:' + + JSON.stringify(ackPayload('<下行 msgId>', 'hood', { ok: true, code: 0, msg: '' }))); + log('[dry-run] 将上行(退出前): ' + t.event + ' ' + JSON.stringify(eventPayload('offline', { reason: 'normal' }))); + return; + } + + const client = mqtt.connect(opts.broker, clientOptions(opts, deviceNo)); + const state = { telemetryTimer: null, sentCount: 0, otaRunning: false, shuttingDown: false }; + + client.on('error', (err) => log(`MQTT 错误: ${err.message}`)); + client.on('reconnect', () => log('重连中…')); + + client.on('connect', () => { + log('已连接 broker'); + client.subscribe(t.commandWildcard, { qos: 1 }, (err) => { + if (err) { + log(`订阅失败: ${err.message}`); + return; + } + // 协议 §5.2:连接成功且完成订阅后才发 online + publishJson(client, t.event, eventPayload('online', { reason: 'normal' })); + log(`<< 已订阅 ${t.commandWildcard}`); + if (opts.scenario === 'silent' || opts.scenario === 'offline') { + // 先落一包遥测把 status 推到在线,再进入静默/下线,避免「从未有过心跳」的设备不受心跳约束 + publishJson(client, t.telemetry, device.telemetry()); + } + if (opts.scenario === 'offline') { + setTimeout(() => shutdown(client, opts, 'normal'), opts.offlineAfter); + return; + } + if (opts.scenario === 'silent') { + log(`进入静默:不再上报遥测。等待服务端在 offline-threshold(120s) 后判离线,勿按 Ctrl+C。`); + return; + } + state.telemetryTimer = setInterval(() => { + publishJson(client, t.telemetry, device.telemetry()); + state.sentCount += 1; + if (opts.count > 0 && state.sentCount >= opts.count) { + shutdown(client, opts, 'normal'); + } + }, opts.interval); + }); + }); + + client.on('message', (topic, buf) => { + const text = buf.toString('utf8'); + log(`<< ${topic} ${text}`); + const payload = safeParse(text); + if (!payload || !payload.msgId) { + log('下行报文无 msgId,按设备侧策略忽略'); + return; + } + if (topic.endsWith('/command/hood')) { + handleHoodCommand(client, opts, device, t, payload); + } else if (topic.endsWith('/command/ota')) { + handleOtaCommand(client, opts, t, payload, state); + } else { + log('未知下行指令,按前向兼容忽略(真实设备应记日志不动作)'); + } + }); + + const onSignal = () => { + if (state.shuttingDown) { + return; + } + state.shuttingDown = true; + log('收到中断信号,按「正常下线」流程退出'); + shutdown(client, opts, 'normal'); + }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); +} + +/** + * 处理眼罩指令:按 `--reply` 决定回执行为,并只在成功后切换本地状态。 + * + * @param {object} client MQTT 客户端 + * @param {object} opts 配置 + * @param {SimulatedDevice} device 模拟设备 + * @param {object} t Topic 集合 + * @param {object} cmd 下行指令报文 + */ +function handleHoodCommand(client, opts, device, t, cmd) { + const target = cmd.coverOn === true; + if (opts.reply === 'none') { + log(`[用例 5] 收到遮盖=${target} 的指令但**故意不回执** → 服务端应 5s 超时并返回「指令下发失败」,hood_cover_on 不变`); + return; + } + if (opts.reply === 'reject') { + publishJson(client, t.ack, ackPayload(cmd.msgId, 'hood', { ok: false, code: 3, msg: 'device busy' })); + log('已回失败回执(ok=false, code=3)→ 服务端应返回「指令下发失败」且不写 hood_cover_on'); + return; + } + // auto:设备真实执行后才改本地状态,再用本地状态回执——顺序反了会掩盖服务端的回写缺陷 + device.hoodCoverOn = target ? 1 : 0; + publishJson(client, t.ack, ackPayload(cmd.msgId, 'hood', { ok: true, code: 0, msg: '' })); + log(`眼罩已${target ? '遮盖' : '打开'},已回执 ok=true(本地 hoodCoverOn=${device.hoodCoverOn})`); +} + +/** + * 处理 OTA 指令:先回执「已受理」,再周期上报进度,最后回终态(协议 §7.2)。 + * + * @param {object} client MQTT 客户端 + * @param {object} opts 配置 + * @param {object} t Topic 集合 + * @param {object} cmd 下行指令报文 + * @param {object} state 运行状态(含 otaRunning 幂等标志) + */ +function handleOtaCommand(client, opts, t, cmd, state) { + if (opts.reply === 'none') { + log('收到 OTA 指令但不回执 → 服务端应 5s 超时'); + return; + } + if (opts.reply === 'reject') { + publishJson(client, t.ack, ackPayload(cmd.msgId, 'ota', { ok: false, code: 3, msg: 'md5 mismatch' })); + log('已回失败回执(code=3)→ 任务应落 FAILED,result_msg 记 md5 mismatch'); + return; + } + if (state.otaRunning) { + log('已有 OTA 在跑,按设备侧幂等策略忽略该次指令'); + return; + } + state.otaRunning = true; + // 受理 ≠ 完成:先回 ok=true 表示「已收到」,最终结果用第二条 ack + publishJson(client, t.ack, ackPayload(cmd.msgId, 'ota', { ok: true, code: 0, progress: 0 })); + log(`OTA 已受理 version=${cmd.version},开始上报进度(间隔 ${opts.otaStepMs}ms,真机为 10s)`); + let progress = 0; + const timer = setInterval(() => { + progress += 20; + if (progress >= 100) { + clearInterval(timer); + publishJson(client, t.event, eventPayload('ota_progress', { version: cmd.version, value: 100 })); + publishJson(client, t.ack, ackPayload(cmd.msgId, 'ota', { ok: true, code: 0, progress: 100 })); + state.otaRunning = false; + log('OTA 完成,已回终态 ack{ok:true, progress:100}'); + return; + } + publishJson(client, t.event, eventPayload('ota_progress', { version: cmd.version, value: progress })); + }, opts.otaStepMs); +} + +// --------------------------------------------------------------------------- +// 工具 +// --------------------------------------------------------------------------- + +/** + * 组装 MQTT 连接选项。 + * + * @param {object} opts 配置 + * @param {string} identity 身份标识(用于 clientId,便于在 broker 侧区分连接) + * @return {object} mqtt.connect 选项 + */ +function clientOptions(opts, identity) { + return { + username: opts.username, + password: opts.password, + clientId: `bm-sim-${identity}-${Math.random().toString(16).slice(2, 8)}`, + protocolVersion: 4, + clean: true, + keepalive: 60, + reconnectPeriod: 5000, + connectTimeout: 10000, + }; +} + +/** + * 发布 JSON 报文并打印。 + * + * @param {object} client MQTT 客户端 + * @param {string} topic 目标 Topic + * @param {object} payload 报文对象 + */ +function publishJson(client, topic, payload) { + const text = JSON.stringify(payload); + client.publish(topic, text, { qos: topic.endsWith('/telemetry') ? 0 : 1 }); + log(`>> ${topic} ${text}`); +} + +/** + * 正常下线:发 offline 事件 → 断开。 + * + * @param {object} client MQTT 客户端 + * @param {object} opts 配置 + * @param {string} reason 下线原因 + */ +function shutdown(client, opts, reason) { + if (!opts.deviceNo) { + finish(client, 0); + return; + } + const t = topicsOf(opts.deviceNo); + publishJson(client, t.event, eventPayload('offline', { reason })); + log('已发 offline 事件(服务端应置 status=2 并写一条 offline 告警)'); + setTimeout(() => finish(client, 0), 500); +} + +/** + * 结束进程。 + * + * @param {object} client MQTT 客户端 + * @param {number} code 退出码 + */ +function finish(client, code) { + client.end(true, () => process.exit(code)); +} + +/** + * 容错解析 JSON。 + * + * @param {string} text 原始报文 + * @return {object|null} 解析结果;失败返回 null + */ +function safeParse(text) { + try { + return JSON.parse(text); + } catch (e) { + return null; + } +} + +// --------------------------------------------------------------------------- +// 入口 +// --------------------------------------------------------------------------- + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(USAGE); + return; + } + validate(opts); + banner(opts); + if (opts.scenario === 'register') { + runRegister(opts); + return; + } + runDevice(opts); +} + +main(); diff --git a/script/mqtt/package-lock.json b/script/mqtt/package-lock.json new file mode 100644 index 0000000..e04edb4 --- /dev/null +++ b/script/mqtt/package-lock.json @@ -0,0 +1,542 @@ +{ + "name": "bm-mqtt-simulate", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bm-mqtt-simulate", + "version": "1.0.0", + "dependencies": { + "mqtt": "^5.10.1" + }, + "bin": { + "mqtt-simulate": "mqtt-simulate.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/broker-factory": { + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz", + "integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.15.2", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.2.tgz", + "integrity": "sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/worker-factory": { + "version": "7.0.50", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz", + "integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.34", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz", + "integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.18", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.18", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz", + "integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "broker-factory": "^3.1.15", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz", + "integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/script/mqtt/package.json b/script/mqtt/package.json new file mode 100644 index 0000000..c369f35 --- /dev/null +++ b/script/mqtt/package.json @@ -0,0 +1,18 @@ +{ + "name": "bm-mqtt-simulate", + "version": "1.0.0", + "private": true, + "description": "智能飞禽物联平台 · 设备侧 MQTT 模拟器(无需硬件的联调工具)", + "bin": { + "mqtt-simulate": "mqtt-simulate.js" + }, + "scripts": { + "start": "node mqtt-simulate.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "mqtt": "^5.10.1" + } +} diff --git a/script/sql/bm_patch.sql b/script/sql/bm_patch.sql new file mode 100644 index 0000000..182f209 --- /dev/null +++ b/script/sql/bm_patch.sql @@ -0,0 +1,201 @@ +-- ============================================================================ +-- 智能飞禽物联平台 · 项目自有补丁脚本(PROJECT PATCH,非框架基线) +-- 文件:script/sql/bm_patch.sql +-- 说明:本文件承载「项目自有的库表变更」,与框架基线 ry_vue.sql 分离维护, +-- 以便将来框架(RuoYi-Vue-Plus)升级时按补丁重放、避免与基线冲突。 +-- 【请勿修改 ry_vue.sql 以保持与上游基线一致】。 +-- 执行方式:连接业务库(bird_monitoring)后整体执行本文件。 +-- ============================================================================ + + +-- ---------------------------------------------------------------------------- +-- 补丁 1:为 sys_user.phone_number 增加唯一索引(uk_sys_user_phone_number) +-- ---------------------------------------------------------------------------- +-- 背景: +-- 框架基线 ry_vue.sql 中 sys_user.phone_number 仅有普通索引 +-- key idx_sys_user_phone (phone_number) -- 非唯一 +-- 无法在 DB 层阻止「同一手机号注册多个账号」。 +-- APP 账号闭环(迭代 I2)的注册为「先查后插」,存在并发同号双写(TOCTOU)风险; +-- 应用层已用「按手机号加 Redisson 锁 + 判重」将其降到接近零(见 AppAuthServiceImpl#register), +-- 但 **DB 唯一键才是最终防线**,故以本补丁补充该约束;应用层锁保留作为双保险。 +-- +-- ★ 执行前必读 · 前置检查(务必先跑 SELECT,确认无重复再执行 ALTER) +-- 1) 检查是否存在重复的「非空」手机号: +-- SELECT phone_number, COUNT(*) AS cnt +-- FROM sys_user +-- WHERE phone_number IS NOT NULL AND phone_number <> '' +-- GROUP BY phone_number +-- HAVING COUNT(*) > 1; +-- → 期望返回 0 行。若返回了行,须先人工清理重复数据(每个手机号仅保留一条), +-- 再执行本补丁;否则第 2 步 ALTER 会因重复键失败(1062)。 +-- +-- 2) 空字符串归一化说明: +-- phone_number 列默认值为 ''(可存在多行同为 ''),而 MySQL 唯一索引 +-- **不允许多个 ''(空串)**、但**允许多个 NULL**(NULL 不参与唯一性判定)。 +-- 因此本补丁先将 '' 归一化为 NULL,使唯一约束只作用于「非空手机号」。 +-- +-- ★ 执行前已核验(2026-09-16 实测线上库 bird_monitoring.sys_user) +-- - 总行数:3; +-- - 非空 phone_number:仅 1 行(属 sys_user); +-- - 重复手机号:无(前置检查 SELECT 返回 0 行); +-- - app_user 记录:0 条。 +-- => 满足前置条件,可安全执行。执行后如需复核: +-- SHOW INDEX FROM sys_user WHERE Key_name = 'uk_sys_user_phone_number'; +-- +-- ★ MySQL 限制说明(不要用存储过程硬凑 IF NOT EXISTS) +-- MySQL 不支持 "ALTER TABLE ... ADD UNIQUE KEY IF NOT EXISTS"。 +-- 本补丁**未**用存储过程模拟幂等;若重复执行本文件,将出现如下**预期报错**(可忽略): +-- ERROR 1061 (42000): Duplicate key name 'uk_sys_user_phone_number' +-- 看到该报错即表示索引已存在,无需再次创建。 +-- +-- ★ 影响面提示(重要) +-- 该唯一索引作用于整张 sys_user 表(含 sys_user 与 app_user 两类 user_type), +-- 即执行后**手机号在全表范围内唯一**(非空手机号不可在「后台用户」与「APP 用户」间重复)。 +-- 这与迭代 I2 早期「两类用户手机号互不冲突」的表述不同:现在由 DB 强制全局唯一。 +-- 若后台新增用户时用到已被 APP 占用的手机号,将因该唯一键报 1062(属预期约束)。 +-- +-- ★ 回滚(如需撤销) +-- ALTER TABLE sys_user DROP INDEX uk_sys_user_phone_number; +-- ---------------------------------------------------------------------------- + +-- 步骤 1:将空字符串手机号归一化为 NULL(唯一索引允许多个 NULL) +UPDATE sys_user SET phone_number = NULL WHERE phone_number = ''; + +-- 步骤 2:新增唯一索引 +ALTER TABLE sys_user ADD UNIQUE KEY uk_sys_user_phone_number (phone_number); + + +-- ---------------------------------------------------------------------------- +-- 补丁 2:为 sys_user.email 增加唯一索引(uk_sys_user_email) +-- ---------------------------------------------------------------------------- +-- 背景: +-- 迭代「邮箱注册」新增邮箱账号入口后,email 成为与 phone_number 并列的账号标识, +-- 同样需要「先查后插」之外的第二道防线(DB 唯一键)。 +-- 框架基线 ry_vue.sql 中 sys_user.email 无任何索引。 +-- +-- ★ 执行前必读 · 前置检查 +-- 1) 重复非空邮箱检查: +-- SELECT email, COUNT(*) AS cnt FROM sys_user +-- WHERE email IS NOT NULL AND email <> '' +-- GROUP BY email HAVING COUNT(*) > 1; +-- → 期望返回 0 行。 +-- 2) 空串归一化:email 列默认值为 ''(实测线上 9 行中 8 行为 ''), +-- 而 MySQL 唯一索引允许多个 NULL 但**不允许多个 ''**,故必须先归一化。 +-- +-- ★ 执行前已核验(2026-09-17 实测线上库 bird_monitoring.sys_user) +-- - 总行数 9;非空 email 仅 1 行(admin 的 crazyLionLi@163.com); +-- - 重复邮箱:无(前置检查 SELECT 返回 0 行); +-- - app_user 记录 8 条,email 全为 ''。 +-- => 满足前置条件,可安全执行。 +-- +-- ★ 影响面提示 +-- 与手机号同一口径:该唯一索引作用于整张 sys_user 表(含 sys_user 与 app_user), +-- 即邮箱在全表范围内唯一。若后台新增用户填写了已被 APP 占用的邮箱,将报 1062(属预期约束)。 +-- +-- ★ 重复执行本文件时的预期报错(可忽略) +-- ERROR 1061 (42000): Duplicate key name 'uk_sys_user_email' +-- +-- ★ 回滚 +-- ALTER TABLE sys_user DROP INDEX uk_sys_user_email; +-- ---------------------------------------------------------------------------- + +-- 步骤 1:将空字符串邮箱归一化为 NULL +UPDATE sys_user SET email = NULL WHERE email = ''; + +-- 步骤 2:新增唯一索引 +ALTER TABLE sys_user ADD UNIQUE KEY uk_sys_user_email (email); + + +-- ---------------------------------------------------------------------------- +-- 补丁 3:将 sys_user.phone_number / email 的列默认值 '' 改为 NULL +-- ---------------------------------------------------------------------------- +-- ★ 本补丁是「邮箱注册」能否落地的关键前提,不可省略。 +-- +-- 背景(两个坑叠加才会暴露): +-- 1) 本项目 MyBatis-Plus 全局字段策略为 insertStrategy: NOT_NULL(见 +-- ruoyi-common-mybatis/src/main/resources/common-mybatis.yml)—— +-- 实体字段为 null 时,该列会被**整个从 INSERT 语句中省略**,而不是写入 NULL; +-- 2) 此时生效的是**列默认值**,而 phone_number / email 的基线默认值都是 ''。 +-- +-- 于是:邮箱注册创建的账号不填手机号 ⇒ INSERT 省略 phone_number ⇒ 落库为 '' +-- ⇒ 第二个邮箱注册账号再落 '' ⇒ 撞 uk_sys_user_phone_number 报 1062。 +-- 同一问题对称存在于 email:手机号注册的账号不填邮箱 ⇒ 落 '' ⇒ 第二个撞 uk_sys_user_email。 +-- +-- 修法:把两列的默认值改为 NULL(NULL 不参与唯一性判定,可多行共存)。 +-- 这样「字段为 null ⇒ INSERT 省略该列 ⇒ 落库 NULL」成为一致且唯一的行为, +-- 无需修改框架实体 SysUser(不改框架文件的铁律得以保持)。 +-- +-- ★ 影响面提示(有意为之的正向变化) +-- 后台「新增用户」不填邮箱/手机号时,落库由 '' 变为 NULL。 +-- 应用层所有比较均已用 StringUtils.isBlank 处理('' 与 null 等价), +-- 实测已确认无逻辑依赖 '' 这一具体取值。 +-- +-- ★ 回滚(恢复到基线默认值,回滚前须先处理已落 NULL 的行) +-- ALTER TABLE sys_user MODIFY COLUMN phone_number varchar(11) DEFAULT '' COMMENT '手机号码'; +-- ALTER TABLE sys_user MODIFY COLUMN email varchar(50) DEFAULT '' COMMENT '用户邮箱'; +-- ---------------------------------------------------------------------------- + +-- 步骤 1:手机号列默认值改为 NULL +ALTER TABLE sys_user MODIFY COLUMN phone_number varchar(11) DEFAULT NULL COMMENT '手机号码'; + +-- 步骤 2:邮箱列默认值改为 NULL +ALTER TABLE sys_user MODIFY COLUMN email varchar(50) DEFAULT NULL COMMENT '用户邮箱'; + + +-- ---------------------------------------------------------------------------- +-- 补丁 4:为 bm_device 增加「设备级低电告警阈值」两列 +-- (hood_battery_threshold / ring_battery_threshold) +-- ---------------------------------------------------------------------------- +-- 背景: +-- 低电告警阈值原先是**全局配置**(application.yml 的 device.mqtt.low-battery-threshold=20), +-- 全平台共用一个数。但 PRD Q9「头套/脚环低电阈值」至今未决,且不同型号设备的电池容量与 +-- 放电特性并不相同,单一全局值无法覆盖,故改为按设备配置。 +-- +-- ★ 口径说明(重要) +-- 1) 本列 = **触发阈值**;**恢复阈值不落库**,由应用层派生: +-- recover = trigger + (low-battery-recover-threshold - low-battery-threshold) +-- 工程默认即 20 / 25 ⇒ 回差 5。不把恢复阈值也暴露给用户, +-- 是为了从根上杜绝「恢复 ≤ 触发」这种会让设备卡死在告警态、再也恢复不了的配置。 +-- 2) 取值语义:电量 ≤ 该值即触发低电告警。**0 表示不触发**(等同对该设备关闭低电保护), +-- 因为「电量 ≤ 0」实际不可能成立。同一取值**同时**作用于「低电拒绝遮盖指令」的拦截判定—— +-- 两者刻意共用同一阈值,避免出现「已经告警了却还能遮盖」的口径撕裂。 +-- 3) 头套与脚环**各自独立**取值,互不影响。 +-- +-- ★ 为什么是 NOT NULL DEFAULT 20,而不是可空(NULL = 未配置) +-- a) ALTER 时一次性把存量行回填为 20,与改造前的全局行为**逐字一致**,升级零行为变化; +-- b) 前端列表要按行判定「电量是否低于阈值并标红」(PRD §7.1),若列可空, +-- 前端还得再知道一次全局默认值 —— 又变成「两处真相」;后端也得额外输出 effective 字段 +-- 并遍历列表回填。NOT NULL 直接把这个问题消灭在 DDL 层; +-- c) 与本表 status / hood_connected / signal_level 等列的既有风格一致(均 NOT NULL 带默认值)。 +-- 注:新增设备时若请求体不带该字段,MP 全局 insertStrategy=NOT_NULL 会**省略该列**, +-- 此时生效的正是本处的列默认值 20,符合预期。 +-- +-- ★ 执行前必读 · 前置检查(确认列尚不存在,避免重复执行报 1060) +-- SELECT column_name, column_type, is_nullable, column_default +-- FROM information_schema.columns +-- WHERE table_schema = 'bird_monitoring' AND table_name = 'bm_device' +-- AND column_name IN ('hood_battery_threshold', 'ring_battery_threshold'); +-- → 期望返回 0 行。 +-- +-- ★ 重复执行本文件时的预期报错(可忽略) +-- ERROR 1060 (42S21): Duplicate column name 'hood_battery_threshold' +-- +-- ★ 影响面提示 +-- 两列为 tinyint(有符号,范围 -128~127),取值域由应用层 @Min(0)/@Max(100) 校验; +-- 本补丁只做「加列 + 回填默认值」,不改动任何既有业务数据。 +-- +-- ★ 回滚 +-- ALTER TABLE bm_device +-- DROP COLUMN hood_battery_threshold, +-- DROP COLUMN ring_battery_threshold; +-- ---------------------------------------------------------------------------- + +-- 步骤 1:头套低电告警阈值(存量行回填 20,与改造前的全局默认一致) +ALTER TABLE bm_device + ADD COLUMN hood_battery_threshold tinyint NOT NULL DEFAULT 20 + COMMENT '头套低电告警阈值0-100(≤该值触发,0=不触发)'; + +-- 步骤 2:脚环低电告警阈值(存量行同样回填 20) +ALTER TABLE bm_device + ADD COLUMN ring_battery_threshold tinyint NOT NULL DEFAULT 20 + COMMENT '脚环低电告警阈值0-100(≤该值触发,0=不触发)'; diff --git a/script/sql/ry_device.sql b/script/sql/ry_device.sql new file mode 100644 index 0000000..2121810 --- /dev/null +++ b/script/sql/ry_device.sql @@ -0,0 +1,255 @@ +-- ---------------------------------------------------------------------------- +-- 智能飞禽物联平台 · 设备域建表脚本(阶段1 / 迭代 I1) +-- 说明: +-- 1. 6 张设备表 + 8 个字典 + 设备菜单 + 8 个权限字 + 2 个角色及其菜单关联。 +-- 2. 严禁出现任何直播字段(stream_url / play_url / live_*)与医疗模板字段。 +-- 3. 逻辑删除取值与框架配置一致:0 存在 / 1 删除(common-mybatis.yml: logicDeleteValue=1)。 +-- 4. 唯一索引(device_no / imei)叠加逻辑删除,存在「逻辑删除后编号不可复用」的既有约束, +-- 与 RuoYi 既有模式一致,已在字段注释中写明;如需复用时改用物理删除策略。 +-- 作者:Kou 日期:2026-09-16 +-- ---------------------------------------------------------------------------- + +-- ============================ 一、设备主表 ============================ +drop table if exists bm_device; +create table bm_device ( + device_id bigint(20) not null comment '主键(雪花,仅内部关联)', + device_no varchar(32) not null comment '业务编号,唯一(逻辑删除后编号不可复用)', + imei varchar(32) not null comment '绑定码IMEI,唯一(逻辑删除后不可复用)', + name varchar(64) default null comment '用户侧名称≤20字符', + model varchar(64) default null comment '型号', + sn varchar(64) default null comment '出厂序列号', + hw_version varchar(32) default null comment '硬件版本', + sw_version varchar(32) default null comment '软件版本', + fw_version varchar(32) default null comment '固件版本', + pkg_version varchar(32) default null comment '数据包版本', + ip varchar(64) default null comment '设备IP', + mac varchar(64) default null comment 'MAC', + wifi_ssid varchar(64) default null comment 'WiFi SSID', + status char(1) not null default '0' comment '状态(0未激活 1在线 2离线)', + bind_user_id bigint(20) default null comment '当前绑定APP用户', + bind_time datetime default null comment '最近一次绑定时间', + hood_connected char(1) not null default '0' comment '头套(0断开 1已连接)', + hood_cover_on char(1) not null default '0' comment '眼罩(0打开 1遮盖)', + signal_level tinyint not null default 0 comment '信号等级(0离线 1弱 2一般 3强)', + hood_battery tinyint default null comment '头套电量0-100', + ring_battery tinyint default null comment '脚环电量0-100', + -- 低电告警阈值:设备级配置(PRD Q9 口径),恢复阈值 = 触发阈值 + 回差(回差由 device.mqtt 的 20/25 差值派生) + hood_battery_threshold tinyint not null default 20 comment '头套低电告警阈值0-100(≤该值触发,0=不触发)', + ring_battery_threshold tinyint not null default 20 comment '脚环低电告警阈值0-100(≤该值触发,0=不触发)', + lng decimal(10,7) default null comment '最新经度', + lat decimal(10,7) default null comment '最新纬度', + address varchar(255) default null comment '地址(设备上报)', + last_online_time datetime default null comment '最后在线时间', + remark varchar(500) default null comment '备注', + create_dept bigint(20) default null comment '创建部门', + create_by bigint(20) default null comment '创建者', + create_time datetime comment '创建时间', + update_by bigint(20) default null comment '更新者', + update_time datetime comment '更新时间', + del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)', + primary key (device_id), + unique key uk_device_no (device_no), + unique key uk_imei (imei), + key idx_bind_user (bind_user_id), + key idx_status (status), + key idx_last_online (last_online_time) +) engine=innodb comment = '设备主表'; + +-- ============================ 二、设备绑定流水 ============================ +drop table if exists bm_device_bind; +create table bm_device_bind ( + bind_id bigint(20) not null comment '主键(雪花)', + device_id bigint(20) not null comment '设备物理主键', + device_no varchar(32) default null comment '设备编号(冗余,便于审计/后台展示)', + user_id bigint(20) not null comment '操作APP用户', + imei varchar(32) default null comment 'IMEI(冗余快照)', + action varchar(16) not null comment '动作(bind/unbind/rebind)', + bind_time datetime not null comment '操作时间', + remark varchar(255) default null comment '备注', + primary key (bind_id), + key idx_device_id (device_id), + key idx_user_id (user_id), + key idx_device_action_time (device_id, action, bind_time) +) engine=innodb comment = '设备绑定流水(无逻辑删除,禁止抹除历史)'; + +-- ============================ 三、设备遥测历史 ============================ +drop table if exists bm_device_telemetry; +create table bm_device_telemetry ( + telemetry_id bigint(20) not null comment '主键(雪花)', + device_id bigint(20) not null comment '设备主键', + device_no varchar(32) default null comment '设备编号(冗余)', + hood_battery tinyint default null comment '头套电量0-100', + ring_battery tinyint default null comment '脚环电量0-100', + signal_level tinyint default null comment '信号等级(0离线 1弱 2一般 3强)', + hood_connected char(1) default null comment '头套连接(0断开 1已连接)', + hood_cover_on char(1) default null comment '眼罩状态(0打开 1遮盖)', + lng decimal(10,7) default null comment '经度', + lat decimal(10,7) default null comment '纬度', + address varchar(255) default null comment '地址', + report_time datetime not null comment '设备上报时间', + primary key (telemetry_id), + key idx_device_time (device_id, report_time) +) engine=innodb comment = '设备遥测历史(一期仅落库,不做轨迹回放)'; + +-- ============================ 四、设备告警 ============================ +drop table if exists bm_device_alert; +create table bm_device_alert ( + alert_id bigint(20) not null comment '主键(雪花)', + device_id bigint(20) not null comment '设备主键', + device_no varchar(32) default null comment '设备编号(冗余)', + alert_type varchar(32) not null comment '告警类型(offline/low_battery_hood/low_battery_ring)', + alert_level char(1) not null comment '告警级别(0提示 1警告 2严重)', + alert_value varchar(64) default null comment '触发值(如电量)', + content varchar(255) default null comment '告警文案', + status char(1) not null comment '状态(0未处理 1已处理,一期只读展示)', + alert_time datetime not null comment '告警时间', + primary key (alert_id), + key idx_device_id (device_id), + key idx_alert_type (alert_type), + key idx_alert_time (alert_time) +) engine=innodb comment = '设备告警'; + +-- ============================ 五、OTA 升级包 ============================ +drop table if exists bm_ota_package; +create table bm_ota_package ( + package_id bigint(20) not null comment '主键(雪花)', + model varchar(64) not null comment '适用型号', + version varchar(32) not null comment '版本号', + oss_id bigint(20) default null comment '关联sys_oss.oss_id', + file_url varchar(500) default null comment '文件URL(冗余)', + file_name varchar(255) default null comment '文件名', + file_size bigint(20) default null comment '文件字节数', + md5 varchar(64) default null comment '校验值MD5', + changelog varchar(500) default null comment '更新说明', + status char(1) not null default '0' comment '状态(0停用 1启用)', + remark varchar(500) default null comment '备注', + create_dept bigint(20) default null comment '创建部门', + create_by bigint(20) default null comment '创建者', + create_time datetime comment '创建时间', + update_by bigint(20) default null comment '更新者', + update_time datetime comment '更新时间', + del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)', + primary key (package_id), + unique key uk_model_version (model, version), + key idx_status (status) +) engine=innodb comment = 'OTA升级包'; + +-- ============================ 六、OTA 升级任务 ============================ +drop table if exists bm_ota_task; +create table bm_ota_task ( + task_id bigint(20) not null comment '主键(雪花)', + package_id bigint(20) not null comment '升级包', + version varchar(32) default null comment '冗余版本', + device_id bigint(20) not null comment '设备主键', + device_no varchar(32) default null comment '冗余编号', + status char(1) not null default '0' comment '状态(0待升级 1升级中 2成功 3失败)', + result_msg varchar(500) default null comment '结果信息', + progress tinyint default null comment '进度0-100', + operator_id bigint(20) default null comment '操作人(后台用户)', + times int(4) not null default 0 comment '下发次数', + start_time datetime default null comment '开始时间', + finish_time datetime default null comment '完成时间', + create_dept bigint(20) default null comment '创建部门', + create_by bigint(20) default null comment '创建者', + create_time datetime comment '创建时间', + update_by bigint(20) default null comment '更新者', + update_time datetime comment '更新时间', + del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)', + primary key (task_id), + key idx_device_no (device_no), + key idx_status (status), + key idx_package_id (package_id) +) engine=innodb comment = 'OTA升级任务'; + +-- ============================ 七、字典类型(8 个) ============================ +insert into sys_dict_type values(1771500000000000001, '设备状态', 'bm_device_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备状态列表'); +insert into sys_dict_type values(1771500000000000002, '信号等级', 'bm_signal_level', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '信号等级列表'); +insert into sys_dict_type values(1771500000000000003, '头套连接', 'bm_hood_connected', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '头套连接列表'); +insert into sys_dict_type values(1771500000000000004, '眼罩状态', 'bm_hood_cover', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '眼罩状态列表'); +insert into sys_dict_type values(1771500000000000005, 'OTA任务状态', 'bm_ota_task_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'OTA任务状态列表'); +insert into sys_dict_type values(1771500000000000006, '升级包状态', 'bm_ota_package_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '升级包状态列表'); +insert into sys_dict_type values(1771500000000000007, '告警类型', 'bm_alert_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '告警类型列表'); +insert into sys_dict_type values(1771500000000000008, '绑定动作', 'bm_bind_action', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '绑定动作列表'); + +-- ============================ 八、字典数据 ============================ +-- 设备状态 +insert into sys_dict_data values(1771600000000000001, 1, '未激活', '0', 'bm_device_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '未激活'); +insert into sys_dict_data values(1771600000000000002, 2, '在线', '1', 'bm_device_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '在线'); +insert into sys_dict_data values(1771600000000000003, 3, '离线', '2', 'bm_device_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '离线'); +-- 信号等级 +insert into sys_dict_data values(1771600000000000004, 1, '离线', '0', 'bm_signal_level', '', 'danger', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '离线'); +insert into sys_dict_data values(1771600000000000005, 2, '弱', '1', 'bm_signal_level', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '弱'); +insert into sys_dict_data values(1771600000000000006, 3, '一般', '2', 'bm_signal_level', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '一般'); +insert into sys_dict_data values(1771600000000000007, 4, '强', '3', 'bm_signal_level', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '强'); +-- 头套连接 +insert into sys_dict_data values(1771600000000000008, 1, '已断开', '0', 'bm_hood_connected', '', 'danger', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '已断开'); +insert into sys_dict_data values(1771600000000000009, 2, '已连接', '1', 'bm_hood_connected', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '已连接'); +-- 眼罩状态 +insert into sys_dict_data values(1771600000000000010, 1, '打开', '0', 'bm_hood_cover', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '打开'); +insert into sys_dict_data values(1771600000000000011, 2, '遮盖', '1', 'bm_hood_cover', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '遮盖'); +-- OTA任务状态 +insert into sys_dict_data values(1771600000000000012, 1, '待升级', '0', 'bm_ota_task_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '待升级'); +insert into sys_dict_data values(1771600000000000013, 2, '升级中', '1', 'bm_ota_task_status', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '升级中'); +insert into sys_dict_data values(1771600000000000014, 3, '成功', '2', 'bm_ota_task_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '成功'); +insert into sys_dict_data values(1771600000000000015, 4, '失败', '3', 'bm_ota_task_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '失败'); +-- 升级包状态 +insert into sys_dict_data values(1771600000000000016, 1, '停用', '0', 'bm_ota_package_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '停用'); +insert into sys_dict_data values(1771600000000000017, 2, '启用', '1', 'bm_ota_package_status', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '启用'); +-- 告警类型 +insert into sys_dict_data values(1771600000000000018, 1, '设备离线', 'offline', 'bm_alert_type', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备离线'); +insert into sys_dict_data values(1771600000000000019, 2, '头套低电', 'low_battery_hood', 'bm_alert_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '头套低电'); +insert into sys_dict_data values(1771600000000000020, 3, '脚环低电', 'low_battery_ring', 'bm_alert_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '脚环低电'); +-- 绑定动作 +insert into sys_dict_data values(1771600000000000021, 1, '绑定', 'bind', 'bm_bind_action', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '绑定'); +insert into sys_dict_data values(1771600000000000022, 2, '解绑', 'unbind', 'bm_bind_action', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '解绑'); +insert into sys_dict_data values(1771600000000000023, 3, '换绑', 'rebind', 'bm_bind_action', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '换绑'); + +-- ============================ 九、菜单 + 权限字 ============================ +-- 一级目录 +insert into sys_menu values(1771400000000000001, '设备管理', 0, 6, 'device', null, '', 'N', 'Y', 'M', '0', '0', '', 'dashboard', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备管理目录'); +-- 二级菜单 +insert into sys_menu values(1771400000000000002, '设备列表', 1771400000000000001, 1, 'device', 'device/device/index', '', 'N', 'Y', 'C', '0', '0', 'device:device:list', 'form', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备列表菜单'); +insert into sys_menu values(1771400000000000003, 'OTA管理', 1771400000000000001, 2, 'ota', 'device/ota/index', '', 'N', 'Y', 'C', '0', '0', 'device:ota:list', 'upload', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'OTA管理菜单'); +-- 设备列表按钮 +insert into sys_menu values(1771400000000000011, '设备查询', 1771400000000000002, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); +insert into sys_menu values(1771400000000000012, '设备新增', 1771400000000000002, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); +insert into sys_menu values(1771400000000000013, '设备修改', 1771400000000000002, 3, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); +insert into sys_menu values(1771400000000000014, '设备删除', 1771400000000000002, 4, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); +insert into sys_menu values(1771400000000000015, '设备导出', 1771400000000000002, 5, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:export', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); +-- OTA 按钮 +insert into sys_menu values(1771400000000000016, 'OTA新增', 1771400000000000003, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:ota:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); +-- APP 用户管理(后台管理 APP 注册账号;与 APP 端 /app/user 不是同一组接口) +-- 数据来源:sys_user 中 user_type = 'app_user' 的账号(仅手机号 / 邮箱注册的 C 端用户) +insert into sys_menu values(1771400000000000004, 'APP用户', 1771400000000000001, 3, 'app-user', 'device/appUser/index', '', 'N', 'Y', 'C', '0', '0', 'device:appuser:list', 'user', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'APP用户管理菜单'); +-- APP 用户按钮(编辑权限字复用:状态变更 / 重置密码 / 解绑设备,口径同 device:ota:add) +insert into sys_menu values(1771400000000000017, '用户查询', 1771400000000000004, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); +insert into sys_menu values(1771400000000000018, '用户编辑', 1771400000000000004, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); + +-- ============================ 十、角色 ============================ +insert into sys_role values(1771300000000000001, '设备管理员', 'device_admin', 5, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域管理角色'); +insert into sys_role values(1771300000000000002, '审计员', 'device_auditor', 6, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域只读审计角色'); + +-- ============================ 十一、角色-菜单关联 ============================ +-- 设备管理员:全部设备菜单 +insert into sys_role_menu values (1771300000000000001, 1771400000000000001); +insert into sys_role_menu values (1771300000000000001, 1771400000000000002); +insert into sys_role_menu values (1771300000000000001, 1771400000000000003); +insert into sys_role_menu values (1771300000000000001, 1771400000000000011); +insert into sys_role_menu values (1771300000000000001, 1771400000000000012); +insert into sys_role_menu values (1771300000000000001, 1771400000000000013); +insert into sys_role_menu values (1771300000000000001, 1771400000000000014); +insert into sys_role_menu values (1771300000000000001, 1771400000000000015); +insert into sys_role_menu values (1771300000000000001, 1771400000000000016); +-- 设备管理员:APP 用户管理(菜单 + 查询 + 编辑) +insert into sys_role_menu values (1771300000000000001, 1771400000000000004); +insert into sys_role_menu values (1771300000000000001, 1771400000000000017); +insert into sys_role_menu values (1771300000000000001, 1771400000000000018); +-- 审计员:目录 + 列表 + 详情 + 导出 + OTA 列表 + APP 用户(只读) +insert into sys_role_menu values (1771300000000000002, 1771400000000000001); +insert into sys_role_menu values (1771300000000000002, 1771400000000000002); +insert into sys_role_menu values (1771300000000000002, 1771400000000000003); +insert into sys_role_menu values (1771300000000000002, 1771400000000000011); +insert into sys_role_menu values (1771300000000000002, 1771400000000000015); +-- 审计员:APP 用户菜单 + 查询(不含编辑——审计只读) +insert into sys_role_menu values (1771300000000000002, 1771400000000000004); +insert into sys_role_menu values (1771300000000000002, 1771400000000000017);