chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-pay</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>app-platform-pay-sdk</artifactId>
</project>

View File

@@ -0,0 +1,22 @@
package com.xspaceagi.pay.sdk;
/** Open API 统一 4 位业务码 */
public final class OpenApiCodes {
private OpenApiCodes() {
}
public static final String SUCCESS = "0000";
public static final String BAD_REQUEST = "0400";
public static final String UNAUTHORIZED = "0401";
public static final String FORBIDDEN = "0403";
public static final String INTERNAL_ERROR = "0500";
public static final String BUSINESS_ERROR = "9999";
public static String fromHttpStatus(int httpStatus) {
if (httpStatus < 0 || httpStatus > 9999) {
return INTERNAL_ERROR;
}
return String.format("%04d", httpStatus);
}
}

View File

@@ -0,0 +1,26 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.OpenApiCodes;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ApiResponse<T> {
private String code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder().code(OpenApiCodes.SUCCESS).message("Success").data(data).build();
}
public static <T> ApiResponse<T> fail(String code, String message) {
return fail(code, message, null);
}
/** 业务失败但附带部分 data如 bootstrap 会话无效时仍返回收银台品牌字段) */
public static <T> ApiResponse<T> fail(String code, String message, T data) {
return ApiResponse.<T>builder().code(code).message(message).data(data).build();
}
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
@Data
public class ByClientIdRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
/** 业务服务端调用:验签通过后创建收银台会话并返回跳转 URL */
@Data
public class CashierSessionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String gatewayPaymentOrderNo;
private Long orderAmount;
private String subject;
/** 支付成功回跳,可空 */
private String bizRedirectUrl;
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CashierSessionCreateResponse {
private String cashierUrl;
}

View File

@@ -0,0 +1,34 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeveloperAccountInfoResponse {
private Long id;
private Long tenantId;
/** 关联 {@code user.id} */
private Long userId;
private String userName;
private String email;
private String phone;
private String realName;
private String idCardNo;
/** 身份证正面照片 URL */
private String idCardFrontPhotoUrl;
/** 身份证反面照片 URL */
private String idCardBackPhotoUrl;
private String bankName;
private String branchName;
private String bankCardNo;
private Date created;
private Date modified;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.MerchantOnboardingStatus;
import com.xspaceagi.pay.sdk.enums.MerchantOnboardingType;
import lombok.Data;
@Data
public class MerchantOnboardingPageRequest {
private String clientId;
private MerchantOnboardingType onboardingType;
private MerchantOnboardingStatus status;
private Long timestamp;
private String nonce;
private String signature;
private Integer page;
private Integer pageSize;
}

View File

@@ -0,0 +1,72 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.MerchantOnboardingStatus;
import com.xspaceagi.pay.sdk.enums.MerchantOnboardingType;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class MerchantOnboardingResponse {
private Long id;
private MerchantOnboardingType onboardingType;
private String clientId;
private Long userId;
private MerchantOnboardingStatus status;
private String auditRemark;
/** 审核时间 */
private LocalDateTime auditedAt;
/** 商户名称 */
private String merchantName;
/** 商户简称 */
private String merchantShortName;
/** 统一社会信用代码 */
private String creditCode;
/** 注册地址 */
private String registeredAddress;
/** 法人姓名 */
private String legalPersonName;
/** 法人证件号 */
private String legalPersonIdNo;
/** 经办联系人 */
private String contactName;
/** 联系人电话 */
private String contactPhone;
/** 联系人邮箱 */
private String contactEmail;
/** 开户名称 */
private String bankName;
/** 开户银行名称 */
private String bankAccountName;
/** 开户银行支行名称 */
private String bankBranchName;
/** 银行账号 */
private String bankAccountNo;
/** 银行回单附言备注 */
private String bankReceiptRemark;
/** 备注 */
private String remark;
/** 营业执照 */
private String orgCertificateUrl;
/** 法人身份证正面 */
private String legalPersonIdCardFrontUrl;
/** 法人身份证背面 */
private String legalPersonIdCardBackUrl;
/** 财务室照片 */
private String photoFinanceRoomUrl;
/** 门头照片 */
private String photoGateUrl;
/** 地标照片 */
private String photoLandmarkUrl;
/** 银行开户证明 */
private String bankAccountProofUrl;
private LocalDateTime created;
private LocalDateTime modified;
}

View File

@@ -0,0 +1,10 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class MerchantOnboardingUpdateRequest extends MerchantOnboardingUpsertRequest {
private Long id;
}

View File

@@ -0,0 +1,59 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.MerchantOnboardingStatus;
import com.xspaceagi.pay.sdk.enums.MerchantOnboardingType;
import lombok.Data;
/**
* 创建/全量更新进件资料(图片字段均为 URL
*/
@Data
public class MerchantOnboardingUpsertRequest {
private MerchantOnboardingType onboardingType;
/** 由服务端按登录租户凭证写入,请求体传入将被忽略 */
private String clientId;
/** 用户进件填写 */
private Long userId;
/** 毫秒级 Unix 时间戳 */
private Long timestamp;
private String nonce;
private String signature;
private MerchantOnboardingStatus status;
private String auditRemark;
private String merchantName;
private String merchantShortName;
private String creditCode;
private String registeredAddress;
private String legalPersonName;
private String legalPersonIdNo;
private String contactName;
private String contactPhone;
private String contactEmail;
private String bankAccountName;
private String bankName;
private String bankBranchName;
private String bankAccountNo;
private String bankReceiptRemark;
private String remark;
private String orgCertificateUrl;
private String legalPersonIdCardUrl;
/** 法人身份证人像面/正面图片 URL */
private String legalPersonIdCardFrontUrl;
/** 法人身份证国徽面/反面图片 URL */
private String legalPersonIdCardBackUrl;
private String photoFinanceRoomUrl;
private String photoGateUrl;
private String photoLandmarkUrl;
private String bankAccountProofUrl;
}

View File

@@ -0,0 +1,16 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class PageResult<T> {
private List<T> records;
private long total;
/** 当前页,从 1 开始 */
private long page;
private long pageSize;
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
@Data
public class PayConfigQueryRequest {
private String clientId;
/** 毫秒级 Unix 时间戳 */
private Long timestamp;
private String nonce;
private String signature;
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Builder
public class PayConfigResponse {
private BigDecimal payRate;
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayMode;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PaymentOrderCreateResponse {
private String gatewayPaymentOrderNo;
private String bizOrderNo;
private PaymentStatus status;
private PayMode payMode;
}

View File

@@ -0,0 +1,22 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
@Data
public class PaymentStatusQueryRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String gatewayPaymentOrderNo;
/**
* 为 true 时向支付渠道发起交易查询(如安心付 /query成功则落库并调用通知状态更新/update/notify_status
* 默认 false仅读本地库。
*/
private Boolean syncFromChannel;
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import lombok.Data;
@Data
public class ScanOrderAndTransactionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String bizOrderNo;
private PayChannel payChannel;
private Long orderAmount;
private String subject;
private String ext;
}

View File

@@ -0,0 +1,22 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
@Data
public class ScanOrderCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String bizOrderNo;
private Long orderAmount;
private String subject;
private String ext;
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayMode;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ScanOrderCreateResponse {
private String gatewayPaymentOrderNo;
private String gatewayPaymentTxNo;
/** 渠道服务费,单位分 */
private Long providerFee;
/** 平台服务费,单位分 */
private Long platformFee;
/** 净额,单位分(订单金额扣减手续费后) */
private Long netAmount;
private PaymentStatus status;
private PayChannel payChannel;
private PayMode payMode;
private String qrCodeContent;
private String expiredAt;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayMode;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ScanOrderStatusQueryResponse {
private String gatewayPaymentOrderNo;
private String gatewayPaymentTxNo;
private PaymentStatus status;
private PaymentStatus transactionStatus;
private PayChannel payChannel;
private PayMode payMode;
private Long orderAmount;
private Long providerFee;
private Long platformFee;
private Long netAmount;
private String qrCodeContent;
private String paidAt;
}

View File

@@ -0,0 +1,21 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import lombok.Data;
@Data
public class ScanTransactionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String gatewayPaymentOrderNo;
private PayChannel payChannel;
private String clientIp;
}

View File

@@ -0,0 +1,20 @@
package com.xspaceagi.pay.sdk.enums;
public enum MerchantOnboardingStatus {
DRAFT("草稿"),
PENDING_REVIEW("待审核"),
UNDER_REVIEW("审核中"),
APPROVED("已通过"),
REJECTED("已拒绝");
private final String name;
MerchantOnboardingStatus(String name) {
this.name = name;
}
/** 管理台/对外展示用中文名 */
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.pay.sdk.enums;
public enum MerchantOnboardingType {
PLATFORM("平台进件"),
TENANT("租户进件"),
USER("用户进件");
private final String name;
MerchantOnboardingType(String name) {
this.name = name;
}
/** 管理台/对外展示用中文名 */
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,32 @@
package com.xspaceagi.pay.sdk.enums;
public enum PayChannel {
WxPay("微信支付"),
AliPay("支付宝"),
UnionPay("银联支付");
private final String name;
PayChannel(String name) {
this.name = name;
}
/** 管理台/对外展示用中文名 */
public String getName() {
return name;
}
/** API / 管理台入参:枚举名,大小写不敏感 */
public static PayChannel parse(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("payChannel is blank");
}
String s = raw.trim();
for (PayChannel pc : values()) {
if (pc.name().equalsIgnoreCase(s)) {
return pc;
}
}
throw new IllegalArgumentException("Unknown pay channel: " + raw);
}
}

View File

@@ -0,0 +1,16 @@
package com.xspaceagi.pay.sdk.enums;
public enum PayMode {
scan("主扫");
private final String name;
PayMode(String name) {
this.name = name;
}
/** 管理台/对外展示用中文名 */
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,28 @@
package com.xspaceagi.pay.sdk.enums;
public enum PaymentStatus {
INIT("待发起"),
PENDING("待支付"),
PAID("成功"),
FAILED("失败"),
CLOSED("关单");
private final String name;
PaymentStatus(String name) {
this.name = name;
}
/** 管理台/对外展示用中文名 */
public String getName() {
return name;
}
public static PaymentStatus fromPersistedString(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("payment status blank");
}
String t = raw.trim();
return valueOf(t);
}
}

View File

@@ -0,0 +1,8 @@
package com.xspaceagi.pay.sdk.service;
import com.xspaceagi.pay.sdk.dto.DeveloperAccountInfoResponse;
public interface IDeveloperAccountInfoRpcService {
DeveloperAccountInfoResponse getDeveloperAccountInfo(Long tenantId, Long userId);
}

View File

@@ -0,0 +1,31 @@
package com.xspaceagi.pay.sdk.service;
import com.xspaceagi.pay.sdk.dto.CashierSessionCreateRequest;
import com.xspaceagi.pay.sdk.dto.CashierSessionCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentOrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
import com.xspaceagi.pay.sdk.dto.ScanOrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
/** 平台内支付 RPC 契约(由 pay-application 实现,供 bill 等外部模块依赖调用)。 */
public interface IPaymentRpcService {
/** 创建扫码支付订单 */
PaymentOrderCreateResponse createOrderForScan(ScanOrderCreateRequest request);
/**
* 获取收银台地址;会话有过期时间,过期后需重新调用。
*/
CashierSessionCreateResponse createCashierSession(CashierSessionCreateRequest request);
/** 查询支付状态(仅更新 pay_order不通知 Bill */
ScanOrderStatusQueryResponse queryStatus(PaymentStatusQueryRequest request);
/**
* 结算页主动同步:按业务单号查网关终态并通知 Bill与支付轮询任务单 tick 一致,幂等)。
*
* @param bizOrderNo 业务订单号,通常为 Bill 订单 id 字符串
* @return 是否已通知到 Bill 终态或此前已完成通知
*/
boolean syncSettlementForBizOrderNo(String bizOrderNo);
}

View File

@@ -0,0 +1,19 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.CashierSessionCreateRequest;
/** 收银台 Open API 路径常量;{@link #PATH_SESSION} 需 HMAC 验签,其余为免签路径。 */
public final class OpenApiCashierSign {
public static final String BASE = "/open-api/cashier";
public static final String PATH_SESSION = BASE + "/session";
public static final String PATH_SESSION_BOOTSTRAP = BASE + "/session/bootstrap";
public static final String PATH_SCAN_INIT = BASE + "/scan/init";
public static final String PATH_SCAN_STATUS = BASE + "/scan/status";
private OpenApiCashierSign() {}
public static byte[] signCreateSession(CashierSessionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_SESSION, request, clientSecret);
}
}

View File

@@ -0,0 +1,30 @@
package com.xspaceagi.pay.sdk.sign;
import java.util.UUID;
/**
* 接入方 Open API 签名通用入口(自动生成 timestamp/nonce 的重载见各 {@code OpenApi*Sign})。
* <p>body 哈希仅包含 {@link OpenApiSignPolicies} 中为该 path 配置的业务字段;完整 JSON 仍可带 ext 等非签名字段。</p>
*/
public final class OpenApiClientSignHelper {
private OpenApiClientSignHelper() {}
public static String newNonce() {
return UUID.randomUUID().toString().replace("-", "");
}
public static long nowTimestamp() {
return System.currentTimeMillis();
}
public static byte[] buildSignedPostBody(
String path, Object requestBody, long timestamp, String nonce, String clientSecret) {
return OpenApiSignedRequestSupport.buildSignedPostBody(path, requestBody, timestamp, nonce, clientSecret);
}
public static String buildSignedPostJson(
String path, Object requestBody, long timestamp, String nonce, String clientSecret) {
return OpenApiSignedRequestSupport.buildSignedPostJson(path, requestBody, timestamp, nonce, clientSecret);
}
}

View File

@@ -0,0 +1,39 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.ByClientIdRequest;
import com.xspaceagi.pay.sdk.dto.MerchantOnboardingPageRequest;
import com.xspaceagi.pay.sdk.dto.MerchantOnboardingUpdateRequest;
import com.xspaceagi.pay.sdk.dto.MerchantOnboardingUpsertRequest;
/** 进件 Open API 路径与签名辅助 */
public final class OpenApiMerchantOnboardingSign {
public static final String BASE = "/open-api/merchant-onboarding";
public static final String PATH_ADD = BASE + "/add";
public static final String PATH_UPDATE = BASE + "/update";
public static final String PATH_GET_BY_CLIENT_ID = BASE + "/get-by-client-id";
public static final String PATH_LIST_BY_CLIENT_ID = BASE + "/list-by-client-id";
public static final String PATH_PAGE = BASE + "/page";
private OpenApiMerchantOnboardingSign() {}
public static byte[] signAdd(MerchantOnboardingUpsertRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_ADD, request, clientSecret);
}
public static byte[] signUpdate(MerchantOnboardingUpdateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_UPDATE, request, clientSecret);
}
public static byte[] signGetByClientId(ByClientIdRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_GET_BY_CLIENT_ID, request, clientSecret);
}
public static byte[] signListByClientId(ByClientIdRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_LIST_BY_CLIENT_ID, request, clientSecret);
}
public static byte[] signPage(MerchantOnboardingPageRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_PAGE, request, clientSecret);
}
}

View File

@@ -0,0 +1,70 @@
package com.xspaceagi.pay.sdk.sign;
import org.apache.commons.codec.binary.Hex;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Locale;
/**
* Open API HMAC-SHA256 签名与验签(唯一实现,接入方与服务端共用)。
* <p>接入方优先使用各 {@code OpenApi*Sign} 或 {@link OpenApiClientSignHelper};服务端验签使用 {@link #verify}。</p>
* <p>body 哈希字段见 {@link OpenApiSignPolicies}。</p>
*/
public final class OpenApiPayloadSigner {
private static final String HMAC_SHA256 = "HmacSHA256";
private OpenApiPayloadSigner() {}
/**
* @param httpBody 可为最终 HTTP body含 signature或不含 signature哈希前均会剔除 signature 字段
*/
public static String sign(
String httpMethod, String path, long timestamp, String nonce, byte[] httpBody, String clientSecret) {
if (clientSecret == null) {
throw new IllegalArgumentException("clientSecret must not be null");
}
String canonical = OpenApiSignCanonical.build(httpMethod, path, timestamp, nonce, httpBody);
return hmacSha256Hex(clientSecret, canonical);
}
public static boolean verify(
String httpMethod,
String path,
long timestamp,
String nonce,
byte[] rawBody,
String clientSecret,
String signature) {
if (signature == null || signature.isBlank() || clientSecret == null) {
return false;
}
String expected = sign(httpMethod, path, timestamp, nonce, rawBody, clientSecret);
return constantTimeEqualsHex(expected, signature.trim());
}
private static String hmacSha256Hex(String secret, String data) {
try {
Mac mac = Mac.getInstance(HMAC_SHA256);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
return Hex.encodeHexString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException("HMAC-SHA256 not available", e);
}
}
private static boolean constantTimeEqualsHex(String expectedHex, String actualHex) {
try {
byte[] expected = Hex.decodeHex(expectedHex.toCharArray());
byte[] actual = Hex.decodeHex(actualHex.toLowerCase(Locale.ROOT).toCharArray());
return expected.length == actual.length && MessageDigest.isEqual(expected, actual);
} catch (Exception e) {
return false;
}
}
}

View File

@@ -0,0 +1,16 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.PayConfigQueryRequest;
/** 支付配置 Open API 路径与签名辅助 */
public final class OpenApiPaymentConfigSign {
public static final String BASE = "/open-api/payment/config";
public static final String PATH_QUERY = BASE + "/query";
private OpenApiPaymentConfigSign() {}
public static byte[] signQuery(PayConfigQueryRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_QUERY, request, clientSecret);
}
}

View File

@@ -0,0 +1,35 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
import com.xspaceagi.pay.sdk.dto.ScanOrderAndTransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.ScanOrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.ScanTransactionCreateRequest;
/** 主扫支付 Open API 路径与签名辅助 */
public final class OpenApiPaymentScanSign {
public static final String BASE = "/open-api/payment/scan";
public static final String PATH_CREATE_ORDER_AND_TRANSACTION = BASE + "/create-order-and-transaction";
public static final String PATH_CREATE_ORDER = BASE + "/create-order";
public static final String PATH_CREATE_TRANSACTION = BASE + "/create-transaction";
public static final String PATH_STATUS = BASE + "/status";
private OpenApiPaymentScanSign() {}
public static byte[] signCreateOrderAndTransaction(
ScanOrderAndTransactionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_ORDER_AND_TRANSACTION, request, clientSecret);
}
public static byte[] signCreateOrder(ScanOrderCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_ORDER, request, clientSecret);
}
public static byte[] signCreateTransaction(ScanTransactionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_TRANSACTION, request, clientSecret);
}
public static byte[] signQueryStatus(PaymentStatusQueryRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_STATUS, request, clientSecret);
}
}

View File

@@ -0,0 +1,26 @@
package com.xspaceagi.pay.sdk.sign;
import org.apache.commons.codec.digest.DigestUtils;
import java.util.List;
import java.util.Locale;
/** OpenAPI 待签名字符串:绑定 HTTP 方法、路径、时间戳、nonce 与请求体(不含 {@code signature} 字段)。 */
final class OpenApiSignCanonical {
private OpenApiSignCanonical() {}
static String bodySha256Hex(byte[] body) {
return DigestUtils.sha256Hex(body == null ? new byte[0] : body);
}
static String build(String method, String path, long timestamp, String nonce, byte[] httpBody) {
String m = method == null ? "" : method.trim().toUpperCase(Locale.ROOT);
String p = OpenApiSignPolicies.normalizePath(path);
String n = nonce == null ? "" : nonce.trim();
List<String> signFields = OpenApiSignPolicies.signFieldsForPath(p);
byte[] bodyForSign = OpenApiSignFieldCanonical.canonicalBytes(
OpenApiSignJson.bodyBytesWithoutSignature(httpBody), signFields);
return m + "\n" + p + "\n" + timestamp + "\n" + n + "\n" + bodySha256Hex(bodyForSign);
}
}

View File

@@ -0,0 +1,40 @@
package com.xspaceagi.pay.sdk.sign;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import java.util.TreeMap;
/** 仅提取参与签名的业务字段,按字段名字典序序列化为 JSON 后做 SHA256避免全量 body 二次序列化漂移)。 */
final class OpenApiSignFieldCanonical {
private OpenApiSignFieldCanonical() {}
static byte[] canonicalBytes(byte[] httpBody, List<String> signFieldNames) {
if (signFieldNames == null || signFieldNames.isEmpty()) {
return OpenApiSignJson.bodyBytesWithoutSignature(httpBody);
}
try {
JsonNode root = OpenApiSignJson.mapper().readTree(httpBody == null || httpBody.length == 0 ? "{}".getBytes() : httpBody);
if (!root.isObject()) {
throw new IllegalArgumentException("OpenAPI sign body must be a JSON object");
}
ObjectNode out = OpenApiSignJson.mapper().createObjectNode();
TreeMap<String, JsonNode> sorted = new TreeMap<>();
for (String name : signFieldNames) {
if (name == null || name.isBlank()) {
continue;
}
JsonNode value = root.get(name);
if (value != null && !value.isNull()) {
sorted.put(name, value);
}
}
sorted.forEach(out::set);
return OpenApiSignJson.mapper().writeValueAsBytes(out);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid OpenAPI JSON body for field signing", e);
}
}
}

View File

@@ -0,0 +1,33 @@
package com.xspaceagi.pay.sdk.sign;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
/** Open API 签名专用 JSON不对外作为通用工具暴露 */
final class OpenApiSignJson {
private static final ObjectMapper MAPPER = new ObjectMapper().disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private OpenApiSignJson() {}
static ObjectMapper mapper() {
return MAPPER;
}
static byte[] bodyBytesWithoutSignature(byte[] httpBody) {
if (httpBody == null || httpBody.length == 0) {
return new byte[0];
}
try {
JsonNode root = MAPPER.readTree(httpBody);
if (root.isObject()) {
((ObjectNode) root).remove("signature");
}
return MAPPER.writeValueAsBytes(root);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid OpenAPI JSON body for signing", e);
}
}
}

View File

@@ -0,0 +1,62 @@
package com.xspaceagi.pay.sdk.sign;
import java.util.List;
import java.util.Map;
/**
* 各 Open API 路径参与 body 哈希的字段(不含 {@code timestamp}/{@code nonce}/{@code signature})。
* <p>path 与各 {@code OpenApi*Sign#PATH_*} 保持一致,为单一来源。</p>
*/
public final class OpenApiSignPolicies {
private OpenApiSignPolicies() {}
private static final Map<String, List<String>> BY_PATH = Map.ofEntries(
Map.entry(
OpenApiPaymentScanSign.PATH_CREATE_ORDER_AND_TRANSACTION,
List.of("bizOrderNo", "clientId", "orderAmount", "payChannel")),
Map.entry(
OpenApiPaymentScanSign.PATH_CREATE_ORDER,
List.of("bizOrderNo", "clientId", "orderAmount")),
Map.entry(
OpenApiPaymentScanSign.PATH_CREATE_TRANSACTION,
List.of("clientId", "gatewayPaymentOrderNo", "payChannel")),
Map.entry(
OpenApiPaymentScanSign.PATH_STATUS,
List.of("clientId", "gatewayPaymentOrderNo", "syncFromChannel")),
Map.entry(OpenApiPaymentConfigSign.PATH_QUERY, List.of("clientId")),
Map.entry(
OpenApiMerchantOnboardingSign.PATH_ADD,
List.of("clientId", "creditCode", "merchantName", "onboardingType", "userId")),
Map.entry(
OpenApiMerchantOnboardingSign.PATH_UPDATE,
List.of("clientId", "creditCode", "id", "merchantName")),
Map.entry(OpenApiMerchantOnboardingSign.PATH_GET_BY_CLIENT_ID, List.of("clientId")),
Map.entry(OpenApiMerchantOnboardingSign.PATH_LIST_BY_CLIENT_ID, List.of("clientId")),
Map.entry(
OpenApiMerchantOnboardingSign.PATH_PAGE,
List.of("clientId", "onboardingType", "page", "pageSize", "status")),
Map.entry(
OpenApiCashierSign.PATH_SESSION,
List.of("clientId", "gatewayPaymentOrderNo", "orderAmount")));
public static List<String> signFieldsForPath(String path) {
String normalized = normalizePath(path);
List<String> fields = BY_PATH.get(normalized);
if (fields == null) {
throw new IllegalArgumentException("No OpenAPI sign field policy for path: " + normalized);
}
return fields;
}
static String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "/";
}
String p = path.trim();
if (!p.startsWith("/")) {
p = "/" + p;
}
return p;
}
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.pay.sdk.sign;
/** 各 {@code OpenApi*Sign} 共用的签名流程。 */
final class OpenApiSignSupport {
private OpenApiSignSupport() {}
static byte[] sign(String path, Object request, String clientSecret) {
long timestamp = OpenApiClientSignHelper.nowTimestamp();
String nonce = OpenApiClientSignHelper.newNonce();
OpenApiSignTimestamps.apply(request, timestamp, nonce);
return OpenApiClientSignHelper.buildSignedPostBody(path, request, timestamp, nonce, clientSecret);
}
}

View File

@@ -0,0 +1,23 @@
package com.xspaceagi.pay.sdk.sign;
import com.fasterxml.jackson.databind.node.ObjectNode;
/** 将 {@code timestamp}/{@code nonce} 写回请求 POJO含 spec 模块 DTO运行时由 Jackson 更新)。 */
final class OpenApiSignTimestamps {
private OpenApiSignTimestamps() {}
static void apply(Object request, long timestamp, String nonce) {
if (request == null) {
throw new IllegalArgumentException("request must not be null");
}
try {
ObjectNode patch = OpenApiSignJson.mapper().createObjectNode();
patch.put("timestamp", timestamp);
patch.put("nonce", nonce);
OpenApiSignJson.mapper().readerForUpdating(request).readValue(patch);
} catch (Exception e) {
throw new IllegalStateException("Failed to apply OpenAPI timestamp/nonce", e);
}
}
}

View File

@@ -0,0 +1,46 @@
package com.xspaceagi.pay.sdk.sign;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
/** 组装带签名的 Open API POST body按路径策略仅对关键字段参与 body 哈希)。 */
final class OpenApiSignedRequestSupport {
private OpenApiSignedRequestSupport() {}
static byte[] buildSignedPostBody(
String path, Object requestBody, long timestamp, String nonce, String clientSecret) {
ObjectNode node = toObjectNode(requestBody);
node.remove("signature");
node.put("timestamp", timestamp);
node.put("nonce", nonce);
byte[] bodyForSign = writeBytes(node);
String signature =
OpenApiPayloadSigner.sign("POST", path, timestamp, nonce, bodyForSign, clientSecret);
node.put("signature", signature);
return writeBytes(node);
}
static String buildSignedPostJson(
String path, Object requestBody, long timestamp, String nonce, String clientSecret) {
return new String(buildSignedPostBody(path, requestBody, timestamp, nonce, clientSecret), StandardCharsets.UTF_8);
}
private static ObjectNode toObjectNode(Object requestBody) {
if (requestBody instanceof ObjectNode on) {
return on.deepCopy();
}
return OpenApiSignJson.mapper().valueToTree(requestBody);
}
private static byte[] writeBytes(JsonNode node) {
try {
return OpenApiSignJson.mapper().writeValueAsBytes(node);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Serialize OpenAPI JSON failed", e);
}
}
}