refactor: SocialEndpoint 瘦控制器化, 编排逻辑下沉至 SocialLoginService

This commit is contained in:
DaxPay Dev
2026-06-21 22:20:01 +08:00
parent c4c115052d
commit 6264bab4bc
2 changed files with 143 additions and 82 deletions

View File

@@ -0,0 +1,90 @@
package cn.daxpay.open.platform.iam.endpoint;
import java.util.List;
import cn.daxpay.open.platform.capability.auth.util.SecurityUtil;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.platform.iam.result.social.SocialBindResult;
import cn.daxpay.open.platform.iam.result.social.SocialEnabledPlatformResult;
import cn.daxpay.open.platform.iam.result.social.SocialExchangeResult;
import cn.daxpay.open.platform.iam.service.social.SocialLoginService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
/// # 第三方社交登录端点
///
/// 瘦控制器, 仅负责 HTTP 参数解包与结果包装, 业务编排全部委托 [SocialLoginService].
/// 采用前端回调模式: 第三方平台直接重定向到前端回调页, 前端拿到 code+state 后
/// 调用 exchange 接口完成换 token, 后端不做 302 跳转.
///
@IgnoreAuth
@Tag(name = "第三方社交登录")
@RestController
@RequestMapping("/social")
@RequiredArgsConstructor
public class SocialEndpoint {
private final SocialLoginService socialLoginService;
/// 查询已启用的第三方登录平台(登录页公开接口)
/// 仅返回平台编码列表, 不含任何敏感字段, 供登录页动态渲染第三方登录按钮.
@Operation(summary = "查询已启用的第三方登录平台")
@GetMapping("/enabled-list")
public Result<List<SocialEnabledPlatformResult>> enabledList() {
return Res.ok(socialLoginService.enabledList());
}
/// 生成授权地址
/// @param source 平台来源
/// @param client 终端编码(admin/merchant), 用于解析端点配置中的 baseUrl
/// @param mode 授权场景(不传则按登录态判断: 已登录=绑定, 未登录=登录)
/// @param redirect 成功后前端跳转路径(可选)
@Operation(summary = "生成授权地址")
@GetMapping("/render/{source}")
public Result<String> render(@PathVariable String source,
@RequestParam String client,
@RequestParam(required = false) String mode,
@RequestParam(required = false) String redirect) {
return Res.ok(socialLoginService.generateAuthorizeUrl(source, client, mode, redirect));
}
/// OAuth 授权码兑换(前端回调模式)
/// 前端回调页收到第三方平台的 code+state 后调用此接口,
/// 后端完成 code 换 token 并返回结果 JSON.
@Operation(summary = "授权码兑换")
@PostMapping("/exchange")
public Result<SocialExchangeResult> exchange(@RequestParam("code") String code,
@RequestParam("state") String state,
HttpServletRequest request,
HttpServletResponse response) {
return Res.ok(socialLoginService.exchangeCode(code, state, request, response));
}
/// 查询当前登录用户已绑定的第三方账号
@IgnoreAuth(login = true)
@Operation(summary = "已绑定的第三方账号列表")
@GetMapping("/bind/list")
public Result<List<SocialBindResult>> bindList() {
return Res.ok(socialLoginService.bindList(SecurityUtil.getUserId()));
}
/// 解除当前登录用户的指定平台绑定
@IgnoreAuth(login = true)
@Operation(summary = "解除第三方账号绑定")
@PostMapping("/unbind")
public Result<Void> unbind(@RequestParam String source) {
socialLoginService.unbind(SecurityUtil.getUserId(), source);
return Res.ok();
}
}

View File

@@ -1,4 +1,6 @@
package cn.daxpay.open.platform.iam.endpoint.social;
package cn.daxpay.open.platform.iam.service.social;
import java.util.List;
import cn.daxpay.open.platform.capability.auth.util.SecurityUtil;
import cn.daxpay.open.platform.capability.social.auth.SocialAuthRequestFactory;
@@ -7,53 +9,39 @@ import cn.daxpay.open.platform.capability.social.justauth.SocialSourceEnum;
import cn.daxpay.open.platform.capability.social.justauth.model.AuthCallback;
import cn.daxpay.open.platform.capability.social.justauth.model.AuthUser;
import cn.daxpay.open.platform.capability.social.justauth.request.SocialAuthRequest;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.exception.operation.OperationFailException;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.platform.iam.entity.social.SocialConfig;
import cn.daxpay.open.platform.iam.enums.SocialClientEnum;
import cn.daxpay.open.platform.iam.result.social.SocialBindResult;
import cn.daxpay.open.platform.iam.result.social.SocialEnabledPlatformResult;
import cn.daxpay.open.platform.iam.result.social.SocialExchangeResult;
import cn.daxpay.open.platform.iam.service.social.IamSocialLoginHandler;
import cn.daxpay.open.platform.iam.service.social.IamUserSocialBindStore;
import cn.daxpay.open.platform.iam.service.social.SocialConfigService;
import cn.daxpay.open.platform.iam.service.social.cache.RedisSocialStateCache;
import cn.daxpay.open.platform.iam.service.social.cache.SocialAuthContext;
import cn.daxpay.open.platform.iam.service.social.cache.SocialAuthMode;
import cn.daxpay.open.platform.system.entity.config.platform.PlatformUrlConfig;
import cn.daxpay.open.platform.iam.enums.SocialClientEnum;
import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import org.springframework.stereotype.Service;
import java.util.List;
/// # 第三方社交登录端点
/// # 第三方社交登录编排服务
///
/// 提供 OAuth2 授权(render)授权码兑换(exchange)绑定管理(bind list/unbind)等接口.
/// 编排 OAuth2 授权流程: 授权地址生成(render)授权码兑换(exchange)
/// 绑定关系查询与解绑. [SocialEndpoint] 作为瘦控制器入口调用,
/// 数据访问与配置管理分别委托 [IamUserSocialBindStore] / [SocialConfigService],
/// Sa-Token 签发委托 [IamSocialLoginHandler].
/// 采用前端回调模式: 第三方平台直接重定向到前端回调页, 前端拿到 code+state
/// 调用 exchange API 完成换 token, 后端不做 302 跳转.
/// 调用 [exchangeCode] 完成换 token, 后端不做 302 跳转.
/// state 超时使用系统默认常量.
///
@Slf4j
@IgnoreAuth
@Tag(name = "第三方社交登录")
@RestController
@RequestMapping("/social")
@Service
@RequiredArgsConstructor
public class SocialEndpoint {
public class SocialLoginService {
/// state 缓存超时时间(), 用户完成第三方授权的合理等待时长
private static final long STATE_TIMEOUT_SECONDS = 300L;
@@ -72,10 +60,8 @@ public class SocialEndpoint {
/// 查询已启用的第三方登录平台(登录页公开接口)
/// 仅返回平台编码列表, 不含任何敏感字段, 供登录页动态渲染第三方登录按钮.
@Operation(summary = "查询已启用的第三方登录平台")
@GetMapping("/enabled-list")
public Result<List<SocialEnabledPlatformResult>> enabledList() {
return Res.ok(socialConfigService.findEnabledList());
public List<SocialEnabledPlatformResult> enabledList() {
return socialConfigService.findEnabledList();
}
/// 生成授权地址并缓存上下文(前端拿到后跳转)
@@ -83,27 +69,16 @@ public class SocialEndpoint {
/// @param client 终端编码(admin/merchant), 用于解析端点配置中的 baseUrl
/// @param mode 授权场景(不传则按登录态判断: 已登录=绑定, 未登录=登录)
/// @param redirect 成功后前端跳转路径(可选)
@Operation(summary = "生成授权地址")
@GetMapping("/render/{source}")
public Result<String> render(
@PathVariable String source,
@RequestParam String client,
@RequestParam(required = false) String mode,
@RequestParam(required = false) String redirect) {
public String generateAuthorizeUrl(String source, String client, String mode, String redirect) {
// 加载平台配置(全局唯一)
var config = this.socialConfigService.findEnabledBySource(source);
if (config == null) {
// 社交登录: 平台未配置或未启用
throw new OperationFailException("error.social.configNotExist");
}
SocialConfig config = this.loadEnabledConfig(source);
SocialSourceEnum socialSource = SocialSourceEnum.of(source);
if (socialSource == null) {
// 社交登录: 不支持的平台
throw new OperationFailException("error.social.unsupportedSource");
}
// client 解析前端 baseUrl(用于 redirectUri 自动生成)
PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig();
String baseUrl = SocialClientEnum.of(client).resolveBaseUrl(urlConfig);
String baseUrl = this.resolveBaseUrl(client);
// 回调地址由端点配置的 baseUrl 自动生成, 必须配置 baseUrl
if (StrUtil.isBlank(baseUrl)) {
// 社交登录: 端点配置缺失
@@ -125,37 +100,30 @@ public class SocialEndpoint {
// 构建授权请求并生成授权地址
SocialAuthConfig authConfig = socialConfigService.buildAuthConfig(config, baseUrl);
SocialAuthRequest request = socialAuthRequestFactory.create(socialSource, authConfig);
String authorizeUrl = request.authorize(state);
return Res.ok(authorizeUrl);
return request.authorize(state);
}
/// OAuth 授权码兑换(前端回调模式)
/// 前端回调页收到第三方平台的 code+state 后调用此接口,
/// 前端回调页收到第三方平台的 code+state 后调用此方法,
/// 后端完成 code token 并返回结果 JSON.
@Operation(summary = "授权码兑换")
@PostMapping("/exchange")
public Result<SocialExchangeResult> exchange(
@RequestParam("code") String code,
@RequestParam("state") String state,
HttpServletRequest request,
HttpServletResponse response) {
public SocialExchangeResult exchangeCode(String code, String state,
HttpServletRequest request, HttpServletResponse response) {
// 校验 state 并恢复上下文
SocialAuthContext context = redisSocialStateCache.getAndRemove(state);
if (context == null) {
// state 已过期或非法
return Res.ok(new SocialExchangeResult().setError("state_invalid"));
return new SocialExchangeResult().setError("state_invalid");
}
String source = context.getSource();
String clientCode = context.getClientCode();
try {
// 加载平台配置 + 创建对应 Request
cn.daxpay.open.platform.iam.entity.social.SocialConfig config = socialConfigService.findEnabledBySource(source);
SocialConfig config = socialConfigService.findEnabledBySource(source);
if (config == null) {
return Res.ok(new SocialExchangeResult().setError("oauth_failed"));
return new SocialExchangeResult().setError("oauth_failed");
}
// exchange 阶段的 redirect_uri 必须与 authorize 阶段一致, 从端点配置按 client 解析 baseUrl
PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig();
String baseUrl = SocialClientEnum.of(clientCode).resolveBaseUrl(urlConfig);
String baseUrl = this.resolveBaseUrl(clientCode);
SocialAuthConfig authConfig = socialConfigService.buildAuthConfig(config, baseUrl);
SocialSourceEnum socialSource = SocialSourceEnum.of(source);
SocialAuthRequest authRequest = socialAuthRequestFactory.create(socialSource, authConfig);
@@ -163,44 +131,31 @@ public class SocialEndpoint {
// 按场景处理
if (context.getMode() == SocialAuthMode.BIND) {
socialBindStore.saveBind(context.getUserId(), clientCode, authUser);
return Res.ok(new SocialExchangeResult().setResult("bind_success"));
return new SocialExchangeResult().setResult("bind_success");
} else {
// LOGIN 场景: 仅已绑定的账号可直接登录
Long userId = socialBindStore.findUserIdBySourceAndOpenId(source, authUser.getUuid()).orElse(null);
if (userId == null) {
// 未绑定
return Res.ok(new SocialExchangeResult().setError("unbind"));
return new SocialExchangeResult().setError("unbind");
}
String token = socialLoginHandler.login(userId, clientCode, request, response);
return Res.ok(new SocialExchangeResult().setToken(token));
return new SocialExchangeResult().setToken(token);
}
} catch (Exception e) {
log.error("社交登录兑换失败: source={}, msg={}", source, e.getMessage(), e);
return Res.ok(new SocialExchangeResult().setError("oauth_failed"));
return new SocialExchangeResult().setError("oauth_failed");
}
}
/// 查询当前登录用户已绑定的第三方账号
@IgnoreAuth(login = true)
@Operation(summary = "已绑定的第三方账号列表")
@GetMapping("/bind/list")
public Result<List<SocialBindResult>> bindList() {
Long userId = SecurityUtil.getUserId();
return Res.ok(socialBindStore.findBindsByUserId(userId));
/// 查询指定用户已绑定的所有第三方账号
public List<SocialBindResult> bindList(Long userId) {
return socialBindStore.findBindsByUserId(userId);
}
/// 解除当前登录用户的指定平台绑定
@IgnoreAuth(login = true)
@Operation(summary = "解除第三方账号绑定")
@PostMapping("/unbind")
public Result<Void> unbind(@RequestParam String source) {
Long userId = SecurityUtil.getUserId();
boolean success = socialBindStore.removeBind(userId, source);
if (!success) {
// 社交登录: 未绑定该平台, 无需解绑
return Res.ok();
}
return Res.ok();
/// 解除指定用户的某个平台绑定(幂等: 未绑定则无操作)
public void unbind(Long userId, String source) {
socialBindStore.removeBind(userId, source);
}
/// 解析授权场景(未传 mode 时按登录态判断)
@@ -215,4 +170,20 @@ public class SocialEndpoint {
boolean login = SecurityUtil.isLogin();
return login ? SocialAuthMode.BIND : SocialAuthMode.LOGIN;
}
/// client 解析前端 baseUrl(用于 redirectUri 自动生成)
private String resolveBaseUrl(String clientCode) {
PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig();
return SocialClientEnum.of(clientCode).resolveBaseUrl(urlConfig);
}
/// 加载已启用的平台配置(不存在则抛业务异常)
private SocialConfig loadEnabledConfig(String source) {
SocialConfig config = socialConfigService.findEnabledBySource(source);
if (config == null) {
// 社交登录: 平台未配置或未启用
throw new OperationFailException("error.social.configNotExist");
}
return config;
}
}