diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/develop/DevelopGatewayController.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/develop/DevelopGatewayController.java new file mode 100644 index 000000000..bdcd4bd73 --- /dev/null +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/develop/DevelopGatewayController.java @@ -0,0 +1,37 @@ +package cn.daxpay.open.payment.admin.controller.develop; + +import cn.daxpay.open.payment.admin.param.develop.DevelopParam; +import cn.daxpay.open.payment.admin.result.develop.DevelopSignResult; +import cn.daxpay.open.payment.admin.service.develop.DevelopGatewayService; +import cn.daxpay.open.payment.unipay.param.gateway.GatewayPrePayParam; +import cn.daxpay.open.platform.core.annotation.PermCode; +import cn.daxpay.open.platform.core.code.PermCodes; +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/// 网关支付开发调试(管理) +/// +/// 仅提供签名辅助, 真实预下单由前端模拟商户请求调用 `/unipay/gateway/pre-pay`。 +@PermCode(menuCode = PermCodes.Develop.Gateway.MENU) +@Tag(name = "网关支付开发调试服务") +@RestController +@RequestMapping("/admin/develop/gateway") +@RequiredArgsConstructor +public class DevelopGatewayController { + + private final DevelopGatewayService developGatewayService; + + @PermCode(code = PermCodes.Action.SIGN) + @Operation(summary = "网关预下单参数签名") + @PostMapping("/sign") + public Result sign(@RequestBody DevelopParam param) { + return Res.ok(developGatewayService.sign(param)); + } +} diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/develop/DevelopGatewayService.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/develop/DevelopGatewayService.java new file mode 100644 index 000000000..d489585af --- /dev/null +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/develop/DevelopGatewayService.java @@ -0,0 +1,32 @@ +package cn.daxpay.open.payment.admin.service.develop; + +import cn.daxpay.open.payment.admin.param.develop.DevelopParam; +import cn.daxpay.open.payment.admin.result.develop.DevelopSignResult; +import cn.daxpay.open.payment.common.util.ObjectSignStrUtil; +import cn.daxpay.open.payment.common.util.PaySignUtil; +import cn.daxpay.open.payment.unipay.param.gateway.GatewayPrePayParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 网关支付开发调试服务 +/// +/// 仅提供组参辅助与签名能力, **不发起真实交易**。 +/// 真实预下单由管理端调试页模拟商户 HTTP 请求调用 `/unipay/gateway/pre-pay` 完成, +/// 与正式对接走同一入口, 避免 admin 内部直调支付核心形成后门。 +@Slf4j +@Service +@RequiredArgsConstructor +public class DevelopGatewayService { + + /// 生成网关预下单参数签名(与正式签名逻辑一致) + /// + /// 调用方需在 param 中自备 reqTime / nonceStr 等公共字段, 本方法只负责签名串与签名值。 + public DevelopSignResult sign(DevelopParam param) { + // 签名串(与 PaySignUtil 内部一致) + String signStr = ObjectSignStrUtil.buildSignStr(param.getParam()); + // 签名值 + String sign = PaySignUtil.sign(param.getParam(), param.getPrivateKey()); + return new DevelopSignResult().setSignStr(signStr).setSign(sign); + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/ChannelAuthFacade.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/ChannelAuthFacade.java index c1cc1445b..bffbbfe32 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/ChannelAuthFacade.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/ChannelAuthFacade.java @@ -5,6 +5,7 @@ import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam; import cn.daxpay.open.payment.unipay.result.assist.AuthResult; import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult; import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum; +import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -33,7 +34,8 @@ public class ChannelAuthFacade { /// 生成授权链接: 支付宝走平台级 OAuth, 其余按支付产品走通道策略 public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param) { if (isAlipayAuth(param.getAuthType())) { - return platformAuthService.generateAlipayAuthUrl(); + // 透传 returnPath, 供码牌等业务页授权完成后回跳 + return platformAuthService.generateAlipayAuthUrl(param.getReturnPath()); } return channelAuthService.generateAuthUrl(param); } @@ -42,6 +44,11 @@ public class ChannelAuthFacade { public AuthResult auth(AuthCodeParam param) { AuthSession session = authSessionStore.loadSession(param.getAuthToken()); AuthResult result = doAuth(param, session); + // 平台级 auth 方法未回填 returnPath 时, 从会话补齐 + if (session != null && StrUtil.isNotBlank(session.getReturnPath()) + && StrUtil.isBlank(result.getReturnPath())) { + result.setReturnPath(session.getReturnPath()); + } // 成功后失效 authToken, 避免 TTL 内重复消费会话上下文 authSessionStore.deleteSession(param.getAuthToken()); return result; diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/PlatformAuthService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/PlatformAuthService.java index cd946e520..b6b74c360 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/PlatformAuthService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/auth/PlatformAuthService.java @@ -83,11 +83,19 @@ public class PlatformAuthService { /// 回调指向固定的 `/auth/alipay`。会话标识 authToken 通过 OAuth state 参数透传, 回调后从 state 恢复会话。 /// session 标记 `source=platform_alipay`, 认证分发层据此走平台级支付宝授权回调分支([#authAlipay])。 public AuthUrlResult generateAlipayAuthUrl() { + return generateAlipayAuthUrl(null); + } + + /// 生成支付宝授权链接, 可携带授权完成后前端回跳路径 + /// + /// @param returnPath 授权完成后前端业务回跳路径(如 `/cashier/{orderNo}/alipay`), 可空 + public AuthUrlResult generateAlipayAuthUrl(String returnPath) { String authToken = IdUtil.fastSimpleUUID(); String queryCode = RandomUtil.randomString(10); AuthSession session = new AuthSession() .setSource(AuthSession.SOURCE_PLATFORM_ALIPAY) - .setQueryCode(queryCode); + .setQueryCode(queryCode) + .setReturnPath(returnPath); authSessionStore.saveSession(authToken, session); String authUrl = buildAlipayAuthUrl(authToken); authSessionStore.saveWaitingResult(queryCode); @@ -120,6 +128,13 @@ public class PlatformAuthService { /// 回调指向固定的 `/auth/wechat`。会话标识 authToken 通过 OAuth state 参数透传, 回调后从 state 恢复会话。 /// session 标记 `source=platform_mp`, 认证分发层据此走平台级微信授权回调分支([#authWechatMp])。 public AuthUrlResult generateWechatMpAuthUrl() { + return generateWechatMpAuthUrl(null); + } + + /// 生成微信公众号授权链接, 可携带授权完成后前端回跳路径 + /// + /// @param returnPath 授权完成后前端业务回跳路径(如 `/cashier/{orderNo}/wechat`), 可空 + public AuthUrlResult generateWechatMpAuthUrl(String returnPath) { PlatformWechatMpAuthConfig config = platformWechatMpAuthConfigService.getWechatMpAuthConfig(); if (!isWechatMpConfigured(config)) { // 微信: 平台级微信公众号配置不完整, 请先在「平台配置」中配置 @@ -134,7 +149,8 @@ public class PlatformAuthService { String queryCode = RandomUtil.randomString(10); AuthSession session = new AuthSession() .setSource(AuthSession.SOURCE_PLATFORM_MP) - .setQueryCode(queryCode); + .setQueryCode(queryCode) + .setReturnPath(returnPath); authSessionStore.saveSession(authToken, session); // redirect_uri 为固定路径, authToken 通过 OAuth state 透传 String redirectUri = StrUtil.removeSuffix(gatewayBase, "/") + WECHAT_AUTH_PATH; @@ -150,6 +166,13 @@ public class PlatformAuthService { /// 会话标识 authToken 通过 state 参数透传, 回调后从 state 恢复会话。 /// session 标记 `source=platform_douyin`, 认证分发层据此走平台级抖音授权回调分支([#authDouyin])。 public AuthUrlResult generateDouyinAuthUrl() { + return generateDouyinAuthUrl(null); + } + + /// 生成抖音 H5 静默授权链接, 可携带授权完成后前端回跳路径 + /// + /// @param returnPath 授权完成后前端业务回跳路径, 可空 + public AuthUrlResult generateDouyinAuthUrl(String returnPath) { PlatformDouyinH5AuthConfig config = platformDouyinH5AuthConfigService.getDouyinH5AuthConfig(); if (!isDouyinH5Configured(config)) { // 抖音: 平台级抖音 H5 应用配置不完整, 请先在「三方平台管理」中配置 @@ -164,7 +187,8 @@ public class PlatformAuthService { String queryCode = RandomUtil.randomString(10); AuthSession session = new AuthSession() .setSource(AuthSession.SOURCE_PLATFORM_DOUYIN) - .setQueryCode(queryCode); + .setQueryCode(queryCode) + .setReturnPath(returnPath); authSessionStore.saveSession(authToken, session); // redirect_uri 为固定路径(需与抖音开放平台配置完全一致), authToken 通过 state 透传 String redirectUri = StrUtil.removeSuffix(gatewayBase, "/") + DOUYIN_AUTH_PATH; @@ -194,6 +218,8 @@ public class PlatformAuthService { .setUserId(userId) .setAccessToken(alipayResult.getAccessToken()) .setStatus(ChannelAuthStatusEnum.SUCCESS.getCode()); + // 回填业务回跳路径, 供前端跳回收银台/聚合等页面 + fillReturnPath(authResult, session); authSessionStore.writeResultByQueryCode(param.getQueryCode(), session, authResult); return authResult; } @@ -214,6 +240,7 @@ public class PlatformAuthService { .setOpenId(data.getOpenId()) .setAccessToken(data.getAccessToken()) .setStatus(ChannelAuthStatusEnum.SUCCESS.getCode()); + fillReturnPath(authResult, session); authSessionStore.writeResultByQueryCode(param.getQueryCode(), session, authResult); return authResult; } @@ -240,6 +267,7 @@ public class PlatformAuthService { .setOpenId(data.getOpenId()) .setAccessToken(data.getAccessToken()) .setStatus(ChannelAuthStatusEnum.SUCCESS.getCode()); + fillReturnPath(authResult, session); authSessionStore.writeResultByQueryCode(param.getQueryCode(), session, authResult); return authResult; } @@ -248,4 +276,11 @@ public class PlatformAuthService { private boolean isDouyinH5Configured(PlatformDouyinH5AuthConfig config) { return StrUtil.isNotBlank(config.getClientKey()) && StrUtil.isNotBlank(config.getClientSecret()); } + + /// 将会话中的 returnPath 回填到认证结果 + private void fillReturnPath(AuthResult authResult, AuthSession session) { + if (session != null && StrUtil.isNotBlank(session.getReturnPath())) { + authResult.setReturnPath(session.getReturnPath()); + } + } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/dao/NormalPayOrderManager.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/dao/NormalPayOrderManager.java index a1a1b7b9e..6796ecf3e 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/dao/NormalPayOrderManager.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/dao/NormalPayOrderManager.java @@ -3,6 +3,7 @@ package cn.daxpay.open.payment.trade.order.dao; import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager; import cn.daxpay.open.platform.common.mybatisplus.query.generator.QueryGenerator; import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil; +import cn.daxpay.open.platform.core.annotation.IgnoreTenant; import cn.daxpay.open.platform.core.rest.param.PageParam; import cn.daxpay.open.payment.trade.order.entity.NormalPayOrder; import cn.daxpay.open.payment.trade.order.param.NormalPayOrderQuery; @@ -24,6 +25,14 @@ public class NormalPayOrderManager extends BaseManager findByOrderNoNotTenant(String orderNo) { + return lambdaQuery() + .eq(NormalPayOrder::getOrderNo, orderNo) + .oneOpt(); + } + /// 根据业务单号查询(按商户号自动租户隔离) public Optional findByBizOrderNo(String bizOrderNo) { return lambdaQuery() diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java index 6ce52ca3a..d5ce450f0 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java @@ -1,14 +1,18 @@ package cn.daxpay.open.payment.trade.runtime.service.pay.gateway; +import cn.daxpay.open.payment.merchant.dao.gateway.GatewayAggregateConfigManager; +import cn.daxpay.open.payment.merchant.entity.gateway.GatewayAggregateConfig; import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum; import cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum; import cn.daxpay.open.payment.merchant.service.gateway.ClientEnvPayResolveService; import cn.daxpay.open.payment.trade.enums.GatewayPayTypeEnum; import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder; import cn.daxpay.open.payment.unipay.param.gateway.AggregateQrPayParam; +import cn.daxpay.open.payment.unipay.result.gateway.AggregatePayMetaResult; import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult; import cn.daxpay.open.platform.common.spring.util.WebServletUtil; import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum; import cn.daxpay.open.platform.core.exception.BizInfoException; import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; @@ -16,18 +20,48 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.Objects; +import java.util.Set; /// # 聚合扫码支付服务 /// -/// 按应用聚合配置的深度(level)解析支付方式, 委托 [ClientEnvPayResolveService] 与码牌共用解析。 +/// 按应用聚合配置的深度(level)解析支付方式, 委托 [ClientEnvPayResolveService]。 @Slf4j @Service @RequiredArgsConstructor public class AggregatePayService { + /// 需要买家标识(openId/userId)的支付方式 + private static final Set METHODS_NEED_OPEN_ID = Set.of( + PayMethodEnum.WECHAT_JSAPI.getCode(), + PayMethodEnum.WECHAT_MINI.getCode(), + PayMethodEnum.ALIPAY_JSAPI.getCode(), + PayMethodEnum.UNION_JSAPI.getCode(), + PayMethodEnum.DOUYIN_JSAPI.getCode() + ); + private final GatewayPayAssistService gatewayPayAssistService; private final ClientEnvPayResolveService clientEnvPayResolveService; private final GatewayPayHandleService gatewayPayHandleService; + private final GatewayAggregateConfigManager aggregateConfigManager; + + /// H5 聚合元数据: autoLaunch / needOpenId(不下发敏感路由字段) + public AggregatePayMetaResult getMeta(String orderNo, String clientEnvCode, String runtimeCode) { + GatewayPayOrder order = gatewayPayAssistService.getOrderAndCheck(orderNo); + if (!Objects.equals(order.getGatewayType(), GatewayPayTypeEnum.AGGREGATE.getCode())) { + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.gateway.typeMismatch"); + } + ClientEnvEnum clientEnv = ClientEnvEnum.findByCode(clientEnvCode); + ClientRuntimeEnum runtime = ClientRuntimeEnum.ofOrDefault(runtimeCode); + var resolved = clientEnvPayResolveService.resolveRequired(order.getAppId(), clientEnv, runtime); + + GatewayAggregateConfig config = aggregateConfigManager.findByAppId(order.getAppId()) + .orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "pay.error.gateway.aggregateConfigMissing")); + + return new AggregatePayMetaResult() + .setAutoLaunch(Boolean.TRUE.equals(config.getAutoLaunch())) + .setNeedOpenId(METHODS_NEED_OPEN_ID.contains(resolved.method())); + } /// 聚合扫码发起支付 public NormalPayResult aggregateQrPay(AggregateQrPayParam param) { diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayAuthService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayAuthService.java new file mode 100644 index 000000000..0e70ac4d1 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayAuthService.java @@ -0,0 +1,77 @@ +package cn.daxpay.open.payment.trade.runtime.service.pay.gateway; + +import cn.daxpay.open.payment.auth.PlatformAuthService; +import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder; +import cn.daxpay.open.payment.unipay.param.gateway.GatewayAuthUrlParam; +import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult; +import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 网关 H5 授权服务 +/// +/// 公开端(无商户签名)根据网关订单生成 OAuth 链接, 用于收银台/聚合页取 openId。 +/// 安全约束: +/// - 订单必须存在且可支付([GatewayPayAssistService#getOrderAndCheck]) +/// - returnPath 仅允许站内业务相对路径, 防止开放重定向 +/// +/// 一期使用平台级认证配置(与调试工具同源); 通道级按支付项解析可二期扩展。 +@Slf4j +@Service +@RequiredArgsConstructor +public class GatewayAuthService { + + private final GatewayPayAssistService gatewayPayAssistService; + private final PlatformAuthService platformAuthService; + + /// 生成授权链接 + public AuthUrlResult generateAuthUrl(GatewayAuthUrlParam param) { + // 校验订单可支付并装载商户上下文 + GatewayPayOrder order = gatewayPayAssistService.getOrderAndCheck(param.getOrderNo()); + String returnPath = sanitizeReturnPath(param.getReturnPath(), order.getOrderNo()); + ChannelAuthTypeEnum authType = ChannelAuthTypeEnum.findByCode(param.getAuthType()); + return switch (authType) { + case WECHAT -> platformAuthService.generateWechatMpAuthUrl(returnPath); + case ALIPAY -> platformAuthService.generateAlipayAuthUrl(returnPath); + case DOUYIN -> platformAuthService.generateDouyinAuthUrl(returnPath); + default -> throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "pay.error.gateway.clientEnvNotSupport"); + }; + } + + /// 校验并规范化 returnPath: 必须是以 / 开头的相对路径, 禁止协议/外链/反斜杠 + private String sanitizeReturnPath(String returnPath, String orderNo) { + if (StrUtil.isBlank(returnPath)) { + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "validation.field.returnPath.notBlank"); + } + String path = returnPath.trim(); + // 禁止绝对 URL / 协议相对 / 反斜杠逃逸 + if (!path.startsWith("/") + || path.startsWith("//") + || path.contains("://") + || path.contains("\\") + || path.contains("@")) { + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "pay.error.gateway.returnPathInvalid"); + } + // 仅允许网关业务落地前缀, 且路径中应含本订单号(防跨单串跳) + boolean allowedPrefix = path.startsWith("/cashier/") + || path.startsWith("/aggregate/") + || path.startsWith("/h/"); + if (!allowedPrefix || !path.contains(orderNo)) { + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "pay.error.gateway.returnPathInvalid"); + } + // 去掉 hash, 保留 query + int hash = path.indexOf('#'); + if (hash >= 0) { + path = path.substring(0, hash); + } + return path; + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/device/CodePayAuthUrlParam.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/device/CodePayAuthUrlParam.java new file mode 100644 index 000000000..d961a6c5a --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/device/CodePayAuthUrlParam.java @@ -0,0 +1,25 @@ +package cn.daxpay.open.payment.unipay.param.device; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/// # 码牌生成授权链接参数 +@Data +@Schema(title = "码牌生成授权链接参数") +public class CodePayAuthUrlParam { + + /// 码牌编码 + @Schema(description = "码牌编码") + @NotBlank(message = "{validation.field.code.notBlank}") + @Size(max = 64, message = "{validation.field.code.size}") + private String code; + + /// 客户端环境 + /// @see cn.daxpay.open.payment.merchant.enums.ClientEnvEnum + @Schema(description = "客户端环境(wechat/alipay/union_pay/douyin)") + @NotBlank(message = "{validation.field.clientEnv.notBlank}") + @Size(max = 32, message = "{validation.field.clientEnv.size}") + private String clientEnv; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/gateway/GatewayAuthUrlParam.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/gateway/GatewayAuthUrlParam.java new file mode 100644 index 000000000..704d131e1 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/param/gateway/GatewayAuthUrlParam.java @@ -0,0 +1,32 @@ +package cn.daxpay.open.payment.unipay.param.gateway; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/// # 网关 H5 生成授权链接参数(无商户签名) +/// +/// 凭网关订单装载商户上下文后生成 OAuth 链接; 用于收银台/聚合等落地页取 openId。 +/// 当前一期走平台级认证配置(微信公众号/支付宝/抖音 H5); 通道级(按支付项 DIRECT)可后续扩展。 +@Data +@Schema(title = "网关授权链接参数") +public class GatewayAuthUrlParam { + + @Schema(description = "平台网关单号") + @NotBlank(message = "{validation.field.orderNo.notBlank}") + @Size(max = 64, message = "{validation.field.orderNo.size}") + private String orderNo; + + /// 认证类型: wechat / alipay / douyin(与 ChannelAuthTypeEnum / ClientEnv 对齐) + @Schema(description = "认证类型 wechat/alipay/douyin") + @NotBlank(message = "{validation.field.authType.notBlank}") + @Size(max = 32, message = "{validation.field.authType.size}") + private String authType; + + /// 授权完成后前端回跳路径, 须为站内相对路径(如 /cashier/{orderNo}/wechat) + @Schema(description = "授权完成后回跳路径") + @NotBlank(message = "{validation.field.returnPath.notBlank}") + @Size(max = 200, message = "{validation.field.returnPath.size}") + private String returnPath; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/gateway/AggregatePayMetaResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/gateway/AggregatePayMetaResult.java new file mode 100644 index 000000000..7d7a16ca8 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/gateway/AggregatePayMetaResult.java @@ -0,0 +1,22 @@ +package cn.daxpay.open.payment.unipay.result.gateway; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 聚合扫码 H5 元数据(公开字段) +/// +/// 仅下发前端决策所需信息, 不下发 method/通道商户等敏感路由字段。 +@Data +@Accessors(chain = true) +@Schema(title = "聚合扫码元数据") +public class AggregatePayMetaResult { + + /// 是否自动拉起支付 + @Schema(description = "是否自动拉起支付") + private Boolean autoLaunch; + + /// 当前环境解析出的支付方式是否需要 openId + @Schema(description = "是否需要先完成 OAuth 获取 openId") + private Boolean needOpenId; +} diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/DeviceQrCodeClientController.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/DeviceQrCodeClientController.java index 2cf9af7bd..7cedf320d 100644 --- a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/DeviceQrCodeClientController.java +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/DeviceQrCodeClientController.java @@ -1,8 +1,11 @@ package cn.daxpay.open.payment.unipay.client.controller; import cn.daxpay.open.payment.unipay.client.result.CodePayInfoResult; +import cn.daxpay.open.payment.unipay.client.result.CodePayOrderStatusResult; import cn.daxpay.open.payment.unipay.client.service.CodePayAssistService; +import cn.daxpay.open.payment.unipay.param.device.CodePayAuthUrlParam; import cn.daxpay.open.payment.unipay.param.device.CodePayParam; +import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult; import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult; import cn.daxpay.open.platform.core.annotation.IgnoreAuth; import cn.daxpay.open.platform.core.rest.Res; @@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /// # 码牌支付(公开/H5/小程序侧) @@ -33,8 +37,10 @@ public class DeviceQrCodeClientController { @Operation(summary = "根据码牌编码查询支付信息") @GetMapping("/get-by-code") - public Result getByCode(@NotBlank(message = "{validation.field.code.notBlank}") String code) { - return Res.ok(codePayAssistService.getByCode(code)); + public Result getByCode( + @NotBlank(message = "{validation.field.code.notBlank}") String code, + @RequestParam(required = false) String clientEnv) { + return Res.ok(codePayAssistService.getByCode(code, clientEnv)); } @Operation(summary = "码牌发起支付") @@ -42,4 +48,17 @@ public class DeviceQrCodeClientController { public Result pay(@RequestBody @Validated CodePayParam param) { return Res.ok(codePayAssistService.pay(param)); } + + @Operation(summary = "码牌生成 OAuth 授权链接") + @PostMapping("/generate-auth-url") + public Result generateAuthUrl(@RequestBody @Validated CodePayAuthUrlParam param) { + return Res.ok(codePayAssistService.generateAuthUrl(param)); + } + + @Operation(summary = "查询码牌订单状态") + @GetMapping("/order-status") + public Result orderStatus( + @NotBlank(message = "{validation.field.orderNo.notBlank}") String orderNo) { + return Res.ok(codePayAssistService.orderStatus(orderNo)); + } } diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/GatewayClientController.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/GatewayClientController.java index 0351cdf47..f48fb2e8b 100644 --- a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/GatewayClientController.java +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/controller/GatewayClientController.java @@ -2,9 +2,13 @@ package cn.daxpay.open.payment.unipay.client.controller; import cn.daxpay.open.payment.trade.runtime.service.pay.gateway.AggregatePayService; import cn.daxpay.open.payment.trade.runtime.service.pay.gateway.CashierPayService; +import cn.daxpay.open.payment.trade.runtime.service.pay.gateway.GatewayAuthService; import cn.daxpay.open.payment.trade.runtime.service.pay.gateway.GatewayOrderQueryService; import cn.daxpay.open.payment.unipay.param.gateway.AggregateQrPayParam; import cn.daxpay.open.payment.unipay.param.gateway.CashierPayParam; +import cn.daxpay.open.payment.unipay.param.gateway.GatewayAuthUrlParam; +import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult; +import cn.daxpay.open.payment.unipay.result.gateway.AggregatePayMetaResult; import cn.daxpay.open.payment.unipay.result.gateway.CashierItemPublicResult; import cn.daxpay.open.payment.unipay.result.gateway.GatewayOrderResult; import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult; @@ -35,6 +39,7 @@ public class GatewayClientController { private final GatewayOrderQueryService gatewayOrderQueryService; private final AggregatePayService aggregatePayService; private final CashierPayService cashierPayService; + private final GatewayAuthService gatewayAuthService; @Operation(summary = "查询网关订单摘要") @GetMapping("/order") @@ -43,6 +48,15 @@ public class GatewayClientController { return Res.ok(gatewayOrderQueryService.queryByOrderNoNotTenant(orderNo)); } + @Operation(summary = "聚合扫码元数据(autoLaunch/needOpenId)") + @GetMapping("/aggregate/meta") + public Result aggregateMeta( + @NotBlank(message = "{validation.field.orderNo.notBlank}") String orderNo, + @NotBlank(message = "{validation.field.clientEnv.notBlank}") String clientEnv, + String runtime) { + return Res.ok(aggregatePayService.getMeta(orderNo, clientEnv, runtime)); + } + @Operation(summary = "聚合扫码发起支付") @PostMapping("/aggregate/pay") public Result aggregatePay(@RequestBody @Validated AggregateQrPayParam param) { @@ -63,4 +77,10 @@ public class GatewayClientController { public Result cashierPay(@RequestBody @Validated CashierPayParam param) { return Res.ok(cashierPayService.pay(param)); } + + @Operation(summary = "网关 H5 生成授权链接(取 openId, 无商户签名)") + @PostMapping("/auth/generate-url") + public Result generateAuthUrl(@RequestBody @Validated GatewayAuthUrlParam param) { + return Res.ok(gatewayAuthService.generateAuthUrl(param)); + } } diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/result/CodePayInfoResult.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/result/CodePayInfoResult.java index c78c53e7c..7d9cf02e3 100644 --- a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/result/CodePayInfoResult.java +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/result/CodePayInfoResult.java @@ -29,5 +29,9 @@ public class CodePayInfoResult { /// @see cn.daxpay.open.payment.device.enums.QrCodeProgramTypeEnum @Schema(description = "落地程序类型(h5/mini_app)") private String programType; + + /// 是否需要 openId(传入 clientEnv 时按策略 method 判定; 未传 clientEnv 时为 null) + @Schema(description = "是否需要 openId") + private Boolean needOpenId; } diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/result/CodePayOrderStatusResult.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/result/CodePayOrderStatusResult.java new file mode 100644 index 000000000..8d31b9d1a --- /dev/null +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/result/CodePayOrderStatusResult.java @@ -0,0 +1,25 @@ +package cn.daxpay.open.payment.unipay.client.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 码牌订单状态(公开脱敏) +@Data +@Accessors(chain = true) +@Schema(title = "码牌订单状态") +public class CodePayOrderStatusResult { + + @Schema(description = "平台业务单号") + private String orderNo; + + /// @see cn.daxpay.open.payment.trade.enums.NormalPayOrderStatusEnum + @Schema(description = "订单状态") + private String status; + + @Schema(description = "金额(分)") + private Long amount; + + @Schema(description = "标题") + private String title; +} diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java index 2b48987c5..f41eb4a4f 100644 --- a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java @@ -1,5 +1,6 @@ package cn.daxpay.open.payment.unipay.client.service; +import cn.daxpay.open.payment.auth.ChannelAuthFacade; import cn.daxpay.open.payment.common.access.MerchantAccessInfo; import cn.daxpay.open.payment.common.context.MerchantContextLoader; import cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum; @@ -11,15 +12,25 @@ import cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum; import cn.daxpay.open.payment.merchant.enums.CodePayFormEnum; import cn.daxpay.open.payment.merchant.service.access.MerchantAccessQueryService; import cn.daxpay.open.payment.merchant.service.gateway.CodePayResolveService; +import cn.daxpay.open.payment.route.service.runtime.PayRouteService; +import cn.daxpay.open.payment.trade.order.dao.NormalPayOrderManager; +import cn.daxpay.open.payment.trade.order.entity.NormalPayOrder; import cn.daxpay.open.payment.trade.runtime.service.pay.normal.NormalPayService; import cn.daxpay.open.payment.unipay.client.result.CodePayInfoResult; +import cn.daxpay.open.payment.unipay.client.result.CodePayOrderStatusResult; +import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam; +import cn.daxpay.open.payment.unipay.param.device.CodePayAuthUrlParam; import cn.daxpay.open.payment.unipay.param.device.CodePayParam; import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam; +import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult; import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult; import cn.daxpay.open.platform.common.spring.util.WebServletUtil; import cn.daxpay.open.platform.core.code.CommonCode; import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.daxpay.open.platform.core.enums.pay.trade.TradeSourceEnum; +import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum; import cn.daxpay.open.platform.core.exception.BizInfoException; import cn.daxpay.open.platform.core.exception.DataNotExistException; import cn.daxpay.open.platform.core.exception.operation.OperationFailException; @@ -31,8 +42,10 @@ import org.springframework.stereotype.Service; /// # 码牌支付编排(公开/H5/小程序侧) /// -/// - 查询: 按编码返回脱敏码牌信息(含 programType) +/// - 查询: 按编码返回脱敏码牌信息(含 programType / needOpenId) +/// - 授权: 按码牌解析商户与策略后生成 OAuth 链接, returnPath 指向分端页 /// - 支付: 读**码牌支付策略**解析 method(不读聚合配置); payForm 由 programType 映射 +/// - 状态: 按 orderNo 查询 cashier_code 来源订单脱敏状态 @Slf4j @Service @RequiredArgsConstructor @@ -43,20 +56,36 @@ public class CodePayAssistService { private final MerchantContextLoader merchantContextLoader; private final CodePayResolveService codePayResolveService; private final NormalPayService normalPayService; + private final PayRouteService payRouteService; + private final ChannelAuthFacade channelAuthFacade; + private final NormalPayOrderManager normalPayOrderManager; /// 根据码牌编码查询支付信息(公开接口, 脱敏返回) - public CodePayInfoResult getByCode(String code) { + /// + /// @param clientEnv 可选; 传入时解析策略 method 并填充 needOpenId + public CodePayInfoResult getByCode(String code, String clientEnv) { DeviceQrCode entity = this.loadEnabledAssigned(code); MerchantAccessInfo merchant = merchantAccessQueryService.getMerchantByMchNo(entity.getMchNo()); if (merchant == null) { throw new DataNotExistException("error.device.qrcode.mchNotFound"); } - return new CodePayInfoResult() + CodePayInfoResult result = new CodePayInfoResult() .setCode(entity.getCode()) .setName(entity.getName()) .setAmountType(entity.getAmountType()) .setFixedAmount(entity.getFixedAmount()) .setProgramType(entity.getProgramType()); + if (StrUtil.isNotBlank(clientEnv)) { + try { + String method = this.resolveMethod(entity, clientEnv); + result.setNeedOpenId(this.needOpenId(method)); + } catch (Exception e) { + // 策略未配置等: 默认 JSAPI 路径需要 openId, 避免前端跳过授权 + log.warn("码牌 needOpenId 解析失败 code={} clientEnv={}: {}", code, clientEnv, e.getMessage()); + result.setNeedOpenId(true); + } + } + return result; } /// 码牌发起支付: 普通订单 + source=cashier_code; 策略仅读码牌配置 @@ -98,6 +127,62 @@ public class CodePayAssistService { return normalPayService.pay(payParam); } + /// 生成码牌 OAuth 授权链接(公开, 按码牌解析商户/策略/路由) + public AuthUrlResult generateAuthUrl(CodePayAuthUrlParam param) { + DeviceQrCode entity = this.loadEnabledAssigned(param.getCode()); + merchantContextLoader.initMch(entity.getMchNo()); + var mchApp = merchantContextLoader.resolveApp(entity.getMchNo(), entity.getAppId()); + + ClientEnvEnum clientEnv = ClientEnvEnum.findByCode(param.getClientEnv()); + if (clientEnv == ClientEnvEnum.BROWSER) { + throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.clientEnvNotSupport"); + } + CodePayFormEnum payForm = CodePayFormEnum.fromProgramType(entity.getProgramType()); + var resolved = codePayResolveService.resolveRequired(mchApp.getAppId(), clientEnv, payForm); + + // 跟随支付同路径路由, 拿到 product/channelMchNo/capability 供通道认证 + NormalPayParam routeParam = new NormalPayParam(); + routeParam.setMchNo(entity.getMchNo()); + routeParam.setAppId(mchApp.getAppId()); + routeParam.setMethod(resolved.method()); + routeParam.setChannelMchNo(resolved.channelMchNo()); + routeParam.setCapability(resolved.capability()); + payRouteService.resolve(routeParam); + + String returnPath = this.buildReturnPath(clientEnv, entity.getCode()); + GenerateAuthUrlParam authParam = new GenerateAuthUrlParam(); + authParam.setMchNo(entity.getMchNo()); + authParam.setAppId(mchApp.getAppId()); + authParam.setProduct(routeParam.getProduct()); + authParam.setChannelMchNo(routeParam.getChannelMchNo()); + authParam.setCapability(routeParam.getCapability()); + authParam.setReturnPath(returnPath); + authParam.setAuthType(this.mapAuthType(clientEnv)); + // channel: 由产品反推 + if (StrUtil.isNotBlank(routeParam.getProduct())) { + authParam.setChannel(ProductEnum.findByCode(routeParam.getProduct()).getChannel()); + } + return channelAuthFacade.generateAuthUrl(authParam); + } + + /// 查询码牌订单状态(忽略租户; 仅 cashier_code 来源) + public CodePayOrderStatusResult orderStatus(String orderNo) { + if (StrUtil.isBlank(orderNo)) { + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "validation.field.orderNo.notBlank"); + } + NormalPayOrder order = normalPayOrderManager.findByOrderNoNotTenant(orderNo) + .orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist")); + if (!TradeSourceEnum.CASHIER_CODE.getCode().equals(order.getSource())) { + // 非码牌订单, 视为不存在防越权探测 + throw new DataNotExistException("pay.error.payOrderNotExist"); + } + return new CodePayOrderStatusResult() + .setOrderNo(order.getOrderNo()) + .setStatus(order.getStatus()) + .setAmount(order.getAmount()) + .setTitle(order.getTitle()); + } + /// 请求 runtime 非空时须与 programType 映射的 payForm 一致 private void validateRuntimeMatchesPayForm(String runtime, CodePayFormEnum payForm) { if (StrUtil.isBlank(runtime)) { @@ -143,4 +228,53 @@ public class CodePayAssistService { } return requestAmount; } + + /// 解析码牌策略 method(供 needOpenId) + private String resolveMethod(DeviceQrCode entity, String clientEnvCode) { + ClientEnvEnum clientEnv = ClientEnvEnum.findByCode(clientEnvCode); + if (clientEnv == ClientEnvEnum.BROWSER) { + throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.clientEnvNotSupport"); + } + merchantContextLoader.initMch(entity.getMchNo()); + var mchApp = merchantContextLoader.resolveApp(entity.getMchNo(), entity.getAppId()); + CodePayFormEnum payForm = CodePayFormEnum.fromProgramType(entity.getProgramType()); + return codePayResolveService.resolveRequired(mchApp.getAppId(), clientEnv, payForm).method(); + } + + /// method 是否需要用户标识(JSAPI/小程序类) + private boolean needOpenId(String methodCode) { + if (StrUtil.isBlank(methodCode)) { + return false; + } + try { + PayMethodEnum method = PayMethodEnum.findByCode(methodCode); + return switch (method) { + case WECHAT_JSAPI, WECHAT_MINI, ALIPAY_JSAPI, UNION_JSAPI, DOUYIN_JSAPI -> true; + default -> false; + }; + } catch (Exception e) { + // 未知 method: 含 jsapi/mini 视作需要 + return methodCode.contains("jsapi") || methodCode.contains("mini"); + } + } + + /// 分端页 returnPath: /h/wechat|{alipay}/:code?authed=1 + private String buildReturnPath(ClientEnvEnum clientEnv, String code) { + String segment = switch (clientEnv) { + case WECHAT -> "wechat"; + case ALIPAY -> "alipay"; + case UNION_PAY -> "union-pay"; + case DOUYIN -> "douyin"; + default -> throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.clientEnvNotSupport"); + }; + return "/h/" + segment + "/" + code + "?authed=1"; + } + + private String mapAuthType(ClientEnvEnum clientEnv) { + return switch (clientEnv) { + case ALIPAY -> ChannelAuthTypeEnum.ALIPAY.getCode(); + case WECHAT -> ChannelAuthTypeEnum.WECHAT.getCode(); + default -> ChannelAuthTypeEnum.WECHAT.getCode(); + }; + } }