refactor(social): 支付宝登录并入统一 Social 认证入口

用 AlipaySocialAuthRequest 收口非标准 OAuth,删除独立 /social/alipay 端点,
登录与绑定与标准平台共用 SocialEndpoint 编排。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-09 17:08:31 +08:00
parent 1761e19a20
commit 428b0b9ce4
10 changed files with 157 additions and 191 deletions

View File

@@ -35,8 +35,8 @@ public class SocialAuthRequestFactory {
case FEISHU -> new FeishuRequest(config);
case DINGTALK -> new DingTalkRequest(config);
case DOUYIN -> new DouyinRequest(config);
// 支付宝非标准 OAuth2, 不走 JustAuth 工厂, 由 iam 模块 AlipayAuthEndpoint 独立处理
case ALIPAY -> throw new OperationFailException("error.social.alipayUseDedicatedEndpoint");
// 支付宝由 iam 模块 AlipaySocialAuthRequestFactory 创建, 不走本工厂
case ALIPAY -> throw new OperationFailException("error.social.unsupportedSource");
};
}

View File

@@ -48,10 +48,8 @@ public enum SocialSourceEnum implements I18nSupport {
"https://api.dingtalk.com/v1.0/contact/users/me"
),
/// 支付宝(非标准 OAuth2, 不走 JustAuth 流程)
/// 端点地址留空, 实际授权由 iam 模块 AlipayAuthEndpoint 独立处理(调用 alipay.system.oauth.token)
/// 在 [SocialAuthRequestFactory] 中会对该 source 抛出"请使用支付宝专用端点"异常,
/// 避免误走标准 OAuth2 工厂分支。
/// 支付宝(非标准 OAuth2: alipay.system.oauth.token)
/// 端点地址留空; 授权由 iam 模块 [AlipaySocialAuthRequest] 实现, 经统一 SocialEndpoint 入口编排
/// 排序位于钉钉之后、抖音之前。
ALIPAY("alipay", "", "", ""),

View File

@@ -4,7 +4,6 @@
"configDuplicated": "The platform configuration already exists",
"notConfigured": "The platform is not configured and cannot be toggled",
"authCodeBlank": "Authorization code cannot be empty",
"alipayUseDedicatedEndpoint": "Alipay uses non-standard OAuth2, please use the dedicated endpoint /social/alipay/render",
"alipayNotConfigured": "Platform-level Alipay configuration is incomplete, please configure it in 'Third-party Platform Management' first",
"weCom": {
"notCorpMember": "Not a member of this enterprise, login is not supported"

View File

@@ -4,7 +4,6 @@
"configDuplicated": "该平台配置已存在",
"notConfigured": "平台尚未配置,无法启停",
"authCodeBlank": "授权码不能为空",
"alipayUseDedicatedEndpoint": "支付宝非标准 OAuth2请使用专用授权端点 /social/alipay/render",
"alipayNotConfigured": "平台级支付宝配置不完整,请先在「三方平台管理」中配置",
"weCom": {
"notCorpMember": "非本企业成员,不支持登录"

View File

@@ -26,7 +26,7 @@
<artifactId>capability-social</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 支付宝开放平台能力(AlipayAuthEndpoint 授权登录使用) -->
<!-- 支付宝开放平台能力(AlipaySocialAuthRequest 授权登录使用) -->
<dependency>
<groupId>cn.daxpay.open</groupId>
<artifactId>capability-alipay</artifactId>

View File

@@ -1,177 +0,0 @@
package cn.daxpay.open.platform.iam.endpoint;
import cn.daxpay.open.platform.capability.alipay.auth.config.AlipayAuthConfig;
import cn.daxpay.open.platform.capability.alipay.auth.result.AlipayAuthResult;
import cn.daxpay.open.platform.capability.alipay.auth.service.AlipayAuthCapability;
import cn.daxpay.open.platform.capability.auth.util.SecurityUtil;
import cn.daxpay.open.platform.capability.social.justauth.SocialSourceEnum;
import cn.daxpay.open.platform.capability.social.justauth.model.AuthUser;
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.core.exception.operation.OperationFailException;
import cn.daxpay.open.platform.iam.enums.SocialAuthMode;
import cn.daxpay.open.platform.iam.enums.SocialClientEnum;
import cn.daxpay.open.platform.iam.result.social.SocialExchangeResult;
import cn.daxpay.open.platform.iam.entity.social.SocialLoginConfig;
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.SocialLoginConfigService;
import cn.daxpay.open.platform.system.entity.config.platform.PlatformUrlConfig;
import cn.daxpay.open.platform.system.service.config.PlatformAlipayAuthConfigService;
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.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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/// # 支付宝授权登录端点
///
/// 支付宝非标准 OAuth2(走 `alipay.system.oauth.token`), 不复用 JustAuth 的 [SocialEndpoint],
/// 独立实现 render/exchange。但登录态签发、绑定关系存储复用 iam 现有体系
/// ([IamSocialLoginHandler] / [IamUserSocialBindStore]), 绑定写入 `iam_user_social` 表(source=`alipay`)。
///
/// ## 流程
/// 1. 前端调 `render` 获取授权链接(用平台级支付宝配置拼 `publicAppAuthorize.htm`)
/// 2. 前端 `location.href` 跳转, 用户在支付宝授权
/// 3. 支付宝回调到前端 `/auth/oauth-callback/alipay?auth_code=xxx&state=xxx`
/// 4. 前端调 `exchange` 用 auth_code 换 userId, 完成登录或绑定
///
@Slf4j
@IgnoreAuth
@Tag(name = "支付宝授权登录")
@RestController
@RequestMapping("/social/alipay")
@RequiredArgsConstructor
public class AlipayAuthEndpoint {
/// 支付宝在三方登录体系中的 source 编码
private static final String SOURCE = SocialSourceEnum.ALIPAY.getCode();
/// 授权范围: auth_user(需用户确认, 可获取用户信息)
private static final String SCOPE = "auth_user";
/// 登录回调路径(前端路由, 与标准 OAuth2 平台同构: /auth/oauth-callback/{source})
private static final String LOGIN_CALLBACK_PATH = "/auth/oauth-callback/" + SOURCE;
/// 绑定回调路径(前端路由: /auth/social-bind-callback/{source})
private static final String BIND_CALLBACK_PATH = "/auth/social-bind-callback/" + SOURCE;
private final PlatformAlipayAuthConfigService platformAlipayAuthConfigService;
private final AlipayAuthCapability alipayAuthCapability;
private final PlatformUrlConfigService platformUrlConfigService;
private final SocialLoginConfigService socialLoginConfigService;
private final IamUserSocialBindStore socialBindStore;
private final IamSocialLoginHandler socialLoginHandler;
/// 生成支付宝授权地址
/// @param client 终端编码(admin/merchant), 用于解析端点配置中的 baseUrl
/// @param mode 授权场景(不传则按登录态判断: 已登录=绑定, 未登录=登录)
@GetMapping("/render")
public Result<String> render(@RequestParam String client,
@RequestParam(required = false) String mode) {
// 0. 校验登录配置已启用(与标准 OAuth 平台一致)
this.requireLoginEnabled();
// 1. 读平台级配置
AlipayAuthConfig capabilityConfig = platformAlipayAuthConfigService.toCapabilityConfig();
if (!alipayAuthCapability.isConfigured(capabilityConfig)) {
// 支付宝: 平台级支付宝配置不完整, 请先在「三方平台管理」中配置
throw new OperationFailException("error.social.alipayNotConfigured");
}
// 2. 按 client 解析前端 baseUrl
PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig();
String baseUrl = SocialClientEnum.of(client).resolveBaseUrl(urlConfig);
if (StrUtil.isBlank(baseUrl)) {
// 社交登录: 端点配置缺失
throw new OperationFailException("error.social.endpointNotConfigured");
}
// 3. 拼回调地址(LOGIN/BIND 不同路径)
SocialAuthMode authMode = this.resolveMode(mode);
String callbackPath = authMode == SocialAuthMode.BIND ? BIND_CALLBACK_PATH : LOGIN_CALLBACK_PATH;
String redirectUri = baseUrl + callbackPath;
// 4. 生成 state(OAuth2 合规, CSRF 防护)
String state = IdUtil.fastSimpleUUID();
// 5. 拼授权链接(平台级配置固定生产环境, 不再读取 sandbox)
String authUrl = alipayAuthCapability.generateAuthUrl(capabilityConfig, redirectUri, SCOPE, state, false);
return Res.ok(authUrl);
}
/// 支付宝授权码兑换(LOGIN/BIND 由 mode 决定, 未传按登录态判断)
/// @param authCode 支付宝回调回传的 auth_code
/// @param state render 阶段生成的 state(原样回传, 目前仅做日志, 不做强校验)
/// @param client 终端编码
/// @param mode 授权场景
@PostMapping("/exchange")
public Result<SocialExchangeResult> exchange(@RequestParam("authCode") String authCode,
@RequestParam("state") String state,
@RequestParam("client") String client,
@RequestParam(name = "mode", required = false) String mode,
HttpServletRequest request,
HttpServletResponse response) {
try {
// 0. 校验登录配置已启用
this.requireLoginEnabled();
// 1. 换 userId
AlipayAuthConfig capabilityConfig = platformAlipayAuthConfigService.toCapabilityConfig();
AlipayAuthResult authResult = alipayAuthCapability.getUserId(capabilityConfig, authCode);
String alipayUserId = authResult.getUserId();
if (StrUtil.isBlank(alipayUserId)) {
return Res.ok(new SocialExchangeResult().setError("oauth_failed"));
}
// 2. 构造 AuthUser(复用现有绑定存储, source=alipay, uuid=支付宝userId)
AuthUser authUser = new AuthUser()
.setSource(SOURCE)
.setUuid(alipayUserId)
.setNickname("支付宝用户");
// 3. 按场景分流
SocialAuthMode authMode = this.resolveMode(mode);
if (authMode == SocialAuthMode.BIND) {
// 绑定场景: 必须已登录
Long userId = SecurityUtil.getUserId();
socialBindStore.saveBind(userId, client, authUser);
return Res.ok(new SocialExchangeResult().setResult("bind_success"));
}
// 登录场景: 查绑定关系
Long userId = socialBindStore.findUserIdBySourceAndOpenId(SOURCE, alipayUserId).orElse(null);
if (userId == null) {
// 未绑定
return Res.ok(new SocialExchangeResult().setError("unbind"));
}
String token = socialLoginHandler.login(userId, client, SOURCE, request, response);
return Res.ok(new SocialExchangeResult().setToken(token));
} catch (Exception e) {
log.error("支付宝授权登录兑换失败: authCode={}, msg={}", authCode, e.getMessage(), e);
return Res.ok(new SocialExchangeResult().setError("oauth_failed"));
}
}
/// 解析授权场景(未传 mode 时按登录态判断)
private SocialAuthMode resolveMode(String mode) {
if (StrUtil.isNotBlank(mode)) {
try {
return SocialAuthMode.valueOf(mode.toUpperCase());
} catch (IllegalArgumentException ignored) {
}
}
// 默认: 已登录走绑定, 未登录走登录
boolean login = SecurityUtil.isLogin();
return login ? SocialAuthMode.BIND : SocialAuthMode.LOGIN;
}
/// 校验支付宝登录已在「三方平台登录配置」中启用
private void requireLoginEnabled() {
SocialLoginConfig config = socialLoginConfigService.findEnabledBySource(SOURCE);
if (config == null) {
// 社交登录: 平台配置不存在或未启用
throw new OperationFailException("error.social.configNotExist");
}
}
}

View File

@@ -15,6 +15,7 @@ import cn.daxpay.open.platform.iam.result.social.SocialEnabledPlatformResult;
import cn.daxpay.open.platform.capability.social.justauth.SocialAuthConfig;
import cn.daxpay.open.platform.capability.social.justauth.SocialSourceEnum;
import cn.daxpay.open.platform.core.exception.operation.OperationFailException;
import cn.daxpay.open.platform.iam.service.social.other.AlipaySocialAuthRequest;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
@@ -30,7 +31,7 @@ import org.springframework.stereotype.Service;
/// 配置表 source 全局唯一, 占位记录仅插入一次.
///
/// **平台级跳转型**(如支付宝): 凭据在独立平台配置中维护, 本表仅存 enabled 占位;
/// 「是否启用」与平台凭据解耦——可先启用登录入口, 凭据是否齐全在发起授权时由专用端点校验。
/// 「是否启用」与平台凭据解耦——可先启用登录入口, 凭据是否齐全在发起授权时由 [AlipaySocialAuthRequest] 校验。
///
@Slf4j
@Service
@@ -135,8 +136,8 @@ public class SocialLoginConfigService {
socialLoginConfigManager.updateById(entity);
}
/// 根据平台来源查询已配置且启用的配置(供 SocialAuthRequestFactory / 支付宝端点使用)
/// 仅看本表 enabled+configured; 平台凭据完整性由专用端点在发起授权时校验。
/// 根据平台来源查询已配置且启用的配置(供 SocialLoginService 授权编排使用)
/// 仅看本表 enabled+configured; 平台凭据完整性由 AlipaySocialAuthRequest 在发起授权时校验。
public SocialLoginConfig findEnabledBySource(String source) {
return socialLoginConfigManager.findEnabledBySource(source).orElse(null);
}

View File

@@ -16,6 +16,8 @@ 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.enums.SocialAuthMode;
import cn.daxpay.open.platform.iam.service.social.other.AlipaySocialAuthRequest;
import cn.daxpay.open.platform.iam.service.social.other.AlipaySocialAuthRequestFactory;
import cn.daxpay.open.platform.system.entity.config.platform.PlatformUrlConfig;
import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService;
import cn.hutool.core.util.IdUtil;
@@ -33,6 +35,7 @@ import org.springframework.stereotype.Service;
/// 前端拿到 code+state 后调用 exchange 接口完成换 token.
/// state 仅用于 OAuth2 合规(CSRF), 不携带业务上下文.
/// 登录和绑定的区分由回调页路由决定(两个不同的前端页面), exchange 时显式传 source + client.
/// 支付宝非标准 OAuth 经 [AlipaySocialAuthRequest] 收口, 对外与标准平台共用本服务入口.
///
@Slf4j
@Service
@@ -47,6 +50,8 @@ public class SocialLoginService {
private final SocialAuthRequestFactory socialAuthRequestFactory;
private final AlipaySocialAuthRequestFactory alipaySocialAuthRequestFactory;
private final IamUserSocialBindStore socialBindStore;
private final IamSocialLoginHandler socialLoginHandler;
@@ -84,7 +89,7 @@ public class SocialLoginService {
String redirectUri = this.buildRedirectUri(baseUrl, authMode);
// 构建授权请求
SocialAuthConfig authConfig = socialLoginConfigService.buildAuthConfig(config, redirectUri);
SocialAuthRequest request = socialAuthRequestFactory.create(socialSource, authConfig);
SocialAuthRequest request = this.createAuthRequest(socialSource, authConfig);
// state 仅用于 OAuth2 合规, 不缓存业务上下文
String state = IdUtil.fastSimpleUUID();
return request.authorize(state);
@@ -157,10 +162,18 @@ public class SocialLoginService {
if (socialSource == null) {
throw new OperationFailException("error.social.unsupportedSource");
}
SocialAuthRequest authRequest = socialAuthRequestFactory.create(socialSource, authConfig);
SocialAuthRequest authRequest = this.createAuthRequest(socialSource, authConfig);
return authRequest.login(AuthCallback.of(code, state));
}
/// 按平台创建授权请求: 支付宝走 iam 侧 [AlipaySocialAuthRequest], 其余走 JustAuth 工厂
private SocialAuthRequest createAuthRequest(SocialSourceEnum socialSource, SocialAuthConfig authConfig) {
if (socialSource == SocialSourceEnum.ALIPAY) {
return alipaySocialAuthRequestFactory.create(authConfig);
}
return socialAuthRequestFactory.create(socialSource, authConfig);
}
/// 解析授权场景(未传 mode 时按登录态判断)
private SocialAuthMode resolveMode(String mode) {
if (StrUtil.isNotBlank(mode)) {

View File

@@ -0,0 +1,105 @@
package cn.daxpay.open.platform.iam.service.social.other;
import cn.daxpay.open.platform.capability.alipay.auth.config.AlipayAuthConfig;
import cn.daxpay.open.platform.capability.alipay.auth.result.AlipayAuthResult;
import cn.daxpay.open.platform.capability.alipay.auth.service.AlipayAuthCapability;
import cn.daxpay.open.platform.capability.social.justauth.SocialAuthConfig;
import cn.daxpay.open.platform.capability.social.justauth.SocialSourceEnum;
import cn.daxpay.open.platform.capability.social.justauth.exception.SocialException;
import cn.daxpay.open.platform.capability.social.justauth.model.AuthCallback;
import cn.daxpay.open.platform.capability.social.justauth.model.AuthToken;
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.exception.operation.OperationFailException;
import cn.daxpay.open.platform.iam.service.social.SocialLoginService;
import cn.daxpay.open.platform.system.service.config.PlatformAlipayAuthConfigService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
/// # 支付宝社交授权请求
///
/// 实现 [SocialAuthRequest], 将支付宝非标准 OAuth(`alipay.system.oauth.token`)
/// 收口进统一的 Social 编排([SocialLoginService]), 对外与 JustAuth 平台同入口。
/// 凭据来自平台级 [PlatformAlipayAuthConfigService], 不使用 [SocialAuthConfig] 的 clientId/secret;
/// 公钥/证书模式差异封装在 [AlipayAuthCapability] 内, 本类不分支 authType。
///
@RequiredArgsConstructor
public class AlipaySocialAuthRequest implements SocialAuthRequest {
/// 授权范围: auth_user(需用户确认, 可获取用户信息)
private static final String SCOPE = "auth_user";
private final SocialAuthConfig config;
private final AlipayAuthCapability alipayAuthCapability;
private final PlatformAlipayAuthConfigService platformAlipayAuthConfigService;
/// 生成支付宝授权地址(仅用平台配置 appId + redirectUri + state)
@Override
public String authorize(String state) {
AlipayAuthConfig capabilityConfig = this.requireCapabilityConfig();
// redirectUri 与标准平台一致: 基础 path + /{source}
String redirectUri = this.buildRedirectUri();
// 平台级配置固定生产环境
return alipayAuthCapability.generateAuthUrl(capabilityConfig, redirectUri, SCOPE, state, false);
}
/// 用授权码换 AuthUser(前端已将 auth_code 归一为 code)
@Override
public AuthUser login(AuthCallback callback) {
if (StrUtil.isBlank(callback.getCode())) {
// 授权码不能为空
throw new SocialException("error.social.authCodeBlank");
}
AlipayAuthConfig capabilityConfig = this.requireCapabilityConfig();
AlipayAuthResult authResult = alipayAuthCapability.getUserId(capabilityConfig, callback.getCode());
String alipayUserId = authResult.getUserId();
if (StrUtil.isBlank(alipayUserId)) {
throw new SocialException("error.social.oauthFailed");
}
AuthToken token = new AuthToken()
.setAccessToken(authResult.getAccessToken())
.setOpenId(authResult.getOpenId())
.setUnionId(alipayUserId);
return new AuthUser()
.setSource(SocialSourceEnum.ALIPAY.getCode())
.setUuid(alipayUserId)
.setNickname("支付宝用户")
.setToken(token);
}
/// 支付宝换用户在 [login] 内一次完成, 不单独暴露 accessToken 步骤
@Override
public AuthToken getAccessToken(AuthCallback callback) {
throw new SocialException("error.social.unsupportedSource");
}
/// 支付宝换用户在 [login] 内一次完成, 不单独暴露 userInfo 步骤
@Override
public AuthUser getUserInfo(AuthToken token) {
throw new SocialException("error.social.unsupportedSource");
}
/// 加载并校验平台级支付宝配置完整可用
private AlipayAuthConfig requireCapabilityConfig() {
AlipayAuthConfig capabilityConfig = platformAlipayAuthConfigService.toCapabilityConfig();
if (!alipayAuthCapability.isConfigured(capabilityConfig)) {
// 支付宝: 平台级支付宝配置不完整, 请先在「三方平台管理」中配置
throw new OperationFailException("error.social.alipayNotConfigured");
}
return capabilityConfig;
}
/// 构建回调地址(配置基础路径 + 平台编码), 与 [AbstractSocialAuthRequest#buildRedirectUri] 对齐
private String buildRedirectUri() {
String base = config.getRedirectUri();
if (base == null) {
base = "";
}
if (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
return base + "/" + SocialSourceEnum.ALIPAY.getCode();
}
}

View File

@@ -0,0 +1,28 @@
package cn.daxpay.open.platform.iam.service.social.other;
import cn.daxpay.open.platform.capability.alipay.auth.service.AlipayAuthCapability;
import cn.daxpay.open.platform.capability.social.justauth.SocialAuthConfig;
import cn.daxpay.open.platform.capability.social.justauth.request.SocialAuthRequest;
import cn.daxpay.open.platform.iam.service.social.SocialLoginService;
import cn.daxpay.open.platform.system.service.config.PlatformAlipayAuthConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/// # 支付宝社交授权请求工厂
///
/// 在 iam 模块创建 [AlipaySocialAuthRequest], 避免 capability-social 反向依赖 capability-alipay。
/// 由 [SocialLoginService] 在 source=alipay 时委托本工厂, 其余平台仍走 [SocialAuthRequestFactory]。
///
@Component
@RequiredArgsConstructor
public class AlipaySocialAuthRequestFactory {
private final AlipayAuthCapability alipayAuthCapability;
private final PlatformAlipayAuthConfigService platformAlipayAuthConfigService;
/// 创建支付宝授权请求(凭据在请求内部从平台配置读取, config 仅提供 redirectUri)
public SocialAuthRequest create(SocialAuthConfig config) {
return new AlipaySocialAuthRequest(config, alipayAuthCapability, platformAlipayAuthConfigService);
}
}