feat(device): 一期设备域 I1-I5 全量实现(APP 接口/后台管理/OTA/MQTT/直播)
模块接入:
- 新增 ruoyi-device(org.dromara.device)并注册到根/ruoyi-modules/ruoyi-admin pom
APP 侧(/app/**,需登录,仅本人绑定设备):
- 账号闭环:短信/邮箱验证码注册登录、密码重置(不跨界返回散列)
- 设备:绑定/列表/详情/重命名/解绑(越权统一"设备不存在",防 IDOR)
- 眼罩开关(/app/device/{deviceNo}/hood,低电不对称拦截)
- 定位(/app/device/{deviceNo}/location)
- 直播(/app/stream 6 端点 + /app/device/{deviceNo}/live,自建 ZLMediaKit,设备主动推流)
后台(/device/**):
- 设备 CRUD/统计/删除/导出(ExcelBuilder)
- OTA(6 端点:升级包管理 + 批量下发,total==created+skipped+failed)
MQTT(T05):
- 遥测/事件/ack 监听、指令下发+回执(防串设备)、离线扫描、低电告警
- 报文身份仅来自 Topic,反查 device_id 防跨设备伪造
数据与脚本:
- script/sql/ry_device.sql(6 表/字典/权限字/菜单)、bm_patch.sql
- script/mqtt 设备模拟器(mqtt-simulate.js)
安全:
- MQTT broker 凭据外置为环境变量 MQTT_USERNAME/MQTT_PASSWORD,不落明文
- ZLM webhook 密钥外置为环境变量 ZLM_SECRET(未配置时 /zlm/hook/** 失败关闭),不落明文
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
package org.dromara.system.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.constant.SystemConstants;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.mybatis.utils.IdGeneratorUtil;
|
||||
import org.dromara.system.api.AppUserApiService;
|
||||
import org.dromara.system.api.domain.AppUserDTO;
|
||||
import org.dromara.system.domain.SysUser;
|
||||
import org.dromara.system.mapper.SysUserMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* APP 用户领域服务({@link AppUserApiService})实现。
|
||||
* <p>
|
||||
* 本实现为迭代 I2(边界方案 A)的<b>纯新增</b>:
|
||||
* <ul>
|
||||
* <li>复用 {@link SysUserMapper} 与框架既有 BCrypt 口径(与 {@code SysUserController}、{@code SysProfileController}、{@code PasswordAuthStrategy} 一致)。</li>
|
||||
* <li>密码仅在 {@code ruoyi-system} 内部散列({@link BCrypt#hashpw(String)})与比对({@link BCrypt#checkpw(String, String)}),
|
||||
* 不向调用方(设备域)暴露散列或明文。</li>
|
||||
* <li>不改动 {@code ruoyi-system} 既有任何类、方法与行为。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class AppUserApiServiceImpl implements AppUserApiService {
|
||||
|
||||
/**
|
||||
* APP 登录账号(user_name)前缀:APP 账号的 user_name = 前缀 + 预分配 user_id。
|
||||
*/
|
||||
private static final String USER_NAME_PREFIX = "app_";
|
||||
|
||||
private final SysUserMapper userMapper;
|
||||
|
||||
/**
|
||||
* 按手机号查询 APP 用户(仅 app_user)。
|
||||
* <p>
|
||||
* <b>防「永久无法登录」加固(P2-2-1)</b>:{@code sys_user.phone_number} 在框架表中为
|
||||
* <b>非唯一</b>索引({@code ry_vue.sql:106} 的 {@code idx_sys_user_phone}),并发注册或历史脏数据
|
||||
* 可能造成同一手机号存在多条 app_user。若此处用 {@code one()},MyBatis-Plus 在命中多行时会抛
|
||||
* {@code TooManyResultsException},将导致该手机号<b>永久无法登录</b>(且不可自愈)。
|
||||
* 因此改用 {@code list()} + {@code orderByAsc(userId)} 取首条:
|
||||
* <ul>
|
||||
* <li>绝不抛 {@code TooManyResultsException};</li>
|
||||
* <li>结果确定(恒取 userId 最小者),可复现、可审计;</li>
|
||||
* <li>命中多行时打印告警,便于运维发现并人工清理。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @return APP 用户信息(不含密码);不存在时返回 {@code null}
|
||||
*/
|
||||
@Override
|
||||
public AppUserDTO selectByPhone(String phoneNumber) {
|
||||
if (StringUtils.isBlank(phoneNumber)) {
|
||||
return null;
|
||||
}
|
||||
List<SysUser> users = userMapper.lambda()
|
||||
.eq(SysUser::getPhoneNumber, phoneNumber)
|
||||
.eq(SysUser::getUserType, UserType.APP_USER.getUserType())
|
||||
.orderByAsc(SysUser::getUserId)
|
||||
.list();
|
||||
if (users == null || users.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (users.size() > 1) {
|
||||
log.warn("手机号 {} 命中 {} 条 app_user 记录(应唯一),已按 userId 升序取首条 userId={};"
|
||||
+ "疑似并发注册或历史脏数据,建议人工核查清理。",
|
||||
phoneNumber, users.size(), users.get(0).getUserId());
|
||||
}
|
||||
return BeanUtil.copyProperties(users.get(0), AppUserDTO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断手机号是否已被注册为 APP 用户(仅 app_user)。
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @return true 已注册;false 未注册
|
||||
*/
|
||||
@Override
|
||||
public boolean existsByPhone(String phoneNumber) {
|
||||
if (StringUtils.isBlank(phoneNumber)) {
|
||||
return false;
|
||||
}
|
||||
return userMapper.lambda()
|
||||
.eq(SysUser::getPhoneNumber, phoneNumber)
|
||||
.eq(SysUser::getUserType, UserType.APP_USER.getUserType())
|
||||
.exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户密码是否匹配;明文密码仅在本方法内部比对。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param rawPassword 明文密码
|
||||
* @return true 匹配;false 不匹配或用户不存在
|
||||
*/
|
||||
@Override
|
||||
public boolean matchesPassword(Long userId, String rawPassword) {
|
||||
if (userId == null || StringUtils.isBlank(rawPassword)) {
|
||||
return false;
|
||||
}
|
||||
SysUser user = userMapper.lambda()
|
||||
.select(SysUser::getUserId, SysUser::getPassword)
|
||||
.eq(SysUser::getUserId, userId)
|
||||
.one();
|
||||
if (user == null || StringUtils.isBlank(user.getPassword())) {
|
||||
return false;
|
||||
}
|
||||
return BCrypt.checkpw(rawPassword, user.getPassword());
|
||||
}
|
||||
|
||||
/**
|
||||
* 以手机号注册一个 APP 用户;密码在本方法内部散列后落库。
|
||||
* <p>
|
||||
* <b>user_name 形式唯一保证(P3-7)</b>:先经框架 {@link IdGeneratorUtil#nextLongId()} <b>预分配 user_id</b>
|
||||
* (与 MyBatis-Plus 主键生成器同源,见 {@code MybatisPlusConfig#idGenerator}),
|
||||
* 令 {@code user_name = "app_" + user_id} 由全局唯一的主键<b>确定性派生</b>,
|
||||
* 从而获得形式唯一保证(不依赖概率、不依赖重名检测):
|
||||
* <ul>
|
||||
* <li>两个 app_user 的 user_id 必不相同 ⇒ user_name 必不相同;</li>
|
||||
* <li>逻辑删除行的 user_id 亦互异 ⇒ 不存在「删除行同名」的漏检问题;</li>
|
||||
* <li>与后台 {@code sys_user} 的账号名冲突需其恰好命名为 {@code app_<雪花ID>},非自然产生。</li>
|
||||
* </ul>
|
||||
* 说明:APP 登录仅依赖 {@code phone_number}(见 {@code selectByPhone}),不依赖 {@code user_name};
|
||||
* 展示用昵称取 {@code nickName}(默认手机号),故 user_name 采用内部派生值不影响用户展示。
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @param rawPassword 明文密码
|
||||
* @param nickName 昵称(为空时默认取手机号)
|
||||
* @return 新建用户主键
|
||||
*/
|
||||
@Override
|
||||
public Long registerByPhone(String phoneNumber, String rawPassword, String nickName) {
|
||||
Long userId = IdGeneratorUtil.nextLongId();
|
||||
SysUser user = new SysUser();
|
||||
user.setUserId(userId);
|
||||
user.setUserName(USER_NAME_PREFIX + userId);
|
||||
user.setNickName(StringUtils.isBlank(nickName) ? phoneNumber : nickName);
|
||||
user.setPhoneNumber(phoneNumber);
|
||||
user.setUserType(UserType.APP_USER.getUserType());
|
||||
user.setPassword(BCrypt.hashpw(rawPassword));
|
||||
user.setStatus(SystemConstants.NORMAL);
|
||||
user.setCreateBy(0L);
|
||||
user.setUpdateBy(0L);
|
||||
userMapper.insert(user);
|
||||
return user.getUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按邮箱查询 APP 用户(仅 app_user)。
|
||||
* <p>
|
||||
* 与 {@link #selectByPhone(String)} 同口径:{@code sys_user.email} 在框架基线中无索引、
|
||||
* 且在补丁 2 之前允许重复,故同样用 {@code list()} + {@code orderByAsc(userId)} 取首条,
|
||||
* 避免 {@code TooManyResultsException} 造成「该邮箱永久无法登录」。
|
||||
*
|
||||
* @param email 邮箱地址(已归一化)
|
||||
* @return APP 用户信息(不含密码);不存在时返回 {@code null}
|
||||
*/
|
||||
@Override
|
||||
public AppUserDTO selectByEmail(String email) {
|
||||
if (StringUtils.isBlank(email)) {
|
||||
return null;
|
||||
}
|
||||
List<SysUser> users = userMapper.lambda()
|
||||
.eq(SysUser::getEmail, email)
|
||||
.eq(SysUser::getUserType, UserType.APP_USER.getUserType())
|
||||
.orderByAsc(SysUser::getUserId)
|
||||
.list();
|
||||
if (users == null || users.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (users.size() > 1) {
|
||||
log.warn("邮箱 {} 命中 {} 条 app_user 记录(应唯一),已按 userId 升序取首条 userId={};"
|
||||
+ "疑似并发注册或历史脏数据,建议人工核查清理。",
|
||||
email, users.size(), users.get(0).getUserId());
|
||||
}
|
||||
return BeanUtil.copyProperties(users.get(0), AppUserDTO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断邮箱是否已被注册为 APP 用户(仅 app_user)。
|
||||
*
|
||||
* @param email 邮箱地址(已归一化)
|
||||
* @return true 已注册;false 未注册
|
||||
*/
|
||||
@Override
|
||||
public boolean existsByEmail(String email) {
|
||||
if (StringUtils.isBlank(email)) {
|
||||
return false;
|
||||
}
|
||||
return userMapper.lambda()
|
||||
.eq(SysUser::getEmail, email)
|
||||
.eq(SysUser::getUserType, UserType.APP_USER.getUserType())
|
||||
.exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 以邮箱注册一个 APP 用户。
|
||||
* <p>
|
||||
* <b>为什么不能写 phoneNumber</b>:{@code sys_user.phone_number} 上有唯一索引
|
||||
* {@code uk_sys_user_phone_number},而 MySQL 唯一索引允许多个 NULL、<b>不允许多个 ''</b>。
|
||||
* 本方法<b>刻意不 set 手机号</b>——MyBatis-Plus 全局 {@code insertStrategy: NOT_NULL} 会把该列
|
||||
* 整个从 INSERT 中省略,因此实际落库值是<b>列默认值</b>;该默认值已由
|
||||
* {@code script/sql/bm_patch.sql} 补丁 3 改为 NULL,从而多个邮箱账号可共存。
|
||||
* <p>
|
||||
* <b>user_name 口径</b>与 {@link #registerByPhone(String, String, String)} 完全一致:
|
||||
* 由预分配的雪花 {@code user_id} 确定性派生 {@code "app_" + userId},获得形式唯一保证。
|
||||
*
|
||||
* @param email 邮箱地址(已归一化)
|
||||
* @param rawPassword 明文密码
|
||||
* @param nickName 昵称(为空时默认取邮箱)
|
||||
* @return 新建用户主键
|
||||
*/
|
||||
@Override
|
||||
public Long registerByEmail(String email, String rawPassword, String nickName) {
|
||||
Long userId = IdGeneratorUtil.nextLongId();
|
||||
SysUser user = new SysUser();
|
||||
user.setUserId(userId);
|
||||
user.setUserName(USER_NAME_PREFIX + userId);
|
||||
user.setNickName(StringUtils.isBlank(nickName) ? email : nickName);
|
||||
user.setEmail(email);
|
||||
user.setUserType(UserType.APP_USER.getUserType());
|
||||
user.setPassword(BCrypt.hashpw(rawPassword));
|
||||
user.setStatus(SystemConstants.NORMAL);
|
||||
user.setCreateBy(0L);
|
||||
user.setUpdateBy(0L);
|
||||
userMapper.insert(user);
|
||||
return user.getUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户密码(忘记密码场景);新密码在本方法内部散列后落库。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param rawPassword 新明文密码
|
||||
* @return true 修改成功
|
||||
*/
|
||||
@Override
|
||||
public boolean resetPassword(Long userId, String rawPassword) {
|
||||
if (userId == null || StringUtils.isBlank(rawPassword)) {
|
||||
return false;
|
||||
}
|
||||
return userMapper.lambda()
|
||||
.set(SysUser::getPassword, BCrypt.hashpw(rawPassword))
|
||||
.eq(SysUser::getUserId, userId)
|
||||
.updateCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 换绑手机号。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param newPhoneNumber 新手机号
|
||||
* @return true 修改成功
|
||||
*/
|
||||
@Override
|
||||
public boolean updatePhone(Long userId, String newPhoneNumber) {
|
||||
if (userId == null || StringUtils.isBlank(newPhoneNumber)) {
|
||||
return false;
|
||||
}
|
||||
return userMapper.lambda()
|
||||
.set(SysUser::getPhoneNumber, newPhoneNumber)
|
||||
.eq(SysUser::getUserId, userId)
|
||||
.updateCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用账号(注销前置:解绑设备后停用)。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return true 停用成功
|
||||
*/
|
||||
@Override
|
||||
public boolean disableUser(Long userId) {
|
||||
if (userId == null) {
|
||||
return false;
|
||||
}
|
||||
return userMapper.lambda()
|
||||
.set(SysUser::getStatus, SystemConstants.DISABLE)
|
||||
.eq(SysUser::getUserId, userId)
|
||||
.updateCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断账号是否已绑定手机号。
|
||||
* <p>
|
||||
* 只取 {@code phone_number} 一列,避免无谓地把整行(含密码散列)读出来。
|
||||
* 口径为 {@code isNotBlank}:兼容历史上可能存在的 {@code ''} 空串(补丁 3 之后新数据为 NULL)。
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return true 已绑定非空手机号;false 未绑定或用户不存在
|
||||
*/
|
||||
@Override
|
||||
public boolean isPhoneBound(Long userId) {
|
||||
if (userId == null) {
|
||||
return false;
|
||||
}
|
||||
SysUser user = userMapper.lambda()
|
||||
.select(SysUser::getPhoneNumber)
|
||||
.eq(SysUser::getUserId, userId)
|
||||
.one();
|
||||
return user != null && StringUtils.isNotBlank(user.getPhoneNumber());
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user