设备列表 视频流服务器

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
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 毫秒

View File

@@ -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 用户管理」用)。
* <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 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.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;
}

View File

@@ -54,7 +54,14 @@ public class PageQuery implements Serializable {
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;

View File

@@ -60,6 +60,14 @@ public class DeviceMqttProperties {
* 低电告警恢复阈值(≥ 该值恢复)。
* <p>
* 与 {@link #lowBatteryThreshold} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。
* <p>
* ⚠️ 引入<b>设备级阈值</b>后本字段的含义有所扩展:
* <ul>
* <li>设备未配置 / 历史行 → 直接作为恢复阈值使用(与原行为完全一致);</li>
* <li>设备已配置 → 仅取「本值 − {@link #lowBatteryThreshold}」的<b>差值</b>作为统一回差,
* 由 {@link #resolveRecoverThreshold(int)} 派生该设备的恢复阈值 ——
* 这样无需为设备级阈值新增配置项,滞回口径也不会各设备一套。</li>
* </ul>
*/
private int lowBatteryRecoverThreshold = 25;
@@ -101,4 +109,39 @@ public class DeviceMqttProperties {
*/
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)";
/**
* 设备编号建档后不可修改(后台编辑时校验)。
* <p>{@code device_no} 由服务端建档时自动生成,是被 APP 绑定关系与运维话术引用的业务主展示键,
* 允许改号会造成「同一台设备两个编号」的口径混乱,故编辑入口显式拒绝。
*/
public static final String DEVICE_NO_IMMUTABLE = "设备编号由系统自动生成,不支持修改";
/**
* 设备已被他人绑定
* <p>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 = "状态更新成功";
}

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>
* </ul>
* 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
* @date 2026-09-16

View File

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

View File

@@ -133,6 +133,16 @@ public class BmDevice extends BaseEntity {
*/
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;
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 表示不触发)
* <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 = "脚环电量")
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;
}
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);
}

View File

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

View File

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

View File

@@ -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());

View File

@@ -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);

View File

@@ -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);

View File

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

View File

@@ -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);

View File

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

View File

@@ -62,22 +62,39 @@ public class DeviceAlertWriter {
}
/**
* 按服务端阈值判定并处理低电(含滞回与落库)。
* 按<b>设备级阈值</b>判定并处理低电(含滞回与落库)。
* <p>
* 这是低电告警的<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 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);
}
// 介于 (触发阈值, 恢复阈值) 之间:保持现状,这正是滞回区间的意义
}

View File

@@ -26,8 +26,9 @@ public interface IAppAuthService {
* 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。
*
* @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);
/**
* 修改设备
* 修改设备(**不支持修改 {@code deviceNo}**)。
* <p>
* 编号不可变:入参编号与库中现值不一致时抛
* {@link org.dromara.common.core.exception.ServiceException};未传或传空串则视为
* 「本次不改编号」,以库中现值回填。设备不存在(含已逻辑删除)同样抛出业务异常。
*
* @param bo 编辑业务对象
* @param bo 编辑业务对象({@code deviceId} 必填,见 {@code EditGroup} 校验组)
* @return 是否修改成功
*/
Boolean updateByBo(DeviceBo bo);

View File

@@ -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());
}
/**

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>
* 两条规则的差异:离线是<b>双向</b>硬拦(指令根本送不到,排队/补投只会造成「延迟的意外动作」);
* 低电<b>只拦遮盖</b>(见类注释「低电只拦遮盖」)。
* <p>
* 低电阈值取<b>该设备自己配置的</b> {@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);
}
}

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 编辑业务对象
* @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;
}

View File

@@ -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;
}
/**

View File

@@ -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 {
/**
* 重置用户密码(忘记密码场景);新密码在本方法内部散列后落库。
* <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 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 用户管理」用)。
* <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
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强)',
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);