refactor: 项目包结构优化

This commit is contained in:
Ray.Hao
2026-07-31 00:21:49 +08:00
parent a4b699a109
commit 07616d9118
144 changed files with 1410 additions and 1935 deletions

View File

@@ -192,59 +192,54 @@ Detailed guide: https://www.youlai.tech/docs/server/spring-boot/quick-start.html
## Directory Structure ## Directory Structure
``` ```
youlai-boot/ youlai-boot/
├── deploy/ ├── docker/ # Docker orchestration
│ └── docker/ # Docker orchestration ── docs/ # Docs and image assets
├── docs/ # Docs and image assets ├── sql/ # Database init scripts
├── sql/ # Database init scripts ├── src/ # Source code
├── src/ # Source code │ ├── auth/ # Auth module (login/logout/token/captcha)
│ ├── YouLaiBootApplication.java # Bootstrap class │ ├── codegen/ # Code generator
│ ├── auth/ # Auth (login/logout/token) │ ├── common/ # Common module (constants/enums/exceptions/response/utils)
│ ├── codegen/ # Code generator │ ├── config/ # Configuration (Security/Redis/Captcha/OSS/Email etc.)
│ ├── common/ # Common module (constants/enums/response) │ ├── framework/ # Infrastructure layer
│ ├── file/ # File service (MinIO/local/OSS) │ ├── mybatis/ # MyBatis-Plus config
│ ├── framework/ # Technical framework layer │ ├── security/ # Spring Security (filters/token/utils)
│ │ ── apidoc/ # OpenAPI / Knife4j │ │ ── web/ # Web layer (global exception/CORS/rate limit)
│ ├── cache/ # Redis / Caffeine cache │ ├── support/ # Capability services
│ │ ├── captcha/ # Graphic captcha │ │ ├── file/ # File storage (MinIO/Alibaba Cloud OSS/local)
│ │ ├── integration/ # SMS / Email / WeChat │ │ ├── mail/ # Email service
│ │ ├── job/ # XXL-Job scheduled tasks │ │ ├── sms/ # SMS service
│ │ ── mybatis/ # MyBatis-Plus config │ │ ── sse/ # SSE push
│ ├── security/ # Security / JWT / Token └── system/ # Business (user/role/menu/dept/dict etc.)
│ │ └── web/ # Global exception / CORS / rate limit └── pom.xml # Maven dependency management
│ ├── message/ # SSE push ```
│ └── system/ # Business (user/role/menu/dept)
└── pom.xml # Maven dependency management
```

View File

@@ -107,28 +107,25 @@
``` ```
youlai-boot/ youlai-boot/
├── deploy/ ├── docker/ # Docker 部署编排
│ └── docker/ # Docker 部署编排 ── docs/ # 项目文档与图片资源
├── docs/ # 项目文档与图片资源 ├── sql/ # 数据库初始化脚本
├── sql/ # 数据库初始化脚本 ├── src/ # 源码目录
├── src/ # 源码目录 │ ├── auth/ # 认证模块(登录/登出/令牌/验证码)
│ ├── YouLaiBootApplication.java # 启动类 │ ├── codegen/ # 代码生成器
│ ├── auth/ # 认证授权(登录/登出/令牌 │ ├── common/ # 公共模块(常量/枚举/异常/统一响应/工具类
│ ├── codegen/ # 代码生成器 │ ├── config/ # 配置类Security/Redis/Captcha/OSS/邮件等)
│ ├── common/ # 公共模块(常量/枚举/统一响应) │ ├── framework/ # 基础设施层
├── file/ # 文件服务MinIO/本地/OSS │ ├── mybatis/ # MyBatis-Plus 配置
├── framework/ # 技术框架层 │ ├── security/ # Spring Security过滤器/Token/工具类)
│ │ ── apidoc/ # OpenAPI / Knife4j │ │ ── web/ # Web 层(全局异常/跨域/限流)
│ ├── cache/ # Redis / Caffeine 缓存 ├── support/ # 能力服务
│ │ ├── captcha/ # 图形验证码 │ │ ├── file/ # 文件存储MinIO/阿里云OSS/本地)
│ │ ├── integration/ # 短信 / 邮件 / 微信 │ │ ├── mail/ # 邮件服务
│ │ ├── job/ # XXL-Job 定时任 │ │ ├── sms/ # 短信服
│ │ ── mybatis/ # MyBatis-Plus 配置 │ │ ── sse/ # SSE 消息推送
│ ├── security/ # Security / JWT / Token └── system/ # 系统业务(用户/角色/菜单/部门/字典等)
│ │ └── web/ # 全局异常 / 跨域 / 限流 └── pom.xml # Maven 依赖管理
│ ├── message/ # SSE 消息推送
│ └── system/ # 系统业务(用户/角色/菜单/部门)
└── pom.xml # Maven 依赖管理
``` ```
## 生态矩阵 ## 生态矩阵

View File

@@ -6,7 +6,7 @@
<groupId>com.youlai</groupId> <groupId>com.youlai</groupId>
<artifactId>youlai-boot</artifactId> <artifactId>youlai-boot</artifactId>
<version>4.5.0</version> <version>4.6.0</version>
<description>基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。</description> <description>基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。</description>
<parent> <parent>

View File

@@ -411,7 +411,7 @@ CREATE TABLE `sys_log` (
`module` TINYINT NOT NULL COMMENT '模块,数字枚举,参考 LogModule 枚举', `module` TINYINT NOT NULL COMMENT '模块,数字枚举,参考 LogModule 枚举',
`action_type` TINYINT NOT NULL COMMENT '操作类型,数字枚举,参考 ActionType 枚举', `action_type` TINYINT NOT NULL COMMENT '操作类型,数字枚举,参考 ActionType 枚举',
`title` VARCHAR(100) NOT NULL COMMENT '前端显示标题', `title` VARCHAR(100) NOT NULL COMMENT '前端显示标题',
`content` TEXT COMMENT '自定义日志内容', `content` VARCHAR(500) COMMENT '自定义日志内容',
`operator_id` BIGINT COMMENT '操作人ID', `operator_id` BIGINT COMMENT '操作人ID',
`operator_name` VARCHAR(50) COMMENT '操作人名称', `operator_name` VARCHAR(50) COMMENT '操作人名称',
`request_uri` VARCHAR(255) COMMENT '请求路径', `request_uri` VARCHAR(255) COMMENT '请求路径',
@@ -423,7 +423,7 @@ CREATE TABLE `sys_log` (
`os` VARCHAR(100) COMMENT '操作系统', `os` VARCHAR(100) COMMENT '操作系统',
`browser` VARCHAR(100) COMMENT '浏览器', `browser` VARCHAR(100) COMMENT '浏览器',
`status` TINYINT DEFAULT 1 COMMENT '0失败 1成功', `status` TINYINT DEFAULT 1 COMMENT '0失败 1成功',
`error_msg` VARCHAR(255) COMMENT '错误信息', `error_msg` VARCHAR(1000) COMMENT '错误信息',
`execution_time` INT COMMENT '执行时间(ms)', `execution_time` INT COMMENT '执行时间(ms)',
`create_time` DATETIME COMMENT '操作时间', `create_time` DATETIME COMMENT '操作时间',
PRIMARY KEY (`id`) USING BTREE, PRIMARY KEY (`id`) USING BTREE,

View File

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

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.captcha.enums; package com.youlai.boot.auth.enums;
/** /**
* EasyCaptcha 验证码类型 * 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 com.youlai.boot.common.result.ResultCode;
import lombok.Getter; import lombok.Getter;
/** /**
* 验证码异常 * 图形验证码校验失败时抛出的异常
* <p>
* 携带 {@link ResultCode} 用于统一错误响应
*
* @author Ray.Hao
* @since 3.1.0
*/ */
@Getter @Getter
public class CaptchaException extends RuntimeException { 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; 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; import lombok.Data;
/** /**
* 扫码登录票据上下文序列化为 JSON 存入 Redis * 扫码登录票据上下文序列化为 JSON 存入 Redis
* <p> *
* 字段说明见 docs/youlai-boot/scan-code-login.md Redis 存储设计 * @author Ray.Hao
* @since 4.5.0
*/ */
@Data @Data
public class QrCodeLoginContext { public class QrCodeLoginContext {
@@ -13,7 +14,7 @@ public class QrCodeLoginContext {
/** 票据UUID 无连字符 */ /** 票据UUID 无连字符 */
private String ticket; private String ticket;
/** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */ /** 状态枚举名,取 {@link com.youlai.boot.auth.enums.QrCodeLoginStatusEnum#name()} */
private String status; private String status;
/** 扫码用户 IDscan 时写入 */ /** 扫码用户 IDscan 时写入 */
@@ -34,6 +35,6 @@ public class QrCodeLoginContext {
/** 确认时间戳(毫秒) */ /** 确认时间戳(毫秒) */
private Long confirmedAt; private Long confirmedAt;
/** generate 时的 PC 端 IP,用于审计 */ /** generate 时的 PC 端 IP */
private String clientIp; 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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
/** /**
* 登录表单 * 登录请求参数
*/ */
@Schema(description = "登录请求参数") @Schema(description = "登录请求参数")
@Data @Data
public class LoginForm { public class LoginReq {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
@NotBlank(message = "用户名不能为空") @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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
/** /**
* 微信小程序绑定手机号表单 * 微信小程序绑定手机号请求
*/ */
@Schema(description = "微信小程序绑定手机号请求") @Schema(description = "微信小程序绑定手机号请求")
@Data @Data
public class WxMaBindMobileForm { public class WxMaBindMobileReq {
@NotBlank(message = "openid 不能为空") @NotBlank(message = "openid 不能为空")
@Schema(description = "微信用户唯一标识", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4") @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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
/** /**
* 微信小程序手机号快捷登录表单 * 微信小程序手机号快捷登录请求
*/ */
@Schema(description = "微信小程序手机号快捷登录请求") @Schema(description = "微信小程序手机号快捷登录请求")
@Data @Data
public class WxMaPhoneLoginForm { public class WxMaPhoneLoginReq {
@NotBlank(message = "微信登录凭证不能为空") @NotBlank(message = "微信登录凭证不能为空")
@Schema(description = "微信登录凭证wx.login 获取)", example = "0xxx") @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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor; 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
/** /**
* generate 接口响应 * generate 接口响应
*
* @author Ray.Hao
* @since 4.5.0
*/ */
@Data @Data
@Builder @Builder
@Schema(description = "扫码票据生成结果") @Schema(description = "扫码票据生成结果")
public class QrCodeGenerateVO { public class QrCodeGenerateResp {
@Schema(description = "票据") @Schema(description = "票据")
private String ticket; 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
/** /**
* status/scan/confirm/cancel 接口响应 * status/scan/confirm/cancel 接口响应
*
* @author Ray.Hao
* @since 4.5.0
*/ */
@Data @Data
@Builder @Builder
@Schema(description = "扫码登录状态") @Schema(description = "扫码登录状态")
public class QrCodeStatusVO { public class QrCodeStatusResp {
@Schema(description = "票据") @Schema(description = "票据")
private String ticket; 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
@@ -14,7 +14,7 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Schema(description = "微信小程序登录响应") @Schema(description = "微信小程序登录响应")
public class WxMaLoginVO { public class WxMaLoginResp {
@Schema(description = "是否新用户") @Schema(description = "是否新用户")
private Boolean isNewUser; 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.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter; import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.captcha.exception.CaptchaException; import com.youlai.boot.auth.exception.CaptchaException;
import com.youlai.boot.framework.captcha.service.CaptchaService; import com.youlai.boot.auth.service.CaptchaService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream; 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.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort; import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.SmsCaptchaException; import com.youlai.boot.auth.exception.SmsCaptchaException;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken; import com.youlai.boot.auth.security.token.SmsAuthenticationToken;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationProvider; 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.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort; import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.MobileNotBoundException; import com.youlai.boot.common.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken; import com.youlai.boot.auth.security.token.WxMaAuthenticationToken;
import com.youlai.boot.system.model.entity.UserSocial; import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService; import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor; 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.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority; 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.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;

View File

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

View File

@@ -1,15 +1,53 @@
package com.youlai.boot.auth.service; 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.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 * @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 获取) * @param code 微信登录凭证wx.login 获取)
* @return 登录结果(成功返回 token需绑定返回 openid * @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>
* 同时使用微信登录凭证和手机号授权凭证, * 同时使用微信登录凭证和手机号授权凭证,
* 一步完成用户注册/登录,无需额外绑定流程。 * 一步完成用户注册/登录,无需额外绑定流程。
* 适用于企业认证的小程序(已开通手机号快捷登录权限)。
* </p> * </p>
* *
* @param loginCode 微信登录凭证wx.login 获取) * @param loginCode 微信登录凭证wx.login 获取)
* @param phoneCode 手机号授权凭证getPhoneNumber 事件获取) * @param phoneCode 手机号授权凭证getPhoneNumber 事件获取)
* @return 认证令牌 * @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 短信验证码 * @param smsCode 短信验证码
* @return 认证令牌 * @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.query.TableQuery;
import com.youlai.boot.codegen.model.vo.CodegenPreviewVO; import com.youlai.boot.codegen.model.vo.CodegenPreviewVO;
import com.youlai.boot.codegen.model.vo.TablePageVO; 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 com.youlai.boot.codegen.service.GenTableService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; 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.baomidou.mybatisplus.annotation.*;
import com.youlai.boot.framework.base.BaseEntity; import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;

View File

@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore; 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.FormTypeEnum;
import com.youlai.boot.codegen.enums.QueryTypeEnum; import com.youlai.boot.codegen.enums.QueryTypeEnum;
import lombok.Getter; 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.*; 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.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum; 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.*; 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.*; 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 com.youlai.boot.common.validator.FieldValidator;
import jakarta.validation.Constraint; 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.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent; import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil; 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.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.util.IPUtils; import com.youlai.boot.common.util.IPUtils;
@@ -37,6 +37,8 @@ import java.util.concurrent.Executor;
@Slf4j @Slf4j
public class LogAspect { public class LogAspect {
private static final int MAX_ERROR_MSG_LENGTH = 1000;
private final LogService logService; private final LogService logService;
private final Executor operationLogExecutor; private final Executor operationLogExecutor;
@@ -66,10 +68,6 @@ public class LogAspect {
throw e; throw e;
} finally { } finally {
long elapsed = System.currentTimeMillis() - start; long elapsed = System.currentTimeMillis() - start;
if (userId == null) {
userId = SecurityUtils.getUserId();
username = SecurityUtils.getUsername();
}
try { try {
SysLog entity = buildEntity(logAnnotation, elapsed, failure, userId, username); SysLog entity = buildEntity(logAnnotation, elapsed, failure, userId, username);
if (entity != null) { if (entity != null) {
@@ -94,7 +92,6 @@ public class LogAspect {
HttpServletRequest req = attrs.getRequest(); HttpServletRequest req = attrs.getRequest();
UserAgent ua = UserAgentUtil.parse(req.getHeader("User-Agent")); UserAgent ua = UserAgentUtil.parse(req.getHeader("User-Agent"));
String ip = IPUtils.getIpAddr(req); String ip = IPUtils.getIpAddr(req);
IpRegion region = parseRegion(IPUtils.getRegion(ip));
LogModuleEnum module = ann.module(); LogModuleEnum module = ann.module();
ActionTypeEnum action = ann.value(); ActionTypeEnum action = ann.value();
@@ -109,25 +106,34 @@ public class LogAspect {
entity.setRequestUri(req.getRequestURI()); entity.setRequestUri(req.getRequestURI());
entity.setRequestMethod(req.getMethod()); entity.setRequestMethod(req.getMethod());
entity.setIp(ip); entity.setIp(ip);
entity.setProvince(region.province);
entity.setCity(region.city);
entity.setDevice(getDevice(ua)); entity.setDevice(getDevice(ua));
entity.setOs(getOs(ua)); entity.setOs(getOs(ua));
entity.setBrowser(getBrowser(ua)); entity.setBrowser(getBrowser(ua));
entity.setStatus(failure == null ? 1 : 0); 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.setExecutionTime((int) Math.min(elapsed, Integer.MAX_VALUE));
entity.setCreateTime(LocalDateTime.now()); entity.setCreateTime(LocalDateTime.now());
IpRegion region = parseRegion(ip);
entity.setProvince(region.province);
entity.setCity(region.city);
return entity; return entity;
} }
private IpRegion parseRegion(String region) { private IpRegion parseRegion(String ip) {
String region = IPUtils.getRegion(ip);
if (StrUtil.isBlank(region)) return IpRegion.EMPTY; if (StrUtil.isBlank(region)) return IpRegion.EMPTY;
String[] parts = region.split("\\|"); String[] parts = region.split("\\|");
if (parts.length < 4) return IpRegion.EMPTY; if (parts.length < 4) return IpRegion.EMPTY;
return new IpRegion(StrUtil.blankToDefault(parts[2], null), StrUtil.blankToDefault(parts[3], null)); 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) { private String getOs(UserAgent ua) {
return Optional.ofNullable(ua).map(UserAgent::getOs).map(os -> os.getName()).orElse(null); 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.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil; 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.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils; import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.web.config.RateLimitProperties; import com.youlai.boot.config.property.RateLimitProperties;
import com.youlai.boot.framework.web.exception.RateLimitException; import com.youlai.boot.common.exception.RateLimitException;
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript; import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; 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.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil; 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.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.exception.BusinessException; 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 com.youlai.boot.common.util.IPUtils;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; 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.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.common.base; 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;

View File

@@ -5,7 +5,12 @@ import com.youlai.boot.common.base.IBaseEnum;
import lombok.Getter; import lombok.Getter;
/** /**
* 第三方登录平台类型枚举 * 第三方登录平台类型枚举
* <p>
* value 作为 MyBatis-Plus 枚举值持久化到数据库。
*
* @author Ray.Hao
* @since 2.17.0
*/ */
@Getter @Getter
public enum SocialPlatformEnum implements IBaseEnum<String> { 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; import org.springframework.security.core.AuthenticationException;
/** /**
* 需要绑定手机号异常微信小程序登录未绑定手机号时抛出 * 微信小程序登录时手机号未绑定异常
* <p>
* 携带 openid sessionKey 供前端引导用户完成手机号绑定
*
* @author Ray.Hao
* @since 3.1.0
*/ */
public class MobileNotBoundException extends AuthenticationException { 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 com.youlai.boot.common.result.ResultCode;
import lombok.Getter; 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 com.youlai.boot.common.result.ResultCode;
import lombok.Getter; import lombok.Getter;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.common.validator; 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.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext; 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 com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j; 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.CodeGenerator;
import cn.hutool.captcha.generator.MathGenerator; import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.captcha.generator.RandomGenerator; import cn.hutool.captcha.generator.RandomGenerator;
import com.youlai.boot.config.property.CaptchaProperties;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean; 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.Bean;
import org.springframework.context.annotation.Configuration; 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; 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.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.annotation.DbType;
import com.baomidou.mybatisplus.core.config.GlobalConfig; 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 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.Components;
import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact; 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.Bean;
import org.springframework.context.annotation.Configuration; 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.Bean;
import org.springframework.context.annotation.Configuration; 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.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.cache.autoconfigure.CacheProperties; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;

View File

@@ -1,9 +1,9 @@
package com.youlai.boot.auth.security.config; package com.youlai.boot.config;
import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.captcha.service.CaptchaService; import com.youlai.boot.auth.service.CaptchaService;
import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter; import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
import com.youlai.boot.framework.security.port.UserAuthenticationPort; import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.framework.security.service.SecurityUserDetailsService; import com.youlai.boot.framework.security.service.SecurityUserDetailsService;
@@ -33,6 +33,8 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.AuthorizationFilter;
import jakarta.servlet.DispatcherType;
/** /**
* Spring Security 配置类 * Spring Security 配置类
* <p> * <p>
@@ -59,6 +61,8 @@ public class SecurityConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http return http
.authorizeHttpRequests(requestMatcherRegistry -> { .authorizeHttpRequests(requestMatcherRegistry -> {
// ASYNC 分发SSE 超时/完成后的异步 dispatch Authorization 直接放行
requestMatcherRegistry.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll();
String[] ignoreUrls = securityProperties.getIgnoreUrls(); String[] ignoreUrls = securityProperties.getIgnoreUrls();
if (ArrayUtil.isNotEmpty(ignoreUrls)) { if (ArrayUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll(); requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();

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.WxMaService;
import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl; import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl;
import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl; 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.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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 com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import lombok.extern.slf4j.Slf4j; 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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; 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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; 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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; 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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; 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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; 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.Min;
import jakarta.validation.constraints.NotEmpty; 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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;

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,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

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

View File

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

View File

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

View File

@@ -13,8 +13,8 @@ import com.youlai.boot.common.constant.JwtClaimConstants;
import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.config.property.SecurityProperties;
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.AuthenticationToken;
import com.youlai.boot.framework.security.model.RoleDataScope; import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.model.SecurityUserDetails;

View File

@@ -5,8 +5,8 @@ import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.config.property.SecurityProperties;
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.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.model.SecurityUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

View File

@@ -4,15 +4,17 @@ import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.Result; import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.result.ResultCode; 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.web.exception.RateLimitException; import com.youlai.boot.common.exception.RateLimitException;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.TypeMismatchException; import org.springframework.beans.TypeMismatchException;
import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
@@ -176,11 +178,15 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public <T> Result<T> handleException(Exception e) throws Exception { public <T> Result<T> handleException(Exception e, HttpServletRequest request) throws Exception {
if (e instanceof AccessDeniedException if (e instanceof AccessDeniedException
|| e instanceof AuthenticationException) { || e instanceof AuthenticationException) {
throw e; throw e;
} }
String accept = request.getHeader("Accept");
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
throw e;
}
log.error("unknown exception", e); log.error("unknown exception", e);
return Result.failed(e.getLocalizedMessage()); return Result.failed(e.getLocalizedMessage());
} }

View File

@@ -4,7 +4,7 @@ import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils; import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.web.config.RateLimitProperties; import com.youlai.boot.config.property.RateLimitProperties;
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript; import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
import com.youlai.boot.framework.web.util.ResponseWriter; import com.youlai.boot.framework.web.util.ResponseWriter;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;

View File

@@ -1,29 +0,0 @@
package com.youlai.boot.message.job;
import com.youlai.boot.message.registry.SseSessionRegistry;
import com.youlai.boot.message.service.SseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 在线用户统计
* <p>定时统计并广播当前在线用户数量</p>
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class OnlineUserCountTask {
private final SseSessionRegistry sessionRegistry;
private final SseService sseService;
@Scheduled(cron = "0 */3 * * * ?")
public void execute() {
int onlineCount = sessionRegistry.getOnlineUserCount();
int connectionCount = sessionRegistry.getTotalConnectionCount();
log.debug("在线用户数={} 总连接数={}", onlineCount, connectionCount);
sseService.sendOnlineCount();
}
}

View File

@@ -1,140 +0,0 @@
package com.youlai.boot.message.service;
import com.youlai.boot.message.dto.DictChangeEvent;
import com.youlai.boot.message.dto.OnlineUserDTO;
import com.youlai.boot.message.registry.SseSessionRegistry;
import com.youlai.boot.message.topic.SseTopics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* SSE 服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SseService {
/** SSE 连接超时时间30 分钟 */
private static final long TIMEOUT = 30 * 60 * 1000L;
private final SseSessionRegistry sessionRegistry;
/**
* 创建 SSE 连接
*
* @param username 用户名
* @return SseEmitter
*/
public SseEmitter createConnection(String username) {
if (username == null || username.isEmpty()) {
log.warn("创建SSE连接失败用户名为空");
return null;
}
// 创建 SseEmitter设置超时时间
SseEmitter emitter = new SseEmitter(TIMEOUT);
// 注册用户连接
sessionRegistry.userConnected(username, emitter);
// 连接建立后立即发送在线用户数
try {
emitter.send(SseEmitter.event()
.name(SseTopics.ONLINE_COUNT)
.data(sessionRegistry.getOnlineUserCount()));
} catch (IOException e) {
log.warn("发送初始在线用户数失败: {}", e.getMessage());
}
log.info("用户[{}]SSE连接已建立当前在线用户数: {}", username, sessionRegistry.getOnlineUserCount());
// 发送在线用户数变更
sendOnlineCount();
return emitter;
}
/**
* 发送字典变更事件
*
* @param dictCode 字典编码
*/
public void sendDictChange(String dictCode) {
if (dictCode == null || dictCode.isEmpty()) {
log.warn("字典编码为空,跳过发送");
return;
}
DictChangeEvent event = new DictChangeEvent(dictCode);
sessionRegistry.broadcast(SseTopics.DICT, event);
log.info("已发送字典变更通知: dictCode={}", dictCode);
}
/**
* 发送在线用户数
*/
public void sendOnlineCount() {
int count = sessionRegistry.getOnlineUserCount();
sessionRegistry.broadcast(SseTopics.ONLINE_COUNT, count);
log.debug("已发送在线用户数: {}", count);
}
/**
* 发送消息给指定用户
*
* @param username 用户名
* @param eventName 事件名称
* @param data 数据
*/
public void sendToUser(String username, String eventName, Object data) {
if (username == null || username.isEmpty()) {
log.warn("用户名为空,无法发送消息");
return;
}
sessionRegistry.sendToUser(username, eventName, data);
log.info("已向用户[{}]发送事件[{}]", username, eventName);
}
/**
* 获取在线用户列表
*
* @return 在线用户列表
*/
public List<OnlineUserDTO> getOnlineUsers() {
return sessionRegistry.getOnlineUsers();
}
/**
* 获取在线用户数
*
* @return 在线用户数
*/
public int getOnlineUserCount() {
return sessionRegistry.getOnlineUserCount();
}
/**
* 发送系统消息
*
* @param message 消息内容
*/
public void sendSystemMessage(String message) {
if (message == null || message.isEmpty()) {
return;
}
Map<String, Object> systemMessage = Map.of(
"sender", "系统通知",
"content", message,
"timestamp", System.currentTimeMillis()
);
sessionRegistry.broadcast(SseTopics.SYSTEM, systemMessage);
log.info("已发送系统消息: {}", message);
}
}

View File

@@ -1,19 +0,0 @@
package com.youlai.boot.message.topic;
/**
* SSE 主题常量
*/
public final class SseTopics {
private SseTopics() {
}
/** 字典变更事件 */
public static final String DICT = "dict";
/** 在线用户数事件 */
public static final String ONLINE_COUNT = "online-count";
/** 系统消息事件 */
public static final String SYSTEM = "system";
}

View File

@@ -1,9 +1,8 @@
package com.youlai.boot.file.model; package com.youlai.boot.support.file.model;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
/** /**
* 文件信息对象 * 文件信息对象
* *

View File

@@ -1,10 +1,10 @@
package com.youlai.boot.file.service; package com.youlai.boot.support.file.service;
import com.youlai.boot.file.model.FileInfo; import com.youlai.boot.support.file.model.FileInfo;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
/** /**
* 对象存储服务接口 * 文件存储服务接口
* *
* @author haoxr * @author haoxr
* @since 2022/11/19 * @since 2022/11/19
@@ -13,6 +13,7 @@ public interface FileService {
/** /**
* 上传文件 * 上传文件
*
* @param file 表单文件对象 * @param file 表单文件对象
* @return 文件信息 * @return 文件信息
*/ */
@@ -26,5 +27,4 @@ public interface FileService {
*/ */
boolean deleteFile(String filePath); boolean deleteFile(String filePath);
} }

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.file.service.impl; package com.youlai.boot.support.file.service.impl;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
@@ -8,8 +8,8 @@ import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder; import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.ObjectMetadata; import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest; import com.aliyun.oss.model.PutObjectRequest;
import com.youlai.boot.file.service.FileService; import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.file.model.FileInfo; import com.youlai.boot.support.file.service.FileService;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.Data; import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -22,6 +22,12 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* 阿里云 OSS 文件存储服务
* <p>
* {@code file-storage.type=aliyun} 时生效
* 上传文件按日期分目录存储URL 格式{@code https://bucket.endpoint/yyyyMMdd/uuid.suffix}
*/
@Component @Component
@ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun") @ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun")
@ConfigurationProperties(prefix = "file-storage.aliyun") @ConfigurationProperties(prefix = "file-storage.aliyun")
@@ -29,9 +35,13 @@ import java.time.LocalDateTime;
@Data @Data
public class AliyunFileServiceImpl implements FileService { public class AliyunFileServiceImpl implements FileService {
/** OSS 地域节点,如 {@code oss-cn-hangzhou.aliyuncs.com} */
private String endpoint; private String endpoint;
/** AccessKey ID */
private String accessKeyId; private String accessKeyId;
/** AccessKey Secret */
private String accessKeySecret; private String accessKeySecret;
/** 存储空间名称 */
private String bucket; private String bucket;
private OSS aliyunOssClient; private OSS aliyunOssClient;
@@ -41,6 +51,9 @@ public class AliyunFileServiceImpl implements FileService {
aliyunOssClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); aliyunOssClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
} }
/**
* 上传文件到阿里云 OSS按日期分目录返回 {@code https://bucket.endpoint/path} 格式的 URL
*/
@Override @Override
@SneakyThrows @SneakyThrows
public FileInfo uploadFile(MultipartFile file) { public FileInfo uploadFile(MultipartFile file) {
@@ -63,6 +76,12 @@ public class AliyunFileServiceImpl implements FileService {
return fileInfo; return fileInfo;
} }
/**
* 从完整 URL 中提取文件名后调用 OSS 删除
*
* @param filePath 文件完整 URL
* @return 删除成功返回 true
*/
@Override @Override
public boolean deleteFile(String filePath) { public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空"); Assert.notBlank(filePath, "删除文件路径不能为空");

View File

@@ -1,11 +1,11 @@
package com.youlai.boot.file.service.impl; package com.youlai.boot.support.file.service.impl;
import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import com.youlai.boot.file.model.FileInfo; import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.file.service.FileService; import com.youlai.boot.support.file.service.FileService;
import lombok.Data; import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -19,6 +19,13 @@ import java.io.File;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* 本地文件存储服务
* <p>
* {@code file-storage.type=local} 时生效
* 文件写入本地磁盘 {@code file-storage.local.path} 下按日期分目录存储
* URL 为相对于存储根目录的路径 {@code /yyyyMMdd/uuid.suffix}
*/
@Data @Data
@Slf4j @Slf4j
@Component @Component
@@ -27,14 +34,18 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor @RequiredArgsConstructor
public class LocalFileServiceImpl implements FileService { public class LocalFileServiceImpl implements FileService {
/** 本地存储根目录,来自 {@code file-storage.local.path} */
@Value("${file-storage.local.path}") @Value("${file-storage.local.path}")
private String path; private String path;
/**
* 上传文件到本地磁盘按日期分目录返回相对于存储根目录的路径
*/
@Override @Override
public FileInfo uploadFile(MultipartFile file) { public FileInfo uploadFile(MultipartFile file) {
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String suffix = FileUtil.getSuffix(originalFilename); String suffix = FileUtil.getSuffix(originalFilename);
String fileName = IdUtil.simpleUUID()+ "." + suffix;; String fileName = IdUtil.simpleUUID() + "." + suffix;
String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN); String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
String filePrefix = path.endsWith(File.separator) ? path : path + File.separator; String filePrefix = path.endsWith(File.separator) ? path : path + File.separator;
try (InputStream inputStream = file.getInputStream()) { try (InputStream inputStream = file.getInputStream()) {
@@ -50,6 +61,12 @@ public class LocalFileServiceImpl implements FileService {
return fileInfo; return fileInfo;
} }
/**
* 删除本地文件路径是目录或不存在时返回 false
*
* @param filePath 相对于存储根目录的文件路径
* @return 删除成功返回 true
*/
@Override @Override
public boolean deleteFile(String filePath) { public boolean deleteFile(String filePath) {
if (filePath == null || filePath.isEmpty()) { if (filePath == null || filePath.isEmpty()) {

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.file.service.impl; package com.youlai.boot.support.file.service.impl;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
@@ -7,8 +7,8 @@ import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.file.model.FileInfo; import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.file.service.FileService; import com.youlai.boot.support.file.service.FileService;
import io.minio.*; import io.minio.*;
import io.minio.http.Method; import io.minio.http.Method;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -24,6 +24,16 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* MinIO 文件存储服务
* <p>
* {@code file-storage.type=minio} 时生效
* 上传文件按日期分目录存储路径格式{@code yyyyMMdd/uuid.suffix}
* <ul>
* <li>配置了 {@code file-storage.minio.domain} 时直接用域名拼接 URL</li>
* <li>未配置则用 MinIO 预签名 URL去掉 query 参数后的纯路径</li>
* </ul>
*/
@Component @Component
@ConditionalOnProperty(value = "file-storage.type", havingValue = "minio") @ConditionalOnProperty(value = "file-storage.type", havingValue = "minio")
@ConfigurationProperties(prefix = "file-storage.minio") @ConfigurationProperties(prefix = "file-storage.minio")
@@ -32,10 +42,15 @@ import java.time.LocalDateTime;
@Slf4j @Slf4j
public class MinioFileServiceImpl implements FileService { public class MinioFileServiceImpl implements FileService {
/** MinIO 服务端点,如 {@code http://localhost:9000} */
private String endpoint; private String endpoint;
/** 访问密钥 */
private String accessKey; private String accessKey;
/** 秘密密钥 */
private String secretKey; private String secretKey;
/** 存储桶名称 */
private String bucket; private String bucket;
/** 自定义域名(可选),配置后不再使用预签名 URL */
private String domain; private String domain;
private MinioClient minioClient; private MinioClient minioClient;
@@ -48,6 +63,9 @@ public class MinioFileServiceImpl implements FileService {
.build(); .build();
} }
/**
* 上传文件到 MinIO按日期分目录自动创建存储桶返回文件访问 URL
*/
@Override @Override
public FileInfo uploadFile(MultipartFile file) { public FileInfo uploadFile(MultipartFile file) {
createBucketIfAbsent(bucket); createBucketIfAbsent(bucket);
@@ -59,7 +77,7 @@ public class MinioFileServiceImpl implements FileService {
try (InputStream inputStream = file.getInputStream()) { try (InputStream inputStream = file.getInputStream()) {
PutObjectArgs putObjectArgs = PutObjectArgs.builder() PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.bucket(bucket) .bucket(bucket)
.object(dateFolder + "/"+ fileName) .object(dateFolder + "/" + fileName)
.contentType(file.getContentType()) .contentType(file.getContentType())
.stream(inputStream, inputStream.available(), -1) .stream(inputStream, inputStream.available(), -1)
.build(); .build();
@@ -69,13 +87,13 @@ public class MinioFileServiceImpl implements FileService {
if (StrUtil.isBlank(domain)) { if (StrUtil.isBlank(domain)) {
GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder() GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
.bucket(bucket) .bucket(bucket)
.object(dateFolder + "/"+ fileName) .object(dateFolder + "/" + fileName)
.method(Method.GET) .method(Method.GET)
.build(); .build();
fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs); fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
fileUrl = fileUrl.substring(0, fileUrl.indexOf("?")); fileUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
} else { } else {
fileUrl = domain + "/"+ bucket + "/"+ dateFolder + "/"+ fileName; fileUrl = domain + "/" + bucket + "/" + dateFolder + "/" + fileName;
} }
FileInfo fileInfo = new FileInfo(); FileInfo fileInfo = new FileInfo();
@@ -88,6 +106,14 @@ public class MinioFileServiceImpl implements FileService {
} }
} }
/**
* 删除文件
* <p>
* 从完整 URL 中提取文件名后调用 MinIO 删除
*
* @param filePath 文件完整 URL
* @return 删除成功返回 true
*/
@Override @Override
public boolean deleteFile(String filePath) { public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空"); Assert.notBlank(filePath, "删除文件路径不能为空");
@@ -107,17 +133,21 @@ public class MinioFileServiceImpl implements FileService {
} }
} }
/** 生成公开读的存储桶策略 JSON */
private static String publicBucketPolicy(String bucket) { private static String publicBucketPolicy(String bucket) {
return "{\"Version\":\"2012-10-17\"," return "{\"Version\":\"2012-10-17\","
+ "\"Statement\":[{\"Effect\":\"Allow\"," + "\"Statement\":[{\"Effect\":\"Allow\","
+ "\"Principal\":{\"AWS\":[\"*\"]}," + "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"]," + "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]}," + "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]},"
+ "{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]}," + "{\"Effect\":\"Allow\",\"" + "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"]," + "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}"; + "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}";
} }
/**
* 存储桶不存在时自动创建并设置为公开读策略
*/
@SneakyThrows @SneakyThrows
private void createBucketIfAbsent(String bucket) { private void createBucketIfAbsent(String bucket) {
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) { if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {

View File

@@ -0,0 +1,32 @@
package com.youlai.boot.support.mail;
/**
* 邮件发送服务接口。
* <p>
* 由各邮件供应商实现SMTP、SendGrid 等),通过 {@code mail.type} 配置项选择。
*
* @author Ray.Hao
* @since 3.1.0
*/
public interface EmailService {
/**
* 发送纯文本邮件。
*
* @param to 收件人邮箱
* @param subject 邮件主题
* @param text 邮件正文
*/
void send(String to, String subject, String text);
/**
* 发送带附件的邮件。
*
* @param to 收件人邮箱
* @param subject 邮件主题
* @param text 邮件正文(支持 HTML
* @param filePath 附件文件绝对路径
*/
void sendWithAttachment(String to, String subject, String text, String filePath);
}

View File

@@ -0,0 +1,74 @@
package com.youlai.boot.support.mail.impl;
import com.youlai.boot.config.property.MailProperties;
import com.youlai.boot.support.mail.EmailService;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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;
/**
* SMTP 邮件服务。
* <p>
* 当 {@code mail.type=smtp}(默认)时生效。基于 Spring {@link JavaMailSender} 发送邮件。
*
* @author Ray.Hao
* @since 3.1.0
*/
@Slf4j
@Service
@ConditionalOnProperty(value = "mail.type", havingValue = "smtp", matchIfMissing = true)
@RequiredArgsConstructor
public class SmtpEmailServiceImpl implements EmailService {
private final JavaMailSender mailSender;
/** 发件人地址等邮件配置,来自 {@code mail.*} */
private final MailProperties mailProperties;
/**
* 发送纯文本邮件,发件人取自 {@code mail.from} 配置。
*/
@Override
public void send(String to, String subject, String text) {
try {
SimpleMailMessage msg = new SimpleMailMessage();
msg.setFrom(mailProperties.getFrom());
msg.setTo(to);
msg.setSubject(subject);
msg.setText(text);
mailSender.send(msg);
} catch (Exception e) {
log.error("发送邮件失败: {}", e.getMessage());
}
}
/**
* 发送带附件的 HTML 邮件。
*/
@Override
public void sendWithAttachment(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);
FileSystemResource file = new FileSystemResource(new File(filePath));
helper.addAttachment(file.getFilename(), file);
mailSender.send(message);
} catch (MessagingException e) {
log.error("发送带附件邮件失败: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,25 @@
package com.youlai.boot.support.sms;
import java.util.Map;
/**
* 短信发送服务接口。
* <p>
* 由各短信供应商实现(阿里云、腾讯云等),通过 {@code sms.type} 配置项选择。
*
* @author Ray.Hao
* @since 2024/8/17
*/
public interface SmsService {
/**
* 发送短信。
*
* @param mobile 手机号
* @param smsType 短信类型,对应 {@code sms.templates.*} 配置中的模板
* @param templateParams 模板参数,用于替换短信模板中的变量
* @return 是否发送成功
*/
boolean send(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams);
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.integration.sms.enums; package com.youlai.boot.support.sms;
import com.youlai.boot.common.base.IBaseEnum; import com.youlai.boot.common.base.IBaseEnum;
import lombok.Getter; import lombok.Getter;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.integration.sms.service.impl; package com.youlai.boot.support.sms.impl;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.aliyuncs.CommonRequest; import com.aliyuncs.CommonRequest;
@@ -8,72 +8,56 @@ import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException; import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType; import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile; import com.aliyuncs.profile.DefaultProfile;
import com.youlai.boot.framework.integration.sms.config.AliyunSmsProperties; import com.youlai.boot.support.sms.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; import com.youlai.boot.config.property.AliyunSmsProperties;
import com.youlai.boot.framework.integration.sms.service.SmsService; import com.youlai.boot.support.sms.SmsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Map; import java.util.Map;
/** /**
* 阿里云短信业务类 * 阿里云短信服务实现
* *
* @author Ray * @author Ray
* @since 2024/8/17 * @since 3.1.0
*/ */
@Service @Slf4j
@Component
@ConditionalOnProperty(value = "sms.type", havingValue = "aliyun", matchIfMissing = true)
@RequiredArgsConstructor @RequiredArgsConstructor
public class AliyunSmsService implements SmsService { public class AliyunSmsServiceImpl implements SmsService {
private final AliyunSmsProperties aliyunSmsProperties; private final AliyunSmsProperties aliyunSmsProperties;
/**
* 发送短信验证码
*
* @param mobile 手机号 13388886666
* @param smsType 短信模板 SMS_194640010
* @param templateParams 模板参数 [{"code":"123456"}]
* @return boolean 是否发送成功
*/
@Override @Override
public boolean sendSms(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams) { public boolean send(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams) {
// 根据 smsType 从配置中获取对应的阿里云短信模板编码
String templateCode = aliyunSmsProperties.getTemplates().get(smsType.getValue()); String templateCode = aliyunSmsProperties.getTemplates().get(smsType.getValue());
DefaultProfile profile = DefaultProfile.getProfile(aliyunSmsProperties.getRegionId(), DefaultProfile profile = DefaultProfile.getProfile(aliyunSmsProperties.getRegionId(),
aliyunSmsProperties.getAccessKeyId(), aliyunSmsProperties.getAccessKeySecret()); aliyunSmsProperties.getAccessKeyId(), aliyunSmsProperties.getAccessKeySecret());
IAcsClient client = new DefaultAcsClient(profile); IAcsClient client = new DefaultAcsClient(profile);
// 创建通用的请求对象
CommonRequest request = new CommonRequest(); CommonRequest request = new CommonRequest();
// 指定请求方式
request.setSysMethod(MethodType.POST); request.setSysMethod(MethodType.POST);
// 短信api的请求地址(固定)
request.setSysDomain(aliyunSmsProperties.getDomain()); request.setSysDomain(aliyunSmsProperties.getDomain());
// 签名算法版(固定)
request.setSysVersion("2017-05-25"); request.setSysVersion("2017-05-25");
// 请求 API 的名称(固定)
request.setSysAction("SendSms"); request.setSysAction("SendSms");
// 指定地域名称
request.putQueryParameter("RegionId", aliyunSmsProperties.getRegionId()); request.putQueryParameter("RegionId", aliyunSmsProperties.getRegionId());
// 要给哪个手机号发送短信 指定手机号
request.putQueryParameter("PhoneNumbers", mobile); request.putQueryParameter("PhoneNumbers", mobile);
// 您的申请签名
request.putQueryParameter("SignName", aliyunSmsProperties.getSignName()); request.putQueryParameter("SignName", aliyunSmsProperties.getSignName());
// 您申请的模板 code
request.putQueryParameter("TemplateCode", templateCode); request.putQueryParameter("TemplateCode", templateCode);
request.putQueryParameter("TemplateParam", JSONUtil.toJsonStr(templateParams)); request.putQueryParameter("TemplateParam", JSONUtil.toJsonStr(templateParams));
try { try {
CommonResponse response = client.getCommonResponse(request); CommonResponse response = client.getCommonResponse(request);
return response.getHttpResponse().isSuccess(); return response.getHttpResponse().isSuccess();
} catch (ClientException e) { } catch (ClientException e) {
e.printStackTrace(); log.error("发送短信失败: {}", e.getMessage());
} }
return false; return false;
} }
} }

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