From 7bdc5c846d392081545c438f52515c4f5f0d1804 Mon Sep 17 00:00:00 2001 From: yuhaiming <40624989@qq.com> Date: Thu, 17 Sep 2026 17:09:49 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=88=97=E8=A1=A8=20=20?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=B5=81=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 14 +- .../dromara/system/api/AppUserApiService.java | 46 ++++ .../dromara/system/api/domain/AppUserDTO.java | 9 +- .../system/api/domain/AppUserQueryDTO.java | 69 ++++++ .../mybatis/config/MybatisPlusConfig.java | 9 + .../common/mybatis/core/page/PageQuery.java | 9 +- .../device/config/DeviceMqttProperties.java | 43 ++++ .../device/constant/DeviceMsgConstants.java | 14 ++ .../controller/AppUserAdminController.java | 150 ++++++++++++ .../device/controller/DeviceController.java | 11 +- .../controller/app/AppAuthController.java | 5 +- .../org/dromara/device/domain/BmDevice.java | 10 + .../device/domain/bo/AppUserAdminQueryBo.java | 49 ++++ .../device/domain/bo/AppUserResetPwdBo.java | 44 ++++ .../device/domain/bo/AppUserStatusBo.java | 39 +++ .../dromara/device/domain/bo/DeviceBo.java | 20 ++ .../device/domain/vo/AppUserAdminVo.java | 81 +++++++ .../device/domain/vo/AppUserDeviceVo.java | 77 ++++++ .../dromara/device/domain/vo/DeviceVo.java | 12 + .../mqtt/impl/DefaultDeviceUplinkHandler.java | 5 +- .../device/mqtt/payload/AckPayload.java | 5 - .../device/mqtt/payload/EventPayload.java | 5 - .../mqtt/payload/HoodCommandPayload.java | 6 - .../mqtt/payload/OtaCommandPayload.java | 6 - .../mqtt/payload/ProvisionAckPayload.java | 7 - .../device/mqtt/payload/ProvisionPayload.java | 5 - .../mqtt/payload/StreamCommandPayload.java | 6 - .../device/mqtt/payload/TelemetryPayload.java | 5 - .../mqtt/support/DeviceAlertWriter.java | 36 ++- .../device/service/IAppAuthService.java | 3 +- .../device/service/IAppUserAdminService.java | 86 +++++++ .../device/service/IDeviceService.java | 8 +- .../service/impl/AppAuthServiceImpl.java | 5 +- .../service/impl/AppUserAdminServiceImpl.java | 224 ++++++++++++++++++ .../impl/DeviceCommandServiceImpl.java | 11 +- .../service/impl/DeviceServiceImpl.java | 28 ++- .../org/dromara/device/sms/AppSmsService.java | 5 +- .../service/impl/AppUserApiServiceImpl.java | 117 +++++++++ script/sql/bm_patch.sql | 59 +++++ script/sql/ry_device.sql | 18 +- 40 files changed, 1283 insertions(+), 78 deletions(-) create mode 100644 ruoyi-api/src/main/java/org/dromara/system/api/domain/AppUserQueryDTO.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/AppUserAdminController.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserAdminQueryBo.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserResetPwdBo.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserStatusBo.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserDeviceVo.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserAdminService.java create mode 100644 ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserAdminServiceImpl.java diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index f24ba50..5760717 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -15,11 +15,11 @@ server: # 最大线程数 max: 256 zlm: - host: ${ZLM_HOST:http://220.203.23.195:9090} # ZLMediaKit 地址 - rtmp-host: ${ZLM_RTMP_HOST:rtmp://220.203.23.195:1935} - rtsp-host: ${ZLM_RTSP_HOST:rtsp://220.203.23.195:554} - webrtc-host: ${ZLM_WEBRTC_HOST:http://220.203.23.195:9090} - secret: ${ZLM_SECRET:} # ZLM webhook 密钥:不落明文,必须通过环境变量 ZLM_SECRET 注入;未配置时 /zlm/hook/** 回调一律拒绝(失败关闭) + 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 @@ -284,8 +284,8 @@ mqtt.client: client-id: bird_monitor # MQTT broker 账号/密码:不落明文,通过环境变量注入(避免凭据进入 git 历史)。 # 联调前 export MQTT_USERNAME / MQTT_PASSWORD;未配置时取空值,broker 会拒绝认证。 - username: ${MQTT_USERNAME:} - password: ${MQTT_PASSWORD:} + username: ${MQTT_USERNAME:admin} + password: ${MQTT_PASSWORD:61e6062129e9} # 超时时间,单位:秒,默认:5秒 timeout: 5 # 重连时间,默认 5000 毫秒 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 index 4706687..2f8e262 100644 --- a/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java +++ b/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java @@ -1,6 +1,8 @@ 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。 @@ -134,4 +136,48 @@ public interface AppUserApiService { */ 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 index 5623159..988082b 100644 --- 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 @@ -61,8 +61,15 @@ public class AppUserDTO implements Serializable { 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/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 index 5d154ff..0c52742 100644 --- 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 @@ -60,6 +60,14 @@ public class DeviceMqttProperties { * 低电告警恢复阈值(≥ 该值恢复)。 *

* 与 {@link #lowBatteryThreshold} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。 + *

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

*/ private int lowBatteryRecoverThreshold = 25; @@ -101,4 +109,39 @@ public class DeviceMqttProperties { */ 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/constant/DeviceMsgConstants.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/DeviceMsgConstants.java index d342ba5..e39a45d 100644 --- 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 @@ -89,6 +89,13 @@ public final class DeviceMsgConstants { */ public static final String DEVICE_NO_LIMIT = "当日设备编号已达上限(9999)"; + /** + * 设备编号建档后不可修改(后台编辑时校验)。 + *

{@code device_no} 由服务端建档时自动生成,是被 APP 绑定关系与运维话术引用的业务主展示键, + * 允许改号会造成「同一台设备两个编号」的口径混乱,故编辑入口显式拒绝。 + */ + public static final String DEVICE_NO_IMMUTABLE = "设备编号由系统自动生成,不支持修改"; + /** * 设备已被他人绑定 *

TODO 阶段 7 按原型核对原句。 @@ -218,4 +225,11 @@ public final class DeviceMsgConstants { */ 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 index dc2b808..e7c0358 100644 --- 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 @@ -38,7 +38,16 @@ import java.util.List; *

  • {@code POST /device/export} —— Excel 导出(I5)
  • * * OTA 相关接口见 {@link org.dromara.device.controller.OtaController}({@code /device/ota/**},I5)。 - * 后台接口一律 {@code @SaCheckPermission}(数据权限由框架全局拦截器承担),变更类加 {@code @Log}。 + * 后台接口一律 {@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 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 index 4da2d87..2b09905 100644 --- 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 @@ -46,8 +46,9 @@ public class AppAuthController extends BaseController { */ @PostMapping("/sms/code") public R sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) { - appAuthService.sendSmsCode(bo); - return R.ok(AppMsgConstants.SMS_CODE_SENT); + String code = appAuthService.sendSmsCode(bo); + // 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案 + return R.ok(code == null ? AppMsgConstants.SMS_CODE_SENT : code); } /** 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 index 594954e..ce858c6 100644 --- 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 @@ -133,6 +133,16 @@ public class BmDevice extends BaseEntity { */ private Integer ringBattery; + /** + * 头套低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发,即对该设备关闭低电保护) + */ + private Integer hoodBatteryThreshold; + + /** + * 脚环低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发) + */ + private Integer ringBatteryThreshold; + /** * 最新经度 */ 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/DeviceBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/DeviceBo.java index 57550c3..5b92d8b 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -133,6 +135,24 @@ public class DeviceBo implements Serializable { */ 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; + /** * 最新经度 */ 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/DeviceVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/DeviceVo.java index c579e43..51fc8af 100644 --- 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 @@ -161,6 +161,18 @@ public class DeviceVo implements Serializable { @ExcelProperty(value = "脚环电量") private Integer ringBattery; + /** + * 头套低电告警阈值 0-100(设备级配置;0 表示不触发) + */ + @ExcelProperty(value = "头套低电阈值") + private Integer hoodBatteryThreshold; + + /** + * 脚环低电告警阈值 0-100(设备级配置;0 表示不触发) + */ + @ExcelProperty(value = "脚环低电阈值") + private Integer ringBatteryThreshold; + /** * 最新经度 */ 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 index 0e61148..0117a7c 100644 --- 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 @@ -107,10 +107,11 @@ public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler { return; } telemetryArchiver.archive(device, payload, receivedAt); + // 低电判定用「该设备自己配的阈值」,头套与脚环各自独立 alertWriter.applyLowBattery(device.getDeviceId(), deviceNo, - AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery()); + AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery(), device.getHoodBatteryThreshold()); alertWriter.applyLowBattery(device.getDeviceId(), deviceNo, - AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery()); + AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery(), device.getRingBatteryThreshold()); } catch (Exception e) { log.error("遥测处理异常 => deviceNo: {}", deviceNo, e); } 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 index 16956f6..45c61c4 100644 --- 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 @@ -21,11 +21,6 @@ public class AckPayload implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** - * 协议版本(可选,一期固定 1) - */ - private Integer v; - /** * 下行指令的 msgId,必须原样回传 */ 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 index 8aa1fc1..da9ef81 100644 --- 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 @@ -20,11 +20,6 @@ public class EventPayload implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** - * 协议版本(可选,一期固定 1) - */ - private Integer v; - /** * 设备本地时间(epoch 毫秒) */ 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 index e3a467c..55dfbe1 100644 --- 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 @@ -27,11 +27,6 @@ public class HoodCommandPayload implements Serializable { */ public static final String CMD = "hood"; - /** - * 协议版本(一期固定 1) - */ - private Integer v; - /** * 指令唯一标识,用于回执关联与设备侧幂等 */ @@ -61,7 +56,6 @@ public class HoodCommandPayload implements Serializable { */ public static HoodCommandPayload of(String msgId, boolean coverOn) { HoodCommandPayload payload = new HoodCommandPayload(); - payload.setV(1); payload.setMsgId(msgId); payload.setCoverOn(coverOn); payload.setTs(System.currentTimeMillis()); 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 index 52f268d..a2104af 100644 --- 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 @@ -31,11 +31,6 @@ public class OtaCommandPayload implements Serializable { */ public static final String CMD = "ota"; - /** - * 协议版本(一期固定 1) - */ - private Integer v; - /** * 指令唯一标识,用于回执关联与设备侧幂等 */ @@ -90,7 +85,6 @@ public class OtaCommandPayload implements Serializable { public static OtaCommandPayload of(String msgId, String version, String url, String md5, Long size, String changelog) { OtaCommandPayload payload = new OtaCommandPayload(); - payload.setV(1); payload.setMsgId(msgId); payload.setVersion(version); payload.setUrl(url); 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 index 3902d9e..59fa1dd 100644 --- 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 @@ -22,11 +22,6 @@ public class ProvisionAckPayload implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** - * 协议版本(一期固定 1) - */ - private Integer v; - /** * 设备 IMEI */ @@ -66,7 +61,6 @@ public class ProvisionAckPayload implements Serializable { */ public static ProvisionAckPayload success(String imei, String deviceNo) { ProvisionAckPayload payload = new ProvisionAckPayload(); - payload.setV(1); payload.setImei(imei); payload.setOk(true); payload.setCode(0); @@ -85,7 +79,6 @@ public class ProvisionAckPayload implements Serializable { */ public static ProvisionAckPayload fail(String imei, int code, String msg) { ProvisionAckPayload payload = new ProvisionAckPayload(); - payload.setV(1); payload.setImei(imei); payload.setOk(false); payload.setCode(code); 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 index 4ee5746..db4be45 100644 --- 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 @@ -22,11 +22,6 @@ public class ProvisionPayload implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** - * 协议版本(一期固定 1) - */ - private Integer v; - /** * 设备本地时间(epoch 毫秒) */ 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 index 5da6965..89cc298 100644 --- 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 @@ -36,11 +36,6 @@ public class StreamCommandPayload implements Serializable { */ public static final String ACTION_STOP = "stop"; - /** - * 协议版本(一期固定 1) - */ - private Integer v; - /** * 指令唯一标识 */ @@ -111,7 +106,6 @@ public class StreamCommandPayload implements Serializable { private static StreamCommandPayload of(String msgId, String action, String streamId, String app, String protocol, String pushUrl) { StreamCommandPayload payload = new StreamCommandPayload(); - payload.setV(1); payload.setMsgId(msgId); payload.setAction(action); payload.setStreamId(streamId); 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 index 5d6afa8..b5fb66a 100644 --- 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 @@ -26,11 +26,6 @@ public class TelemetryPayload implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** - * 协议版本(可选,一期固定 1) - */ - private Integer v; - /** * 设备本地时间(epoch 毫秒) */ 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 index 503266c..ddf54f3 100644 --- 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 @@ -62,22 +62,39 @@ public class DeviceAlertWriter { } /** - * 按服务端阈值判定并处理低电(含滞回与落库)。 + * 按设备级阈值判定并处理低电(含滞回与落库)。 *

    * 这是低电告警的主路径:每一次遥测都调用,内部按滞回状态决定是否需要动作。 + *

    + * 触发阈值取设备自身配置({@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 deviceId 设备物理主键 + * @param deviceNo 设备编号 + * @param type 告警类型(头套 / 脚环低电) + * @param battery 当前电量(0-100) + * @param deviceThreshold 设备上配置的触发阈值({@code null} 表示未配置,走工程默认) */ - public void applyLowBattery(Long deviceId, String deviceNo, AlertTypeEnum type, Integer battery) { + 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)); - if (battery <= properties.getLowBatteryThreshold()) { + // trigger = 0 是「对该设备关闭低电保护」的显式开关,不是「阈值取 0」: + // 若照直写成 battery <= trigger,0 会退化成「电量恰好归零才告警」,与 DDL 注释、 + // 表单提示里的「0=不触发」承诺不符。 + if (trigger > 0 && battery <= trigger) { if (latched) { // 已处于低电态:不重复告警,也不刷新闩锁 TTL(TTL 只作残留清理兜底) return; @@ -88,9 +105,10 @@ public class DeviceAlertWriter { String.valueOf(battery), lowBatteryContent(type, battery)); return; } - if (battery >= properties.getLowBatteryRecoverThreshold() && latched) { + if (battery >= recover && latched) { RedisUtils.deleteObject(latchKey); - log.info("低电恢复 => deviceNo: {}, type: {}, battery: {}", deviceNo, type.getType(), battery); + log.info("低电恢复 => deviceNo: {}, type: {}, battery: {}, recoverThreshold: {}", + deviceNo, type.getType(), battery, recover); } // 介于 (触发阈值, 恢复阈值) 之间:保持现状,这正是滞回区间的意义 } 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 index 2dad51c..84f7f9d 100644 --- 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 @@ -26,8 +26,9 @@ public interface IAppAuthService { * 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。 * * @param bo 验证码入参(手机号 + 场景) + * @return mock 模式下返回验证码,真实短信模式返回 {@code null} */ - void sendSmsCode(AppSmsCodeBo bo); + String sendSmsCode(AppSmsCodeBo bo); /** * 手机号注册。 diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserAdminService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserAdminService.java new file mode 100644 index 0000000..ec8405e --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserAdminService.java @@ -0,0 +1,86 @@ +package org.dromara.device.service; + +import org.dromara.common.core.domain.PageResult; +import org.dromara.device.domain.bo.AppUserAdminQueryBo; +import org.dromara.device.domain.bo.AppUserResetPwdBo; +import org.dromara.device.domain.bo.AppUserStatusBo; +import org.dromara.device.domain.vo.AppUserAdminVo; +import org.dromara.device.domain.vo.AppUserDeviceVo; + +import java.util.List; + +/** + * APP 用户管理服务(后台管理端)。 + *

    + * 与 APP 端服务的边界:{@code IAppUserService} 面向 C 端「本人操作本人」,用户主键取自登录态; + * 本接口面向后台「管理员操作他人」,用户主键由调用方显式传入,因此必须由权限字把关 + * ({@code device:appuser:*}),且每个写操作都要自证目标确为 APP 账号。 + *

    + * 越权防线(本接口的核心约束):APP 账号与后台账号同处 {@code sys_user} 表, + * 故所有写操作都强制附加 {@code user_type = app_user}: + *

    + *

    + * 密码不跨界:重置密码只把明文交给 {@code AppUserApiService}, + * 散列在 {@code ruoyi-system} 内部完成后落库,本层不接触散列值。 + * + * @author Kou + * @date 2026-09-17 + */ +public interface IAppUserAdminService { + + /** + * 分页查询 APP 用户列表(结果恒不含后台账号)。 + * + * @param bo 查询条件(关键词 / 状态 / 注册时间区间 / 分页) + * @return APP 用户分页结果(含绑定设备数) + */ + PageResult 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/IDeviceService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IDeviceService.java index 2b4b922..01a4145 100644 --- 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 @@ -51,9 +51,13 @@ public interface IDeviceService { Boolean insertByBo(DeviceBo bo); /** - * 修改设备 + * 修改设备(**不支持修改 {@code deviceNo}**)。 + *

    + * 编号不可变:入参编号与库中现值不一致时抛 + * {@link org.dromara.common.core.exception.ServiceException};未传或传空串则视为 + * 「本次不改编号」,以库中现值回填。设备不存在(含已逻辑删除)同样抛出业务异常。 * - * @param bo 编辑业务对象 + * @param bo 编辑业务对象({@code deviceId} 必填,见 {@code EditGroup} 校验组) * @return 是否修改成功 */ Boolean updateByBo(DeviceBo 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 index 6729d34..1863ed3 100644 --- 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 @@ -62,10 +62,11 @@ public class AppAuthServiceImpl implements IAppAuthService { * 发送短信验证码。 * * @param bo 验证码入参(手机号 + 场景) + * @return mock 模式下返回验证码,真实短信模式返回 {@code null} */ @Override - public void sendSmsCode(AppSmsCodeBo bo) { - appSmsService.sendCode(bo.getPhoneNumber(), bo.getScene()); + public String sendSmsCode(AppSmsCodeBo bo) { + return appSmsService.sendCode(bo.getPhoneNumber(), bo.getScene()); } /** 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/DeviceCommandServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/DeviceCommandServiceImpl.java index 0de3816..830090f 100644 --- 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 @@ -101,6 +101,10 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService { *

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

    + * 低电阈值取该设备自己配置的 {@code hood_battery_threshold}(未配置回落工程默认), + * 与 {@code DeviceAlertWriter#applyLowBattery} 共用同一阈值口径 —— 刻意不拆成两套值, + * 否则会出现「已经判定低电并告警了,却仍能下发遮盖」的口径撕裂。 * * @param device 目标设备 * @param deviceNo 设备编号(日志用) @@ -113,10 +117,13 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService { throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE); } Integer battery = device.getHoodBattery(); - if (coverOn && battery != null && battery <= properties.getLowBatteryThreshold()) { + 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, properties.getLowBatteryThreshold()); + deviceNo, battery, trigger); throw new ServiceException(DeviceMsgConstants.LOW_BATTERY); } } 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 index 88e3aad..6cf658f 100644 --- 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 @@ -169,14 +169,40 @@ public class DeviceServiceImpl implements IDeviceService { } /** - * 修改设备 + * 修改设备(**不接受修改 {@code device_no}**)。 + *

    + * 编号不可变:{@code device_no} 是业务主展示键,由建档时服务端自动生成,且被 APP 绑定关系 + * 与运维话术引用;允许改号会产生「同一台设备两个编号」的口径混乱。因此: + *

    + * 第二条是必需的兜底: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; } 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 index 45139bf..5418a5f 100644 --- 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 @@ -46,8 +46,9 @@ public class AppSmsService { * * @param phoneNumber 手机号 * @param scene 场景(login / register / resetPwd / rebind) + * @return mock 模式下返回验证码供接口联调,真实短信模式返回 {@code null} */ - public void sendCode(String phoneNumber, String scene) { + public String sendCode(String phoneNumber, String scene) { if (!SmsSceneEnum.isValid(scene)) { throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID); } @@ -66,6 +67,8 @@ public class AppSmsService { // 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算) RedisUtils.deleteObject(AppConstants.SMS_FAIL_KEY_PREFIX + scene + ":" + phoneNumber); send(phoneNumber, code); + // 仅模拟发送时向上返回验证码,避免真实短信模式通过接口泄露验证码 + return sms.isMock() ? code : null; } /** 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 index 326bf69..fbe53e1 100644 --- 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 @@ -2,19 +2,25 @@ 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})实现。 @@ -240,6 +246,13 @@ public class AppUserApiServiceImpl implements AppUserApiService { /** * 重置用户密码(忘记密码场景);新密码在本方法内部散列后落库。 + *

    + * 限定 {@code user_type = app_user}(2026-09-17 加固):本方法原先只按 {@code userId} 更新, + * 若被后台管理端以任意主键调用,即可重置后台账号密码——属越权写入。 + * 现补上类型条件,使「重置密码」这一高危动作的爆炸半径限定在 APP 用户内。 + *

    + * 对既有调用方无行为影响:三处调用(手机号找回 / 邮箱找回 / APP 内改密)的 {@code userId} + * 均取自 {@code selectByPhone} / {@code selectByEmail} / 登录态,本就恒为 app_user。 * * @param userId 用户主键 * @param rawPassword 新明文密码 @@ -253,6 +266,7 @@ public class AppUserApiServiceImpl implements AppUserApiService { return userMapper.lambda() .set(SysUser::getPassword, BCrypt.hashpw(rawPassword)) .eq(SysUser::getUserId, userId) + .eq(SysUser::getUserType, UserType.APP_USER.getUserType()) .updateCount() > 0; } @@ -312,4 +326,107 @@ public class AppUserApiServiceImpl implements AppUserApiService { 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/sql/bm_patch.sql b/script/sql/bm_patch.sql index 8cd8954..182f209 100644 --- a/script/sql/bm_patch.sql +++ b/script/sql/bm_patch.sql @@ -140,3 +140,62 @@ 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 index 735cf14..2121810 100644 --- a/script/sql/ry_device.sql +++ b/script/sql/ry_device.sql @@ -33,6 +33,9 @@ create table bm_device ( 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 '地址(设备上报)', @@ -215,6 +218,12 @@ insert into sys_menu values(1771400000000000014, '设备删除', 177140000000000 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, '设备域管理角色'); @@ -231,9 +240,16 @@ 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); --- 审计员:目录 + 列表 + 详情 + 导出 + OTA 列表 +-- 设备管理员: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);