feat: 增加sn管理接口

This commit is contained in:
2026-06-01 08:24:02 +08:00
parent 06857f6c88
commit 33fbee9a00
73 changed files with 4591 additions and 28 deletions

20
pom.xml
View File

@@ -117,6 +117,11 @@
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
@@ -283,6 +288,21 @@
<version>${dynamic-datasource.version}</version>
</dependency>-->
<!--以5.3.0版本为例-->
<!-- jiguang-sdk -->
<dependency>
<groupId>io.github.jpush</groupId>
<artifactId>jiguang-sdk</artifactId>
<version>5.3.0</version>
</dependency>
<!-- 腾讯云短信 -->
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-sms</artifactId>
<version>3.1.1451</version>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,63 @@
package com.youlai.boot.app.controller;
import com.youlai.boot.app.model.req.MobileLoginReq;
import com.youlai.boot.app.model.req.MobileRegisterReq;
import com.youlai.boot.app.service.AppAuthService;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 认证控制层
*
*/
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/auth/app")
@RequiredArgsConstructor
@Slf4j
public class AppAuthController {
private final AppAuthService appAuthService;
@Operation(summary = "手机号注册")
@PostMapping("/register/mobile")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.REGISTER)
public Result<AuthenticationToken> registerByMobile(@RequestBody @Valid MobileRegisterReq request) {
AuthenticationToken authenticationToken = appAuthService.registerByMobile(
request.getMobile(),
request.getCode(),
request.getPassword(),
request.getNickname()
);
return Result.success(authenticationToken);
}
@Operation(summary = "发送注册短信验证码")
@PostMapping("/register/sms/code")
public Result<Void> sendRegisterSmsCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
return Result.judge(appAuthService.sendRegisterSmsCode(mobile));
}
@Operation(summary = "手机号验证码登录")
@PostMapping("/login/mobile")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> loginByMobile(@RequestBody @Valid MobileLoginReq request) {
AuthenticationToken authenticationToken = appAuthService.loginBySms(request.getMobile(), request.getCode());
return Result.success(authenticationToken);
}
}

View File

@@ -0,0 +1,47 @@
package com.youlai.boot.app.converter;
import com.youlai.boot.app.model.entity.AppUser;
import com.youlai.boot.app.model.form.AppUserForm;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.model.form.UserImportForm;
import com.youlai.boot.system.model.form.UserProfileForm;
import com.youlai.boot.system.model.vo.CurrentUserVO;
import org.mapstruct.InheritInverseConfiguration;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import java.util.List;
/**
* 用户对象转换器
*
* @author Ray.Hao
* @since 2022/6/8
*/
@Mapper(componentModel = "spring")
public interface AppUserConverter {
AppUserForm toForm(AppUser entity);
@InheritInverseConfiguration(name = "toForm")
AppUser toEntity(AppUserForm entity);
@Mappings({
@Mapping(target = "userId", source = "id")
})
CurrentUserVO toCurrentUserVo(AppUser entity);
AppUser toEntity(UserImportForm vo);
AppUser toEntity(UserProfileForm formData);
@Mappings({
@Mapping(target = "label", source = "nickname"),
@Mapping(target = "value", source = "id")
})
Option<String> toOption(AppUser entity);
List<Option<String>> toOptions(List<AppUser> list);
}

View File

@@ -0,0 +1,84 @@
package com.youlai.boot.app.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.app.model.entity.AppUser;
import com.youlai.boot.app.model.form.AppUserForm;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.model.query.UserQuery;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 用户持久层接口
*
*/
@Mapper
public interface AppUserMapper extends BaseMapper<AppUser> {
/**
* 获取用户分页列表
*
* @param page 分页参数
* @param queryParams 查询参数
* @return 用户分页列表
*/
@DataPermission(deptAlias = "u", userAlias = "u")
Page<UserPageVO> getUserPage(Page<UserPageVO> page, @Param("queryParams") UserQuery queryParams);
/**
* 获取用户表单详情
*
* @param userId 用户ID
* @return 用户表单详情
*/
AppUserForm getUserFormData(Long userId);
/**
* 根据用户名获取认证信息
*
* @param username 用户名
* @return 认证信息
*/
UserAuthInfo getAuthInfoByUsername(String username);
default UserAuthInfo getAuthCredentialsByUsername(String username) {
return getAuthInfoByUsername(username);
}
/**
* 根据手机号获取用户认证信息
*
* @param mobile 手机号
* @return 认证信息
*/
UserAuthInfo getAuthInfoByMobile(String mobile);
default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
return getAuthInfoByMobile(mobile);
}
/**
* 获取导出用户列表
*
* @param queryParams 查询参数
* @return 导出用户列表
*/
@DataPermission(deptAlias = "u", userAlias = "u")
List<UserExportVO> listExportUsers(UserQuery queryParams);
/**
* 获取用户个人中心信息
*
* @param userId 用户ID
* @return 用户个人中心信息
*/
UserProfileVO getUserProfile(Long userId);
}

View File

@@ -0,0 +1,65 @@
package com.youlai.boot.app.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 移动端用户实体
*/
@TableName("app_user")
@Getter
@Setter
public class AppUser extends BaseEntity {
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 性别((1-男 2-女 0-保密)
*/
private Integer gender;
/**
* 密码
*/
private String password;
/**
* 用户头像
*/
private String avatar;
/**
* 绑定手机
*/
private String mobile;
/**
* 绑定微信
*/
private String wechatOpenid;
/**
* 状态((1-正常 0-禁用)
*/
private Integer status;
/**
* 用户邮箱
*/
private String email;
/**
* 是否删除(0-否 1-是)
*/
private Integer isDeleted;
}

View File

@@ -0,0 +1,58 @@
package com.youlai.boot.app.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import java.util.List;
/**
* 用户表单对象
*
* @author haoxr
* @since 2022/4/12 11:04
*/
@Schema(description = "用户表单对象")
@Data
public class AppUserForm {
@Schema(description="用户ID")
private Long id;
@Schema(description="用户名")
@NotBlank(message = "用户名不能为空")
private String username;
@Schema(description="昵称")
@NotBlank(message = "昵称不能为空")
private String nickname;
@Schema(description="手机号码")
@Pattern(regexp = "^$|^1(3\\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\\d|9[0-35-9])\\d{8}$", message = "手机号码格式不正确")
private String mobile;
@Schema(description="性别")
private Integer gender;
@Schema(description="用户头像")
private String avatar;
@Schema(description="邮箱")
private String email;
@Schema(description="用户状态(1:正常;0:禁用)")
@Range(min = 0, max = 1, message = "用户状态不正确")
private Integer status;
@Schema(description="部门ID")
private Long deptId;
@Schema(description="角色ID集合")
@NotEmpty(message = "用户角色不能为空")
private List<Long> roleIds;
}

View File

@@ -0,0 +1,24 @@
package com.youlai.boot.app.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* 手机号登录请求参数
*
*/
@Schema(description = "手机号登录请求参数")
@Data
public class MobileLoginReq {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18888888888")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String mobile;
@Schema(description = "验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "验证码不能为空")
private String code;
}

View File

@@ -0,0 +1,32 @@
package com.youlai.boot.app.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* 手机号注册请求参数
*
*/
@Schema(description = "手机号注册请求参数")
@Data
public class MobileRegisterReq {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18888888888")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String mobile;
@Schema(description = "验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "验证码不能为空")
private String code;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
@Schema(description = "昵称", example = "用户昵称")
private String nickname;
}

View File

@@ -0,0 +1,70 @@
package com.youlai.boot.app.service;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
public interface AppAuthService {
/**
* 获取验证码
*/
CaptchaInfo getCaptcha();
/**
* 发送注册短信验证码
*
* @param mobile 手机号
*/
boolean sendRegisterSmsCode(String mobile);
/**
* 手机号注册
*
* @param mobile 手机号
* @param code 验证码
* @param password 密码
* @param nickname 昵称
* @return 认证令牌
*/
AuthenticationToken registerByMobile(String mobile, String code, String password, String nickname);
/**
* 发送登录短信验证码
*
* @param mobile 手机号
*/
void sendLoginSmsCode(String mobile);
/**
* 短信验证码登录
*
* @param mobile 手机号
* @param code 验证码
* @return 认证令牌
*/
AuthenticationToken loginBySms(String mobile, String code);
/**
* 账号密码登录
*
* @param username 用户名
* @param password 密码
* @return 认证令牌
*/
AuthenticationToken login(String username, String password);
/**
* 退出登录
*/
void logout();
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 认证令牌
*/
AuthenticationToken refreshToken(String refreshToken);
}

View File

@@ -0,0 +1,198 @@
package com.youlai.boot.app.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.app.model.entity.AppUser;
import com.youlai.boot.app.model.form.AppUserForm;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.model.form.*;
import com.youlai.boot.system.model.query.UserQuery;
import com.youlai.boot.system.model.vo.CurrentUserVO;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
import java.util.List;
/**
* 用户业务接口
*
* @author Ray.Hao
* @since 2022/1/14
*/
public interface AppUserService extends IService<AppUser> {
/**
* 用户分页列表
*
* @return {@link IPage<UserPageVO>} 用户分页列表
*/
IPage<UserPageVO> getUserPage(UserQuery queryParams);
/**
* 获取用户表单数据
*
* @param userId 用户ID
* @return {@link AppUserForm} 用户表单数据
*/
AppUserForm getUserFormData(Long userId);
/**
* 新增用户
*
* @param userForm 用户表单对象
* @return {@link Boolean} 是否新增成功
*/
boolean saveUser(AppUserForm userForm);
/**
* 修改用户
*
* @param userId 用户ID
* @param userForm 用户表单对象
* @return {@link Boolean} 是否修改成功
*/
boolean updateUser(Long userId, AppUserForm userForm);
/**
* 删除用户
*
* @param idsStr 用户ID多个以英文逗号(,)分割
* @return {@link Boolean} 是否删除成功
*/
boolean deleteUsers(String idsStr);
/**
* 根据用户名获取认证信息
*
* @param username 用户名
* @return {@link UserAuthInfo}
*/
UserAuthInfo getAuthInfoByUsername(String username);
default UserAuthInfo getAuthCredentialsByUsername(String username) {
return getAuthInfoByUsername(username);
}
/**
* 获取导出用户列表
*
* @param queryParams 查询参数
* @return {@link List<UserExportVO>} 导出用户列表
*/
List<UserExportVO> listExportUsers(UserQuery queryParams);
/**
* 获取登录用户信息
*
* @return {@link CurrentUserVO} 登录用户信息
*/
CurrentUserVO getCurrentUserInfo();
/**
* 获取个人中心用户信息
*
* @return {@link UserProfileVO} 个人中心用户信息
*/
UserProfileVO getUserProfile(Long userId);
/**
* 修改个人中心用户信息
*
* @param formData 表单数据
* @return {@link Boolean} 是否修改成功
*/
boolean updateUserProfile(UserProfileForm formData);
/**
* 修改指定用户密码
*
* @param userId 用户ID
* @param data 修改密码表单数据
* @return {@link Boolean} 是否修改成功
*/
boolean changeUserPassword(Long userId, PasswordUpdateForm data);
/**
* 重置指定用户密码
*
* @param userId 用户ID
* @param password 重置后的密码
* @return {@link Boolean} 是否重置成功
*/
boolean resetUserPassword(Long userId, String password);
/**
* 发送短信验证码(绑定或更换手机号)
*
* @param mobile 手机号
* @return {@link Boolean} 是否发送成功
*/
boolean sendMobileCode(String mobile);
/**
* 修改当前用户手机号
*
* @param data 表单数据
* @return {@link Boolean} 是否修改成功
*/
boolean bindOrChangeMobile(MobileUpdateForm data);
/**
* 发送邮箱验证码(绑定或更换邮箱)
*
* @param email 邮箱
*/
void sendEmailCode(String email);
/**
* 绑定或更换邮箱
*
* @param data 表单数据
* @return {@link Boolean} 是否绑定成功
*/
boolean bindOrChangeEmail(EmailUpdateForm data);
/**
* 解绑手机号
*
* @param data 表单数据
* @return {@link Boolean} 是否解绑成功
*/
boolean unbindMobile(PasswordVerifyForm data);
/**
* 解绑邮箱
*
* @param data 表单数据
* @return {@link Boolean} 是否解绑成功
*/
boolean unbindEmail(PasswordVerifyForm data);
/**
* 获取用户选项列表
*
* @return {@link List<Option<String>>} 用户选项列表
*/
// List<Option<String>> listUserOptions();
/**
* 根据手机号获取用户认证信息
*
* @param mobile 手机号
* @return {@link UserAuthInfo}
*/
UserAuthInfo getAuthInfoByMobile(String mobile);
default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
return getAuthInfoByMobile(mobile);
}
}

View File

@@ -0,0 +1,198 @@
package com.youlai.boot.app.service.impl;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.app.model.entity.AppUser;
import com.youlai.boot.app.service.AppAuthService;
import com.youlai.boot.app.service.AppUserService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.util.CodeGeneratorUtil;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import com.youlai.boot.framework.integration.sms.service.impl.AliyunSmsService;
import com.youlai.boot.framework.integration.sms.service.impl.TencentSmsService;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.youlai.boot.common.exception.BusinessException;
import cn.hutool.core.lang.Assert;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 认证服务实现类
*
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AppAuthServiceImpl implements AppAuthService {
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final AliyunSmsService smsService;
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaService captchaService;
private final AppUserService userService;
private final PasswordEncoder passwordEncoder;
@Override
public CaptchaInfo getCaptcha() {
return null;
}
/**
* 发送注册短信验证码
*
* @param mobile 手机号
*/
@Override
public boolean sendRegisterSmsCode(String mobile) {
// 检查手机号是否已注册
long count = userService.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getMobile, mobile));
if (count > 0) {
throw new BusinessException("该手机号已被注册");
}
String code = CodeGeneratorUtil.generateNumericCode(6);
// 发送短信验证码
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
boolean success = smsService.sendSms(mobile, SmsTypeEnum.REGISTER, templateParams);
if (success) {
// 缓存验证码至Redis用于注册校验
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_REGISTER_CODE, mobile), code, 5, TimeUnit.MINUTES);
} else {
log.warn("短信发送失败,手机号: {}", mobile);
}
return success;
}
/**
* 手机号注册
*
* @param mobile 手机号
* @param code 验证码
* @param password 密码
* @param nickname 昵称
* @return 认证令牌
*/
@Override
public AuthenticationToken registerByMobile(String mobile, String code, String password, String nickname) {
// 1. 校验验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_REGISTER_CODE, mobile);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isBlank(cachedCode)) {
throw new BusinessException("验证码已过期");
}
if (!Objects.equals(code, cachedCode)) {
throw new BusinessException("验证码错误");
}
// 2. 检查手机号是否已注册
long count = userService.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getMobile, mobile));
Assert.isTrue(count == 0, "该手机号已被注册");
// 3. 创建新用户
AppUser user = new AppUser();
user.setUsername(mobile); // 使用手机号作为用户名
user.setMobile(mobile);
user.setPassword(passwordEncoder.encode(password));
user.setNickname(StrUtil.isNotBlank(nickname) ? nickname : mobile.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"));
user.setStatus(1); // 正常状态
boolean saveResult = userService.save(user);
if (!saveResult) {
throw new BusinessException("注册失败");
}
// 4. 删除验证码
redisTemplate.delete(cacheKey);
// 5. 自动登录并生成token
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(mobile, password);
Authentication authentication = authenticationManager.authenticate(authenticationToken);
AuthenticationToken authenticationTokenResponse =
tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationTokenResponse;
}
@Override
public void sendLoginSmsCode(String mobile) {
String code = "1234";
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
boolean success = false;
// 方式1: 使用阿里云短信(默认)
// try {
// success = aliyunSmsService.sendSms(mobile, SmsTypeEnum.LOGIN, templateParams);
// log.info("阿里云短信发送结果: {}", success ? "成功" : "失败");
// } catch (Exception e) {
// log.error("阿里云短信发送异常", e);
// }
// 方式2: 使用腾讯云短信(需要时取消下面注释,并注释掉上面的阿里云代码)
try {
success = smsService.sendSms(mobile, SmsTypeEnum.LOGIN, templateParams);
log.info("腾讯云短信发送结果: {}", success ? "成功" : "失败");
} catch (Exception e) {
log.error("腾讯云短信发送异常", e);
}
if (success) {
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES);
} else {
log.warn("短信发送失败,手机号: {}", mobile);
}
}
@Override
public AuthenticationToken loginBySms(String mobile, String code) {
return null;
}
@Override
public AuthenticationToken login(String username, String password) {
return null;
}
@Override
public void logout() {
}
@Override
public AuthenticationToken refreshToken(String refreshToken) {
return null;
}
}

View File

@@ -0,0 +1,683 @@
package com.youlai.boot.app.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.app.converter.AppUserConverter;
import com.youlai.boot.app.mapper.AppUserMapper;
import com.youlai.boot.app.model.entity.AppUser;
import com.youlai.boot.app.model.form.AppUserForm;
import com.youlai.boot.app.service.AppUserService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SystemConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.framework.integration.mail.service.MailService;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.system.converter.UserConverter;
import com.youlai.boot.system.enums.DictCodeEnum;
import com.youlai.boot.system.mapper.UserMapper;
import com.youlai.boot.system.model.entity.Dept;
import com.youlai.boot.system.model.entity.DictItem;
import com.youlai.boot.system.model.entity.Role;
import com.youlai.boot.system.model.form.*;
import com.youlai.boot.system.model.query.UserQuery;
import com.youlai.boot.system.model.vo.CurrentUserVO;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
import com.youlai.boot.system.service.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* app用户业务实现类
*
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implements AppUserService {
private final PasswordEncoder passwordEncoder;
private final UserRoleService userRoleService;
private final DeptService deptService;
private final RoleService roleService;
private final RoleMenuService roleMenuService;
private final SmsService smsService;
private final MailService mailService;
private final StringRedisTemplate redisTemplate;
private final TokenManager tokenManager;
private final DictItemService dictItemService;
private final AppUserConverter userConverter;
/**
* 获取用户分页列表
*
* @param queryParams 查询参数
* @return {@link IPage<UserPageVO>} 用户分页列表
*/
@Override
public IPage<UserPageVO> getUserPage(UserQuery queryParams) {
// 参数构建
int pageNum = queryParams.getPageNum();
int pageSize = queryParams.getPageSize();
Page<UserPageVO> page = new Page<>(pageNum, pageSize);
boolean isRoot = SecurityUtils.isRoot();
queryParams.setIsRoot(isRoot);
// 查询数据
return this.baseMapper.getUserPage(page, queryParams);
}
/**
* 获取用户表单数据
*
* @param userId 用户ID
* @return {@link AppUserForm} 用户表单数据
*/
@Override
public AppUserForm getUserFormData(Long userId) {
return this.baseMapper.getUserFormData(userId);
}
/**
* 新增用户
*
* @param userForm 用户表单对象
* @return true|false
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveUser(AppUserForm userForm) {
String username = userForm.getUsername();
// 实体转换 form->entity
AppUser entity = userConverter.toEntity(userForm);
// 检查用户名是否已存在
long count = this.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getUsername, username));
Assert.isTrue(count == 0, "用户名已存在");
// 设置默认加密密码
String defaultEncryptPwd = passwordEncoder.encode(SystemConstants.DEFAULT_PASSWORD);
entity.setPassword(defaultEncryptPwd);
// entity.setCreateBy(SecurityUtils.getUserId());
// 新增用户
boolean result = this.save(entity);
if (result) {
// 保存用户角色
userRoleService.saveUserRoles(entity.getId(), userForm.getRoleIds());
}
return result;
}
/**
* 更新用户
*
* @param userId 用户ID
* @param userForm 用户表单对象
* @return true|false
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateUser(Long userId, AppUserForm userForm) {
String username = userForm.getUsername();
// 获取原用户信息
AppUser oldUser = this.getById(userId);
Assert.notNull(oldUser, "用户不存在");
// 检查用户名是否已存在(排除当前用户)
long count = this.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getUsername, username)
.ne(AppUser::getId, userId)
);
Assert.isTrue(count == 0, "用户名已存在");
// form -> entity
AppUser entity = userConverter.toEntity(userForm);
// entity.setUpdateBy(SecurityUtils.getUserId());
// 修改用户
boolean result = this.updateById(entity);
if (result) {
// 保存用户角色
userRoleService.saveUserRoles(entity.getId(), userForm.getRoleIds());
}
return result;
}
/**
* 删除用户
*
* @param idsStr 用户ID多个以英文逗号(,)分割
* @return true|false
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteUsers(String idsStr) {
Assert.isTrue(StrUtil.isNotBlank(idsStr), "删除的用户数据为空");
// 逻辑删除
List<Long> ids = Arrays.stream(idsStr.split(","))
.map(Long::parseLong)
.collect(Collectors.toList());
boolean result = this.removeByIds(ids);
return result;
}
/**
* 根据用户名获取认证凭证信息
*
* @param username 用户名
* @return 用户认证凭证信息 {@link UserAuthInfo}
*/
@Override
public UserAuthInfo getAuthInfoByUsername(String username) {
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username);
if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles();
// 获取数据权限列表(用于并集策略)
List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
userAuthInfo.setDataScopes(dataScopes);
}
return userAuthInfo;
}
/**
* 根据手机号获取用户认证信息
*
* @param mobile 手机号
* @return 用户认证信息
*/
@Override
public UserAuthInfo getAuthInfoByMobile(String mobile) {
if (StrUtil.isBlank(mobile)) {
return null;
}
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile);
if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles();
// 获取数据权限列表(用于并集策略)
List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
userAuthInfo.setDataScopes(dataScopes);
}
return userAuthInfo;
}
/**
* 获取导出用户列表
*
* @param queryParams 查询参数
* @return {@link List<UserExportVO>} 导出用户列表
*/
@Override
public List<UserExportVO> listExportUsers(UserQuery queryParams) {
boolean isRoot = SecurityUtils.isRoot();
queryParams.setIsRoot(isRoot);
List<UserExportVO> exportUsers = this.baseMapper.listExportUsers(queryParams);
if (CollectionUtil.isNotEmpty(exportUsers)) {
//获取性别的字典项
Map<String, String> genderMap = dictItemService.list(
new LambdaQueryWrapper<DictItem>().eq(DictItem::getDictCode,
DictCodeEnum.GENDER.getValue())
).stream()
.collect(Collectors.toMap(DictItem::getValue, DictItem::getLabel)
);
exportUsers.forEach(item -> {
String gender = item.getGender();
if (StrUtil.isBlank(gender)) {
return;
}
// 判断map是否为空
if (genderMap.isEmpty()) {
return;
}
item.setGender(genderMap.get(gender));
});
}
return exportUsers;
}
/**
* 获取登录用户信息
*
* @return {@link CurrentUserVO} 用户信息
*/
@Override
public CurrentUserVO getCurrentUserInfo() {
String username = SecurityUtils.getUsername();
// 获取登录用户基础信息
AppUser user = this.getOne(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getUsername, username)
.select(
AppUser::getId,
AppUser::getUsername,
AppUser::getNickname,
AppUser::getAvatar,
AppUser::getGender
// AppUser::getDeptId
)
);
// entity->Vo
CurrentUserVO userInfoVo = userConverter.toCurrentUserVo(user);
// 性别
userInfoVo.setGender(user.getGender());
// 部门名称
// if (user.getDeptId() != null) {
// Dept dept = deptService.getById(user.getDeptId());
// if (dept != null) {
// userInfoVo.setDeptName(dept.getName());
// }
// }
// 用户角色集合
Set<String> roles = SecurityUtils.getRoles();
userInfoVo.setRoles(roles);
// 用户角色名称集合
if (CollectionUtil.isNotEmpty(roles)) {
Set<String> roleNames = roleService.list(new LambdaQueryWrapper<Role>()
.in(Role::getCode, roles)
.select(Role::getName)
).stream()
.map(Role::getName)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toCollection(LinkedHashSet::new));
userInfoVo.setRoleNames(roleNames);
}
// 用户权限集合
if (CollectionUtil.isNotEmpty(roles)) {
Set<String> perms = roleMenuService.getRolePermsByRoleCodes(roles);
userInfoVo.setPerms(perms);
}
return userInfoVo;
}
/**
* 获取个人中心用户信息
*
* @param userId 用户ID
* @return {@link UserProfileVO} 个人中心用户信息
*/
@Override
public UserProfileVO getUserProfile(Long userId) {
return this.baseMapper.getUserProfile(userId);
}
/**
* 修改个人中心用户信息
*
* @param formData 表单数据
* @return true|false
*/
@Override
public boolean updateUserProfile(UserProfileForm formData) {
Long userId = SecurityUtils.getUserId();
if (formData.getNickname() == null && formData.getAvatar() == null && formData.getGender() == null) {
throw new BusinessException("请修改至少一个字段");
}
return this.update(new LambdaUpdateWrapper<AppUser>()
.eq(AppUser::getId, userId)
.set(formData.getNickname() != null, AppUser::getNickname, formData.getNickname())
.set(formData.getAvatar() != null, AppUser::getAvatar, formData.getAvatar())
.set(formData.getGender() != null, AppUser::getGender, formData.getGender())
);
}
/**
* 修改指定用户密码
*
* @param userId 用户ID
* @param data 密码修改表单数据
* @return true|false
*/
@Override
public boolean changeUserPassword(Long userId, PasswordUpdateForm data) {
AppUser user = this.getById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
String oldPassword = data.getOldPassword();
// 校验原密码
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
throw new BusinessException("原密码错误");
}
// 新旧密码不能相同
if (passwordEncoder.matches(data.getNewPassword(), user.getPassword())) {
throw new BusinessException("新密码不能与原密码相同");
}
// 判断新密码和确认密码是否一致
if (!Objects.equals(data.getNewPassword(), data.getConfirmPassword())) {
throw new BusinessException("新密码和确认密码不一致");
}
String newPassword = data.getNewPassword();
boolean result = this.update(new LambdaUpdateWrapper<AppUser>()
.eq(AppUser::getId, userId)
.set(AppUser::getPassword, passwordEncoder.encode(newPassword))
);
if (result) {
// 密码变更后,使当前用户的所有会话失效,强制重新登录
tokenManager.invalidateUserSessions(userId);
}
return result;
}
/**
* 重置指定用户密码
*
* @param userId 用户ID
* @param password 密码重置表单数据
* @return true|false
*/
@Override
public boolean resetUserPassword(Long userId, String password) {
boolean result = this.update(new LambdaUpdateWrapper<AppUser>()
.eq(AppUser::getId, userId)
.set(AppUser::getPassword, passwordEncoder.encode(password))
);
if (result) {
// 管理员重置用户密码后,使该用户的所有会话失效
tokenManager.invalidateUserSessions(userId);
}
return result;
}
/**
* 发送短信验证码(绑定或更换手机号)
*
* @param mobile 手机号
* @return true|false
*/
@Override
public boolean sendMobileCode(String mobile) {
Long currentUserId = SecurityUtils.getUserId();
long mobileCount = this.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getMobile, mobile)
.ne(AppUser::getId, currentUserId)
);
if (mobileCount > 0) {
throw new BusinessException("手机号已被其他账号绑定");
}
// String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
// TODO 为了方便测试,验证码固定为 123456实际开发中在配置了厂商短信服务后可以使用上面的随机验证码
String code = "123456";
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
boolean result = smsService.sendSms(mobile, SmsTypeEnum.CHANGE_MOBILE, templateParams);
if (result) {
// 缓存验证码5分钟有效用于更换手机号校验
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.MOBILE_CODE, mobile);
redisTemplate.opsForValue().set(redisCacheKey, code, 5, TimeUnit.MINUTES);
}
return result;
}
/**
* 绑定或更换手机号
*
* @param form 表单数据
* @return true|false
*/
@Override
public boolean bindOrChangeMobile(MobileUpdateForm form) {
Long currentUserId = SecurityUtils.getUserId();
AppUser currentUser = this.getById(currentUserId);
if (currentUser == null) {
throw new BusinessException("用户不存在");
}
if (!passwordEncoder.matches(form.getPassword(), currentUser.getPassword())) {
throw new BusinessException("当前密码错误");
}
// 校验验证码
String inputVerifyCode = form.getCode();
String mobile = form.getMobile();
String cacheKey = StrUtil.format(RedisConstants.Captcha.MOBILE_CODE, mobile);
String cachedVerifyCode = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isBlank(cachedVerifyCode)) {
throw new BusinessException("验证码已过期");
}
if (!inputVerifyCode.equals(cachedVerifyCode)) {
throw new BusinessException("验证码错误");
}
long mobileCount = this.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getMobile, mobile)
.ne(AppUser::getId, currentUserId)
);
if (mobileCount > 0) {
throw new BusinessException("手机号已被其他账号绑定");
}
redisTemplate.delete(cacheKey);
// 更新手机号码
return this.update(
new LambdaUpdateWrapper<AppUser>()
.eq(AppUser::getId, currentUserId)
.set(AppUser::getMobile, mobile)
);
}
/**
* 发送邮箱验证码(绑定或更换邮箱)
*
* @param email 邮箱
*/
@Override
public void sendEmailCode(String email) {
Long currentUserId = SecurityUtils.getUserId();
long emailCount = this.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getEmail, email)
.ne(AppUser::getId, currentUserId)
);
if (emailCount > 0) {
throw new BusinessException("邮箱已被其他账号绑定");
}
// String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
// TODO 为了方便测试,验证码固定为 123456实际开发中在配置了邮箱服务后可以使用上面的随机验证码
String code = "123456";
mailService.sendMail(email, "邮箱验证码", "您的验证码为:" + code + "请在5分钟内使用");
// 缓存验证码5分钟有效用于更换邮箱校验
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.EMAIL_CODE, email);
redisTemplate.opsForValue().set(redisCacheKey, code, 5, TimeUnit.MINUTES);
}
/**
* 修改当前用户邮箱
*
* @param form 表单数据
* @return true|false
*/
@Override
public boolean bindOrChangeEmail(EmailUpdateForm form) {
Long currentUserId = SecurityUtils.getUserId();
AppUser currentUser = this.getById(currentUserId);
if (currentUser == null) {
throw new BusinessException("用户不存在");
}
if (!passwordEncoder.matches(form.getPassword(), currentUser.getPassword())) {
throw new BusinessException("当前密码错误");
}
// 获取前端输入的验证码
String inputVerifyCode = form.getCode();
// 获取缓存的验证码
String email = form.getEmail();
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.EMAIL_CODE, email);
String cachedVerifyCode = redisTemplate.opsForValue().get(redisCacheKey);
if (StrUtil.isBlank(cachedVerifyCode)) {
throw new BusinessException("验证码已过期");
}
if (!inputVerifyCode.equals(cachedVerifyCode)) {
throw new BusinessException("验证码错误");
}
long emailCount = this.count(new LambdaQueryWrapper<AppUser>()
.eq(AppUser::getEmail, email)
.ne(AppUser::getId, currentUserId)
);
if (emailCount > 0) {
throw new BusinessException("邮箱已被其他账号绑定");
}
redisTemplate.delete(redisCacheKey);
// 更新邮箱地址
return this.update(
new LambdaUpdateWrapper<AppUser>()
.eq(AppUser::getId, currentUserId)
.set(AppUser::getEmail, email)
);
}
/**
* 解绑手机号
*
* @param form 表单数据
* @return true|false
*/
@Override
public boolean unbindMobile(PasswordVerifyForm form) {
Long currentUserId = SecurityUtils.getUserId();
AppUser currentUser = this.getById(currentUserId);
if (currentUser == null) {
throw new BusinessException("用户不存在");
}
if (StrUtil.isBlank(currentUser.getMobile())) {
throw new BusinessException("当前账号未绑定手机号");
}
if (!passwordEncoder.matches(form.getPassword(), currentUser.getPassword())) {
throw new BusinessException("当前密码错误");
}
return this.update(new LambdaUpdateWrapper<AppUser>()
.eq(AppUser::getId, currentUserId)
.set(AppUser::getMobile, null)
);
}
/**
* 解绑邮箱
*
* @param form 表单数据
* @return true|false
*/
@Override
public boolean unbindEmail(PasswordVerifyForm form) {
Long currentUserId = SecurityUtils.getUserId();
AppUser currentUser = this.getById(currentUserId);
if (currentUser == null) {
throw new BusinessException("用户不存在");
}
if (StrUtil.isBlank(currentUser.getEmail())) {
throw new BusinessException("当前账号未绑定邮箱");
}
if (!passwordEncoder.matches(form.getPassword(), currentUser.getPassword())) {
throw new BusinessException("当前密码错误");
}
return this.update(new LambdaUpdateWrapper<AppUser>()
.eq(AppUser::getId, currentUserId)
.set(AppUser::getEmail, null)
);
}
/**
* 获取用户选项列表
*
* @return {@link List<Option<String>>} 用户选项列表
*/
// @Override
// public List<Option<String>> listUserOptions() {
// List<AppUser> list = this.list(new LambdaQueryWrapper<AppUser>()
// .eq(AppUser::getStatus, 1)
// );
// return userConverter.toOptions(list);
// }
}

View File

@@ -95,6 +95,7 @@ public class LogAspect {
// 解析 User-Agent
String userAgentStr = request.getHeader("User-Agent");
UserAgent userAgent = UserAgentUtil.parse(userAgentStr);
String sn = request.getHeader("X-Device-SN");
// 解析 IP 地区
String ip = IPUtils.getIpAddr(request);
@@ -121,8 +122,8 @@ public class LogAspect {
logEntity.setActionType(actionType);
logEntity.setTitle(title);
logEntity.setContent(content);
logEntity.setOperatorId(userId);
logEntity.setOperatorName(username);
logEntity.setOperatorId(userId != null ? userId : 99);
logEntity.setOperatorName(username != null ? username : sn);
logEntity.setRequestUri(request.getRequestURI());
logEntity.setRequestMethod(request.getMethod());
logEntity.setIp(ip);

View File

@@ -0,0 +1,46 @@
package com.youlai.boot.common.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
public class FilePath {
// @Value("${file.upload-dir-unix}")
private static final String unixUploadDir = "/data/uploads" ;
// @Value("${file.upload-dir-windows}")
private static final String windowsUploadDir = "uploadFile";
private static Logger logger = LoggerFactory.getLogger(FilePath.class);
public static final String TABLET_PATH = "tablet";
public static final String AVATAR_PATH = "avatar";
public static final String APK_ICON_PATH = "apkIcon";
public static final String SCREENSHOT_PATH = "screenshot";
public static String getRootPath() {
String osName = System.getProperty("os.name");
logger.info("osName: {}", osName);
if (osName.contains("Windows")) {
String projectPath = System.getProperty("user.dir");
logger.info("projectPath: {}", projectPath);
return projectPath + File.separator + windowsUploadDir;
} else {
return unixUploadDir;
}
}
public static String getAvatarPath() {
return getRootPath() + File.separator + TABLET_PATH + File.separator + AVATAR_PATH + File.separator;
}
public static String getApkIconPath() {
return getRootPath() + File.separator + TABLET_PATH + File.separator + APK_ICON_PATH + File.separator;
}
public static String getScreenshotPath() {
return getRootPath() + File.separator + TABLET_PATH + File.separator + SCREENSHOT_PATH + File.separator;
}
}

View File

@@ -31,6 +31,15 @@ public enum ActionTypeEnum implements IBaseEnum<Integer> {
ENABLE(13, "启用"),
DISABLE(14, "禁用"),
LIST(15, "查询列表"),
REGISTER(16, "注册"),
VIEW(17, "查看"),
REFRESH(18, "刷新"),
SCREENSHOT(19, "截图"),
REBOOT(20, "重启"),
SHUTDOWN(21, "关机"),
LOCATE(22, "定位"),
RESTORE(23, "重置"),
DEVELOPER(24, "开发者选项"),
OTHER(99, "其他");
@EnumValue

View File

@@ -27,6 +27,8 @@ public enum LogModuleEnum implements IBaseEnum<Integer> {
NOTICE(9, "通知公告"),
LOG(10, "日志管理"),
CODEGEN(11, "代码生成"),
DEVICE(15, "设备SN管理"),
MOBILE(16, "移动设备管理"),
OTHER(99, "其他");
@EnumValue

View File

@@ -53,10 +53,10 @@ import java.io.Serializable;
public enum ResultCode implements IResultCode, Serializable {
SUCCESS("00000", "成功"),
/** 一级宏观错误码:用户端错误(由客户端输入/认证/权限/请求方式等引起,需客户端配合修正) */
USER_ERROR("A0001", "用户端错误"),
/** 二级宏观错误码:用户端具体错误(按号段细分,便于定位是注册/登录/令牌/参数/防重等问题) */
@@ -98,7 +98,17 @@ public enum ResultCode implements IResultCode, Serializable {
/** A07xx文件处理异常 */
UPLOAD_FILE_EXCEPTION("A0700", "上传文件异常"),
DELETE_FILE_EXCEPTION("A0710", "删除文件异常"),
/** A08xx移动设备认证异常 */
MOBILE_DEVICE_ID_REQUIRED("A0801", "设备标识不能为空"),
MOBILE_NONCE_REQUIRED("A0802", "随机数不能为空"),
MOBILE_TIMESTAMP_REQUIRED("A0803", "时间戳不能为空"),
MOBILE_TIMESTAMP_INVALID("A0804", "时间戳格式无效"),
MOBILE_TIMESTAMP_EXPIRED("A0805", "请求已过期,请重试"),
MOBILE_SIGN_REQUIRED("A0806", "签名不能为空"),
MOBILE_SIGN_INVALID("A0807", "签名验证失败"),
MOBILE_DEVICE_NOT_REGISTERED("A0808", "设备未注册"),
/** 一级宏观错误码:系统端错误(服务端内部异常/超时/不可用等,需后端排查修复) */
SYSTEM_ERROR("B0001", "系统执行出错"),

View File

@@ -0,0 +1,70 @@
package com.youlai.boot.common.util;
import java.security.SecureRandom;
/**
* 验证码工具类
*/
public class CodeGeneratorUtil {
private static final SecureRandom secureRandom = new SecureRandom();
// 数字字符集
private static final String DIGITS = "0123456789";
// 字母字符集
private static final String LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// 数字和字母组合字符集
private static final String ALPHANUMERIC = DIGITS + LETTERS;
/**
* 生成指定长度的纯数字验证码
*
* @param length 验证码长度
* @return 验证码字符串
*/
public static String generateNumericCode(int length) {
return generateCode(DIGITS, length);
}
/**
* 生成指定长度的字母验证码
*
* @param length 验证码长度
* @return 验证码字符串
*/
public static String generateLetterCode(int length) {
return generateCode(LETTERS, length);
}
/**
* 生成指定长度的字母数字混合验证码
*
* @param length 验证码长度
* @return 验证码字符串
*/
public static String generateAlphanumericCode(int length) {
return generateCode(ALPHANUMERIC, length);
}
/**
* 根据指定字符集生成验证码
*
* @param charSet 字符集
* @param length 验证码长度
* @return 验证码字符串
*/
private static String generateCode(String charSet, int length) {
if (length <= 0) {
throw new IllegalArgumentException("验证码长度必须大于0");
}
StringBuilder code = new StringBuilder(length);
for (int i = 0; i < length; i++) {
int index = secureRandom.nextInt(charSet.length());
code.append(charSet.charAt(index));
}
return code.toString();
}
}

View File

@@ -0,0 +1,138 @@
package com.youlai.boot.common.util;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class HashUtils {
public static String calculateMultipartFileMd5(MultipartFile file) throws NoSuchAlgorithmException, IOException {
MessageDigest digest = MessageDigest.getInstance("MD5");
// 使用try-with-resources自动管理输入流
try (InputStream inputStream = file.getInputStream()) {
byte[] buffer = new byte[4096]; // 统一缓冲区大小为4KB
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
byte[] hashBytes = digest.digest();
return bytesToHex(hashBytes); // 复用现有工具方法
}
/**
* 计算 MultipartFile 的 SHA1 哈希值
*
* @param file 上传的文件
* @return SHA1 哈希值(小写十六进制字符串)
* @throws NoSuchAlgorithmException 算法不存在异常
* @throws IOException IO异常
*/
public static String calculateMultipartFileSha1(MultipartFile file) throws NoSuchAlgorithmException, IOException {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
try (InputStream inputStream = file.getInputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
byte[] hashBytes = digest.digest();
return bytesToHex(hashBytes);
}
/**
* 计算 MultipartFile 的 SHA256 哈希值
*
* @param file 上传的文件
* @return SHA256 哈希值(小写十六进制字符串)
* @throws NoSuchAlgorithmException 算法不存在异常
* @throws IOException IO异常
*/
public static String calculateMultipartFileSha256(MultipartFile file) throws NoSuchAlgorithmException, IOException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = file.getInputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
byte[] hashBytes = digest.digest();
return bytesToHex(hashBytes);
}
public static String getFileMD5(File file) throws NoSuchAlgorithmException, IOException {
MessageDigest md = MessageDigest.getInstance("MD5");
FileInputStream fis = new FileInputStream(file);
byte[] dataBytes = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(dataBytes)) != -1) {
md.update(dataBytes, 0, bytesRead);
}
byte[] mdBytes = md.digest();
StringBuilder sb = new StringBuilder();
for (byte mdByte : mdBytes) {
sb.append(Integer.toString((mdByte & 0xff) + 0x100, 16).substring(1));
}
return sb.toString();
}
public static String calculateSHA1(File file) throws NoSuchAlgorithmException, IOException {
InputStream inputStream = new FileInputStream(file);
return calculateSHA1(inputStream);
}
public static String calculateSHA1(InputStream inputStream) throws NoSuchAlgorithmException, IOException {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
byte[] hashBytes = digest.digest();
return bytesToHex(hashBytes, true);
}
public static String calculateSHA256(File file) throws NoSuchAlgorithmException, IOException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = new FileInputStream(file)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
byte[] hashBytes = digest.digest();
return bytesToHex(hashBytes);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = String.format("%02x", b & 0xFF);
/*大写*/
// String hex = String.format("%02X", b & 0xFF);
hexString.append(hex);
}
return hexString.toString();
}
private static String bytesToHex(byte[] bytes, boolean upcase) {
String format = upcase ? "%02X" : "%02x";
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = String.format(format, b & 0xFF);
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@@ -0,0 +1,85 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.device.model.form.ContactForm;
import com.youlai.boot.device.model.vo.ContactVO;
import com.youlai.boot.device.service.ContactService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "16.移动设备管理")
@RestController
@RequestMapping("/api/v1/sn/contact")
@RequiredArgsConstructor
public class ContactController {
private final ContactService contactService;
@Operation(summary = "联系人分页列表")
@GetMapping("/list")
// @PreAuthorize("@ss.hasPerm('sys:contact:list')")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.LIST)
public Result<List<ContactVO>> getContactList(@RequestHeader(value = "X-Device-SN") String sn) {
List<ContactVO> result = contactService.getAllContacts(sn);
return Result.success(result);
}
@Operation(summary = "新增联系人")
@PostMapping("/insert")
// @PreAuthorize("@ss.hasPerm('sys:contact:create')")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.INSERT)
public Result<?> saveContact(
@RequestHeader(value = "X-Device-SN") String sn,
@Valid @RequestBody ContactForm contactForm
) {
Long contactId = contactService.saveContact(sn, contactForm);
if (contactId == null) {
return Result.failed(ResultCode.DUPLICATE_SUBMISSION, "该手机号已存在于当前设备的联系人中", contactForm);
}
return Result.success(contactId);
}
@Operation(summary = "获取联系人表单数据")
@GetMapping("/form")
// @PreAuthorize("@ss.hasPerm('sys:contact:query')")
public Result<ContactForm> getContactForm(
@Parameter(description = "联系人ID") @RequestParam Long id,
@RequestHeader(value = "X-Device-SN") String sn
) {
ContactForm formData = contactService.getContactForm(id, sn);
return Result.success(formData);
}
@Operation(summary = "修改联系人")
@PutMapping("/update")
// @PreAuthorize("@ss.hasPerm('sys:contact:update')")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE)
public Result<?> updateContact(
@Parameter(description = "联系人ID") @RequestParam Long id,
@RequestHeader(value = "X-Device-SN") String sn,
@Valid @RequestBody ContactForm contactForm
) {
return Result.judge(contactService.updateContact(id, sn, contactForm));
}
@Operation(summary = "删除联系人")
@DeleteMapping("/delete")
// @PreAuthorize("@ss.hasPerm('sys:contact:delete')")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DELETE)
public Result<?> deleteContact(
@RequestParam Long id,
@RequestHeader(value = "X-Device-SN") String sn
) {
return Result.judge(contactService.deleteContact(id, sn));
}
}

View File

@@ -0,0 +1,150 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.device.model.entity.SnDeviceInfo;
import com.youlai.boot.device.model.form.DeveloperForm;
import com.youlai.boot.device.model.query.DeviceQuery;
import com.youlai.boot.device.model.vo.DeviceHardwareVO;
import com.youlai.boot.device.model.vo.DevicePageVO;
import com.youlai.boot.device.service.DeviceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* 设备控制层
* @author TTSTD
* @since 2026/04/05
*/
@Tag(name = "15.SN管理")
@RestController
@RequestMapping("/api/v1/device")
@RequiredArgsConstructor
public class DeviceController {
private final DeviceService deviceService;
@Operation(summary = "SN列表")
@GetMapping
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.LIST)
public PageResult<DevicePageVO> getSnList(@Valid DeviceQuery deviceQuery) {
return PageResult.success(deviceService.getSnPage(deviceQuery));
}
@Operation(summary = "获取SN绑定激活信息")
@GetMapping("/{sn}/info")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.VIEW)
// @PreAuthorize("@ss.hasPerm('sys:sn:view')")
public Result<DevicePageVO> getSnBindInfo(@PathVariable String sn) {
DevicePageVO detail = deviceService.getSnBindInfo(sn);
return Result.success(detail);
}
@Operation(summary = "获取SN基础信息")
@GetMapping("/{sn}/hardware_info")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.VIEW)
// @PreAuthorize("@ss.hasPerm('sys:sn:view')")
public Result<DeviceHardwareVO> getSnHardwareInfo(@PathVariable String sn) {
DeviceHardwareVO info = deviceService.getSnHardwareInfo(sn);
return Result.success(info);
}
@Operation(summary = "新增SN")
@PostMapping("/add")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.INSERT)
// @PreAuthorize("@ss.hasPerm('sys:sn:create')")
public Result<Void> addSn(@RequestBody SnDeviceInfo snDeviceInfo) {
deviceService.addSn(snDeviceInfo);
return Result.success();
}
@Operation(summary = "删除SN")
@DeleteMapping("/{sn}")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.DELETE)
@PreAuthorize("@ss.hasPerm('sys:sn:delete')")
public Result<Void> deleteSn(@PathVariable String sn) {
deviceService.deleteSn(sn);
return Result.success();
}
@Operation(summary = "设备刷新")
@PostMapping("/refresh")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.REFRESH)
public Result<?> devicerefresh(@RequestParam String sn) {
boolean result = deviceService.deviceRefresh(sn);
return Result.judge(result);
}
@Operation(summary = "设备截图")
@PostMapping("/screenshot")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.SCREENSHOT)
public Result<?> screenSnapshot(@RequestParam String sn) {
boolean result = deviceService.screenSnapshot(sn);
return Result.judge(result);
}
@Operation(summary = "设备重启")
@PostMapping("/reboot")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.REBOOT)
public Result<?> reboot(@RequestParam String sn) {
boolean result = deviceService.deviceReboot(sn);
return Result.judge(result);
}
@Operation(summary = "设备关机")
@PostMapping("/shutdown")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.SHUTDOWN)
public Result<?> shutdown(@RequestParam String sn) {
boolean result = deviceService.deviceShutdown(sn);
return Result.judge(result);
}
@Operation(summary = "设备定位")
@PostMapping("/locate")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.LOCATE)
public Result<?> deviceLocate(@RequestParam String sn) {
boolean result = deviceService.deviceLocate(sn);
return Result.judge(result);
}
@Operation(summary = "设备重置")
@PostMapping("/restore")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.RESTORE)
public Result<?> deviceRestore(@RequestParam String sn) {
boolean result = deviceService.restore(sn);
return Result.judge(result);
}
@Operation(summary = "开发者模式")
@PostMapping("/developer")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.DEVELOPER)
public Result<?> deviceDeveloper(@RequestParam String sn) {
boolean result = deviceService.setDeviceDeveloper(sn);
return Result.judge(result);
}
@Operation(summary = "新增开发者选项配置")
@PostMapping("/developer/config")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.INSERT)
public Result<Void> addDeveloperConfig(@Valid @RequestBody DeveloperForm developerForm) {
boolean result = deviceService.addDeveloperConfig(
developerForm.getSn(),
developerForm.getDeveloperOptions()
);
return Result.judge(result);
}
@Operation(summary = "删除开发者选项配置")
@DeleteMapping("/developer/config")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.DELETE)
public Result<Void> deleteDeveloperConfig(@RequestParam String sn) {
boolean result = deviceService.deleteDeveloperConfig(sn);
return Result.judge(result);
}
}

View File

@@ -0,0 +1,313 @@
package com.youlai.boot.device.controller;
import cn.hutool.core.util.IdUtil;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.config.FilePath;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.util.HashUtils;
import com.youlai.boot.device.model.entity.SnDeviceHardware;
import com.youlai.boot.device.model.req.ApkInstallInfoReq;
import com.youlai.boot.device.model.req.SnHardwareInfoReq;
import com.youlai.boot.device.model.req.SnLocationReq;
import com.youlai.boot.device.model.entity.SnDeveloper;
import com.youlai.boot.device.model.entity.SnLocation;
import com.youlai.boot.device.model.entity.SnScreenshot;
import com.youlai.boot.device.model.vo.DeveloperOptionsVO;
import com.youlai.boot.device.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.apache.commons.io.FilenameUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 设备控制层
* @author TTSTD
* @since 2026/04/05
*/
@Tag(name = "16.移动设备管理")
@RestController
@RequestMapping("/api/v1/sn")
@RequiredArgsConstructor
public class MobileController {
private static final String DEVICE_SECRET_PREFIX = "device:secret:";
private final RedisTemplate<String, Object> redisTemplate;
private final DeviceService deviceService;
private final ScreenshotService screenshotService;
private final LocationService locationService;
private final DeveloperService developerService;
private final ApkInstallService apkInstallService;
private final HardwareService hardwareService;
private final Logger logger = LoggerFactory.getLogger(MobileController.class);
@Operation(summary = "注册设备")
@PostMapping("/register")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.REGISTER)
public Map<String, Object> registerDevice(@RequestParam() String sn) {
// 生成设备密钥
String deviceSecret = IdUtil.fastSimpleUUID();
// 存储到Redis可根据需要设置过期时间
redisTemplate.opsForValue().set(DEVICE_SECRET_PREFIX + sn, deviceSecret, 365, TimeUnit.DAYS);
Map<String, Object> result = new HashMap<>();
result.put("deviceId", sn);
result.put("deviceSecret", deviceSecret);
result.put("message", "请妥善保管设备密钥用于生成API签名");
return result;
}
@Operation(summary = "上传设备硬件信息")
@PostMapping("/update_hardware_info")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
public Result<Void> updateHardwareInfo(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody SnHardwareInfoReq hardwareInfoReq
) {
try {
if (sn == null || sn.trim().isEmpty()) {
return Result.failed("设备序列号不能为空");
}
if (hardwareInfoReq == null) {
return Result.failed("硬件信息不能为空");
}
SnDeviceHardware hardware = new SnDeviceHardware();
BeanUtils.copyProperties(hardwareInfoReq, hardware);
hardware.setSerialno(sn);
hardware.setUpdateTime(LocalDateTime.now());
SnDeviceHardware existingHardware = hardwareService.lambdaQuery()
.eq(SnDeviceHardware::getSerialno, sn)
.one();
boolean saved;
if (existingHardware != null) {
hardware.setId(existingHardware.getId());
saved = hardwareService.updateById(hardware);
} else {
hardware.setCreateTime(LocalDateTime.now());
saved = hardwareService.save(hardware);
}
if (!saved) {
return Result.failed("保存硬件信息失败");
}
logger.info("硬件信息上传成功, sn: {}", sn);
return Result.success();
} catch (Exception e) {
logger.error("updateHardwareInfo error, sn: {}", sn, e);
return Result.failed("上传硬件信息失败: " + e.getMessage());
}
}
@Operation(summary = "上传设备截图")
@PostMapping("/upload_screenshot")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.SCREENSHOT)
public Result<Void> uploadScreenshot(
@RequestPart(value = "file") MultipartFile file,
@RequestHeader(value = "X-Device-SN") String sn
) {
try {
if (file.isEmpty()) {
return Result.failed("上传文件不能为空");
}
if (sn == null || sn.trim().isEmpty()) {
return Result.failed("设备序列号不能为空");
}
String screenshotPath = FilePath.getScreenshotPath();
logger.info("uploadScreenshot, screenshotPath: {}", screenshotPath);
File fileDir = new File(screenshotPath);
if (!fileDir.exists()) {
boolean created = fileDir.mkdirs();
if (!created) {
logger.error("创建目录失败: {}", screenshotPath);
return Result.failed("创建目录失败");
}
}
String originName = file.getOriginalFilename();
if (originName == null || originName.isEmpty()) {
return Result.failed("文件名无效");
}
String fileExtension = FilenameUtils.getExtension(originName);
String md5 = HashUtils.calculateMultipartFileMd5(file);
String sha1 = HashUtils.calculateMultipartFileSha1(file);
String sha256 = HashUtils.calculateMultipartFileSha256(file);
String fileName = sn + "_" + System.currentTimeMillis() + "_" + md5 + "." + fileExtension;
File destFile = new File(fileDir, fileName);
file.transferTo(destFile);
SnScreenshot screenshotInfo = new SnScreenshot();
screenshotInfo.setSn(sn);
screenshotInfo.setFileName(fileName);
screenshotInfo.setFilePath(screenshotPath + fileName);
screenshotInfo.setFileSize(file.getSize());
screenshotInfo.setFileMd5(md5);
screenshotInfo.setFileSha1(sha1);
screenshotInfo.setFileSha256(sha256);
screenshotInfo.setUploadTime(LocalDateTime.now());
screenshotService.save(screenshotInfo);
logger.info("截图上传成功, sn: {}, fileName: {}", sn, fileName);
return Result.success();
} catch (Exception e) {
logger.error("uploadScreenshot error, sn: {}", sn, e);
return Result.failed("上传截图失败: " + e.getMessage());
}
}
@Operation(summary = "上传设备定位信息")
@PostMapping("/upload_location")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.LOCATE)
public Result<Void> uploadLocation(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody SnLocationReq locationReq
) {
try {
if (sn == null || sn.trim().isEmpty()) {
return Result.failed("设备序列号不能为空");
}
if (locationReq == null) {
return Result.failed("定位信息不能为空");
}
SnLocation location = new SnLocation();
BeanUtils.copyProperties(locationReq, location);
location.setSn(sn);
SnLocation existingLocation = locationService.lambdaQuery()
.eq(SnLocation::getSn, sn)
.one();
boolean saved;
if (existingLocation != null) {
location.setId(existingLocation.getId());
saved = locationService.updateById(location);
} else {
saved = locationService.save(location);
}
if (!saved) {
return Result.failed("保存定位信息失败");
}
logger.info("定位信息上传成功, sn: {}, location: {}", sn, locationReq);
return Result.success();
} catch (Exception e) {
logger.error("uploadLocation error, sn: {}", sn, e);
return Result.failed("上传定位信息失败: " + e.getMessage());
}
}
@Operation(summary = "获取开发者选项开关")
@GetMapping("/get_developer_options")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
public Result<DeveloperOptionsVO> getDeveloperOptions(@RequestHeader(value = "X-Device-SN") String sn) {
try {
if (sn == null || sn.trim().isEmpty()) {
return Result.failed("设备序列号不能为空");
}
// 查询该设备的开发者选项配置
SnDeveloper developerConfig = developerService.lambdaQuery()
.eq(SnDeveloper::getSn, sn)
.one();
DeveloperOptionsVO vo = new DeveloperOptionsVO();
if (developerConfig != null) {
vo.setDeveloperOptions(developerConfig.getDeveloperOptions());
} else {
vo.setDeveloperOptions(0);
}
// 返回开发者选项开关状态
return Result.success(vo);
} catch (Exception e) {
logger.error("getDeveloperOptions error, sn: {}", sn, e);
return Result.failed("获取开发者选项失败: " + e.getMessage());
}
}
@Operation(summary = "上传设备已安装应用列表")
@PostMapping("/upload_install_apks")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
public Result<Void> uploadInstallApks(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody List<ApkInstallInfoReq> apkInfos
) {
try {
if (sn == null || sn.trim().isEmpty()) {
return Result.failed("设备序列号不能为空");
}
if (apkInfos == null || apkInfos.isEmpty()) {
return Result.failed("应用列表不能为空");
}
boolean saved = apkInstallService.saveOrUpdateDeviceApkInfo(sn, apkInfos);
if (!saved) {
return Result.failed("保存应用信息失败");
}
logger.info("应用列表上传成功, sn: {}, count: {}", sn, apkInfos.size());
return Result.success();
} catch (Exception e) {
logger.error("uploadInstallApks error, sn: {}", sn, e);
return Result.failed("上传应用列表失败: " + e.getMessage());
}
}
@Operation(summary = "获取设备已安装应用列表")
@GetMapping("/get_install_apks")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
public Result<List<ApkInstallInfoReq>> getInstallApks(@RequestHeader(value = "X-Device-SN") String sn) {
try {
if (sn == null || sn.trim().isEmpty()) {
return Result.failed("设备序列号不能为空");
}
List<ApkInstallInfoReq> apkInfos = apkInstallService.getDeviceApkInfo(sn);
logger.info("获取应用列表成功, sn: {}, count: {}", sn, apkInfos.size());
return Result.success(apkInfos);
} catch (Exception e) {
logger.error("getInstallApks error, sn: {}", sn, e);
return Result.failed("获取应用列表失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,37 @@
package com.youlai.boot.device.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.device.model.entity.SnContact;
import com.youlai.boot.device.model.form.ContactForm;
import com.youlai.boot.device.model.vo.ContactVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
@Mapper(componentModel = "spring")
public interface ContactConverter {
Page<ContactVO> toPageVo(Page<SnContact> page);
SnContact toEntity(ContactForm contactForm);
ContactForm toForm(SnContact entity);
@Mapping(target = "createTime", source = "createTime", qualifiedByName = "localDateTimeToLong")
@Mapping(target = "updateTime", source = "updateTime", qualifiedByName = "localDateTimeToLong")
ContactVO toVo(SnContact entity);
List<ContactVO> toVoList(List<SnContact> list);
@Named("localDateTimeToLong")
default Long localDateTimeToLong(LocalDateTime dateTime) {
if (dateTime == null) {
return null;
}
return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
}

View File

@@ -0,0 +1,10 @@
package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.device.model.entity.SnContact;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContactMapper extends BaseMapper<SnContact> {
}

View File

@@ -0,0 +1,12 @@
package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.device.model.entity.SnDeveloper;
import org.apache.ibatis.annotations.Mapper;
/**
* 设备开发者选项Mapper
*/
@Mapper
public interface DeveloperMapper extends BaseMapper<SnDeveloper> {
}

View File

@@ -0,0 +1,25 @@
package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.device.model.entity.SnDeviceInfo;
import com.youlai.boot.device.model.query.DeviceQuery;
import com.youlai.boot.device.model.vo.DevicePageVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface DeviceMapper extends BaseMapper<SnDeviceInfo> {
/**
* 获取用户分页列表
*
* @param page 分页参数
* @param queryParams 查询参数
* @return 用户分页列表
*/
@DataPermission(deptAlias = "u", userAlias = "u")
Page<DevicePageVO> getSnPage(Page<DevicePageVO> page, @Param("queryParams") DeviceQuery queryParams);
}

View File

@@ -0,0 +1,9 @@
package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.device.model.entity.SnDeviceHardware;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HardwareMapper extends BaseMapper<SnDeviceHardware> {
}

View File

@@ -0,0 +1,12 @@
package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.device.model.entity.SnLocation;
import org.apache.ibatis.annotations.Mapper;
/**
* 设备定位Mapper
*/
@Mapper
public interface LocationMapper extends BaseMapper<SnLocation> {
}

View File

@@ -0,0 +1,10 @@
package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.device.model.entity.SnScreenshot;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ScreenshotMapper extends BaseMapper<SnScreenshot> {
}

View File

@@ -0,0 +1,68 @@
package com.youlai.boot.device.model.document;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Document(collection = "apk_install_info")
public class ApkInstallDocument {
@Id
private String id;
@Field("sn")
@Indexed(unique = true)
private String sn;
@Field("apk_list")
private List<ApkInfo> apkList;
@Field("create_time")
private LocalDateTime createTime;
@Field("update_time")
private LocalDateTime updateTime;
@Data
public static class ApkInfo {
@Field("package_name")
private String packageName;
@Field("app_name")
private String appName;
@Field("version_name")
private String versionName;
@Field("version_code")
private Long versionCode;
@Field("install_time")
private LocalDateTime installTime;
@Field("last_update_time")
private LocalDateTime lastUpdateTime;
@Field("apk_size")
private Long apkSize;
@Field("data_size")
private Long dataSize;
@Field("cache_size")
private Long cacheSize;
@Field("md5")
private String md5;
@Field("system_app")
private boolean systemApp;
}
}

View File

@@ -0,0 +1,41 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 联系人实体
*/
@TableName("sys_sn_contact")
@Getter
@Setter
public class SnContact extends BaseEntity {
@TableField("name")
private String name;
@TableField("nick_name")
private String nickName;
@TableField("phone_number")
private String phoneNumber;
private String avatar;
private int position;
@TableField("emergency")
private boolean emergency;
@TableField("show_desktop")
private boolean showDesktop;
@TableField("bind_phone")
private String bindPhone;
@TableField("bind_sn")
private String bindSn;
}

View File

@@ -0,0 +1,25 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 设备截图实体
*/
@TableName("sys_sn_developer")
@Getter
@Setter
public class SnDeveloper extends BaseEntity {
/**
* 设备序列号
*/
private String sn;
/**
* 开发者选项开关0关闭1开启
*/
private int developerOptions;
}

View File

@@ -0,0 +1,75 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 设备实体
*/
@TableName("sys_device")
@Getter
@Setter
public class SnDevice extends BaseEntity {
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 性别((1-男 2-女 0-保密)
*/
private Integer gender;
/**
* 密码
*/
private String password;
/**
* 部门ID
*/
private Long deptId;
/**
* 用户头像
*/
private String avatar;
/**
* 联系方式
*/
private String mobile;
/**
* 状态((1-正常 0-禁用)
*/
private Integer status;
/**
* 用户邮箱
*/
private String email;
/**
* 创建人 ID
*/
private Long createBy;
/**
* 更新人 ID
*/
private Long updateBy;
/**
* 是否删除(0-否 1-是)
*/
private Integer isDeleted;
}

View File

@@ -0,0 +1,32 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*/
@TableName("sys_sn_hardware")
@Getter
@Setter
public class SnDeviceHardware extends BaseEntity {
/**
* 设备序列号
*/
private String serialno;
private String snImei;
private String snImsi;
private String snWlanMac;
private String snDeviceMac;
private String snBluetoothMac;
private String snModel;
private String snBrand;
private String snBoard;
private String snAndroidVersion;
private int snAndroidApi;
private String snBuildId;
private String snBuildDisplayId;
}

View File

@@ -0,0 +1,34 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户实体
*/
@TableName("sys_sn")
@Getter
@Setter
public class SnDeviceInfo extends BaseEntity {
/**
* 设备序列号
*/
private String serialno;
private String snModel;
private String snName;
private String snMobile;
private String pushId;
private Integer status;
private LocalDateTime activateTime;
}

View File

@@ -0,0 +1,110 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 设备定位实体
*/
@TableName("sys_sn_location")
@Getter
@Setter
public class SnLocation extends BaseEntity {
/**
* 设备序列号
*/
private String sn;
/**
*国家
*/
private String country;
/**
*国家代码
*/
private String countryCode;
/**
*省
*/
private String province;
/**
*市
*/
private String city;
/**
*城市代码
*/
private String cityCode;
/**
*区
*/
private String district;
/**
*街道
*/
private String street;
/**
*门牌号
*/
private String streetNumber;
/**
*详细地址
*/
private String address;
/**
*区域编码
*/
private String adCode;
/**
*镇
*/
private String town;
/**
*镇级行政区划编码
*/
private String townCode;
/**
*位置描述
*/
private String locationDescribe;
/**
*经度
*/
private String longitude;
/**
*纬度
*/
private String latitude;
/**
*失败原因
*/
private String mapError;
/**
*上次定位成功时间
*/
private LocalDateTime lastSuccessfulTime;
}

View File

@@ -0,0 +1,57 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 设备截图实体
*/
@TableName("sys_sn_screenshot")
@Getter
@Setter
public class SnScreenshot extends BaseEntity {
/**
* 设备序列号
*/
private String sn;
/**
* 文件名称
*/
private String fileName;
/**
* 文件路径
*/
private String filePath;
/**
* 文件大小(字节)
*/
private Long fileSize;
/**
* 文件MD5值
*/
private String fileMd5;
/**
* 文件Sha1值
*/
private String fileSha1;
/**
* 文件Sha256值
*/
private String fileSha256;
/**
* 上传时间戳
*/
private LocalDateTime uploadTime;
}

View File

@@ -0,0 +1,48 @@
package com.youlai.boot.device.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
@Data
@Schema(description = "联系人表单")
public class ContactForm implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "主键ID")
private Long id;
@NotBlank(message = "姓名不能为空")
@Schema(description = "姓名")
private String name;
@Schema(description = "昵称")
private String nickName;
@NotBlank(message = "手机号不能为空")
@Schema(description = "手机号")
private String phoneNumber;
@Schema(description = "头像")
private String avatar;
@Schema(description = "排序")
private int position;
@Schema(description = "是否紧急联系人")
private boolean emergency;
@Schema(description = "是否显示")
private boolean showDesktop;
@Schema(description = "绑定手机号")
private String bindPhone;
@Schema(description = "绑定设备SN")
private String bindSn;
}

View File

@@ -0,0 +1,34 @@
package com.youlai.boot.device.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 设备开发者选项表单
*
* @author TTSTD
* @since 2026-04-27
*/
@Data
@Schema(description = "设备开发者选项Form")
public class DeveloperForm implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "主键ID")
private Long id;
@NotBlank(message = "设备序列号不能为空")
@Schema(description = "设备序列号")
private String sn;
@NotNull(message = "开发者选项开关不能为空")
@Schema(description = "开发者选项开关0关闭1开启")
private Integer developerOptions;
}

View File

@@ -0,0 +1,24 @@
package com.youlai.boot.device.model.query;
import com.youlai.boot.common.base.BaseQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Schema(description = "联系人查询")
public class ContactQuery extends BaseQuery {
// @Schema(description = "关键字(姓名/昵称/手机号)")
// private String keywords;
//
// @Schema(description = "是否紧急联系人")
// private Boolean isEmergency;
//
// @Schema(description = "是否显示")
// private Boolean show;
@Schema(description = "绑定设备SN")
private String bindSn;
}

View File

@@ -0,0 +1,34 @@
package com.youlai.boot.device.model.query;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.youlai.boot.common.base.BaseQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(description = "设备SN对象")
public class DeviceQuery extends BaseQuery {
@Schema(description = "关键字(SN/IMEI/MAC)")
private String keywords;
@Schema(description = "用户状态")
private Integer status;
@Schema(description = "部门ID")
private Long deptId;
@Schema(description = "角色ID")
private List<Long> roleIds;
@Schema(description = "创建时间范围")
private List<String> createTime;
@JsonIgnore
@Schema(hidden = true)
private Boolean isRoot;
}

View File

@@ -0,0 +1,56 @@
package com.youlai.boot.device.model.req;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.annotations.SerializedName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
@Data
@Schema(description = "APK安装信息")
public class ApkInstallInfoReq {
@JsonProperty("package_name")
@Schema(description = "包名")
private String packageName;
@JsonProperty("app_name")
@Schema(description = "应用名称")
private String appName;
@JsonProperty("version_name")
@Schema(description = "版本名称")
private String versionName;
@JsonProperty("version_code")
@Schema(description = "版本号")
private Long versionCode;
@JsonProperty("install_time")
@Schema(description = "安装时间")
private Date installTime;
@JsonProperty("last_update_time")
@Schema(description = "最后更新时间")
private Date lastUpdateTime;
@JsonProperty("apk_size")
@Schema(description = "Apk文件大小")
private long apkSize;
@JsonProperty("data_size")
@Schema(description = "App数据占用空间")
private long dataSize;
@JsonProperty("cache_size")
@Schema(description = "App缓存占用空间")
private long cacheSize;
@JsonProperty("md5")
@Schema(description = "apk MD5")
private String md5;
@JsonProperty("system_app")
@Schema(description = "是否为系统应用")
private boolean systemApp;
}

View File

@@ -0,0 +1,60 @@
package com.youlai.boot.device.model.req;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "设备硬件信息")
@Data
public class SnHardwareInfoReq {
@Schema(description="设备ID")
private Long id;
@Schema(description="SN")
private String serialno;
@Schema(description="WLAN MAC地址")
@JsonProperty("sn_wlan_mac")
private String snWlanMac;
@Schema(description="设备MAC地址")
@JsonProperty("sn_device_mac")
private String snDeviceMac;
@Schema(description="蓝牙MAC地址")
@JsonProperty("sn_bluetooth_mac")
private String snBluetoothMac;
@Schema(description="设备IMEI")
@JsonProperty("sn_imei")
private String snImei;
@Schema(description="设备型号")
@JsonProperty("sn_model")
private String snModel;
@Schema(description="设备品牌")
@JsonProperty("sn_brand")
private String snBrand;
@Schema(description="设备主板")
@JsonProperty("sn_board")
private String snBoard;
@Schema(description="设备Android版本")
@JsonProperty("sn_android_version")
private String snAndroidVersion;
@Schema(description="设备Android API")
@JsonProperty("sn_android_api")
private int snAndroidApi;
@Schema(description="设备构建ID")
@JsonProperty("sn_build_id")
private String snBuildId;
@Schema(description="设备显示ID")
@JsonProperty("sn_build_display_id")
private String snBuildDisplayId;
}

View File

@@ -0,0 +1,68 @@
package com.youlai.boot.device.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 设备定位信息请求DTO
*/
@Data
@Schema(description = "设备定位信息")
public class SnLocationReq {
@Schema(description = "设备序列号")
private String sn;
@Schema(description = "国家")
private String country;
@Schema(description = "国家代码")
private String countryCode;
@Schema(description = "")
private String province;
@Schema(description = "")
private String city;
@Schema(description = "城市代码")
private String cityCode;
@Schema(description = "")
private String district;
@Schema(description = "街道")
private String street;
@Schema(description = "门牌号")
private String streetNumber;
@Schema(description = "详细地址")
private String address;
@Schema(description = "区域编码")
private String adCode;
@Schema(description = "")
private String town;
@Schema(description = "镇级行政区划编码")
private String townCode;
@Schema(description = "位置描述")
private String locationDescribe;
@Schema(description = "经度")
private String longitude;
@Schema(description = "纬度")
private String latitude;
@Schema(description = "失败原因")
private String mapError;
@Schema(description = "上次定位成功时间")
private LocalDateTime lastSuccessfulTime;
}

View File

@@ -0,0 +1,53 @@
package com.youlai.boot.device.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serial;
import java.io.Serializable;
@Data
@Builder
@EqualsAndHashCode(callSuper = false)
@Schema(description = "联系人视图对象")
public class ContactVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "主键ID")
private Long id;
@Schema(description = "姓名")
private String name;
@Schema(description = "昵称")
private String nickName;
@Schema(description = "手机号")
private String phoneNumber;
private String avatar;
private int position;
@Schema(description = "是否紧急联系人")
private boolean emergency;
@Schema(description = "是否显示")
private boolean showDesktop;
@Schema(description = "绑定手机号")
private String bindPhone;
@Schema(description = "绑定设备SN")
private String bindSn;
@Schema(description = "创建时间")
private Long createTime;
@Schema(description = "更新时间")
private Long updateTime;
}

View File

@@ -0,0 +1,12 @@
package com.youlai.boot.device.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "开发者选项响应对象")
@Data
public class DeveloperOptionsVO {
@Schema(description = "开发者选项开关0关闭1开启")
private Integer developerOptions;
}

View File

@@ -0,0 +1,57 @@
package com.youlai.boot.device.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 设备硬件对象
*
* @author haoxr
* @since 2022/1/15 9:41
*/
@Schema(description ="设备硬件对象")
@Data
public class DeviceHardwareVO {
@Schema(description="设备ID")
private Long id;
@Schema(description="SN")
private String serialno;
@Schema(description="WLAN MAC地址")
private String snWlanMac;
@Schema(description="设备MAC地址")
private String snDeviceMac;
@Schema(description="蓝牙MAC地址")
private String snBluetoothMac;
@Schema(description="设备IMEI")
private String snImei;
@Schema(description="设备型号")
private String snModel;
@Schema(description="设备品牌")
private String snBrand;
@Schema(description="设备主板")
private String snBoard;
@Schema(description="设备Android版本")
private String snAndroidVersion;
@Schema(description="设备Android API")
private int snAndroidApi;
@Schema(description="设备构建ID")
private String snBuildId;
@Schema(description="设备显示ID")
private String snBuildDisplayId;
}

View File

@@ -0,0 +1,51 @@
package com.youlai.boot.device.model.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 设备分页视图对象
*
* @author haoxr
* @since 2022/1/15 9:41
*/
@Schema(description ="设备分页对象")
@Data
public class DevicePageVO {
@Schema(description="设备ID")
private Long id;
@Schema(description="SN")
private String serialno;
@Schema(description="设备型号")
private String snModel;
@Schema(description="设备名")
private String snName;
@Schema(description="设备绑定手机")
private String snMobile;
@Schema(description="推送ID")
private String pushId;
@Schema(description="设备状态(1:启用;0:禁用)")
private Integer status;
@Schema(description="是否删除(1:删除;0:未删除)")
private Integer isDelete;
@Schema(description="激活时间")
@JsonFormat(pattern = "yyyy/MM/dd HH:mm")
private LocalDateTime activateTime;
@Schema(description="创建时间")
@JsonFormat(pattern = "yyyy/MM/dd HH:mm")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,15 @@
package com.youlai.boot.device.repository;
import com.youlai.boot.device.model.document.ApkInstallDocument;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ApkInstallRepository extends MongoRepository<ApkInstallDocument, String> {
Optional<ApkInstallDocument> findBySn(String sn);
void deleteBySn(String sn);
}

View File

@@ -0,0 +1,12 @@
package com.youlai.boot.device.service;
import com.youlai.boot.device.model.req.ApkInstallInfoReq;
import java.util.List;
public interface ApkInstallService {
boolean saveOrUpdateDeviceApkInfo(String sn, List<ApkInstallInfoReq> apkInfos);
List<ApkInstallInfoReq> getDeviceApkInfo(String sn);
}

View File

@@ -0,0 +1,25 @@
package com.youlai.boot.device.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.device.model.entity.SnContact;
import com.youlai.boot.device.model.form.ContactForm;
import com.youlai.boot.device.model.query.ContactQuery;
import com.youlai.boot.device.model.vo.ContactVO;
import java.util.List;
public interface ContactService extends IService<SnContact> {
IPage<ContactVO> getContactPage(ContactQuery queryParams);
List<ContactVO> getAllContacts(String sn);
Long saveContact(String sn, ContactForm contactForm);
ContactForm getContactForm(Long id, String sn);
boolean updateContact(Long id, String sn, ContactForm contactForm);
boolean deleteContact(Long id, String sn);
}

View File

@@ -0,0 +1,11 @@
package com.youlai.boot.device.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.device.model.entity.SnDeveloper;
/**
* 设备开发者选项服务接口
*/
public interface DeveloperService extends IService<SnDeveloper> {
}

View File

@@ -0,0 +1,86 @@
package com.youlai.boot.device.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.device.model.entity.SnDeviceInfo;
import com.youlai.boot.device.model.query.DeviceQuery;
import com.youlai.boot.device.model.vo.DeviceHardwareVO;
import com.youlai.boot.device.model.vo.DevicePageVO;
public interface DeviceService extends IService<SnDeviceInfo> {
/**
* 用户分页列表
*
* @return {@link IPage<DevicePageVO>} 用户分页列表
*/
IPage<DevicePageVO> getSnPage(DeviceQuery queryParams);
/**
* 获取设备详情
*
* @param sn 设备序列号
* @return 设备详情信息
*/
DevicePageVO getSnBindInfo(String sn);
/**
* 获取设备硬件信息
*
* @param sn 设备序列号
* @return 设备硬件信息
*/
DeviceHardwareVO getSnHardwareInfo(String sn);
/**
* 添加设备
* @param device 设备信息
* @return 是否成功
*/
boolean addSn(SnDeviceInfo device);
/**
* 删除设备
* @param sn 设备序列号
* @return 是否成功
*/
boolean deleteSn(String sn);
boolean deviceRefresh(String sn);
/**
* 截图
* @param sn 序列号
* @return 是否推送成功
*/
boolean screenSnapshot(String sn);
boolean deviceReboot(String sn);
boolean deviceShutdown(String sn);
boolean deviceLocate(String sn);
/**
* 恢复出厂设置
* @param sn 序列号
* @return 是否推送成功
*/
boolean restore(String sn);
boolean setDeviceDeveloper(String sn);
/**
* 新增开发者选项配置
*
* @param sn 设备序列号
* @param developerOptions 开发者选项开关0关闭1开启
* @return 是否成功
*/
boolean addDeveloperConfig(String sn, Integer developerOptions);
/**
* 删除开发者选项配置
*
* @param sn 设备序列号
* @return 是否成功
*/
boolean deleteDeveloperConfig(String sn);
}

View File

@@ -0,0 +1,7 @@
package com.youlai.boot.device.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.device.model.entity.SnDeviceHardware;
public interface HardwareService extends IService<SnDeviceHardware> {
}

View File

@@ -0,0 +1,11 @@
package com.youlai.boot.device.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.device.model.entity.SnLocation;
/**
* 设备定位服务接口
*/
public interface LocationService extends IService<SnLocation> {
}

View File

@@ -0,0 +1,8 @@
package com.youlai.boot.device.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.device.model.entity.SnScreenshot;
public interface ScreenshotService extends IService<SnScreenshot> {
}

View File

@@ -0,0 +1,4 @@
package com.youlai.boot.device.service;
public interface StorageService {
}

View File

@@ -0,0 +1,93 @@
package com.youlai.boot.device.service.impl;
import com.youlai.boot.device.model.document.ApkInstallDocument;
import com.youlai.boot.device.model.req.ApkInstallInfoReq;
import com.youlai.boot.device.repository.ApkInstallRepository;
import com.youlai.boot.device.service.ApkInstallService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class ApkInstallServiceImpl implements ApkInstallService {
private final ApkInstallRepository apkInstallRepository;
@Override
public boolean saveOrUpdateDeviceApkInfo(String sn, List<ApkInstallInfoReq> apkInfos) {
if (apkInfos == null || apkInfos.isEmpty()) {
return false;
}
Optional<ApkInstallDocument> existingOpt = apkInstallRepository.findBySn(sn);
ApkInstallDocument document;
if (existingOpt.isPresent()) {
document = existingOpt.get();
} else {
document = new ApkInstallDocument();
document.setSn(sn);
document.setCreateTime(LocalDateTime.now());
}
List<ApkInstallDocument.ApkInfo> apkInfoList = apkInfos.stream().map(req -> {
ApkInstallDocument.ApkInfo apkInfo = new ApkInstallDocument.ApkInfo();
BeanUtils.copyProperties(req, apkInfo);
if (req.getInstallTime() != null) {
apkInfo.setInstallTime(LocalDateTime.ofInstant(
req.getInstallTime().toInstant(), ZoneId.systemDefault()));
}
if (req.getLastUpdateTime() != null) {
apkInfo.setLastUpdateTime(LocalDateTime.ofInstant(
req.getLastUpdateTime().toInstant(), ZoneId.systemDefault()));
}
return apkInfo;
}).collect(Collectors.toList());
document.setApkList(apkInfoList);
document.setUpdateTime(LocalDateTime.now());
apkInstallRepository.save(document);
return true;
}
@Override
public List<ApkInstallInfoReq> getDeviceApkInfo(String sn) {
Optional<ApkInstallDocument> documentOpt = apkInstallRepository.findBySn(sn);
if (!documentOpt.isPresent() || documentOpt.get().getApkList() == null) {
return List.of();
}
ApkInstallDocument document = documentOpt.get();
List<ApkInstallDocument.ApkInfo> apkInfoList = document.getApkList();
return apkInfoList.stream().map(apkInfo -> {
ApkInstallInfoReq req = new ApkInstallInfoReq();
BeanUtils.copyProperties(apkInfo, req);
if (apkInfo.getInstallTime() != null) {
req.setInstallTime(Date.from(apkInfo.getInstallTime().atZone(ZoneId.systemDefault()).toInstant()));
}
if (apkInfo.getLastUpdateTime() != null) {
req.setLastUpdateTime(Date.from(apkInfo.getLastUpdateTime().atZone(ZoneId.systemDefault()).toInstant()));
}
return req;
}).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,151 @@
package com.youlai.boot.device.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.device.converter.ContactConverter;
import com.youlai.boot.device.mapper.ContactMapper;
import com.youlai.boot.device.model.entity.SnContact;
import com.youlai.boot.device.model.form.ContactForm;
import com.youlai.boot.device.model.query.ContactQuery;
import com.youlai.boot.device.model.vo.ContactVO;
import com.youlai.boot.device.service.ContactService;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ContactServiceImpl extends ServiceImpl<ContactMapper, SnContact> implements ContactService {
private final ContactConverter contactConverter;
@Override
public IPage<ContactVO> getContactPage(ContactQuery queryParams) {
int pageNum = queryParams.getPageNum();
int pageSize = queryParams.getPageSize();
Page<SnContact> page = new Page<>(pageNum, pageSize);
// String keywords = queryParams.getKeywords();
// Boolean isEmergency = queryParams.getIsEmergency();
// Boolean show = queryParams.getShow();
String bindSn = queryParams.getBindSn();
LambdaQueryWrapper<SnContact> wrapper = new LambdaQueryWrapper<SnContact>()
// .and(StringUtils.isNotBlank(keywords),
// w -> w.like(SnContact::getName, keywords)
// .or()
// .like(SnContact::getNick_name, keywords)
// .or()
// .like(SnContact::getPhone_number, keywords)
// )
// .eq(isEmergency != null, SnContact::is_emergency, isEmergency)
// .eq(show != null, SnContact::is_show, show)
.eq(StringUtils.isNotBlank(bindSn), SnContact::getBindSn, bindSn)
.orderByDesc(SnContact::getCreateTime);
Page<SnContact> contactPage = this.page(page, wrapper);
return contactConverter.toPageVo(contactPage);
}
@Override
public List<ContactVO> getAllContacts(String sn) {
Assert.isTrue(sn != null && !sn.trim().isEmpty(), "设备序列号不能为空");
LambdaQueryWrapper<SnContact> wrapper = new LambdaQueryWrapper<SnContact>()
.eq(SnContact::getBindSn, sn)
.orderByDesc(SnContact::getUpdateTime);
List<SnContact> contacts = this.list(wrapper);
return contactConverter.toVoList(contacts);
}
@Override
public Long saveContact(String sn, ContactForm contactForm) {
Assert.isTrue(sn != null && !sn.trim().isEmpty(), "设备序列号不能为空");
// 检查是否已存在相同的电话号码和设备SN组合
LambdaQueryWrapper<SnContact> wrapper = new LambdaQueryWrapper<SnContact>()
.eq(SnContact::getPhoneNumber, contactForm.getPhoneNumber())
.eq(SnContact::getBindSn, sn);
long count = this.count(wrapper);
if (count > 0) {
// 如果存在重复,返回 null 表示失败
return null;
}
SnContact contact = contactConverter.toEntity(contactForm);
if (contact.getId() < 0) {
contact.setId(null);
}
contact.setBindSn(sn);
if (contact.getCreateTime() == null) {
contact.setCreateTime(LocalDateTime.now());
}
if (contact.getUpdateTime() == null) {
contact.setUpdateTime(LocalDateTime.now());
}
if (contact.getPosition() == 0) {
LambdaQueryWrapper<SnContact> positionWrapper = new LambdaQueryWrapper<SnContact>()
.eq(SnContact::getBindSn, sn)
.orderByDesc(SnContact::getPosition)
.last("LIMIT 1");
SnContact lastContact = this.getOne(positionWrapper);
int newPosition = (lastContact != null) ? lastContact.getPosition() + 1 : 1;
contact.setPosition(newPosition);
}
// contact.setCreateBy(SecurityUtils.getUserId());
if (this.save(contact)) {
return contact.getId();
}
return null;
}
@Override
public ContactForm getContactForm(Long id, String sn) {
Assert.isTrue(sn != null && !sn.trim().isEmpty(), "设备序列号不能为空");
SnContact contact = this.getById(id);
Assert.isTrue(contact != null, "联系人不存在");
Assert.isTrue(sn.equals(contact.getBindSn()), "无权访问该联系人数据");
return contactConverter.toForm(contact);
}
@Override
public boolean updateContact(Long id, String sn, ContactForm contactForm) {
Assert.isTrue(sn != null && !sn.trim().isEmpty(), "设备序列号不能为空");
SnContact existingContact = this.getById(id);
Assert.isTrue(existingContact != null, "联系人不存在");
Assert.isTrue(sn.equals(existingContact.getBindSn()), "无权修改该联系人数据");
SnContact contact = contactConverter.toEntity(contactForm);
contact.setId(id);
contact.setBindSn(sn);
// contact.setUpdateBy(SecurityUtils.getUserId());
return this.updateById(contact);
}
@Override
public boolean deleteContact(Long id, String sn) {
Assert.isTrue(sn != null && !sn.trim().isEmpty(), "设备序列号不能为空");
SnContact contact = this.getById(id);
Assert.isTrue(contact != null, "联系人不存在");
Assert.isTrue(sn.equals(contact.getBindSn()), "无权删除该联系人数据");
return this.removeById(id);
}
}

View File

@@ -0,0 +1,16 @@
package com.youlai.boot.device.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.device.mapper.DeveloperMapper;
import com.youlai.boot.device.model.entity.SnDeveloper;
import com.youlai.boot.device.service.DeveloperService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 设备开发者选项服务实现类
*/
@Service
@RequiredArgsConstructor
public class DeveloperServiceImpl extends ServiceImpl<DeveloperMapper, SnDeveloper> implements DeveloperService {
}

View File

@@ -0,0 +1,282 @@
package com.youlai.boot.device.service.impl;
import cn.jiguang.sdk.api.PushApi;
import cn.jiguang.sdk.bean.push.PushSendParam;
import cn.jiguang.sdk.bean.push.PushSendResult;
import cn.jiguang.sdk.bean.push.message.custom.CustomMessage;
import cn.jiguang.sdk.exception.ApiErrorException;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.device.mapper.DeviceMapper;
import com.youlai.boot.device.model.entity.SnDeviceInfo;
import com.youlai.boot.device.model.entity.SnDeveloper;
import com.youlai.boot.device.model.query.DeviceQuery;
import com.youlai.boot.device.model.vo.DeviceHardwareVO;
import com.youlai.boot.device.model.vo.DevicePageVO;
import com.youlai.boot.device.service.DeveloperService;
import com.youlai.boot.device.service.DeviceService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 设备控制实现类
* @author TTSTD
* @since 2026/04/06
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, SnDeviceInfo> implements DeviceService {
PushApi pushApi = new PushApi.Builder()
.setAppKey("d779178d9900d4fb5d633678")
.setMasterSecret("be0e197d30fec7bec118a70d")
.build();
private final DeveloperService developerService;
@Override
public IPage<DevicePageVO> getSnPage(DeviceQuery queryParams) {
// 参数构建
int pageNum = queryParams.getPageNum();
int pageSize = queryParams.getPageSize();
Page<DevicePageVO> page = new Page<>(pageNum, pageSize);
boolean isRoot = SecurityUtils.isRoot();
queryParams.setIsRoot(isRoot);
// 查询数据
return this.baseMapper.getSnPage(page, queryParams);
}
@Override
public DevicePageVO getSnBindInfo(String sn) {
// 根据SN查询设备详情
LambdaQueryWrapper<SnDeviceInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SnDeviceInfo::getSerialno, sn);
SnDeviceInfo device = this.getOne(wrapper);
if (device == null) {
return null;
}
// 这里可以扩展查询更多详细信息,如部门、角色等
DevicePageVO devicePageVO = new DevicePageVO();
BeanUtils.copyProperties(device, devicePageVO);
return devicePageVO;
}
@Override
public DeviceHardwareVO getSnHardwareInfo(String sn) {
return null;
}
@Override
public boolean addSn(SnDeviceInfo device) {
return false;
}
@Override
public boolean deleteSn(String sn) {
return false;
}
private PushSendParam getSinglePushSendParam(String sn, String type, String title, String content) {
PushSendParam pushSendParam = new PushSendParam();
pushSendParam.setPlatform("android");
Map<String, Set<String>> aaudience = new HashMap<>();
Set<String> aliasSet = new HashSet<>();
aliasSet.add(sn);
aaudience.put("alias", aliasSet);
pushSendParam.setAudience(aaudience);
CustomMessage customMessage = new CustomMessage();
customMessage.setTitle(title);
customMessage.setContentType(type);
customMessage.setContent(content);
customMessage.setExtras(new HashMap<>());
pushSendParam.setCustom(customMessage);
return pushSendParam;
}
@Override
public boolean deviceRefresh(String sn) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, "1", "deviceRefresh", "refresh");
try {
PushSendResult result = pushApi.send(pushSendParam);
log.info("send success:{}", result);
return true;
} catch (ApiErrorException e) {
// 错误信息
int httpStatus = e.getStats(); // HTTP状态码
int errorCode = e.getApiError().getError().getCode(); // 错误码
String errorMessage = e.getApiError().getError().getMessage(); // 错误信息
log.error("send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean screenSnapshot(String sn) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, "2", "screenSnapshot", "screenshot");
try {
PushSendResult result = pushApi.send(pushSendParam);
log.info("send success:{}", result);
return true;
} catch (ApiErrorException e) {
// 错误信息
int httpStatus = e.getStats(); // HTTP状态码
int errorCode = e.getApiError().getError().getCode(); // 错误码
String errorMessage = e.getApiError().getError().getMessage(); // 错误信息
log.error("send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean deviceReboot(String sn) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, "3", "deviceReboot", "reboot");
try {
PushSendResult result = pushApi.send(pushSendParam);
log.info("send success:{}", result);
return true;
} catch (ApiErrorException e) {
// 错误信息
int httpStatus = e.getStats(); // HTTP状态码
int errorCode = e.getApiError().getError().getCode(); // 错误码
String errorMessage = e.getApiError().getError().getMessage(); // 错误信息
log.error("send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean deviceShutdown(String sn) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, "4", "deviceShutdown", "shutdown");
try {
PushSendResult result = pushApi.send(pushSendParam);
log.info("send success:{}", result);
return true;
} catch (ApiErrorException e) {
// 错误信息
int httpStatus = e.getStats(); // HTTP状态码
int errorCode = e.getApiError().getError().getCode(); // 错误码
String errorMessage = e.getApiError().getError().getMessage(); // 错误信息
log.error("send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean deviceLocate(String sn) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, "5", "deviceLocate", "locate");
try {
PushSendResult result = pushApi.send(pushSendParam);
log.info("send success:{}", result);
return true;
} catch (ApiErrorException e) {
// 错误信息
int httpStatus = e.getStats(); // HTTP状态码
int errorCode = e.getApiError().getError().getCode(); // 错误码
String errorMessage = e.getApiError().getError().getMessage(); // 错误信息
log.error("send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean restore(String sn) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, "6", "deviceRestore", "restore");
try {
PushSendResult result = pushApi.send(pushSendParam);
log.info("send success:{}", result);
return true;
} catch (ApiErrorException e) {
// 错误信息
int httpStatus = e.getStats(); // HTTP状态码
int errorCode = e.getApiError().getError().getCode(); // 错误码
String errorMessage = e.getApiError().getError().getMessage(); // 错误信息
log.error("send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean setDeviceDeveloper(String sn) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, "7", "deviceDeveloper", "developer");
try {
PushSendResult result = pushApi.send(pushSendParam);
log.info("send success:{}", result);
return true;
} catch (ApiErrorException e) {
// 错误信息
int httpStatus = e.getStats(); // HTTP状态码
int errorCode = e.getApiError().getError().getCode(); // 错误码
String errorMessage = e.getApiError().getError().getMessage(); // 错误信息
log.error("send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean addDeveloperConfig(String sn, Integer developerOptions) {
try {
// 检查是否已存在该设备的配置
SnDeveloper existingConfig = developerService.lambdaQuery()
.eq(SnDeveloper::getSn, sn)
.one();
SnDeveloper developerConfig = new SnDeveloper();
developerConfig.setSn(sn);
developerConfig.setDeveloperOptions(developerOptions);
boolean result;
if (existingConfig != null) {
// 如果已存在,则更新
developerConfig.setId(existingConfig.getId());
result = developerService.updateById(developerConfig);
log.info("更新开发者选项配置成功, sn: {}, developerOptions: {}", sn, developerOptions);
} else {
// 如果不存在,则新增
result = developerService.save(developerConfig);
log.info("新增开发者选项配置成功, sn: {}, developerOptions: {}", sn, developerOptions);
}
return result;
} catch (Exception e) {
log.error("新增开发者选项配置失败, sn: {}", sn, e);
return false;
}
}
@Override
public boolean deleteDeveloperConfig(String sn) {
try {
boolean result = developerService.lambdaUpdate()
.eq(SnDeveloper::getSn, sn)
.remove();
if (result) {
log.info("删除开发者选项配置成功, sn: {}", sn);
} else {
log.warn("删除开发者选项配置失败,记录不存在, sn: {}", sn);
}
return result;
} catch (Exception e) {
log.error("删除开发者选项配置失败, sn: {}", sn, e);
return false;
}
}
}

View File

@@ -0,0 +1,13 @@
package com.youlai.boot.device.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.device.mapper.HardwareMapper;
import com.youlai.boot.device.model.entity.SnDeviceHardware;
import com.youlai.boot.device.service.HardwareService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class HardwareServiceImpl extends ServiceImpl<HardwareMapper, SnDeviceHardware> implements HardwareService {
}

View File

@@ -0,0 +1,16 @@
package com.youlai.boot.device.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.device.mapper.LocationMapper;
import com.youlai.boot.device.model.entity.SnLocation;
import com.youlai.boot.device.service.LocationService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 设备定位服务实现类
*/
@Service
@RequiredArgsConstructor
public class LocationServiceImpl extends ServiceImpl<LocationMapper, SnLocation> implements LocationService {
}

View File

@@ -0,0 +1,16 @@
package com.youlai.boot.device.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.device.mapper.ScreenshotMapper;
import com.youlai.boot.device.model.entity.SnScreenshot;
import com.youlai.boot.device.service.ScreenshotService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class ScreenshotServiceImpl extends ServiceImpl<ScreenshotMapper, SnScreenshot> implements ScreenshotService {
}

View File

@@ -0,0 +1,26 @@
package com.youlai.boot.framework.integration.sms.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
@Configuration
@ConfigurationProperties(prefix = "sms.tencent")
@Data
public class TencentSmsProperties {
private String secretId;
private String secretKey;
private String regionId;
private String sdkAppId;
private String signName;
private Map<String, String> templates;
}

View File

@@ -1,5 +1,6 @@
package com.youlai.boot.framework.integration.sms.service.impl;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
@@ -12,6 +13,7 @@ import com.youlai.boot.framework.integration.sms.config.AliyunSmsProperties;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
@@ -24,6 +26,7 @@ import java.util.Map;
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AliyunSmsService implements SmsService {
private final AliyunSmsProperties aliyunSmsProperties;
@@ -68,9 +71,23 @@ public class AliyunSmsService implements SmsService {
try {
CommonResponse response = client.getCommonResponse(request);
return response.getHttpResponse().isSuccess();
String data = response.getData();
log.info("阿里云短信响应: {}", data);
// 解析响应判断是否真正发送成功
JSONObject jsonObject = JSONUtil.parseObj(data);
String code = jsonObject.getStr("Code");
String message = jsonObject.getStr("Message");
boolean success = "OK".equals(code);
if (!success) {
log.error("阿里云短信发送失败,手机号: {}, Code: {}, Message: {}", mobile, code, message);
} else {
log.info("阿里云短信发送成功,手机号: {}", mobile);
}
return success;
} catch (ClientException e) {
e.printStackTrace();
log.error("阿里云短信发送异常,手机号: {}, 错误信息: {}", mobile, e.getMessage(), e);
}
return false;
}

View File

@@ -0,0 +1,69 @@
package com.youlai.boot.framework.integration.sms.service.impl;
import cn.hutool.json.JSONUtil;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.common.profile.ClientProfile;
import com.tencentcloudapi.common.profile.HttpProfile;
import com.tencentcloudapi.sms.v20210111.SmsClient;
import com.tencentcloudapi.sms.v20210111.models.SendSmsRequest;
import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse;
import com.youlai.boot.framework.integration.sms.config.TencentSmsProperties;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@Slf4j
@RequiredArgsConstructor
@Primary
public class TencentSmsService implements SmsService {
private final TencentSmsProperties tencentSmsProperties;
@Override
public boolean sendSms(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams) {
try {
String templateCode = tencentSmsProperties.getTemplates().get(smsType.getValue());
Credential cred = new Credential(
tencentSmsProperties.getSecretId(),
tencentSmsProperties.getSecretKey()
);
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("sms.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
SmsClient client = new SmsClient(cred, tencentSmsProperties.getRegionId(), clientProfile);
SendSmsRequest req = new SendSmsRequest();
req.setSmsSdkAppId(tencentSmsProperties.getSdkAppId());
req.setSignName(tencentSmsProperties.getSignName());
req.setTemplateId(templateCode);
String[] phoneNumberSet = {"+86" + mobile};
req.setPhoneNumberSet(phoneNumberSet);
String[] templateParamSet = templateParams.values().toArray(new String[0]);
req.setTemplateParamSet(templateParamSet);
SendSmsResponse resp = client.SendSms(req);
log.info("腾讯云短信发送响应: {}", JSONUtil.toJsonStr(resp));
return "Ok".equals(resp.getSendStatusSet()[0].getCode());
} catch (TencentCloudSDKException e) {
log.error("腾讯云短信发送失败", e);
return false;
}
}
}

View File

@@ -3,6 +3,7 @@ package com.youlai.boot.framework.security.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.security.filter.MobileApiSignatureFilter;
import com.youlai.boot.framework.web.filter.RateLimiterFilter;
import com.youlai.boot.framework.security.filter.CaptchaValidationFilter;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
@@ -67,6 +68,10 @@ public class SecurityConfig {
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();
}
// 移动设备专用接口路径(需要设备签名验证,但不需要用户登录)
requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll();
requestMatcherRegistry.requestMatchers("/api/v1/auth/app/**").permitAll();
// 其他所有请求需登录后访问
requestMatcherRegistry.anyRequest().authenticated();
}
@@ -90,6 +95,8 @@ public class SecurityConfig {
.addFilterBefore(new RateLimiterFilter(redisTemplate, configService), UsernamePasswordAuthenticationFilter.class)
// 验证码校验过滤器
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
// 移动设备API签名验证过滤器仅对 /api/v1/sn/** 路径生效)
.addFilterBefore(new MobileApiSignatureFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
// 验证和解析过滤器
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
.build();

View File

@@ -0,0 +1,187 @@
package com.youlai.boot.framework.security.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.result.ResponseWriter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.function.Predicate;
/**
* 移动设备API签名验证过滤器
* <p>
* 用于验证 /api/v1/sn/* 路径下的移动设备请求
* 验证规则:
* 1. 检查必需的设备标识(deviceId)
* 2. 检查时间戳(timestamp),防止重放攻击
* 3. 验证签名(sign),确保请求未被篡改
*
* @author Ray.Hao
* @since 2026/4/21
*/
public class MobileApiSignatureFilter extends OncePerRequestFilter {
private static final String HEADER_DEVICE_ID = "X-Device-SN";
private static final String HEADER_NONCE = "X-Nonce";
private static final String HEADER_TIMESTAMP = "X-Timestamp";
private static final String HEADER_SIGN = "X-Sign";
/**
* 签名有效期毫秒默认2分钟
*/
private static final long SIGN_VALID_DURATION = 2 * 60 * 1000L;
/**
* 设备密钥前缀
*/
private static final String DEVICE_SECRET_PREFIX = "device:secret:";
private final RedisTemplate<String, Object> redisTemplate;
public MobileApiSignatureFilter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return !requestURI.startsWith("/api/v1/sn/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. 获取设备标识
String deviceId = request.getHeader(HEADER_DEVICE_ID);
if (StrUtil.isBlank(deviceId)) {
ResponseWriter.writeSuccess(response, ResultCode.MOBILE_DEVICE_ID_REQUIRED);
return;
}
// 获取随机数
String nonce = request.getHeader(HEADER_NONCE);
if (StrUtil.isBlank(nonce)) {
ResponseWriter.writeSuccess(response, ResultCode.MOBILE_NONCE_REQUIRED);
return;
}
// 2. 获取时间戳
String timestampStr = request.getHeader(HEADER_TIMESTAMP);
if (StrUtil.isBlank(timestampStr)) {
ResponseWriter.writeSuccess(response, ResultCode.MOBILE_TIMESTAMP_REQUIRED);
return;
}
long timestamp;
try {
timestamp = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
ResponseWriter.writeSuccess(response, ResultCode.MOBILE_TIMESTAMP_INVALID);
return;
}
// 3. 验证时间戳是否在有效期内
long currentTime = System.currentTimeMillis();
if (Math.abs(currentTime - timestamp) > SIGN_VALID_DURATION) {
ResponseWriter.writeSuccess(response, ResultCode.MOBILE_TIMESTAMP_EXPIRED);
return;
}
// 4. 获取签名
String sign = request.getHeader(HEADER_SIGN);
if (StrUtil.isBlank(sign)) {
ResponseWriter.writeSuccess(response, ResultCode.MOBILE_SIGN_REQUIRED);
return;
}
// 5. 获取设备密钥从Redis或数据库
// String deviceSecret = getDeviceSecret(deviceId);
// if (StrUtil.isBlank(deviceSecret)) {
// ResponseWriter.writeSuccess(response, ResultCode.MOBILE_DEVICE_NOT_REGISTERED);
// return;
// }
// 6. 验证签名
String expectedSign = generateSign(request);
logger.info("Expected sign: " + expectedSign);
if (!sign.equals(expectedSign)) {
ResponseWriter.writeSuccess(response, ResultCode.MOBILE_SIGN_INVALID);
return;
}
// 7. 将设备信息存入请求属性,供后续使用
request.setAttribute("deviceId", deviceId);
request.setAttribute("deviceAuthenticated", true);
// 8. 继续过滤器链
filterChain.doFilter(request, response);
}
/**
* 获取设备密钥
*/
private String getDeviceSecret(String deviceId) {
Object secret = redisTemplate.opsForValue().get(DEVICE_SECRET_PREFIX + deviceId);
return secret != null ? secret.toString() : null;
}
/**
* 生成签名
* <p>
* 签名算法MD5(sorted_params + timestamp + deviceSecret)
*/
private String generateSign(HttpServletRequest request, String deviceSecret) {
// 1. 收集所有请求参数
TreeMap<String, String> params = new TreeMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0) {
params.put(key, values[0]);
}
});
// 2. 按字母顺序拼接参数
StringBuilder sb = new StringBuilder();
params.forEach((key, value) -> {
sb.append(key).append("=").append(value).append("&");
});
// 3. 添加时间戳和设备密钥
String timestamp = request.getHeader(HEADER_TIMESTAMP);
sb.append("timestamp=").append(timestamp)
.append("&secret=").append(deviceSecret);
// 4. 生成MD5签名
return DigestUtil.md5Hex(sb.toString(), StandardCharsets.UTF_8);
}
private String generateSign(HttpServletRequest request) {
// 1. 收集所有请求参数
SortedMap<String, String> params = new TreeMap<>();
params.put(HEADER_DEVICE_ID, request.getHeader(HEADER_DEVICE_ID));
params.put(HEADER_NONCE, request.getHeader(HEADER_NONCE));
params.put(HEADER_TIMESTAMP, request.getHeader(HEADER_TIMESTAMP));
// 2. 按字母顺序拼接参数
StringBuilder sb = new StringBuilder();
params.forEach((key, value) -> {
sb.append(key).append("=").append(value).append("&");
});
sb.setLength(sb.length() - 1);
// 3. 生成MD5签名
return DigestUtil.sha256Hex(sb.toString());
}
}

View File

@@ -2,6 +2,7 @@ package com.youlai.boot.framework.web.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;
@@ -41,6 +42,7 @@ public class JacksonConfig {
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(BigInteger.class, ToStringSerializer.instance)
)
// .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.build();
}

View File

@@ -2,19 +2,23 @@ server:
port: 8000
spring:
threads:
virtual:
enabled: true # 开启虚拟线程
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
url: jdbc:mysql://www.youlai.tech:3306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: youlai
password: 123456
url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: root
password: fanhuitong
data:
redis:
database: 0
host: www.youlai.tech
port: 6379
password: 123456
database: 12
host: 175.178.213.60
port: 26379
password: fanhuitong
timeout: 10s
lettuce:
pool:
@@ -50,6 +54,14 @@ spring:
enable: true
# 邮件发送者
from: youlaitech@163.com
mongodb:
host: 175.178.213.60
port: 27027
database: device_apks
username: fht
password: fanhuitong
authentication-database: admin
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
global-config:
@@ -86,6 +98,7 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}
@@ -130,18 +143,29 @@ oss:
sms:
# 阿里云短信
aliyun:
accessKeyId: LTAI5tSMgfxxxxxxdiBJLyR
accessKeySecret: SoOWRqpjtS7xxxxxxZ2PZiMTJOVC
accessKeyId: LTAI5t6DdbXsfbyE91bscHEc
accessKeySecret: s37PIUqflWiQT4FSNiwCSC30Bc5ojf
domain: dysmsapi.aliyuncs.com
regionId: cn-shanghai
signName: 有来技术
regionId: cn-shenzhen
signName: 深圳市壹键通讯科技
templates:
# 注册短信验证码模板
register: SMS_22xxx771
register: SMS_506225577
# 登录短信验证码模板
login: SMS_22xxx772
login: SMS_506225577
# 修改手机号短信验证码模板
change-mobile: SMS_22xxx773
change-mobile: SMS_506225577
tencent:
secretId: AKIDJXDqJk2963sUuAE7oIsQtAD4jANNBmCG
secretKey: znQq58i8NTQAhR2Qi1KRO9i5HG2jDWcX
regionId: ap-guangzhou
sdkAppId: "1401023068"
signName: 深圳市壹键通讯科技
templates:
register: "2510826"
login: "2496464"
change-mobile: "1234569"
# springdoc 配置文档: https://springdoc.org/properties.html
springdoc:

View File

@@ -5,15 +5,15 @@ spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
url: jdbc:mysql://www.youlai.tech:3306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: youlai
password: 123456
url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: root
password: fanhuitong
data:
redis:
database: 1
host: www.youlai.tech
port: 6379
password: 123456
database: 11
host: 175.178.213.60
port: 26379
password: fanhuitong
timeout: 10s
lettuce:
pool:
@@ -49,6 +49,13 @@ spring:
enable: true
# 邮件发送者
from: youlaitech@163.com
mongodb:
host: 175.178.213.60
port: 27027
database: device_apks
username: fht
password: fanhuitong
authentication-database: admin
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
global-config:
@@ -85,6 +92,7 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--suppress ALL -->
<mapper namespace="com.youlai.boot.device.mapper.DeviceMapper">
<!-- 设备SN分页列表 -->
<select id="getSnPage" resultType="com.youlai.boot.device.model.vo.DevicePageVO">
SELECT
u.id,
u.serialno,
u.sn_model,
u.sn_name,
u.push_id,
u.sn_mobile,
u.STATUS,
u.create_time,
u.activate_time,
u.is_deleted
FROM
sys_sn u
GROUP BY
u.id
<choose>
<!-- 如果排序参数都传入 -->
<when test="queryParams.sortBy != null and queryParams.sortBy != '' and queryParams.order != null">
ORDER BY u.${queryParams.sortBy} ${queryParams.order}
</when>
<!-- 默认排序 -->
<otherwise>
ORDER BY u.create_time DESC
</otherwise>
</choose>
</select>
</mapper>