feat(gateway): 网关/码牌鉴权与开发调试能力增强

补充网关授权 URL、聚合支付元数据、码牌辅助与订单状态查询;运营端 DevelopGateway 调试接口。
This commit is contained in:
DaxPay Dev
2026-07-17 08:24:30 +08:00
parent 86b0fa0be2
commit 2e2391acc7
15 changed files with 522 additions and 10 deletions

View File

@@ -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<DevelopSignResult> sign(@RequestBody DevelopParam<GatewayPrePayParam> param) {
return Res.ok(developGatewayService.sign(param));
}
}

View File

@@ -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<GatewayPrePayParam> param) {
// 签名串(与 PaySignUtil 内部一致)
String signStr = ObjectSignStrUtil.buildSignStr(param.getParam());
// 签名值
String sign = PaySignUtil.sign(param.getParam(), param.getPrivateKey());
return new DevelopSignResult().setSignStr(signStr).setSign(sign);
}
}

View File

@@ -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;

View File

@@ -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());
}
}
}

View File

@@ -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<NormalPayOrderMapper, Nor
.oneOpt();
}
/// 根据平台业务单号查询(忽略租户, H5 码牌订单状态轮询用)
@IgnoreTenant
public Optional<NormalPayOrder> findByOrderNoNotTenant(String orderNo) {
return lambdaQuery()
.eq(NormalPayOrder::getOrderNo, orderNo)
.oneOpt();
}
/// 根据业务单号查询(按商户号自动租户隔离)
public Optional<NormalPayOrder> findByBizOrderNo(String bizOrderNo) {
return lambdaQuery()

View File

@@ -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<String> 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) {

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<CodePayInfoResult> getByCode(@NotBlank(message = "{validation.field.code.notBlank}") String code) {
return Res.ok(codePayAssistService.getByCode(code));
public Result<CodePayInfoResult> 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<NormalPayResult> pay(@RequestBody @Validated CodePayParam param) {
return Res.ok(codePayAssistService.pay(param));
}
@Operation(summary = "码牌生成 OAuth 授权链接")
@PostMapping("/generate-auth-url")
public Result<AuthUrlResult> generateAuthUrl(@RequestBody @Validated CodePayAuthUrlParam param) {
return Res.ok(codePayAssistService.generateAuthUrl(param));
}
@Operation(summary = "查询码牌订单状态")
@GetMapping("/order-status")
public Result<CodePayOrderStatusResult> orderStatus(
@NotBlank(message = "{validation.field.orderNo.notBlank}") String orderNo) {
return Res.ok(codePayAssistService.orderStatus(orderNo));
}
}

View File

@@ -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<AggregatePayMetaResult> 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<NormalPayResult> aggregatePay(@RequestBody @Validated AggregateQrPayParam param) {
@@ -63,4 +77,10 @@ public class GatewayClientController {
public Result<NormalPayResult> cashierPay(@RequestBody @Validated CashierPayParam param) {
return Res.ok(cashierPayService.pay(param));
}
@Operation(summary = "网关 H5 生成授权链接(取 openId, 无商户签名)")
@PostMapping("/auth/generate-url")
public Result<AuthUrlResult> generateAuthUrl(@RequestBody @Validated GatewayAuthUrlParam param) {
return Res.ok(gatewayAuthService.generateAuthUrl(param));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
};
}
}