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
+ * 同样强制 {@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
+ * ⚠️ 这里是 {@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} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。
+ *
+ * ⚠️ 引入设备级阈值后本字段的含义有所扩展:
+ *
+ * 设备级阈值由 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
+ * ⚠️ 本模块没有数据权限(部门)隔离:{@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
+ * 仅面向 APP 用户({@code sys_user.user_type = app_user})——类型条件由服务端强制附加,
+ * 前端不需要也无法通过本入参覆盖。
+ *
+ * 分页字段({@code pageNum} / {@code pageSize} / {@code orderByColumn} / {@code isAsc})
+ * 继承自 {@link PageQuery},与框架其余列表页口径一致。
+ *
+ * 日期区间经 {@link #params} 传递:前端用 {@code useDateRangeQuery} hook
+ * (内部 {@code utils/ruoyi.ts#addDateRange})把区间写为 {@code params[beginTime]} /
+ * {@code params[endTime]},与框架既有约定一致。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AppUserAdminQueryBo extends PageQuery {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 关键词:模糊匹配账号 / 昵称 / 手机号 / 邮箱(四字段 OR)。
+ */
+ private String keyword;
+
+ /**
+ * 账号状态(0 正常 1 停用);留空查询全部。
+ */
+ private String status;
+
+ /**
+ * 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:注册时间区间)。
+ */
+ private Map
+ * 密码规则与 APP 端注册 / 改密完全同源({@link AppConstants#PASSWORD_REGEX}):
+ * 管理员在后台设的密码,必须同样满足 APP 端的登录前置要求,
+ * 否则会出现「后台设置成功、用户却因密码不合规被 APP 拒」的割裂体验。
+ *
+ * 不引入「确认密码」字段:二次输入属前端交互,服务端只需最终值。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+public class AppUserResetPwdBo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户主键
+ */
+ @NotNull(message = "用户主键不能为空")
+ private Long userId;
+
+ /**
+ * 新密码(8-20 位,含大小写字母和数字)
+ */
+ @NotBlank(message = "新密码不能为空")
+ @Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
+ private String newPassword;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserStatusBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserStatusBo.java
new file mode 100644
index 0000000..6e4535f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppUserStatusBo.java
@@ -0,0 +1,39 @@
+package org.dromara.device.domain.bo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * APP 用户状态变更入参({@code PUT /device/app-user/status})。
+ *
+ * {@code status} 用正则而非枚举校验:字典 {@code sys_user.status} 为 {@code varchar(1)},
+ * 取值仅 {@code 0}(正常)/ {@code 1}(停用),正则能同时挡住空串与超长值。
+ *
+ * @author Kou
+ * @date 2026-09-17
+ */
+@Data
+public class AppUserStatusBo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户主键
+ */
+ @NotNull(message = "用户主键不能为空")
+ private Long userId;
+
+ /**
+ * 目标状态(0 正常 1 停用)
+ */
+ @NotBlank(message = "账号状态不能为空")
+ @Pattern(regexp = "^[01]$", message = "账号状态只能为 0(正常)或 1(停用)")
+ private String status;
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/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
+ * 复用单一解绑入口 {@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
+ * 「不存在」与「非 APP 账号」在此归为同一结果({@code null}):
+ * 后者属于越界访问,不应向外泄露「该主键其实是后台账号」这一信息。
+ */
+ @Override
+ public AppUserAdminVo queryById(Long userId) {
+ AppUserDTO user = appUserApiService.selectAppUserById(userId);
+ if (user == null) {
+ return null;
+ }
+ AppUserAdminVo vo = toVo(user);
+ vo.setDeviceCount(countDevicesByUser(List.of(userId)).getOrDefault(userId, 0L));
+ return vo;
+ }
+
+ /**
+ * 查询指定 APP 用户当前绑定的设备列表。
+ *
+ * 复用 {@link IDeviceService#queryList}——其底层已支持按 {@code bind_user_id} 精确过滤
+ * ({@code DeviceServiceImpl#buildQueryWrapper}),故无需新增设备侧查询方法。
+ *
+ * 目标不存在或非 APP 账号时返回空列表而非抛异常:与 {@link #queryById} 的「返回 {@code null}」
+ * 口径保持一致,避免前端出现「详情显示空态、设备列表却弹错误」的割裂表现。
+ * 写操作(解绑)则不适用该宽松口径——见 {@link #unbindDevice}。
+ */
+ @Override
+ public List
+ * {@code updateStatus} 返回 {@code false} 即表示目标不存在或非 APP 账号
+ * (类型条件由 {@code ruoyi-system} 侧强制附加),统一转「账号不存在」,
+ * 不向前端区分具体原因,避免被用来探测 user_id 归属。
+ */
+ @Override
+ public Boolean updateStatus(AppUserStatusBo bo) {
+ if (!appUserApiService.updateStatus(bo.getUserId(), bo.getStatus())) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ return true;
+ }
+
+ /**
+ * 重置账号密码(管理员代为设置)。
+ *
+ * 明文只在内存中从入参传递到 {@link AppUserApiService#resetPassword},
+ * 散列由 {@code ruoyi-system} 内部完成(BCrypt),本层不感知散列值。
+ */
+ @Override
+ public Boolean resetPassword(AppUserResetPwdBo bo) {
+ if (!appUserApiService.resetPassword(bo.getUserId(), bo.getNewPassword())) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ return true;
+ }
+
+ /**
+ * 解绑该用户名下的某台设备。
+ *
+ * 复用单一解绑入口:{@code IDeviceBindService#unbind} 内部以
+ * {@code device_no + bind_user_id = userId} 双重定位,因此「设备不属于该用户」
+ * 与「设备未绑定」都会得到影响行数 0,统一转「设备不存在」——
+ * 既保证管理员不会误操他人设备,也不泄露设备归属。
+ */
+ @Override
+ public Boolean unbindDevice(Long userId, String deviceNo) {
+ requireAppUser(userId);
+ if (StringUtils.isBlank(deviceNo)) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ if (deviceBindService.unbind(deviceNo, userId) <= 0) {
+ throw new ServiceException(DeviceMsgConstants.DEVICE_NOT_EXIST);
+ }
+ return true;
+ }
+
+ /**
+ * 校验目标确为存在的 APP 账号,否则抛「账号不存在」。
+ *
+ * 用于「列表 / 解绑」这类需要先确认主体存在的入口;
+ * 状态变更与重置密码则由跨模块方法的返回值兜底(类型条件已在其内部强制)。
+ *
+ * @param userId 用户主键
+ */
+ private void requireAppUser(Long userId) {
+ if (userId == null || appUserApiService.selectAppUserById(userId) == null) {
+ throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
+ }
+ }
+
+ /**
+ * 批量统计这批用户各自的绑定设备数。
+ *
+ * 逻辑删除条件由 MyBatis-Plus 依据实体的 {@code @TableLogic} 自动附加,此处不手写 {@code del_flag}。
+ *
+ * @param userIds 用户主键集合
+ * @return userId → 设备数(无设备的用户不出现在 map 中)
+ */
+ private Map
+ * {@code phoneBound} 由服务端计算而非让前端判空:该语义后续可能变化
+ * (例如兼容历史 {@code ''} 空串),收敛在一处更易维护。
+ */
+ private AppUserAdminVo toVo(AppUserDTO user) {
+ AppUserAdminVo vo = BeanUtil.copyProperties(user, AppUserAdminVo.class);
+ vo.setPhoneBound(StringUtils.isNotBlank(user.getPhoneNumber()));
+ return vo;
+ }
+
+ /**
+ * 设备 VO → 用户视角的设备条目(字段为裁剪关系,同名自动映射)。
+ */
+ private AppUserDeviceVo toDeviceVo(DeviceVo device) {
+ return BeanUtil.copyProperties(device, AppUserDeviceVo.class);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/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 绑定关系
+ * 与运维话术引用;允许改号会产生「同一台设备两个编号」的口径混乱。因此:
+ *
+ * 存在性校验:{@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
+ * 强制 {@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);
+ *
*/
private int lowBatteryRecoverThreshold = 25;
@@ -101,4 +109,39 @@ public class DeviceMqttProperties {
*/
private Duration pendingAckTtl = Duration.ofMinutes(10);
+ // ---------------- 低电阈值解析(设备级配置 → 生效值) ----------------
+
+ /**
+ * 解析生效的触发阈值:设备级配置优先,未配置则回落工程默认。
+ *
+ *
+ *
+ *
+ * > 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
+ *
+ *
+ *
+ * 第二条是必需的兜底:MyBatis-Plus 全局 {@code updateStrategy = NOT_NULL},
+ * 而空串「非 null」——若不回填,空串会被当作有效值写入 {@code device_no},
+ * 既破坏业务主键、又可能撞唯一键 {@code uk_device_no}。
+ *