From 44fd38b0463505b3709c4720b766ccebc935019b Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Mon, 27 Jul 2026 23:27:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(open-auth):=20=E6=96=B0=E5=A2=9E=E5=AF=B9?= =?UTF-8?q?=E5=A4=96=E5=BC=80=E6=94=BE=E8=AE=A4=E8=AF=81(OPEN=20scene)?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=E6=8E=A5=E5=8F=A3,=20=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=E6=96=B9=E8=8E=B7=E5=8F=96=E7=94=A8=E6=88=B7=E6=A0=87?= =?UTF-8?q?=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 --- .../unipay/param/open/OpenAuthParam.java | 45 +++++ .../result/open/OpenAuthRedirectResult.java | 38 +++++ .../client/controller/OpenAuthController.java | 60 +++++++ .../client/service/OpenAuthService.java | 156 ++++++++++++++++++ 4 files changed, 299 insertions(+) create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/open/OpenAuthParam.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/open/OpenAuthRedirectResult.java create mode 100644 daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/OpenAuthController.java create mode 100644 daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/OpenAuthService.java diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/open/OpenAuthParam.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/open/OpenAuthParam.java new file mode 100644 index 000000000..a05b995e8 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/open/OpenAuthParam.java @@ -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; +} \ No newline at end of file diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/open/OpenAuthRedirectResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/open/OpenAuthRedirectResult.java new file mode 100644 index 000000000..eec085aaa --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/open/OpenAuthRedirectResult.java @@ -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; +} \ No newline at end of file diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/OpenAuthController.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/OpenAuthController.java new file mode 100644 index 000000000..551d72d66 --- /dev/null +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/OpenAuthController.java @@ -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); + } +} \ No newline at end of file diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/OpenAuthService.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/OpenAuthService.java new file mode 100644 index 000000000..898ff697e --- /dev/null +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/OpenAuthService.java @@ -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(); + } +} \ No newline at end of file