设备列表 视频流服务器

This commit is contained in:
yuhaiming
2026-09-17 17:09:49 +08:00
parent 1cfb071dfb
commit 7bdc5c846d
40 changed files with 1283 additions and 78 deletions

View File

@@ -15,11 +15,11 @@ server:
# 最大线程数 # 最大线程数
max: 256 max: 256
zlm: zlm:
host: ${ZLM_HOST:http://220.203.23.195:9090} # ZLMediaKit 地址 host: ${ZLM_HOST:http://47.104.136.221:8090} # ZLMediaKit 地址
rtmp-host: ${ZLM_RTMP_HOST:rtmp://220.203.23.195:1935} rtmp-host: ${ZLM_RTMP_HOST:rtmp://47.104.136.221:1935}
rtsp-host: ${ZLM_RTSP_HOST:rtsp://220.203.23.195:554} rtsp-host: ${ZLM_RTSP_HOST:rtsp://47.104.136.221:554}
webrtc-host: ${ZLM_WEBRTC_HOST:http://220.203.23.195:9090} webrtc-host: ${ZLM_WEBRTC_HOST:http://47.104.136.221:8090}
secret: ${ZLM_SECRET:} # ZLM webhook 密钥:不落明文,必须通过环境变量 ZLM_SECRET 注入;未配置时 /zlm/hook/** 回调一律拒绝(失败关闭) secret: ${ZLM_SECRET:D5QbzhEkO8X0cdxMnIwj5USsCIfSKcOq} # ZLM webhook 密钥:不落明文,必须通过环境变量 ZLM_SECRET 注入;未配置时 /zlm/hook/** 回调一律拒绝(失败关闭)
app: live app: live
schema: rtmp schema: rtmp
push-protocol: rtmp push-protocol: rtmp
@@ -284,8 +284,8 @@ mqtt.client:
client-id: bird_monitor client-id: bird_monitor
# MQTT broker 账号/密码:不落明文,通过环境变量注入(避免凭据进入 git 历史)。 # MQTT broker 账号/密码:不落明文,通过环境变量注入(避免凭据进入 git 历史)。
# 联调前 export MQTT_USERNAME / MQTT_PASSWORD;未配置时取空值,broker 会拒绝认证。 # 联调前 export MQTT_USERNAME / MQTT_PASSWORD;未配置时取空值,broker 会拒绝认证。
username: ${MQTT_USERNAME:} username: ${MQTT_USERNAME:admin}
password: ${MQTT_PASSWORD:} password: ${MQTT_PASSWORD:61e6062129e9}
# 超时时间,单位:秒,默认:5秒 # 超时时间,单位:秒,默认:5秒
timeout: 5 timeout: 5
# 重连时间,默认 5000 毫秒 # 重连时间,默认 5000 毫秒

View File

@@ -1,6 +1,8 @@
package org.dromara.system.api; 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.AppUserDTO;
import org.dromara.system.api.domain.AppUserQueryDTO;
/** /**
* APP 用户(C 端)领域服务对外暴露的 API。 * APP 用户(C 端)领域服务对外暴露的 API。
@@ -134,4 +136,48 @@ public interface AppUserApiService {
*/ */
boolean isPhoneBound(Long userId); boolean isPhoneBound(Long userId);
// ---------------- 后台管理端(APP 用户管理) ----------------
/**
* 分页查询 APP 用户列表(后台「APP 用户管理」用)。
* <p>
* <b>强制口径</b>:实现侧固定附加 {@code user_type = app_user} 条件,
* 调用方<b>无法</b>通过 {@link AppUserQueryDTO} 覆盖该条件,
* 因此本方法<b>恒不返回</b>后台账号({@code sys_user})。
* <p>
* <b>数据权限</b>:APP 用户注册时不写 {@code dept_id}(该列恒为 {@code NULL}),
* 不参与后台部门树,故实现侧<b>刻意不加</b>框架 {@code @DataPermission} 过滤——
* 否则按部门维度过滤会把全部 APP 用户过滤为空。
*
* @param query 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
* @return APP 用户分页结果(不含密码)
*/
PageResult<AppUserDTO> selectAppUserPage(AppUserQueryDTO query);
/**
* 按主键查询 APP 用户详情(后台「APP 用户管理」用)。
* <p>
* 同样强制 {@code user_type = app_user}:传入后台账号主键时返回 {@code null},
* 避免管理端越界读到系统账号信息。
*
* @param userId 用户主键
* @return APP 用户信息(不含密码);不存在或非 app_user 时返回 {@code null}
*/
AppUserDTO selectAppUserById(Long userId);
/**
* 修改 APP 用户账号状态(停用 / 启用)。
* <p>
* 与 {@link #disableUser(Long)} 的区别:本方法同时支持<b>恢复启用</b>,
* 供后台管理端做双向状态切换;{@code disableUser} 保持「注销前置」单向语义不变。
* <p>
* <b>安全约束</b>:实现侧限定 {@code user_type = app_user},
* 防止通过本方法改动后台账号状态。
*
* @param userId 用户主键
* @param status 目标状态({@code 0} 正常 / {@code 1} 停用)
* @return true 修改成功(含目标状态与当前一致的情形)
*/
boolean updateStatus(Long userId, String status);
} }

View File

@@ -61,8 +61,15 @@ public class AppUserDTO implements Serializable {
private String status; private String status;
/** /**
* 创建时间 * 创建时间(注册时间)
*/ */
private LocalDateTime createTime; private LocalDateTime createTime;
/**
* 更新时间
* <p>
* 供后台「APP 用户管理」展示「最近变更时间」(改密 / 换绑 / 停用等均会刷新该值)。
*/
private LocalDateTime updateTime;
} }

View File

@@ -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 用户管理查询条件(后台管理端使用)。
* <p>
* <b>为什么单独建一个 DTO 而不是复用 {@code SysUserBo} + {@code PageQuery}</b>:
* {@code ruoyi-api} 模块仅依赖 {@code ruoyi-common-core}(见 {@code ruoyi-api/pom.xml}),
* 而 {@code PageQuery} 位于 {@code ruoyi-common-mybatis},<b>在本模块不可见</b>。
* 因此分页参数直接内联为 {@code pageNum} / {@code pageSize},由 {@code ruoyi-system} 侧
* 组装成框架 {@code Page},从而在不放松模块依赖边界的前提下完成跨模块分页契约。
* <p>
* <b>日期区间用 {@link #params} 而非平铺字段</b>:与框架既有约定保持一致——
* 前端 {@code utils/ruoyi.ts#addDateRange}(经 {@code useDateRangeQuery} hook 调用)
* 把区间写入 {@code params.beginTime} / {@code params.endTime},
* 后端 {@code betweenParams(column, params, "beginTime", "endTime")} 消费,
* 故此处必须保留同名的 {@code params} 载体,前端才能直接复用现成 hook。
* <p>
* <b>不含 {@code userType}</b>:本查询只为 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;
/**
* 扩展查询参数。
* <p>
* 约定键:{@code beginTime} / {@code endTime}——注册时间区间(闭区间)。
* 两者<b>均非空</b>时才生效(与框架 {@code betweenParams} 口径一致)。
* 注意:框架<b>不做</b>「日期补 23:59:59」的隐式处理,前端选「日期」而非「日期时间」时
* 需自行补全时分秒,否则会漏掉结束日当天数据。
*/
private Map<String, Object> params = new HashMap<>();
/**
* 页码(从 1 开始);为空时按 1 处理。
*/
private Integer pageNum;
/**
* 每页条数;为空时按 10 处理。
*/
private Integer pageSize;
}

View File

@@ -77,6 +77,15 @@ public class MybatisPlusConfig {
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
// 分页合理化 // 分页合理化
paginationInnerInterceptor.setOverflow(true); 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; return paginationInnerInterceptor;
} }

View File

@@ -54,7 +54,14 @@ public class PageQuery implements Serializable {
public static final int DEFAULT_PAGE_NUM = 1; public static final int DEFAULT_PAGE_NUM = 1;
/** /**
* 每页显示记录数 默认值 默认查全部 * 每页显示记录数 默认值:**不传 pageSize 时回落值**。
* <p>
* ⚠️ 这里是 {@link Integer#MAX_VALUE},字面语义是「查全部」,但**实际不会真的全表返回** ——
* {@code MybatisPlusConfig#paginationInnerInterceptor} 设了 {@code maxLimit = 1000},
* MyBatis-Plus 会把超限的 size 静默改写掉。原注释「默认查全部」有误导性,已订正。
* <p>
* 另外 {@link #getFirstNum()} 用本值算偏移量,{@code (pageNum - 1) * MAX_VALUE}
* 在 {@code pageNum > 1} 时会**整型溢出** —— 该方法目前无调用方,但若要启用必须先收口。
*/ */
public static final int DEFAULT_PAGE_SIZE = Integer.MAX_VALUE; public static final int DEFAULT_PAGE_SIZE = Integer.MAX_VALUE;

View File

@@ -60,6 +60,14 @@ public class DeviceMqttProperties {
* 低电告警恢复阈值(≥ 该值恢复)。 * 低电告警恢复阈值(≥ 该值恢复)。
* <p> * <p>
* 与 {@link #lowBatteryThreshold} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。 * 与 {@link #lowBatteryThreshold} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。
* <p>
* ⚠️ 引入<b>设备级阈值</b>后本字段的含义有所扩展:
* <ul>
* <li>设备未配置 / 历史行 → 直接作为恢复阈值使用(与原行为完全一致);</li>
* <li>设备已配置 → 仅取「本值 − {@link #lowBatteryThreshold}」的<b>差值</b>作为统一回差,
* 由 {@link #resolveRecoverThreshold(int)} 派生该设备的恢复阈值 ——
* 这样无需为设备级阈值新增配置项,滞回口径也不会各设备一套。</li>
* </ul>
*/ */
private int lowBatteryRecoverThreshold = 25; private int lowBatteryRecoverThreshold = 25;
@@ -101,4 +109,39 @@ public class DeviceMqttProperties {
*/ */
private Duration pendingAckTtl = Duration.ofMinutes(10); private Duration pendingAckTtl = Duration.ofMinutes(10);
// ---------------- 低电阈值解析(设备级配置 → 生效值) ----------------
/**
* 解析生效的<b>触发阈值</b>:设备级配置优先,未配置则回落工程默认。
* <p>
* 设备级阈值由 DDL 保证 {@code NOT NULL DEFAULT 20},正常路径下 {@code deviceThreshold}
* 不会是 null;保留兜底是为兼容「补丁执行前写入的历史行」等边界情形。
* <p>
* <b>返回 0 表示对该设备关闭低电保护</b>,而非「阈值取 0」——调用方必须显式判 0,
* 不能直接写 {@code battery <= resolveTriggerThreshold(...)},否则会退化成
* 「电量恰好归零才告警」。目前两个消费点(低电告警判定、遮盖指令拦截)都已按此约定处理。
*
* @param deviceThreshold 设备上配置的阈值(可为 null)
* @return 生效的触发阈值(电量 ≤ 该值即触发低电)
*/
public int resolveTriggerThreshold(Integer deviceThreshold) {
return deviceThreshold == null ? lowBatteryThreshold : deviceThreshold;
}
/**
* 派生生效的<b>恢复阈值</b>:{@code 触发阈值 + 回差}。
* <p>
* 回差取自 {@link #lowBatteryThreshold} 与 {@link #lowBatteryRecoverThreshold} 之差
* (工程默认为 {@code 25 - 20 = 5}),使设备级阈值天然沿用同一滞回口径。
* <p>
* 回差下限保护为 1:若运维把两个默认值配成相等(回差为 0,等于没有滞回),
* 电量在阈值附近抖动时会反复告警/恢复,故强制至少留 1 个百分点。
*
* @param triggerThreshold 生效的触发阈值
* @return 生效的恢复阈值(严格大于 {@code triggerThreshold})
*/
public int resolveRecoverThreshold(int triggerThreshold) {
return triggerThreshold + Math.max(1, lowBatteryRecoverThreshold - lowBatteryThreshold);
}
} }

View File

@@ -89,6 +89,13 @@ public final class DeviceMsgConstants {
*/ */
public static final String DEVICE_NO_LIMIT = "当日设备编号已达上限(9999)"; public static final String DEVICE_NO_LIMIT = "当日设备编号已达上限(9999)";
/**
* 设备编号建档后不可修改(后台编辑时校验)。
* <p>{@code device_no} 由服务端建档时自动生成,是被 APP 绑定关系与运维话术引用的业务主展示键,
* 允许改号会造成「同一台设备两个编号」的口径混乱,故编辑入口显式拒绝。
*/
public static final String DEVICE_NO_IMMUTABLE = "设备编号由系统自动生成,不支持修改";
/** /**
* 设备已被他人绑定 * 设备已被他人绑定
* <p>TODO 阶段 7 按原型核对原句。 * <p>TODO 阶段 7 按原型核对原句。
@@ -218,4 +225,11 @@ public final class DeviceMsgConstants {
*/ */
public static final String OTA_BATCH_FAIL_DISPATCH = "设备[%s]指令下发失败,已失败"; public static final String OTA_BATCH_FAIL_DISPATCH = "设备[%s]指令下发失败,已失败";
// ---------------- APP 用户管理(后台) ----------------
/**
* 账号状态更新成功(停用 / 启用)。
*/
public static final String USER_STATUS_SUCCESS = "状态更新成功";
} }

View File

@@ -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})。
* <p>
* 与 APP 端的 {@code AppUserController}({@code /app/user})<b>不是同一组接口</b>:
* <ul>
* <li>{@code /app/user/**}:C 端「本人操作本人」,令牌登录态,无权限字;</li>
* <li>本类:后台「管理员操作 APP 账号」,<b>全部需权限字</b>,与后台设备管理同口径。</li>
* </ul>
* <p>
* 接口清单:
* <ul>
* <li>{@code GET /device/app-user/list} —— 分页列表(恒不含后台账号)</li>
* <li>{@code GET /device/app-user/{userId}} —— 用户详情</li>
* <li>{@code GET /device/app-user/{userId}/devices} —— 该用户绑定设备</li>
* <li>{@code PUT /device/app-user/status} —— 停用 / 启用</li>
* <li>{@code PUT /device/app-user/reset-pwd} —— 重置密码</li>
* <li>{@code DELETE /device/app-user/{userId}/device/{deviceNo}} —— 解绑设备</li>
* </ul>
* <p>
* <b>权限字</b>:{@code device:appuser:list}(列表) / {@code device:appuser:query}(详情与绑定设备) /
* {@code device:appuser:edit}(状态变更、重置密码、解绑设备)。
* 与 OTA 的做法一致,变更类复用单一 {@code edit} 权限字,避免权限字数量膨胀。
* <p>
* <b>数据权限</b>: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 用户列表。
* <p>
* 入参只用 {@link AppUserAdminQueryBo}(它已继承 {@code PageQuery}),
* 不再并列声明 {@code PageQuery}——否则两个分页参数会同时参与绑定、
* 且「谁生效」取决于绑定顺序,属易错设计。
*
* @param bo 查询条件与分页参数
* @return APP 用户分页结果(含绑定设备数)
*/
@SaCheckPermission("device:appuser:list")
@GetMapping("/list")
public R<PageResult<AppUserAdminVo>> 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<AppUserAdminVo> getInfo(@PathVariable("userId") Long userId) {
return R.ok(appUserAdminService.queryById(userId));
}
/**
* 查询指定 APP 用户当前绑定的设备列表。
*
* @param userId 用户主键
* @return 绑定设备列表(无绑定或用户无效时为空列表,不报错)
*/
@SaCheckPermission("device:appuser:query")
@GetMapping("/{userId}/devices")
public R<List<AppUserDeviceVo>> 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<Void> 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<Void> 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<Void> unbindDevice(@PathVariable("userId") Long userId,
@PathVariable("deviceNo") String deviceNo) {
appUserAdminService.unbindDevice(userId, deviceNo);
return R.ok(DeviceMsgConstants.UNBIND_SUCCESS);
}
}

View File

@@ -38,7 +38,16 @@ import java.util.List;
* <li>{@code POST /device/export} —— Excel 导出(I5)</li> * <li>{@code POST /device/export} —— Excel 导出(I5)</li>
* </ul> * </ul>
* OTA 相关接口见 {@link org.dromara.device.controller.OtaController}({@code /device/ota/**},I5)。 * OTA 相关接口见 {@link org.dromara.device.controller.OtaController}({@code /device/ota/**},I5)。
* 后台接口一律 {@code @SaCheckPermission}(数据权限由框架全局拦截器承担),变更类加 {@code @Log}。 * 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log}。
* <p>
* ⚠️ <b>本模块没有数据权限(部门)隔离</b>:{@code BmDeviceMapper} 未标注
* {@link org.dromara.common.mybatis.annotation.DataPermission},而框架的
* {@code PlusDataPermissionInterceptor} <b>必须靠该注解才会生成部门过滤条件</b> ——
* 因此任何持有 {@code device:device:list} 的账号看到的都是<b>全量设备</b>。
* <p>
* 原注释写「数据权限由框架全局拦截器承担」与本模块实际情况不符(全模块 {@code grep DataPermission} 零命中),
* 会让人误以为已经隔离,故订正。若后续确需按部门隔离,必须显式给 Mapper 加 {@code @DataPermission},
* 不能指望「全局生效」。
* *
* @author Kou * @author Kou
* @date 2026-09-16 * @date 2026-09-16

View File

@@ -46,8 +46,9 @@ public class AppAuthController extends BaseController {
*/ */
@PostMapping("/sms/code") @PostMapping("/sms/code")
public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) { public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) {
appAuthService.sendSmsCode(bo); String code = appAuthService.sendSmsCode(bo);
return R.ok(AppMsgConstants.SMS_CODE_SENT); // 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案
return R.ok(code == null ? AppMsgConstants.SMS_CODE_SENT : code);
} }
/** /**

View File

@@ -133,6 +133,16 @@ public class BmDevice extends BaseEntity {
*/ */
private Integer ringBattery; private Integer ringBattery;
/**
* 头套低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发,即对该设备关闭低电保护)
*/
private Integer hoodBatteryThreshold;
/**
* 脚环低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发)
*/
private Integer ringBatteryThreshold;
/** /**
* 最新经度 * 最新经度
*/ */

View File

@@ -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})。
* <p>
* 仅面向 APP 用户({@code sys_user.user_type = app_user})——类型条件由服务端强制附加,
* 前端<b>不需要</b>也无法通过本入参覆盖。
* <p>
* 分页字段({@code pageNum} / {@code pageSize} / {@code orderByColumn} / {@code isAsc})
* 继承自 {@link PageQuery},与框架其余列表页口径一致。
* <p>
* <b>日期区间经 {@link #params} 传递</b>:前端用 {@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<String, Object> params = new HashMap<>();
}

View File

@@ -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})。
* <p>
* 密码规则与 APP 端注册 / 改密<b>完全同源</b>({@link AppConstants#PASSWORD_REGEX}):
* 管理员在后台设的密码,必须同样满足 APP 端的登录前置要求,
* 否则会出现「后台设置成功、用户却因密码不合规被 APP 拒」的割裂体验。
* <p>
* <b>不引入「确认密码」字段</b>:二次输入属前端交互,服务端只需最终值。
*
* @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;
}

View File

@@ -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})。
* <p>
* {@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;
}

View File

@@ -1,6 +1,8 @@
package org.dromara.device.domain.bo; package org.dromara.device.domain.bo;
import io.github.linpeilie.annotations.AutoMapper; 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.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
@@ -133,6 +135,24 @@ public class DeviceBo implements Serializable {
*/ */
private Integer ringBattery; private Integer ringBattery;
/**
* 头套低电告警阈值 0-100(电量 ≤ 该值触发告警;0 表示不触发)
* <p>
* 恢复阈值不在此配置:由服务端按「触发阈值 + 回差」派生
* (见 {@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;
/** /**
* 最新经度 * 最新经度
*/ */

View File

@@ -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 用户管理」列表与详情共用)。
* <p>
* <b>不脱敏</b>:后台管理端与 APP 端口径不同——APP 端({@code AppUserProfileVo})对手机号 / 邮箱脱敏,
* 而管理端需要完整信息用于人工核对与客服定位,故此处保留明文,
* 与框架 {@code GET /system/user/list} 的 {@code SysUserVo} 口径一致。
* <p>
* <b>不含密码</b>:密码散列不跨模块边界(见 {@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;
/**
* 是否已绑定手机号。
* <p>
* 供管理员识别「邮箱注册但未绑手机号」的账号——这类账号<b>无法绑定设备</b>
* (服务端硬拦截,见 {@code IAppDeviceService#bind}),是需要提醒用户补绑的对象。
*/
private Boolean phoneBound;
/**
* 当前绑定设备数
*/
private Long deviceCount;
/**
* 注册时间
*/
private LocalDateTime createTime;
/**
* 最近更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -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})。
* <p>
* 字段是对 {@code DeviceVo} 的<b>裁剪</b>:只保留管理端在「用户视角」下关心的身份与运行态信息,
* 不含 IP / MAC / 版本号等设备运维字段(那些在「设备列表」页看更合适)。
* <p>
* <b>IMEI 不脱敏</b>:与后台设备列表口径一致(后台可见完整 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;
}

View File

@@ -161,6 +161,18 @@ public class DeviceVo implements Serializable {
@ExcelProperty(value = "脚环电量") @ExcelProperty(value = "脚环电量")
private Integer ringBattery; private Integer ringBattery;
/**
* 头套低电告警阈值 0-100(设备级配置;0 表示不触发)
*/
@ExcelProperty(value = "头套低电阈值")
private Integer hoodBatteryThreshold;
/**
* 脚环低电告警阈值 0-100(设备级配置;0 表示不触发)
*/
@ExcelProperty(value = "脚环低电阈值")
private Integer ringBatteryThreshold;
/** /**
* 最新经度 * 最新经度
*/ */

View File

@@ -107,10 +107,11 @@ public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler {
return; return;
} }
telemetryArchiver.archive(device, payload, receivedAt); telemetryArchiver.archive(device, payload, receivedAt);
// 低电判定用「该设备自己配的阈值」,头套与脚环各自独立
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo, alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery()); AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery(), device.getHoodBatteryThreshold());
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo, alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery()); AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery(), device.getRingBatteryThreshold());
} catch (Exception e) { } catch (Exception e) {
log.error("遥测处理异常 => deviceNo: {}", deviceNo, e); log.error("遥测处理异常 => deviceNo: {}", deviceNo, e);
} }

View File

@@ -21,11 +21,6 @@ public class AckPayload implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/**
* 协议版本(可选,一期固定 1)
*/
private Integer v;
/** /**
* 下行指令的 msgId,必须原样回传 * 下行指令的 msgId,必须原样回传
*/ */

View File

@@ -20,11 +20,6 @@ public class EventPayload implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/**
* 协议版本(可选,一期固定 1)
*/
private Integer v;
/** /**
* 设备本地时间(epoch 毫秒) * 设备本地时间(epoch 毫秒)
*/ */

View File

@@ -27,11 +27,6 @@ public class HoodCommandPayload implements Serializable {
*/ */
public static final String CMD = "hood"; 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) { public static HoodCommandPayload of(String msgId, boolean coverOn) {
HoodCommandPayload payload = new HoodCommandPayload(); HoodCommandPayload payload = new HoodCommandPayload();
payload.setV(1);
payload.setMsgId(msgId); payload.setMsgId(msgId);
payload.setCoverOn(coverOn); payload.setCoverOn(coverOn);
payload.setTs(System.currentTimeMillis()); payload.setTs(System.currentTimeMillis());

View File

@@ -31,11 +31,6 @@ public class OtaCommandPayload implements Serializable {
*/ */
public static final String CMD = "ota"; 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, public static OtaCommandPayload of(String msgId, String version, String url,
String md5, Long size, String changelog) { String md5, Long size, String changelog) {
OtaCommandPayload payload = new OtaCommandPayload(); OtaCommandPayload payload = new OtaCommandPayload();
payload.setV(1);
payload.setMsgId(msgId); payload.setMsgId(msgId);
payload.setVersion(version); payload.setVersion(version);
payload.setUrl(url); payload.setUrl(url);

View File

@@ -22,11 +22,6 @@ public class ProvisionAckPayload implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/**
* 协议版本(一期固定 1)
*/
private Integer v;
/** /**
* 设备 IMEI * 设备 IMEI
*/ */
@@ -66,7 +61,6 @@ public class ProvisionAckPayload implements Serializable {
*/ */
public static ProvisionAckPayload success(String imei, String deviceNo) { public static ProvisionAckPayload success(String imei, String deviceNo) {
ProvisionAckPayload payload = new ProvisionAckPayload(); ProvisionAckPayload payload = new ProvisionAckPayload();
payload.setV(1);
payload.setImei(imei); payload.setImei(imei);
payload.setOk(true); payload.setOk(true);
payload.setCode(0); payload.setCode(0);
@@ -85,7 +79,6 @@ public class ProvisionAckPayload implements Serializable {
*/ */
public static ProvisionAckPayload fail(String imei, int code, String msg) { public static ProvisionAckPayload fail(String imei, int code, String msg) {
ProvisionAckPayload payload = new ProvisionAckPayload(); ProvisionAckPayload payload = new ProvisionAckPayload();
payload.setV(1);
payload.setImei(imei); payload.setImei(imei);
payload.setOk(false); payload.setOk(false);
payload.setCode(code); payload.setCode(code);

View File

@@ -22,11 +22,6 @@ public class ProvisionPayload implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/**
* 协议版本(一期固定 1)
*/
private Integer v;
/** /**
* 设备本地时间(epoch 毫秒) * 设备本地时间(epoch 毫秒)
*/ */

View File

@@ -36,11 +36,6 @@ public class StreamCommandPayload implements Serializable {
*/ */
public static final String ACTION_STOP = "stop"; 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, private static StreamCommandPayload of(String msgId, String action, String streamId,
String app, String protocol, String pushUrl) { String app, String protocol, String pushUrl) {
StreamCommandPayload payload = new StreamCommandPayload(); StreamCommandPayload payload = new StreamCommandPayload();
payload.setV(1);
payload.setMsgId(msgId); payload.setMsgId(msgId);
payload.setAction(action); payload.setAction(action);
payload.setStreamId(streamId); payload.setStreamId(streamId);

View File

@@ -26,11 +26,6 @@ public class TelemetryPayload implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/**
* 协议版本(可选,一期固定 1)
*/
private Integer v;
/** /**
* 设备本地时间(epoch 毫秒) * 设备本地时间(epoch 毫秒)
*/ */

View File

@@ -62,22 +62,39 @@ public class DeviceAlertWriter {
} }
/** /**
* 按服务端阈值判定并处理低电(含滞回与落库)。 * 按<b>设备级阈值</b>判定并处理低电(含滞回与落库)。
* <p> * <p>
* 这是低电告警的<b>主路径</b>:每一次遥测都调用,内部按滞回状态决定是否需要动作。 * 这是低电告警的<b>主路径</b>:每一次遥测都调用,内部按滞回状态决定是否需要动作。
* <p>
* 触发阈值取设备自身配置({@code bm_device.hood_battery_threshold} /
* {@code ring_battery_threshold}),恢复阈值由
* {@link DeviceMqttProperties#resolveRecoverThreshold(int)} 派生;两者统一由
* {@code DeviceMqttProperties} 解析,调用方只需把设备上配置的<b>原始值</b>传进来,
* 不要在调用处自行做 null 兜底 —— 否则「未配置时的默认口径」会出现第二份实现。
* <p>
* ⚠️ 修改阈值<b>不会</b>清理已有闩锁,这是有意为之:若设备当前正处于低电态,
* 抬高阈值只会让它继续告警(已闩锁,不会重复落库);调低阈值则在下一次遥测
* 达到新的恢复阈值时自动解锁,无需人工干预。
* *
* @param deviceId 设备物理主键 * @param deviceId 设备物理主键
* @param deviceNo 设备编号 * @param deviceNo 设备编号
* @param type 告警类型(头套 / 脚环低电) * @param type 告警类型(头套 / 脚环低电)
* @param battery 当前电量(0-100) * @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)) { if (battery == null || !isLowBatteryType(type)) {
return; return;
} }
int trigger = properties.resolveTriggerThreshold(deviceThreshold);
int recover = properties.resolveRecoverThreshold(trigger);
String latchKey = DeviceMqttConstants.BATTERY_LOW_PREFIX + deviceNo + ":" + type.getType(); String latchKey = DeviceMqttConstants.BATTERY_LOW_PREFIX + deviceNo + ":" + type.getType();
boolean latched = Boolean.TRUE.equals(RedisUtils.getCacheObject(latchKey)); 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) { if (latched) {
// 已处于低电态:不重复告警,也不刷新闩锁 TTL(TTL 只作残留清理兜底) // 已处于低电态:不重复告警,也不刷新闩锁 TTL(TTL 只作残留清理兜底)
return; return;
@@ -88,9 +105,10 @@ public class DeviceAlertWriter {
String.valueOf(battery), lowBatteryContent(type, battery)); String.valueOf(battery), lowBatteryContent(type, battery));
return; return;
} }
if (battery >= properties.getLowBatteryRecoverThreshold() && latched) { if (battery >= recover && latched) {
RedisUtils.deleteObject(latchKey); RedisUtils.deleteObject(latchKey);
log.info("低电恢复 => deviceNo: {}, type: {}, battery: {}", deviceNo, type.getType(), battery); log.info("低电恢复 => deviceNo: {}, type: {}, battery: {}, recoverThreshold: {}",
deviceNo, type.getType(), battery, recover);
} }
// 介于 (触发阈值, 恢复阈值) 之间:保持现状,这正是滞回区间的意义 // 介于 (触发阈值, 恢复阈值) 之间:保持现状,这正是滞回区间的意义
} }

View File

@@ -26,8 +26,9 @@ public interface IAppAuthService {
* 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。 * 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。
* *
* @param bo 验证码入参(手机号 + 场景) * @param bo 验证码入参(手机号 + 场景)
* @return mock 模式下返回验证码,真实短信模式返回 {@code null}
*/ */
void sendSmsCode(AppSmsCodeBo bo); String sendSmsCode(AppSmsCodeBo bo);
/** /**
* 手机号注册。 * 手机号注册。

View File

@@ -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 用户管理服务(后台管理端)。
* <p>
* <b>与 APP 端服务的边界</b>:{@code IAppUserService} 面向 C 端「本人操作本人」,用户主键取自登录态;
* 本接口面向后台「管理员操作他人」,用户主键由调用方显式传入,因此<b>必须由权限字把关</b>
* ({@code device:appuser:*}),且每个写操作都要自证目标确为 APP 账号。
* <p>
* <b>越权防线(本接口的核心约束)</b>:APP 账号与后台账号同处 {@code sys_user} 表,
* 故所有写操作都强制附加 {@code user_type = app_user}:
* <ul>
* <li>状态变更 / 重置密码:类型不匹配则影响行数为 0,等价于「目标不存在」;</li>
* <li>解绑设备:沿用 {@code IDeviceBindService#unbind} 的归属校验({@code bind_user_id = userId})。</li>
* </ul>
* <p>
* <b>密码不跨界</b>:重置密码只把明文交给 {@code AppUserApiService},
* 散列在 {@code ruoyi-system} 内部完成后落库,本层不接触散列值。
*
* @author Kou
* @date 2026-09-17
*/
public interface IAppUserAdminService {
/**
* 分页查询 APP 用户列表(结果恒不含后台账号)。
*
* @param bo 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
* @return APP 用户分页结果(含绑定设备数)
*/
PageResult<AppUserAdminVo> queryPageList(AppUserAdminQueryBo bo);
/**
* 查询 APP 用户详情。
*
* @param userId 用户主键
* @return 用户详情;不存在或非 APP 账号时返回 {@code null}
*/
AppUserAdminVo queryById(Long userId);
/**
* 查询指定 APP 用户当前绑定的设备列表。
*
* @param userId 用户主键
* @return 绑定设备列表(无绑定时为空列表)
*/
List<AppUserDeviceVo> listDevices(Long userId);
/**
* 修改账号状态(停用 / 启用)。
*
* @param bo 状态变更入参
* @return true 目标状态已生效
*/
Boolean updateStatus(AppUserStatusBo bo);
/**
* 重置账号密码(管理员代为设置)。
*
* @param bo 重置密码入参(新密码须满足 APP 端密码规则)
* @return true 重置成功
*/
Boolean resetPassword(AppUserResetPwdBo bo);
/**
* 解绑该用户名下的某台设备。
* <p>
* 复用单一解绑入口 {@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);
}

View File

@@ -51,9 +51,13 @@ public interface IDeviceService {
Boolean insertByBo(DeviceBo bo); Boolean insertByBo(DeviceBo bo);
/** /**
* 修改设备 * 修改设备(**不支持修改 {@code deviceNo}**)。
* <p>
* 编号不可变:入参编号与库中现值不一致时抛
* {@link org.dromara.common.core.exception.ServiceException};未传或传空串则视为
* 「本次不改编号」,以库中现值回填。设备不存在(含已逻辑删除)同样抛出业务异常。
* *
* @param bo 编辑业务对象 * @param bo 编辑业务对象({@code deviceId} 必填,见 {@code EditGroup} 校验组)
* @return 是否修改成功 * @return 是否修改成功
*/ */
Boolean updateByBo(DeviceBo bo); Boolean updateByBo(DeviceBo bo);

View File

@@ -62,10 +62,11 @@ public class AppAuthServiceImpl implements IAppAuthService {
* 发送短信验证码。 * 发送短信验证码。
* *
* @param bo 验证码入参(手机号 + 场景) * @param bo 验证码入参(手机号 + 场景)
* @return mock 模式下返回验证码,真实短信模式返回 {@code null}
*/ */
@Override @Override
public void sendSmsCode(AppSmsCodeBo bo) { public String sendSmsCode(AppSmsCodeBo bo) {
appSmsService.sendCode(bo.getPhoneNumber(), bo.getScene()); return appSmsService.sendCode(bo.getPhoneNumber(), bo.getScene());
} }
/** /**

View File

@@ -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 用户管理服务实现(后台管理端)。
* <p>
* 账号数据经 {@link AppUserApiService} 获取(跨模块边界,密码不跨界);
* 设备数据直接复用设备域既有能力({@link IDeviceService} / {@link IDeviceBindService}),
* <b>不新增任何设备侧方法</b>。
*
* @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 用户列表。
* <p>
* <b>绑定设备数用一次额外查询批量补齐</b>(而非逐行 count):先取本页全部 userId,
* 一次性查出这些用户的设备行再在内存里分组计数,避免 pageSize 次往返(N+1)。
* 只 select {@code bind_user_id} 一列,降低结果集体积。
*/
@Override
public PageResult<AppUserAdminVo> 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<AppUserDTO> page = appUserApiService.selectAppUserPage(query);
List<AppUserAdminVo> rows = page.getRows().stream().map(this::toVo).toList();
if (!rows.isEmpty()) {
Map<Long, Long> 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 用户详情。
* <p>
* 「不存在」与「非 APP 账号」在此<b>归为同一结果</b>({@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 用户当前绑定的设备列表。
* <p>
* 复用 {@link IDeviceService#queryList}——其底层已支持按 {@code bind_user_id} 精确过滤
* ({@code DeviceServiceImpl#buildQueryWrapper}),故无需新增设备侧查询方法。
* <p>
* 目标不存在或非 APP 账号时<b>返回空列表而非抛异常</b>:与 {@link #queryById} 的「返回 {@code null}」
* 口径保持一致,避免前端出现「详情显示空态、设备列表却弹错误」的割裂表现。
* 写操作(解绑)则不适用该宽松口径——见 {@link #unbindDevice}。
*/
@Override
public List<AppUserDeviceVo> 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();
}
/**
* 修改账号状态(停用 / 启用)。
* <p>
* {@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;
}
/**
* 重置账号密码(管理员代为设置)。
* <p>
* 明文只在内存中从入参传递到 {@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;
}
/**
* 解绑该用户名下的某台设备。
* <p>
* 复用单一解绑入口:{@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 账号,否则抛「账号不存在」。
* <p>
* 用于「列表 / 解绑」这类需要先确认主体存在的入口;
* 状态变更与重置密码则由跨模块方法的返回值兜底(类型条件已在其内部强制)。
*
* @param userId 用户主键
*/
private void requireAppUser(Long userId) {
if (userId == null || appUserApiService.selectAppUserById(userId) == null) {
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
}
}
/**
* 批量统计这批用户各自的绑定设备数。
* <p>
* 逻辑删除条件由 MyBatis-Plus 依据实体的 {@code @TableLogic} 自动附加,此处不手写 {@code del_flag}。
*
* @param userIds 用户主键集合
* @return userId → 设备数(无设备的用户不出现在 map 中)
*/
private Map<Long, Long> countDevicesByUser(Collection<Long> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyMap();
}
List<BmDevice> devices = deviceMapper.selectList(
new LambdaQueryWrapper<BmDevice>()
.select(BmDevice::getBindUserId)
.in(BmDevice::getBindUserId, userIds));
return devices.stream()
.filter(device -> device.getBindUserId() != null)
.collect(Collectors.groupingBy(BmDevice::getBindUserId, Collectors.counting()));
}
/**
* DTO → 管理端 VO,并派生「是否已绑手机号」。
* <p>
* {@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);
}
}

View File

@@ -101,6 +101,10 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
* <p> * <p>
* 两条规则的差异:离线是<b>双向</b>硬拦(指令根本送不到,排队/补投只会造成「延迟的意外动作」); * 两条规则的差异:离线是<b>双向</b>硬拦(指令根本送不到,排队/补投只会造成「延迟的意外动作」);
* 低电<b>只拦遮盖</b>(见类注释「低电只拦遮盖」)。 * 低电<b>只拦遮盖</b>(见类注释「低电只拦遮盖」)。
* <p>
* 低电阈值取<b>该设备自己配置的</b> {@code hood_battery_threshold}(未配置回落工程默认),
* 与 {@code DeviceAlertWriter#applyLowBattery} 共用同一阈值口径 —— 刻意不拆成两套值,
* 否则会出现「已经判定低电并告警了,却仍能下发遮盖」的口径撕裂。
* *
* @param device 目标设备 * @param device 目标设备
* @param deviceNo 设备编号(日志用) * @param deviceNo 设备编号(日志用)
@@ -113,10 +117,13 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE); throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE);
} }
Integer battery = device.getHoodBattery(); 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: {}", log.info("头套低电,拒绝遮盖 => deviceNo: {}, hoodBattery: {}, threshold: {}",
deviceNo, battery, properties.getLowBatteryThreshold()); deviceNo, battery, trigger);
throw new ServiceException(DeviceMsgConstants.LOW_BATTERY); throw new ServiceException(DeviceMsgConstants.LOW_BATTERY);
} }
} }

View File

@@ -169,14 +169,40 @@ public class DeviceServiceImpl implements IDeviceService {
} }
/** /**
* 修改设备 * 修改设备(**不接受修改 {@code device_no}**)。
* <p>
* <b>编号不可变</b>:{@code device_no} 是业务主展示键,由建档时服务端自动生成,且被 APP 绑定关系
* 与运维话术引用;允许改号会产生「同一台设备两个编号」的口径混乱。因此:
* <ul>
* <li>入参带了编号且与库中现值<b>不一致</b> → 抛 {@link ServiceException}
* (显式拒绝,让调用方拿到明确反馈,而非静默忽略);</li>
* <li>入参未带或带空串 → 语义是「本次不改编号」,一律以库中现值回填后再更新。</li>
* </ul>
* 第二条是<b>必需的兜底</b>:MyBatis-Plus 全局 {@code updateStrategy = NOT_NULL},
* 而<b>空串「非 null」</b>——若不回填,空串会被当作有效值写入 {@code device_no},
* 既破坏业务主键、又可能撞唯一键 {@code uk_device_no}。
* <p>
* <b>存在性校验</b>:{@code selectById} 受 {@code @TableLogic} 自动过滤,已逻辑删除的设备取不到,
* 此时抛 {@code DEVICE_NOT_EXIST},避免「编辑一条不存在的数据」静默返回成功。
* *
* @param bo 编辑业务对象 * @param bo 编辑业务对象
* @return 是否修改成功 * @return 是否修改成功
*/ */
@Override @Override
public Boolean updateByBo(DeviceBo bo) { 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); BmDevice update = MapstructUtils.convert(bo, BmDevice.class);
// 兜底:无论入参传号与否(含空串),一律以库中现值回填,杜绝 NOT_NULL 策略把空串写库
update.setDeviceNo(exist.getDeviceNo());
validEntityBeforeSave(update); validEntityBeforeSave(update);
return deviceMapper.updateById(update) > 0; return deviceMapper.updateById(update) > 0;
} }

View File

@@ -46,8 +46,9 @@ public class AppSmsService {
* *
* @param phoneNumber 手机号 * @param phoneNumber 手机号
* @param scene 场景(login / register / resetPwd / rebind) * @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)) { if (!SmsSceneEnum.isValid(scene)) {
throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID); throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID);
} }
@@ -66,6 +67,8 @@ public class AppSmsService {
// 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算) // 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算)
RedisUtils.deleteObject(AppConstants.SMS_FAIL_KEY_PREFIX + scene + ":" + phoneNumber); RedisUtils.deleteObject(AppConstants.SMS_FAIL_KEY_PREFIX + scene + ":" + phoneNumber);
send(phoneNumber, code); send(phoneNumber, code);
// 仅模拟发送时向上返回验证码,避免真实短信模式通过接口泄露验证码
return sms.isMock() ? code : null;
} }
/** /**

View File

@@ -2,19 +2,25 @@ package org.dromara.system.service.impl;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.hutool.crypto.digest.BCrypt; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.SystemConstants; 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.enums.UserType;
import org.dromara.common.core.utils.StringUtils; 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.common.mybatis.utils.IdGeneratorUtil;
import org.dromara.system.api.AppUserApiService; import org.dromara.system.api.AppUserApiService;
import org.dromara.system.api.domain.AppUserDTO; import org.dromara.system.api.domain.AppUserDTO;
import org.dromara.system.api.domain.AppUserQueryDTO;
import org.dromara.system.domain.SysUser; import org.dromara.system.domain.SysUser;
import org.dromara.system.mapper.SysUserMapper; import org.dromara.system.mapper.SysUserMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Objects;
/** /**
* APP 用户领域服务({@link AppUserApiService})实现。 * APP 用户领域服务({@link AppUserApiService})实现。
@@ -240,6 +246,13 @@ public class AppUserApiServiceImpl implements AppUserApiService {
/** /**
* 重置用户密码(忘记密码场景);新密码在本方法内部散列后落库。 * 重置用户密码(忘记密码场景);新密码在本方法内部散列后落库。
* <p>
* <b>限定 {@code user_type = app_user}</b>(2026-09-17 加固):本方法原先只按 {@code userId} 更新,
* 若被后台管理端以任意主键调用,即可重置<b>后台账号</b>密码——属越权写入。
* 现补上类型条件,使「重置密码」这一高危动作的爆炸半径限定在 APP 用户内。
* <p>
* 对既有调用方<b>无行为影响</b>:三处调用(手机号找回 / 邮箱找回 / APP 内改密)的 {@code userId}
* 均取自 {@code selectByPhone} / {@code selectByEmail} / 登录态,本就恒为 app_user。
* *
* @param userId 用户主键 * @param userId 用户主键
* @param rawPassword 新明文密码 * @param rawPassword 新明文密码
@@ -253,6 +266,7 @@ public class AppUserApiServiceImpl implements AppUserApiService {
return userMapper.lambda() return userMapper.lambda()
.set(SysUser::getPassword, BCrypt.hashpw(rawPassword)) .set(SysUser::getPassword, BCrypt.hashpw(rawPassword))
.eq(SysUser::getUserId, userId) .eq(SysUser::getUserId, userId)
.eq(SysUser::getUserType, UserType.APP_USER.getUserType())
.updateCount() > 0; .updateCount() > 0;
} }
@@ -312,4 +326,107 @@ public class AppUserApiServiceImpl implements AppUserApiService {
return user != null && StringUtils.isNotBlank(user.getPhoneNumber()); return user != null && StringUtils.isNotBlank(user.getPhoneNumber());
} }
// ---------------- 后台管理端(APP 用户管理) ----------------
/**
* 分页查询 APP 用户列表(后台「APP 用户管理」用)。
* <p>
* <b>强制 {@code user_type = app_user}</b>:该条件由本方法固定附加,
* {@link AppUserQueryDTO} 中也没有对应字段可覆盖,因此结果集恒不含后台账号。
* <p>
* <b>为什么刻意不加 {@code @DataPermission}</b>:APP 账号注册时不写 {@code dept_id}
* (见 {@link #registerByPhone}/{@link #registerByEmail}),该列恒为 {@code NULL};
* 若叠加框架按部门维度的数据权限过滤,会把全部 APP 用户过滤成空集。
* APP 用户本就游离于后台组织架构之外,故此处按「业务归属正确」优先。
* <p>
* <b>排序</b>:按注册时间倒序(新注册的排前面),与后台「看最新」的使用习惯一致。
*
* @param query 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
* @return APP 用户分页结果(不含密码)
*/
@Override
public PageResult<AppUserDTO> 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<SysUser> 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<SysUser> page = userMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
List<AppUserDTO> rows = page.getRecords().stream()
.map(user -> BeanUtil.copyProperties(user, AppUserDTO.class))
.toList();
return PageResult.build(rows, page.getTotal());
}
/**
* 按主键查询 APP 用户详情(后台「APP 用户管理」用)。
* <p>
* <b>强制 {@code user_type = app_user}</b>:传入后台账号主键时返回 {@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 用户账号状态(停用 / 启用)。
* <p>
* <b>为什么同时限定 {@code user_type}</b>:本方法来自后台管理端(有登录态),
* 若不限定类型,构造任意 {@code userId} 即可停用后台账号——属于典型的越权写入。
* 加上该条件后,「非 APP 账号」的 update 影响行数为 0,天然被拒。
* <p>
* 返回 {@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;
}
} }

View File

@@ -140,3 +140,62 @@ ALTER TABLE sys_user MODIFY COLUMN phone_number varchar(11) DEFAULT NULL COMMENT
-- 步骤 2:邮箱列默认值改为 NULL -- 步骤 2:邮箱列默认值改为 NULL
ALTER TABLE sys_user MODIFY COLUMN email varchar(50) DEFAULT NULL COMMENT '用户邮箱'; 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=不触发)';

View File

@@ -33,6 +33,9 @@ create table bm_device (
signal_level tinyint not null default 0 comment '信号等级(0离线 1弱 2一般 3强)', signal_level tinyint not null default 0 comment '信号等级(0离线 1弱 2一般 3强)',
hood_battery tinyint default null comment '头套电量0-100', hood_battery tinyint default null comment '头套电量0-100',
ring_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 '最新经度', lng decimal(10,7) default null comment '最新经度',
lat decimal(10,7) default null comment '最新纬度', lat decimal(10,7) default null comment '最新纬度',
address varchar(255) 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, ''); insert into sys_menu values(1771400000000000015, '设备导出', 1771400000000000002, 5, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:export', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
-- OTA 按钮 -- OTA 按钮
insert into sys_menu values(1771400000000000016, 'OTA新增', 1771400000000000003, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:ota:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); insert into sys_menu values(1771400000000000016, 'OTA新增', 1771400000000000003, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:ota:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
-- APP 用户管理(后台管理 APP 注册账号;与 APP 端 /app/user 不是同一组接口)
-- 数据来源:sys_user 中 user_type = 'app_user' 的账号(仅手机号 / 邮箱注册的 C 端用户)
insert into sys_menu values(1771400000000000004, 'APP用户', 1771400000000000001, 3, 'app-user', 'device/appUser/index', '', 'N', 'Y', 'C', '0', '0', 'device:appuser:list', 'user', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'APP用户管理菜单');
-- APP 用户按钮(编辑权限字复用:状态变更 / 重置密码 / 解绑设备,口径同 device:ota:add)
insert into sys_menu values(1771400000000000017, '用户查询', 1771400000000000004, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
insert into sys_menu values(1771400000000000018, '用户编辑', 1771400000000000004, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
-- ============================ 十、角色 ============================ -- ============================ 十、角色 ============================
insert into sys_role values(1771300000000000001, '设备管理员', 'device_admin', 5, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域管理角色'); insert into sys_role values(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, 1771400000000000014);
insert into sys_role_menu values (1771300000000000001, 1771400000000000015); insert into sys_role_menu values (1771300000000000001, 1771400000000000015);
insert into sys_role_menu values (1771300000000000001, 1771400000000000016); 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, 1771400000000000001);
insert into sys_role_menu values (1771300000000000002, 1771400000000000002); insert into sys_role_menu values (1771300000000000002, 1771400000000000002);
insert into sys_role_menu values (1771300000000000002, 1771400000000000003); insert into sys_role_menu values (1771300000000000002, 1771400000000000003);
insert into sys_role_menu values (1771300000000000002, 1771400000000000011); insert into sys_role_menu values (1771300000000000002, 1771400000000000011);
insert into sys_role_menu values (1771300000000000002, 1771400000000000015); 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);