Merge branch 'master' of gitee.com:youlaiorg/youlai-boot

# Conflicts:
#	src/main/java/com/youlai/boot/config/SecurityConfig.java
#	src/main/java/com/youlai/boot/config/property/TencentSmsProperties.java
#	src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java
#	src/main/java/com/youlai/boot/support/sms/impl/TencentSmsService.java
#	src/main/resources/application-dev.yml
#	src/main/resources/application-prod.yml
This commit is contained in:
TongTongStudio
2026-07-31 02:32:01 +08:00
165 changed files with 1438 additions and 2065 deletions

View File

@@ -3,7 +3,7 @@ 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.framework.annotation.Log;
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;

View File

@@ -4,7 +4,7 @@ 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.framework.annotation.DataPermission;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.query.UserQuery;
import com.youlai.boot.system.model.vo.UserExportVO;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.app.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.app.service;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
public interface AppAuthService {

View File

@@ -4,17 +4,15 @@ 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.auth.model.resp.CaptchaInfo;
import com.youlai.boot.auth.service.CaptchaService;
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 com.youlai.boot.support.sms.SmsService;
import com.youlai.boot.support.sms.SmsTypeEnum;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -44,7 +42,7 @@ public class AppAuthServiceImpl implements AppAuthService {
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final AliyunSmsService smsService;
private final SmsService smsService;
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaService captchaService;
private final AppUserService userService;
@@ -76,7 +74,7 @@ public class AppAuthServiceImpl implements AppAuthService {
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
boolean success = smsService.sendSms(mobile, SmsTypeEnum.REGISTER, templateParams);
boolean success = smsService.send(mobile, SmsTypeEnum.REGISTER, templateParams);
if (success) {
// 缓存验证码至Redis用于注册校验
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_REGISTER_CODE, mobile), code, 5, TimeUnit.MINUTES);
@@ -162,7 +160,7 @@ public class AppAuthServiceImpl implements AppAuthService {
// 方式2: 使用腾讯云短信(需要时取消下面注释,并注释掉上面的阿里云代码)
try {
success = smsService.sendSms(mobile, SmsTypeEnum.LOGIN, templateParams);
success = smsService.send(mobile, SmsTypeEnum.LOGIN, templateParams);
log.info("腾讯云短信发送结果: {}", success ? "成功" : "失败");
} catch (Exception e) {
log.error("腾讯云短信发送异常", e);

View File

@@ -17,13 +17,13 @@ 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.SecurityUser;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.support.mail.EmailService;
import com.youlai.boot.support.sms.SmsService;
import com.youlai.boot.support.sms.SmsTypeEnum;
import com.youlai.boot.system.converter.UserConverter;
import com.youlai.boot.system.enums.DictCodeEnum;
import com.youlai.boot.system.mapper.UserMapper;
@@ -69,7 +69,7 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
private final SmsService smsService;
private final MailService mailService;
private final EmailService mailService;
private final StringRedisTemplate redisTemplate;
@@ -463,7 +463,7 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
boolean result = smsService.sendSms(mobile, SmsTypeEnum.CHANGE_MOBILE, templateParams);
boolean result = smsService.send(mobile, SmsTypeEnum.CHANGE_MOBILE, templateParams);
if (result) {
// 缓存验证码5分钟有效用于更换手机号校验
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.MOBILE_CODE, mobile);
@@ -546,7 +546,7 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
// TODO 为了方便测试,验证码固定为 123456实际开发中在配置了邮箱服务后可以使用上面的随机验证码
String code = "123456";
mailService.sendMail(email, "邮箱验证码", "您的验证码为:" + code + "请在5分钟内使用");
mailService.send(email, "邮箱验证码", "您的验证码为:" + code + "请在5分钟内使用");
// 缓存验证码5分钟有效用于更换邮箱校验
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.EMAIL_CODE, email);
redisTemplate.opsForValue().set(redisCacheKey, code, 5, TimeUnit.MINUTES);

View File

@@ -6,7 +6,6 @@ import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.youlai.boot.app.model.vo.AppFileInfo;
import com.youlai.boot.app.service.AppFileService;
import com.youlai.boot.file.service.FileService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

View File

@@ -1,13 +1,13 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.form.LoginForm;
import com.youlai.boot.auth.model.req.LoginReq;
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.auth.service.AuthService;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -49,32 +49,11 @@ public class AuthController {
@PostMapping("/login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<AuthenticationToken> login(@RequestBody @Valid LoginForm request) {
public Result<AuthenticationToken> login(@RequestBody @Valid LoginReq request) {
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
return Result.success(authenticationToken);
}
@Operation(summary = "短信验证码登录")
@PostMapping("/login/sms")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> loginBySms(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile,
@Parameter(description = "验证码", example = "123456") @RequestParam String code
) {
AuthenticationToken loginResult = authService.loginBySms(mobile, code);
return Result.success(loginResult);
}
@Operation(summary = "发送登录短信验证码")
@PostMapping("/sms/code")
@RateLimit(limit = 1, window = 60)
public Result<Void> sendSmsCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
authService.sendSmsCode(mobile);
return Result.success();
}
@Operation(summary = "退出登录")
@DeleteMapping("/logout")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGOUT)

View File

@@ -0,0 +1,96 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.req.QrCodeTicketReq;
import com.youlai.boot.auth.model.resp.QrCodeGenerateResp;
import com.youlai.boot.auth.model.resp.QrCodeStatusResp;
import com.youlai.boot.auth.service.QrCodeLoginService;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 扫码登录认证接口
*
* <p>generate/status/login 不需要登录态PC 端未登录),由 Security 配置放行;
* scan/confirm/cancel 需要 APP 端登录态,当前用户 ID 从 Security 上下文获取。</p>
*
* @author Ray.Hao
* @since 4.5.0
*/
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/auth/qr-code")
@RequiredArgsConstructor
public class QrCodeAuthController {
private final QrCodeLoginService qrCodeLoginService;
/**
* 生成扫码登录票据
*/
@Operation(summary = "[扫码]生成扫码登录票据")
@PostMapping("/generate")
@RateLimit(limit = 30, window = 60)
public Result<QrCodeGenerateResp> generate(HttpServletRequest request) {
return Result.success(qrCodeLoginService.generate(IPUtils.getIpAddr(request)));
}
/**
* 查询扫码状态
*/
@Operation(summary = "[扫码]查询扫码状态")
@GetMapping("/status")
@RateLimit(limit = 60, window = 60)
public Result<QrCodeStatusResp> status(@RequestParam String ticket) {
return Result.success(qrCodeLoginService.status(ticket));
}
/**
* APP 标记已扫码
*/
@Operation(summary = "[扫码]APP 标记已扫码")
@PostMapping("/scan")
public Result<QrCodeStatusResp> scan(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId()));
}
/**
* APP 确认登录
*/
@Operation(summary = "[扫码]APP 确认登录")
@PostMapping("/confirm")
public Result<QrCodeStatusResp> confirm(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId()));
}
/**
* APP 取消登录
*/
@Operation(summary = "[扫码]APP 取消登录")
@PostMapping("/cancel")
public Result<QrCodeStatusResp> cancel(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId()));
}
/**
* PC 端用票据换取会话令牌
*/
@Operation(summary = "[扫码]PC 端换取会话令牌")
@PostMapping("/login")
public Result<AuthenticationToken> login(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.login(form.getTicket()));
}
}

View File

@@ -0,0 +1,56 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.service.AuthService;
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.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 短信验证码认证接口
*
* @author Ray.Hao
* @since 4.5.0
*/
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/auth/sms")
@RequiredArgsConstructor
@Slf4j
public class SmsAuthController {
private final AuthService authService;
@Operation(summary = "[短信]发送登录短信验证码")
@PostMapping("/code")
@RateLimit(limit = 1, window = 60)
public Result<Void> sendCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
authService.sendSmsCode(mobile);
return Result.success();
}
@Operation(summary = "[短信]短信验证码登录")
@PostMapping("/login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> login(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile,
@Parameter(description = "验证码", example = "123456") @RequestParam String code
) {
AuthenticationToken loginResult = authService.loginBySms(mobile, code);
return Result.success(loginResult);
}
}

View File

@@ -1,11 +1,11 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.form.WxMaBindMobileForm;
import com.youlai.boot.auth.model.form.WxMaPhoneLoginForm;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.model.req.WxMaBindMobileReq;
import com.youlai.boot.auth.model.req.WxMaPhoneLoginReq;
import com.youlai.boot.auth.model.resp.WxMaLoginResp;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
@@ -29,9 +29,9 @@ import jakarta.validation.Valid;
* @author Ray.Hao
* @since 2.4.0
*/
@Tag(name = "13.微信小程序认证")
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/wxma/auth")
@RequestMapping("/api/v1/auth/wxma")
@RequiredArgsConstructor
@Slf4j
public class WxMaAuthController {
@@ -47,15 +47,15 @@ public class WxMaAuthController {
* <li>未绑定手机号的用户:返回 openid需调用绑定手机号接口</li>
* </ul>
*/
@Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token未绑定用户返回 openid 需绑定手机号")
@Operation(summary = "[小程序]静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token未绑定用户返回 openid 需绑定手机号")
@PostMapping("/silent-login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<WxMaLoginVO> silentLogin(
public Result<WxMaLoginResp> silentLogin(
@Parameter(description = "微信登录凭证wx.login 获取)", required = true, example = "0xxx")
@RequestParam String code
) {
WxMaLoginVO result = wxMaAuthService.silentLogin(code);
WxMaLoginResp result = wxMaAuthService.silentLogin(code);
return Result.success(result);
}
@@ -66,11 +66,11 @@ public class WxMaAuthController {
* <p>
* 一步完成登录,无需绑定流程,自动创建新用户
*/
@Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
@Operation(summary = "[小程序]手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
@PostMapping("/phone-login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) {
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginReq req) {
AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode());
return Result.success(result);
}
@@ -82,11 +82,11 @@ public class WxMaAuthController {
* <p>
* 绑定成功后自动完成登录
*/
@Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
@Operation(summary = "[小程序]绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
@PostMapping("/bind-mobile")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileReq req) {
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
return Result.success(result);
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.captcha.enums;
package com.youlai.boot.auth.enums;
/**
* EasyCaptcha 验证码类型

View File

@@ -0,0 +1,28 @@
package com.youlai.boot.auth.enums;
/**
* 扫码登录票据状态
*
* @author Ray.Hao
* @since 4.5.0
*/
public enum QrCodeLoginStatusEnum {
/** 票据已创建,等待 APP 扫码 */
WAITING,
/** APP 已扫码,等待用户在手机上确认 */
SCANNED,
/** 用户已在 APP 上确认登录 */
CONFIRMED,
/** PC 已用票据换取会话令牌,票据作废,不可再用 */
LOGGED_IN,
/** 用户在 APP 上取消登录 */
CANCELED,
/** 票据超时,由 Redis TTL 自动清理 */
EXPIRED
}

View File

@@ -1,10 +1,15 @@
package com.youlai.boot.framework.captcha.exception;
package com.youlai.boot.auth.exception;
import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
* 验证码异常
* 图形验证码校验失败时抛出的异常
* <p>
* 携带 {@link ResultCode} 用于统一错误响应
*
* @author Ray.Hao
* @since 3.1.0
*/
@Getter
public class CaptchaException extends RuntimeException {

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.security.exception;
package com.youlai.boot.auth.exception;
import org.springframework.security.core.AuthenticationException;

View File

@@ -1,11 +1,12 @@
package com.youlai.boot.auth.qrcode.model;
package com.youlai.boot.auth.model.dto;
import lombok.Data;
/**
* 扫码登录票据上下文序列化为 JSON 存入 Redis
* <p>
* 字段说明见 docs/youlai-boot/scan-code-login.md Redis 存储设计
* 扫码登录票据上下文序列化为 JSON 存入 Redis
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
public class QrCodeLoginContext {
@@ -13,7 +14,7 @@ public class QrCodeLoginContext {
/** 票据UUID 无连字符 */
private String ticket;
/** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
/** 状态枚举名,取 {@link com.youlai.boot.auth.enums.QrCodeLoginStatusEnum#name()} */
private String status;
/** 扫码用户 IDscan 时写入 */
@@ -34,6 +35,6 @@ public class QrCodeLoginContext {
/** 确认时间戳(毫秒) */
private Long confirmedAt;
/** generate 时的 PC 端 IP,用于审计 */
/** generate 时的 PC 端 IP */
private String clientIp;
}

View File

@@ -1,15 +1,15 @@
package com.youlai.boot.auth.model.form;
package com.youlai.boot.auth.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录表单
* 登录请求参数
*/
@Schema(description = "登录请求参数")
@Data
public class LoginForm {
public class LoginReq {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
@NotBlank(message = "用户名不能为空")

View File

@@ -0,0 +1,17 @@
package com.youlai.boot.auth.model.req;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 扫码登录票据请求,用于 scan/confirm/cancel/login 接口
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
public class QrCodeTicketReq {
@NotBlank(message = "票据不能为空")
private String ticket;
}

View File

@@ -1,15 +1,15 @@
package com.youlai.boot.auth.model.form;
package com.youlai.boot.auth.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 微信小程序绑定手机号表单
* 微信小程序绑定手机号请求
*/
@Schema(description = "微信小程序绑定手机号请求")
@Data
public class WxMaBindMobileForm {
public class WxMaBindMobileReq {
@NotBlank(message = "openid 不能为空")
@Schema(description = "微信用户唯一标识", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4")

View File

@@ -1,15 +1,15 @@
package com.youlai.boot.auth.model.form;
package com.youlai.boot.auth.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 微信小程序手机号快捷登录表单
* 微信小程序手机号快捷登录请求
*/
@Schema(description = "微信小程序手机号快捷登录请求")
@Data
public class WxMaPhoneLoginForm {
public class WxMaPhoneLoginReq {
@NotBlank(message = "微信登录凭证不能为空")
@Schema(description = "微信登录凭证wx.login 获取)", example = "0xxx")

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.captcha.model;
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;

View File

@@ -1,16 +1,19 @@
package com.youlai.boot.auth.qrcode.model.vo;
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
* generate 接口响应
* generate 接口响应
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
@Builder
@Schema(description = "扫码票据生成结果")
public class QrCodeGenerateVO {
public class QrCodeGenerateResp {
@Schema(description = "票据")
private String ticket;

View File

@@ -1,16 +1,19 @@
package com.youlai.boot.auth.qrcode.model.vo;
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
* status/scan/confirm/cancel 接口响应
* status/scan/confirm/cancel 接口响应
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
@Builder
@Schema(description = "扫码登录状态")
public class QrCodeStatusVO {
public class QrCodeStatusResp {
@Schema(description = "票据")
private String ticket;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.model.vo;
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@@ -14,7 +14,7 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "微信小程序登录响应")
public class WxMaLoginVO {
public class WxMaLoginResp {
@Schema(description = "是否新用户")
private Boolean isNewUser;

View File

@@ -1,89 +0,0 @@
package com.youlai.boot.auth.qrcode.controller;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.qrcode.model.form.QrCodeTicketForm;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 扫码登录接口。
* <p>
* generate/status/login 不需要登录态PC 端未登录),由 Security 配置放行;
* scan/confirm/cancel 需要 APP 端登录态,当前用户 ID 从 Security 上下文获取。
*/
@Tag(name = "02.扫码登录")
@RestController
@RequestMapping("/api/v1/auth/qr-code")
@RequiredArgsConstructor
public class QrCodeLoginController {
private final QrCodeLoginService qrCodeLoginService;
@Operation(summary = "生成扫码登录票据")
@PostMapping("/generate")
@RateLimit(limit = 30, window = 60)
public Result<QrCodeGenerateVO> generate(HttpServletRequest request) {
return Result.success(qrCodeLoginService.generate(getClientIp(request)));
}
@Operation(summary = "查询扫码状态")
@GetMapping("/status")
@RateLimit(limit = 60, window = 60)
public Result<QrCodeStatusVO> status(@RequestParam String ticket) {
return Result.success(qrCodeLoginService.status(ticket));
}
@Operation(summary = "APP 标记已扫码")
@PostMapping("/scan")
public Result<QrCodeStatusVO> scan(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "APP 确认登录")
@PostMapping("/confirm")
public Result<QrCodeStatusVO> confirm(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "APP 取消登录")
@PostMapping("/cancel")
public Result<QrCodeStatusVO> cancel(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "PC 端换取会话令牌")
@PostMapping("/login")
public Result<AuthenticationToken> login(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.login(form.getTicket()));
}
/** 从请求头或连接信息中提取客户端 IP兼容反向代理 */
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
int comma = ip.indexOf(',');
return comma > 0 ? ip.substring(0, comma).trim() : ip.trim();
}
ip = request.getHeader("X-Real-IP");
if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
return ip.trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -1,21 +0,0 @@
package com.youlai.boot.auth.qrcode.model;
/**
* 扫码登录票据状态。
* <p>
* WAITING 票据已创建,等待 APP 扫码
* SCANNED APP 已扫码,等待用户在手机上确认
* CONFIRMED 用户已在 APP 上确认登录
* LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用
* CANCELED 用户在 APP 上取消登录
* EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值
*/
public enum QrCodeLoginStatusEnum {
WAITING,
SCANNED,
CONFIRMED,
LOGGED_IN,
CANCELED,
EXPIRED
}

View File

@@ -1,14 +0,0 @@
package com.youlai.boot.auth.qrcode.model.form;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 扫码登录票据表单,用于 scan/confirm/cancel/login 接口。
*/
@Data
public class QrCodeTicketForm {
@NotBlank(message = "票据不能为空")
private String ticket;
}

View File

@@ -1,29 +0,0 @@
package com.youlai.boot.auth.qrcode.service;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
import com.youlai.boot.framework.security.model.AuthenticationToken;
/**
* 扫码登录服务。
*/
public interface QrCodeLoginService {
/** 生成票据,未登录调用 */
QrCodeGenerateVO generate(String clientIp);
/** 查询状态,未登录调用 */
QrCodeStatusVO status(String ticket);
/** APP 标记已扫码,需 APP 端已登录 */
QrCodeStatusVO scan(String ticket, Long userId);
/** APP 确认登录,需 APP 端已登录 */
QrCodeStatusVO confirm(String ticket, Long userId);
/** APP 取消登录,需 APP 端已登录 */
QrCodeStatusVO cancel(String ticket, Long userId);
/** PC 端用票据换取会话令牌,未登录调用 */
AuthenticationToken login(String ticket);
}

View File

@@ -1,34 +0,0 @@
package com.youlai.boot.auth.qrcode.util;
/**
* 昵称脱敏:保留首尾各一个字符,中间用 * 替换。
* <p>
* 长度 1原样返回
* 长度 2首字 + *
* 长度 ≥3首字 + (n-2) 个 * + 末字
*/
public final class QrCodeNicknameMasker {
private QrCodeNicknameMasker() {
}
public static String mask(String nickname) {
if (nickname == null || nickname.isEmpty()) {
return "";
}
int len = nickname.length();
if (len == 1) {
return nickname;
}
if (len == 2) {
return nickname.charAt(0) + "*";
}
StringBuilder sb = new StringBuilder();
sb.append(nickname.charAt(0));
for (int i = 0; i < len - 2; i++) {
sb.append('*');
}
sb.append(nickname.charAt(len - 1));
return sb.toString();
}
}

View File

@@ -6,8 +6,8 @@ import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.captcha.exception.CaptchaException;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.auth.exception.CaptchaException;
import com.youlai.boot.auth.service.CaptchaService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;

View File

@@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.SmsCaptchaException;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import com.youlai.boot.auth.exception.SmsCaptchaException;
import com.youlai.boot.auth.security.token.SmsAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationProvider;

View File

@@ -7,8 +7,8 @@ import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.common.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.token.WxMaAuthenticationToken;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.security.model;
package com.youlai.boot.auth.security.token;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.security.model;
package com.youlai.boot.auth.security.token;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

View File

@@ -1,56 +1,143 @@
package com.youlai.boot.auth.service;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.token.SmsAuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.support.sms.SmsTypeEnum;
import com.youlai.boot.support.sms.SmsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 认证服务接口
* 认证服务
*
* @author Ray.Hao
* @since 2.4.0
*/
public interface AuthService {
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final SmsService smsService;
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaService captchaService;
/**
* 账号密码登录
* 用户名密码登录
*
* @param username 用户名
* @param password 密码
* @return 认证令牌
* @return 访问令牌
*/
AuthenticationToken login(String username, String password);
public AuthenticationToken login(String username, String password) {
// 1. 创建用于密码认证的令牌(未认证)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username.trim(), password);
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
// 认证通过后返回已认证的 Authenticationprincipal 为 SecurityUserDetailsauthorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationTokenResponse =
tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationTokenResponse;
}
/**
* 发送登录短信验证码
*
* @param mobile 手机号
*/
public void sendSmsCode(String mobile) {
// 随机生成4位验证码
// String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
// TODO 为了方便测试,验证码固定为 1234实际开发中在配置了厂商短信服务后可以使用上面的随机验证码
String code = "1234";
// 发送短信验证码
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
try {
smsService.send(mobile, SmsTypeEnum.LOGIN, templateParams);
} catch (Exception e) {
log.error("发送短信验证码失败", e);
}
// 缓存验证码至Redis用于登录校验
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES);
}
/**
* 短信验证码登录
*
* @param mobile 手机号
* @param code 验证码
* @return 认证令牌
* @return 访问令牌
*/
AuthenticationToken loginBySms(String mobile, String code);
public AuthenticationToken loginBySms(String mobile, String code) {
// 1. 创建用户短信验证码认证的令牌(未认证)
SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobile, code);
// 2. 执行认证(认证中)
Authentication authentication = authenticationManager.authenticate(smsAuthenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationToken;
}
/**
* 发送短信验证码
*
* @param mobile 手机号
* 注销登录
*/
void sendSmsCode(String mobile);
/**
* 退出登录
*/
void logout();
public void logout() {
String token = SecurityUtils.getAccessToken();
if (StrUtil.isNotBlank(token)) {
tokenManager.invalidateToken(token);
// 清除Security上下文
SecurityContextHolder.clearContext();
}
}
/**
* 获取验证码
*/
CaptchaInfo getCaptcha();
public CaptchaInfo getCaptcha() {
return captchaService.generate();
}
/**
* 刷新令牌
* 刷新token
*
* @param refreshToken 刷新令牌
* @return 认证令牌
* @return 新的访问令牌
*/
AuthenticationToken refreshToken(String refreshToken);
public AuthenticationToken refreshToken(String refreshToken) {
return tokenManager.refreshToken(refreshToken);
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.captcha.service;
package com.youlai.boot.auth.service;
import cn.hutool.captcha.AbstractCaptcha;
import cn.hutool.captcha.CaptchaUtil;
@@ -6,11 +6,11 @@ import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.captcha.enums.CaptchaTypeEnum;
import com.youlai.boot.auth.enums.CaptchaTypeEnum;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.captcha.config.CaptchaProperties;
import com.youlai.boot.framework.captcha.exception.CaptchaException;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.config.property.CaptchaProperties;
import com.youlai.boot.auth.exception.CaptchaException;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@@ -20,6 +20,9 @@ import java.util.concurrent.TimeUnit;
/**
* 验证码服务
*
* @author Ray.Hao
* @since 3.1.0
*/
@Service
@RequiredArgsConstructor

View File

@@ -1,16 +1,14 @@
package com.youlai.boot.auth.qrcode.service.impl;
package com.youlai.boot.auth.service;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext;
import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker;
import com.youlai.boot.auth.enums.QrCodeLoginStatusEnum;
import com.youlai.boot.auth.model.dto.QrCodeLoginContext;
import com.youlai.boot.auth.model.resp.QrCodeGenerateResp;
import com.youlai.boot.auth.model.resp.QrCodeStatusResp;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.common.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
@@ -27,23 +25,20 @@ import tools.jackson.databind.json.JsonMapper;
import java.util.concurrent.TimeUnit;
/**
* 扫码登录服务实现
* <p>
* 票据上下文以 JSON 形式存入 RedisTTL 与有效期一致状态流转的合法性全部由
* {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截非法迁移直接抛业务异常
* 因此同一 ticket 被多端并发操作时不需要分布式锁最坏情况是后到的操作在状态校验处失败
* PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)}
* 生成的令牌与账号密码登录走的是同一套会话治理/多设备登出刷新前端无差别处理
* 扫码登录服务
*
* @author Ray.Hao
* @since 4.5.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QrCodeLoginServiceImpl implements QrCodeLoginService {
public class QrCodeLoginService {
/** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */
/** 票据默认有效期(秒) */
private static final int DEFAULT_EXPIRE_SECONDS = 300;
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值 */
private static final int MIN_REMAIN_SECONDS = 30;
/**
@@ -56,8 +51,10 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
private final TokenManager tokenManager;
private final UserSocialService userSocialService;
@Override
public QrCodeGenerateVO generate(String clientIp) {
/**
* 生成扫码票据写入 Redis 后返回 ticket 和有效期
*/
public QrCodeGenerateResp generate(String clientIp) {
String ticket = IdUtil.fastSimpleUUID();
QrCodeLoginContext ctx = new QrCodeLoginContext();
ctx.setTicket(ticket);
@@ -65,60 +62,70 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
ctx.setCreatedAt(System.currentTimeMillis());
ctx.setClientIp(clientIp);
save(ctx, DEFAULT_EXPIRE_SECONDS);
return QrCodeGenerateVO.builder()
return QrCodeGenerateResp.builder()
.ticket(ticket)
.expireSeconds(DEFAULT_EXPIRE_SECONDS)
.build();
}
@Override
public QrCodeStatusVO status(String ticket) {
/**
* PC 端轮询扫码状态未登录调用
*/
public QrCodeStatusResp status(String ticket) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
public QrCodeStatusVO scan(String ticket, Long userId) {
/**
* APP 端标记已扫码需已登录WAITING SCANNED
*/
public QrCodeStatusResp scan(String ticket, Long userId) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
requireStatus(ctx, QrCodeLoginStatusEnum.WAITING);
fillUserInfo(ctx, userId);
ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name());
ctx.setScannedAt(System.currentTimeMillis());
save(ctx, refreshTtl(ticket));
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
public QrCodeStatusVO confirm(String ticket, Long userId) {
/**
* APP 端确认登录需扫码本人操作SCANNED CONFIRMED
*/
public QrCodeStatusResp confirm(String ticket, Long userId) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED);
requireSameUser(ctx, userId);
ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name());
ctx.setConfirmedAt(System.currentTimeMillis());
save(ctx, refreshTtl(ticket));
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
public QrCodeStatusVO cancel(String ticket, Long userId) {
/**
* APP 端取消登录仅流程中的票据可取消
*/
public QrCodeStatusResp cancel(String ticket, Long userId) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
// 只有还在流程中的票据等待扫码 / 已扫码 / 已确认允许取消已登录或已取消的重复操作直接拒绝
// 只有还在流程中的票据等待扫码 / 已扫码 / 已确认允许取消
if (current != QrCodeLoginStatusEnum.WAITING
&& current != QrCodeLoginStatusEnum.SCANNED
&& current != QrCodeLoginStatusEnum.CONFIRMED) {
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
}
// 一旦有人扫过码取消权就归扫码本人防止他人替扫码用户取消
// 一旦有人扫过码取消权就归扫码本人
if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) {
requireSameUser(ctx, userId);
}
ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name());
save(ctx, refreshTtl(ticket));
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
/**
* PC 端用票据换取会话令牌CONFIRMED LOGGED_IN一次性
*/
public AuthenticationToken login(String ticket) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED);
@@ -130,15 +137,15 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
Authentication authentication = new UsernamePasswordAuthenticationToken(
details, null, details.getAuthorities());
AuthenticationToken token = tokenManager.generateToken(authentication);
// 换取令牌成功后立即把票据置为已使用一次性再次 login 会在 requireStatus(CONFIRMED) 处被拒杜绝重放
// 换取令牌成功后立即把票据置为已使用一次性再次 login 会在 requireStatus(CONFIRMED) 处被拒
ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name());
save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS));
return token;
}
// ======================== private ========================
/** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */
/**
* 读取票据上下文票据为空不存在或已过期都视为 QR_CODE_NOT_FOUND
*/
private QrCodeLoginContext loadOrThrow(String ticket) {
if (StrUtil.isBlank(ticket)) {
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
@@ -159,21 +166,27 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
}
}
/** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */
/**
* 当前状态必须等于期望状态否则说明流程被跳步或重复操作
*/
private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) {
if (!expected.name().equals(ctx.getStatus())) {
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
}
}
/** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */
/**
* 操作者必须是当初扫码的那个用户防止 A 扫码 B 确认
*/
private void requireSameUser(QrCodeLoginContext ctx, Long userId) {
if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) {
throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH);
}
}
/** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */
/**
* 扫码时把当前 APP 用户的昵称头像写进上下文 PC status 展示
*/
private void fillUserInfo(QrCodeLoginContext ctx, Long userId) {
SecurityUser info = userSocialService.getAuthInfoByUserId(userId);
if (info == null) {
@@ -184,35 +197,43 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
ctx.setAvatar(info.getAvatar());
}
/** 重写整个上下文并刷新 TTL */
/**
* 重写整个上下文并刷新 TTL
*/
private void save(QrCodeLoginContext ctx, int ttl) {
redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS);
}
/** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */
/**
* 票据在 Redis 中的剩余秒数取不到或已过期返回 0
*/
private int remainingSeconds(String ticket) {
Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS);
return ttl == null ? 0 : Math.max(ttl.intValue(), 0);
}
/** 状态流转时写回的 TTL维持剩余时间不足 MIN_REMAIN_SECONDS 则补足 */
/**
* 状态流转时写回的 TTL维持剩余时间不足 MIN_REMAIN_SECONDS 则补足
*/
private int refreshTtl(String ticket) {
int remain = remainingSeconds(ticket);
return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain;
}
/** 上下文转前端 VO昵称脱敏、用户信息仅在扫码后暴露 */
private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) {
/**
* 上下文转前端 VO昵称脱敏用户信息仅在扫码后暴露
*/
private QrCodeStatusResp toResp(QrCodeLoginContext ctx, int expireSeconds) {
QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
String nickname = null;
String avatar = null;
// WAITING 阶段谁都能查状态此时不能泄露用户信息扫码/确认后才回传脱敏昵称与头像
if (status == QrCodeLoginStatusEnum.SCANNED
|| status == QrCodeLoginStatusEnum.CONFIRMED) {
nickname = QrCodeNicknameMasker.mask(ctx.getNickname());
nickname = maskNickname(ctx.getNickname());
avatar = ctx.getAvatar();
}
return QrCodeStatusVO.builder()
return QrCodeStatusResp.builder()
.ticket(ctx.getTicket())
.status(status.name())
.nickname(nickname)
@@ -221,7 +242,23 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
.build();
}
/** 拼接票据在 Redis 中的完整 Key */
/**
* 昵称脱敏保留首尾各一个字符中间用 * 替换
*/
private String maskNickname(String nickname) {
if (StrUtil.isBlank(nickname)) {
return "";
}
int len = nickname.length();
if (len == 1) {
return nickname;
}
return StrUtil.hide(nickname, 1, len - 1);
}
/**
* 拼接票据在 Redis 中的完整 Key
*/
private String key(String ticket) {
return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket);
}

View File

@@ -1,15 +1,53 @@
package com.youlai.boot.auth.service;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.resp.WxMaLoginResp;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.common.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.token.WxMaAuthenticationToken;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.system.service.UserRoleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collections;
/**
* 微信小程序认证服务接口
* 微信小程序认证服务
*
* @author Ray.Hao
* @since 2.4.0
* @since 4.0.0
*/
public interface WxMaAuthService {
@Service
@RequiredArgsConstructor
@Slf4j
public class WxMaAuthService {
private final WxMaService wxMaService;
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final UserService userService;
private final UserSocialService userSocialService;
private final UserRoleService userRoleService;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 静默登录
@@ -21,21 +59,61 @@ public interface WxMaAuthService {
* @param code 微信登录凭证wx.login 获取)
* @return 登录结果(成功返回 token需绑定返回 openid
*/
WxMaLoginVO silentLogin(String code);
public WxMaLoginResp silentLogin(String code) {
WxMaAuthenticationToken token = new WxMaAuthenticationToken(code);
try {
Authentication authentication = authenticationManager.authenticate(token);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return WxMaLoginResp.builder()
.isNewUser(false)
.needBindMobile(false)
.accessToken(authToken.getAccessToken())
.refreshToken(authToken.getRefreshToken())
.tokenType(authToken.getTokenType())
.expiresIn(authToken.getExpiresIn())
.build();
} catch (MobileNotBoundException e) {
return WxMaLoginResp.builder()
.isNewUser(true)
.needBindMobile(true)
.openid(e.getOpenid())
.build();
}
}
/**
* 手机号快捷登录
* <p>
* 同时使用微信登录凭证和手机号授权凭证,
* 一步完成用户注册/登录,无需额外绑定流程。
* 适用于企业认证的小程序(已开通手机号快捷登录权限)。
* </p>
*
* @param loginCode 微信登录凭证wx.login 获取)
* @param phoneCode 手机号授权凭证getPhoneNumber 事件获取)
* @return 认证令牌
*/
AuthenticationToken phoneLogin(String loginCode, String phoneCode);
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken phoneLogin(String loginCode, String phoneCode) {
// 1. 解析微信登录凭证,获取会话信息
WxMaJscode2SessionResult session = resolveSession(loginCode);
String openid = session.getOpenid();
// 2. 解析手机号授权凭证,获取手机号
String mobile = resolvePhoneNumber(phoneCode);
log.info("微信小程序手机号快捷登录openid={}, mobile={}", openid, mobile);
// 3. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 4. 绑定微信 openid
bindWechatOpenid(user, session);
// 5. 生成认证令牌
return generateAuthToken(mobile);
}
/**
* 绑定手机号
@@ -49,5 +127,139 @@ public interface WxMaAuthService {
* @param smsCode 短信验证码
* @return 认证令牌
*/
AuthenticationToken bindMobile(String openid, String mobile, String smsCode);
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken bindMobile(String openid, String mobile, String smsCode) {
// 1. 验证短信验证码
validateSmsCode(mobile, smsCode);
// 2. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 3. 绑定微信 openid
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
openid,
null, null, null, null
);
log.info("微信小程序绑定手机号成功mobile={}, openid={}", mobile, openid);
// 4. 生成认证令牌
return generateAuthToken(mobile);
}
// ==================== 私有方法 ====================
/**
* 解析微信登录凭证,获取会话信息
*/
private WxMaJscode2SessionResult resolveSession(String loginCode) {
try {
return wxMaService.jsCode2SessionInfo(loginCode);
} catch (Exception e) {
log.error("获取微信会话信息失败loginCode={}", loginCode, e);
throw new IllegalArgumentException("微信登录失败:" + e.getMessage());
}
}
/**
* 解析手机号授权凭证,获取手机号
*/
private String resolvePhoneNumber(String phoneCode) {
try {
WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode);
return phoneInfo.getPhoneNumber();
} catch (Exception e) {
log.error("获取微信手机号失败phoneCode={}", phoneCode, e);
throw new IllegalArgumentException("获取手机号失败:" + e.getMessage());
}
}
/**
* 查询或创建用户
*/
private SysUser findOrCreateUser(String mobile) {
SysUser user = userService.lambdaQuery()
.eq(SysUser::getMobile, mobile)
.one();
if (user == null) {
user = createNewUser(mobile);
log.info("微信小程序登录创建新用户mobile={}, userId={}", mobile, user.getId());
}
return user;
}
/**
* 创建新用户
* <p>
* 新用户默认分配 GUEST访问游客角色
* </p>
*/
private SysUser createNewUser(String mobile) {
SysUser user = new SysUser();
user.setMobile(mobile);
user.setUsername("wx_" + IdUtil.fastSimpleUUID().substring(0, 8));
user.setNickname("微信用户");
user.setStatus(1);
user.setIsDeleted(0);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userService.save(user);
// 分配 GUEST 角色角色ID=3
userRoleService.saveUserRoles(user.getId(), Collections.singletonList(3L));
return user;
}
/**
* 绑定微信 openid
*/
private void bindWechatOpenid(SysUser user, WxMaJscode2SessionResult session) {
try {
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
session.getOpenid(),
session.getUnionid(),
user.getNickname(),
user.getAvatar(),
session.getSessionKey()
);
} catch (Exception e) {
// 绑定失败不影响登录
log.warn("绑定微信 openid 失败userId={}, openid={}", user.getId(), session.getOpenid(), e);
}
}
/**
* 验证短信验证码
*/
private void validateSmsCode(String mobile, String smsCode) {
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (!StrUtil.equals(smsCode, cachedCode)) {
throw new IllegalArgumentException("验证码错误");
}
// 验证成功后删除验证码
redisTemplate.delete(cacheKey);
}
/**
* 生成认证令牌
*/
private AuthenticationToken generateAuthToken(String mobile) {
SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authToken;
}
}

View File

@@ -1,151 +0,0 @@
package com.youlai.boot.auth.service.impl;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
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.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.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 认证服务实现类
*
* @author Ray.Hao
* @since 2.4.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService {
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final SmsService smsService;
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaService captchaService;
/**
* 用户名密码登录
*
* @param username 用户名
* @param password 密码
* @return 访问令牌
*/
@Override
public AuthenticationToken login(String username, String password) {
// 1. 创建用于密码认证的令牌(未认证)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username.trim(), password);
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
// 认证通过后返回已认证的 Authenticationprincipal 为 SecurityUserDetailsauthorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationTokenResponse =
tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationTokenResponse;
}
/**
* 发送登录短信验证码
*
* @param mobile 手机号
*/
@Override
public void sendSmsCode(String mobile) {
// 随机生成4位验证码
// String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
// TODO 为了方便测试,验证码固定为 1234实际开发中在配置了厂商短信服务后可以使用上面的随机验证码
String code = "1234";
// 发送短信验证码
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
try {
smsService.sendSms(mobile, SmsTypeEnum.LOGIN, templateParams);
} catch (Exception e) {
log.error("发送短信验证码失败", e);
}
// 缓存验证码至Redis用于登录校验
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES);
}
/**
* 短信验证码登录
*
* @param mobile 手机号
* @param code 验证码
* @return 访问令牌
*/
@Override
public AuthenticationToken loginBySms(String mobile, String code) {
// 1. 创建用户短信验证码认证的令牌(未认证)
SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobile, code);
// 2. 执行认证(认证中)
Authentication authentication = authenticationManager.authenticate(smsAuthenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationToken;
}
/**
* 注销登录
*/
@Override
public void logout() {
String token = SecurityUtils.getAccessToken();
if (StrUtil.isNotBlank(token)) {
tokenManager.invalidateToken(token);
// 清除Security上下文
SecurityContextHolder.clearContext();
}
}
/**
* 获取验证码
*/
@Override
public CaptchaInfo getCaptcha() {
return captchaService.generate();
}
/**
* 刷新token
*
* @param refreshToken 刷新令牌
* @return 新的访问令牌
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
return tokenManager.refreshToken(refreshToken);
}
}

View File

@@ -1,245 +0,0 @@
package com.youlai.boot.auth.service.impl;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.system.service.UserRoleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collections;
/**
* 微信小程序认证服务实现
*
* @author Ray.Hao
* @since 4.0.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class WxMaAuthServiceImpl implements WxMaAuthService {
private final WxMaService wxMaService;
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final UserService userService;
private final UserSocialService userSocialService;
private final UserRoleService userRoleService;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 静默登录
*/
@Override
public WxMaLoginVO silentLogin(String code) {
WxMaAuthenticationToken token = new WxMaAuthenticationToken(code);
try {
Authentication authentication = authenticationManager.authenticate(token);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return WxMaLoginVO.builder()
.isNewUser(false)
.needBindMobile(false)
.accessToken(authToken.getAccessToken())
.refreshToken(authToken.getRefreshToken())
.tokenType(authToken.getTokenType())
.expiresIn(authToken.getExpiresIn())
.build();
} catch (MobileNotBoundException e) {
return WxMaLoginVO.builder()
.isNewUser(true)
.needBindMobile(true)
.openid(e.getOpenid())
.build();
}
}
/**
* 手机号快捷登录
*/
@Override
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken phoneLogin(String loginCode, String phoneCode) {
// 1. 解析微信登录凭证,获取会话信息
WxMaJscode2SessionResult session = resolveSession(loginCode);
String openid = session.getOpenid();
// 2. 解析手机号授权凭证,获取手机号
String mobile = resolvePhoneNumber(phoneCode);
log.info("微信小程序手机号快捷登录openid={}, mobile={}", openid, mobile);
// 3. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 4. 绑定微信 openid
bindWechatOpenid(user, session);
// 5. 生成认证令牌
return generateAuthToken(mobile);
}
/**
* 绑定手机号
*/
@Override
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken bindMobile(String openid, String mobile, String smsCode) {
// 1. 验证短信验证码
validateSmsCode(mobile, smsCode);
// 2. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 3. 绑定微信 openid
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
openid,
null, null, null, null
);
log.info("微信小程序绑定手机号成功mobile={}, openid={}", mobile, openid);
// 4. 生成认证令牌
return generateAuthToken(mobile);
}
// ==================== 私有方法 ====================
/**
* 解析微信登录凭证,获取会话信息
*/
private WxMaJscode2SessionResult resolveSession(String loginCode) {
try {
return wxMaService.jsCode2SessionInfo(loginCode);
} catch (Exception e) {
log.error("获取微信会话信息失败loginCode={}", loginCode, e);
throw new IllegalArgumentException("微信登录失败:" + e.getMessage());
}
}
/**
* 解析手机号授权凭证,获取手机号
*/
private String resolvePhoneNumber(String phoneCode) {
try {
WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode);
return phoneInfo.getPhoneNumber();
} catch (Exception e) {
log.error("获取微信手机号失败phoneCode={}", phoneCode, e);
throw new IllegalArgumentException("获取手机号失败:" + e.getMessage());
}
}
/**
* 查询或创建用户
*/
private SysUser findOrCreateUser(String mobile) {
SysUser user = userService.lambdaQuery()
.eq(SysUser::getMobile, mobile)
.one();
if (user == null) {
user = createNewUser(mobile);
log.info("微信小程序登录创建新用户mobile={}, userId={}", mobile, user.getId());
}
return user;
}
/**
* 创建新用户
* <p>
* 新用户默认分配 GUEST访问游客角色
* </p>
*/
private SysUser createNewUser(String mobile) {
SysUser user = new SysUser();
user.setMobile(mobile);
user.setUsername("wx_" + IdUtil.fastSimpleUUID().substring(0, 8));
user.setNickname("微信用户");
user.setStatus(1);
user.setIsDeleted(0);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userService.save(user);
// 分配 GUEST 角色角色ID=3
userRoleService.saveUserRoles(user.getId(), Collections.singletonList(3L));
return user;
}
/**
* 绑定微信 openid
*/
private void bindWechatOpenid(SysUser user, WxMaJscode2SessionResult session) {
try {
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
session.getOpenid(),
session.getUnionid(),
user.getNickname(),
user.getAvatar(),
session.getSessionKey()
);
} catch (Exception e) {
// 绑定失败不影响登录
log.warn("绑定微信 openid 失败userId={}, openid={}", user.getId(), session.getOpenid(), e);
}
}
/**
* 验证短信验证码
*/
private void validateSmsCode(String mobile, String smsCode) {
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (!StrUtil.equals(smsCode, cachedCode)) {
throw new IllegalArgumentException("验证码错误");
}
// 验证成功后删除验证码
redisTemplate.delete(cacheKey);
}
/**
* 生成认证令牌
*/
private AuthenticationToken generateAuthToken(String mobile) {
SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authToken;
}
}

View File

@@ -11,7 +11,7 @@ import com.youlai.boot.codegen.model.form.GenConfigForm;
import com.youlai.boot.codegen.model.query.TableQuery;
import com.youlai.boot.codegen.model.vo.CodegenPreviewVO;
import com.youlai.boot.codegen.model.vo.TablePageVO;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.codegen.service.GenTableService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.codegen.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.codegen.enums.FormTypeEnum;
import com.youlai.boot.codegen.enums.QueryTypeEnum;
import lombok.Getter;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.annotation;
package com.youlai.boot.common.annotation;
import java.lang.annotation.*;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.annotation;
package com.youlai.boot.common.annotation;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.annotation;
package com.youlai.boot.common.annotation;
import java.lang.annotation.*;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.annotation;
package com.youlai.boot.common.annotation;
import java.lang.annotation.*;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.annotation;
package com.youlai.boot.common.annotation;
import com.youlai.boot.common.validator.FieldValidator;
import jakarta.validation.Constraint;

View File

@@ -1,9 +1,9 @@
package com.youlai.boot.framework.web.aspect;
package com.youlai.boot.common.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import com.youlai.boot.framework.annotation.Log;
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.util.IPUtils;
@@ -37,6 +37,8 @@ import java.util.concurrent.Executor;
@Slf4j
public class LogAspect {
private static final int MAX_ERROR_MSG_LENGTH = 1000;
private final LogService logService;
private final Executor operationLogExecutor;
@@ -66,10 +68,6 @@ public class LogAspect {
throw e;
} finally {
long elapsed = System.currentTimeMillis() - start;
if (userId == null) {
userId = SecurityUtils.getUserId();
username = SecurityUtils.getUsername();
}
try {
SysLog entity = buildEntity(logAnnotation, elapsed, failure, userId, username);
if (entity != null) {
@@ -94,7 +92,6 @@ public class LogAspect {
HttpServletRequest req = attrs.getRequest();
UserAgent ua = UserAgentUtil.parse(req.getHeader("User-Agent"));
String ip = IPUtils.getIpAddr(req);
IpRegion region = parseRegion(IPUtils.getRegion(ip));
LogModuleEnum module = ann.module();
ActionTypeEnum action = ann.value();
@@ -109,25 +106,34 @@ public class LogAspect {
entity.setRequestUri(req.getRequestURI());
entity.setRequestMethod(req.getMethod());
entity.setIp(ip);
entity.setProvince(region.province);
entity.setCity(region.city);
entity.setDevice(getDevice(ua));
entity.setOs(getOs(ua));
entity.setBrowser(getBrowser(ua));
entity.setStatus(failure == null ? 1 : 0);
entity.setErrorMsg(failure == null ? null : failure.getMessage());
entity.setErrorMsg(failure == null ? null : truncate(failure.getMessage(), MAX_ERROR_MSG_LENGTH));
entity.setExecutionTime((int) Math.min(elapsed, Integer.MAX_VALUE));
entity.setCreateTime(LocalDateTime.now());
IpRegion region = parseRegion(ip);
entity.setProvince(region.province);
entity.setCity(region.city);
return entity;
}
private IpRegion parseRegion(String region) {
private IpRegion parseRegion(String ip) {
String region = IPUtils.getRegion(ip);
if (StrUtil.isBlank(region)) return IpRegion.EMPTY;
String[] parts = region.split("\\|");
if (parts.length < 4) return IpRegion.EMPTY;
return new IpRegion(StrUtil.blankToDefault(parts[2], null), StrUtil.blankToDefault(parts[3], null));
}
private String truncate(String msg, int maxLen) {
if (msg == null) return null;
return msg.length() <= maxLen ? msg : msg.substring(0, maxLen);
}
private String getOs(UserAgent ua) {
return Optional.ofNullable(ua).map(UserAgent::getOs).map(os -> os.getName()).orElse(null);
}

View File

@@ -1,14 +1,14 @@
package com.youlai.boot.framework.web.aspect;
package com.youlai.boot.common.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.web.config.RateLimitProperties;
import com.youlai.boot.framework.web.exception.RateLimitException;
import com.youlai.boot.config.property.RateLimitProperties;
import com.youlai.boot.common.exception.RateLimitException;
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.web.aspect;
package com.youlai.boot.common.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
@@ -7,7 +7,7 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.annotation.RepeatSubmit;
import com.youlai.boot.common.util.IPUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.base;
package com.youlai.boot.common.base;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.common.base;
import com.youlai.boot.framework.annotation.ValidField;
import com.youlai.boot.common.annotation.ValidField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

View File

@@ -5,7 +5,12 @@ import com.youlai.boot.common.base.IBaseEnum;
import lombok.Getter;
/**
* 第三方登录平台类型枚举
* 第三方登录平台类型枚举
* <p>
* value 作为 MyBatis-Plus 枚举值持久化到数据库。
*
* @author Ray.Hao
* @since 2.17.0
*/
@Getter
public enum SocialPlatformEnum implements IBaseEnum<String> {

View File

@@ -1,9 +1,14 @@
package com.youlai.boot.auth.security.exception;
package com.youlai.boot.common.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 需要绑定手机号异常微信小程序登录未绑定手机号时抛出
* 微信小程序登录时手机号未绑定异常
* <p>
* 携带 openid sessionKey 供前端引导用户完成手机号绑定
*
* @author Ray.Hao
* @since 3.1.0
*/
public class MobileNotBoundException extends AuthenticationException {

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.web.exception;
package com.youlai.boot.common.exception;
import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.exception;
package com.youlai.boot.common.exception;
import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.common.validator;
import com.youlai.boot.framework.annotation.ValidField;
import com.youlai.boot.common.annotation.ValidField;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.cache;
package com.youlai.boot.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;

View File

@@ -1,8 +1,9 @@
package com.youlai.boot.framework.captcha.config;
package com.youlai.boot.config;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.captcha.generator.RandomGenerator;
import com.youlai.boot.config.property.CaptchaProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.web.config;
package com.youlai.boot.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.web.config;
package com.youlai.boot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.apidoc;
package com.youlai.boot.config;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;

View File

@@ -1,5 +1,6 @@
package com.youlai.boot.framework.integration.mail.config;
package com.youlai.boot.config;
import com.youlai.boot.config.property.MailProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.mybatis.config;
package com.youlai.boot.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.config.GlobalConfig;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.framework.apidoc;
package com.youlai.boot.config;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.config.property.SecurityProperties;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.web.config;
package com.youlai.boot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.config;
package com.youlai.boot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.cache;
package com.youlai.boot.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.cache.autoconfigure.CacheProperties;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.cache;
package com.youlai.boot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,10 +1,10 @@
package com.youlai.boot.auth.security.config;
package com.youlai.boot.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.security.filter.MobileApiSignatureFilter;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.auth.service.CaptchaService;
import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.framework.security.service.SecurityUserDetailsService;
@@ -23,7 +23,6 @@ import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import jakarta.servlet.DispatcherType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
@@ -35,6 +34,8 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import jakarta.servlet.DispatcherType;
/**
* Spring Security 配置类
* <p>
@@ -61,7 +62,7 @@ public class SecurityConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requestMatcherRegistry -> {
// ASYNC 调度请求 SSE emitter.send()不经过外部 HTTP 请求没有 Authorization 直接放行
// ASYNC 分发SSE 超时/完成后的异步 dispatch Authorization 直接放行
requestMatcherRegistry.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll();
String[] ignoreUrls = securityProperties.getIgnoreUrls();
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
@@ -69,8 +70,8 @@ public class SecurityConfig {
}
// 移动设备专用接口路径需要设备签名验证但不需要用户登录
requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll();
requestMatcherRegistry.requestMatchers("/api/v1/app/**").permitAll();
requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll();
requestMatcherRegistry.requestMatchers("/api/v1/app/**").permitAll();
// 其他所有请求需登录后访问
requestMatcherRegistry.anyRequest().authenticated();
}

View File

@@ -1,8 +1,9 @@
package com.youlai.boot.framework.integration.wxma.config;
package com.youlai.boot.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl;
import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl;
import com.youlai.boot.config.property.WxMaProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.job;
package com.youlai.boot.config;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import lombok.extern.slf4j.Slf4j;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.integration.sms.config;
package com.youlai.boot.config.property;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.captcha.config;
package com.youlai.boot.config.property;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.file.config;
package com.youlai.boot.config.property;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.integration.mail.config;
package com.youlai.boot.config.property;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.web.config;
package com.youlai.boot.config.property;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.config;
package com.youlai.boot.config.property;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.integration.wxma.config;
package com.youlai.boot.config.property;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.framework.annotation.Log;
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;

View File

@@ -10,7 +10,7 @@ import com.youlai.boot.device.model.query.DeveloperQuery;
import com.youlai.boot.device.model.vo.DeveloperVO;
import com.youlai.boot.device.service.DeveloperService;
import com.youlai.boot.device.service.DeviceService;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.annotation.Log;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.framework.annotation.Log;
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;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.controller;
import cn.hutool.core.util.IdUtil;
import com.youlai.boot.framework.annotation.Log;
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;

View File

@@ -2,7 +2,7 @@ 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.framework.annotation.DataPermission;
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;

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,76 +0,0 @@
package com.youlai.boot.framework.integration.mail.service;
import com.youlai.boot.framework.integration.mail.config.MailProperties;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.FileSystemResource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.io.File;
/**
* 邮件服务
*
* @author Ray.Hao
* @since 2024/8/17
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MailService {
private final JavaMailSender mailSender;
private final MailProperties mailProperties;
/**
* 发送简单文本邮件
*
* @param to 收件人地址
* @param subject 邮件主题
* @param text 邮件内容
*/
public void sendMail(String to, String subject, String text) {
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailProperties.getFrom());
message.setTo(to);
message.setSubject(subject);
message.setText(text);
mailSender.send(message);
} catch (Exception e) {
log.error("发送邮件失败{}", e.getMessage());
}
}
/**
* 发送带附件的邮件
*
* @param to 收件人地址
* @param subject 邮件主题
* @param text 邮件内容
* @param filePath 附件路径
*/
public void sendMailWithAttachment(String to, String subject, String text, String filePath) {
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(mailProperties.getFrom());
helper.setTo(to);
helper.setSubject(subject);
helper.setText(text, true); // true 表示支持HTML内容
FileSystemResource file = new FileSystemResource(new File(filePath));
helper.addAttachment(file.getFilename(), file);
mailSender.send(message);
} catch (MessagingException e) {
log.error("发送带附件的邮件失败{}", e.getMessage());
}
}
}

View File

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

View File

@@ -1,24 +0,0 @@
package com.youlai.boot.framework.integration.sms.service;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import java.util.Map;
/**
* 短信服务接口层
*
* @author Ray.Hao
* @since 2024/8/17
*/
public interface SmsService {
/**
* 发送短信
*
* @param mobile 手机号 13388886666
* @param smsType 短信模板 SMS_194640010模板内容您的验证码为${code}请在5分钟内使用
* @param templateParams 模板参数 [{"code":"123456"}] ,用于替换短信模板中的变量
* @return boolean 是否发送成功
*/
boolean sendSms(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams);
}

View File

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

View File

@@ -4,7 +4,7 @@ import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler;
import com.youlai.boot.framework.annotation.DataPermission;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.common.enums.DataScopeEnum;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SecurityUserDetails;

View File

@@ -8,7 +8,7 @@ import java.util.Set;
* 由 system 模块提供适配器实现framework 层通过此接口获取角色权限集合,
* 不直接依赖 system 模块的 {@code RoleMenuService}。
*
* @see com.youlai.boot.system.security.adapter.PermissionAdapter
* @see com.youlai.boot.system.security.port.PermissionAdapter
*/
public interface PermissionPort {

View File

@@ -9,7 +9,7 @@ import com.youlai.boot.framework.security.model.SecurityUser;
* 由 system 模块提供适配器实现framework 层通过此接口获取认证数据,
* 不直接依赖 system 模块的 {@code UserService} / {@code UserSocialService}。
*
* @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter
* @see com.youlai.boot.system.security.port.UserAuthenticationAdapter
*/
public interface UserAuthenticationPort {

Some files were not shown because too many files have changed in this diff Show More