diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/SocialEndpoint.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/SocialEndpoint.java new file mode 100644 index 000000000..0d4a83e9f --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/SocialEndpoint.java @@ -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> enabledList() { + return Res.ok(socialLoginService.enabledList()); + } + + /// 生成授权地址 + /// @param source 平台来源 + /// @param client 终端编码(admin/merchant), 用于解析端点配置中的 baseUrl + /// @param mode 授权场景(不传则按登录态判断: 已登录=绑定, 未登录=登录) + /// @param redirect 成功后前端跳转路径(可选) + @Operation(summary = "生成授权地址") + @GetMapping("/render/{source}") + public Result 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 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> bindList() { + return Res.ok(socialLoginService.bindList(SecurityUtil.getUserId())); + } + + /// 解除当前登录用户的指定平台绑定 + @IgnoreAuth(login = true) + @Operation(summary = "解除第三方账号绑定") + @PostMapping("/unbind") + public Result unbind(@RequestParam String source) { + socialLoginService.unbind(SecurityUtil.getUserId(), source); + return Res.ok(); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/social/SocialEndpoint.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialLoginService.java similarity index 63% rename from daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/social/SocialEndpoint.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialLoginService.java index 6b7925138..5b6d64a0d 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/social/SocialEndpoint.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialLoginService.java @@ -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> enabledList() { - return Res.ok(socialConfigService.findEnabledList()); + public List 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 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 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> bindList() { - Long userId = SecurityUtil.getUserId(); - return Res.ok(socialBindStore.findBindsByUserId(userId)); + /// 查询指定用户已绑定的所有第三方账号 + public List bindList(Long userId) { + return socialBindStore.findBindsByUserId(userId); } - /// 解除当前登录用户的指定平台绑定 - @IgnoreAuth(login = true) - @Operation(summary = "解除第三方账号绑定") - @PostMapping("/unbind") - public Result 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; + } }