Go SDK
Go SDK 是单文件 anonyv2.go(包名 anonyv2),仅依赖 Go 标准库,提供 v2 协议所需的全部加密 / 签名 / 防重放原语。与 PHP / Node 版不同,Go 版是纯函数集合:不含 Client 结构体、不内置 HTTP 调用、不内置 nonce 去重存储——HTTP 请求由你用任意库发起(本页示例全部使用标准库 net/http),nonce 去重存储由你自备。
开始前请先备齐 4 个凭据(APP-ID / secret / 平台公钥 public_t / 商户私钥 private_u),见凭据与密钥;协议细节(OAEP-SHA1、canonical 串、±5 分钟时间窗等)见加密与签名协议。
引入
SDK 在平台提供的对接文档包中,路径为 sdk/go/anonyv2.go。将该文件复制进你的项目即可,例如:
your-project/
└── internal/
└── anonyv2/
└── anonyv2.go // package anonyv2无需 go get 任何第三方依赖。
导出函数一览
| 函数 | 签名 | 作用 |
|---|---|---|
Encrypt | Encrypt(plaintext []byte, serverPublicKeyB64 string) (string, error) | RSA-OAEP(MGF1 = SHA-1)加密:明文按 214 字节分段逐段加密,密文拼接后整体 Base64 |
Decrypt | Decrypt(cipherB64 string, merchantPrivateKeyB64 string) ([]byte, error) | Base64 解码后按 256 字节分段 OAEP 解密并拼接 |
Sign | Sign(canonical string, merchantPrivateKeyB64 string) (string, error) | SHA256withRSA(PKCS#1 v1.5)签名 canonical 串,返回 Base64 |
Verify | Verify(canonical string, serverPublicKeyB64 string, signB64 string) bool | SHA256withRSA 验签(下行响应 / 回调用平台公钥) |
Canonical | Canonical(ts, nonce, payload string) string | 拼 canonical 签名串:ts + "\n" + nonce + "\n" + payload |
NewNonce | NewNonce() (string, error) | 生成 16 字节 CSPRNG 随机数,hex 编码(32 字符) |
AuthHeader | AuthHeader(appID, secret string) string | 计算 getToken 的 Authorization 头值:"auth " + md5(appID + "&" + secret) |
VerifyTimestamp | VerifyTimestamp(ts string) bool | 校验秒级时间戳是否在 ±300 秒窗口内 |
两个密钥参数贯穿所有函数,角色不要用反:
| 参数 | 对应凭据 | 用途 |
|---|---|---|
serverPublicKeyB64 | 平台公钥 public_t | Encrypt 加密上行请求、Verify 验证下行签名 |
merchantPrivateKeyB64 | 商户私钥 private_u | Sign 签名上行请求、Decrypt 解密下行数据 |
两者格式均为 base64(PEM)——对整段 PEM 文本再做一次 Base64(见凭据与密钥)。
实现细节
- OAEP 哈希 SHA-1 已在 SDK 内写死(
rsa.EncryptOAEP(sha1.New(), ...)),请勿改成 SHA-256,否则与服务端解不开对方的密文。 - 私钥加载优先按 PKCS#8(
BEGIN PRIVATE KEY)解析,失败时回退 PKCS#1(BEGIN RSA PRIVATE KEY),两种格式都能用;公钥必须是 SPKI(BEGIN PUBLIC KEY)。 - 每次调用都会重新 Base64 解码并解析密钥。低频调用无感知;高并发场景可自行在外层缓存解析结果。
第一步:获取 token(getToken)
getToken 的请求不加密、不签名,只带 ANONY-APP-ID 与 Authorization 两个头(AuthHeader 负责计算后者);但响应与其它接口一样是「加密 + 签名」信封,必须验签 + 解密后才能取出 token 字段——不是明文 token。
流程:AuthHeader(appID, secret) 算出 Authorization → POST getToken → 解析外层 JSON {code, data, message} → 校验响应头时间戳 → Verify 验签 → Decrypt 解密 → 取 token。
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"your-project/internal/anonyv2"
)
const (
baseURL = "https://api.anonypay.io/api/merchant/"
appID = "你的商户号"
secret = "你的 secret"
serverPub = "<public_t:平台公钥 base64(PEM)>"
merchPriv = "<private_u:商户私钥 base64(PEM)>"
)
// envelope 下行响应外层结构(务必用标准 JSON 库解析,data 中的 \/ 会被自动还原)
type envelope struct {
Code int `json:"code"`
Data string `json:"data"`
Message string `json:"message"`
}
// openEnvelope 处理下行信封:解析外层 JSON → 校验时间窗 → 验签 → 解密,返回业务 JSON 明文。
func openEnvelope(resp *http.Response, body []byte) ([]byte, error) {
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if env.Code != 10000 {
return nil, fmt.Errorf("接口返回失败: %s", env.Message)
}
ts := resp.Header.Get("ANONY-TIMESTAMP")
nonce := resp.Header.Get("ANONY-NONCE")
sign := resp.Header.Get("ANONY-SIGN")
if !anonyv2.VerifyTimestamp(ts) {
return nil, fmt.Errorf("响应时间戳超出 ±5 分钟窗口")
}
if !anonyv2.Verify(anonyv2.Canonical(ts, nonce, env.Data), serverPub, sign) {
return nil, fmt.Errorf("响应验签失败")
}
return anonyv2.Decrypt(env.Data, merchPriv)
}
func getToken() (string, error) {
req, err := http.NewRequest(http.MethodPost, baseURL+"getToken", nil)
if err != nil {
return "", err
}
req.Header.Set("ANONY-APP-ID", appID)
req.Header.Set("Authorization", anonyv2.AuthHeader(appID, secret))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
plain, err := openEnvelope(resp, body)
if err != nil {
return "", err
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(plain, &out); err != nil {
return "", err
}
return out.Token, nil
}token 有效期
收到 tokenExpired 说明 token 已失效,重新调用 getToken 即可。当前实现细节:token 为滑动过期 8 小时、绝对上限 24 小时,有效期内请复用,不要每笔请求都取新 token。接口详情见 getToken。
业务请求:完整流程
除 getToken 外的所有业务接口都走同一套流程:
- 业务参数序列化为 JSON;
Encrypt用平台公钥加密,得到 Base64 密文(即 HTTP body);NewNonce()生成随机数,取当前秒级 Unix 时间戳;Canonical(ts, nonce, 密文)拼签名串,Sign用商户私钥签名;- 设置 5 个请求头
ANONY-APP-ID / ANONY-TOKEN / ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN,POST 发送; - 响应用
openEnvelope(验时间窗 → 验签 → 解密)取回业务 JSON。
把 2–6 封装成一个通用 post 函数:
import (
"strconv"
"strings"
"time"
)
// post 发送一次 v2 业务请求,返回解密后的业务 JSON 明文。
func post(path string, params any, token string) ([]byte, error) {
// ① 业务 JSON
plain, err := json.Marshal(params)
if err != nil {
return nil, err
}
// ② 平台公钥 OAEP 加密 → body
cipher, err := anonyv2.Encrypt(plain, serverPub)
if err != nil {
return nil, err
}
// ③ nonce + 秒级时间戳
nonce, err := anonyv2.NewNonce()
if err != nil {
return nil, err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
// ④ canonical 串 → 商户私钥签名
sign, err := anonyv2.Sign(anonyv2.Canonical(ts, nonce, cipher), merchPriv)
if err != nil {
return nil, err
}
// ⑤ 5 个请求头 + POST
req, err := http.NewRequest(http.MethodPost, baseURL+path, strings.NewReader(cipher))
if err != nil {
return nil, err
}
req.Header.Set("ANONY-APP-ID", appID)
req.Header.Set("ANONY-TOKEN", token)
req.Header.Set("ANONY-TIMESTAMP", ts)
req.Header.Set("ANONY-NONCE", nonce)
req.Header.Set("ANONY-SIGN", sign)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// ⑥ 验时间窗 → 验签 → 解密
return openEnvelope(resp, body)
}调用业务接口就只剩传参:
func main() {
token, err := getToken()
if err != nil {
panic(err)
}
// 创建收款地址
data, err := post("createAddress", map[string]string{
"userOrder": "A1001",
"addressType": "trx",
}, token)
if err != nil {
panic(err)
}
fmt.Println(string(data)) // {"address":"T...","apiOrder":"...","addressDatas":...}
// 创建提现单
data, err = post("createWithdrawOrder", map[string]string{
"userOrder": "W2001",
"address": "T目标地址",
"amount": "100",
"currency": "trx.usdt",
"withdrawType": "normal",
}, token)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}TRON 链 USDC 充值已关闭
TRON(trx)收款地址请只用于接收 TRX / USDT。向 TRON 地址转入 USDC 不入账、不回调——资金到链上也不会记入商户余额。提现币种枚举中同样不包含 trx.usdc(支持的 13 种见创建提现单)。
金额类字段建议以字符串传递以避免精度问题;请求体(密文)上限默认 128KB。各接口的参数与响应字段见 API 参考。
处理回调
平台在充值到账 / 提现状态变更时 POST 你的 callback_url:HTTP body 就是 Base64 密文(没有外层 JSON 包裹),请求头带 ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN。回调与响应用同一套验签 + 解密逻辑,但有一点不同:回调方向的 nonce 去重必须由你实现——Go SDK 不内置去重存储,跳过去重意味着回调可被重放。
处理顺序:读头和 body → 校验时间窗(VerifyTimestamp)→ nonce 去重 → Verify 验签 → Decrypt 解密 → 按 businessType 分发 → 返回成功应答。
import "sync"
// —— nonce 去重:演示用内存实现,生产环境请换成 Redis 等原子存储,
// 例如 SET anony:cb:nonce:<nonce> 1 NX EX 900(过期时间 ≥ 15 分钟)。
var (
seenMu sync.Mutex
seen = map[string]int64{}
)
func isFreshNonce(nonce string) bool {
seenMu.Lock()
defer seenMu.Unlock()
now := time.Now().Unix()
for k, exp := range seen {
if exp < now {
delete(seen, k)
}
}
if _, dup := seen[nonce]; dup {
return false
}
seen[nonce] = now + 900
return true
}
func callbackHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
ts := r.Header.Get("ANONY-TIMESTAMP")
nonce := r.Header.Get("ANONY-NONCE")
sign := r.Header.Get("ANONY-SIGN")
// ① 时间窗 ±5 分钟 + ② nonce 去重(拒绝重放的回调)
if nonce == "" || !anonyv2.VerifyTimestamp(ts) || !isFreshNonce(nonce) {
http.Error(w, "replay rejected", http.StatusForbidden)
return
}
// ③ 平台公钥验签(payload = 密文 body 原文)
cipher := string(body)
if !anonyv2.Verify(anonyv2.Canonical(ts, nonce, cipher), serverPub, sign) {
http.Error(w, "bad signature", http.StatusForbidden)
return
}
// ④ 商户私钥解密
plain, err := anonyv2.Decrypt(cipher, merchPriv)
if err != nil {
http.Error(w, "decrypt failed", http.StatusBadRequest)
return
}
var evt struct {
BusinessType string `json:"businessType"`
UserOrder string `json:"userOrder"`
ApiOrder string `json:"apiOrder"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Txid string `json:"txid"`
OrderStatus string `json:"orderStatus"`
}
if err := json.Unmarshal(plain, &evt); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
switch evt.BusinessType {
case "deposit":
// 充值到账:入账逻辑务必幂等(以 txid / apiOrder 判重)
case "withdraw":
// 提现状态变更:按 orderStatus / txid 更新本地订单
}
// ⑤ 处理成功后返回成功应答
w.Write([]byte("SUCCESS"))
}
func startCallbackServer() {
http.HandleFunc("/anony/callback", callbackHandler)
http.ListenAndServe(":8080", nil)
}回调应答的实现细节
当前平台实现按应答正文判定回调结果:存款回调需返回纯文本 SUCCESS 才算成功;提现回调 SUCCESS 表示确认、FAIL 表示拒绝(会触发退款流程)、其他应答会按退避策略重试。财务确认类提现还会先收到 businessType = "withdrawalPendingConfirm" 的待确认回调。回调字段与应答约定详见回调机制。
同步响应方向(openEnvelope)可以省略 nonce 去重,但回调方向必须去重——这是防重放的最后一道闸。
上线自检(selfcheck)
正式启用前,用真实密钥调用只读探活接口 selfcheck 把上下行链路完整跑一遍。它复用上面的 post,路径为 v2/selfcheck:
data, err := post("v2/selfcheck", map[string]string{"hello": "anony"}, token)
if err != nil {
panic(err)
}
fmt.Println(string(data))
// {"pong":true,"apiVersion":2,"serverTime":...,"echo":{"hello":"anony"}}能成功验签、解密并取回 echo,即代表你的加密 / 签名 / 防重放实现与服务端完全对齐。自检通过后通知平台完成正式启用。
常见坑
- OAEP 必须 SHA-1:Go 里显式写
rsa.EncryptOAEP(sha1.New(), ...),SDK 已固定,改动后 100% 解密失败。 - canonical 串逐字节一致:三段以
\n(LF)连接,顺序固定ts, nonce, body,多一个空格都会signatureFailed。 - 密钥角色:上行用平台公钥加密、商户私钥签名;下行用平台公钥验签、商户私钥解密。传反了表现为验签失败或解密乱码。
- 时钟同步:
VerifyTimestamp与服务端时间窗都是 ±300 秒,服务器务必开 NTP,否则会莫名signatureFailed。 - 外层 JSON 用标准库解析:响应
data里的/可能被转义为\/,encoding/json会自动还原,不要手写字符串切割。