mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-08 06:05:33 +08:00
feat(open-auth): 新增对外开放认证(OPEN scene)重定向接口, 对接方获取用户标识
新增 OpenAuthController + OpenAuthService, 支持对接方通过 redirect_url 重定向模式获取 openid: - OpenAuthParam: 对接方上送 mchNo/appId/redirect_url, 签名验证 - OpenAuthRedirectResult: 回调参数 code(0/1 对齐 CommonCode)/msg/openid/sign, 放弃 hkrt 风格 return_code=10000/10001 - 签名机制沿用 PaySignUtil 字典序 + RSA, 参与签名字段为 code/msg/openId
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package cn.daxpay.open.payment.unipay.param.open;
|
||||
|
||||
import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 通用认证请求参数(对外开放认证 OPEN 场景)
|
||||
///
|
||||
/// 对接方通过 GET 重定向方式请求 DaxPay 获取用户标识(openId/userId)。
|
||||
/// 继承 [MerchantPaymentCommonParam] 以复用商户签名验证机制: 所有字段(含继承字段)参与签名。
|
||||
///
|
||||
/// ## 签名规则
|
||||
/// 与支付接口一致: 参数名 ASCII 字典序排序, 空值不参与, 使用商户私钥签名, 平台用商户公钥验签。
|
||||
/// 详见 [cn.daxpay.open.payment.common.util.PaySignUtil]。
|
||||
///
|
||||
/// ## 请求方式
|
||||
/// GET 重定向, 参数通过 query string 传递。Controller 通过 `@Valid` 对象绑定
|
||||
/// (Spring `@ModelAttribute`) 自动组装, 校验由字段级 `@NotBlank/@Size/@NotNull` 注解触发。
|
||||
/// `reqTime` 字段的时间格式解析由基类 [PaymentCommonParam] 的 `@DateTimeFormat` 支撑。
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "通用认证请求参数")
|
||||
public class OpenAuthParam extends MerchantPaymentCommonParam {
|
||||
|
||||
/// 认证类型: wechat / alipay / douyin
|
||||
/// @see cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum
|
||||
@Schema(description = "认证类型 wechat/alipay/douyin")
|
||||
@NotBlank(message = "{validation.field.authType.notBlank}")
|
||||
@Size(max = 32, message = "{validation.field.authType.size}")
|
||||
private String authType;
|
||||
|
||||
/// 回调地址(获取到用户标识后重定向的目标地址)
|
||||
///
|
||||
/// RESTful 风格, 不要在地址后面拼接 query 参数(系统会在后面追加 code/openid/sign 等参数)。
|
||||
/// 验签通过后即信任(商户自己指定的回调地址)。
|
||||
@Schema(description = "回调地址")
|
||||
@NotBlank(message = "{validation.field.redirectUrl.notBlank}")
|
||||
@Size(max = 500, message = "{validation.field.redirectUrl.size}")
|
||||
private String redirectUrl;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.daxpay.open.payment.unipay.result.open;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 通用认证回调重定向参数
|
||||
///
|
||||
/// OAuth 回调处理完成后, 系统将用户标识(openId)和状态封装为本对象,
|
||||
/// 拼接为 query string 重定向到对接方的 redirect_url, 并附加平台签名供对接方验签。
|
||||
///
|
||||
/// ## 参数说明
|
||||
/// - 成功时: code=0, msg=success, openid 填充, sign 签名
|
||||
/// - 失败时: code=1, msg=错误描述, sign 签名
|
||||
///
|
||||
/// ## 验签方式
|
||||
/// 对接方使用平台公钥验签, 规则与支付接口一致(字段 ASCII 字典序排序, 空值不参与签名)。
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "通用认证回调参数")
|
||||
public class OpenAuthRedirectResult {
|
||||
|
||||
/// 状态码: 0=成功, 1=失败(对齐 CommonCode.SUCCESS_CODE/FAIL_CODE)
|
||||
@Schema(description = "状态码")
|
||||
private int code;
|
||||
|
||||
/// 状态描述
|
||||
@Schema(description = "状态描述")
|
||||
private String msg;
|
||||
|
||||
/// 用户标识(微信openId / 支付宝userId / 抖音openId, 由各通道 Provider 统一映射)
|
||||
@Schema(description = "用户标识(openId)")
|
||||
private String openId;
|
||||
|
||||
/// 平台签名(对接方用平台公钥验签)
|
||||
@Schema(description = "签名")
|
||||
private String sign;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cn.daxpay.open.payment.unipay.client.controller;
|
||||
|
||||
import cn.daxpay.open.payment.unipay.client.service.OpenAuthService;
|
||||
import cn.daxpay.open.payment.unipay.param.open.OpenAuthParam;
|
||||
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
/// # 通用认证接口(对外开放认证 OPEN 场景)
|
||||
///
|
||||
/// 对外提供获取用户标识(openId/userId)的重定向接口, 参考 hkrt getOpenid 模式:
|
||||
/// 对接方构建签名 URL 引导用户浏览器访问 → 系统验签后 302 到第三方 OAuth →
|
||||
/// OAuth 回调后系统换 openId → 302 重定向到对接方 redirect_url 带用户标识和签名。
|
||||
///
|
||||
/// ## 三通道支持
|
||||
/// - **wechat**: 走商户通道绑定的微信应用(WxAppFacade 解析), 公众号 OAuth 取 openId
|
||||
/// - **alipay**: 走平台级支付宝配置, auth_base 静默授权取 userId
|
||||
/// - **douyin**: 走商户通道绑定的抖音应用, H5 silent_auth 取 openId
|
||||
///
|
||||
/// ## 安全
|
||||
/// 入口要求商户签名(与支付接口一致), 验签通过后 redirect_url 即可信。
|
||||
/// 回调重定向参数附加平台签名, 对接方可验签。
|
||||
@IgnoreAuth
|
||||
@Validated
|
||||
@Tag(name = "通用认证服务(对外开放)")
|
||||
@RestController
|
||||
@RequestMapping("/unipay/open/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class OpenAuthController {
|
||||
|
||||
private final OpenAuthService openAuthService;
|
||||
|
||||
/// 获取用户标识(重定向入口)
|
||||
///
|
||||
/// 验签通过后, 生成 OAuth 授权链接并 302 重定向。
|
||||
/// 授权完成后第三方回调到 `/unipay/open/auth/callback`。
|
||||
@Operation(summary = "获取用户标识(重定向)")
|
||||
@GetMapping("/get-openid")
|
||||
public RedirectView getOpenId(@Valid OpenAuthParam param) {
|
||||
return new RedirectView(openAuthService.generateOpenAuthRedirect(param));
|
||||
}
|
||||
|
||||
/// OAuth 回调处理
|
||||
///
|
||||
/// 第三方 OAuth 授权完成后回调到此接口, 系统用 code 换取 openId/userId,
|
||||
/// 然后 302 重定向到对接方的 redirect_url 带用户标识和签名。
|
||||
@Operation(summary = "OAuth 认证回调")
|
||||
@GetMapping("/callback")
|
||||
public RedirectView callback(String code, String state) {
|
||||
String redirectUrl = openAuthService.handleCallback(code, state);
|
||||
return new RedirectView(redirectUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package cn.daxpay.open.payment.unipay.client.service;
|
||||
|
||||
import cn.daxpay.open.payment.auth.core.AuthScene;
|
||||
import cn.daxpay.open.payment.auth.core.AuthSession;
|
||||
import cn.daxpay.open.payment.auth.core.AuthSessionStore;
|
||||
import cn.daxpay.open.payment.auth.merchant.ChannelAuthService;
|
||||
import cn.daxpay.open.payment.common.context.MerchantContextLoader;
|
||||
import cn.daxpay.open.payment.common.util.PaySignUtil;
|
||||
import cn.daxpay.open.payment.unipay.aop.PaymentSignService;
|
||||
import cn.daxpay.open.payment.unipay.param.assist.AuthCodeParam;
|
||||
import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam;
|
||||
import cn.daxpay.open.payment.unipay.param.open.OpenAuthParam;
|
||||
import cn.daxpay.open.payment.unipay.result.assist.AuthResult;
|
||||
import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult;
|
||||
import cn.daxpay.open.payment.unipay.result.open.OpenAuthRedirectResult;
|
||||
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
|
||||
import cn.daxpay.open.platform.core.code.CommonCode;
|
||||
import cn.daxpay.open.platform.core.code.DaxPayErrorCode;
|
||||
import cn.daxpay.open.platform.core.exception.BizInfoException;
|
||||
import cn.daxpay.open.platform.core.util.ValidationUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.util.URLUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/// # 通用认证服务(OPEN 场景)
|
||||
///
|
||||
/// 对外开放认证的场景适配层, 供 [OpenAuthController] 使用。核心职责:
|
||||
///
|
||||
/// 1. **入口验签**: 验证商户签名后生成 OAuth 重定向链接(委托 [ChannelAuthService])
|
||||
/// 2. **回调处理**: OAuth 回调后用 code 换 openId/userId, 构建带签名的重定向 URL 回给对接方
|
||||
///
|
||||
/// ## 与 ChannelAuthService 的关系
|
||||
/// 复用 [ChannelAuthService] 的分发能力(source/product 路由), 但在生成授权链接后更新 session
|
||||
/// 标记 `scene=OPEN`, 以便回调时做重定向式结果返回(而非 JSON)。
|
||||
///
|
||||
/// ## 安全约束
|
||||
/// - 入口要求商户签名, 验签通过后 redirect_url 即可信(商户自己指定的回调地址)
|
||||
/// - 回调重定向参数附加平台签名, 对接方可用平台公钥验签确认响应来源
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OpenAuthService {
|
||||
|
||||
private final PaymentSignService paymentSignService;
|
||||
private final MerchantContextLoader merchantContextLoader;
|
||||
private final ChannelAuthService channelAuthService;
|
||||
private final AuthSessionStore authSessionStore;
|
||||
private final PlatformConfigProperties platformConfigProperties;
|
||||
|
||||
/// 生成 OAuth 重定向链接
|
||||
///
|
||||
/// 流程: 参数校验 → 加载商户上下文 → 验签 → 委托 ChannelAuthService 生成 authUrl →
|
||||
/// 更新 session(scene=OPEN, redirect_url) → 返回 authUrl 供 Controller 302 重定向
|
||||
public String generateOpenAuthRedirect(OpenAuthParam param) {
|
||||
// 参数校验
|
||||
ValidationUtil.validateParam(param);
|
||||
// 商户身份初始化(含状态校验), 使 mchNo 进入线程上下文供签名校验
|
||||
merchantContextLoader.initMch(param.getMchNo());
|
||||
// 参数签名校验
|
||||
paymentSignService.signVerify(param);
|
||||
|
||||
// 组装认证参数, 委托 ChannelAuthService 按 authType 分发
|
||||
GenerateAuthUrlParam authParam = new GenerateAuthUrlParam();
|
||||
authParam.setMchNo(param.getMchNo());
|
||||
authParam.setAppId(param.getAppId());
|
||||
authParam.setAuthType(param.getAuthType());
|
||||
authParam.setChannelMchNo(param.getChannelMchNo());
|
||||
// redirect_url 存入 session.returnPath, 回调时取出构建重定向
|
||||
authParam.setReturnPath(param.getRedirectUrl());
|
||||
AuthUrlResult urlResult = channelAuthService.generateAuthUrl(authParam);
|
||||
|
||||
// 更新 session: 标记 scene=OPEN(回调时据此做重定向而非 JSON 返回)
|
||||
String authToken = urlResult.getAuthToken();
|
||||
if (StrUtil.isNotBlank(authToken)) {
|
||||
AuthSession session = authSessionStore.loadSession(authToken);
|
||||
if (session != null) {
|
||||
session.setScene(AuthScene.OPEN.getCode());
|
||||
authSessionStore.saveSession(authToken, session);
|
||||
}
|
||||
}
|
||||
return urlResult.getAuthUrl();
|
||||
}
|
||||
|
||||
/// OAuth 回调处理
|
||||
///
|
||||
/// 流程: 恢复 session → 委托 ChannelAuthService.auth 获取 openId/userId →
|
||||
/// 构建带签名的重定向 URL 回给对接方
|
||||
///
|
||||
/// @param code 第三方 OAuth 授权码
|
||||
/// @param state 认证会话码(=authToken, 由 OAuth state 透传)
|
||||
/// @return 完整的重定向 URL(redirect_url?code=0&openid=xxx&sign=xxx)
|
||||
public String handleCallback(String code, String state) {
|
||||
// 恢复 session
|
||||
AuthSession session = authSessionStore.loadSession(state);
|
||||
if (session == null) {
|
||||
// 会话已失效, 无法恢复 redirect_url, 只能抛异常
|
||||
throw new BizInfoException(DaxPayErrorCode.OPERATION_FAIL,
|
||||
"pay.error.assist.authSessionExpired");
|
||||
}
|
||||
// 先保存 redirect_url(ChannelAuthService.auth 成功后会销毁 session)
|
||||
String redirectUrl = session.getReturnPath();
|
||||
|
||||
// 构建 AuthCodeParam 并委托获取 openId/userId
|
||||
AuthCodeParam authCodeParam = new AuthCodeParam();
|
||||
authCodeParam.setAuthCode(code);
|
||||
authCodeParam.setAuthToken(state);
|
||||
authCodeParam.setQueryCode(session.getQueryCode());
|
||||
try {
|
||||
AuthResult authResult = channelAuthService.auth(authCodeParam);
|
||||
return buildSuccessRedirectUrl(redirectUrl, authResult);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("OPEN 认证回调失败, redirectUrl={}, error={}", redirectUrl, e.getMessage());
|
||||
return buildErrorRedirectUrl(redirectUrl, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建成功重定向 URL: redirect_url?code=0&msg=success&openid=xxx&sign=xxx
|
||||
private String buildSuccessRedirectUrl(String redirectUrl, AuthResult authResult) {
|
||||
OpenAuthRedirectResult result = new OpenAuthRedirectResult()
|
||||
.setCode(CommonCode.SUCCESS_CODE)
|
||||
.setMsg(CommonCode.SUCCESS_MSG)
|
||||
.setOpenId(authResult.getOpenId());
|
||||
signResult(result);
|
||||
return appendQueryParams(redirectUrl, result);
|
||||
}
|
||||
|
||||
/// 构建失败重定向 URL: redirect_url?code=1&msg=xxx&sign=xxx
|
||||
private String buildErrorRedirectUrl(String redirectUrl, String errorMsg) {
|
||||
OpenAuthRedirectResult result = new OpenAuthRedirectResult()
|
||||
.setCode(CommonCode.FAIL_CODE)
|
||||
.setMsg(StrUtil.sub(errorMsg, 0, 200));
|
||||
signResult(result);
|
||||
return appendQueryParams(redirectUrl, result);
|
||||
}
|
||||
|
||||
/// 使用平台私钥对回调参数签名
|
||||
private void signResult(OpenAuthRedirectResult result) {
|
||||
String privateKey = platformConfigProperties.getKeyConfig().getPrivateKey();
|
||||
result.setSign(PaySignUtil.sign(result, privateKey));
|
||||
}
|
||||
|
||||
/// 将回调参数拼接为 query string 追加到 baseUrl
|
||||
private String appendQueryParams(String baseUrl, OpenAuthRedirectResult result) {
|
||||
StringBuilder sb = new StringBuilder(baseUrl);
|
||||
sb.append(baseUrl.contains("?") ? "&" : "?");
|
||||
sb.append("code=").append(result.getCode());
|
||||
sb.append("&msg=").append(URLUtil.encode(result.getMsg()));
|
||||
if (StrUtil.isNotBlank(result.getOpenId())) {
|
||||
sb.append("&openid=").append(URLUtil.encode(result.getOpenId()));
|
||||
}
|
||||
sb.append("&sign=").append(URLUtil.encode(result.getSign()));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user