设备列表 视频流服务器
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 = "状态更新成功";
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,6 +133,16 @@ public class BmDevice extends BaseEntity {
|
||||
*/
|
||||
private Integer ringBattery;
|
||||
|
||||
/**
|
||||
* 头套低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发,即对该设备关闭低电保护)
|
||||
*/
|
||||
private Integer hoodBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 脚环低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发)
|
||||
*/
|
||||
private Integer ringBatteryThreshold;
|
||||
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 最新经度
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,6 @@ public class AckPayload implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 协议版本(可选,一期固定 1)
|
||||
*/
|
||||
private Integer v;
|
||||
|
||||
/**
|
||||
* 下行指令的 msgId,必须原样回传
|
||||
*/
|
||||
|
||||
@@ -20,11 +20,6 @@ public class EventPayload implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 协议版本(可选,一期固定 1)
|
||||
*/
|
||||
private Integer v;
|
||||
|
||||
/**
|
||||
* 设备本地时间(epoch 毫秒)
|
||||
*/
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,11 +22,6 @@ public class ProvisionPayload implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 协议版本(一期固定 1)
|
||||
*/
|
||||
private Integer v;
|
||||
|
||||
/**
|
||||
* 设备本地时间(epoch 毫秒)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,11 +26,6 @@ public class TelemetryPayload implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 协议版本(可选,一期固定 1)
|
||||
*/
|
||||
private Integer v;
|
||||
|
||||
/**
|
||||
* 设备本地时间(epoch 毫秒)
|
||||
*/
|
||||
|
||||
@@ -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 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);
|
||||
}
|
||||
// 介于 (触发阈值, 恢复阈值) 之间:保持现状,这正是滞回区间的意义
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@ public interface IAppAuthService {
|
||||
* 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。
|
||||
*
|
||||
* @param bo 验证码入参(手机号 + 场景)
|
||||
* @return mock 模式下返回验证码,真实短信模式返回 {@code null}
|
||||
*/
|
||||
void sendSmsCode(AppSmsCodeBo bo);
|
||||
String sendSmsCode(AppSmsCodeBo bo);
|
||||
|
||||
/**
|
||||
* 手机号注册。
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user