Java SDK
Java SDK 是单文件 AnonyV2Client.java,全部能力以静态方法提供,直接拷贝进你的项目即可使用,无需引入任何第三方依赖。
它的职责边界很清晰:只处理密码学与签名——RSA-OAEP 加密/解密、SHA256withRSA 签名/验签、canonical 串拼接、nonce/时间戳等防重放要素、上行请求头构造。JSON 序列化与 HTTP 发送由你自行选择库完成(Jackson / Gson、java.net.http.HttpClient / OkHttp 等均可)。
协议本身(加密、签名、防重放的完整规范)见 协议说明;对接所需的 4 个凭据(APP-ID、secret、平台公钥 public_t、商户私钥 private_u)见 密钥与凭据。
环境要求
| 项 | 要求 |
|---|---|
| JDK | JDK 8+。SDK 仅使用 JDK 标准库:javax.crypto.Cipher、java.security.*(KeyFactory / MessageDigest / Signature / SecureRandom)、java.util.Base64 等,其中 java.util.Base64 自 JDK 8 引入 |
| 第三方依赖 | 无。SDK 本体零依赖 |
| JSON 库 | 自选(本页示例用 Jackson,可换 Gson 等) |
| HTTP 客户端 | 自选(本页示例给出 JDK 11+ 的 java.net.http.HttpClient 与 JDK 8 的 HttpURLConnection 两种写法) |
安装:把 AnonyV2Client.java 复制到你的源码目录(按需加上你自己的 package 声明)即可,不需要 Maven/Gradle 坐标。
密钥格式为 base64(PEM):对整段 PEM 文本再做一次 Base64。SDK 加载时会自动解码还原——私钥须为 PKCS#8(BEGIN PRIVATE KEY),公钥为 SPKI(BEGIN PUBLIC KEY),RSA-2048。
方法一览
常量
| 常量 | 值 | 含义 |
|---|---|---|
OAEP_MAX_PLAIN | 214 | OAEP 加密单段明文上限(字节) |
RSA_BLOCK | 256 | 解密时密文分段大小(字节) |
TS_WINDOW | 300 | 时间戳允许偏差(秒,即 ±5 分钟) |
静态方法
| 方法 | 说明 |
|---|---|
PublicKey loadPublic(String pubB64) | 加载平台公钥(base64(PEM)) |
PrivateKey loadPrivate(String privB64) | 加载商户私钥(base64(PEM)) |
String encrypt(byte[] plaintext, String serverPublicKeyB64) | 用平台公钥按 214 字节分段做 RSA-OAEP(SHA-1 + MGF1-SHA1)加密,返回 Base64 密文(即 HTTP body) |
byte[] decrypt(String cipherB64, String merchantPrivateKeyB64) | 用商户私钥解密:Base64 解码后按 256 字节分段逐段解密并拼接 |
String sign(String canonical, String merchantPrivateKeyB64) | 用商户私钥对 canonical 串做 SHA256withRSA 签名,返回 Base64 |
boolean verify(String canonical, String serverPublicKeyB64, String signB64) | 用平台公钥验证下行(响应/回调)签名 |
String canonical(String ts, String nonce, String payload) | 拼 canonical 串:ts + "\n" + nonce + "\n" + payload(LF 连接,逐字节对齐) |
String newNonce() | 生成 16 字节 CSPRNG 随机数,返回 32 位 hex 字符串 |
String authHeader(String appId, String secret) | 计算 getToken 的 Authorization 头值:"auth " + md5(APP-ID + "&" + secret) |
boolean verifyTimestamp(String ts) | 校验时间戳与本机时间偏差 ≤ 300 秒 |
Map<String,String> buildHeaders(String appId, String token, String encryptedBody, String merchantPrivateKeyB64) | 构造上行 5 个请求头:内部生成秒级时间戳与 nonce,并对 canonical 串签名,返回 ANONY-APP-ID / ANONY-TOKEN / ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN |
密钥角色请务必对齐(用反了必然失败):
| 方向 | 加密/解密 | 签名/验签 |
|---|---|---|
| 上行请求 | 平台公钥 public_t 加密 | 商户私钥 private_u 签名 |
| 下行响应/回调 | 商户私钥 private_u 解密 | 平台公钥 public_t 验签 |
获取 token(getToken)
Java SDK 不内置 HTTP 调用,取 token 分四步:
- 用
authHeader(appId, secret)计算Authorization头(auth+md5(APP-ID + "&" + secret),注意auth后有一个空格)。 - 用任意 HTTP 库
POST {baseUrl}getToken,带ANONY-APP-ID与Authorization两个头。请求体不加密、不签名,可为空。 - 响应不是明文 token,而是与其它接口一样的加密+签名信封:外层 JSON
{code, data, message},响应头带ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN。 verifyTimestamp校验时间窗 →verify(canonical(ts, nonce, data), 平台公钥, sign)验签 →decrypt(data, 商户私钥)解密,从解密后的 JSON 中取token字段。
接口字段详情见 getToken 接口文档。
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
String baseUrl = "https://api.anonypay.io/api/merchant/";
String appId = "<你的商户号>";
String secret = "<你的 secret>";
String serverPubB64 = "<平台公钥 public_t(base64(PEM))>";
String merchantPrivB64 = "<商户私钥 private_u(base64(PEM))>";
// ① 计算 Authorization 头
String auth = AnonyV2Client.authHeader(appId, secret);
// ② POST getToken(体为空,不加密不签名)
HttpClient http = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "getToken"))
.header("ANONY-APP-ID", appId)
.header("Authorization", auth)
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
// ③ 用标准 JSON 库解析外层信封 {code, data, message}
ObjectMapper om = new ObjectMapper();
JsonNode envelope = om.readTree(resp.body());
if (envelope.get("code").asInt() != 10000) {
throw new IllegalStateException("getToken 失败: " + envelope.get("message").asText());
}
String data = envelope.get("data").asText();
String ts = resp.headers().firstValue("ANONY-TIMESTAMP").orElseThrow();
String nonce = resp.headers().firstValue("ANONY-NONCE").orElseThrow();
String sig = resp.headers().firstValue("ANONY-SIGN").orElseThrow();
// ④ 验时间窗 → 验签(平台公钥)→ 解密(商户私钥)→ 取 token
if (!AnonyV2Client.verifyTimestamp(ts)) throw new SecurityException("响应时间戳超窗");
if (!AnonyV2Client.verify(AnonyV2Client.canonical(ts, nonce, data), serverPubB64, sig)) {
throw new SecurityException("响应验签失败");
}
byte[] plain = AnonyV2Client.decrypt(data, merchantPrivB64);
String token = om.readTree(plain).get("token").asText();import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
String baseUrl = "https://api.anonypay.io/api/merchant/";
String auth = AnonyV2Client.authHeader(appId, secret);
// ② POST getToken(体为空,不加密不签名)
HttpURLConnection conn = (HttpURLConnection) new URL(baseUrl + "getToken").openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("ANONY-APP-ID", appId);
conn.setRequestProperty("Authorization", auth);
conn.getOutputStream().close(); // 空 body
String body;
try (InputStream in = conn.getInputStream();
ByteArrayOutputStream buf = new ByteArrayOutputStream()) {
byte[] tmp = new byte[4096];
int n;
while ((n = in.read(tmp)) > 0) buf.write(tmp, 0, n);
body = buf.toString("UTF-8");
}
String ts = conn.getHeaderField("ANONY-TIMESTAMP");
String nonce = conn.getHeaderField("ANONY-NONCE");
String sig = conn.getHeaderField("ANONY-SIGN");
// ③④ 之后的信封处理(解析 JSON → verifyTimestamp → verify → decrypt → 取 token)
// 与左侧 HttpClient 版本完全一致实现细节:token 有效期
token 采用滑动过期 8 小时、绝对上限 24 小时的策略。收到 tokenExpired 错误时重新调用 getToken 即可,无需固定周期刷新。
发起业务请求
业务接口的固定套路:业务 JSON → encrypt 加密 → buildHeaders 生成 5 个头 → POST 密文 → 响应验签 + 解密。以 createAddress 为例:
// ① 业务参数序列化为 JSON 字节(JSON 库自选,这里用 Jackson)
Map<String, Object> params = new LinkedHashMap<>();
params.put("userOrder", "A1001");
params.put("addressType", "trx");
byte[] plaintext = om.writeValueAsBytes(params);
// ② 用平台公钥 OAEP 加密 → Base64 密文,作为 HTTP body
String encryptedBody = AnonyV2Client.encrypt(plaintext, serverPubB64);
// ③ 构造 5 个请求头(内部生成 ts/nonce,并用商户私钥对 canonical 串签名)
Map<String, String> headers =
AnonyV2Client.buildHeaders(appId, token, encryptedBody, merchantPrivB64);
// ④ POST 密文
HttpRequest.Builder rb = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "createAddress"))
.POST(HttpRequest.BodyPublishers.ofString(encryptedBody));
headers.forEach(rb::header);
HttpResponse<String> r = http.send(rb.build(), HttpResponse.BodyHandlers.ofString());
// ⑤ 处理响应信封:解析 → 验时间窗 → 验签 → 解密(与 getToken 第③④步相同)
JsonNode env = om.readTree(r.body());
if (env.get("code").asInt() != 10000) {
throw new IllegalStateException("请求失败: " + env.get("message").asText());
}
String data = env.get("data").asText();
String rts = r.headers().firstValue("ANONY-TIMESTAMP").orElseThrow();
String rnonce = r.headers().firstValue("ANONY-NONCE").orElseThrow();
String rsig = r.headers().firstValue("ANONY-SIGN").orElseThrow();
if (!AnonyV2Client.verifyTimestamp(rts)) throw new SecurityException("响应时间戳超窗");
if (!AnonyV2Client.verify(AnonyV2Client.canonical(rts, rnonce, data), serverPubB64, rsig)) {
throw new SecurityException("响应验签失败");
}
JsonNode result = om.readTree(AnonyV2Client.decrypt(data, merchantPrivB64));
// result 即业务数据:{ "address": "...", "apiOrder": "...", "addressDatas": [...] }其余接口(createWithdrawOrder、merchantInfo、getDeposits、checkWithdrawOrder)只需替换 URL 与业务参数,信封处理完全相同。参数与响应字段见 接口参考。
币种与 TRON-USDC 提示
- 当前开放的地址类型
addressType:trxsolanaeth_bnbton;btc为预留值,Bitcoin 正式开放前不要使用。 - 提现币种
currency(共 13 种):trxtrx.usdtetheth.usdteth.usdcsolanasolana.usdtsolana.usdcbnbbnb.usdtbnb.usdctonton.usdt。
TRON 链 USDC 充值已关闭
向 TRON 收款地址转入 USDC 的资金不会入账、也不会触发回调。请勿引导你的用户在 TRON 链上使用 USDC 充值;TRON 链充值请使用 TRX 或 USDT。
处理回调
平台在充值到账 / 提现状态变更时回调你的 callback_url。回调与响应使用同一套 canonical 验签 + OAEP 解密逻辑,处理步骤:
- 读取请求头
ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN,请求体即 Base64 密文。 - 校验时间窗(±5 分钟)+ nonce 去重——Java SDK 没有内置去重存储,
verify之前必须自行用原子操作判重(如 RedisSET key 1 NX EX 900,过期 ≥ 15 分钟),否则回调可被重放。 verify(canonical(ts, nonce, body), 平台公钥, sign)验签。decrypt(body, 商户私钥)解密得到业务 JSON,按businessType分发处理。- 处理成功后按与平台约定返回(如纯文本 success)。
以 Spring Boot 为例:
@RestController
public class AnonyCallbackController {
@Autowired
private StringRedisTemplate redis;
private static final ObjectMapper OM = new ObjectMapper();
@PostMapping("/anony/callback")
public String callback(@RequestHeader("ANONY-TIMESTAMP") String ts,
@RequestHeader("ANONY-NONCE") String nonce,
@RequestHeader("ANONY-SIGN") String sig,
@RequestBody String cipherBody) throws Exception {
// ① 时间窗 ±300 秒
if (!AnonyV2Client.verifyTimestamp(ts)) return "FAIL";
// ② nonce 原子去重(重复即为重放,直接拒绝)
Boolean fresh = redis.opsForValue()
.setIfAbsent("anony:cb:nonce:" + nonce, "1", Duration.ofSeconds(900));
if (!Boolean.TRUE.equals(fresh)) return "FAIL";
// ③ 平台公钥验签(canonical 串 = ts + "\n" + nonce + "\n" + 密文体)
if (!AnonyV2Client.verify(
AnonyV2Client.canonical(ts, nonce, cipherBody), serverPubB64, sig)) {
return "FAIL";
}
// ④ 商户私钥解密,按 businessType 分发
JsonNode data = OM.readTree(AnonyV2Client.decrypt(cipherBody, merchantPrivB64));
String businessType = data.get("businessType").asText();
if ("deposit".equals(businessType)) {
// 充值到账:txid / address / amount / currency / apiOrder / userOrder ...
// 建议按 apiOrder 幂等入账
} else if ("withdraw".equals(businessType)) {
// 提现状态:orderStatus / txid / toAddress ...
}
// ⑤ 处理成功后返回约定内容
return "SUCCESS";
}
}回调报文字段的完整说明见 回调机制。
实现细节:回调体与应答约定
- 回调的 HTTP body 就是密文本身(不套
{code,data}外层 JSON),Content-Type为application/json但内容是 Base64 密文,注意不要按 JSON 解析回调体。 - 平台按应答内容判定结果:存款回调需返回纯文本
SUCCESS才算成功;提现回调SUCCESS=确认推进、FAIL=拒绝(订单退款)、其他=稍后重试。 - 提现方向还有
businessType = "withdrawalPendingConfirm"的待确认回调(无txid/orderStatus),返回SUCCESS表示放行、FAIL表示拒绝并退款。 - 回调超时 3 秒;失败按
2s → 30s → 2min → 5min → 15min → 60min退避重试,最多 5 次后停止。请保证回调处理快速返回(耗时逻辑异步化)。
上线自检(selfcheck)
正式启用前,务必用真实密钥调一次只读探活接口 POST /api/merchant/v2/selfcheck:请求走完整的加密+签名流程(任意业务 JSON 会被原样 echo 回来),你能成功验签 + 解密并取回 echo,即证明上下行链路全部就绪。详见 上线自检。
常见坑
| 坑 | 正确做法 |
|---|---|
| OAEP 哈希用错 | 必须是 SHA-1:Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")(SDK 已内置,自行实现时注意) |
| canonical 串不一致 | 三段用 LF(\n) 连接,顺序固定 ts, nonce, body,逐字节一致 |
| 密钥角色用反 | 上行用平台公钥加密、商户私钥签名;下行用平台公钥验签、商户私钥解密 |
| 手写解析外层 JSON | 响应体里 / 可能被转义为 \/,必须用标准 JSON 库(Jackson/Gson)解析,不要字符串切割 |
| 服务器时钟漂移 | 时间窗仅 ±5 分钟,务必开启 NTP 同步,否则报 signatureFailed |
| 把 getToken 响应当明文 | getToken 响应同样是加密+签名信封,必须验签 + 解密后再取 token |