bebebus-merchant-openapi-sdk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bebebus_merchant_openapi_sdk-1.0.0.dist-info/METADATA +155 -0
- bebebus_merchant_openapi_sdk-1.0.0.dist-info/RECORD +10 -0
- bebebus_merchant_openapi_sdk-1.0.0.dist-info/WHEEL +5 -0
- bebebus_merchant_openapi_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- bebebus_merchant_openapi_sdk-1.0.0.dist-info/top_level.txt +1 -0
- openapi_sdk/__init__.py +31 -0
- openapi_sdk/client.py +374 -0
- openapi_sdk/config.py +71 -0
- openapi_sdk/exceptions.py +46 -0
- openapi_sdk/signer.py +95 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bebebus-merchant-openapi-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 商户支付 OpenAPI Python SDK(零第三方依赖)
|
|
5
|
+
Author: bebebus
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bebebus/SDK/tree/main/python
|
|
8
|
+
Project-URL: Repository, https://github.com/bebebus/SDK
|
|
9
|
+
Project-URL: Issues, https://github.com/bebebus/SDK/issues
|
|
10
|
+
Keywords: payment,openapi,merchant,sdk
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# 商户支付 OpenAPI — Python SDK
|
|
21
|
+
|
|
22
|
+
**零第三方依赖**的 Python 3 SDK:HTTP 用 `urllib.request`,签名用 `hmac`/`hashlib`,测试用标准库 `unittest`。无需 `pip install` 任何运行时依赖。
|
|
23
|
+
|
|
24
|
+
签名算法与服务端签名实现逐字节一致,单测对 [`../test-vectors.json`](../test-vectors.json) 全量复现 `base` 与 `sign`。
|
|
25
|
+
|
|
26
|
+
## 引入(无需安装依赖)
|
|
27
|
+
|
|
28
|
+
把 `openapi_sdk/` 目录放进你的项目(或把本目录加入 `sys.path` / `PYTHONPATH`)即可:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import sys
|
|
32
|
+
sys.path.insert(0, "/path/to/python")
|
|
33
|
+
|
|
34
|
+
from openapi_sdk import Client, Config, Environment
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
可选地用标准打包安装(仍零依赖):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cd python
|
|
41
|
+
pip install . # 或 python3 -m build;本身不拉任何第三方运行时依赖
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 快速开始
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from openapi_sdk import Client, Config, Environment, ApiError, TransportError
|
|
48
|
+
|
|
49
|
+
config = Config(
|
|
50
|
+
merchant_no="M00000001",
|
|
51
|
+
api_key="ak_xxx",
|
|
52
|
+
api_secret_pay="sk_pay_xxx", # pay 类接口 + 代收/退款回调
|
|
53
|
+
api_secret_payout="sk_payout_xxx", # payout 类接口 + 代付回调
|
|
54
|
+
environment=Environment.SANDBOX, # 或 Environment.PRODUCTION(须显式传 base_url)
|
|
55
|
+
)
|
|
56
|
+
client = Client(config)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
# 金额是最小单位整数:10000 = 1 元
|
|
60
|
+
order = client.pay_create(
|
|
61
|
+
out_order_no="ORD1", amount=10000, currency="PHP",
|
|
62
|
+
pay_method="gcash", country="PH",
|
|
63
|
+
notify_url="https://m.example.com/api/notify/pay",
|
|
64
|
+
)
|
|
65
|
+
print(order["pay_url"])
|
|
66
|
+
except ApiError as e: # 业务失败 code != 0
|
|
67
|
+
print(e.code, e.message, e.data)
|
|
68
|
+
except TransportError as e: # HTTP / 网络 / 超时
|
|
69
|
+
print(e, e.status_code)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 双环境与自定义基址
|
|
73
|
+
|
|
74
|
+
| 环境 | 基址 |
|
|
75
|
+
|------|------|
|
|
76
|
+
| `Environment.PRODUCTION` | 无内置基址,**必须显式传 `base_url`** |
|
|
77
|
+
| `Environment.SANDBOX` | `http://127.0.0.1:3090/api/open/v1` |
|
|
78
|
+
|
|
79
|
+
正式真实地址按上级代理专有域名派生(`https://api.<agent_domain>/api/open/v1`),用 `base_url=` 显式传入。选 `PRODUCTION` 又不传 `base_url` 会抛 `ValueError`(提示 `baseUrl is required`):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
config = Config(
|
|
83
|
+
merchant_no="M00000001", api_key="ak_xxx",
|
|
84
|
+
api_secret_pay="...", api_secret_payout="...",
|
|
85
|
+
base_url="https://api.agent.example.com/api/open/v1", # 正式环境必传
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 全部 11 个端点
|
|
90
|
+
|
|
91
|
+
代收(密钥 `api_secret_pay`,自动选用):
|
|
92
|
+
|
|
93
|
+
| 方法 | 端点 |
|
|
94
|
+
|------|------|
|
|
95
|
+
| `pay_create(...)` | `/merchant/pay/create` |
|
|
96
|
+
| `pay_query(order_no=, out_order_no=)` | `/merchant/pay/query` |
|
|
97
|
+
| `pay_methods_query(country=)` | `/merchant/pay-methods/query` |
|
|
98
|
+
| `balance_query(currency=)` | `/merchant/balance/query` |
|
|
99
|
+
| `pay_test_complete(result=, ...)` | `/merchant/pay/test/complete`(仅测试密钥) |
|
|
100
|
+
|
|
101
|
+
代付(密钥 `api_secret_payout`,自动选用):
|
|
102
|
+
|
|
103
|
+
| 方法 | 端点 |
|
|
104
|
+
|------|------|
|
|
105
|
+
| `payout_create(...)` | `/merchant/payout/create` |
|
|
106
|
+
| `payout_query(payout_no=, out_payout_no=)` | `/merchant/payout/query` |
|
|
107
|
+
| `payout_banks_query(pay_method=, country=, currency=)` | `/merchant/payout/banks/query` |
|
|
108
|
+
| `payout_proof_query(payout_no=, out_payout_no=)` | `/merchant/payout/proof/query` |
|
|
109
|
+
| `payout_receipt_query(..., inline=)` | `/merchant/payout/receipt/query` |
|
|
110
|
+
| `payout_test_complete(result=, ...)` | `/merchant/payout/test/complete`(仅测试密钥) |
|
|
111
|
+
|
|
112
|
+
约定:
|
|
113
|
+
|
|
114
|
+
- 每请求自动注入 `merchant_no`/`api_key`/`timestamp`(Unix 秒)/唯一 `nonce` 与 `sign`。
|
|
115
|
+
- 值为 `None` 的参数不放入请求体、也不参与签名。
|
|
116
|
+
- `payout_receipt_query` 的 `inline` 以整数 **1/0** 发送(`True`→1 内联 base64 图片;`False`→0 返回带 token 的 URL)。
|
|
117
|
+
- 金额是最小单位整数(`10000 = 1 元`),用 `int` 类型传入。
|
|
118
|
+
- 成功返回 `data`(dict);`code != 0` 抛 `ApiError`(携带 `code`/`message`/`data`);HTTP/网络错误抛 `TransportError`。
|
|
119
|
+
- 需要原始信封时用 `client.call_raw(path, body, secret)`(不因 `code != 0` 抛异常)。
|
|
120
|
+
|
|
121
|
+
## 签名工具(可单独使用)
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from openapi_sdk import build_sign_base, sign, verify_callback
|
|
125
|
+
|
|
126
|
+
base = build_sign_base(payload, secret) # 逐字节可断言的签名 base
|
|
127
|
+
sig = sign(payload, secret) # HMAC-SHA256 -> hex 小写
|
|
128
|
+
ok = verify_callback(callback, secret) # 时序安全(hmac.compare_digest),字段无关
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 回调验签 + 处理
|
|
132
|
+
|
|
133
|
+
见 [`examples/callback_verify.py`](examples/callback_verify.py):解析原始 body → `verify_callback`(时序安全)→ 按 `status` 幂等处理(success/failed)→ 应答 **HTTP 200 + 纯文本 `success`**。代收回调用 `api_secret_pay`、代付回调用 `api_secret_payout`,示例各演示一次。验签失败不回成功,让平台重试;处理务必幂等(同一订单可能多次回调)。
|
|
134
|
+
|
|
135
|
+
## 示例
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
cd python
|
|
139
|
+
python3 examples/pay_create.py
|
|
140
|
+
python3 examples/payout_create.py
|
|
141
|
+
python3 examples/callback_verify.py # 自演示:造签名回调 -> 验签 -> 应答 -> 篡改反例
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## 跑测试
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
cd python
|
|
148
|
+
python3 -m unittest discover -s tests
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
测试包含:
|
|
152
|
+
|
|
153
|
+
- 读取 `../test-vectors.json`,对每个向量断言 `build_sign_base == base` 且 `sign == sign`;
|
|
154
|
+
- 回调验签正例 + 篡改一字节反例(含错误密钥、缺 sign);
|
|
155
|
+
- 客户端请求构建(通用字段注入、`None` 过滤、密钥选择、`inline` 整数化、信封解析与异常分类)。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
bebebus_merchant_openapi_sdk-1.0.0.dist-info/licenses/LICENSE,sha256=LRLvJ9lOPGafky5AzLCrCGsFBckxF5-mRCdHWfBLdLI,1064
|
|
2
|
+
openapi_sdk/__init__.py,sha256=L6Yh_u2Q5zzzAodm1sV8chOnS0kkBzZBuDJrcdqkpjg,677
|
|
3
|
+
openapi_sdk/client.py,sha256=ffoNTKR8Jf6AVh1dihyXcJ1I5gWkiSU2g37goeeoiGc,13599
|
|
4
|
+
openapi_sdk/config.py,sha256=018iGLv4KqYWFMDYuRQHIlTxw2yq2GRnovxOllqkYd8,2360
|
|
5
|
+
openapi_sdk/exceptions.py,sha256=fG1JGXIdLnTJZ9WkMCcuQ__9FMV2nzN-F9vGhLSheUk,1360
|
|
6
|
+
openapi_sdk/signer.py,sha256=KUVwZXY6nMSG54IG-jaTxfHuyR-UsuS8JPFHwq6Jgys,3465
|
|
7
|
+
bebebus_merchant_openapi_sdk-1.0.0.dist-info/METADATA,sha256=yVTVTgqr-I8E5ED9xB8IAFiYJmtxs-DcS3BYb-XMNKg,6090
|
|
8
|
+
bebebus_merchant_openapi_sdk-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
bebebus_merchant_openapi_sdk-1.0.0.dist-info/top_level.txt,sha256=LcgKFGNtkcmLE49DiOxUpUVMZIHIc4HlCMpGS1Goy04,12
|
|
10
|
+
bebebus_merchant_openapi_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bebebus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openapi_sdk
|
openapi_sdk/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""商户支付 OpenAPI Python SDK(零第三方依赖)。
|
|
2
|
+
|
|
3
|
+
公开 API::
|
|
4
|
+
|
|
5
|
+
from openapi_sdk import (
|
|
6
|
+
Client, Config, Environment,
|
|
7
|
+
sign, build_sign_base, verify_callback,
|
|
8
|
+
ApiError, TransportError, OpenApiError,
|
|
9
|
+
)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .client import Client
|
|
15
|
+
from .config import Config, Environment
|
|
16
|
+
from .exceptions import ApiError, OpenApiError, TransportError
|
|
17
|
+
from .signer import build_sign_base, sign, verify_callback
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Client",
|
|
21
|
+
"Config",
|
|
22
|
+
"Environment",
|
|
23
|
+
"sign",
|
|
24
|
+
"build_sign_base",
|
|
25
|
+
"verify_callback",
|
|
26
|
+
"ApiError",
|
|
27
|
+
"TransportError",
|
|
28
|
+
"OpenApiError",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "1.0.0"
|
openapi_sdk/client.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""商户支付 OpenAPI 客户端:覆盖全部 11 个端点。
|
|
2
|
+
|
|
3
|
+
仅用标准库:urllib.request(HTTP)、json、uuid、time、secrets。
|
|
4
|
+
|
|
5
|
+
约定:
|
|
6
|
+
- 所有请求 POST application/json,请求体 JSON,设超时。
|
|
7
|
+
- 每请求自动注入通用字段 merchant_no/api_key/timestamp/nonce。
|
|
8
|
+
- 值为 None 的字段不放入请求体、也不参与签名。
|
|
9
|
+
- pay 类接口用 api_secret_pay;payout 类接口用 api_secret_payout(各方法自动选对)。
|
|
10
|
+
- 解析统一信封 {code, message, data}:code != 0 抛 ApiError;HTTP/网络错误抛 TransportError。
|
|
11
|
+
- ``call_raw`` 暴露原始信封(code/message/data 全量),供调用方自行判断。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import secrets
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
23
|
+
|
|
24
|
+
from . import signer
|
|
25
|
+
from .config import Config, Environment
|
|
26
|
+
from .exceptions import ApiError, TransportError
|
|
27
|
+
|
|
28
|
+
__all__ = ["Client"]
|
|
29
|
+
|
|
30
|
+
_JSON_HEADERS = {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
"Accept": "application/json",
|
|
33
|
+
# 显式 User-Agent:urllib 默认 UA(Python-urllib/x.y)常被 WAF/CDN(如 Cloudflare)拦成 403。
|
|
34
|
+
"User-Agent": "openapi-sdk-python/1.0.0",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Client:
|
|
39
|
+
"""商户支付 OpenAPI 客户端。
|
|
40
|
+
|
|
41
|
+
用法::
|
|
42
|
+
|
|
43
|
+
from openapi_sdk import Client, Config, Environment
|
|
44
|
+
cfg = Config(
|
|
45
|
+
merchant_no="M00000001", api_key="ak_xxx",
|
|
46
|
+
api_secret_pay="sk_pay_xxx", api_secret_payout="sk_payout_xxx",
|
|
47
|
+
environment=Environment.SANDBOX,
|
|
48
|
+
)
|
|
49
|
+
client = Client(cfg)
|
|
50
|
+
resp = client.pay_create(out_order_no="ORD1", amount=10000,
|
|
51
|
+
currency="PHP", pay_method="gcash",
|
|
52
|
+
notify_url="https://m.example.com/cb")
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, config: Config) -> None:
|
|
56
|
+
self._config = config
|
|
57
|
+
|
|
58
|
+
# ---- 便捷构造 -----------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def for_environment(
|
|
62
|
+
cls,
|
|
63
|
+
merchant_no: str,
|
|
64
|
+
api_key: str,
|
|
65
|
+
api_secret_pay: str,
|
|
66
|
+
api_secret_payout: str,
|
|
67
|
+
environment: Environment = Environment.PRODUCTION,
|
|
68
|
+
base_url: Optional[str] = None,
|
|
69
|
+
timeout: float = 30.0,
|
|
70
|
+
) -> "Client":
|
|
71
|
+
return cls(
|
|
72
|
+
Config(
|
|
73
|
+
merchant_no=merchant_no,
|
|
74
|
+
api_key=api_key,
|
|
75
|
+
api_secret_pay=api_secret_pay,
|
|
76
|
+
api_secret_payout=api_secret_payout,
|
|
77
|
+
environment=environment,
|
|
78
|
+
base_url=base_url,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# ---- 公共属性 -----------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def config(self) -> Config:
|
|
87
|
+
return self._config
|
|
88
|
+
|
|
89
|
+
# =====================================================================
|
|
90
|
+
# 代收(Pay,密钥:api_secret_pay)
|
|
91
|
+
# =====================================================================
|
|
92
|
+
|
|
93
|
+
def pay_create(
|
|
94
|
+
self,
|
|
95
|
+
out_order_no: str,
|
|
96
|
+
amount: int,
|
|
97
|
+
currency: str,
|
|
98
|
+
pay_method: str,
|
|
99
|
+
notify_url: str,
|
|
100
|
+
country: Optional[str] = None,
|
|
101
|
+
return_url: Optional[str] = None,
|
|
102
|
+
subject: Optional[str] = None,
|
|
103
|
+
remark: Optional[str] = None,
|
|
104
|
+
client_ip: Optional[str] = None,
|
|
105
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""POST /merchant/pay/create — 代收下单。"""
|
|
108
|
+
body = {
|
|
109
|
+
"out_order_no": out_order_no,
|
|
110
|
+
"amount": amount,
|
|
111
|
+
"currency": currency,
|
|
112
|
+
"pay_method": pay_method,
|
|
113
|
+
"notify_url": notify_url,
|
|
114
|
+
"country": country,
|
|
115
|
+
"return_url": return_url,
|
|
116
|
+
"subject": subject,
|
|
117
|
+
"remark": remark,
|
|
118
|
+
"client_ip": client_ip,
|
|
119
|
+
"extra": extra,
|
|
120
|
+
}
|
|
121
|
+
return self._call_pay("/merchant/pay/create", body)
|
|
122
|
+
|
|
123
|
+
def pay_query(
|
|
124
|
+
self,
|
|
125
|
+
order_no: Optional[str] = None,
|
|
126
|
+
out_order_no: Optional[str] = None,
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
"""POST /merchant/pay/query — 代收查单(order_no / out_order_no 二选一)。"""
|
|
129
|
+
return self._call_pay(
|
|
130
|
+
"/merchant/pay/query",
|
|
131
|
+
{"order_no": order_no, "out_order_no": out_order_no},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def pay_methods_query(self, country: Optional[str] = None) -> Dict[str, Any]:
|
|
135
|
+
"""POST /merchant/pay-methods/query — 可用支付方式。"""
|
|
136
|
+
return self._call_pay("/merchant/pay-methods/query", {"country": country})
|
|
137
|
+
|
|
138
|
+
def balance_query(self, currency: Optional[str] = None) -> Dict[str, Any]:
|
|
139
|
+
"""POST /merchant/balance/query — 余额查询。"""
|
|
140
|
+
return self._call_pay("/merchant/balance/query", {"currency": currency})
|
|
141
|
+
|
|
142
|
+
def pay_test_complete(
|
|
143
|
+
self,
|
|
144
|
+
result: str,
|
|
145
|
+
order_no: Optional[str] = None,
|
|
146
|
+
out_order_no: Optional[str] = None,
|
|
147
|
+
actual_amount: Optional[int] = None,
|
|
148
|
+
) -> Dict[str, Any]:
|
|
149
|
+
"""POST /merchant/pay/test/complete — 代收测试单完成(仅测试密钥)。"""
|
|
150
|
+
return self._call_pay(
|
|
151
|
+
"/merchant/pay/test/complete",
|
|
152
|
+
{
|
|
153
|
+
"order_no": order_no,
|
|
154
|
+
"out_order_no": out_order_no,
|
|
155
|
+
"result": result,
|
|
156
|
+
"actual_amount": actual_amount,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# =====================================================================
|
|
161
|
+
# 代付(Payout,密钥:api_secret_payout)
|
|
162
|
+
# =====================================================================
|
|
163
|
+
|
|
164
|
+
def payout_create(
|
|
165
|
+
self,
|
|
166
|
+
out_payout_no: str,
|
|
167
|
+
amount: int,
|
|
168
|
+
currency: str,
|
|
169
|
+
pay_method: str,
|
|
170
|
+
notify_url: str,
|
|
171
|
+
account_no: str,
|
|
172
|
+
country: Optional[str] = None,
|
|
173
|
+
account_name: Optional[str] = None,
|
|
174
|
+
bank_code: Optional[str] = None,
|
|
175
|
+
bank_name: Optional[str] = None,
|
|
176
|
+
remark: Optional[str] = None,
|
|
177
|
+
client_ip: Optional[str] = None,
|
|
178
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
179
|
+
) -> Dict[str, Any]:
|
|
180
|
+
"""POST /merchant/payout/create — 代付下单。"""
|
|
181
|
+
body = {
|
|
182
|
+
"out_payout_no": out_payout_no,
|
|
183
|
+
"amount": amount,
|
|
184
|
+
"currency": currency,
|
|
185
|
+
"pay_method": pay_method,
|
|
186
|
+
"notify_url": notify_url,
|
|
187
|
+
"account_no": account_no,
|
|
188
|
+
"country": country,
|
|
189
|
+
"account_name": account_name,
|
|
190
|
+
"bank_code": bank_code,
|
|
191
|
+
"bank_name": bank_name,
|
|
192
|
+
"remark": remark,
|
|
193
|
+
"client_ip": client_ip,
|
|
194
|
+
"extra": extra,
|
|
195
|
+
}
|
|
196
|
+
return self._call_payout("/merchant/payout/create", body)
|
|
197
|
+
|
|
198
|
+
def payout_query(
|
|
199
|
+
self,
|
|
200
|
+
payout_no: Optional[str] = None,
|
|
201
|
+
out_payout_no: Optional[str] = None,
|
|
202
|
+
) -> Dict[str, Any]:
|
|
203
|
+
"""POST /merchant/payout/query — 代付查单(payout_no / out_payout_no 二选一)。"""
|
|
204
|
+
return self._call_payout(
|
|
205
|
+
"/merchant/payout/query",
|
|
206
|
+
{"payout_no": payout_no, "out_payout_no": out_payout_no},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def payout_banks_query(
|
|
210
|
+
self,
|
|
211
|
+
pay_method: str,
|
|
212
|
+
country: Optional[str] = None,
|
|
213
|
+
currency: Optional[str] = None,
|
|
214
|
+
) -> Dict[str, Any]:
|
|
215
|
+
"""POST /merchant/payout/banks/query — 可用银行。"""
|
|
216
|
+
return self._call_payout(
|
|
217
|
+
"/merchant/payout/banks/query",
|
|
218
|
+
{"pay_method": pay_method, "country": country, "currency": currency},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def payout_proof_query(
|
|
222
|
+
self,
|
|
223
|
+
payout_no: Optional[str] = None,
|
|
224
|
+
out_payout_no: Optional[str] = None,
|
|
225
|
+
) -> Dict[str, Any]:
|
|
226
|
+
"""POST /merchant/payout/proof/query — 代付凭证查询(仅 success 可查)。"""
|
|
227
|
+
return self._call_payout(
|
|
228
|
+
"/merchant/payout/proof/query",
|
|
229
|
+
{"payout_no": payout_no, "out_payout_no": out_payout_no},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def payout_receipt_query(
|
|
233
|
+
self,
|
|
234
|
+
payout_no: Optional[str] = None,
|
|
235
|
+
out_payout_no: Optional[str] = None,
|
|
236
|
+
lang: Optional[str] = None,
|
|
237
|
+
inline: Optional[bool] = None,
|
|
238
|
+
) -> Dict[str, Any]:
|
|
239
|
+
"""POST /merchant/payout/receipt/query — 代付收据。
|
|
240
|
+
|
|
241
|
+
``inline`` 以整数 1/0 发送(避免布尔跨语言签名歧义):
|
|
242
|
+
``True``->1 内联返回 base64 图片;``False``->0 / 省略 返回带 token 的 URL。
|
|
243
|
+
"""
|
|
244
|
+
inline_int = None if inline is None else (1 if inline else 0)
|
|
245
|
+
return self._call_payout(
|
|
246
|
+
"/merchant/payout/receipt/query",
|
|
247
|
+
{
|
|
248
|
+
"payout_no": payout_no,
|
|
249
|
+
"out_payout_no": out_payout_no,
|
|
250
|
+
"lang": lang,
|
|
251
|
+
"inline": inline_int,
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def payout_test_complete(
|
|
256
|
+
self,
|
|
257
|
+
result: str,
|
|
258
|
+
payout_no: Optional[str] = None,
|
|
259
|
+
out_payout_no: Optional[str] = None,
|
|
260
|
+
) -> Dict[str, Any]:
|
|
261
|
+
"""POST /merchant/payout/test/complete — 代付测试单完成(仅测试密钥)。"""
|
|
262
|
+
return self._call_payout(
|
|
263
|
+
"/merchant/payout/test/complete",
|
|
264
|
+
{
|
|
265
|
+
"payout_no": payout_no,
|
|
266
|
+
"out_payout_no": out_payout_no,
|
|
267
|
+
"result": result,
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# =====================================================================
|
|
272
|
+
# 内部:请求构建 / 发送 / 解析
|
|
273
|
+
# =====================================================================
|
|
274
|
+
|
|
275
|
+
def _call_pay(self, path: str, body: Mapping[str, Any]) -> Dict[str, Any]:
|
|
276
|
+
return self._call(path, body, self._config.api_secret_pay)
|
|
277
|
+
|
|
278
|
+
def _call_payout(self, path: str, body: Mapping[str, Any]) -> Dict[str, Any]:
|
|
279
|
+
return self._call(path, body, self._config.api_secret_payout)
|
|
280
|
+
|
|
281
|
+
def call_raw(
|
|
282
|
+
self, path: str, body: Mapping[str, Any], secret: str
|
|
283
|
+
) -> Dict[str, Any]:
|
|
284
|
+
"""发起请求并返回**原始统一信封**(不因 code != 0 抛 ApiError)。
|
|
285
|
+
|
|
286
|
+
仍会对 HTTP / 网络 / JSON 解析失败抛 TransportError。
|
|
287
|
+
供调用方需要自行处理 code 时使用。
|
|
288
|
+
"""
|
|
289
|
+
envelope = self._request(path, body, secret)
|
|
290
|
+
return envelope
|
|
291
|
+
|
|
292
|
+
def _call(self, path: str, body: Mapping[str, Any], secret: str) -> Dict[str, Any]:
|
|
293
|
+
envelope = self._request(path, body, secret)
|
|
294
|
+
code = envelope.get("code")
|
|
295
|
+
if code != 0:
|
|
296
|
+
raise ApiError(
|
|
297
|
+
code=code if isinstance(code, int) else -1,
|
|
298
|
+
message=str(envelope.get("message", "")),
|
|
299
|
+
data=envelope.get("data"),
|
|
300
|
+
)
|
|
301
|
+
data = envelope.get("data")
|
|
302
|
+
return data if isinstance(data, dict) else {}
|
|
303
|
+
|
|
304
|
+
def _build_payload(
|
|
305
|
+
self, body: Mapping[str, Any], secret: str
|
|
306
|
+
) -> Dict[str, Any]:
|
|
307
|
+
"""注入通用字段、剔除 None、计算签名,返回最终请求体。"""
|
|
308
|
+
payload: Dict[str, Any] = {}
|
|
309
|
+
for k, v in body.items():
|
|
310
|
+
if v is not None:
|
|
311
|
+
payload[k] = v
|
|
312
|
+
# 通用字段由 SDK 统一注入,且**始终覆盖**调用方同名字段(跨语言一致语义)。
|
|
313
|
+
payload["merchant_no"] = self._config.merchant_no
|
|
314
|
+
payload["api_key"] = self._config.api_key
|
|
315
|
+
payload["timestamp"] = int(time.time())
|
|
316
|
+
payload["nonce"] = self._gen_nonce()
|
|
317
|
+
payload["sign"] = signer.sign(payload, secret)
|
|
318
|
+
return payload
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def _gen_nonce() -> str:
|
|
322
|
+
"""每请求唯一 nonce:UUID4 + 随机 hex,碰撞概率可忽略。"""
|
|
323
|
+
return uuid.uuid4().hex + secrets.token_hex(8)
|
|
324
|
+
|
|
325
|
+
def _request(
|
|
326
|
+
self, path: str, body: Mapping[str, Any], secret: str
|
|
327
|
+
) -> Dict[str, Any]:
|
|
328
|
+
payload = self._build_payload(body, secret)
|
|
329
|
+
url = self._config.base_url + path
|
|
330
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
331
|
+
request = urllib.request.Request(
|
|
332
|
+
url, data=data, headers=dict(_JSON_HEADERS), method="POST"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
with urllib.request.urlopen(request, timeout=self._config.timeout) as resp:
|
|
337
|
+
status = resp.getcode()
|
|
338
|
+
raw = resp.read().decode("utf-8")
|
|
339
|
+
except urllib.error.HTTPError as exc: # 非 2xx
|
|
340
|
+
raw_body = None
|
|
341
|
+
try:
|
|
342
|
+
raw_body = exc.read().decode("utf-8")
|
|
343
|
+
except Exception: # noqa: BLE001 - 读取错误体本身失败时忽略
|
|
344
|
+
pass
|
|
345
|
+
raise TransportError(
|
|
346
|
+
f"HTTP {exc.code} 请求失败: {url}",
|
|
347
|
+
status_code=exc.code,
|
|
348
|
+
body=raw_body,
|
|
349
|
+
cause=exc,
|
|
350
|
+
) from exc
|
|
351
|
+
except urllib.error.URLError as exc: # DNS/连接/超时
|
|
352
|
+
raise TransportError(
|
|
353
|
+
f"网络请求失败: {url} ({exc.reason})", cause=exc
|
|
354
|
+
) from exc
|
|
355
|
+
except Exception as exc: # noqa: BLE001 - 其余 IO/超时
|
|
356
|
+
raise TransportError(f"请求异常: {url} ({exc})", cause=exc) from exc
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
envelope = json.loads(raw)
|
|
360
|
+
except json.JSONDecodeError as exc:
|
|
361
|
+
raise TransportError(
|
|
362
|
+
f"响应非合法 JSON: {url}",
|
|
363
|
+
status_code=status,
|
|
364
|
+
body=raw,
|
|
365
|
+
cause=exc,
|
|
366
|
+
) from exc
|
|
367
|
+
|
|
368
|
+
if not isinstance(envelope, dict) or "code" not in envelope:
|
|
369
|
+
raise TransportError(
|
|
370
|
+
f"响应缺少统一信封字段(code): {url}",
|
|
371
|
+
status_code=status,
|
|
372
|
+
body=raw,
|
|
373
|
+
)
|
|
374
|
+
return envelope
|
openapi_sdk/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""环境与客户端配置。
|
|
2
|
+
|
|
3
|
+
- ``Environment``:预设环境枚举(PRODUCTION / SANDBOX)。
|
|
4
|
+
- ``Config``:商户号 + API Key + 双密钥 + 基址 + 超时。
|
|
5
|
+
|
|
6
|
+
正式环境(PRODUCTION)没有内置基址:真实地址按上级代理专有域名派生
|
|
7
|
+
(``https://api.<agent_domain>/api/open/v1``),必须显式传入 ``base_url``。
|
|
8
|
+
沙箱(SANDBOX)保留本地预设基址,便于本地联调。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
__all__ = ["Environment", "Config"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Environment(Enum):
|
|
20
|
+
"""预设环境。
|
|
21
|
+
|
|
22
|
+
- ``PRODUCTION``:无内置基址(空字符串),必须显式传 ``base_url``。
|
|
23
|
+
- ``SANDBOX``:本地预设基址。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
PRODUCTION = ""
|
|
27
|
+
SANDBOX = "http://127.0.0.1:3090/api/open/v1"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def base_url(self) -> str:
|
|
31
|
+
return self.value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Config:
|
|
35
|
+
"""客户端配置。
|
|
36
|
+
|
|
37
|
+
密钥分两套:``api_secret_pay`` 用于 pay 类接口与代收/退款回调;
|
|
38
|
+
``api_secret_payout`` 用于 payout 类接口与代付回调。客户端各方法自动选对密钥。
|
|
39
|
+
|
|
40
|
+
基址优先级:显式 ``base_url`` > ``environment`` 预设。``base_url`` 末尾斜杠会被去除。
|
|
41
|
+
若最终基址为空(如选了 PRODUCTION 又未传 ``base_url``)则抛 ``ValueError``。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
merchant_no: str,
|
|
47
|
+
api_key: str,
|
|
48
|
+
api_secret_pay: str,
|
|
49
|
+
api_secret_payout: str,
|
|
50
|
+
environment: Environment = Environment.PRODUCTION,
|
|
51
|
+
base_url: Optional[str] = None,
|
|
52
|
+
timeout: float = 30.0,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not merchant_no:
|
|
55
|
+
raise ValueError("merchant_no 不能为空")
|
|
56
|
+
if not api_key:
|
|
57
|
+
raise ValueError("api_key 不能为空")
|
|
58
|
+
|
|
59
|
+
self.merchant_no = merchant_no
|
|
60
|
+
self.api_key = api_key
|
|
61
|
+
self.api_secret_pay = api_secret_pay
|
|
62
|
+
self.api_secret_payout = api_secret_payout
|
|
63
|
+
self.environment = environment
|
|
64
|
+
resolved = base_url if base_url else environment.base_url
|
|
65
|
+
if not resolved:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"baseUrl is required: production base URL is provided per your "
|
|
68
|
+
"agent domain (e.g. https://api.<agent_domain>/api/open/v1)"
|
|
69
|
+
)
|
|
70
|
+
self.base_url = resolved.rstrip("/")
|
|
71
|
+
self.timeout = timeout
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SDK 异常类型。
|
|
2
|
+
|
|
3
|
+
- ``ApiError``:业务失败(统一信封 ``code != 0``),携带 code/message/data。
|
|
4
|
+
- ``TransportError``:HTTP 状态非 2xx、网络错误、超时、响应非合法 JSON 等传输层问题。
|
|
5
|
+
|
|
6
|
+
二者均继承自 ``OpenApiError``,调用方可只捕获基类。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
__all__ = ["OpenApiError", "ApiError", "TransportError"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenApiError(Exception):
|
|
17
|
+
"""SDK 异常基类。"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiError(OpenApiError):
|
|
21
|
+
"""业务异常:统一响应信封中 ``code != 0``。
|
|
22
|
+
|
|
23
|
+
携带原始 ``code``/``message``/``data`` 供调用方判断,不在 SDK 内穷举写死分支。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, code: int, message: str, data: Any = None) -> None:
|
|
27
|
+
super().__init__(f"[{code}] {message}")
|
|
28
|
+
self.code = code
|
|
29
|
+
self.message = message
|
|
30
|
+
self.data = data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TransportError(OpenApiError):
|
|
34
|
+
"""传输异常:HTTP 非 2xx、网络错误、超时、响应不可解析为 JSON 等。"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
status_code: Optional[int] = None,
|
|
40
|
+
body: Optional[str] = None,
|
|
41
|
+
cause: Optional[BaseException] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
self.body = body
|
|
46
|
+
self.cause = cause
|
openapi_sdk/signer.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""签名器:HMAC-SHA256 -> 十六进制小写。
|
|
2
|
+
|
|
3
|
+
与服务端签名实现逐字节一致。
|
|
4
|
+
算法权威定义见 ../SIGNING.md,可复现 ../test-vectors.json 的 base 与 sign。
|
|
5
|
+
|
|
6
|
+
仅用 Python 标准库:json / hmac / hashlib。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Dict, Mapping
|
|
15
|
+
|
|
16
|
+
__all__ = ["build_sign_base", "sign", "verify_callback"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _stable_stringify(value: Any) -> str:
|
|
20
|
+
"""嵌套 object/array 的稳定 JSON 序列化,对齐 JS ``JSON.stringify`` + key 升序。
|
|
21
|
+
|
|
22
|
+
- ``ensure_ascii=False``:非 ASCII(中文/emoji)原样保留,不转 ``\\uXXXX``。
|
|
23
|
+
- ``separators=(',', ':')``:紧凑无空格。
|
|
24
|
+
- ``sort_keys=True``:对象 key 递归升序。
|
|
25
|
+
|
|
26
|
+
Python ``json.dumps`` 不转义 ``/`` 与 ``<>&``,与 JS 默认行为一致;
|
|
27
|
+
会转义 ``"`` ``\\`` 及控制字符(``\\b\\f\\n\\r\\t`` 与其余 ``\\u00XX``),亦与 JS 一致。
|
|
28
|
+
"""
|
|
29
|
+
return json.dumps(
|
|
30
|
+
value,
|
|
31
|
+
ensure_ascii=False,
|
|
32
|
+
separators=(",", ":"),
|
|
33
|
+
sort_keys=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _value_for_sign(v: Any) -> str:
|
|
38
|
+
"""单字段取值的字符串形态(不加引号)。
|
|
39
|
+
|
|
40
|
+
- object / array -> 稳定 JSON 序列化。
|
|
41
|
+
- 标量 -> 原始字符串形态。
|
|
42
|
+
|
|
43
|
+
关键坑:必须先判 ``bool`` 再判 ``int``(``bool`` 是 ``int`` 子类),
|
|
44
|
+
且布尔须归一为 ``true``/``false``(``str(True) == "True"`` 是错的)。
|
|
45
|
+
"""
|
|
46
|
+
if v is None:
|
|
47
|
+
# 调用方在 build_sign_base 已过滤 None;此处兜底,与服务端 null 一致。
|
|
48
|
+
return "null"
|
|
49
|
+
if isinstance(v, bool):
|
|
50
|
+
return "true" if v else "false"
|
|
51
|
+
if isinstance(v, (dict, list)):
|
|
52
|
+
return _stable_stringify(v)
|
|
53
|
+
# 其余标量(int / str / float),用原始字符串形态、不加引号。
|
|
54
|
+
return str(v)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_sign_base(payload: Mapping[str, Any], secret: str) -> str:
|
|
58
|
+
"""构造签名 base 字符串(不计算 HMAC,便于逐字节断言)。
|
|
59
|
+
|
|
60
|
+
步骤:
|
|
61
|
+
1. 过滤掉键名为 ``sign`` 的字段,以及值为 ``None`` 的字段。
|
|
62
|
+
2. 剩余字段按键名 ASCII(码点)升序排序。
|
|
63
|
+
3. 每个字段拼成 ``key=value``,用 ``&`` 连接。
|
|
64
|
+
4. 末尾追加 ``&secret=<secret>``。
|
|
65
|
+
"""
|
|
66
|
+
keys = sorted(k for k, v in payload.items() if k != "sign" and v is not None)
|
|
67
|
+
parts = [f"{k}={_value_for_sign(payload[k])}" for k in keys]
|
|
68
|
+
parts.append(f"secret={secret}")
|
|
69
|
+
return "&".join(parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sign(payload: Mapping[str, Any], secret: str) -> str:
|
|
73
|
+
"""对 payload 计算签名,返回十六进制小写字符串。"""
|
|
74
|
+
base = build_sign_base(payload, secret)
|
|
75
|
+
return hmac.new(
|
|
76
|
+
secret.encode("utf-8"),
|
|
77
|
+
base.encode("utf-8"),
|
|
78
|
+
hashlib.sha256,
|
|
79
|
+
).hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verify_callback(payload: Mapping[str, Any], secret: str) -> bool:
|
|
83
|
+
"""回调验签(字段无关、时序安全)。
|
|
84
|
+
|
|
85
|
+
取回调表里除 ``sign`` 外的所有字段,用本算法算出期望签名,
|
|
86
|
+
与回调携带的 ``sign`` 做时序安全比较(``hmac.compare_digest``)。
|
|
87
|
+
|
|
88
|
+
代收/退款回调用 ``api_secret_pay``;代付回调用 ``api_secret_payout``。
|
|
89
|
+
"""
|
|
90
|
+
provided = payload.get("sign")
|
|
91
|
+
if not isinstance(provided, str) or not provided:
|
|
92
|
+
return False
|
|
93
|
+
# build_sign_base 自身已排除 sign 字段,无需事先剔除。
|
|
94
|
+
expected = sign(payload, secret)
|
|
95
|
+
return hmac.compare_digest(expected, provided)
|