Skip to content

Commit 8d3733b

Browse files
DoBest888claude
andcommitted
feat(v3.1.0): add Withdraw namespace — 4 new endpoints + RSA-2048 signing
多链收款业务的资金出库端 — 与已有 Payment(HMAC) namespace 配对. 新增 endpoint(4 个): POST /api/v1/withdraw createWithdraw / create_withdraw GET /api/v1/withdraw/:id getWithdraw / get_withdraw GET /api/v1/balance/withdrawable getWithdrawableBalance / get_withdrawable_balance GET /api/v1/fee/quote quoteFee / quote_fee 技术细节: - 鉴权:RSA-PKCS1v15-SHA256 双向签名 + 4 个请求头(X-API-Key / X-Timestamp / X-Nonce / X-Withdraw-Signature) - signString = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY - 4 个 SDK 的 Http.request 统一支持 raw body bytes/string(签名场景必须保证签名串和实际 body 字节一致) - Withdraw.sign() / verifyCallback() 公开,便于 curl 调试 + 平台回调验签 - Python `cryptography` 依赖采用 lazy import,不用提币功能不会强制装 总覆盖 endpoint 数:25 → 29. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent abd11c5 commit 8d3733b

17 files changed

Lines changed: 1139 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@
22

33
本仓库遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)[Semantic Versioning](https://semver.org/lang/zh-CN/).
44

5+
## [3.1.0] - 2026-05-23
6+
7+
### 新增
8+
9+
- 4 个语言 SDK 全部新增 **`.withdraw` namespace** — 多链收款业务的资金出库端,共 4 个 endpoint:
10+
- `POST /api/v1/withdraw` 发起提币(`createWithdraw` / `create_withdraw`)
11+
- `GET /api/v1/withdraw/:id` 查询单笔(`getWithdraw` / `get_withdraw`)
12+
- `GET /api/v1/balance/withdrawable` 查询可提余额(`getWithdrawableBalance` / `get_withdrawable_balance`)
13+
- `GET /api/v1/fee/quote` 费用预估(`quoteFee` / `quote_fee`)
14+
- 提币 API 使用 **RSA-PKCS1v15-SHA256** 签名(非对称),与收款 API 的 HMAC-SHA256(对称)并存
15+
- 各 SDK 配置项新增 `withdraw_api_key` / `withdraw_private_key_pem` / `withdraw_platform_public_key_pem`
16+
- 新增 `Withdraw.sign()` 公开方法(便于 curl 调试场景手算签名)
17+
- 新增 `Withdraw.verifyCallback()` 公开方法(对接方收到平台回调时校验签名)
18+
19+
### 变更
20+
21+
- 各 SDK 覆盖 endpoint 数从 25 增至 **29**(4 个新增提币 endpoint)
22+
- `Http.request` 在 4 个语言中统一支持 raw body bytes/string 传入(RSA 签名场景必须用,保证签名串与实际 body 字节一致)
23+
24+
### 依赖
25+
26+
- Python SDK `pyproject.toml` 新增 `cryptography>=3.4.0`(运行时 lazy import,不用提币功能不会强制装)
27+
- PHP SDK `composer.json` 新增 `ext-openssl: *`
28+
- Go / Node 用各自语言标准库内置 crypto,无新增第三方依赖
29+
30+
### 文档
31+
32+
- 各 SDK `doc.go` / `__init__.py` / `client.js` / `Client.php` 顶部注释同步更新 namespace 列表
33+
- `index.d.ts`(Node)新增 `WithdrawNamespace` / `WithdrawCreateParams` 类型
34+
535
## [3.0.0] - 2026-05-23
636

737
### 新增

go/client.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ type Config struct {
2424
// SMTPAPIKey SMTP 聚合 API 的 smk_ 前缀 Token.
2525
SMTPAPIKey string
2626

27+
// WithdrawAPIKey 提币 API 的 X-API-Key(账户级 API Key).
28+
WithdrawAPIKey string
29+
// WithdrawPrivateKeyPEM 对接方 RSA 私钥 PEM 字符串(用于请求签名).
30+
// 推荐运行时从环境变量或密钥管理服务读出,不要硬编码.
31+
WithdrawPrivateKeyPEM string
32+
// WithdrawPlatformPublicKeyPEM 平台 RSA 公钥 PEM(用于回调验签,可选).
33+
WithdrawPlatformPublicKeyPEM string
34+
2735
// Timeout HTTP 超时,默认 30s.
2836
Timeout time.Duration
2937

@@ -53,6 +61,8 @@ type Client struct {
5361
Energy *EnergyNamespace
5462
// SMTP SMTP 聚合命名空间
5563
SMTP *SMTPNamespace
64+
// Withdraw 提币命名空间(多链收款业务的资金出库端,RSA-2048 签名)
65+
Withdraw *WithdrawNamespace
5666
}
5767

5868
// NewClient creates a new NexCore client.
@@ -83,5 +93,6 @@ func NewClient(cfg Config) *Client {
8393
c.Exchange = &ExchangeNamespace{c: c}
8494
c.Energy = &EnergyNamespace{c: c}
8595
c.SMTP = &SMTPNamespace{c: c}
96+
c.Withdraw = &WithdrawNamespace{c: c}
8697
return c
8798
}

go/doc.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// 一次配置覆盖 NexCore 平台全部 v1 公开接口,业务按 namespace 划分:
44
//
55
// - client.Payment — 多链收款(HMAC-SHA256 签名)
6+
// - client.Withdraw — 多链收款 · 提币(RSA-2048 签名)
67
// - client.Exchange — 汇率(X-App-Key + X-App-Secret header)
78
// - client.Energy — TRON 能量租赁(X-API-Key + X-Secret-Key)
89
// - client.SMTP — SMTP 聚合(Bearer Token)
@@ -12,12 +13,14 @@
1213
// import nexcore "github.com/DoBestone/nexcore-sdk/go"
1314
//
1415
// c := nexcore.NewClient(nexcore.Config{
15-
// BaseURL: "https://your-domain.com",
16-
// PaymentAppID: "APP20260412XXXX",
17-
// PaymentAppKey: "your_app_key_here",
18-
// EnergyAPIKey: "energy_key",
19-
// EnergySecretKey: "energy_secret",
20-
// SMTPAPIKey: "smk_xxx",
16+
// BaseURL: "https://your-domain.com",
17+
// PaymentAppID: "APP20260412XXXX",
18+
// PaymentAppKey: "your_app_key_here",
19+
// EnergyAPIKey: "energy_key",
20+
// EnergySecretKey: "energy_secret",
21+
// SMTPAPIKey: "smk_xxx",
22+
// WithdrawAPIKey: "MPK_xxx",
23+
// WithdrawPrivateKeyPEM: os.Getenv("WITHDRAW_RSA_PRIV"),
2124
// })
2225
//
2326
// raw, err := c.Payment.CreateOrder(map[string]any{
@@ -36,4 +39,4 @@
3639
package nexcore
3740

3841
// Version is the SDK version, kept in sync with the public repository tags.
39-
const Version = "3.0.0"
42+
const Version = "3.1.0"

go/http.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type httpTransport struct {
2121
// requestOpts 是 do() 的可选参数.
2222
type requestOpts struct {
2323
Body any // 自动 JSON 序列化为 body
24+
BodyRaw []byte // 已 marshal 好的原始 body(优先于 Body,RSA 签名场景必须用这个保证字节一致)
2425
Query map[string]any // query 参数(自动 url-encode + 过滤空值)
2526
Headers map[string]string // 额外 header
2627
}
@@ -61,9 +62,11 @@ func (t *httpTransport) do(method, path string, opts *requestOpts) (json.RawMess
6162
}
6263
}
6364

64-
// body 编码
65+
// body 编码:BodyRaw 优先(RSA 签名场景必须用,否则签名串和实际 body 不一致)
6566
var body io.Reader
66-
if opts.Body != nil {
67+
if opts.BodyRaw != nil {
68+
body = bytes.NewReader(opts.BodyRaw)
69+
} else if opts.Body != nil {
6770
b, err := json.Marshal(opts.Body)
6871
if err != nil {
6972
return nil, &Error{Message: "marshal body: " + err.Error(), Code: -1}

go/withdraw.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package nexcore
2+
3+
import (
4+
"crypto"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"crypto/sha256"
8+
"crypto/x509"
9+
"encoding/base64"
10+
"encoding/json"
11+
"encoding/pem"
12+
"fmt"
13+
"strconv"
14+
"strings"
15+
"sync"
16+
"time"
17+
)
18+
19+
// WithdrawNamespace implements the v1 提币 API — 多链收款业务的资金出库端.
20+
//
21+
// 鉴权:RSA-PKCS1v15-SHA256 签名 + 4 个请求头
22+
//
23+
// X-API-Key 账户级 API Key(控制台「账号 → API 密钥」)
24+
// X-Timestamp unix ms,与服务器时差 ≤ 60s
25+
// X-Nonce 一次性 nonce(uuid v4),5 分钟内不可重复
26+
// X-Withdraw-Signature RSA-PKCS1v15-SHA256(caller_private_key, signString),Base64
27+
//
28+
// signString = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY
29+
// 其中 BODY 为 HTTP body 原文(JSON 字符串原样,GET 请求为空字符串).
30+
//
31+
// 对应 /docs 文档 "提币 API" 章节的 4 个 endpoint(internal/handler/api_withdraw_v1.go):
32+
//
33+
// POST /api/v1/withdraw CreateWithdraw 发起提币
34+
// GET /api/v1/withdraw/:id GetWithdraw 查询单笔状态
35+
// GET /api/v1/balance/withdrawable GetWithdrawableBalance 查询可提余额
36+
// GET /api/v1/fee/quote QuoteFee 费用预估
37+
//
38+
// 另提供 VerifyCallback() 校验平台回调签名(用平台公钥).
39+
type WithdrawNamespace struct {
40+
c *Client
41+
42+
// 私钥/公钥懒解析缓存,避免每次请求重复解码 PEM
43+
once sync.Once
44+
privKey *rsa.PrivateKey
45+
privParseErr error
46+
platformPubOnce sync.Once
47+
platformPub *rsa.PublicKey
48+
platformPubErr error
49+
}
50+
51+
// parsePrivKey 解析配置的对接方私钥 PEM,带缓存.
52+
func (n *WithdrawNamespace) parsePrivKey() (*rsa.PrivateKey, error) {
53+
n.once.Do(func() {
54+
if n.c.cfg.WithdrawPrivateKeyPEM == "" {
55+
n.privParseErr = &Error{Message: "WithdrawPrivateKeyPEM not configured", Code: -1}
56+
return
57+
}
58+
block, _ := pem.Decode([]byte(n.c.cfg.WithdrawPrivateKeyPEM))
59+
if block == nil {
60+
n.privParseErr = &Error{Message: "withdraw: invalid private key PEM", Code: -1}
61+
return
62+
}
63+
// 兼容 PKCS#1 和 PKCS#8
64+
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
65+
n.privKey = key
66+
return
67+
}
68+
if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
69+
if rsaKey, ok := k.(*rsa.PrivateKey); ok {
70+
n.privKey = rsaKey
71+
return
72+
}
73+
n.privParseErr = &Error{Message: "withdraw: PKCS#8 key is not RSA", Code: -1}
74+
return
75+
}
76+
n.privParseErr = &Error{Message: "withdraw: cannot parse private key (neither PKCS#1 nor PKCS#8)", Code: -1}
77+
})
78+
return n.privKey, n.privParseErr
79+
}
80+
81+
// parsePlatformPub 解析平台公钥(回调验签用).
82+
func (n *WithdrawNamespace) parsePlatformPub() (*rsa.PublicKey, error) {
83+
n.platformPubOnce.Do(func() {
84+
if n.c.cfg.WithdrawPlatformPublicKeyPEM == "" {
85+
n.platformPubErr = &Error{Message: "WithdrawPlatformPublicKeyPEM not configured", Code: -1}
86+
return
87+
}
88+
block, _ := pem.Decode([]byte(n.c.cfg.WithdrawPlatformPublicKeyPEM))
89+
if block == nil {
90+
n.platformPubErr = &Error{Message: "withdraw: invalid platform public key PEM", Code: -1}
91+
return
92+
}
93+
if key, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
94+
if rsaKey, ok := key.(*rsa.PublicKey); ok {
95+
n.platformPub = rsaKey
96+
return
97+
}
98+
n.platformPubErr = &Error{Message: "withdraw: PKIX key is not RSA", Code: -1}
99+
return
100+
}
101+
// 兼容 PKCS#1
102+
if key, err := x509.ParsePKCS1PublicKey(block.Bytes); err == nil {
103+
n.platformPub = key
104+
return
105+
}
106+
n.platformPubErr = &Error{Message: "withdraw: cannot parse platform public key", Code: -1}
107+
})
108+
return n.platformPub, n.platformPubErr
109+
}
110+
111+
// Sign computes the RSA-PKCS1v15-SHA256 signature for a withdraw request.
112+
//
113+
// 业务方一般不需要直接调,SDK 内部 do 时自动调用.公开出来便于:
114+
// - 测试签名正确性
115+
// - 自行实现非标场景(比如 curl 调试)
116+
func (n *WithdrawNamespace) Sign(method, path, timestamp, nonce, body string) (string, error) {
117+
priv, err := n.parsePrivKey()
118+
if err != nil {
119+
return "", err
120+
}
121+
signString := strings.ToUpper(method) + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body
122+
h := sha256.Sum256([]byte(signString))
123+
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:])
124+
if err != nil {
125+
return "", &Error{Message: "withdraw: RSA sign failed: " + err.Error(), Code: -1}
126+
}
127+
return base64.StdEncoding.EncodeToString(sig), nil
128+
}
129+
130+
// newNonce 生成 uuid v4(本地实现避免引入第三方依赖).
131+
func newNonce() (string, error) {
132+
b := make([]byte, 16)
133+
if _, err := rand.Read(b); err != nil {
134+
return "", err
135+
}
136+
// RFC 4122 v4 variant
137+
b[6] = (b[6] & 0x0f) | 0x40
138+
b[8] = (b[8] & 0x3f) | 0x80
139+
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
140+
}
141+
142+
// do 内部统一发请求 — 自动加 4 个鉴权头.
143+
//
144+
// body 必须传入"已序列化"的 JSON bytes(确保和签名串里的 BODY 字符串完全一致).
145+
// GET 请求 body 传 nil 即可.
146+
func (n *WithdrawNamespace) do(method, path string, body []byte, query map[string]any) (json.RawMessage, error) {
147+
if n.c.cfg.WithdrawAPIKey == "" {
148+
return nil, &Error{Message: "WithdrawAPIKey not configured", Code: -1}
149+
}
150+
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
151+
nonce, err := newNonce()
152+
if err != nil {
153+
return nil, &Error{Message: "withdraw: gen nonce: " + err.Error(), Code: -1}
154+
}
155+
bodyStr := ""
156+
if body != nil {
157+
bodyStr = string(body)
158+
}
159+
sig, err := n.Sign(method, path, timestamp, nonce, bodyStr)
160+
if err != nil {
161+
return nil, err
162+
}
163+
return n.c.transport.do(method, path, &requestOpts{
164+
BodyRaw: body,
165+
Query: query,
166+
Headers: map[string]string{
167+
"X-API-Key": n.c.cfg.WithdrawAPIKey,
168+
"X-Timestamp": timestamp,
169+
"X-Nonce": nonce,
170+
"X-Withdraw-Signature": sig,
171+
},
172+
})
173+
}
174+
175+
// CreateWithdraw 发起提币 — POST /api/v1/withdraw
176+
//
177+
// 下单后状态为 pending,等延迟到期由 worker 自动广播.期间可在控制台暂停 / 加速 / 取消.
178+
//
179+
// params := map[string]any{
180+
// "chain": "tron",
181+
// "symbol": "USDT",
182+
// "amount": "100.5",
183+
// "to_address": "TXxxxxxxxx",
184+
// "memo": "withdraw to user #1024", // 可选
185+
// "callback_url": "https://your-domain.com/cb", // 可选
186+
// "request_id": "your-idempotency-uuid", // 可选,推荐传
187+
// }
188+
// raw, err := client.Withdraw.CreateWithdraw(params)
189+
func (n *WithdrawNamespace) CreateWithdraw(params map[string]any) (json.RawMessage, error) {
190+
body, err := json.Marshal(params)
191+
if err != nil {
192+
return nil, &Error{Message: "withdraw: marshal body: " + err.Error(), Code: -1}
193+
}
194+
return n.do("POST", "/api/v1/withdraw", body, nil)
195+
}
196+
197+
// GetWithdraw 查询单笔提币状态 — GET /api/v1/withdraw/:id
198+
//
199+
// 返回订单详情(可用来轮询状态,也建议优先用回调).
200+
func (n *WithdrawNamespace) GetWithdraw(id string) (json.RawMessage, error) {
201+
if id == "" {
202+
return nil, &Error{Message: "withdraw: id is required", Code: -1}
203+
}
204+
return n.do("GET", "/api/v1/withdraw/"+id, nil, nil)
205+
}
206+
207+
// GetWithdrawableBalance 查询可提余额 — GET /api/v1/balance/withdrawable
208+
//
209+
// 返回该账户在每条链 × 每种资产下的「已归集待提现」余额.
210+
// 只有这部分可用于 API 提币.
211+
func (n *WithdrawNamespace) GetWithdrawableBalance() (json.RawMessage, error) {
212+
return n.do("GET", "/api/v1/balance/withdrawable", nil, nil)
213+
}
214+
215+
// QuoteFee 费用预估 — GET /api/v1/fee/quote?chain=&symbol=&amount=
216+
//
217+
// 返回管理端为该 chain × symbol 配置的预扣费(OKX 式固定值).
218+
//
219+
// raw, err := client.Withdraw.QuoteFee("tron", "USDT", "100")
220+
func (n *WithdrawNamespace) QuoteFee(chain, symbol, amount string) (json.RawMessage, error) {
221+
if chain == "" || symbol == "" {
222+
return nil, &Error{Message: "withdraw: chain and symbol are required", Code: -1}
223+
}
224+
q := map[string]any{"chain": chain, "symbol": symbol}
225+
if amount != "" {
226+
q["amount"] = amount
227+
}
228+
return n.do("GET", "/api/v1/fee/quote", nil, q)
229+
}
230+
231+
// VerifyCallback 验证平台回调签名.
232+
//
233+
// 用法(对接方收到回调时):
234+
//
235+
// sig := req.Header.Get("X-Platform-Signature")
236+
// body, _ := io.ReadAll(req.Body)
237+
// ts := req.Header.Get("X-Timestamp")
238+
// nonce := req.Header.Get("X-Nonce")
239+
// if err := client.Withdraw.VerifyCallback(req.Method, req.URL.Path, ts, nonce, body, sig); err != nil {
240+
// // 验签失败,拒绝处理
241+
// }
242+
//
243+
// 验签算法与请求方向一致:RSA-PKCS1v15-SHA256(platform_public_key, signString).
244+
func (n *WithdrawNamespace) VerifyCallback(method, path, timestamp, nonce string, body []byte, base64Sig string) error {
245+
pub, err := n.parsePlatformPub()
246+
if err != nil {
247+
return err
248+
}
249+
sig, err := base64.StdEncoding.DecodeString(base64Sig)
250+
if err != nil {
251+
return &Error{Message: "withdraw: bad signature base64: " + err.Error(), Code: -1}
252+
}
253+
signString := strings.ToUpper(method) + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + string(body)
254+
h := sha256.Sum256([]byte(signString))
255+
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig); err != nil {
256+
return &Error{Message: "withdraw: signature verify failed", Code: -1}
257+
}
258+
return nil
259+
}
260+

0 commit comments

Comments
 (0)