最近需要用到部署 SDK 支付服务网关服务器, 需要对不同第三方支付商的开发包有所支持;
一般第三方的支付商对 Java 方面支持最多, 可以节约时间省下从底层编写发起网络请求的代码.
本来打算直接采用 springboot 直接构建 web 接口即可, 但是现在 springboot 越来越臃肿且庞大,
有时候启动服务的时候也很缓慢, 所以打算采用别的框架处理这方面独立服务.
也就是在这个时间发现 Java 当中轻量级的框架: quarkus
甚至可以依靠
GraalVM | Mandrel将程序打包成原生可执行文件(依赖Java21+), 方便直接把可执行二进制丢到服务器运行
如果从0开始可以按照官方文档处理: 创建你的第一个应用程序
不过我习惯还是采用 IDEA 初始化构建, 并且因为要和 Android 客户端环境尽可能匹配而采用 gradle 管理(
build.gradle.kts ):
// 声明插件
plugins {
java
id("io.quarkus")
}
// 第三方库
repositories {
mavenCentral()
mavenLocal()
}
// 加载全局项目当中配置
val quarkusPlatformGroupId: String by project
val quarkusPlatformArtifactId: String by project
val quarkusPlatformVersion: String by project
// 第三方包
dependencies {
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
// Web 配置
implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-hibernate-validator") // 参数验证器
// ORM-JPA 配置, 如果没有立即使用数据库建议先屏蔽
//implementation("io.quarkus:quarkus-hibernate-orm")
//implementation("io.quarkus:quarkus-jdbc-mariadb")
// 核心基础包和单元测试
implementation("io.quarkus:quarkus-arc") // quarkus 依赖注入
implementation("io.quarkus:quarkus-config-yaml") // 支持 yaml 配置
//implementation("io.quarkus:quarkus-logging-json") // 支持日志转为 JSON, 提供给一些第三方支付SDK做数据采集
testImplementation("io.quarkus:quarkus-junit5")
testImplementation("io.rest-assured:rest-assured")
}
// 项目详情
group = "com.meteorcat.sdk"
version = "1.0-SNAPSHOT"
// java 编译配置
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// 单元测试配置
tasks.withType<Test> {
systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager")
}
// 打包配置
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
注意: 国内镜像源同步缓慢并不推荐配置第三方镜像, 或者在选用第三方镜像源的时候, 最好确认框架版本及时更新到官方的版本号
我这边目前采用是官方的 3.25.2 版本, 具体 gradle.properties 如下:
# Gradle properties
quarkusPluginId=io.quarkus
quarkusPluginVersion=3.25.2
quarkusPlatformGroupId=io.quarkus.platform
quarkusPlatformArtifactId=quarkus-bom
quarkusPlatformVersion=3.25.2
注意: 如果使用 gradle-wrapper.jar 要留意 distributionUrl 是否为官方版本匹配, 否则会出现依赖错误
让第三方包配置好之后就能启动应用, 我这边直接采用 IDEA 启动, 如果是命令行启动可以参照文档来配置,
最后访问默认的 http://localhost:8080 访问服务.
默认初始化 src/main/docker 文件内部就有具体测试代码可以学习查看.
初始化
聚合第三方支付基本上也就只需要实现以下接口就行:
order/create: 商户服务端创建订单order/query: 商户服务端查询订单order/notify: 第三方支付统一回调
这里面需要定义创建订单 Pojo 结构(个人习惯将请求数据设置为 XXXXForm 作为提交参数表单的结构):
package com.meteorcat.sdk.form;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.hibernate.validator.constraints.URL;
/**
* 创建订单表单
*
* @param merchant 商户标识
* @param identity 发起的第三方支付标识
* @param description 订单详情,用于展示在商户订单的购买信息
* @param referenceId 商户申请的时候创建的订单标识
* @param region 发起支付的地区代码: US|CN|HK 等
* @param currency 发起支付的货币代码: USD|CNY|HKD 等
* @param amount 发起支付的数额: 以地区最小单位定义, 如 1美元:100美分就取值 100, 有些特殊地区则是 1:1, 如日元需要1:1
* @param extension 商户提交的特殊支付标识, 可能是道具标识或者玩家标识等, 回调的时候会原样返回
* @param environments 有些商户需要特殊的发起请求环境扩展参数, 这里采用 JSON 的对象组保存提交
* @param redirectUrl 支付完成的重定向地址
* @param notifyUrl 支付完成的回调地址
*/
public record OrderCreateForm(
@NotBlank
@Size(min = 5, max = 64)
String merchant, // 商户的发起标识
@NotBlank
@Size(min = 5, max = 64)
String identity, // 第三方支付标识
@NotBlank
@Size(min = 1, max = 255)
String description, // 订单详情,用于展示在商户订单的购买信息
@NotBlank
@Size(min = 5, max = 64)
String referenceId, // 商户申请的订单
String region, // todo: 需要自定义支持 CN|US|JP 等 iso3166-11 国家代码
String currency, // todo: 需要自定义支持 CNY|USD|JPY 等货币代码
@NotNull
@Min(value = 1)
Long amount, // 支付的金额, 以地区最小单位定义, 如 1美元:100美分就取值 100, 有些特殊地区则是 1:1, 如日元需要1:1
@Size(max = 255)
String extension, // 商户提交的扩展参数, 回调的时候一起返回
String environments, // 客户端有时候需要提交客户端的UA等环境数据, 必须采用 {} 对象组
@URL
String redirectUrl, // 支付通道需要只玩等待的时候跳转地址
@URL
@NotBlank
String notifyUrl // 支付回调的商户地址
) {
}
这里需要注意的是 region 和 currency 需要自定义验证器, 这里提供个样例用于针对验证提交, 需要以下文件:
RegionCode: 地区码验证注解RegionCodeValidator: 地区码验证器CurrencyCode: 货币码验证注解CurrencyCodeValidator: 货币码验证器
RegionCode 注解声明:
package com.meteorcat.sdk.utils;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 验证 iso3166-11 国家代码
*/
@Documented
@Constraint(validatedBy = RegionCodeValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RegionCode {
/**
* 默认的异常返回错误
* 这里复用系统错误, 具体可以查看系统的 jar 内部默认的错误
* hibernate-validator-9.0.1.Final.jar!/org/hibernate/validator/ValidationMessages.properties
* 如果有系统定义多选一的选项, 推荐采用 org.hibernate.validator.constraints.Normalized.message 默认消息
*
* @return String
*/
String message() default "{org.hibernate.validator.constraints.Normalized.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
RegionCodeValidator 验证器功能类:
package com.meteorcat.sdk.utils;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.HashSet;
import java.util.Set;
/**
* 默认的地区验证器
*/
public class RegionCodeValidator implements ConstraintValidator<RegionCode, String> {
/**
* 允许的地区代码集合
*/
private static final Set<String> ALLOWED_REGION_CODES = new HashSet<>();
static {
// A字头
ALLOWED_REGION_CODES.add("AD"); // 安道尔
ALLOWED_REGION_CODES.add("AE"); // 阿拉伯联合酋长国
ALLOWED_REGION_CODES.add("AF"); // 阿富汗
ALLOWED_REGION_CODES.add("AG"); // 安提瓜和巴布达
ALLOWED_REGION_CODES.add("AI"); // 安圭拉
ALLOWED_REGION_CODES.add("AL"); // 阿尔巴尼亚
ALLOWED_REGION_CODES.add("AM"); // 亚美尼亚
ALLOWED_REGION_CODES.add("AO"); // 安哥拉
ALLOWED_REGION_CODES.add("AQ"); // 南极洲
ALLOWED_REGION_CODES.add("AR"); // 阿根廷
ALLOWED_REGION_CODES.add("AS"); // 美属萨摩亚
ALLOWED_REGION_CODES.add("AT"); // 奥地利
ALLOWED_REGION_CODES.add("AU"); // 澳大利亚
ALLOWED_REGION_CODES.add("AW"); // 阿鲁巴
ALLOWED_REGION_CODES.add("AX"); // 奥兰群岛
ALLOWED_REGION_CODES.add("AZ"); // 阿塞拜疆
// B字头
ALLOWED_REGION_CODES.add("BA"); // 波斯尼亚和黑塞哥维那
ALLOWED_REGION_CODES.add("BB"); // 巴巴多斯
ALLOWED_REGION_CODES.add("BD"); // 孟加拉国
ALLOWED_REGION_CODES.add("BE"); // 比利时
ALLOWED_REGION_CODES.add("BF"); // 布基纳法索
ALLOWED_REGION_CODES.add("BG"); // 保加利亚
ALLOWED_REGION_CODES.add("BH"); // 巴林
ALLOWED_REGION_CODES.add("BI"); // 布隆迪
ALLOWED_REGION_CODES.add("BJ"); // 贝宁
ALLOWED_REGION_CODES.add("BL"); // 圣巴泰勒米
ALLOWED_REGION_CODES.add("BM"); // 百慕大
ALLOWED_REGION_CODES.add("BN"); // 文莱
ALLOWED_REGION_CODES.add("BO"); // 玻利维亚
ALLOWED_REGION_CODES.add("BQ"); // 荷属加勒比区
ALLOWED_REGION_CODES.add("BR"); // 巴西
ALLOWED_REGION_CODES.add("BS"); // 巴哈马
ALLOWED_REGION_CODES.add("BT"); // 不丹
ALLOWED_REGION_CODES.add("BV"); // 布韦岛
ALLOWED_REGION_CODES.add("BW"); // 博茨瓦纳
ALLOWED_REGION_CODES.add("BY"); // 白俄罗斯
ALLOWED_REGION_CODES.add("BZ"); // 伯利兹
// C字头
ALLOWED_REGION_CODES.add("CA"); // 加拿大
ALLOWED_REGION_CODES.add("CC"); // 科科斯群岛
ALLOWED_REGION_CODES.add("CD"); // 刚果(金)
ALLOWED_REGION_CODES.add("CF"); // 中非共和国
ALLOWED_REGION_CODES.add("CG"); // 刚果(布)
ALLOWED_REGION_CODES.add("CH"); // 瑞士
ALLOWED_REGION_CODES.add("CI"); // 科特迪瓦
ALLOWED_REGION_CODES.add("CK"); // 库克群岛
ALLOWED_REGION_CODES.add("CL"); // 智利
ALLOWED_REGION_CODES.add("CM"); // 喀麦隆
ALLOWED_REGION_CODES.add("CN"); // 中国
ALLOWED_REGION_CODES.add("CO"); // 哥伦比亚
ALLOWED_REGION_CODES.add("CR"); // 哥斯达黎加
ALLOWED_REGION_CODES.add("CU"); // 古巴
ALLOWED_REGION_CODES.add("CV"); // 佛得角
ALLOWED_REGION_CODES.add("CW"); // 库拉索
ALLOWED_REGION_CODES.add("CX"); // 圣诞岛
ALLOWED_REGION_CODES.add("CY"); // 塞浦路斯
ALLOWED_REGION_CODES.add("CZ"); // 捷克共和国
// D字头
ALLOWED_REGION_CODES.add("DE"); // 德国
ALLOWED_REGION_CODES.add("DJ"); // 吉布提
ALLOWED_REGION_CODES.add("DK"); // 丹麦
ALLOWED_REGION_CODES.add("DM"); // 多米尼克
ALLOWED_REGION_CODES.add("DO"); // 多米尼加共和国
ALLOWED_REGION_CODES.add("DZ"); // 阿尔及利亚
// E字头
ALLOWED_REGION_CODES.add("EC"); // 厄瓜多尔
ALLOWED_REGION_CODES.add("EE"); // 爱沙尼亚
ALLOWED_REGION_CODES.add("EG"); // 埃及
ALLOWED_REGION_CODES.add("EH"); // 西撒哈拉
ALLOWED_REGION_CODES.add("ER"); // 厄立特里亚
ALLOWED_REGION_CODES.add("ES"); // 西班牙
ALLOWED_REGION_CODES.add("ET"); // 埃塞俄比亚
// F字头
ALLOWED_REGION_CODES.add("FI"); // 芬兰
ALLOWED_REGION_CODES.add("FJ"); // 斐济
ALLOWED_REGION_CODES.add("FK"); // 福克兰群岛
ALLOWED_REGION_CODES.add("FM"); // 密克罗尼西亚联邦
ALLOWED_REGION_CODES.add("FO"); // 法罗群岛
ALLOWED_REGION_CODES.add("FR"); // 法国
// G字头
ALLOWED_REGION_CODES.add("GA"); // 加蓬
ALLOWED_REGION_CODES.add("GB"); // 英国
ALLOWED_REGION_CODES.add("GD"); // 格林纳达
ALLOWED_REGION_CODES.add("GE"); // 格鲁吉亚
ALLOWED_REGION_CODES.add("GF"); // 法属圭亚那
ALLOWED_REGION_CODES.add("GG"); // 根西岛
ALLOWED_REGION_CODES.add("GH"); // 加纳
ALLOWED_REGION_CODES.add("GI"); // 直布罗陀
ALLOWED_REGION_CODES.add("GL"); // 格陵兰
ALLOWED_REGION_CODES.add("GM"); // 冈比亚
ALLOWED_REGION_CODES.add("GN"); // 几内亚
ALLOWED_REGION_CODES.add("GP"); // 瓜德罗普
ALLOWED_REGION_CODES.add("GQ"); // 赤道几内亚
ALLOWED_REGION_CODES.add("GR"); // 希腊
ALLOWED_REGION_CODES.add("GS"); // 南乔治亚和南桑威奇群岛
ALLOWED_REGION_CODES.add("GT"); // 危地马拉
ALLOWED_REGION_CODES.add("GU"); // 关岛
ALLOWED_REGION_CODES.add("GW"); // 几内亚比绍
ALLOWED_REGION_CODES.add("GY"); // 圭亚那
// H字头
ALLOWED_REGION_CODES.add("HK"); // 中国香港
ALLOWED_REGION_CODES.add("HM"); // 赫德岛和麦克唐纳群岛
ALLOWED_REGION_CODES.add("HN"); // 洪都拉斯
ALLOWED_REGION_CODES.add("HR"); // 克罗地亚
ALLOWED_REGION_CODES.add("HT"); // 海地
ALLOWED_REGION_CODES.add("HU"); // 匈牙利
// I字头
ALLOWED_REGION_CODES.add("ID"); // 印度尼西亚
ALLOWED_REGION_CODES.add("IE"); // 爱尔兰
ALLOWED_REGION_CODES.add("IL"); // 以色列
ALLOWED_REGION_CODES.add("IM"); // 马恩岛
ALLOWED_REGION_CODES.add("IN"); // 印度
ALLOWED_REGION_CODES.add("IO"); // 英属印度洋领地
ALLOWED_REGION_CODES.add("IQ"); // 伊拉克
ALLOWED_REGION_CODES.add("IR"); // 伊朗
ALLOWED_REGION_CODES.add("IS"); // 冰岛
ALLOWED_REGION_CODES.add("IT"); // 意大利
// J字头
ALLOWED_REGION_CODES.add("JE"); // 泽西岛
ALLOWED_REGION_CODES.add("JM"); // 牙买加
ALLOWED_REGION_CODES.add("JO"); // 约旦
ALLOWED_REGION_CODES.add("JP"); // 日本
// K字头
ALLOWED_REGION_CODES.add("KE"); // 肯尼亚
ALLOWED_REGION_CODES.add("KG"); // 吉尔吉斯斯坦
ALLOWED_REGION_CODES.add("KH"); // 柬埔寨
ALLOWED_REGION_CODES.add("KI"); // 基里巴斯
ALLOWED_REGION_CODES.add("KM"); // 科摩罗
ALLOWED_REGION_CODES.add("KN"); // 圣基茨和尼维斯
ALLOWED_REGION_CODES.add("KP"); // 朝鲜
ALLOWED_REGION_CODES.add("KR"); // 韩国
ALLOWED_REGION_CODES.add("KW"); // 科威特
ALLOWED_REGION_CODES.add("KY"); // 开曼群岛
ALLOWED_REGION_CODES.add("KZ"); // 哈萨克斯坦
// L字头
ALLOWED_REGION_CODES.add("LA"); // 老挝
ALLOWED_REGION_CODES.add("LB"); // 黎巴嫩
ALLOWED_REGION_CODES.add("LC"); // 圣卢西亚
ALLOWED_REGION_CODES.add("LI"); // 列支敦士登
ALLOWED_REGION_CODES.add("LK"); // 斯里兰卡
ALLOWED_REGION_CODES.add("LR"); // 利比里亚
ALLOWED_REGION_CODES.add("LS"); // 莱索托
ALLOWED_REGION_CODES.add("LT"); // 立陶宛
ALLOWED_REGION_CODES.add("LU"); // 卢森堡
ALLOWED_REGION_CODES.add("LV"); // 拉脱维亚
ALLOWED_REGION_CODES.add("LY"); // 利比亚
// M字头
ALLOWED_REGION_CODES.add("MA"); // 摩洛哥
ALLOWED_REGION_CODES.add("MC"); // 摩纳哥
ALLOWED_REGION_CODES.add("MD"); // 摩尔多瓦
ALLOWED_REGION_CODES.add("ME"); // 黑山
ALLOWED_REGION_CODES.add("MF"); // 法属圣马丁
ALLOWED_REGION_CODES.add("MG"); // 马达加斯加
ALLOWED_REGION_CODES.add("MH"); // 马绍尔群岛
ALLOWED_REGION_CODES.add("MK"); // 北马其顿
ALLOWED_REGION_CODES.add("ML"); // 马里
ALLOWED_REGION_CODES.add("MM"); // 缅甸
ALLOWED_REGION_CODES.add("MN"); // 蒙古
ALLOWED_REGION_CODES.add("MO"); // 中国澳门
ALLOWED_REGION_CODES.add("MP"); // 北马里亚纳群岛
ALLOWED_REGION_CODES.add("MQ"); // 马提尼克
ALLOWED_REGION_CODES.add("MR"); // 毛里塔尼亚
ALLOWED_REGION_CODES.add("MS"); // 蒙特塞拉特
ALLOWED_REGION_CODES.add("MT"); // 马耳他
ALLOWED_REGION_CODES.add("MU"); // 毛里求斯
ALLOWED_REGION_CODES.add("MV"); // 马尔代夫
ALLOWED_REGION_CODES.add("MW"); // 马拉维
ALLOWED_REGION_CODES.add("MX"); // 墨西哥
ALLOWED_REGION_CODES.add("MY"); // 马来西亚
ALLOWED_REGION_CODES.add("MZ"); // 莫桑比克
// N字头
ALLOWED_REGION_CODES.add("NA"); // 纳米比亚
ALLOWED_REGION_CODES.add("NC"); // 新喀里多尼亚
ALLOWED_REGION_CODES.add("NE"); // 尼日尔
ALLOWED_REGION_CODES.add("NF"); // 诺福克岛
ALLOWED_REGION_CODES.add("NG"); // 尼日利亚
ALLOWED_REGION_CODES.add("NI"); // 尼加拉瓜
ALLOWED_REGION_CODES.add("NL"); // 荷兰
ALLOWED_REGION_CODES.add("NO"); // 挪威
ALLOWED_REGION_CODES.add("NP"); // 尼泊尔
ALLOWED_REGION_CODES.add("NR"); // 瑙鲁
ALLOWED_REGION_CODES.add("NU"); // 纽埃
ALLOWED_REGION_CODES.add("NZ"); // 新西兰
// O字头
ALLOWED_REGION_CODES.add("OM"); // 阿曼
// P字头
ALLOWED_REGION_CODES.add("PA"); // 巴拿马
ALLOWED_REGION_CODES.add("PE"); // 秘鲁
ALLOWED_REGION_CODES.add("PF"); // 法属波利尼西亚
ALLOWED_REGION_CODES.add("PG"); // 巴布亚新几内亚
ALLOWED_REGION_CODES.add("PH"); // 菲律宾
ALLOWED_REGION_CODES.add("PK"); // 巴基斯坦
ALLOWED_REGION_CODES.add("PL"); // 波兰
ALLOWED_REGION_CODES.add("PM"); // 圣皮埃尔和密克隆
ALLOWED_REGION_CODES.add("PN"); // 皮特凯恩群岛
ALLOWED_REGION_CODES.add("PR"); // 波多黎各
ALLOWED_REGION_CODES.add("PS"); // 巴勒斯坦
ALLOWED_REGION_CODES.add("PT"); // 葡萄牙
ALLOWED_REGION_CODES.add("PW"); // 帕劳
ALLOWED_REGION_CODES.add("PY"); // 巴拉圭
// Q字头
ALLOWED_REGION_CODES.add("QA"); // 卡塔尔
// R字头
ALLOWED_REGION_CODES.add("RE"); // 留尼汪
ALLOWED_REGION_CODES.add("RO"); // 罗马尼亚
ALLOWED_REGION_CODES.add("RS"); // 塞尔维亚
ALLOWED_REGION_CODES.add("RU"); // 俄罗斯
ALLOWED_REGION_CODES.add("RW"); // 卢旺达
// S字头
ALLOWED_REGION_CODES.add("SA"); // 沙特阿拉伯
ALLOWED_REGION_CODES.add("SB"); // 所罗门群岛
ALLOWED_REGION_CODES.add("SC"); // 塞舌尔
ALLOWED_REGION_CODES.add("SD"); // 苏丹
ALLOWED_REGION_CODES.add("SE"); // 瑞典
ALLOWED_REGION_CODES.add("SG"); // 新加坡
ALLOWED_REGION_CODES.add("SH"); // 圣赫勒拿
ALLOWED_REGION_CODES.add("SI"); // 斯洛文尼亚
ALLOWED_REGION_CODES.add("SJ"); // 斯瓦尔巴群岛和扬马延岛
ALLOWED_REGION_CODES.add("SK"); // 斯洛伐克
ALLOWED_REGION_CODES.add("SL"); // 塞拉利昂
ALLOWED_REGION_CODES.add("SM"); // 圣马力诺
ALLOWED_REGION_CODES.add("SN"); // 塞内加尔
ALLOWED_REGION_CODES.add("SO"); // 索马里
ALLOWED_REGION_CODES.add("SR"); // 苏里南
ALLOWED_REGION_CODES.add("SS"); // 南苏丹
ALLOWED_REGION_CODES.add("ST"); // 圣多美和普林西比
ALLOWED_REGION_CODES.add("SV"); // 萨尔瓦多
ALLOWED_REGION_CODES.add("SX"); // 荷属圣马丁
ALLOWED_REGION_CODES.add("SY"); // 叙利亚
ALLOWED_REGION_CODES.add("SZ"); // 斯威士兰
// T字头
ALLOWED_REGION_CODES.add("TC"); // 特克斯和凯科斯群岛
ALLOWED_REGION_CODES.add("TD"); // 乍得
ALLOWED_REGION_CODES.add("TF"); // 法属南部领地
ALLOWED_REGION_CODES.add("TG"); // 多哥
ALLOWED_REGION_CODES.add("TH"); // 泰国
ALLOWED_REGION_CODES.add("TJ"); // 塔吉克斯坦
ALLOWED_REGION_CODES.add("TK"); // 托克劳
ALLOWED_REGION_CODES.add("TL"); // 东帝汶
ALLOWED_REGION_CODES.add("TM"); // 土库曼斯坦
ALLOWED_REGION_CODES.add("TN"); // 突尼斯
ALLOWED_REGION_CODES.add("TO"); // 汤加
ALLOWED_REGION_CODES.add("TR"); // 土耳其
ALLOWED_REGION_CODES.add("TT"); // 特立尼达和多巴哥
ALLOWED_REGION_CODES.add("TV"); // 图瓦卢
ALLOWED_REGION_CODES.add("TW"); // 中国台湾
ALLOWED_REGION_CODES.add("TZ"); // 坦桑尼亚
// U字头
ALLOWED_REGION_CODES.add("UA"); // 乌克兰
ALLOWED_REGION_CODES.add("UG"); // 乌干达
ALLOWED_REGION_CODES.add("UM"); // 美国本土外小岛屿
ALLOWED_REGION_CODES.add("US"); // 美国
ALLOWED_REGION_CODES.add("UY"); // 乌拉圭
ALLOWED_REGION_CODES.add("UZ"); // 乌兹别克斯坦
// V字头
ALLOWED_REGION_CODES.add("VA"); // 梵蒂冈
ALLOWED_REGION_CODES.add("VC"); // 圣文森特和格林纳丁斯
ALLOWED_REGION_CODES.add("VE"); // 委内瑞拉
ALLOWED_REGION_CODES.add("VG"); // 英属维尔京群岛
ALLOWED_REGION_CODES.add("VI"); // 美属维尔京群岛
ALLOWED_REGION_CODES.add("VN"); // 越南
ALLOWED_REGION_CODES.add("VU"); // 瓦努阿图
// W字头
ALLOWED_REGION_CODES.add("WF"); // 瓦利斯和富图纳
ALLOWED_REGION_CODES.add("WS"); // 萨摩亚
// Y字头
ALLOWED_REGION_CODES.add("YE"); // 也门
ALLOWED_REGION_CODES.add("YT"); // 马约特
// Z字头
ALLOWED_REGION_CODES.add("ZA"); // 南非
ALLOWED_REGION_CODES.add("ZM"); // 赞比亚
ALLOWED_REGION_CODES.add("ZW"); // 津巴布韦
}
/**
* 验证
*
* @param s 传入数据
* @param context 验证器上下文
* @return boolean
*/
@Override
public boolean isValid(String s, ConstraintValidatorContext context) {
// 填充需要显示的错误内容
String message = context.getDefaultConstraintMessageTemplate();
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(String.format("%s:region", message))
.addConstraintViolation();
if (s == null) return false; // 不能为null
if (s.isBlank()) return false; // 不能为空字符串
if (s.length() < 2) return false; // 必须最少两个字母
return ALLOWED_REGION_CODES.contains(s); // 必须是集合其中之一
}
}
CurrencyCode 注解声明:
package com.meteorcat.sdk.utils;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 验证 iso3166-11 货币代码
*/
@Documented
@Constraint(validatedBy = CurrencyCodeValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrencyCode {
/**
* 默认的异常返回错误
* 这里复用系统错误, 具体可以查看系统的 jar 内部默认的错误
* hibernate-validator-9.0.1.Final.jar!/org/hibernate/validator/ValidationMessages.properties
* 这里采用取巧异常重写, 等待填充内容 org.hibernate.validator.constraints.UniqueElements.message
*
* @return String
*/
String message() default "{org.hibernate.validator.constraints.UniqueElements.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
CurrencyCodeValidator 验证器功能类:
package com.meteorcat.sdk.utils;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.Currency;
import java.util.HashSet;
import java.util.Set;
/**
* 默认的货币验证器
*/
public class CurrencyCodeValidator implements ConstraintValidator<CurrencyCode, String> {
/**
* 允许的 ISO 4217 货币代码集合(3 位字母代码)
*/
private static final Set<String> ALLOWED_CURRENCY_CODES = new HashSet<>();
static {
for (Currency currency : Currency.getAvailableCurrencies()) {
ALLOWED_CURRENCY_CODES.add(currency.getCurrencyCode());
}
}
/**
* 验证
*
* @param s 传入数据
* @param context 验证器上下文
* @return boolean
*/
@Override
public boolean isValid(String s, ConstraintValidatorContext context) {
// 填充需要显示的错误内容
String message = context.getDefaultConstraintMessageTemplate();
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(String.format("%s:currency", message))
.addConstraintViolation();
if (s == null) return false; // 不能为null
if (s.isBlank()) return false; // 不能为空字符串
if (s.length() < 3) return false; // 必须最少三个字母
return ALLOWED_CURRENCY_CODES.contains(s); // 必须是集合其中之一
}
}
最后就是实现下注解就行:
public record OrderCreateForm(
// 其他略
@RegionCode
String region, // 需要自定义支持 CN|US|JP 等 iso3166-11 国家代码
@CurrencyCode
String currency // 需要自定义支持 CNY|USD|JPY 等货币代码
) {
}
之后就是等待测试调用, 确认是否生效.
REST接口
测试创建接口控制器文件:
package com.meteorcat.sdk.controllers.order;
import com.meteorcat.sdk.form.OrderCreateForm;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
/**
* 创建订单
*/
@Path("/order/create")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderCreateController {
private static final Logger log = LoggerFactory.getLogger(OrderCreateController.class);
/**
* POST请求, 这里没有定义全局异常
*/
@POST
public Response create(@Valid OrderCreateForm form) {
if (form == null) return Response.status(Response.Status.BAD_REQUEST).build();
log.debug(form.toString());
return Response.ok(Map.of(
"message", form.toString()
)).build();
}
}
注意: 这里提交的表单参数必须采用 Content-Type=application/json;charset=UTF-8 的 JSON 原生数据提交.
这样基本上就能形成简单订单系统, 发起类似的 JSON 结构:
{
// 发起商户号标识
"merchant": "MeteorCat",
// 发起的第三方支付渠道
"identity": "ALI_HOSTED_PAY",
// 发起的商户订单ID
"referenceId": "20250810023720",
// 发起的商户订单内容
"description": "测试订单",
// 发起支付的数额: 以地区最小单位定义, 如 1美元:100美分就取值 100, 有些特殊地区则是 1:1, 如日元需要1:1
"amount": 100,
// 发起支付的地区代码: US|CN|HK 等
"region": "US",
// 发起支付的货币代码: USD|CNY|HKD 等
"currency": "USD",
// 支付完成的商户回调地址
"notifyUrl": "https://www.self.com?key=123"
}
这就是简单自己开发的第三方聚合支付接口功能, 但是目前来看还需要进一步做点安全处理, 那就是 参数签名.
参数签名
参数签名是为了防止被中间人监听拦截请求并修改参数之后伪造成客户端提交请求的措施, 以此比较常用的方法有以下方式:
- 将提交
KEY-VALUE参数, 以KEY正序排列 - 按照
K1=V1&K2=V2方式组合成字符串 - 添加通过参数字符串附加
APPKEY去MD5得出SIGN - 在提交参数最后添加
SIGN在表单或者Header提交当作此次的参数签名
这种就是行业当中比较常见的参数签名方式, 保证提交过程当中的参数被拦截也无法被修改.
但是 JAVA 每次 pojo 对象变动参数都要修改挺麻烦的, 所以需要考虑能够把提交参数结构自动转为 Map<String,String> 对象.
这里先编写个组件 ParamSignUtils 方便处理参数签名功能:
package com.meteorcat.sdk.utils;
import jakarta.annotation.Nonnull;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 参数签名工具
*/
public final class ParamSignUtils {
private ParamSignUtils() {
// ignore
}
/**
* 参数签名
* <p>
* 这里考虑到要和系统库一致所以直接采用异常弹出, 让上层去自己接受异常处理而不会返回 Sting|null 这种可能引发特殊二义性的问题
*
* @param params 签名哈希表
* @param key 签名附加的KEY
* @return String
* @throws NoSuchAlgorithmException 哈希错误异常
*/
public static @Nonnull String sign(Map<String, String> params, String key) throws NoSuchAlgorithmException {
// 首选需要采用有序 Map 处理, 默认按照 KEY 正序, 最后合并成参数数组
List<String> sortedResults = getSignParams(params);
String result = key == null ?
String.join("&", sortedResults) :
String.format("%s%s", String.join("&", sortedResults), key);
// 利用 java.security.MessageDigest 自带 MD5 工具处理生成
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hashBytes = md.digest(result.getBytes(StandardCharsets.UTF_8));
// 16进制转化
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0'); // 补0,确保两位16进制
}
hexString.append(hex);
}
return hexString.toString();
}
/**
* 过滤所需的参数对象
*
* @param params 参数列表
* @return List
*/
private static List<String> getSignParams(Map<String, String> params) {
// 以Key正序排列并生成 K1=V1 格式列表
Map<String, String> sortedParams = new TreeMap<>(params);
List<String> sortedResults = new ArrayList<>(params.size());
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
// 排除掉 null, null 是无意义的合并取值
String entryKey = entry.getKey();
String entryValue = entry.getValue();
if (entryKey != null && entryValue != null) {
sortedResults.add(entry.getKey() + "=" + entry.getValue());
}
}
return sortedResults;
}
}
这里直接调用就行了, 其实也没什么技术含量:
// 最后得出的哈希结果 750b02dc3319904fc94eebccb4d869a1
String sign = ParamSignUtils.sign(Map.of(
"appid", "10000",
"username", "meteorcat",
"password", "meteorcat"
), "testKey");
现在就是我们之前提到过对于用户提交 FORM|JSON 数据结构做自动化参数签名,
这里可以采用 ObjectMapper 特性将对象转为 JSON 对象然后再转回 MAP 对象:
package com.meteorcat.sdk.utils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 参数哈希组件
*/
public interface ParamSignComponent {
/**
* POJO解析器, interface 默认规则默认成员是 public static final 类型
*/
ObjectMapper objectMapper = new ObjectMapper();
/**
* 参数签名
*
* @param key 参与哈希的KEY
* @param ignores 忽略的KEY
* @return String
* @throws IllegalArgumentException 转换参数异常
* @throws NoSuchAlgorithmException 哈希处理异常
*/
default String sign(String key, List<String> ignores) throws IllegalArgumentException, NoSuchAlgorithmException {
// 转为参数对象
final Map<String, Object> params = objectMapper.convertValue(
this, new TypeReference<>() {
});
// 需要排除特定的字段
Map<String, String> newParams = new HashMap<>(params.size());
for (Map.Entry<String, Object> entry : params.entrySet()) {
String entryKey = entry.getKey();
Object entryValue = entry.getValue();
// 需要排除 value=null 那些字段
if (!ignores.contains(entryKey) && entryValue != null) {
newParams.put(entryKey, entryValue.toString());
}
}
return ParamSignUtils.sign(newParams, key);
}
}
现在重新改造 OrderCreateForm 对象, 让其能够现在会自动赋予签名函数:
public record OrderCreateForm(
// 其他略
) implements ParamSignComponent { // 引入参数签名接口
}
现在对之前提出的 请求对象 打印一下就能查看到最后参数签名:
package com.meteorcat.sdk.controllers.order;
import com.meteorcat.sdk.form.OrderCreateForm;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
/**
* 创建订单
*/
@Path("/order/create")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderCreateController {
private static final Logger log = LoggerFactory.getLogger(OrderCreateController.class);
/**
* POST请求, 这里没有定义全局异常
*/
@POST
public Response create(@Valid OrderCreateForm form) throws NoSuchAlgorithmException {
if (form == null) return Response.status(Response.Status.BAD_REQUEST).build();
log.debug(form.toString());
// 签名验证, 排除掉 sign 字段
log.info("sign: {}", form.sign("test", List.of("sign")));
return Response.ok(Map.of(
"message", form.toString()
)).build();
}
}
最后得出的参数签名和哈希值:
# 请求参数:
amount=100¤cy=USD&description=测试订单&identity=ALI_HOSTED_PAY&merchant=MeteorCat¬ifyUrl=https://www.self.com?key=123&referenceId=20250810023720®ion=UStest
# 签名哈希:
6d044be3cd90c0d28be2273e81cc39b4
需要注意有些转发采用
GET传输会导致参数被URLEncode导致内容可能变动, 最终签名校验不过关
注意: 这里面参数一致哈希起来会导致哈希值一直不变, 所以在内部字段应该加上 time(时间戳) 或者 noise(随机值).
订单号设计
这里提供下我之前常用的订单好生成函数, 主要生成的是否碰撞少且不太依赖尽量少依赖太多第三方保持简洁:
package com.meteorcat.sdk.utils;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Random;
/**
* 唯一数据标识生成
*/
public final class UniqueCodeUtils {
private UniqueCodeUtils() {
// ignore
}
/**
* UTC格式化时间 YmdHis 格式
*/
private static final DateTimeFormatter UTC_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
.withZone(ZoneOffset.UTC);
/**
* 生成和时间相关的订单标识, 尽可能少减少碰撞概率
* 虽然长度是36位, 但是推荐采用 varchar(64) 保存
*
* @return String
*/
public static String order() {
StringBuilder builder = new StringBuilder(); // 字符串缓存
Instant now = Instant.now();
// 格式化为 YmdHis 格式(UTC 时区)
builder.append(UTC_FORMATTER.format(now));
// 获取微秒段
int microseconds = now.getNano() / 1000;
String microFormated = String.format("%06d", microseconds); // 格式式化为 6 位字符串(补零)
builder.append(microFormated, 0, 4); // 只需要4位
// 其他随即位填充 - offset:8
Random random = new Random();
int pos1 = random.nextInt(9999) + 1;
builder.append(String.format("%04d", pos1)); // 4位补零
// pos2
int pos2 = random.nextInt(9999) + 1;
builder.append(String.format("%04d", pos2));
// pos3
int pos3 = random.nextInt(9999) + 1;
builder.append(String.format("%04d", pos3));
// pos4
int pos4 = random.nextInt(9999) + 1;
builder.append(String.format("%04d", pos4));
// pos5
int pos5 = random.nextInt(99) + 1;
builder.append(String.format("%02d", pos5));
// Pos5位的随机值其实很难被相同碰撞, 特别是还附加上毫秒时间戳
return builder.toString();
}
}
这个函数在正式环境已经上线测试很久基本上很少出错且碰撞极少, 后续订单生成频率快可以提高随机位的填充; 并且需要将订单ID在数据库设置为唯一键或者主键, 从而在数据库层面也保证订单号绝对唯一.
JPA-ORM
对于数据结构单一基本上用 JPA-ORM 就能应付大量日常会用的数据库日常保存功能, 直接添加依赖即可:
// ORM-JPA 配置, 如果没有立即使用数据库建议先屏蔽
implementation("io.quarkus:quarkus-hibernate-orm") // JPA
implementation("io.quarkus:quarkus-jdbc-mariadb") // 客户端驱动
我个人习惯采用 MySQL 的分支版本 MariaDB
首先定义表结构:
# 基础的支付信息, 支持跨境支付
create table if not exists order_info
(
# 订单信息, 用类似的 sprintf("%04d",mt_rand(1,9999)) 就可以生成填充值
order_id varchar(64) not null comment '唯一订单标识',
order_reference_id varchar(255) not null comment '支付方传递过来的订单唯一标识, 也就是发起方的订单ID, 需要注意判断时间段 order_status = 0 的订单可能是处于正在支付中',
# 商户特殊信息
order_description varchar(255) not null default '' comment '订单支付具体信息',
order_status tinyint unsigned not null default '0' comment '订单状态, 0 = 预下单, 1 = 支付成功等待回调, 2 = 支付成功且回调完成, status > 100 为错误',
# 传递的支付标识信息
pay_merchant varchar(64) not null comment '自定义支付的商户, 如 meteorcat_pay 等自定义渠道商户',
pay_identity varchar(64) not null comment '自定义支付的渠道, 如ALI_HOSTED_PAY|WX_HOSTED_PAY',
# 订单支付信息
order_region varchar(4) not null comment '订单发起支付的地区, 如 CN|US|HK 等',
order_currency varchar(4) not null comment '订单发起支付的货币, 如 CNY|USD|HKD 等',
order_unit tinyint unsigned not null default '2' comment '支付订单最小单位, 也就是金额小数点后几位',
order_amount bigint unsigned not null comment '订单结算金额, 取支付货币最小单位值',
# 结算支付信息, 也就是最后第三方结算返回的真实金额
settlement_region varchar(4) not null default '' comment '订单结算支付的地区, 如 CN|US 等',
settlement_currency varchar(4) not null default '' comment '订单结算支付的货币, 如 CNY|USD 等',
settlement_unit tinyint unsigned not null default '0' comment '支付订单最小单位, 也就是金额小数点后几位',
settlement_amount bigint unsigned not null default '0' comment '订单结算支付的金额, 取支付货币最小单位值',
settlement_time bigint unsigned not null default '0' comment '订单结算完成的时间, 毫秒级别的UTC时间戳',
# 发起的第三方原生身份证所需信息, 有的反洗钱和欺诈会需要传入身份证和卡号信息
metadata json not null default JSON_OBJECT() comment '发起的第三方原生身份证所需信息, 有的反洗钱和欺诈会需要传入身份证和卡号信息(对象组形式)',
# 支付回调信息
redirect_url varchar(255) not null default '' comment '支付完成跳转地址',
notify_url varchar(255) not null default '' comment '通知地址',
notify_time bigint unsigned not null default '0' comment '回调通知成功的时间戳, 毫秒级别的UTC时间戳',
# 创建和更新, 超时时间
create_time bigint unsigned not null comment '订单创建时间, 毫秒级别的UTC时间戳',
create_ip varchar(64) not null comment '订单创建IP地址',
update_time bigint unsigned not null default '0' comment '订单数据更新时间, 毫秒级别的UTC时间戳',
update_ip varchar(64) not null default '' comment '订单更新IP地址',
expiry_time bigint unsigned not null comment '0' comment '订单超时时间戳, 毫秒级别的UTC时间戳',
# 客户端相关信息
extension varchar(255) not null default '' comment '支付方传递过来的扩展参数, 回调的时候会原样返回',
environments json not null default JSON_OBJECT() comment '支付设备的信息(对象组形式), 回调时会原样返回, 可能是UA等数据',
# 本次发起支付的收集扩展信息, 可以放置回调过来的第三方数据
extra json not null default JSON_OBJECT() comment '第三方支付的返回扩展信息JSON(对象组形式)',
# 商户传递过来的订单必须和商户号标识合并成唯一标识, 避免掉在等待回调的时刻商户再次发送发送相同订单ID
unique key order_reference_id (pay_merchant, order_reference_id),
primary key (order_id)
) comment '支付订单信息表'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci;
这里直接声明 JPA 的 Entity 和 Repository 来做 ORM 映射:
package com.meteorcat.sdk.models.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Objects;
@Entity(name = "order_info")
public record OrderInfoEntity(
// -----------------------------
// 基础信息
// -----------------------------
@Id
@Column(name = "order_id", nullable = false, length = 64)
@JdbcTypeCode(SqlTypes.VARCHAR)
String orderId,
@Column(name = "order_reference_id", nullable = false)
@JdbcTypeCode(SqlTypes.VARCHAR)
String orderReferenceId,
@Column(name = "order_description", nullable = false)
@JdbcTypeCode(SqlTypes.VARCHAR)
String orderDescription,
@Column(name = "order_status", nullable = false)
@JdbcTypeCode(SqlTypes.TINYINT)
Short orderStatus,
// -----------------------------
// 支付发起信息
// -----------------------------
@Column(name = "order_region", nullable = false, length = 4)
@JdbcTypeCode(SqlTypes.VARCHAR)
String orderRegion,
@Column(name = "order_currency", nullable = false, length = 4)
@JdbcTypeCode(SqlTypes.VARCHAR)
String orderCurrency,
@Column(name = "order_unit", nullable = false)
@JdbcTypeCode(SqlTypes.TINYINT)
Short orderUnit,
@Column(name = "order_amount", nullable = false)
@JdbcTypeCode(SqlTypes.BIGINT)
Long orderAmount,
// -----------------------------
// 支付通道信息
// -----------------------------
@Column(name = "pay_merchant", nullable = false, length = 64)
@JdbcTypeCode(SqlTypes.VARCHAR)
String payMerchant,
@Column(name = "pay_identity", nullable = false, length = 64)
@JdbcTypeCode(SqlTypes.VARCHAR)
String payIdentity,
// -----------------------------
// 结算信息
// -----------------------------
@Column(name = "settlement_region", nullable = false, length = 4)
@JdbcTypeCode(SqlTypes.VARCHAR)
String settlementRegion,
@Column(name = "settlement_currency", nullable = false, length = 4)
@JdbcTypeCode(SqlTypes.VARCHAR)
String settlementCurrency,
@Column(name = "settlement_unit", nullable = false)
@JdbcTypeCode(SqlTypes.TINYINT)
Short settlementUnit,
@Column(name = "settlement_amount", nullable = false)
@JdbcTypeCode(SqlTypes.BIGINT)
Long settlementAmount,
@Column(name = "settlement_time", nullable = false)
@JdbcTypeCode(SqlTypes.BIGINT)
Long settlementTime,
// -----------------------------
// 第三方所需的信息
// -----------------------------
@Column(name = "metadata", nullable = false)
@JdbcTypeCode(SqlTypes.JSON)
String metadata,
// -----------------------------
// 回调商户的信息
// -----------------------------
@Column(name = "redirect_url", nullable = false)
@JdbcTypeCode(SqlTypes.VARCHAR)
String redirectUrl,
@Column(name = "notify_url", nullable = false)
@JdbcTypeCode(SqlTypes.VARCHAR)
String notifyUrl,
@Column(name = "notify_time", nullable = false)
@JdbcTypeCode(SqlTypes.BIGINT)
Long notifyTime,
@Column(name = "extension", nullable = false)
@JdbcTypeCode(SqlTypes.VARCHAR)
String extension,
@Column(name = "environments", nullable = false)
@JdbcTypeCode(SqlTypes.JSON)
String environments,
// -----------------------------
// 创建和更新的信息
// -----------------------------
@Column(name = "create_time", nullable = false)
@JdbcTypeCode(SqlTypes.BIGINT)
Long createTime,
@Column(name = "create_ip", nullable = false, length = 64)
@JdbcTypeCode(SqlTypes.VARCHAR)
String createIp,
@Column(name = "update_time", nullable = false)
@JdbcTypeCode(SqlTypes.BIGINT)
Long updateTime,
@Column(name = "update_ip", nullable = false, length = 64)
@JdbcTypeCode(SqlTypes.VARCHAR)
String updateIp,
@Column(name = "expiry_time", nullable = false)
@JdbcTypeCode(SqlTypes.BIGINT)
Long expiryTime,
@Column(name = "extra", nullable = false)
@JdbcTypeCode(SqlTypes.JSON)
String extra
) {
/**
* 格式化
*
* @param orderId 默认订单ID
* @param orderReferenceId 支付订单ID
* @param orderDescription 订单内容
* @param orderStatus 订单状态
* @param orderRegion 订单发起区域
* @param orderCurrency 订单支付货币
* @param orderUnit 订单最小单位
* @param orderAmount 订单支付金额
* @param payMerchant 订单商户标识
* @param payIdentity 订单支付通道
* @param settlementRegion 订单结算区域
* @param settlementCurrency 订单结算货币
* @param settlementUnit 订单最小单位
* @param settlementAmount 订单结算金额
* @param settlementTime 订单结算时间
* @param metadata 第三方所需的反诈附加信息, 是 JSON 对象组
* @param redirectUrl 订单支付等待回调的跳转地址
* @param notifyUrl 订单支付的回调地址
* @param notifyTime 订单支付的回调时间
* @param extension 商户发起的传递扩展参数
* @param environments 商户提交的UA等收集信息, 是 JSON 对象组
* @param createTime 订单创建时间
* @param createIp 订单创建IP
* @param updateTime 订单更新时间
* @param updateIp 订单更新IP
* @param expiryTime 订单超时时间
* @param extra 订单回调的信息, 是 JSON 对象组
*/
public OrderInfoEntity {
Instant now = Instant.now();
orderDescription = Objects.requireNonNullElse(orderDescription, "");
orderStatus = Objects.requireNonNullElse(orderStatus, Short.valueOf("0"));
settlementRegion = Objects.requireNonNullElse(settlementRegion, "");
settlementCurrency = Objects.requireNonNullElse(settlementCurrency, "");
settlementUnit = Objects.requireNonNullElse(settlementUnit, Short.valueOf("0"));
settlementAmount = Objects.requireNonNullElse(settlementAmount, 0L);
settlementTime = Objects.requireNonNullElse(settlementTime, 0L);
metadata = Objects.requireNonNullElse(metadata, "{}");
redirectUrl = Objects.requireNonNullElse(redirectUrl, "");
notifyUrl = Objects.requireNonNullElse(notifyUrl, "");
extension = Objects.requireNonNullElse(extension, "");
environments = Objects.requireNonNullElse(environments, "{}");
createTime = Objects.requireNonNullElse(createTime, now.toEpochMilli());
createIp = Objects.requireNonNullElse(createIp, "");
updateTime = Objects.requireNonNullElse(updateTime, 0L);
updateIp = Objects.requireNonNullElse(updateIp, "");
expiryTime = Objects.requireNonNullElse(expiryTime, 0L);
extra = Objects.requireNonNullElse(extra, "{}");
}
/**
* 简单初始化
*/
public static OrderInfoEntity build(
String orderId,
String orderReferenceId,
String orderDescription,
String orderRegion,
String orderCurrency,
Short orderUnit,
Long orderAmount,
String payMerchant,
String payIdentity,
String redirectUrl,
String notifyUrl,
String extension,
String environments,
String ipAddress
) {
Instant now = Instant.now();
return new OrderInfoEntity(
orderId,
orderReferenceId,
orderDescription,
Short.valueOf("0"),
orderRegion,
orderCurrency,
orderUnit,
orderAmount,
payMerchant,
payIdentity,
"",
"",
Short.valueOf("0"),
0L,
0L,
"{}",
redirectUrl,
notifyUrl,
0L,
extension,
environments,
now.toEpochMilli(),
ipAddress,
0L,
"",
0L,
"{}"
);
}
}
这里节约下时间直接采用 EntityManager 写入测试:
/**
* 创建订单
*/
@Path("/order/create")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderCreateController {
/**
* 注入简单管理器
*/
@Inject
EntityManager entityManager;
/**
* POST请求, 这里没有定义全局异常
*/
@POST
@Transactional
public Response create(
@Valid OrderCreateForm form,
@Context RoutingContext request
) throws NoSuchAlgorithmException {
if (form == null) return Response.status(Response.Status.BAD_REQUEST).build();
// 创建订单实体准备入库
OrderInfoEntity entity = OrderInfoEntity.build(
UniqueCodeUtils.order(),
form.referenceId(),
form.description(),
form.region(),
form.currency(),
Short.valueOf("2"),
form.amount(),
form.merchant(),
form.identity(),
form.redirectUrl(),
form.notifyUrl(),
form.extension(),
form.environments(),
request.request().remoteAddress().host()
);
log.info("entity:{}", entity);
entityManager.persist(entity);
return Response.ok(Map.of(
"message", form.toString()
)).build();
}
}
这里就是个简单的第三方订单系统了, 后续就是需要按照 merchant 和 identity 来正确触发第三方支付通道和配置.