openhub-fee-sdk 0.1.0__tar.gz
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.
- openhub_fee_sdk-0.1.0/.gitignore +5 -0
- openhub_fee_sdk-0.1.0/PKG-INFO +101 -0
- openhub_fee_sdk-0.1.0/README.md +92 -0
- openhub_fee_sdk-0.1.0/api.md +291 -0
- openhub_fee_sdk-0.1.0/openhub_fee/__init__.py +39 -0
- openhub_fee_sdk-0.1.0/openhub_fee/client.py +317 -0
- openhub_fee_sdk-0.1.0/openhub_fee_sdk.egg-info/PKG-INFO +101 -0
- openhub_fee_sdk-0.1.0/openhub_fee_sdk.egg-info/SOURCES.txt +12 -0
- openhub_fee_sdk-0.1.0/openhub_fee_sdk.egg-info/dependency_links.txt +1 -0
- openhub_fee_sdk-0.1.0/openhub_fee_sdk.egg-info/requires.txt +1 -0
- openhub_fee_sdk-0.1.0/openhub_fee_sdk.egg-info/scm_file_list.json +10 -0
- openhub_fee_sdk-0.1.0/openhub_fee_sdk.egg-info/top_level.txt +1 -0
- openhub_fee_sdk-0.1.0/pyproject.toml +25 -0
- openhub_fee_sdk-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openhub-fee-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: openhub 费用接口 Python 客户端(/api/fee),基于 requests 封装 HMAC-SHA256 签名
|
|
5
|
+
Author: staugur
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.20
|
|
9
|
+
|
|
10
|
+
# openhub-fee-sdk
|
|
11
|
+
|
|
12
|
+
openhub 费用接口的 Python 客户端,基于 `requests` 封装 `/api/fee` 的全部接口,
|
|
13
|
+
统一处理 HMAC-SHA256 请求签名、JSON 序列化与错误判定。鉴权、字段与错误码见仓库
|
|
14
|
+
根目录 `arch/fee.md` 第 5 节。
|
|
15
|
+
|
|
16
|
+
> 注意:与 pcs 的费用接口不同,openhub **不开放直接入账**(`recharge`),资金只能经
|
|
17
|
+
> 管理员审核进入账本;SDK 因此不提供 `recharge`,转而提供**半自动收款**的订单类接口
|
|
18
|
+
> (建单 / 上传凭证 / 查单 / 取消)。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 方式一:从 PyPI 安装(推荐)
|
|
24
|
+
pip install openhub-fee-sdk
|
|
25
|
+
|
|
26
|
+
# 方式二:本地开发(可编辑安装)
|
|
27
|
+
cd sdk/fee
|
|
28
|
+
pip install -e .
|
|
29
|
+
|
|
30
|
+
# 方式三:直接安装 GitHub 仓库中的 sdk/fee 子目录
|
|
31
|
+
pip install "git+https://github.com/staugur/open-hub.git#subdirectory=sdk/fee"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 版本号与发布
|
|
35
|
+
|
|
36
|
+
版本号由 `setuptools_scm` 从 git 自动派生,**无需手改** `openhub_fee/__init__.py` 里的
|
|
37
|
+
`__version__`:
|
|
38
|
+
|
|
39
|
+
- 打了 `v*` tag(如 `v0.1.0`)并推送 → 正式版 `0.1.0`;
|
|
40
|
+
- 推送改动落到 `sdk/fee/` 目录(仅 `master` / `dev` 分支)→ 自动发布开发版;
|
|
41
|
+
- 在 Actions 页手动 Run workflow → 开发版 `0.1.0.dev<时间戳>`(精确到秒,天然唯一、
|
|
42
|
+
可直发 PyPI)。
|
|
43
|
+
|
|
44
|
+
> 仅 `sdk/fee/` 目录的改动会触发自动发布;改 `src/`、`arch/`、`README.md`
|
|
45
|
+
> 等其他目录(含 `sdk/` 下的其他项目,如 `sdk/ipamcli`)不会触发,避免无关改动误发版本。
|
|
46
|
+
|
|
47
|
+
发布由仓库的 GitHub Action(`.github/workflows/publish-sdk.yml`)完成:
|
|
48
|
+
|
|
49
|
+
- 推送 `v*` tag → 自动发布正式版到 PyPI;
|
|
50
|
+
- 推送改动落到 `sdk/fee/**`(仅 `master` / `dev`)→ 自动发布开发版;
|
|
51
|
+
- 在 Actions 页手动 Run workflow → 快速发布开发版(无需打 tag)。
|
|
52
|
+
|
|
53
|
+
> 采用 OIDC trusted publishing(免密钥):首次发布前需在 PyPI 的 `openhub-fee-sdk`
|
|
54
|
+
> 项目配置 Trusted Publisher(Owner=`staugur`、Repository name=`open-hub`、
|
|
55
|
+
> Workflow name=`publish-sdk.yml`、Environment name 留空),无需配置 `PYPI_API_TOKEN`。
|
|
56
|
+
|
|
57
|
+
## 快速上手
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from openhub_fee import FeeClient
|
|
61
|
+
|
|
62
|
+
client = FeeClient("https://openhub.example.com", app_id="<APP_ID>", secret="<SECRET>")
|
|
63
|
+
|
|
64
|
+
# 费用:查询余额(记录不存在返回 None)
|
|
65
|
+
balance = client.fee_balance(uid="u1", module="sec")
|
|
66
|
+
|
|
67
|
+
# 费用:查询全部模块状态 / 流水 / 付费模块
|
|
68
|
+
client.fee_balances(uid="u1")
|
|
69
|
+
client.fee_logs(uid="u1", module="sec", limit=50)
|
|
70
|
+
client.fee_modules()
|
|
71
|
+
|
|
72
|
+
# 费用:按量扣费(仅 postpaid;biz_no 务必幂等——重试复用原值,新动作换新值)
|
|
73
|
+
client.fee_consume(uid="u1", module="sec", amount=30, biz_no="sec-usage-001")
|
|
74
|
+
|
|
75
|
+
# 半自动收款:建单 -> 上传凭证 -> 查单 / 取消
|
|
76
|
+
order = client.fee_order_create(uid="u1", module="sec", pay_type="prepaid", level="pro")
|
|
77
|
+
client.fee_order_upload_voucher(order["order_no"], "transfer.png")
|
|
78
|
+
client.fee_order_get(order["order_no"])
|
|
79
|
+
client.fee_order_cancel(order["order_no"])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 错误处理
|
|
83
|
+
|
|
84
|
+
接口返回 `success=false` 或 HTTP 错误时抛出 `openhub_fee.FeeApiError`,可读取
|
|
85
|
+
`message` 与 `status_code`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from openhub_fee import FeeClient, FeeApiError
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
client.fee_consume(uid="u1", module="sec", amount=999, biz_no="x-1")
|
|
92
|
+
except FeeApiError as e:
|
|
93
|
+
print(e.status_code, e.message)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> openhub 的业务错误响应目前不含 `code` 字段,故 `FeeApiError.code` 恒为 `None`。
|
|
97
|
+
|
|
98
|
+
## 复用连接
|
|
99
|
+
|
|
100
|
+
`FeeClient` 默认内部持有 `requests.Session`(连接池复用)。需要自定义会话
|
|
101
|
+
(代理、证书、重试策略)时传入 `session=` 即可。
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# openhub-fee-sdk
|
|
2
|
+
|
|
3
|
+
openhub 费用接口的 Python 客户端,基于 `requests` 封装 `/api/fee` 的全部接口,
|
|
4
|
+
统一处理 HMAC-SHA256 请求签名、JSON 序列化与错误判定。鉴权、字段与错误码见仓库
|
|
5
|
+
根目录 `arch/fee.md` 第 5 节。
|
|
6
|
+
|
|
7
|
+
> 注意:与 pcs 的费用接口不同,openhub **不开放直接入账**(`recharge`),资金只能经
|
|
8
|
+
> 管理员审核进入账本;SDK 因此不提供 `recharge`,转而提供**半自动收款**的订单类接口
|
|
9
|
+
> (建单 / 上传凭证 / 查单 / 取消)。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 方式一:从 PyPI 安装(推荐)
|
|
15
|
+
pip install openhub-fee-sdk
|
|
16
|
+
|
|
17
|
+
# 方式二:本地开发(可编辑安装)
|
|
18
|
+
cd sdk/fee
|
|
19
|
+
pip install -e .
|
|
20
|
+
|
|
21
|
+
# 方式三:直接安装 GitHub 仓库中的 sdk/fee 子目录
|
|
22
|
+
pip install "git+https://github.com/staugur/open-hub.git#subdirectory=sdk/fee"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 版本号与发布
|
|
26
|
+
|
|
27
|
+
版本号由 `setuptools_scm` 从 git 自动派生,**无需手改** `openhub_fee/__init__.py` 里的
|
|
28
|
+
`__version__`:
|
|
29
|
+
|
|
30
|
+
- 打了 `v*` tag(如 `v0.1.0`)并推送 → 正式版 `0.1.0`;
|
|
31
|
+
- 推送改动落到 `sdk/fee/` 目录(仅 `master` / `dev` 分支)→ 自动发布开发版;
|
|
32
|
+
- 在 Actions 页手动 Run workflow → 开发版 `0.1.0.dev<时间戳>`(精确到秒,天然唯一、
|
|
33
|
+
可直发 PyPI)。
|
|
34
|
+
|
|
35
|
+
> 仅 `sdk/fee/` 目录的改动会触发自动发布;改 `src/`、`arch/`、`README.md`
|
|
36
|
+
> 等其他目录(含 `sdk/` 下的其他项目,如 `sdk/ipamcli`)不会触发,避免无关改动误发版本。
|
|
37
|
+
|
|
38
|
+
发布由仓库的 GitHub Action(`.github/workflows/publish-sdk.yml`)完成:
|
|
39
|
+
|
|
40
|
+
- 推送 `v*` tag → 自动发布正式版到 PyPI;
|
|
41
|
+
- 推送改动落到 `sdk/fee/**`(仅 `master` / `dev`)→ 自动发布开发版;
|
|
42
|
+
- 在 Actions 页手动 Run workflow → 快速发布开发版(无需打 tag)。
|
|
43
|
+
|
|
44
|
+
> 采用 OIDC trusted publishing(免密钥):首次发布前需在 PyPI 的 `openhub-fee-sdk`
|
|
45
|
+
> 项目配置 Trusted Publisher(Owner=`staugur`、Repository name=`open-hub`、
|
|
46
|
+
> Workflow name=`publish-sdk.yml`、Environment name 留空),无需配置 `PYPI_API_TOKEN`。
|
|
47
|
+
|
|
48
|
+
## 快速上手
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from openhub_fee import FeeClient
|
|
52
|
+
|
|
53
|
+
client = FeeClient("https://openhub.example.com", app_id="<APP_ID>", secret="<SECRET>")
|
|
54
|
+
|
|
55
|
+
# 费用:查询余额(记录不存在返回 None)
|
|
56
|
+
balance = client.fee_balance(uid="u1", module="sec")
|
|
57
|
+
|
|
58
|
+
# 费用:查询全部模块状态 / 流水 / 付费模块
|
|
59
|
+
client.fee_balances(uid="u1")
|
|
60
|
+
client.fee_logs(uid="u1", module="sec", limit=50)
|
|
61
|
+
client.fee_modules()
|
|
62
|
+
|
|
63
|
+
# 费用:按量扣费(仅 postpaid;biz_no 务必幂等——重试复用原值,新动作换新值)
|
|
64
|
+
client.fee_consume(uid="u1", module="sec", amount=30, biz_no="sec-usage-001")
|
|
65
|
+
|
|
66
|
+
# 半自动收款:建单 -> 上传凭证 -> 查单 / 取消
|
|
67
|
+
order = client.fee_order_create(uid="u1", module="sec", pay_type="prepaid", level="pro")
|
|
68
|
+
client.fee_order_upload_voucher(order["order_no"], "transfer.png")
|
|
69
|
+
client.fee_order_get(order["order_no"])
|
|
70
|
+
client.fee_order_cancel(order["order_no"])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 错误处理
|
|
74
|
+
|
|
75
|
+
接口返回 `success=false` 或 HTTP 错误时抛出 `openhub_fee.FeeApiError`,可读取
|
|
76
|
+
`message` 与 `status_code`:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from openhub_fee import FeeClient, FeeApiError
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
client.fee_consume(uid="u1", module="sec", amount=999, biz_no="x-1")
|
|
83
|
+
except FeeApiError as e:
|
|
84
|
+
print(e.status_code, e.message)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
> openhub 的业务错误响应目前不含 `code` 字段,故 `FeeApiError.code` 恒为 `None`。
|
|
88
|
+
|
|
89
|
+
## 复用连接
|
|
90
|
+
|
|
91
|
+
`FeeClient` 默认内部持有 `requests.Session`(连接池复用)。需要自定义会话
|
|
92
|
+
(代理、证书、重试策略)时传入 `session=` 即可。
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# openhub-fee-sdk API 参考
|
|
2
|
+
|
|
3
|
+
`openhub-fee-sdk` 是 openhub 费用接口(`/api/fee`)的 Python 客户端,基于
|
|
4
|
+
`requests` 封装,统一处理 HMAC-SHA256 请求签名、JSON 序列化与错误判定。
|
|
5
|
+
鉴权、字段与错误码详见仓库根目录 `arch/fee.md` 第 5 节。
|
|
6
|
+
|
|
7
|
+
> 与 pcs 的费用接口不同,openhub **不开放直接入账**(`recharge`),资金只能经
|
|
8
|
+
> 管理员审核进入账本;SDK 因此不提供 `recharge`,转而提供**半自动收款**的订单类
|
|
9
|
+
> 接口(建单 / 上传凭证 / 查单 / 取消)。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install openhub-fee-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
本地开发:`cd sdk/fee && pip install -e .`
|
|
18
|
+
|
|
19
|
+
## 客户端
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from openhub_fee import FeeClient
|
|
23
|
+
|
|
24
|
+
client = FeeClient(
|
|
25
|
+
base_url="https://openhub.example.com",
|
|
26
|
+
app_id="<APP_ID>",
|
|
27
|
+
secret="<SECRET>",
|
|
28
|
+
timeout=10.0, # 请求超时(秒)
|
|
29
|
+
session=None, # 可复用的 requests.Session;缺省自动创建
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| 参数 | 类型 | 说明 |
|
|
34
|
+
|------|------|------|
|
|
35
|
+
| `base_url` | str | API 根地址,如 `https://openhub.example.com` |
|
|
36
|
+
| `app_id` | str | 调用方标识(对应 `hub_fee_app.app_id`),随 `X-App-Id` 头传输 |
|
|
37
|
+
| `secret` | str | 调用方密钥(与服务端一致),**仅本地签名用,不随请求传输** |
|
|
38
|
+
| `timeout` | float | 请求超时(秒),默认 `10.0` |
|
|
39
|
+
| `session` | Optional[Session] | 自定义 `requests.Session`(代理 / 证书 / 重试策略),缺省自动创建 |
|
|
40
|
+
|
|
41
|
+
每次请求自动附加签名头:`X-App-Id`、`X-Timestamp`、`X-Nonce`、`X-Signature`、
|
|
42
|
+
`Accept: application/json`。签名串 = `时间戳 + nonce`,用 `secret` 做 HMAC-SHA256
|
|
43
|
+
得到十六进制签名值,每次请求重新生成时间戳与随机 nonce,防重放。
|
|
44
|
+
|
|
45
|
+
## 错误处理
|
|
46
|
+
|
|
47
|
+
接口返回 `success=false` 或 HTTP 错误时抛出 `FeeApiError`,可读取以下属性:
|
|
48
|
+
|
|
49
|
+
| 属性 | 类型 | 说明 |
|
|
50
|
+
|------|------|------|
|
|
51
|
+
| `message` | str | 错误描述 |
|
|
52
|
+
| `status_code` | Optional[int] | HTTP 状态码 |
|
|
53
|
+
| `code` | Optional[str] | 业务错误码(openhub 响应目前不含此字段,恒为 `None`) |
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from openhub_fee import FeeClient, FeeApiError
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
client.fee_consume(uid="u1", module="sec", amount=999, biz_no="x-1")
|
|
60
|
+
except FeeApiError as e:
|
|
61
|
+
print(e.status_code, e.message)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 接口总览
|
|
65
|
+
|
|
66
|
+
| 方法 | HTTP | 路径 | 分类 |
|
|
67
|
+
|------|------|------|------|
|
|
68
|
+
| `fee_balance` | GET | `/api/fee/balance` | 查询 |
|
|
69
|
+
| `fee_balances` | GET | `/api/fee/balances` | 查询 |
|
|
70
|
+
| `fee_logs` | GET | `/api/fee/logs` | 查询 |
|
|
71
|
+
| `fee_modules` | GET | `/api/fee/modules` | 查询 |
|
|
72
|
+
| `fee_consume` | POST | `/api/fee/consume` | 扣费 |
|
|
73
|
+
| `fee_order_create` | POST | `/api/fee/order` | 订单 |
|
|
74
|
+
| `fee_order_get` | GET | `/api/fee/order` | 订单 |
|
|
75
|
+
| `fee_order_upload_voucher` | POST | `/api/fee/order/voucher` | 订单 |
|
|
76
|
+
| `fee_order_cancel` | POST | `/api/fee/order/cancel` | 订单 |
|
|
77
|
+
|
|
78
|
+
所有方法在业务成功时返回响应体中的 `data` 字段;失败抛出 `FeeApiError`。
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 查询类
|
|
83
|
+
|
|
84
|
+
### `fee_balance(uid, module) -> Optional[Dict[str, Any]]`
|
|
85
|
+
|
|
86
|
+
查询某用户的指定模块费用状态(余额 / 生效 / 到期 / 档位)。
|
|
87
|
+
|
|
88
|
+
- **HTTP**:`GET /api/fee/balance`
|
|
89
|
+
- **查询参数**:
|
|
90
|
+
|
|
91
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
92
|
+
|------|------|------|------|
|
|
93
|
+
| `uid` | str | 是 | 用户唯一标识 |
|
|
94
|
+
| `module` | str | 是 | 付费模块标识 |
|
|
95
|
+
|
|
96
|
+
- **返回**:费用状态对象(dict);记录不存在时返回 `None`
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
balance = client.fee_balance(uid="u1", module="sec")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `fee_balances(uid, limit=100, offset=0) -> List[Dict[str, Any]]`
|
|
103
|
+
|
|
104
|
+
查询用户全部模块的费用状态(仅返回本调用方拥有的模块)。
|
|
105
|
+
|
|
106
|
+
- **HTTP**:`GET /api/fee/balances`
|
|
107
|
+
- **查询参数**:
|
|
108
|
+
|
|
109
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
110
|
+
|------|------|------|------|
|
|
111
|
+
| `uid` | str | 是 | 用户唯一标识 |
|
|
112
|
+
| `limit` | int | 否 | 返回条数上限,默认 `100` |
|
|
113
|
+
| `offset` | int | 否 | 偏移量,配合 `limit` 分页,默认 `0` |
|
|
114
|
+
|
|
115
|
+
- **返回**:费用状态对象列表
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
client.fee_balances(uid="u1")
|
|
119
|
+
client.fee_balances(uid="u1", limit=20, offset=0)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `fee_logs(uid, module=None, limit=50, offset=0) -> List[Dict[str, Any]]`
|
|
123
|
+
|
|
124
|
+
查询费用流水(充值 / 消费记录,倒序)。
|
|
125
|
+
|
|
126
|
+
- **HTTP**:`GET /api/fee/logs`
|
|
127
|
+
- **查询参数**:
|
|
128
|
+
|
|
129
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
130
|
+
|------|------|------|------|
|
|
131
|
+
| `uid` | str | 是 | 用户唯一标识 |
|
|
132
|
+
| `module` | str | 否 | 模块过滤,为空返回全部模块 |
|
|
133
|
+
| `limit` | int | 否 | 返回条数上限(封顶 `200`),默认 `50` |
|
|
134
|
+
| `offset` | int | 否 | 偏移量,配合 `limit` 分页,默认 `0` |
|
|
135
|
+
|
|
136
|
+
- **返回**:流水记录列表
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
client.fee_logs(uid="u1", module="sec", limit=50)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `fee_modules(module=None) -> List[Dict[str, Any]]`
|
|
143
|
+
|
|
144
|
+
查询本调用方拥有的付费模块(含付费类型、档位与档位费用)。
|
|
145
|
+
|
|
146
|
+
- **HTTP**:`GET /api/fee/modules`
|
|
147
|
+
- **查询参数**:
|
|
148
|
+
|
|
149
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
150
|
+
|------|------|------|------|
|
|
151
|
+
| `module` | str | 否 | 模块过滤,为空返回全部 |
|
|
152
|
+
|
|
153
|
+
- **返回**:付费模块元数据列表
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
client.fee_modules()
|
|
157
|
+
client.fee_modules(module="sec")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 写类:按量扣费
|
|
163
|
+
|
|
164
|
+
### `fee_consume(uid, module, amount, *, biz_no, remark="") -> Dict[str, Any]`
|
|
165
|
+
|
|
166
|
+
消费扣减(仅按量付费 `postpaid` 模块,扣减 `balance`)。
|
|
167
|
+
|
|
168
|
+
- **HTTP**:`POST /api/fee/consume`(JSON 请求体)
|
|
169
|
+
- **请求体字段**:
|
|
170
|
+
|
|
171
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
172
|
+
|------|------|------|------|
|
|
173
|
+
| `uid` | str | 是 | 用户唯一标识 |
|
|
174
|
+
| `module` | str | 是 | 付费模块标识 |
|
|
175
|
+
| `amount` | float \| str | 是 | 扣减金额(元),须 ≤ 当前余额 |
|
|
176
|
+
| `biz_no` | str | 是 | **幂等键**:重试须复用原值,新动作须换新值 |
|
|
177
|
+
| `remark` | str | 否 | 备注 |
|
|
178
|
+
|
|
179
|
+
- **返回**:扣减结果(含扣减后余额等字段)
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
client.fee_consume(
|
|
183
|
+
uid="u1", module="sec", amount=30,
|
|
184
|
+
biz_no="sec-usage-001", remark="收录查询批量",
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 半自动收款:订单类接口
|
|
191
|
+
|
|
192
|
+
### `fee_order_create(uid, module, pay_type, level="", duration_days=0, amount=0, remark="") -> Dict[str, Any]`
|
|
193
|
+
|
|
194
|
+
代用户创建支付订单,返回订单号与收款指引。
|
|
195
|
+
|
|
196
|
+
- **HTTP**:`POST /api/fee/order`(JSON 请求体)
|
|
197
|
+
- **请求体字段**:
|
|
198
|
+
|
|
199
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
200
|
+
|------|------|------|------|
|
|
201
|
+
| `uid` | str | 是 | 用户唯一标识 |
|
|
202
|
+
| `module` | str | 是 | 付费模块标识(须属本调用方) |
|
|
203
|
+
| `pay_type` | str | 是 | 付费类型:`prepaid` / `postpaid` / `lifetime` |
|
|
204
|
+
| `level` | str | 否 | 付费档位(`prepaid` / `lifetime` 使用,可空) |
|
|
205
|
+
| `duration_days` | int | 否 | 周期天数(`prepaid` / `lifetime` 使用) |
|
|
206
|
+
| `amount` | float \| str | 否 | 金额(`postpaid` 由用户填写;其它类型取档位价) |
|
|
207
|
+
| `remark` | str | 否 | 备注 |
|
|
208
|
+
|
|
209
|
+
- **返回**:订单对象(含 `order_no` 与收款指引)
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
order = client.fee_order_create(
|
|
213
|
+
uid="u1", module="sec", pay_type="prepaid",
|
|
214
|
+
level="pro", duration_days=365,
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### `fee_order_get(order_no) -> Dict[str, Any]`
|
|
219
|
+
|
|
220
|
+
按订单号查询订单状态(含凭证列表)。
|
|
221
|
+
|
|
222
|
+
- **HTTP**:`GET /api/fee/order`
|
|
223
|
+
- **查询参数**:
|
|
224
|
+
|
|
225
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
226
|
+
|------|------|------|------|
|
|
227
|
+
| `order_no` | str | 是 | 订单号(付款标识码) |
|
|
228
|
+
|
|
229
|
+
- **返回**:订单对象(含 `vouchers` 列表)
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
client.fee_order_get(order["order_no"])
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### `fee_order_upload_voucher(order_no, file, filename=None) -> Dict[str, Any]`
|
|
236
|
+
|
|
237
|
+
上传支付凭证(multipart,文件域 `file`)。
|
|
238
|
+
|
|
239
|
+
- **HTTP**:`POST /api/fee/order/voucher`(multipart/form-data)
|
|
240
|
+
- **查询参数**:`order_no`(str,订单号)
|
|
241
|
+
- **文件参数**:
|
|
242
|
+
|
|
243
|
+
| 参数 | 类型 | 说明 |
|
|
244
|
+
|------|------|------|
|
|
245
|
+
| `file` | str \| bytes \| bytearray \| BytesIO | 本地路径、原始字节或类文件对象(需可 `read()`) |
|
|
246
|
+
| `filename` | Optional[str] | 文件名;传路径时自动取 basename,传字节时默认 `voucher.png` |
|
|
247
|
+
|
|
248
|
+
- **返回**:上传结果
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
client.fee_order_upload_voucher(order["order_no"], "transfer.png")
|
|
252
|
+
# 或传入字节 / 类文件对象:
|
|
253
|
+
client.fee_order_upload_voucher(order["order_no"], b"<bytes>", filename="shot.png")
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### `fee_order_cancel(order_no) -> Dict[str, Any]`
|
|
257
|
+
|
|
258
|
+
取消未支付订单。
|
|
259
|
+
|
|
260
|
+
- **HTTP**:`POST /api/fee/order/cancel`(JSON 请求体)
|
|
261
|
+
- **请求体字段**:`order_no`(str,订单号)
|
|
262
|
+
- **返回**:取消结果
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
client.fee_order_cancel(order["order_no"])
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## 完整示例
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
from openhub_fee import FeeClient
|
|
274
|
+
|
|
275
|
+
client = FeeClient("https://openhub.example.com", app_id="<APP_ID>", secret="<SECRET>")
|
|
276
|
+
|
|
277
|
+
# 查询类
|
|
278
|
+
balance = client.fee_balance(uid="u1", module="sec")
|
|
279
|
+
client.fee_balances(uid="u1")
|
|
280
|
+
client.fee_logs(uid="u1", module="sec", limit=50)
|
|
281
|
+
client.fee_modules()
|
|
282
|
+
|
|
283
|
+
# 按量扣费(biz_no 务必幂等)
|
|
284
|
+
client.fee_consume(uid="u1", module="sec", amount=30, biz_no="sec-usage-001")
|
|
285
|
+
|
|
286
|
+
# 半自动收款:建单 -> 上传凭证 -> 查单 / 取消
|
|
287
|
+
order = client.fee_order_create(uid="u1", module="sec", pay_type="prepaid", level="pro")
|
|
288
|
+
client.fee_order_upload_voucher(order["order_no"], "transfer.png")
|
|
289
|
+
client.fee_order_get(order["order_no"])
|
|
290
|
+
client.fee_order_cancel(order["order_no"])
|
|
291
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""openhub 费用接口客户端 SDK。
|
|
2
|
+
|
|
3
|
+
基于 ``requests`` 封装 openhub 的 ``/api/fee`` 费用接口,统一处理 HMAC-SHA256
|
|
4
|
+
请求签名、JSON 序列化与错误判定。接口字段、鉴权与错误码见仓库 ``arch/fee.md``。
|
|
5
|
+
|
|
6
|
+
典型用法::
|
|
7
|
+
|
|
8
|
+
from openhub_fee import FeeClient
|
|
9
|
+
|
|
10
|
+
client = FeeClient("https://openhub.example.com", app_id="<APP_ID>", secret="<SECRET>")
|
|
11
|
+
|
|
12
|
+
# 查询余额(记录不存在返回 None)
|
|
13
|
+
balance = client.fee_balance(uid="u1", module="sec")
|
|
14
|
+
|
|
15
|
+
# 查询全部模块状态 / 流水 / 付费模块
|
|
16
|
+
client.fee_balances(uid="u1")
|
|
17
|
+
client.fee_logs(uid="u1", module="sec", limit=50)
|
|
18
|
+
client.fee_modules()
|
|
19
|
+
|
|
20
|
+
# 按量扣费(仅 postpaid,biz_no 务必幂等——重试复用原值,新动作换新值)
|
|
21
|
+
client.fee_consume(uid="u1", module="sec", amount=30, biz_no="sec-usage-001")
|
|
22
|
+
|
|
23
|
+
# 半自动收款:建单 -> 上传凭证 -> 查询 / 取消
|
|
24
|
+
order = client.fee_order_create(uid="u1", module="sec", pay_type="prepaid", level="pro")
|
|
25
|
+
client.fee_order_upload_voucher(order["order_no"], "transfer.png")
|
|
26
|
+
client.fee_order_get(order["order_no"])
|
|
27
|
+
client.fee_order_cancel(order["order_no"])
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from .client import FeeApiError, FeeClient
|
|
31
|
+
|
|
32
|
+
__all__ = ["FeeClient", "FeeApiError"]
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from importlib.metadata import version as _pkg_version
|
|
36
|
+
|
|
37
|
+
__version__ = _pkg_version("openhub-fee-sdk")
|
|
38
|
+
except Exception: # pragma: no cover - 非安装态(如直接跑源码)回退
|
|
39
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""openhub 费用接口客户端实现。
|
|
2
|
+
|
|
3
|
+
安全说明
|
|
4
|
+
--------
|
|
5
|
+
``secret`` 只在客户端本地参与 HMAC-SHA256 签名计算,**绝不**随请求发往服务端;
|
|
6
|
+
网络层只传输签名值 ``X-Signature`` 与时间戳 / nonce,因此密钥本身不会泄露。
|
|
7
|
+
服务端使用相同的 ``secret`` 独立验签,实现调用方鉴权(详见仓库 ``arch/fee.md``)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import io
|
|
15
|
+
import time
|
|
16
|
+
from secrets import token_hex
|
|
17
|
+
from typing import Any, Dict, List, Optional, Union
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import requests
|
|
21
|
+
except ImportError as exc: # pragma: no cover
|
|
22
|
+
raise ImportError("openhub-fee-sdk 需要 requests:pip install requests") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FeeApiError(Exception):
|
|
26
|
+
"""API 调用失败(HTTP 错误或非 success 响应)。"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
code: Optional[str] = None,
|
|
32
|
+
status_code: Optional[int] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""构造 API 错误。
|
|
35
|
+
|
|
36
|
+
:param message: 错误描述
|
|
37
|
+
:param code: 业务错误码(openhub 响应目前不含此字段,恒为 ``None``)
|
|
38
|
+
:param status_code: HTTP 状态码,可选
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.message = message
|
|
42
|
+
self.code = code
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
if self.code:
|
|
47
|
+
return "[{}] {}".format(self.code, self.message)
|
|
48
|
+
return self.message
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FeeClient:
|
|
52
|
+
"""openhub 费用接口 HTTP 客户端(``/api/fee``)。"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
base_url: str,
|
|
57
|
+
app_id: str,
|
|
58
|
+
secret: str,
|
|
59
|
+
timeout: float = 10.0,
|
|
60
|
+
session: Optional["requests.Session"] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""
|
|
63
|
+
:param base_url: API 根地址,如 ``https://openhub.example.com``
|
|
64
|
+
:param app_id: 调用方标识(对应 ``hub_fee_app.app_id``,随 ``X-App-Id`` 头传输)
|
|
65
|
+
:param secret: 调用方密钥(与服务端 ``secret`` 一致,仅本地签名用,不随请求传输)
|
|
66
|
+
:param timeout: 请求超时(秒)
|
|
67
|
+
:param session: 可复用的 ``requests.Session``;缺省自动创建
|
|
68
|
+
"""
|
|
69
|
+
self._base_url = base_url.rstrip("/")
|
|
70
|
+
self._app_id = app_id
|
|
71
|
+
self._secret = secret
|
|
72
|
+
self._timeout = timeout
|
|
73
|
+
self._session = session or requests.Session()
|
|
74
|
+
|
|
75
|
+
# -- 签名与底层请求 ----------------------------------------------------
|
|
76
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
77
|
+
# 每次请求都重新生成时间戳与随机 nonce,保证签名不可重放
|
|
78
|
+
ts = str(int(time.time()))
|
|
79
|
+
nonce = token_hex(8)
|
|
80
|
+
# 待签名字符串 = 时间戳与 nonce 直接拼接(如 "1700000000a1b2c3d4"),
|
|
81
|
+
# 用 secret 做 HMAC-SHA256 得到十六进制签名值(与服务端验签算法一致)
|
|
82
|
+
sign = hmac.new(
|
|
83
|
+
self._secret.encode("utf-8"),
|
|
84
|
+
(ts + nonce).encode("utf-8"),
|
|
85
|
+
hashlib.sha256,
|
|
86
|
+
).hexdigest()
|
|
87
|
+
return {
|
|
88
|
+
"X-App-Id": self._app_id,
|
|
89
|
+
"X-Timestamp": ts,
|
|
90
|
+
"X-Nonce": nonce,
|
|
91
|
+
"X-Signature": sign,
|
|
92
|
+
"Accept": "application/json",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def _request(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
path: str,
|
|
99
|
+
*,
|
|
100
|
+
params: Optional[Dict[str, Any]] = None,
|
|
101
|
+
json: Optional[Dict[str, Any]] = None,
|
|
102
|
+
files: Optional[Dict[str, Any]] = None,
|
|
103
|
+
) -> Any:
|
|
104
|
+
"""发起一次签名鉴权的 HTTP 请求并统一解析业务响应。
|
|
105
|
+
|
|
106
|
+
:param method: HTTP 方法(``GET`` / ``POST`` ...)
|
|
107
|
+
:param path: 接口路径,如 ``/api/fee/balance``
|
|
108
|
+
:param params: 查询参数(GET)
|
|
109
|
+
:param json: 请求体(POST),以 JSON 序列化
|
|
110
|
+
:param files: 多部分文件(POST 上传凭证用),文件域 ``file``
|
|
111
|
+
:return: 业务成功时返回响应体中的 ``data`` 字段
|
|
112
|
+
:rtype: Any
|
|
113
|
+
:raises FeeApiError: HTTP 错误(>= 400)或业务层 ``success is False`` 时抛出
|
|
114
|
+
"""
|
|
115
|
+
resp = self._session.request(
|
|
116
|
+
method,
|
|
117
|
+
self._base_url + path,
|
|
118
|
+
params=params,
|
|
119
|
+
json=json,
|
|
120
|
+
files=files,
|
|
121
|
+
headers=self._auth_headers(),
|
|
122
|
+
timeout=self._timeout,
|
|
123
|
+
)
|
|
124
|
+
# 统一解析 JSON:非 JSON 且为错误状态码时直接以原始文本报错
|
|
125
|
+
try:
|
|
126
|
+
body = resp.json()
|
|
127
|
+
except ValueError:
|
|
128
|
+
if resp.status_code >= 400:
|
|
129
|
+
raise FeeApiError(
|
|
130
|
+
resp.text or "HTTP {}".format(resp.status_code),
|
|
131
|
+
status_code=resp.status_code,
|
|
132
|
+
) from None
|
|
133
|
+
raise FeeApiError("invalid json response") from None
|
|
134
|
+
# 业务层约定:HTTP >= 400 或 body.success is False 都视为失败
|
|
135
|
+
if resp.status_code >= 400 or body.get("success") is False:
|
|
136
|
+
raise FeeApiError(
|
|
137
|
+
body.get("message") or "request failed",
|
|
138
|
+
code=body.get("code"),
|
|
139
|
+
status_code=resp.status_code,
|
|
140
|
+
)
|
|
141
|
+
return body.get("data")
|
|
142
|
+
|
|
143
|
+
# -- 查询类 ------------------------------------------------------------
|
|
144
|
+
def fee_balance(
|
|
145
|
+
self, uid: str, module: str
|
|
146
|
+
) -> Optional[Dict[str, Any]]:
|
|
147
|
+
"""查询某个用户的指定模块费用状态(余额 / 生效 / 到期 / 档位)。
|
|
148
|
+
|
|
149
|
+
:param uid: 用户唯一标识
|
|
150
|
+
:param module: 付费模块标识
|
|
151
|
+
:return: 费用状态对象(dict);记录不存在时返回 ``None``
|
|
152
|
+
:rtype: Optional[Dict[str, Any]]
|
|
153
|
+
"""
|
|
154
|
+
return self._request(
|
|
155
|
+
"GET", "/api/fee/balance",
|
|
156
|
+
params={"uid": uid, "module": module},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def fee_balances(
|
|
160
|
+
self,
|
|
161
|
+
uid: str,
|
|
162
|
+
limit: int = 100,
|
|
163
|
+
offset: int = 0,
|
|
164
|
+
) -> List[Dict[str, Any]]:
|
|
165
|
+
"""查询用户全部模块的费用状态(仅返回本调用方拥有的模块)。
|
|
166
|
+
|
|
167
|
+
:param uid: 用户唯一标识
|
|
168
|
+
:param limit: 返回条数上限
|
|
169
|
+
:param offset: 偏移量,配合 ``limit`` 分页
|
|
170
|
+
:return: 费用状态对象列表
|
|
171
|
+
:rtype: List[Dict[str, Any]]
|
|
172
|
+
"""
|
|
173
|
+
return self._request(
|
|
174
|
+
"GET", "/api/fee/balances",
|
|
175
|
+
params={"uid": uid, "limit": limit, "offset": offset},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def fee_logs(
|
|
179
|
+
self,
|
|
180
|
+
uid: str,
|
|
181
|
+
module: Optional[str] = None,
|
|
182
|
+
limit: int = 50,
|
|
183
|
+
offset: int = 0,
|
|
184
|
+
) -> List[Dict[str, Any]]:
|
|
185
|
+
"""查询费用流水(充值 / 消费记录,倒序)。
|
|
186
|
+
|
|
187
|
+
:param uid: 用户唯一标识
|
|
188
|
+
:param module: 付费模块标识过滤,为空返回全部模块
|
|
189
|
+
:param limit: 返回条数上限(封顶 200)
|
|
190
|
+
:param offset: 偏移量,配合 ``limit`` 分页
|
|
191
|
+
:return: 流水记录列表
|
|
192
|
+
:rtype: List[Dict[str, Any]]
|
|
193
|
+
"""
|
|
194
|
+
params: Dict[str, Any] = {
|
|
195
|
+
"uid": uid, "limit": limit, "offset": offset,
|
|
196
|
+
}
|
|
197
|
+
if module:
|
|
198
|
+
params["module"] = module
|
|
199
|
+
return self._request("GET", "/api/fee/logs", params=params)
|
|
200
|
+
|
|
201
|
+
def fee_modules(
|
|
202
|
+
self, module: Optional[str] = None
|
|
203
|
+
) -> List[Dict[str, Any]]:
|
|
204
|
+
"""查询本调用方拥有的付费模块(含付费类型、档位与档位费用)。
|
|
205
|
+
|
|
206
|
+
:param module: 模块标识过滤,为空返回全部
|
|
207
|
+
:return: 付费模块元数据列表
|
|
208
|
+
:rtype: List[Dict[str, Any]]
|
|
209
|
+
"""
|
|
210
|
+
params = {"module": module} if module else None
|
|
211
|
+
return self._request("GET", "/api/fee/modules", params=params)
|
|
212
|
+
|
|
213
|
+
# -- 写类:按量扣费 ----------------------------------------------------
|
|
214
|
+
def fee_consume(
|
|
215
|
+
self,
|
|
216
|
+
uid: str,
|
|
217
|
+
module: str,
|
|
218
|
+
amount: Union[float, str],
|
|
219
|
+
*,
|
|
220
|
+
biz_no: str,
|
|
221
|
+
remark: str = "",
|
|
222
|
+
) -> Dict[str, Any]:
|
|
223
|
+
"""消费扣减(仅按量付费 ``postpaid`` 模块,扣减 ``balance``)。
|
|
224
|
+
|
|
225
|
+
:param uid: 用户唯一标识
|
|
226
|
+
:param module: 付费模块标识
|
|
227
|
+
:param amount: 扣减金额(元,须 ≤ 当前余额)
|
|
228
|
+
:param biz_no: 幂等键,重试须复用原值,新动作须换新值
|
|
229
|
+
:param remark: 备注(可选)
|
|
230
|
+
:return: 扣减结果(含扣减后余额等字段)
|
|
231
|
+
:rtype: Dict[str, Any]
|
|
232
|
+
"""
|
|
233
|
+
return self._request("POST", "/api/fee/consume", json={
|
|
234
|
+
"uid": uid, "module": module, "amount": amount,
|
|
235
|
+
"biz_no": biz_no, "remark": remark,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
# -- 半自动收款:订单 ---------------------------------------------------
|
|
239
|
+
def fee_order_create(
|
|
240
|
+
self,
|
|
241
|
+
uid: str,
|
|
242
|
+
module: str,
|
|
243
|
+
pay_type: str,
|
|
244
|
+
level: str = "",
|
|
245
|
+
duration_days: int = 0,
|
|
246
|
+
amount: Union[float, str] = 0,
|
|
247
|
+
remark: str = "",
|
|
248
|
+
) -> Dict[str, Any]:
|
|
249
|
+
"""代用户创建支付订单,返回标识码与收款指引。
|
|
250
|
+
|
|
251
|
+
:param uid: 用户唯一标识
|
|
252
|
+
:param module: 付费模块标识(须属本调用方)
|
|
253
|
+
:param pay_type: 付费类型 ``prepaid`` / ``postpaid`` / ``lifetime``
|
|
254
|
+
:param level: 付费档位(``prepaid`` / ``lifetime`` 使用,可空)
|
|
255
|
+
:param duration_days: 周期天数(``prepaid`` / ``lifetime`` 使用)
|
|
256
|
+
:param amount: 金额(``postpaid`` 由用户填写;其它类型取档位价)
|
|
257
|
+
:param remark: 备注(可选)
|
|
258
|
+
:return: 订单对象(含 ``order_no`` 与收款指引)
|
|
259
|
+
:rtype: Dict[str, Any]
|
|
260
|
+
"""
|
|
261
|
+
return self._request("POST", "/api/fee/order", json={
|
|
262
|
+
"uid": uid, "module": module, "pay_type": pay_type,
|
|
263
|
+
"level": level, "duration_days": duration_days,
|
|
264
|
+
"amount": amount, "remark": remark,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
def fee_order_get(self, order_no: str) -> Dict[str, Any]:
|
|
268
|
+
"""按订单号查询订单状态(含凭证列表)。
|
|
269
|
+
|
|
270
|
+
:param order_no: 订单号(付款标识码)
|
|
271
|
+
:return: 订单对象(含 ``vouchers`` 列表)
|
|
272
|
+
:rtype: Dict[str, Any]
|
|
273
|
+
"""
|
|
274
|
+
return self._request(
|
|
275
|
+
"GET", "/api/fee/order", params={"order_no": order_no}
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def fee_order_upload_voucher(
|
|
279
|
+
self,
|
|
280
|
+
order_no: str,
|
|
281
|
+
file: Union[str, bytes, bytearray, "io.BytesIO"],
|
|
282
|
+
filename: Optional[str] = None,
|
|
283
|
+
) -> Dict[str, Any]:
|
|
284
|
+
"""上传支付凭证(multipart,文件域 ``file``)。
|
|
285
|
+
|
|
286
|
+
:param order_no: 订单号
|
|
287
|
+
:param file: 本地文件路径(str)、原始字节,或类文件对象(已 ``read()`` 可读)
|
|
288
|
+
:param filename: 文件名(传路径时自动取 basename,传字节时默认 ``voucher.png``)
|
|
289
|
+
:return: 上传结果
|
|
290
|
+
:rtype: Dict[str, Any]
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(file, str):
|
|
293
|
+
with open(file, "rb") as fp:
|
|
294
|
+
content = fp.read()
|
|
295
|
+
name = filename or file.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
296
|
+
elif isinstance(file, (bytes, bytearray)):
|
|
297
|
+
content = bytes(file)
|
|
298
|
+
name = filename or "voucher.png"
|
|
299
|
+
else:
|
|
300
|
+
content = file.read()
|
|
301
|
+
name = filename or "voucher.png"
|
|
302
|
+
files = {"file": (name, content, "application/octet-stream")}
|
|
303
|
+
return self._request(
|
|
304
|
+
"POST", "/api/fee/order/voucher",
|
|
305
|
+
params={"order_no": order_no}, files=files,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def fee_order_cancel(self, order_no: str) -> Dict[str, Any]:
|
|
309
|
+
"""取消未支付订单。
|
|
310
|
+
|
|
311
|
+
:param order_no: 订单号
|
|
312
|
+
:return: 取消结果
|
|
313
|
+
:rtype: Dict[str, Any]
|
|
314
|
+
"""
|
|
315
|
+
return self._request(
|
|
316
|
+
"POST", "/api/fee/order/cancel", json={"order_no": order_no}
|
|
317
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openhub-fee-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: openhub 费用接口 Python 客户端(/api/fee),基于 requests 封装 HMAC-SHA256 签名
|
|
5
|
+
Author: staugur
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.20
|
|
9
|
+
|
|
10
|
+
# openhub-fee-sdk
|
|
11
|
+
|
|
12
|
+
openhub 费用接口的 Python 客户端,基于 `requests` 封装 `/api/fee` 的全部接口,
|
|
13
|
+
统一处理 HMAC-SHA256 请求签名、JSON 序列化与错误判定。鉴权、字段与错误码见仓库
|
|
14
|
+
根目录 `arch/fee.md` 第 5 节。
|
|
15
|
+
|
|
16
|
+
> 注意:与 pcs 的费用接口不同,openhub **不开放直接入账**(`recharge`),资金只能经
|
|
17
|
+
> 管理员审核进入账本;SDK 因此不提供 `recharge`,转而提供**半自动收款**的订单类接口
|
|
18
|
+
> (建单 / 上传凭证 / 查单 / 取消)。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 方式一:从 PyPI 安装(推荐)
|
|
24
|
+
pip install openhub-fee-sdk
|
|
25
|
+
|
|
26
|
+
# 方式二:本地开发(可编辑安装)
|
|
27
|
+
cd sdk/fee
|
|
28
|
+
pip install -e .
|
|
29
|
+
|
|
30
|
+
# 方式三:直接安装 GitHub 仓库中的 sdk/fee 子目录
|
|
31
|
+
pip install "git+https://github.com/staugur/open-hub.git#subdirectory=sdk/fee"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 版本号与发布
|
|
35
|
+
|
|
36
|
+
版本号由 `setuptools_scm` 从 git 自动派生,**无需手改** `openhub_fee/__init__.py` 里的
|
|
37
|
+
`__version__`:
|
|
38
|
+
|
|
39
|
+
- 打了 `v*` tag(如 `v0.1.0`)并推送 → 正式版 `0.1.0`;
|
|
40
|
+
- 推送改动落到 `sdk/fee/` 目录(仅 `master` / `dev` 分支)→ 自动发布开发版;
|
|
41
|
+
- 在 Actions 页手动 Run workflow → 开发版 `0.1.0.dev<时间戳>`(精确到秒,天然唯一、
|
|
42
|
+
可直发 PyPI)。
|
|
43
|
+
|
|
44
|
+
> 仅 `sdk/fee/` 目录的改动会触发自动发布;改 `src/`、`arch/`、`README.md`
|
|
45
|
+
> 等其他目录(含 `sdk/` 下的其他项目,如 `sdk/ipamcli`)不会触发,避免无关改动误发版本。
|
|
46
|
+
|
|
47
|
+
发布由仓库的 GitHub Action(`.github/workflows/publish-sdk.yml`)完成:
|
|
48
|
+
|
|
49
|
+
- 推送 `v*` tag → 自动发布正式版到 PyPI;
|
|
50
|
+
- 推送改动落到 `sdk/fee/**`(仅 `master` / `dev`)→ 自动发布开发版;
|
|
51
|
+
- 在 Actions 页手动 Run workflow → 快速发布开发版(无需打 tag)。
|
|
52
|
+
|
|
53
|
+
> 采用 OIDC trusted publishing(免密钥):首次发布前需在 PyPI 的 `openhub-fee-sdk`
|
|
54
|
+
> 项目配置 Trusted Publisher(Owner=`staugur`、Repository name=`open-hub`、
|
|
55
|
+
> Workflow name=`publish-sdk.yml`、Environment name 留空),无需配置 `PYPI_API_TOKEN`。
|
|
56
|
+
|
|
57
|
+
## 快速上手
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from openhub_fee import FeeClient
|
|
61
|
+
|
|
62
|
+
client = FeeClient("https://openhub.example.com", app_id="<APP_ID>", secret="<SECRET>")
|
|
63
|
+
|
|
64
|
+
# 费用:查询余额(记录不存在返回 None)
|
|
65
|
+
balance = client.fee_balance(uid="u1", module="sec")
|
|
66
|
+
|
|
67
|
+
# 费用:查询全部模块状态 / 流水 / 付费模块
|
|
68
|
+
client.fee_balances(uid="u1")
|
|
69
|
+
client.fee_logs(uid="u1", module="sec", limit=50)
|
|
70
|
+
client.fee_modules()
|
|
71
|
+
|
|
72
|
+
# 费用:按量扣费(仅 postpaid;biz_no 务必幂等——重试复用原值,新动作换新值)
|
|
73
|
+
client.fee_consume(uid="u1", module="sec", amount=30, biz_no="sec-usage-001")
|
|
74
|
+
|
|
75
|
+
# 半自动收款:建单 -> 上传凭证 -> 查单 / 取消
|
|
76
|
+
order = client.fee_order_create(uid="u1", module="sec", pay_type="prepaid", level="pro")
|
|
77
|
+
client.fee_order_upload_voucher(order["order_no"], "transfer.png")
|
|
78
|
+
client.fee_order_get(order["order_no"])
|
|
79
|
+
client.fee_order_cancel(order["order_no"])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 错误处理
|
|
83
|
+
|
|
84
|
+
接口返回 `success=false` 或 HTTP 错误时抛出 `openhub_fee.FeeApiError`,可读取
|
|
85
|
+
`message` 与 `status_code`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from openhub_fee import FeeClient, FeeApiError
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
client.fee_consume(uid="u1", module="sec", amount=999, biz_no="x-1")
|
|
92
|
+
except FeeApiError as e:
|
|
93
|
+
print(e.status_code, e.message)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> openhub 的业务错误响应目前不含 `code` 字段,故 `FeeApiError.code` 恒为 `None`。
|
|
97
|
+
|
|
98
|
+
## 复用连接
|
|
99
|
+
|
|
100
|
+
`FeeClient` 默认内部持有 `requests.Session`(连接池复用)。需要自定义会话
|
|
101
|
+
(代理、证书、重试策略)时传入 `session=` 即可。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
README.md
|
|
3
|
+
api.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
openhub_fee/__init__.py
|
|
6
|
+
openhub_fee/client.py
|
|
7
|
+
openhub_fee_sdk.egg-info/PKG-INFO
|
|
8
|
+
openhub_fee_sdk.egg-info/SOURCES.txt
|
|
9
|
+
openhub_fee_sdk.egg-info/dependency_links.txt
|
|
10
|
+
openhub_fee_sdk.egg-info/requires.txt
|
|
11
|
+
openhub_fee_sdk.egg-info/scm_file_list.json
|
|
12
|
+
openhub_fee_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.20
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openhub_fee
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "setuptools_scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openhub-fee-sdk"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "openhub 费用接口 Python 客户端(/api/fee),基于 requests 封装 HMAC-SHA256 签名"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
dependencies = ["requests>=2.20"]
|
|
12
|
+
authors = [{ name = "staugur" }]
|
|
13
|
+
|
|
14
|
+
[tool.setuptools]
|
|
15
|
+
packages = ["openhub_fee"]
|
|
16
|
+
|
|
17
|
+
# 版本号由 git 自动派生(无需手改 __version__):
|
|
18
|
+
# - 打了 v* tag(如 v0.1.0)→ 正式版 0.1.0
|
|
19
|
+
# - 开发期手动快速发(workflow_dispatch)→ 0.1.0.dev<日期>(如 0.1.0.dev20261005,
|
|
20
|
+
# 日期取自 UTC,天然唯一、可直发 PyPI;PyPI 要求 PEP 440,故用 .dev 前缀而非连字符)
|
|
21
|
+
# - local_scheme=no-local-version 去掉 +g<sha> 本地后缀(PyPI 会拒收本地版本)
|
|
22
|
+
[tool.setuptools_scm]
|
|
23
|
+
version_scheme = "guess-next-dev"
|
|
24
|
+
local_scheme = "no-local-version"
|
|
25
|
+
fallback_version = "0.1.0"
|