diff --git a/pom.xml b/pom.xml index 6bbc8410..9ef8272c 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,11 @@ spring-boot-starter-data-redis + + org.springframework.boot + spring-boot-starter-data-mongodb + + org.springframework.boot spring-boot-starter-cache @@ -283,6 +288,21 @@ ${dynamic-datasource.version} --> + + + + io.github.jpush + jiguang-sdk + 5.3.0 + + + + + com.tencentcloudapi + tencentcloud-sdk-java-sms + 3.1.1451 + + diff --git a/src/main/java/com/youlai/boot/app/controller/AppAuthController.java b/src/main/java/com/youlai/boot/app/controller/AppAuthController.java new file mode 100644 index 00000000..0461ba1d --- /dev/null +++ b/src/main/java/com/youlai/boot/app/controller/AppAuthController.java @@ -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 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 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 loginByMobile(@RequestBody @Valid MobileLoginReq request) { + AuthenticationToken authenticationToken = appAuthService.loginBySms(request.getMobile(), request.getCode()); + return Result.success(authenticationToken); + } + +} + diff --git a/src/main/java/com/youlai/boot/app/converter/AppUserConverter.java b/src/main/java/com/youlai/boot/app/converter/AppUserConverter.java new file mode 100644 index 00000000..457e0501 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/converter/AppUserConverter.java @@ -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 toOption(AppUser entity); + + List> toOptions(List list); +} diff --git a/src/main/java/com/youlai/boot/app/mapper/AppUserMapper.java b/src/main/java/com/youlai/boot/app/mapper/AppUserMapper.java new file mode 100644 index 00000000..64ee286e --- /dev/null +++ b/src/main/java/com/youlai/boot/app/mapper/AppUserMapper.java @@ -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 { + + /** + * 获取用户分页列表 + * + * @param page 分页参数 + * @param queryParams 查询参数 + * @return 用户分页列表 + */ + @DataPermission(deptAlias = "u", userAlias = "u") + Page getUserPage(Page 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 listExportUsers(UserQuery queryParams); + + /** + * 获取用户个人中心信息 + * + * @param userId 用户ID + * @return 用户个人中心信息 + */ + UserProfileVO getUserProfile(Long userId); + +} diff --git a/src/main/java/com/youlai/boot/app/model/entity/AppUser.java b/src/main/java/com/youlai/boot/app/model/entity/AppUser.java new file mode 100644 index 00000000..88c6ebaa --- /dev/null +++ b/src/main/java/com/youlai/boot/app/model/entity/AppUser.java @@ -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; + +} diff --git a/src/main/java/com/youlai/boot/app/model/form/AppUserForm.java b/src/main/java/com/youlai/boot/app/model/form/AppUserForm.java new file mode 100644 index 00000000..1d16b16a --- /dev/null +++ b/src/main/java/com/youlai/boot/app/model/form/AppUserForm.java @@ -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 roleIds; + +} diff --git a/src/main/java/com/youlai/boot/app/model/req/MobileLoginReq.java b/src/main/java/com/youlai/boot/app/model/req/MobileLoginReq.java new file mode 100644 index 00000000..6fefa762 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/model/req/MobileLoginReq.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/app/model/req/MobileRegisterReq.java b/src/main/java/com/youlai/boot/app/model/req/MobileRegisterReq.java new file mode 100644 index 00000000..388e431d --- /dev/null +++ b/src/main/java/com/youlai/boot/app/model/req/MobileRegisterReq.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/app/service/AppAuthService.java b/src/main/java/com/youlai/boot/app/service/AppAuthService.java new file mode 100644 index 00000000..09018f90 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/service/AppAuthService.java @@ -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); +} + diff --git a/src/main/java/com/youlai/boot/app/service/AppUserService.java b/src/main/java/com/youlai/boot/app/service/AppUserService.java new file mode 100644 index 00000000..620bfe85 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/service/AppUserService.java @@ -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 { + + /** + * 用户分页列表 + * + * @return {@link IPage} 用户分页列表 + */ + IPage 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} 导出用户列表 + */ + List 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>} 用户选项列表 + */ +// List> listUserOptions(); + + /** + * 根据手机号获取用户认证信息 + * + * @param mobile 手机号 + * @return {@link UserAuthInfo} + */ + UserAuthInfo getAuthInfoByMobile(String mobile); + + default UserAuthInfo getAuthCredentialsByMobile(String mobile) { + return getAuthInfoByMobile(mobile); + } + +} diff --git a/src/main/java/com/youlai/boot/app/service/impl/AppAuthServiceImpl.java b/src/main/java/com/youlai/boot/app/service/impl/AppAuthServiceImpl.java new file mode 100644 index 00000000..cbfac5c9 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/service/impl/AppAuthServiceImpl.java @@ -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 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() + .eq(AppUser::getMobile, mobile)); + if (count > 0) { + throw new BusinessException("该手机号已被注册"); + } + + String code = CodeGeneratorUtil.generateNumericCode(6); + + // 发送短信验证码 + Map 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() + .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 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; + } + +} diff --git a/src/main/java/com/youlai/boot/app/service/impl/AppUserServiceImpl.java b/src/main/java/com/youlai/boot/app/service/impl/AppUserServiceImpl.java new file mode 100644 index 00000000..1c02ecbc --- /dev/null +++ b/src/main/java/com/youlai/boot/app/service/impl/AppUserServiceImpl.java @@ -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 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} 用户分页列表 + */ + @Override + public IPage getUserPage(UserQuery queryParams) { + + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page 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() + .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() + .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 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 roles = userAuthInfo.getRoles(); + // 获取数据权限列表(用于并集策略) + List 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 roles = userAuthInfo.getRoles(); + // 获取数据权限列表(用于并集策略) + List dataScopes = roleService.getRoleDataScopes(roles); + userAuthInfo.setDataScopes(dataScopes); + } + return userAuthInfo; + } + + /** + * 获取导出用户列表 + * + * @param queryParams 查询参数 + * @return {@link List} 导出用户列表 + */ + @Override + public List listExportUsers(UserQuery queryParams) { + + boolean isRoot = SecurityUtils.isRoot(); + queryParams.setIsRoot(isRoot); + + List exportUsers = this.baseMapper.listExportUsers(queryParams); + if (CollectionUtil.isNotEmpty(exportUsers)) { + //获取性别的字典项 + Map genderMap = dictItemService.list( + new LambdaQueryWrapper().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() + .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 roles = SecurityUtils.getRoles(); + userInfoVo.setRoles(roles); + + // 用户角色名称集合 + if (CollectionUtil.isNotEmpty(roles)) { + Set roleNames = roleService.list(new LambdaQueryWrapper() + .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 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() + .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() + .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() + .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() + .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 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() + .eq(AppUser::getMobile, mobile) + .ne(AppUser::getId, currentUserId) + ); + if (mobileCount > 0) { + throw new BusinessException("手机号已被其他账号绑定"); + } + + redisTemplate.delete(cacheKey); + + // 更新手机号码 + return this.update( + new LambdaUpdateWrapper() + .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() + .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() + .eq(AppUser::getEmail, email) + .ne(AppUser::getId, currentUserId) + ); + if (emailCount > 0) { + throw new BusinessException("邮箱已被其他账号绑定"); + } + + redisTemplate.delete(redisCacheKey); + + // 更新邮箱地址 + return this.update( + new LambdaUpdateWrapper() + .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() + .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() + .eq(AppUser::getId, currentUserId) + .set(AppUser::getEmail, null) + ); + } + + /** + * 获取用户选项列表 + * + * @return {@link List>} 用户选项列表 + */ +// @Override +// public List> listUserOptions() { +// List list = this.list(new LambdaQueryWrapper() +// .eq(AppUser::getStatus, 1) +// ); +// return userConverter.toOptions(list); +// } + + +} diff --git a/src/main/java/com/youlai/boot/common/aspect/LogAspect.java b/src/main/java/com/youlai/boot/common/aspect/LogAspect.java index 145341c9..81d31082 100644 --- a/src/main/java/com/youlai/boot/common/aspect/LogAspect.java +++ b/src/main/java/com/youlai/boot/common/aspect/LogAspect.java @@ -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); diff --git a/src/main/java/com/youlai/boot/common/config/FilePath.java b/src/main/java/com/youlai/boot/common/config/FilePath.java new file mode 100644 index 00000000..35ea4b60 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/config/FilePath.java @@ -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; + } +} diff --git a/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java b/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java index 3b71821a..db17fc83 100644 --- a/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java @@ -31,6 +31,15 @@ public enum ActionTypeEnum implements IBaseEnum { 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 diff --git a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java index dce1ea3b..d812d6a9 100644 --- a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java @@ -27,6 +27,8 @@ public enum LogModuleEnum implements IBaseEnum { NOTICE(9, "通知公告"), LOG(10, "日志管理"), CODEGEN(11, "代码生成"), + DEVICE(15, "设备SN管理"), + MOBILE(16, "移动设备管理"), OTHER(99, "其他"); @EnumValue diff --git a/src/main/java/com/youlai/boot/common/result/ResultCode.java b/src/main/java/com/youlai/boot/common/result/ResultCode.java index dd7fa3d2..0754f44c 100644 --- a/src/main/java/com/youlai/boot/common/result/ResultCode.java +++ b/src/main/java/com/youlai/boot/common/result/ResultCode.java @@ -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", "系统执行出错"), diff --git a/src/main/java/com/youlai/boot/common/util/CodeGeneratorUtil.java b/src/main/java/com/youlai/boot/common/util/CodeGeneratorUtil.java new file mode 100644 index 00000000..087a7f96 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/util/CodeGeneratorUtil.java @@ -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(); + } +} diff --git a/src/main/java/com/youlai/boot/common/util/HashUtils.java b/src/main/java/com/youlai/boot/common/util/HashUtils.java new file mode 100644 index 00000000..c3ed4c76 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/util/HashUtils.java @@ -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(); + } +} diff --git a/src/main/java/com/youlai/boot/device/controller/ContactController.java b/src/main/java/com/youlai/boot/device/controller/ContactController.java new file mode 100644 index 00000000..7b282250 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/controller/ContactController.java @@ -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> getContactList(@RequestHeader(value = "X-Device-SN") String sn) { + List 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 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)); + } +} diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceController.java b/src/main/java/com/youlai/boot/device/controller/DeviceController.java new file mode 100644 index 00000000..48c27d2c --- /dev/null +++ b/src/main/java/com/youlai/boot/device/controller/DeviceController.java @@ -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 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 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 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 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 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 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 deleteDeveloperConfig(@RequestParam String sn) { + boolean result = deviceService.deleteDeveloperConfig(sn); + return Result.judge(result); + } +} diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java new file mode 100644 index 00000000..775eb735 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java @@ -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 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 registerDevice(@RequestParam() String sn) { + + // 生成设备密钥 + String deviceSecret = IdUtil.fastSimpleUUID(); + + // 存储到Redis(可根据需要设置过期时间) + redisTemplate.opsForValue().set(DEVICE_SECRET_PREFIX + sn, deviceSecret, 365, TimeUnit.DAYS); + + Map 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 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 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 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 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 uploadInstallApks( + @RequestHeader(value = "X-Device-SN") String sn, + @RequestBody List 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> getInstallApks(@RequestHeader(value = "X-Device-SN") String sn) { + try { + if (sn == null || sn.trim().isEmpty()) { + return Result.failed("设备序列号不能为空"); + } + + List 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()); + } + } +} diff --git a/src/main/java/com/youlai/boot/device/converter/ContactConverter.java b/src/main/java/com/youlai/boot/device/converter/ContactConverter.java new file mode 100644 index 00000000..daee298c --- /dev/null +++ b/src/main/java/com/youlai/boot/device/converter/ContactConverter.java @@ -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 toPageVo(Page 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 toVoList(List list); + + @Named("localDateTimeToLong") + default Long localDateTimeToLong(LocalDateTime dateTime) { + if (dateTime == null) { + return null; + } + return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +} diff --git a/src/main/java/com/youlai/boot/device/mapper/ContactMapper.java b/src/main/java/com/youlai/boot/device/mapper/ContactMapper.java new file mode 100644 index 00000000..61c53987 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/mapper/ContactMapper.java @@ -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 { + +} diff --git a/src/main/java/com/youlai/boot/device/mapper/DeveloperMapper.java b/src/main/java/com/youlai/boot/device/mapper/DeveloperMapper.java new file mode 100644 index 00000000..4e6f472b --- /dev/null +++ b/src/main/java/com/youlai/boot/device/mapper/DeveloperMapper.java @@ -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 { +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java b/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java new file mode 100644 index 00000000..3db11714 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java @@ -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 { + + /** + * 获取用户分页列表 + * + * @param page 分页参数 + * @param queryParams 查询参数 + * @return 用户分页列表 + */ + @DataPermission(deptAlias = "u", userAlias = "u") + Page getSnPage(Page page, @Param("queryParams") DeviceQuery queryParams); + +} diff --git a/src/main/java/com/youlai/boot/device/mapper/HardwareMapper.java b/src/main/java/com/youlai/boot/device/mapper/HardwareMapper.java new file mode 100644 index 00000000..5fe9afde --- /dev/null +++ b/src/main/java/com/youlai/boot/device/mapper/HardwareMapper.java @@ -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 { +} diff --git a/src/main/java/com/youlai/boot/device/mapper/LocationMapper.java b/src/main/java/com/youlai/boot/device/mapper/LocationMapper.java new file mode 100644 index 00000000..8abf1924 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/mapper/LocationMapper.java @@ -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 { +} diff --git a/src/main/java/com/youlai/boot/device/mapper/ScreenshotMapper.java b/src/main/java/com/youlai/boot/device/mapper/ScreenshotMapper.java new file mode 100644 index 00000000..8361ecf0 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/mapper/ScreenshotMapper.java @@ -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 { + +} diff --git a/src/main/java/com/youlai/boot/device/model/document/ApkInstallDocument.java b/src/main/java/com/youlai/boot/device/model/document/ApkInstallDocument.java new file mode 100644 index 00000000..a61993c4 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/document/ApkInstallDocument.java @@ -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 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; + + } +} diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnContact.java b/src/main/java/com/youlai/boot/device/model/entity/SnContact.java new file mode 100644 index 00000000..7e17502b --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/SnContact.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java new file mode 100644 index 00000000..034bbdb3 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java b/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java new file mode 100644 index 00000000..d756f82e --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java new file mode 100644 index 00000000..d3264da6 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java new file mode 100644 index 00000000..b3fb2a45 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java b/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java new file mode 100644 index 00000000..d6a09a61 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java @@ -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; + + +} diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java b/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java new file mode 100644 index 00000000..a9b68324 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/form/ContactForm.java b/src/main/java/com/youlai/boot/device/model/form/ContactForm.java new file mode 100644 index 00000000..d096785f --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/form/ContactForm.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/form/DeveloperForm.java b/src/main/java/com/youlai/boot/device/model/form/DeveloperForm.java new file mode 100644 index 00000000..702e369a --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/form/DeveloperForm.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/query/ContactQuery.java b/src/main/java/com/youlai/boot/device/model/query/ContactQuery.java new file mode 100644 index 00000000..652f77b3 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/query/ContactQuery.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/query/DeviceQuery.java b/src/main/java/com/youlai/boot/device/model/query/DeviceQuery.java new file mode 100644 index 00000000..16fe58ff --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/query/DeviceQuery.java @@ -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 roleIds; + + @Schema(description = "创建时间范围") + private List createTime; + + @JsonIgnore + @Schema(hidden = true) + private Boolean isRoot; +} diff --git a/src/main/java/com/youlai/boot/device/model/req/ApkInstallInfoReq.java b/src/main/java/com/youlai/boot/device/model/req/ApkInstallInfoReq.java new file mode 100644 index 00000000..614fa8b2 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/req/ApkInstallInfoReq.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/req/SnHardwareInfoReq.java b/src/main/java/com/youlai/boot/device/model/req/SnHardwareInfoReq.java new file mode 100644 index 00000000..4806f442 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/req/SnHardwareInfoReq.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/req/SnLocationReq.java b/src/main/java/com/youlai/boot/device/model/req/SnLocationReq.java new file mode 100644 index 00000000..36c7136d --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/req/SnLocationReq.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/vo/ContactVO.java b/src/main/java/com/youlai/boot/device/model/vo/ContactVO.java new file mode 100644 index 00000000..291bb770 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/ContactVO.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/vo/DeveloperOptionsVO.java b/src/main/java/com/youlai/boot/device/model/vo/DeveloperOptionsVO.java new file mode 100644 index 00000000..261e1093 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/DeveloperOptionsVO.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/vo/DeviceHardwareVO.java b/src/main/java/com/youlai/boot/device/model/vo/DeviceHardwareVO.java new file mode 100644 index 00000000..cc218e6b --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/DeviceHardwareVO.java @@ -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; + + +} + diff --git a/src/main/java/com/youlai/boot/device/model/vo/DevicePageVO.java b/src/main/java/com/youlai/boot/device/model/vo/DevicePageVO.java new file mode 100644 index 00000000..02078e8a --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/DevicePageVO.java @@ -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; +} + diff --git a/src/main/java/com/youlai/boot/device/repository/ApkInstallRepository.java b/src/main/java/com/youlai/boot/device/repository/ApkInstallRepository.java new file mode 100644 index 00000000..ff2076d9 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/repository/ApkInstallRepository.java @@ -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 { + + Optional findBySn(String sn); + + void deleteBySn(String sn); +} diff --git a/src/main/java/com/youlai/boot/device/service/ApkInstallService.java b/src/main/java/com/youlai/boot/device/service/ApkInstallService.java new file mode 100644 index 00000000..ab0183db --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/ApkInstallService.java @@ -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 apkInfos); + + List getDeviceApkInfo(String sn); +} diff --git a/src/main/java/com/youlai/boot/device/service/ContactService.java b/src/main/java/com/youlai/boot/device/service/ContactService.java new file mode 100644 index 00000000..9ca7edbd --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/ContactService.java @@ -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 { + + IPage getContactPage(ContactQuery queryParams); + + List 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); +} diff --git a/src/main/java/com/youlai/boot/device/service/DeveloperService.java b/src/main/java/com/youlai/boot/device/service/DeveloperService.java new file mode 100644 index 00000000..0c790e62 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/DeveloperService.java @@ -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 { + +} diff --git a/src/main/java/com/youlai/boot/device/service/DeviceService.java b/src/main/java/com/youlai/boot/device/service/DeviceService.java new file mode 100644 index 00000000..569941fe --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/DeviceService.java @@ -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 { + + /** + * 用户分页列表 + * + * @return {@link IPage} 用户分页列表 + */ + IPage 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); +} diff --git a/src/main/java/com/youlai/boot/device/service/HardwareService.java b/src/main/java/com/youlai/boot/device/service/HardwareService.java new file mode 100644 index 00000000..308176e8 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/HardwareService.java @@ -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 { +} diff --git a/src/main/java/com/youlai/boot/device/service/LocationService.java b/src/main/java/com/youlai/boot/device/service/LocationService.java new file mode 100644 index 00000000..44b87f52 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/LocationService.java @@ -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 { + +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/device/service/ScreenshotService.java b/src/main/java/com/youlai/boot/device/service/ScreenshotService.java new file mode 100644 index 00000000..a20df820 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/ScreenshotService.java @@ -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 { + +} diff --git a/src/main/java/com/youlai/boot/device/service/StorageService.java b/src/main/java/com/youlai/boot/device/service/StorageService.java new file mode 100644 index 00000000..706c29d0 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/StorageService.java @@ -0,0 +1,4 @@ +package com.youlai.boot.device.service; + +public interface StorageService { +} diff --git a/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java new file mode 100644 index 00000000..7eea00a3 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java @@ -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 apkInfos) { + if (apkInfos == null || apkInfos.isEmpty()) { + return false; + } + + Optional existingOpt = apkInstallRepository.findBySn(sn); + ApkInstallDocument document; + + if (existingOpt.isPresent()) { + document = existingOpt.get(); + } else { + document = new ApkInstallDocument(); + document.setSn(sn); + document.setCreateTime(LocalDateTime.now()); + } + + List 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 getDeviceApkInfo(String sn) { + Optional documentOpt = apkInstallRepository.findBySn(sn); + + if (!documentOpt.isPresent() || documentOpt.get().getApkList() == null) { + return List.of(); + } + + ApkInstallDocument document = documentOpt.get(); + List 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()); + } +} diff --git a/src/main/java/com/youlai/boot/device/service/impl/ContactServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/ContactServiceImpl.java new file mode 100644 index 00000000..bb433d11 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/ContactServiceImpl.java @@ -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 implements ContactService { + + private final ContactConverter contactConverter; + + @Override + public IPage getContactPage(ContactQuery queryParams) { + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + +// String keywords = queryParams.getKeywords(); +// Boolean isEmergency = queryParams.getIsEmergency(); +// Boolean show = queryParams.getShow(); + String bindSn = queryParams.getBindSn(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() +// .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 contactPage = this.page(page, wrapper); + return contactConverter.toPageVo(contactPage); + } + + + @Override + public List getAllContacts(String sn) { + Assert.isTrue(sn != null && !sn.trim().isEmpty(), "设备序列号不能为空"); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SnContact::getBindSn, sn) + .orderByDesc(SnContact::getUpdateTime); + + List 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 wrapper = new LambdaQueryWrapper() + .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 positionWrapper = new LambdaQueryWrapper() + .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); + } +} diff --git a/src/main/java/com/youlai/boot/device/service/impl/DeveloperServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/DeveloperServiceImpl.java new file mode 100644 index 00000000..e4b6f86d --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/DeveloperServiceImpl.java @@ -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 implements DeveloperService { +} diff --git a/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java new file mode 100644 index 00000000..888e0687 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java @@ -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 implements DeviceService { + PushApi pushApi = new PushApi.Builder() + .setAppKey("d779178d9900d4fb5d633678") + .setMasterSecret("be0e197d30fec7bec118a70d") + .build(); + + private final DeveloperService developerService; + + @Override + public IPage getSnPage(DeviceQuery queryParams) { + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page 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 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> aaudience = new HashMap<>(); + Set 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; + } + } +} diff --git a/src/main/java/com/youlai/boot/device/service/impl/HardwareServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/HardwareServiceImpl.java new file mode 100644 index 00000000..c6822de6 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/HardwareServiceImpl.java @@ -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 implements HardwareService { +} diff --git a/src/main/java/com/youlai/boot/device/service/impl/LocationServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/LocationServiceImpl.java new file mode 100644 index 00000000..63386693 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/LocationServiceImpl.java @@ -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 implements LocationService { +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/device/service/impl/ScreenshotServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/ScreenshotServiceImpl.java new file mode 100644 index 00000000..95af1153 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/ScreenshotServiceImpl.java @@ -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 implements ScreenshotService { + +} diff --git a/src/main/java/com/youlai/boot/framework/integration/sms/config/TencentSmsProperties.java b/src/main/java/com/youlai/boot/framework/integration/sms/config/TencentSmsProperties.java new file mode 100644 index 00000000..c0008bae --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/sms/config/TencentSmsProperties.java @@ -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 templates; +} + diff --git a/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/AliyunSmsService.java b/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/AliyunSmsService.java index 8053b8f2..4edc9f76 100644 --- a/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/AliyunSmsService.java +++ b/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/AliyunSmsService.java @@ -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; } diff --git a/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/TencentSmsService.java b/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/TencentSmsService.java new file mode 100644 index 00000000..fe526961 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/TencentSmsService.java @@ -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 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; + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java b/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java index 190175e6..20d5704d 100644 --- a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java @@ -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(); diff --git a/src/main/java/com/youlai/boot/framework/security/filter/MobileApiSignatureFilter.java b/src/main/java/com/youlai/boot/framework/security/filter/MobileApiSignatureFilter.java new file mode 100644 index 00000000..1a9d2d27 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/filter/MobileApiSignatureFilter.java @@ -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签名验证过滤器 + *

+ * 用于验证 /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 redisTemplate; + + public MobileApiSignatureFilter(RedisTemplate 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; + } + + /** + * 生成签名 + *

+ * 签名算法:MD5(sorted_params + timestamp + deviceSecret) + */ + private String generateSign(HttpServletRequest request, String deviceSecret) { + // 1. 收集所有请求参数 + TreeMap 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 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()); + } +} diff --git a/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java b/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java index 7ca0f637..d3aeed62 100644 --- a/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java +++ b/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java @@ -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(); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a95e360d..024222ac 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -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: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 86085e47..667e433d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -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} diff --git a/src/main/resources/mapper/system/DeviceMapper.xml b/src/main/resources/mapper/system/DeviceMapper.xml new file mode 100644 index 00000000..130c5430 --- /dev/null +++ b/src/main/resources/mapper/system/DeviceMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + +