refactor: 项目包结构优化
This commit is contained in:
49
README.en.md
49
README.en.md
@@ -192,59 +192,54 @@ Detailed guide: https://www.youlai.tech/docs/server/spring-boot/quick-start.html
|
||||
|
||||
|
||||
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
41
README.md
41
README.md
@@ -107,28 +107,25 @@
|
||||
|
||||
```
|
||||
youlai-boot/
|
||||
├── deploy/
|
||||
│ └── docker/ # Docker 部署编排
|
||||
├── docs/ # 项目文档与图片资源
|
||||
├── sql/ # 数据库初始化脚本
|
||||
├── src/ # 源码目录
|
||||
│ ├── YouLaiBootApplication.java # 启动类
|
||||
│ ├── auth/ # 认证授权(登录/登出/令牌)
|
||||
│ ├── codegen/ # 代码生成器
|
||||
│ ├── common/ # 公共模块(常量/枚举/统一响应)
|
||||
│ ├── file/ # 文件服务(MinIO/本地/OSS)
|
||||
│ ├── framework/ # 技术框架层
|
||||
│ │ ├── apidoc/ # OpenAPI / Knife4j
|
||||
│ │ ├── cache/ # Redis / Caffeine 缓存
|
||||
│ │ ├── captcha/ # 图形验证码
|
||||
│ │ ├── integration/ # 短信 / 邮件 / 微信
|
||||
│ │ ├── job/ # XXL-Job 定时任务
|
||||
│ │ ├── mybatis/ # MyBatis-Plus 配置
|
||||
│ │ ├── security/ # Security / JWT / Token
|
||||
│ │ └── web/ # 全局异常 / 跨域 / 限流
|
||||
│ ├── message/ # SSE 消息推送
|
||||
│ └── system/ # 系统业务(用户/角色/菜单/部门)
|
||||
└── pom.xml # Maven 依赖管理
|
||||
├── docker/ # Docker 部署编排
|
||||
├── docs/ # 项目文档与图片资源
|
||||
├── sql/ # 数据库初始化脚本
|
||||
├── src/ # 源码目录
|
||||
│ ├── auth/ # 认证模块(登录/登出/令牌/验证码)
|
||||
│ ├── codegen/ # 代码生成器
|
||||
│ ├── common/ # 公共模块(常量/枚举/异常/统一响应/工具类)
|
||||
│ ├── config/ # 配置类(Security/Redis/Captcha/OSS/邮件等)
|
||||
│ ├── framework/ # 基础设施层
|
||||
│ │ ├── mybatis/ # MyBatis-Plus 配置
|
||||
│ │ ├── security/ # Spring Security(过滤器/Token/工具类)
|
||||
│ │ └── web/ # Web 层(全局异常/跨域/限流)
|
||||
│ ├── support/ # 能力服务
|
||||
│ │ ├── file/ # 文件存储(MinIO/阿里云OSS/本地)
|
||||
│ │ ├── mail/ # 邮件服务
|
||||
│ │ ├── sms/ # 短信服务
|
||||
│ │ └── sse/ # SSE 消息推送
|
||||
│ └── system/ # 系统业务(用户/角色/菜单/部门/字典等)
|
||||
└── pom.xml # Maven 依赖管理
|
||||
```
|
||||
|
||||
## 生态矩阵
|
||||
|
||||
2
pom.xml
2
pom.xml
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.youlai</groupId>
|
||||
<artifactId>youlai-boot</artifactId>
|
||||
<version>4.5.0</version>
|
||||
<version>4.6.0</version>
|
||||
<description>基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。</description>
|
||||
|
||||
<parent>
|
||||
|
||||
@@ -411,7 +411,7 @@ CREATE TABLE `sys_log` (
|
||||
`module` TINYINT NOT NULL COMMENT '模块,数字枚举,参考 LogModule 枚举',
|
||||
`action_type` TINYINT NOT NULL COMMENT '操作类型,数字枚举,参考 ActionType 枚举',
|
||||
`title` VARCHAR(100) NOT NULL COMMENT '前端显示标题',
|
||||
`content` TEXT COMMENT '自定义日志内容',
|
||||
`content` VARCHAR(500) COMMENT '自定义日志内容',
|
||||
`operator_id` BIGINT COMMENT '操作人ID',
|
||||
`operator_name` VARCHAR(50) COMMENT '操作人名称',
|
||||
`request_uri` VARCHAR(255) COMMENT '请求路径',
|
||||
@@ -423,7 +423,7 @@ CREATE TABLE `sys_log` (
|
||||
`os` VARCHAR(100) COMMENT '操作系统',
|
||||
`browser` VARCHAR(100) COMMENT '浏览器',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '0失败 1成功',
|
||||
`error_msg` VARCHAR(255) COMMENT '错误信息',
|
||||
`error_msg` VARCHAR(1000) COMMENT '错误信息',
|
||||
`execution_time` INT COMMENT '执行时间(ms)',
|
||||
`create_time` DATETIME COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.youlai.boot.auth.controller;
|
||||
|
||||
import com.youlai.boot.auth.model.form.LoginForm;
|
||||
import com.youlai.boot.auth.model.req.LoginReq;
|
||||
import com.youlai.boot.common.enums.ActionTypeEnum;
|
||||
import com.youlai.boot.common.enums.LogModuleEnum;
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.auth.service.AuthService;
|
||||
import com.youlai.boot.framework.annotation.Log;
|
||||
import com.youlai.boot.framework.annotation.RateLimit;
|
||||
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
|
||||
import com.youlai.boot.common.annotation.Log;
|
||||
import com.youlai.boot.common.annotation.RateLimit;
|
||||
import com.youlai.boot.auth.model.resp.CaptchaInfo;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -49,32 +49,11 @@ public class AuthController {
|
||||
@PostMapping("/login")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit
|
||||
public Result<AuthenticationToken> login(@RequestBody @Valid LoginForm request) {
|
||||
public Result<AuthenticationToken> login(@RequestBody @Valid LoginReq request) {
|
||||
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
|
||||
return Result.success(authenticationToken);
|
||||
}
|
||||
|
||||
@Operation(summary = "短信验证码登录")
|
||||
@PostMapping("/login/sms")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
public Result<AuthenticationToken> loginBySms(
|
||||
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile,
|
||||
@Parameter(description = "验证码", example = "123456") @RequestParam String code
|
||||
) {
|
||||
AuthenticationToken loginResult = authService.loginBySms(mobile, code);
|
||||
return Result.success(loginResult);
|
||||
}
|
||||
|
||||
@Operation(summary = "发送登录短信验证码")
|
||||
@PostMapping("/sms/code")
|
||||
@RateLimit(limit = 1, window = 60)
|
||||
public Result<Void> sendSmsCode(
|
||||
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
|
||||
) {
|
||||
authService.sendSmsCode(mobile);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "退出登录")
|
||||
@DeleteMapping("/logout")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGOUT)
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.youlai.boot.auth.controller;
|
||||
|
||||
import com.youlai.boot.auth.model.form.WxMaBindMobileForm;
|
||||
import com.youlai.boot.auth.model.form.WxMaPhoneLoginForm;
|
||||
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
|
||||
import com.youlai.boot.auth.model.req.WxMaBindMobileReq;
|
||||
import com.youlai.boot.auth.model.req.WxMaPhoneLoginReq;
|
||||
import com.youlai.boot.auth.model.resp.WxMaLoginResp;
|
||||
import com.youlai.boot.auth.service.WxMaAuthService;
|
||||
import com.youlai.boot.framework.annotation.Log;
|
||||
import com.youlai.boot.framework.annotation.RateLimit;
|
||||
import com.youlai.boot.common.annotation.Log;
|
||||
import com.youlai.boot.common.annotation.RateLimit;
|
||||
import com.youlai.boot.common.enums.ActionTypeEnum;
|
||||
import com.youlai.boot.common.enums.LogModuleEnum;
|
||||
import com.youlai.boot.common.result.Result;
|
||||
@@ -29,9 +29,9 @@ import jakarta.validation.Valid;
|
||||
* @author Ray.Hao
|
||||
* @since 2.4.0
|
||||
*/
|
||||
@Tag(name = "13.微信小程序认证")
|
||||
@Tag(name = "01.认证中心")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/wxma/auth")
|
||||
@RequestMapping("/api/v1/auth/wxma")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WxMaAuthController {
|
||||
@@ -47,15 +47,15 @@ public class WxMaAuthController {
|
||||
* <li>未绑定手机号的用户:返回 openid,需调用绑定手机号接口</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号")
|
||||
@Operation(summary = "[小程序]静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号")
|
||||
@PostMapping("/silent-login")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit
|
||||
public Result<WxMaLoginVO> silentLogin(
|
||||
public Result<WxMaLoginResp> silentLogin(
|
||||
@Parameter(description = "微信登录凭证(wx.login 获取)", required = true, example = "0xxx")
|
||||
@RequestParam String code
|
||||
) {
|
||||
WxMaLoginVO result = wxMaAuthService.silentLogin(code);
|
||||
WxMaLoginResp result = wxMaAuthService.silentLogin(code);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@@ -66,11 +66,11 @@ public class WxMaAuthController {
|
||||
* <p>
|
||||
* 一步完成登录,无需绑定流程,自动创建新用户
|
||||
*/
|
||||
@Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
|
||||
@Operation(summary = "[小程序]手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
|
||||
@PostMapping("/phone-login")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit
|
||||
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) {
|
||||
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginReq req) {
|
||||
AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode());
|
||||
return Result.success(result);
|
||||
}
|
||||
@@ -82,11 +82,11 @@ public class WxMaAuthController {
|
||||
* <p>
|
||||
* 绑定成功后自动完成登录
|
||||
*/
|
||||
@Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
|
||||
@Operation(summary = "[小程序]绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
|
||||
@PostMapping("/bind-mobile")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit
|
||||
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
|
||||
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileReq req) {
|
||||
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.captcha.enums;
|
||||
package com.youlai.boot.auth.enums;
|
||||
|
||||
/**
|
||||
* EasyCaptcha 验证码类型
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.youlai.boot.framework.captcha.exception;
|
||||
package com.youlai.boot.auth.exception;
|
||||
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 验证码异常
|
||||
* 图形验证码校验失败时抛出的异常。
|
||||
* <p>
|
||||
* 携带 {@link ResultCode} 用于统一错误响应。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 3.1.0
|
||||
*/
|
||||
@Getter
|
||||
public class CaptchaException extends RuntimeException {
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.auth.security.exception;
|
||||
package com.youlai.boot.auth.exception;
|
||||
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.youlai.boot.auth.qrcode.model;
|
||||
package com.youlai.boot.auth.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 扫码登录票据上下文,序列化为 JSON 存入 Redis。
|
||||
* <p>
|
||||
* 字段说明见 docs/youlai-boot/scan-code-login.md 的 Redis 存储设计。
|
||||
* 扫码登录票据上下文,序列化为 JSON 存入 Redis
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.5.0
|
||||
*/
|
||||
@Data
|
||||
public class QrCodeLoginContext {
|
||||
@@ -13,7 +14,7 @@ public class QrCodeLoginContext {
|
||||
/** 票据,UUID 无连字符 */
|
||||
private String ticket;
|
||||
|
||||
/** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
|
||||
/** 状态枚举名,取 {@link com.youlai.boot.auth.enums.QrCodeLoginStatusEnum#name()} */
|
||||
private String status;
|
||||
|
||||
/** 扫码用户 ID,scan 时写入 */
|
||||
@@ -34,6 +35,6 @@ public class QrCodeLoginContext {
|
||||
/** 确认时间戳(毫秒) */
|
||||
private Long confirmedAt;
|
||||
|
||||
/** generate 时的 PC 端 IP,用于审计 */
|
||||
/** generate 时的 PC 端 IP */
|
||||
private String clientIp;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.youlai.boot.auth.model.form;
|
||||
package com.youlai.boot.auth.model.req;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 登录表单
|
||||
* 登录请求参数
|
||||
*/
|
||||
@Schema(description = "登录请求参数")
|
||||
@Data
|
||||
public class LoginForm {
|
||||
public class LoginReq {
|
||||
|
||||
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.youlai.boot.auth.model.form;
|
||||
package com.youlai.boot.auth.model.req;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信小程序绑定手机号表单
|
||||
* 微信小程序绑定手机号请求
|
||||
*/
|
||||
@Schema(description = "微信小程序绑定手机号请求")
|
||||
@Data
|
||||
public class WxMaBindMobileForm {
|
||||
public class WxMaBindMobileReq {
|
||||
|
||||
@NotBlank(message = "openid 不能为空")
|
||||
@Schema(description = "微信用户唯一标识", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4")
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.youlai.boot.auth.model.form;
|
||||
package com.youlai.boot.auth.model.req;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信小程序手机号快捷登录表单
|
||||
* 微信小程序手机号快捷登录请求
|
||||
*/
|
||||
@Schema(description = "微信小程序手机号快捷登录请求")
|
||||
@Data
|
||||
public class WxMaPhoneLoginForm {
|
||||
public class WxMaPhoneLoginReq {
|
||||
|
||||
@NotBlank(message = "微信登录凭证不能为空")
|
||||
@Schema(description = "微信登录凭证(wx.login 获取)", example = "0xxx")
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.captcha.model;
|
||||
package com.youlai.boot.auth.model.resp;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.youlai.boot.auth.qrcode.model.vo;
|
||||
package com.youlai.boot.auth.model.resp;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* generate 接口响应。
|
||||
* generate 接口响应
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.5.0
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Schema(description = "扫码票据生成结果")
|
||||
public class QrCodeGenerateVO {
|
||||
public class QrCodeGenerateResp {
|
||||
|
||||
@Schema(description = "票据")
|
||||
private String ticket;
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.youlai.boot.auth.qrcode.model.vo;
|
||||
package com.youlai.boot.auth.model.resp;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* status/scan/confirm/cancel 接口响应。
|
||||
* status/scan/confirm/cancel 接口响应
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.5.0
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Schema(description = "扫码登录状态")
|
||||
public class QrCodeStatusVO {
|
||||
public class QrCodeStatusResp {
|
||||
|
||||
@Schema(description = "票据")
|
||||
private String ticket;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.auth.model.vo;
|
||||
package com.youlai.boot.auth.model.resp;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -14,7 +14,7 @@ import lombok.NoArgsConstructor;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "微信小程序登录响应")
|
||||
public class WxMaLoginVO {
|
||||
public class WxMaLoginResp {
|
||||
|
||||
@Schema(description = "是否新用户")
|
||||
private Boolean isNewUser;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import cn.hutool.json.JSONUtil;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.web.util.ResponseWriter;
|
||||
import com.youlai.boot.framework.captcha.exception.CaptchaException;
|
||||
import com.youlai.boot.framework.captcha.service.CaptchaService;
|
||||
import com.youlai.boot.auth.exception.CaptchaException;
|
||||
import com.youlai.boot.auth.service.CaptchaService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
|
||||
@@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.framework.security.model.SecurityUser;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
|
||||
import com.youlai.boot.auth.security.exception.SmsCaptchaException;
|
||||
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
|
||||
import com.youlai.boot.auth.exception.SmsCaptchaException;
|
||||
import com.youlai.boot.auth.security.token.SmsAuthenticationToken;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
|
||||
@@ -7,8 +7,8 @@ import com.youlai.boot.common.enums.SocialPlatformEnum;
|
||||
import com.youlai.boot.framework.security.model.SecurityUser;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
|
||||
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
|
||||
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
|
||||
import com.youlai.boot.common.exception.MobileNotBoundException;
|
||||
import com.youlai.boot.auth.security.token.WxMaAuthenticationToken;
|
||||
import com.youlai.boot.system.model.entity.UserSocial;
|
||||
import com.youlai.boot.system.service.UserSocialService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.auth.security.model;
|
||||
package com.youlai.boot.auth.security.token;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.auth.security.model;
|
||||
package com.youlai.boot.auth.security.token;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
@@ -1,56 +1,143 @@
|
||||
package com.youlai.boot.auth.service;
|
||||
|
||||
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.auth.model.resp.CaptchaInfo;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.token.TokenManager;
|
||||
import com.youlai.boot.auth.security.token.SmsAuthenticationToken;
|
||||
import com.youlai.boot.framework.security.util.SecurityUtils;
|
||||
import com.youlai.boot.support.sms.SmsTypeEnum;
|
||||
import com.youlai.boot.support.sms.SmsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 认证服务接口
|
||||
* 认证服务
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public interface AuthService {
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthService {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final TokenManager tokenManager;
|
||||
|
||||
private final SmsService smsService;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final CaptchaService captchaService;
|
||||
|
||||
/**
|
||||
* 账号密码登录
|
||||
* 用户名密码登录
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @return 认证令牌
|
||||
* @return 访问令牌
|
||||
*/
|
||||
AuthenticationToken login(String username, String password);
|
||||
public AuthenticationToken login(String username, String password) {
|
||||
// 1. 创建用于密码认证的令牌(未认证)
|
||||
UsernamePasswordAuthenticationToken authenticationToken =
|
||||
new UsernamePasswordAuthenticationToken(username.trim(), password);
|
||||
|
||||
// 2. 执行认证(认证中)
|
||||
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
|
||||
// 默认情况下会委托给 DaoAuthenticationProvider:
|
||||
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
|
||||
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。
|
||||
Authentication authentication = authenticationManager.authenticate(authenticationToken);
|
||||
|
||||
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
|
||||
AuthenticationToken authenticationTokenResponse =
|
||||
tokenManager.generateToken(authentication);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
return authenticationTokenResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录短信验证码
|
||||
*
|
||||
* @param mobile 手机号
|
||||
*/
|
||||
public void sendSmsCode(String mobile) {
|
||||
|
||||
// 随机生成4位验证码
|
||||
// String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
|
||||
// TODO 为了方便测试,验证码固定为 1234,实际开发中在配置了厂商短信服务后,可以使用上面的随机验证码
|
||||
String code = "1234";
|
||||
|
||||
// 发送短信验证码
|
||||
Map<String, String> templateParams = new HashMap<>();
|
||||
templateParams.put("code", code);
|
||||
try {
|
||||
smsService.send(mobile, SmsTypeEnum.LOGIN, templateParams);
|
||||
} catch (Exception e) {
|
||||
log.error("发送短信验证码失败", e);
|
||||
}
|
||||
// 缓存验证码至Redis,用于登录校验
|
||||
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码登录
|
||||
*
|
||||
* @param mobile 手机号
|
||||
* @param code 验证码
|
||||
* @return 认证令牌
|
||||
* @return 访问令牌
|
||||
*/
|
||||
AuthenticationToken loginBySms(String mobile, String code);
|
||||
public AuthenticationToken loginBySms(String mobile, String code) {
|
||||
// 1. 创建用户短信验证码认证的令牌(未认证)
|
||||
SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobile, code);
|
||||
|
||||
// 2. 执行认证(认证中)
|
||||
Authentication authentication = authenticationManager.authenticate(smsAuthenticationToken);
|
||||
|
||||
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
|
||||
AuthenticationToken authenticationToken = tokenManager.generateToken(authentication);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
return authenticationToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*
|
||||
* @param mobile 手机号
|
||||
* 注销登录
|
||||
*/
|
||||
void sendSmsCode(String mobile);
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
void logout();
|
||||
public void logout() {
|
||||
String token = SecurityUtils.getAccessToken();
|
||||
if (StrUtil.isNotBlank(token)) {
|
||||
tokenManager.invalidateToken(token);
|
||||
// 清除Security上下文
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
CaptchaInfo getCaptcha();
|
||||
public CaptchaInfo getCaptcha() {
|
||||
return captchaService.generate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
* 刷新token
|
||||
*
|
||||
* @param refreshToken 刷新令牌
|
||||
* @return 认证令牌
|
||||
* @return 新的访问令牌
|
||||
*/
|
||||
AuthenticationToken refreshToken(String refreshToken);
|
||||
public AuthenticationToken refreshToken(String refreshToken) {
|
||||
return tokenManager.refreshToken(refreshToken);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.captcha.service;
|
||||
package com.youlai.boot.auth.service;
|
||||
|
||||
import cn.hutool.captcha.AbstractCaptcha;
|
||||
import cn.hutool.captcha.CaptchaUtil;
|
||||
@@ -6,11 +6,11 @@ import cn.hutool.captcha.generator.CodeGenerator;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.framework.captcha.enums.CaptchaTypeEnum;
|
||||
import com.youlai.boot.auth.enums.CaptchaTypeEnum;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.captcha.config.CaptchaProperties;
|
||||
import com.youlai.boot.framework.captcha.exception.CaptchaException;
|
||||
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
|
||||
import com.youlai.boot.config.property.CaptchaProperties;
|
||||
import com.youlai.boot.auth.exception.CaptchaException;
|
||||
import com.youlai.boot.auth.model.resp.CaptchaInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -20,6 +20,9 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 验证码服务
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 3.1.0
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -1,16 +1,14 @@
|
||||
package com.youlai.boot.auth.qrcode.service.impl;
|
||||
package com.youlai.boot.auth.service;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext;
|
||||
import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
|
||||
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
|
||||
import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker;
|
||||
import com.youlai.boot.auth.enums.QrCodeLoginStatusEnum;
|
||||
import com.youlai.boot.auth.model.dto.QrCodeLoginContext;
|
||||
import com.youlai.boot.auth.model.resp.QrCodeGenerateResp;
|
||||
import com.youlai.boot.auth.model.resp.QrCodeStatusResp;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.security.exception.TokenInvalidException;
|
||||
import com.youlai.boot.common.exception.TokenInvalidException;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.model.SecurityUser;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
@@ -27,23 +25,20 @@ import tools.jackson.databind.json.JsonMapper;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 扫码登录服务实现。
|
||||
* <p>
|
||||
* 票据上下文以 JSON 形式存入 Redis,TTL 与有效期一致;状态流转的合法性全部由
|
||||
* {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截,非法迁移直接抛业务异常,
|
||||
* 因此同一 ticket 被多端并发操作时不需要分布式锁——最坏情况是后到的操作在状态校验处失败。
|
||||
* PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)},
|
||||
* 生成的令牌与账号密码登录走的是同一套会话治理(单/多设备、登出、刷新),前端无差别处理。
|
||||
* 扫码登录服务
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.5.0
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
public class QrCodeLoginService {
|
||||
|
||||
/** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */
|
||||
/** 票据默认有效期(秒) */
|
||||
private static final int DEFAULT_EXPIRE_SECONDS = 300;
|
||||
|
||||
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */
|
||||
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值 */
|
||||
private static final int MIN_REMAIN_SECONDS = 30;
|
||||
|
||||
/**
|
||||
@@ -56,8 +51,10 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
private final TokenManager tokenManager;
|
||||
private final UserSocialService userSocialService;
|
||||
|
||||
@Override
|
||||
public QrCodeGenerateVO generate(String clientIp) {
|
||||
/**
|
||||
* 生成扫码票据,写入 Redis 后返回 ticket 和有效期
|
||||
*/
|
||||
public QrCodeGenerateResp generate(String clientIp) {
|
||||
String ticket = IdUtil.fastSimpleUUID();
|
||||
QrCodeLoginContext ctx = new QrCodeLoginContext();
|
||||
ctx.setTicket(ticket);
|
||||
@@ -65,60 +62,70 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
ctx.setCreatedAt(System.currentTimeMillis());
|
||||
ctx.setClientIp(clientIp);
|
||||
save(ctx, DEFAULT_EXPIRE_SECONDS);
|
||||
return QrCodeGenerateVO.builder()
|
||||
return QrCodeGenerateResp.builder()
|
||||
.ticket(ticket)
|
||||
.expireSeconds(DEFAULT_EXPIRE_SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO status(String ticket) {
|
||||
/**
|
||||
* PC 端轮询扫码状态,未登录调用
|
||||
*/
|
||||
public QrCodeStatusResp status(String ticket) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
return toResp(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO scan(String ticket, Long userId) {
|
||||
/**
|
||||
* APP 端标记已扫码,需已登录。WAITING → SCANNED
|
||||
*/
|
||||
public QrCodeStatusResp scan(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.WAITING);
|
||||
fillUserInfo(ctx, userId);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name());
|
||||
ctx.setScannedAt(System.currentTimeMillis());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
return toResp(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO confirm(String ticket, Long userId) {
|
||||
/**
|
||||
* APP 端确认登录,需扫码本人操作。SCANNED → CONFIRMED
|
||||
*/
|
||||
public QrCodeStatusResp confirm(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED);
|
||||
requireSameUser(ctx, userId);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name());
|
||||
ctx.setConfirmedAt(System.currentTimeMillis());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
return toResp(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO cancel(String ticket, Long userId) {
|
||||
/**
|
||||
* APP 端取消登录,仅流程中的票据可取消
|
||||
*/
|
||||
public QrCodeStatusResp cancel(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
|
||||
// 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消,已登录或已取消的重复操作直接拒绝
|
||||
// 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消
|
||||
if (current != QrCodeLoginStatusEnum.WAITING
|
||||
&& current != QrCodeLoginStatusEnum.SCANNED
|
||||
&& current != QrCodeLoginStatusEnum.CONFIRMED) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
|
||||
}
|
||||
// 一旦有人扫过码,取消权就归扫码本人,防止他人替扫码用户取消
|
||||
// 一旦有人扫过码,取消权就归扫码本人
|
||||
if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) {
|
||||
requireSameUser(ctx, userId);
|
||||
}
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
return toResp(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
/**
|
||||
* PC 端用票据换取会话令牌,CONFIRMED → LOGGED_IN(一次性)
|
||||
*/
|
||||
public AuthenticationToken login(String ticket) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED);
|
||||
@@ -130,15 +137,15 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken(
|
||||
details, null, details.getAuthorities());
|
||||
AuthenticationToken token = tokenManager.generateToken(authentication);
|
||||
// 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒,杜绝重放
|
||||
// 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name());
|
||||
save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS));
|
||||
return token;
|
||||
}
|
||||
|
||||
// ======================== private ========================
|
||||
|
||||
/** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */
|
||||
/**
|
||||
* 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND
|
||||
*/
|
||||
private QrCodeLoginContext loadOrThrow(String ticket) {
|
||||
if (StrUtil.isBlank(ticket)) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
|
||||
@@ -159,21 +166,27 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */
|
||||
/**
|
||||
* 当前状态必须等于期望状态,否则说明流程被跳步或重复操作
|
||||
*/
|
||||
private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) {
|
||||
if (!expected.name().equals(ctx.getStatus())) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
|
||||
}
|
||||
}
|
||||
|
||||
/** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */
|
||||
/**
|
||||
* 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认
|
||||
*/
|
||||
private void requireSameUser(QrCodeLoginContext ctx, Long userId) {
|
||||
if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */
|
||||
/**
|
||||
* 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示
|
||||
*/
|
||||
private void fillUserInfo(QrCodeLoginContext ctx, Long userId) {
|
||||
SecurityUser info = userSocialService.getAuthInfoByUserId(userId);
|
||||
if (info == null) {
|
||||
@@ -184,35 +197,43 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
ctx.setAvatar(info.getAvatar());
|
||||
}
|
||||
|
||||
/** 重写整个上下文并刷新 TTL */
|
||||
/**
|
||||
* 重写整个上下文并刷新 TTL
|
||||
*/
|
||||
private void save(QrCodeLoginContext ctx, int ttl) {
|
||||
redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */
|
||||
/**
|
||||
* 票据在 Redis 中的剩余秒数,取不到或已过期返回 0
|
||||
*/
|
||||
private int remainingSeconds(String ticket) {
|
||||
Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS);
|
||||
return ttl == null ? 0 : Math.max(ttl.intValue(), 0);
|
||||
}
|
||||
|
||||
/** 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足 */
|
||||
/**
|
||||
* 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足
|
||||
*/
|
||||
private int refreshTtl(String ticket) {
|
||||
int remain = remainingSeconds(ticket);
|
||||
return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain;
|
||||
}
|
||||
|
||||
/** 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */
|
||||
private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) {
|
||||
/**
|
||||
* 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露
|
||||
*/
|
||||
private QrCodeStatusResp toResp(QrCodeLoginContext ctx, int expireSeconds) {
|
||||
QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
|
||||
String nickname = null;
|
||||
String avatar = null;
|
||||
// WAITING 阶段谁都能查状态,此时不能泄露用户信息;扫码/确认后才回传脱敏昵称与头像
|
||||
if (status == QrCodeLoginStatusEnum.SCANNED
|
||||
|| status == QrCodeLoginStatusEnum.CONFIRMED) {
|
||||
nickname = QrCodeNicknameMasker.mask(ctx.getNickname());
|
||||
nickname = maskNickname(ctx.getNickname());
|
||||
avatar = ctx.getAvatar();
|
||||
}
|
||||
return QrCodeStatusVO.builder()
|
||||
return QrCodeStatusResp.builder()
|
||||
.ticket(ctx.getTicket())
|
||||
.status(status.name())
|
||||
.nickname(nickname)
|
||||
@@ -221,7 +242,23 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 拼接票据在 Redis 中的完整 Key */
|
||||
/**
|
||||
* 昵称脱敏:保留首尾各一个字符,中间用 * 替换
|
||||
*/
|
||||
private String maskNickname(String nickname) {
|
||||
if (StrUtil.isBlank(nickname)) {
|
||||
return "";
|
||||
}
|
||||
int len = nickname.length();
|
||||
if (len == 1) {
|
||||
return nickname;
|
||||
}
|
||||
return StrUtil.hide(nickname, 1, len - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接票据在 Redis 中的完整 Key
|
||||
*/
|
||||
private String key(String ticket) {
|
||||
return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket);
|
||||
}
|
||||
@@ -1,15 +1,53 @@
|
||||
package com.youlai.boot.auth.service;
|
||||
|
||||
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
|
||||
import cn.binarywang.wx.miniapp.api.WxMaService;
|
||||
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
|
||||
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.auth.model.resp.WxMaLoginResp;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
import com.youlai.boot.framework.security.token.TokenManager;
|
||||
import com.youlai.boot.common.exception.MobileNotBoundException;
|
||||
import com.youlai.boot.auth.security.token.WxMaAuthenticationToken;
|
||||
import com.youlai.boot.common.enums.SocialPlatformEnum;
|
||||
import com.youlai.boot.system.model.entity.SysUser;
|
||||
import com.youlai.boot.system.service.UserSocialService;
|
||||
import com.youlai.boot.system.service.UserService;
|
||||
import com.youlai.boot.system.service.UserRoleService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 微信小程序认证服务接口
|
||||
* 微信小程序认证服务
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.4.0
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public interface WxMaAuthService {
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WxMaAuthService {
|
||||
|
||||
private final WxMaService wxMaService;
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final TokenManager tokenManager;
|
||||
private final UserService userService;
|
||||
private final UserSocialService userSocialService;
|
||||
private final UserRoleService userRoleService;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/**
|
||||
* 静默登录
|
||||
@@ -21,21 +59,61 @@ public interface WxMaAuthService {
|
||||
* @param code 微信登录凭证(wx.login 获取)
|
||||
* @return 登录结果(成功返回 token,需绑定返回 openid)
|
||||
*/
|
||||
WxMaLoginVO silentLogin(String code);
|
||||
public WxMaLoginResp silentLogin(String code) {
|
||||
WxMaAuthenticationToken token = new WxMaAuthenticationToken(code);
|
||||
|
||||
try {
|
||||
Authentication authentication = authenticationManager.authenticate(token);
|
||||
AuthenticationToken authToken = tokenManager.generateToken(authentication);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
return WxMaLoginResp.builder()
|
||||
.isNewUser(false)
|
||||
.needBindMobile(false)
|
||||
.accessToken(authToken.getAccessToken())
|
||||
.refreshToken(authToken.getRefreshToken())
|
||||
.tokenType(authToken.getTokenType())
|
||||
.expiresIn(authToken.getExpiresIn())
|
||||
.build();
|
||||
} catch (MobileNotBoundException e) {
|
||||
return WxMaLoginResp.builder()
|
||||
.isNewUser(true)
|
||||
.needBindMobile(true)
|
||||
.openid(e.getOpenid())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号快捷登录
|
||||
* <p>
|
||||
* 同时使用微信登录凭证和手机号授权凭证,
|
||||
* 一步完成用户注册/登录,无需额外绑定流程。
|
||||
* 适用于企业认证的小程序(已开通手机号快捷登录权限)。
|
||||
* </p>
|
||||
*
|
||||
* @param loginCode 微信登录凭证(wx.login 获取)
|
||||
* @param phoneCode 手机号授权凭证(getPhoneNumber 事件获取)
|
||||
* @return 认证令牌
|
||||
*/
|
||||
AuthenticationToken phoneLogin(String loginCode, String phoneCode);
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AuthenticationToken phoneLogin(String loginCode, String phoneCode) {
|
||||
// 1. 解析微信登录凭证,获取会话信息
|
||||
WxMaJscode2SessionResult session = resolveSession(loginCode);
|
||||
String openid = session.getOpenid();
|
||||
|
||||
// 2. 解析手机号授权凭证,获取手机号
|
||||
String mobile = resolvePhoneNumber(phoneCode);
|
||||
|
||||
log.info("微信小程序手机号快捷登录:openid={}, mobile={}", openid, mobile);
|
||||
|
||||
// 3. 查询或创建用户
|
||||
SysUser user = findOrCreateUser(mobile);
|
||||
|
||||
// 4. 绑定微信 openid
|
||||
bindWechatOpenid(user, session);
|
||||
|
||||
// 5. 生成认证令牌
|
||||
return generateAuthToken(mobile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号
|
||||
@@ -49,5 +127,139 @@ public interface WxMaAuthService {
|
||||
* @param smsCode 短信验证码
|
||||
* @return 认证令牌
|
||||
*/
|
||||
AuthenticationToken bindMobile(String openid, String mobile, String smsCode);
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AuthenticationToken bindMobile(String openid, String mobile, String smsCode) {
|
||||
// 1. 验证短信验证码
|
||||
validateSmsCode(mobile, smsCode);
|
||||
|
||||
// 2. 查询或创建用户
|
||||
SysUser user = findOrCreateUser(mobile);
|
||||
|
||||
// 3. 绑定微信 openid
|
||||
userSocialService.bindOrUpdate(
|
||||
user.getId(),
|
||||
SocialPlatformEnum.WECHAT_MINI,
|
||||
openid,
|
||||
null, null, null, null
|
||||
);
|
||||
|
||||
log.info("微信小程序绑定手机号成功:mobile={}, openid={}", mobile, openid);
|
||||
|
||||
// 4. 生成认证令牌
|
||||
return generateAuthToken(mobile);
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
* 解析微信登录凭证,获取会话信息
|
||||
*/
|
||||
private WxMaJscode2SessionResult resolveSession(String loginCode) {
|
||||
try {
|
||||
return wxMaService.jsCode2SessionInfo(loginCode);
|
||||
} catch (Exception e) {
|
||||
log.error("获取微信会话信息失败,loginCode={}", loginCode, e);
|
||||
throw new IllegalArgumentException("微信登录失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析手机号授权凭证,获取手机号
|
||||
*/
|
||||
private String resolvePhoneNumber(String phoneCode) {
|
||||
try {
|
||||
WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode);
|
||||
return phoneInfo.getPhoneNumber();
|
||||
} catch (Exception e) {
|
||||
log.error("获取微信手机号失败,phoneCode={}", phoneCode, e);
|
||||
throw new IllegalArgumentException("获取手机号失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询或创建用户
|
||||
*/
|
||||
private SysUser findOrCreateUser(String mobile) {
|
||||
SysUser user = userService.lambdaQuery()
|
||||
.eq(SysUser::getMobile, mobile)
|
||||
.one();
|
||||
|
||||
if (user == null) {
|
||||
user = createNewUser(mobile);
|
||||
log.info("微信小程序登录:创建新用户,mobile={}, userId={}", mobile, user.getId());
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新用户
|
||||
* <p>
|
||||
* 新用户默认分配 GUEST(访问游客)角色
|
||||
* </p>
|
||||
*/
|
||||
private SysUser createNewUser(String mobile) {
|
||||
SysUser user = new SysUser();
|
||||
user.setMobile(mobile);
|
||||
user.setUsername("wx_" + IdUtil.fastSimpleUUID().substring(0, 8));
|
||||
user.setNickname("微信用户");
|
||||
user.setStatus(1);
|
||||
user.setIsDeleted(0);
|
||||
user.setCreateTime(LocalDateTime.now());
|
||||
user.setUpdateTime(LocalDateTime.now());
|
||||
userService.save(user);
|
||||
|
||||
// 分配 GUEST 角色(角色ID=3)
|
||||
userRoleService.saveUserRoles(user.getId(), Collections.singletonList(3L));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定微信 openid
|
||||
*/
|
||||
private void bindWechatOpenid(SysUser user, WxMaJscode2SessionResult session) {
|
||||
try {
|
||||
userSocialService.bindOrUpdate(
|
||||
user.getId(),
|
||||
SocialPlatformEnum.WECHAT_MINI,
|
||||
session.getOpenid(),
|
||||
session.getUnionid(),
|
||||
user.getNickname(),
|
||||
user.getAvatar(),
|
||||
session.getSessionKey()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
// 绑定失败不影响登录
|
||||
log.warn("绑定微信 openid 失败,userId={}, openid={}", user.getId(), session.getOpenid(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证短信验证码
|
||||
*/
|
||||
private void validateSmsCode(String mobile, String smsCode) {
|
||||
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
|
||||
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (!StrUtil.equals(smsCode, cachedCode)) {
|
||||
throw new IllegalArgumentException("验证码错误");
|
||||
}
|
||||
|
||||
// 验证成功后删除验证码
|
||||
redisTemplate.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成认证令牌
|
||||
*/
|
||||
private AuthenticationToken generateAuthToken(String mobile) {
|
||||
SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities()
|
||||
);
|
||||
AuthenticationToken authToken = tokenManager.generateToken(authentication);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
return authToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 完成匹配)
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import com.youlai.boot.codegen.model.form.GenConfigForm;
|
||||
import com.youlai.boot.codegen.model.query.TableQuery;
|
||||
import com.youlai.boot.codegen.model.vo.CodegenPreviewVO;
|
||||
import com.youlai.boot.codegen.model.vo.TablePageVO;
|
||||
import com.youlai.boot.framework.annotation.Log;
|
||||
import com.youlai.boot.common.annotation.Log;
|
||||
import com.youlai.boot.codegen.service.GenTableService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.youlai.boot.codegen.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
|
||||
import com.youlai.boot.framework.base.BaseEntity;
|
||||
import com.youlai.boot.common.base.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.youlai.boot.framework.base.BaseEntity;
|
||||
import com.youlai.boot.common.base.BaseEntity;
|
||||
import com.youlai.boot.codegen.enums.FormTypeEnum;
|
||||
import com.youlai.boot.codegen.enums.QueryTypeEnum;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.annotation;
|
||||
package com.youlai.boot.common.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.annotation;
|
||||
package com.youlai.boot.common.annotation;
|
||||
|
||||
import com.youlai.boot.common.enums.ActionTypeEnum;
|
||||
import com.youlai.boot.common.enums.LogModuleEnum;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.annotation;
|
||||
package com.youlai.boot.common.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.annotation;
|
||||
package com.youlai.boot.common.annotation;
|
||||
|
||||
|
||||
import java.lang.annotation.*;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.annotation;
|
||||
package com.youlai.boot.common.annotation;
|
||||
|
||||
import com.youlai.boot.common.validator.FieldValidator;
|
||||
import jakarta.validation.Constraint;
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.youlai.boot.framework.web.aspect;
|
||||
package com.youlai.boot.common.aspect;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.useragent.UserAgent;
|
||||
import cn.hutool.http.useragent.UserAgentUtil;
|
||||
import com.youlai.boot.framework.annotation.Log;
|
||||
import com.youlai.boot.common.annotation.Log;
|
||||
import com.youlai.boot.common.enums.ActionTypeEnum;
|
||||
import com.youlai.boot.common.enums.LogModuleEnum;
|
||||
import com.youlai.boot.common.util.IPUtils;
|
||||
@@ -37,6 +37,8 @@ import java.util.concurrent.Executor;
|
||||
@Slf4j
|
||||
public class LogAspect {
|
||||
|
||||
private static final int MAX_ERROR_MSG_LENGTH = 1000;
|
||||
|
||||
private final LogService logService;
|
||||
private final Executor operationLogExecutor;
|
||||
|
||||
@@ -66,10 +68,6 @@ public class LogAspect {
|
||||
throw e;
|
||||
} finally {
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
if (userId == null) {
|
||||
userId = SecurityUtils.getUserId();
|
||||
username = SecurityUtils.getUsername();
|
||||
}
|
||||
try {
|
||||
SysLog entity = buildEntity(logAnnotation, elapsed, failure, userId, username);
|
||||
if (entity != null) {
|
||||
@@ -94,7 +92,6 @@ public class LogAspect {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
UserAgent ua = UserAgentUtil.parse(req.getHeader("User-Agent"));
|
||||
String ip = IPUtils.getIpAddr(req);
|
||||
IpRegion region = parseRegion(IPUtils.getRegion(ip));
|
||||
|
||||
LogModuleEnum module = ann.module();
|
||||
ActionTypeEnum action = ann.value();
|
||||
@@ -109,25 +106,34 @@ public class LogAspect {
|
||||
entity.setRequestUri(req.getRequestURI());
|
||||
entity.setRequestMethod(req.getMethod());
|
||||
entity.setIp(ip);
|
||||
entity.setProvince(region.province);
|
||||
entity.setCity(region.city);
|
||||
entity.setDevice(getDevice(ua));
|
||||
entity.setOs(getOs(ua));
|
||||
entity.setBrowser(getBrowser(ua));
|
||||
entity.setStatus(failure == null ? 1 : 0);
|
||||
entity.setErrorMsg(failure == null ? null : failure.getMessage());
|
||||
entity.setErrorMsg(failure == null ? null : truncate(failure.getMessage(), MAX_ERROR_MSG_LENGTH));
|
||||
entity.setExecutionTime((int) Math.min(elapsed, Integer.MAX_VALUE));
|
||||
entity.setCreateTime(LocalDateTime.now());
|
||||
|
||||
IpRegion region = parseRegion(ip);
|
||||
entity.setProvince(region.province);
|
||||
entity.setCity(region.city);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private IpRegion parseRegion(String region) {
|
||||
private IpRegion parseRegion(String ip) {
|
||||
String region = IPUtils.getRegion(ip);
|
||||
if (StrUtil.isBlank(region)) return IpRegion.EMPTY;
|
||||
String[] parts = region.split("\\|");
|
||||
if (parts.length < 4) return IpRegion.EMPTY;
|
||||
return new IpRegion(StrUtil.blankToDefault(parts[2], null), StrUtil.blankToDefault(parts[3], null));
|
||||
}
|
||||
|
||||
private String truncate(String msg, int maxLen) {
|
||||
if (msg == null) return null;
|
||||
return msg.length() <= maxLen ? msg : msg.substring(0, maxLen);
|
||||
}
|
||||
|
||||
private String getOs(UserAgent ua) {
|
||||
return Optional.ofNullable(ua).map(UserAgent::getOs).map(os -> os.getName()).orElse(null);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package com.youlai.boot.framework.web.aspect;
|
||||
package com.youlai.boot.common.aspect;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.youlai.boot.framework.annotation.RateLimit;
|
||||
import com.youlai.boot.common.annotation.RateLimit;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.common.util.IPUtils;
|
||||
import com.youlai.boot.framework.web.config.RateLimitProperties;
|
||||
import com.youlai.boot.framework.web.exception.RateLimitException;
|
||||
import com.youlai.boot.config.property.RateLimitProperties;
|
||||
import com.youlai.boot.common.exception.RateLimitException;
|
||||
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.web.aspect;
|
||||
package com.youlai.boot.common.aspect;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
@@ -7,7 +7,7 @@ import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.framework.annotation.RepeatSubmit;
|
||||
import com.youlai.boot.common.annotation.RepeatSubmit;
|
||||
import com.youlai.boot.common.util.IPUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.base;
|
||||
package com.youlai.boot.common.base;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.youlai.boot.common.base;
|
||||
|
||||
import com.youlai.boot.framework.annotation.ValidField;
|
||||
import com.youlai.boot.common.annotation.ValidField;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import com.youlai.boot.common.base.IBaseEnum;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 第三方登录平台类型枚举
|
||||
* 第三方登录平台类型枚举。
|
||||
* <p>
|
||||
* value 作为 MyBatis-Plus 枚举值持久化到数据库。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.17.0
|
||||
*/
|
||||
@Getter
|
||||
public enum SocialPlatformEnum implements IBaseEnum<String> {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.youlai.boot.auth.security.exception;
|
||||
package com.youlai.boot.common.exception;
|
||||
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
/**
|
||||
* 需要绑定手机号异常(微信小程序登录未绑定手机号时抛出)。
|
||||
* 微信小程序登录时手机号未绑定异常。
|
||||
* <p>
|
||||
* 携带 openid 和 sessionKey 供前端引导用户完成手机号绑定。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 3.1.0
|
||||
*/
|
||||
public class MobileNotBoundException extends AuthenticationException {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.web.exception;
|
||||
package com.youlai.boot.common.exception;
|
||||
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import lombok.Getter;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.security.exception;
|
||||
package com.youlai.boot.common.exception;
|
||||
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import lombok.Getter;
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.youlai.boot.common.validator;
|
||||
|
||||
import com.youlai.boot.framework.annotation.ValidField;
|
||||
import com.youlai.boot.common.annotation.ValidField;
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.cache;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.youlai.boot.framework.captcha.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import cn.hutool.captcha.generator.CodeGenerator;
|
||||
import cn.hutool.captcha.generator.MathGenerator;
|
||||
import cn.hutool.captcha.generator.RandomGenerator;
|
||||
import com.youlai.boot.config.property.CaptchaProperties;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.web.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.web.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.apidoc;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.youlai.boot.framework.integration.mail.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import com.youlai.boot.config.property.MailProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.mybatis.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.config.GlobalConfig;
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.youlai.boot.framework.apidoc;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import com.youlai.boot.framework.security.config.SecurityProperties;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Contact;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.web.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.security.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.cache;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.cache.autoconfigure.CacheProperties;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.cache;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -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.hutool.core.util.ArrayUtil;
|
||||
import com.youlai.boot.framework.captcha.service.CaptchaService;
|
||||
import com.youlai.boot.framework.security.config.SecurityProperties;
|
||||
import com.youlai.boot.auth.service.CaptchaService;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
|
||||
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
|
||||
import com.youlai.boot.framework.security.service.SecurityUserDetailsService;
|
||||
@@ -33,6 +33,8 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
|
||||
import jakarta.servlet.DispatcherType;
|
||||
|
||||
/**
|
||||
* Spring Security 配置类。
|
||||
* <p>
|
||||
@@ -59,6 +61,8 @@ public class SecurityConfig {
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests(requestMatcherRegistry -> {
|
||||
// ASYNC 分发(SSE 超时/完成后的异步 dispatch)无 Authorization 头,直接放行
|
||||
requestMatcherRegistry.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll();
|
||||
String[] ignoreUrls = securityProperties.getIgnoreUrls();
|
||||
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
|
||||
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.youlai.boot.framework.integration.wxma.config;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import cn.binarywang.wx.miniapp.api.WxMaService;
|
||||
import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl;
|
||||
import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl;
|
||||
import com.youlai.boot.config.property.WxMaProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.job;
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.integration.sms.config;
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.captcha.config;
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.file.config;
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.integration.mail.config;
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.web.config;
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.security.config;
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.framework.integration.wxma.config;
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import cn.hutool.core.collection.CollectionUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.StringPool;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler;
|
||||
import com.youlai.boot.framework.annotation.DataPermission;
|
||||
import com.youlai.boot.common.annotation.DataPermission;
|
||||
import com.youlai.boot.common.enums.DataScopeEnum;
|
||||
import com.youlai.boot.framework.security.model.RoleDataScope;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.util.Set;
|
||||
* 由 system 模块提供适配器实现,framework 层通过此接口获取角色权限集合,
|
||||
* 不直接依赖 system 模块的 {@code RoleMenuService}。
|
||||
*
|
||||
* @see com.youlai.boot.system.security.adapter.PermissionAdapter
|
||||
* @see com.youlai.boot.system.security.port.PermissionAdapter
|
||||
*/
|
||||
public interface PermissionPort {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import com.youlai.boot.framework.security.model.SecurityUser;
|
||||
* 由 system 模块提供适配器实现,framework 层通过此接口获取认证数据,
|
||||
* 不直接依赖 system 模块的 {@code UserService} / {@code UserSocialService}。
|
||||
*
|
||||
* @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter
|
||||
* @see com.youlai.boot.system.security.port.UserAuthenticationAdapter
|
||||
*/
|
||||
public interface UserAuthenticationPort {
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import com.youlai.boot.common.constant.JwtClaimConstants;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.security.config.SecurityProperties;
|
||||
import com.youlai.boot.framework.security.exception.TokenInvalidException;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.common.exception.TokenInvalidException;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.model.RoleDataScope;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
|
||||
@@ -5,8 +5,8 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.security.config.SecurityProperties;
|
||||
import com.youlai.boot.framework.security.exception.TokenInvalidException;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.common.exception.TokenInvalidException;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
|
||||
@@ -4,15 +4,17 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.security.exception.TokenInvalidException;
|
||||
import com.youlai.boot.framework.web.exception.RateLimitException;
|
||||
import com.youlai.boot.common.exception.TokenInvalidException;
|
||||
import com.youlai.boot.common.exception.RateLimitException;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.TypeMismatchException;
|
||||
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
@@ -176,11 +178,15 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@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
|
||||
|| e instanceof AuthenticationException) {
|
||||
throw e;
|
||||
}
|
||||
String accept = request.getHeader("Accept");
|
||||
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
|
||||
throw e;
|
||||
}
|
||||
log.error("unknown exception", e);
|
||||
return Result.failed(e.getLocalizedMessage());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.common.util.IPUtils;
|
||||
import com.youlai.boot.framework.web.config.RateLimitProperties;
|
||||
import com.youlai.boot.config.property.RateLimitProperties;
|
||||
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
|
||||
import com.youlai.boot.framework.web.util.ResponseWriter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 lombok.Data;
|
||||
|
||||
|
||||
/**
|
||||
* 文件信息对象
|
||||
*
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 对象存储服务接口层
|
||||
* 文件存储服务接口
|
||||
*
|
||||
* @author haoxr
|
||||
* @since 2022/11/19
|
||||
@@ -13,6 +13,7 @@ public interface FileService {
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*
|
||||
* @param file 表单文件对象
|
||||
* @return 文件信息
|
||||
*/
|
||||
@@ -26,5 +27,4 @@ public interface FileService {
|
||||
*/
|
||||
boolean deleteFile(String filePath);
|
||||
|
||||
|
||||
}
|
||||
@@ -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.io.FileUtil;
|
||||
@@ -8,8 +8,8 @@ import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.model.ObjectMetadata;
|
||||
import com.aliyun.oss.model.PutObjectRequest;
|
||||
import com.youlai.boot.file.service.FileService;
|
||||
import com.youlai.boot.file.model.FileInfo;
|
||||
import com.youlai.boot.support.file.model.FileInfo;
|
||||
import com.youlai.boot.support.file.service.FileService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -22,6 +22,12 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 阿里云 OSS 文件存储服务。
|
||||
* <p>
|
||||
* 当 {@code file-storage.type=aliyun} 时生效。
|
||||
* 上传文件按日期分目录存储,URL 格式:{@code https://bucket.endpoint/yyyyMMdd/uuid.suffix}。
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun")
|
||||
@ConfigurationProperties(prefix = "file-storage.aliyun")
|
||||
@@ -29,9 +35,13 @@ import java.time.LocalDateTime;
|
||||
@Data
|
||||
public class AliyunFileServiceImpl implements FileService {
|
||||
|
||||
/** OSS 地域节点,如 {@code oss-cn-hangzhou.aliyuncs.com} */
|
||||
private String endpoint;
|
||||
/** AccessKey ID */
|
||||
private String accessKeyId;
|
||||
/** AccessKey Secret */
|
||||
private String accessKeySecret;
|
||||
/** 存储空间名称 */
|
||||
private String bucket;
|
||||
|
||||
private OSS aliyunOssClient;
|
||||
@@ -41,6 +51,9 @@ public class AliyunFileServiceImpl implements FileService {
|
||||
aliyunOssClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到阿里云 OSS,按日期分目录,返回 {@code https://bucket.endpoint/path} 格式的 URL。
|
||||
*/
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public FileInfo uploadFile(MultipartFile file) {
|
||||
@@ -63,6 +76,12 @@ public class AliyunFileServiceImpl implements FileService {
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从完整 URL 中提取文件名后调用 OSS 删除。
|
||||
*
|
||||
* @param filePath 文件完整 URL
|
||||
* @return 删除成功返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean deleteFile(String filePath) {
|
||||
Assert.notBlank(filePath, "删除文件路径不能为空");
|
||||
@@ -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.DateUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.youlai.boot.file.model.FileInfo;
|
||||
import com.youlai.boot.file.service.FileService;
|
||||
import com.youlai.boot.support.file.model.FileInfo;
|
||||
import com.youlai.boot.support.file.service.FileService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -19,6 +19,13 @@ import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 本地文件存储服务。
|
||||
* <p>
|
||||
* 当 {@code file-storage.type=local} 时生效。
|
||||
* 文件写入本地磁盘 {@code file-storage.local.path} 下按日期分目录存储。
|
||||
* URL 为相对于存储根目录的路径,如 {@code /yyyyMMdd/uuid.suffix}。
|
||||
*/
|
||||
@Data
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -27,14 +34,18 @@ import java.time.LocalDateTime;
|
||||
@RequiredArgsConstructor
|
||||
public class LocalFileServiceImpl implements FileService {
|
||||
|
||||
/** 本地存储根目录,来自 {@code file-storage.local.path} */
|
||||
@Value("${file-storage.local.path}")
|
||||
private String path;
|
||||
|
||||
/**
|
||||
* 上传文件到本地磁盘,按日期分目录,返回相对于存储根目录的路径。
|
||||
*/
|
||||
@Override
|
||||
public FileInfo uploadFile(MultipartFile file) {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
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 filePrefix = path.endsWith(File.separator) ? path : path + File.separator;
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
@@ -50,6 +61,12 @@ public class LocalFileServiceImpl implements FileService {
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除本地文件。路径是目录或不存在时返回 false。
|
||||
*
|
||||
* @param filePath 相对于存储根目录的文件路径
|
||||
* @return 删除成功返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean deleteFile(String filePath) {
|
||||
if (filePath == null || filePath.isEmpty()) {
|
||||
@@ -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.io.FileUtil;
|
||||
@@ -7,8 +7,8 @@ import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.file.model.FileInfo;
|
||||
import com.youlai.boot.file.service.FileService;
|
||||
import com.youlai.boot.support.file.model.FileInfo;
|
||||
import com.youlai.boot.support.file.service.FileService;
|
||||
import io.minio.*;
|
||||
import io.minio.http.Method;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -24,6 +24,16 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.InputStream;
|
||||
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
|
||||
@ConditionalOnProperty(value = "file-storage.type", havingValue = "minio")
|
||||
@ConfigurationProperties(prefix = "file-storage.minio")
|
||||
@@ -32,10 +42,15 @@ import java.time.LocalDateTime;
|
||||
@Slf4j
|
||||
public class MinioFileServiceImpl implements FileService {
|
||||
|
||||
/** MinIO 服务端点,如 {@code http://localhost:9000} */
|
||||
private String endpoint;
|
||||
/** 访问密钥 */
|
||||
private String accessKey;
|
||||
/** 秘密密钥 */
|
||||
private String secretKey;
|
||||
/** 存储桶名称 */
|
||||
private String bucket;
|
||||
/** 自定义域名(可选),配置后不再使用预签名 URL */
|
||||
private String domain;
|
||||
|
||||
private MinioClient minioClient;
|
||||
@@ -48,6 +63,9 @@ public class MinioFileServiceImpl implements FileService {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到 MinIO,按日期分目录,自动创建存储桶,返回文件访问 URL。
|
||||
*/
|
||||
@Override
|
||||
public FileInfo uploadFile(MultipartFile file) {
|
||||
createBucketIfAbsent(bucket);
|
||||
@@ -59,7 +77,7 @@ public class MinioFileServiceImpl implements FileService {
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(dateFolder + "/"+ fileName)
|
||||
.object(dateFolder + "/" + fileName)
|
||||
.contentType(file.getContentType())
|
||||
.stream(inputStream, inputStream.available(), -1)
|
||||
.build();
|
||||
@@ -69,13 +87,13 @@ public class MinioFileServiceImpl implements FileService {
|
||||
if (StrUtil.isBlank(domain)) {
|
||||
GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(dateFolder + "/"+ fileName)
|
||||
.object(dateFolder + "/" + fileName)
|
||||
.method(Method.GET)
|
||||
.build();
|
||||
fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
|
||||
fileUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
|
||||
} else {
|
||||
fileUrl = domain + "/"+ bucket + "/"+ dateFolder + "/"+ fileName;
|
||||
fileUrl = domain + "/" + bucket + "/" + dateFolder + "/" + fileName;
|
||||
}
|
||||
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
@@ -88,6 +106,14 @@ public class MinioFileServiceImpl implements FileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件。
|
||||
* <p>
|
||||
* 从完整 URL 中提取文件名后调用 MinIO 删除。
|
||||
*
|
||||
* @param filePath 文件完整 URL
|
||||
* @return 删除成功返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean deleteFile(String filePath) {
|
||||
Assert.notBlank(filePath, "删除文件路径不能为空");
|
||||
@@ -107,17 +133,21 @@ public class MinioFileServiceImpl implements FileService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成公开读的存储桶策略 JSON */
|
||||
private static String publicBucketPolicy(String bucket) {
|
||||
return "{\"Version\":\"2012-10-17\","
|
||||
+ "\"Statement\":[{\"Effect\":\"Allow\","
|
||||
+ "\"Principal\":{\"AWS\":[\"*\"]},"
|
||||
+ "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"],"
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]},"
|
||||
+ "{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"" + "\"Principal\":{\"AWS\":[\"*\"]},"
|
||||
+ "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"],"
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储桶不存在时自动创建,并设置为公开读策略。
|
||||
*/
|
||||
@SneakyThrows
|
||||
private void createBucketIfAbsent(String bucket) {
|
||||
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
|
||||
32
src/main/java/com/youlai/boot/support/mail/EmailService.java
Normal file
32
src/main/java/com/youlai/boot/support/mail/EmailService.java
Normal 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);
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/main/java/com/youlai/boot/support/sms/SmsService.java
Normal file
25
src/main/java/com/youlai/boot/support/sms/SmsService.java
Normal 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);
|
||||
|
||||
}
|
||||
@@ -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 lombok.Getter;
|
||||
@@ -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 com.aliyuncs.CommonRequest;
|
||||
@@ -8,72 +8,56 @@ import com.aliyuncs.IAcsClient;
|
||||
import com.aliyuncs.exceptions.ClientException;
|
||||
import com.aliyuncs.http.MethodType;
|
||||
import com.aliyuncs.profile.DefaultProfile;
|
||||
import com.youlai.boot.framework.integration.sms.config.AliyunSmsProperties;
|
||||
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
|
||||
import com.youlai.boot.framework.integration.sms.service.SmsService;
|
||||
import com.youlai.boot.support.sms.SmsTypeEnum;
|
||||
import com.youlai.boot.config.property.AliyunSmsProperties;
|
||||
import com.youlai.boot.support.sms.SmsService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 阿里云短信业务类
|
||||
* 阿里云短信服务实现
|
||||
*
|
||||
* @author Ray
|
||||
* @since 2024/8/17
|
||||
* @since 3.1.0
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "sms.type", havingValue = "aliyun", matchIfMissing = true)
|
||||
@RequiredArgsConstructor
|
||||
public class AliyunSmsService implements SmsService {
|
||||
public class AliyunSmsServiceImpl implements SmsService {
|
||||
|
||||
private final AliyunSmsProperties aliyunSmsProperties;
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*
|
||||
* @param mobile 手机号 13388886666
|
||||
* @param smsType 短信模板 SMS_194640010
|
||||
* @param templateParams 模板参数 [{"code":"123456"}]
|
||||
* @return boolean 是否发送成功
|
||||
*/
|
||||
@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());
|
||||
|
||||
DefaultProfile profile = DefaultProfile.getProfile(aliyunSmsProperties.getRegionId(),
|
||||
aliyunSmsProperties.getAccessKeyId(), aliyunSmsProperties.getAccessKeySecret());
|
||||
IAcsClient client = new DefaultAcsClient(profile);
|
||||
|
||||
// 创建通用的请求对象
|
||||
CommonRequest request = new CommonRequest();
|
||||
// 指定请求方式
|
||||
request.setSysMethod(MethodType.POST);
|
||||
// 短信api的请求地址(固定)
|
||||
request.setSysDomain(aliyunSmsProperties.getDomain());
|
||||
// 签名算法版(固定)
|
||||
request.setSysVersion("2017-05-25");
|
||||
// 请求 API 的名称(固定)
|
||||
request.setSysAction("SendSms");
|
||||
// 指定地域名称
|
||||
request.putQueryParameter("RegionId", aliyunSmsProperties.getRegionId());
|
||||
// 要给哪个手机号发送短信 指定手机号
|
||||
request.putQueryParameter("PhoneNumbers", mobile);
|
||||
// 您的申请签名
|
||||
request.putQueryParameter("SignName", aliyunSmsProperties.getSignName());
|
||||
// 您申请的模板 code
|
||||
request.putQueryParameter("TemplateCode", templateCode);
|
||||
|
||||
request.putQueryParameter("TemplateParam", JSONUtil.toJsonStr(templateParams));
|
||||
|
||||
try {
|
||||
CommonResponse response = client.getCommonResponse(request);
|
||||
return response.getHttpResponse().isSuccess();
|
||||
} catch (ClientException e) {
|
||||
e.printStackTrace();
|
||||
log.error("发送短信失败: {}", e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user