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.
@@ -0,0 +1,5 @@
1
+ # 构建与安装产物(发布由 CI 完成,无需提交)
2
+ __pycache__/
3
+ *.egg-info/
4
+ build/
5
+ dist/
@@ -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
+ requests>=2.20
@@ -0,0 +1,10 @@
1
+ {
2
+ "files": [
3
+ ".gitignore",
4
+ "README.md",
5
+ "api.md",
6
+ "openhub_fee/__init__.py",
7
+ "openhub_fee/client.py",
8
+ "pyproject.toml"
9
+ ]
10
+ }
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+