pcs-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.
pcs_sdk-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: pcs-sdk
3
+ Version: 0.1.0
4
+ Summary: pcs API client built on requests
5
+ Author: staugur
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.20
9
+
10
+ # pcs SDK
11
+
12
+ pcs 的 Python 客户端,基于 `requests` 封装「费用模块」与「防火墙模块」接口,
13
+ 统一处理 HMAC-SHA256 请求签名、JSON 序列化与错误判定。接口字段与错误码见仓库根目录 `API.md`。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ # 方式一:从 PyPI 安装(推荐)
19
+ pip install pcs-sdk
20
+
21
+ # 方式二:本地开发(可编辑安装)
22
+ cd sdk
23
+ pip install -e .
24
+
25
+ # 方式三:直接安装 GitHub 仓库中的 sdk 子目录
26
+ pip install "git+https://github.com/staugur/pcs.git#subdirectory=sdk"
27
+ ```
28
+
29
+ ## 版本号与发布
30
+
31
+ 版本号由 `setuptools_scm` 从 git 自动派生,**无需手改** `pcs/__init__.py` 里的
32
+ `__version__`:
33
+
34
+ - 打了 `v*` tag(如 `v0.1.0`)并推送 → 正式版 `0.1.0`;
35
+ - 推送改动落到 `sdk/` 目录(仅 `master` / `dev` 分支)→ 自动发布开发版,版本号形如
36
+ `0.1.0.dev12+gabcdef`(距最近 tag 的提交数 + commit sha,天然唯一、不撞车);
37
+ - 在 Actions 页手动 Run workflow → 开发版 `0.1.0.dev<时间戳>`(精确到秒,天然唯一、
38
+ 可直发 PyPI)。
39
+
40
+ > 仅 `sdk/` 目录的改动会触发自动发布;改 `src/`、`AGENTS.md`、`API.md` 等其他目录
41
+ > 不会触发,避免无关改动误发版本。同一 push 若同时命中多个触发条件(如打了 `v*` tag
42
+ > 且其提交刚好改了 `sdk/`),工作流只运行一次。
43
+
44
+ 发布由仓库的 GitHub Action(`.github/workflows/publish.yml`)完成:
45
+
46
+ - 推送 `v*` tag → 自动发布正式版到 PyPI;
47
+ - 推送改动落到 `sdk/**`(仅 `master` / `dev`)→ 自动发布开发版;
48
+ - 在 Actions 页手动 Run workflow → 快速发布开发版(无需打 tag)。
49
+
50
+ > 采用 OIDC trusted publishing(免密钥):首次发布前需在 PyPI 的 `pcs-sdk` 项目配置
51
+ > Trusted Publisher(Owner=`staugur`、Repository name=`pcs`、Workflow name=`publish.yml`、
52
+ > Environment name 留空),无需配置 `PYPI_API_TOKEN`。
53
+
54
+ ## 快速上手
55
+
56
+ ```python
57
+ from pcs import PcsClient
58
+
59
+ client = PcsClient("https://pcs.example.com", api_key="<API_KEY>")
60
+
61
+ # 费用:查询余额(记录不存在返回 None)
62
+ balance = client.fee_balance(uid="u1", module="blog")
63
+
64
+ # 费用:查询全部模块状态 / 流水 / 付费模块
65
+ client.fee_balances(uid="u1")
66
+ client.fee_logs(uid="u1", module="blog", limit=50)
67
+ client.fee_modules()
68
+
69
+ # 费用:充值(biz_no 务必幂等——重试复用原值,新动作换新值)
70
+ client.fee_recharge(
71
+ uid="u1", module="blog", amount=120, pay_type="prepaid",
72
+ pay_source="alipay", start_at=1790304000, end_at=1821840000,
73
+ level="pro", trade_no="202609010001",
74
+ biz_no="blog-2026-09-recharge-001",
75
+ )
76
+
77
+ # 费用:消费扣减(仅按量付费模块)
78
+ client.fee_consume(uid="u1", module="blog", amount=30,
79
+ biz_no="blog-2026-09-usage-001")
80
+
81
+ # 主机管理:列出已登记主机
82
+ client.host_hosts()
83
+
84
+ # 防火墙:列出放行记录(按主机过滤 / 分页:limit 上限 200)
85
+ client.host_allows(host_id=1)
86
+ client.host_allows(limit=20, offset=0)
87
+
88
+ # 防火墙:临时放行(到期时间 expire_at 为 10 位时间戳)
89
+ client.host_allow(host_id=1, ip="1.2.3.4", port="3306",
90
+ expire_at=1790304000 + 3600, remark="临时开库")
91
+
92
+ # 防火墙:撤销放行
93
+ client.host_revoke(allow_id=42)
94
+
95
+ # nginx 白名单:列出 / 新增 / 撤销(pcs 全权托管 conf,到期自动 reload)
96
+ client.host_nginx_allows(host_id=1)
97
+ client.host_nginx_allow(host_id=1, ip="1.2.3.4",
98
+ expire_at=1790304000 + 3600, remark="临时开站")
99
+ client.host_nginx_revoke(allow_id=42)
100
+ ```
101
+
102
+ ## 错误处理
103
+
104
+ 接口返回 `success=false` 或 HTTP 错误时抛出 `pcs.PcsApiError`,可读取
105
+ `code`(错误码,如 `PARAM_ERROR` / `INSUFFICIENT_BALANCE` / `IDEMPOTENCY_CONFLICT`)、
106
+ `message` 与 `status_code`:
107
+
108
+ ```python
109
+ from pcs import PcsClient, PcsApiError
110
+
111
+ try:
112
+ client.fee_consume(uid="u1", module="blog", amount=999, biz_no="x-1")
113
+ except PcsApiError as e:
114
+ print(e.code, e.status_code, e.message)
115
+ ```
116
+
117
+ ## 复用连接
118
+
119
+ `PcsClient` 默认内部持有 `requests.Session`(连接池复用)。需要自定义会话
120
+ (代理、证书、重试策略)时传入 `session=` 即可。
@@ -0,0 +1,111 @@
1
+ # pcs SDK
2
+
3
+ pcs 的 Python 客户端,基于 `requests` 封装「费用模块」与「防火墙模块」接口,
4
+ 统一处理 HMAC-SHA256 请求签名、JSON 序列化与错误判定。接口字段与错误码见仓库根目录 `API.md`。
5
+
6
+ ## 安装
7
+
8
+ ```bash
9
+ # 方式一:从 PyPI 安装(推荐)
10
+ pip install pcs-sdk
11
+
12
+ # 方式二:本地开发(可编辑安装)
13
+ cd sdk
14
+ pip install -e .
15
+
16
+ # 方式三:直接安装 GitHub 仓库中的 sdk 子目录
17
+ pip install "git+https://github.com/staugur/pcs.git#subdirectory=sdk"
18
+ ```
19
+
20
+ ## 版本号与发布
21
+
22
+ 版本号由 `setuptools_scm` 从 git 自动派生,**无需手改** `pcs/__init__.py` 里的
23
+ `__version__`:
24
+
25
+ - 打了 `v*` tag(如 `v0.1.0`)并推送 → 正式版 `0.1.0`;
26
+ - 推送改动落到 `sdk/` 目录(仅 `master` / `dev` 分支)→ 自动发布开发版,版本号形如
27
+ `0.1.0.dev12+gabcdef`(距最近 tag 的提交数 + commit sha,天然唯一、不撞车);
28
+ - 在 Actions 页手动 Run workflow → 开发版 `0.1.0.dev<时间戳>`(精确到秒,天然唯一、
29
+ 可直发 PyPI)。
30
+
31
+ > 仅 `sdk/` 目录的改动会触发自动发布;改 `src/`、`AGENTS.md`、`API.md` 等其他目录
32
+ > 不会触发,避免无关改动误发版本。同一 push 若同时命中多个触发条件(如打了 `v*` tag
33
+ > 且其提交刚好改了 `sdk/`),工作流只运行一次。
34
+
35
+ 发布由仓库的 GitHub Action(`.github/workflows/publish.yml`)完成:
36
+
37
+ - 推送 `v*` tag → 自动发布正式版到 PyPI;
38
+ - 推送改动落到 `sdk/**`(仅 `master` / `dev`)→ 自动发布开发版;
39
+ - 在 Actions 页手动 Run workflow → 快速发布开发版(无需打 tag)。
40
+
41
+ > 采用 OIDC trusted publishing(免密钥):首次发布前需在 PyPI 的 `pcs-sdk` 项目配置
42
+ > Trusted Publisher(Owner=`staugur`、Repository name=`pcs`、Workflow name=`publish.yml`、
43
+ > Environment name 留空),无需配置 `PYPI_API_TOKEN`。
44
+
45
+ ## 快速上手
46
+
47
+ ```python
48
+ from pcs import PcsClient
49
+
50
+ client = PcsClient("https://pcs.example.com", api_key="<API_KEY>")
51
+
52
+ # 费用:查询余额(记录不存在返回 None)
53
+ balance = client.fee_balance(uid="u1", module="blog")
54
+
55
+ # 费用:查询全部模块状态 / 流水 / 付费模块
56
+ client.fee_balances(uid="u1")
57
+ client.fee_logs(uid="u1", module="blog", limit=50)
58
+ client.fee_modules()
59
+
60
+ # 费用:充值(biz_no 务必幂等——重试复用原值,新动作换新值)
61
+ client.fee_recharge(
62
+ uid="u1", module="blog", amount=120, pay_type="prepaid",
63
+ pay_source="alipay", start_at=1790304000, end_at=1821840000,
64
+ level="pro", trade_no="202609010001",
65
+ biz_no="blog-2026-09-recharge-001",
66
+ )
67
+
68
+ # 费用:消费扣减(仅按量付费模块)
69
+ client.fee_consume(uid="u1", module="blog", amount=30,
70
+ biz_no="blog-2026-09-usage-001")
71
+
72
+ # 主机管理:列出已登记主机
73
+ client.host_hosts()
74
+
75
+ # 防火墙:列出放行记录(按主机过滤 / 分页:limit 上限 200)
76
+ client.host_allows(host_id=1)
77
+ client.host_allows(limit=20, offset=0)
78
+
79
+ # 防火墙:临时放行(到期时间 expire_at 为 10 位时间戳)
80
+ client.host_allow(host_id=1, ip="1.2.3.4", port="3306",
81
+ expire_at=1790304000 + 3600, remark="临时开库")
82
+
83
+ # 防火墙:撤销放行
84
+ client.host_revoke(allow_id=42)
85
+
86
+ # nginx 白名单:列出 / 新增 / 撤销(pcs 全权托管 conf,到期自动 reload)
87
+ client.host_nginx_allows(host_id=1)
88
+ client.host_nginx_allow(host_id=1, ip="1.2.3.4",
89
+ expire_at=1790304000 + 3600, remark="临时开站")
90
+ client.host_nginx_revoke(allow_id=42)
91
+ ```
92
+
93
+ ## 错误处理
94
+
95
+ 接口返回 `success=false` 或 HTTP 错误时抛出 `pcs.PcsApiError`,可读取
96
+ `code`(错误码,如 `PARAM_ERROR` / `INSUFFICIENT_BALANCE` / `IDEMPOTENCY_CONFLICT`)、
97
+ `message` 与 `status_code`:
98
+
99
+ ```python
100
+ from pcs import PcsClient, PcsApiError
101
+
102
+ try:
103
+ client.fee_consume(uid="u1", module="blog", amount=999, biz_no="x-1")
104
+ except PcsApiError as e:
105
+ print(e.code, e.status_code, e.message)
106
+ ```
107
+
108
+ ## 复用连接
109
+
110
+ `PcsClient` 默认内部持有 `requests.Session`(连接池复用)。需要自定义会话
111
+ (代理、证书、重试策略)时传入 `session=` 即可。
@@ -0,0 +1,34 @@
1
+ """pcs API 客户端 SDK。
2
+
3
+ 基于 ``requests`` 封装 pcs 的费用模块与主机管理模块接口,统一处理
4
+ HMAC-SHA256 请求签名、JSON 序列化与错误判定。接口细节见仓库根目录 ``API.md``。
5
+
6
+ 典型用法::
7
+
8
+ from pcs import PcsClient
9
+
10
+ client = PcsClient("https://pcs.example.com", api_key="<API_KEY>")
11
+
12
+ balance = client.fee_balance(uid="u1", module="blog")
13
+ client.fee_recharge(
14
+ uid="u1", module="blog", amount=120, pay_type="prepaid",
15
+ pay_source="alipay", start_at=1790304000, end_at=1821840000,
16
+ level="pro", trade_no="202609010001",
17
+ biz_no="blog-2026-09-recharge-001",
18
+ )
19
+ client.host_allow(host_id=1, ip="1.2.3.4", port="3306",
20
+ expire_at=1790304000 + 3600, remark="临时开库")
21
+ client.host_nginx_allow(host_id=1, ip="1.2.3.4",
22
+ expire_at=1790304000 + 3600)
23
+ """
24
+
25
+ from .client import PcsApiError, PcsClient
26
+
27
+ __all__ = ["PcsClient", "PcsApiError"]
28
+
29
+ try:
30
+ from importlib.metadata import version as _pkg_version
31
+
32
+ __version__ = _pkg_version("pcs-sdk")
33
+ except Exception: # pragma: no cover - 非安装态(如直接跑源码)回退
34
+ __version__ = "0.1.0"
@@ -0,0 +1,390 @@
1
+ """pcs API 客户端实现。
2
+
3
+ 安全说明
4
+ --------
5
+ ``api_key`` 只在客户端本地参与 HMAC-SHA256 签名计算,**绝不**随请求发往服务端;
6
+ 网络层只传输签名值 ``X-Signature`` 与时间戳 / nonce,因此密钥本身不会泄露。
7
+ 服务端使用相同的 ``API_KEY`` 独立验签,实现请求来源鉴权(详见仓库根 ``API.md``)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import time
15
+ from secrets import token_hex
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ try:
19
+ import requests
20
+ except ImportError as exc: # pragma: no cover
21
+ raise ImportError("pcs SDK 需要 requests:pip install requests") from exc
22
+
23
+
24
+ class PcsApiError(Exception):
25
+ """API 调用失败(HTTP 错误或非 success 响应)。"""
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ code: Optional[str] = None,
31
+ status_code: Optional[int] = None,
32
+ ) -> None:
33
+ """构造 API 错误。
34
+
35
+ :param message: 错误描述
36
+ :param code: 业务错误码(如 ``PARAM_ERROR`` / ``INSUFFICIENT_BALANCE``),可选
37
+ :param status_code: HTTP 状态码,可选
38
+ """
39
+ super().__init__(message)
40
+ self.message = message
41
+ self.code = code
42
+ self.status_code = status_code
43
+
44
+ def __str__(self) -> str:
45
+ if self.code:
46
+ return "[{}] {}".format(self.code, self.message)
47
+ return self.message
48
+
49
+
50
+ class PcsClient:
51
+ """pcs HTTP API 客户端(费用模块 + 防火墙模块)。"""
52
+
53
+ def __init__(
54
+ self,
55
+ base_url: str,
56
+ api_key: str,
57
+ timeout: float = 10.0,
58
+ session: Optional["requests.Session"] = None,
59
+ ) -> None:
60
+ """
61
+ :param base_url: API 根地址,如 ``https://pcs.example.com``
62
+ :param api_key: 与服务端 ``API_KEY`` 一致的密钥(仅本地签名用,不随请求传输)
63
+ :param timeout: 请求超时(秒)
64
+ :param session: 可复用的 ``requests.Session``;缺省自动创建
65
+ """
66
+ self._base_url = base_url.rstrip("/")
67
+ self._api_key = api_key
68
+ self._timeout = timeout
69
+ self._session = session or requests.Session()
70
+
71
+ # -- 签名与底层请求 ----------------------------------------------------
72
+ def _auth_headers(self) -> Dict[str, str]:
73
+ # 每次请求都重新生成时间戳与随机 nonce,保证签名不可重放
74
+ ts = str(int(time.time()))
75
+ nonce = token_hex(8)
76
+ # 待签名字符串 = 时间戳与 nonce 直接拼接(如 "1700000000a1b2c3d4"),
77
+ # 用 api_key 做 HMAC-SHA256 得到十六进制签名值
78
+ sign = hmac.new(
79
+ self._api_key.encode("utf-8"),
80
+ (ts + nonce).encode("utf-8"),
81
+ hashlib.sha256,
82
+ ).hexdigest()
83
+ return {
84
+ "X-Timestamp": ts,
85
+ "X-Nonce": nonce,
86
+ "X-Signature": sign,
87
+ "Accept": "application/json",
88
+ }
89
+
90
+ def _request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ *,
95
+ params: Optional[Dict[str, Any]] = None,
96
+ json: Optional[Dict[str, Any]] = None,
97
+ ) -> Any:
98
+ """发起一次签名鉴权的 HTTP 请求并统一解析业务响应。
99
+
100
+ :param method: HTTP 方法(``GET`` / ``POST`` ...)
101
+ :param path: 接口路径,如 ``/api/fee/balance``
102
+ :param params: 查询参数(GET)
103
+ :param json: 请求体(POST),以 JSON 序列化
104
+ :return: 业务成功时返回响应体中的 ``data`` 字段
105
+ :rtype: Any
106
+ :raises PcsApiError: HTTP 错误(>= 400)或业务层 ``success is False`` 时抛出
107
+ """
108
+ resp = self._session.request(
109
+ method,
110
+ self._base_url + path,
111
+ params=params,
112
+ json=json,
113
+ headers=self._auth_headers(),
114
+ timeout=self._timeout,
115
+ )
116
+ # 统一解析 JSON:非 JSON 且为错误状态码时直接以原始文本报错
117
+ try:
118
+ body = resp.json()
119
+ except ValueError:
120
+ if resp.status_code >= 400:
121
+ raise PcsApiError(
122
+ resp.text or "HTTP {}".format(resp.status_code),
123
+ status_code=resp.status_code,
124
+ ) from None
125
+ raise PcsApiError("invalid json response") from None
126
+ # 业务层约定:HTTP >= 400 或 body.success is False 都视为失败
127
+ if resp.status_code >= 400 or body.get("success") is False:
128
+ raise PcsApiError(
129
+ body.get("message") or "request failed",
130
+ code=body.get("code"),
131
+ status_code=resp.status_code,
132
+ )
133
+ return body.get("data")
134
+
135
+ # -- 费用模块 ----------------------------------------------------------
136
+ def fee_balance(
137
+ self, uid: str, module: str
138
+ ) -> Optional[Dict[str, Any]]:
139
+ """查询某个用户的指定模块费用状态(余额 / 生效 / 到期 / 档位)。
140
+
141
+ :param uid: 用户唯一标识
142
+ :param module: 付费模块标识
143
+ :return: 费用状态对象(dict);记录不存在时返回 ``None``
144
+ :rtype: Optional[Dict[str, Any]]
145
+ """
146
+ return self._request(
147
+ "GET", "/api/fee/balance",
148
+ params={"uid": uid, "module": module},
149
+ )
150
+
151
+ def fee_balances(
152
+ self,
153
+ uid: str,
154
+ module: Optional[str] = None,
155
+ limit: int = 100,
156
+ offset: int = 0,
157
+ ) -> List[Dict[str, Any]]:
158
+ """查询用户全部模块的费用状态(按 ctime 倒序)。
159
+
160
+ :param uid: 用户唯一标识
161
+ :param module: 付费模块标识过滤,为空返回全部模块
162
+ :param limit: 返回条数上限
163
+ :param offset: 偏移量,配合 ``limit`` 分页
164
+ :return: 费用状态对象列表
165
+ :rtype: List[Dict[str, Any]]
166
+ """
167
+ params: Dict[str, Any] = {
168
+ "uid": uid, "limit": limit, "offset": offset,
169
+ }
170
+ if module:
171
+ params["module"] = module
172
+ return self._request("GET", "/api/fee/balances", params=params)
173
+
174
+ def fee_logs(
175
+ self,
176
+ uid: str,
177
+ module: Optional[str] = None,
178
+ limit: int = 50,
179
+ offset: int = 0,
180
+ ) -> List[Dict[str, Any]]:
181
+ """查询费用流水(充值 / 消费记录)。
182
+
183
+ :param uid: 用户唯一标识
184
+ :param module: 付费模块标识过滤,为空返回全部模块
185
+ :param limit: 返回条数上限
186
+ :param offset: 偏移量,配合 ``limit`` 分页
187
+ :return: 流水记录列表
188
+ :rtype: List[Dict[str, Any]]
189
+ """
190
+ params: Dict[str, Any] = {
191
+ "uid": uid, "limit": limit, "offset": offset,
192
+ }
193
+ if module:
194
+ params["module"] = module
195
+ return self._request("GET", "/api/fee/logs", params=params)
196
+
197
+ def fee_modules(
198
+ self, module: Optional[str] = None
199
+ ) -> List[Dict[str, Any]]:
200
+ """查询已登记的付费模块(含支持的付费类型、档位与档位费用)。
201
+
202
+ :param module: 模块标识过滤,为空返回全部
203
+ :return: 付费模块元数据列表
204
+ :rtype: List[Dict[str, Any]]
205
+ """
206
+ params = {"module": module} if module else None
207
+ return self._request("GET", "/api/fee/modules", params=params)
208
+
209
+ def fee_recharge(
210
+ self,
211
+ uid: str,
212
+ module: str,
213
+ amount: float,
214
+ *,
215
+ biz_no: str,
216
+ pay_type: str = "postpaid",
217
+ pay_source: str = "other",
218
+ level: str = "",
219
+ start_at: int = 0,
220
+ end_at: int = 0,
221
+ trade_no: str = "",
222
+ remark: str = "",
223
+ operator: str = "",
224
+ ) -> Dict[str, Any]:
225
+ """充值 / 续期(同一事务内更新费用状态并写流水)。
226
+
227
+ :param uid: 用户唯一标识
228
+ :param module: 付费模块标识
229
+ :param amount: 金额(元,保留两位小数)
230
+ :param biz_no: 幂等键,重试须复用原值,新动作须换新值
231
+ :param pay_type: 付费类型 ``prepaid`` / ``postpaid`` / ``lifetime``
232
+ :param pay_source: 付费来源 ``cash`` / ``alipay`` / ``wechat`` / ``qq`` / ``other``
233
+ :param level: 付费档位(仅 ``prepaid`` / ``lifetime`` 有意义,可空)
234
+ :param start_at: 生效时间(10 位时间戳);``lifetime`` 忽略,``postpaid`` 仅首次入账时写
235
+ :param end_at: 结束时间(10 位时间戳);仅 ``prepaid`` 可填,须晚于当前结束时间
236
+ :param trade_no: 交易号(可选)
237
+ :param remark: 备注(可选)
238
+ :param operator: 操作人(可选,仅服务端可传)
239
+ :return: 变更结果(含 ``duplicated`` 等字段)
240
+ :rtype: Dict[str, Any]
241
+ """
242
+ return self._request("POST", "/api/fee/recharge", json={
243
+ "uid": uid, "module": module, "amount": amount,
244
+ "pay_type": pay_type, "pay_source": pay_source, "level": level,
245
+ "start_at": start_at, "end_at": end_at, "biz_no": biz_no,
246
+ "trade_no": trade_no, "remark": remark, "operator": operator,
247
+ })
248
+
249
+ def fee_consume(
250
+ self,
251
+ uid: str,
252
+ module: str,
253
+ amount: float,
254
+ *,
255
+ biz_no: str,
256
+ remark: str = "",
257
+ operator: str = "",
258
+ ) -> Dict[str, Any]:
259
+ """消费扣减(仅按量付费 ``postpaid`` 模块,扣减 ``balance``)。
260
+
261
+ :param uid: 用户唯一标识
262
+ :param module: 付费模块标识
263
+ :param amount: 扣减金额(元,须 ≤ 当前余额)
264
+ :param biz_no: 幂等键,重试须复用原值,新动作须换新值
265
+ :param remark: 备注(可选)
266
+ :param operator: 操作人(可选,仅服务端可传)
267
+ :return: 扣减结果(含扣减后余额等字段)
268
+ :rtype: Dict[str, Any]
269
+ """
270
+ return self._request("POST", "/api/fee/consume", json={
271
+ "uid": uid, "module": module, "amount": amount,
272
+ "biz_no": biz_no, "remark": remark, "operator": operator,
273
+ })
274
+
275
+ # -- 主机管理 ----------------------------------------------------------
276
+ def host_hosts(self) -> List[Dict[str, Any]]:
277
+ """列出已登记主机(不含密码 / 私钥等敏感字段)。
278
+
279
+ :return: 主机列表
280
+ :rtype: List[Dict[str, Any]]
281
+ """
282
+ return self._request("GET", "/api/host/hosts")
283
+
284
+ # -- 防火墙临时放行 ----------------------------------------------------
285
+ def host_allows(
286
+ self,
287
+ host_id: Optional[int] = None,
288
+ limit: int = 50,
289
+ offset: int = 0,
290
+ ) -> List[Dict[str, Any]]:
291
+ """列出防火墙临时放行记录。
292
+
293
+ :param host_id: 按主机过滤,为空返回全部主机
294
+ :param limit: 返回条数上限(封顶 200)
295
+ :param offset: 偏移量,配合 ``limit`` 分页
296
+ :return: 临时放行记录列表
297
+ :rtype: List[Dict[str, Any]]
298
+ """
299
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
300
+ if host_id is not None:
301
+ params["host_id"] = host_id
302
+ return self._request("GET", "/api/host/allows", params=params)
303
+
304
+ def host_allow(
305
+ self,
306
+ host_id: int,
307
+ ip: str,
308
+ port: str,
309
+ *,
310
+ protocol: str = "tcp",
311
+ expire_at: int = 0,
312
+ remark: str = "",
313
+ ) -> Dict[str, Any]:
314
+ """防火墙临时放行:允许 ``ip`` 访问指定主机的 ``protocol/port``,到期自动撤销。
315
+
316
+ :param host_id: 目标主机 ID(来自 ``host_hosts``)
317
+ :param ip: 来源 IP
318
+ :param port: 端口(单个 / 范围 / 多个不连续,逗号分隔)
319
+ :param protocol: 协议,``tcp`` 或 ``udp``
320
+ :param expire_at: 到期时间(10 位时间戳),``0`` 或缺省表示**永久**(不自动撤销)
321
+ :param remark: 备注(可选)
322
+ :return: 新建的放行记录
323
+ :rtype: Dict[str, Any]
324
+ """
325
+ return self._request("POST", "/api/host/allow", json={
326
+ "host_id": host_id, "ip": ip, "protocol": protocol,
327
+ "port": port, "expire_at": expire_at, "remark": remark,
328
+ })
329
+
330
+ def host_revoke(self, allow_id: int) -> Dict[str, Any]:
331
+ """撤销一条防火墙放行(幂等:不存在或已撤销即视为成功)。
332
+
333
+ :param allow_id: 放行记录 ID(来自 ``host_allows``)
334
+ :return: 撤销结果
335
+ :rtype: Dict[str, Any]
336
+ """
337
+ return self._request("POST", "/api/host/revoke", json={"id": allow_id})
338
+
339
+ # -- nginx 白名单 ------------------------------------------------------
340
+ def host_nginx_allows(
341
+ self,
342
+ host_id: Optional[int] = None,
343
+ limit: int = 50,
344
+ offset: int = 0,
345
+ ) -> List[Dict[str, Any]]:
346
+ """列出 nginx 白名单记录。
347
+
348
+ :param host_id: 按主机过滤,为空返回全部主机
349
+ :param limit: 返回条数上限(封顶 200)
350
+ :param offset: 偏移量,配合 ``limit`` 分页
351
+ :return: 白名单记录列表
352
+ :rtype: List[Dict[str, Any]]
353
+ """
354
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
355
+ if host_id is not None:
356
+ params["host_id"] = host_id
357
+ return self._request("GET", "/api/host/nginx-allows", params=params)
358
+
359
+ def host_nginx_allow(
360
+ self,
361
+ host_id: int,
362
+ ip: str,
363
+ *,
364
+ expire_at: int = 0,
365
+ remark: str = "",
366
+ ) -> Dict[str, Any]:
367
+ """nginx 白名单:允许 ``ip`` 访问指定主机,到期自动撤销(pcs 全权托管 conf)。
368
+
369
+ :param host_id: 目标主机 ID(来自 ``host_hosts``)
370
+ :param ip: 来源 IP
371
+ :param expire_at: 到期时间(10 位时间戳),``0`` 或缺省表示**永久**(不自动撤销)
372
+ :param remark: 备注(可选)
373
+ :return: 新建的白名单记录
374
+ :rtype: Dict[str, Any]
375
+ """
376
+ return self._request("POST", "/api/host/nginx-allow", json={
377
+ "host_id": host_id, "ip": ip,
378
+ "expire_at": expire_at, "remark": remark,
379
+ })
380
+
381
+ def host_nginx_revoke(self, allow_id: int) -> Dict[str, Any]:
382
+ """撤销一条 nginx 白名单(幂等:不存在或已撤销即视为成功)。
383
+
384
+ :param allow_id: 白名单记录 ID(来自 ``host_nginx_allows``)
385
+ :return: 撤销结果
386
+ :rtype: Dict[str, Any]
387
+ """
388
+ return self._request(
389
+ "POST", "/api/host/nginx-revoke", json={"id": allow_id}
390
+ )
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: pcs-sdk
3
+ Version: 0.1.0
4
+ Summary: pcs API client built on requests
5
+ Author: staugur
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.20
9
+
10
+ # pcs SDK
11
+
12
+ pcs 的 Python 客户端,基于 `requests` 封装「费用模块」与「防火墙模块」接口,
13
+ 统一处理 HMAC-SHA256 请求签名、JSON 序列化与错误判定。接口字段与错误码见仓库根目录 `API.md`。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ # 方式一:从 PyPI 安装(推荐)
19
+ pip install pcs-sdk
20
+
21
+ # 方式二:本地开发(可编辑安装)
22
+ cd sdk
23
+ pip install -e .
24
+
25
+ # 方式三:直接安装 GitHub 仓库中的 sdk 子目录
26
+ pip install "git+https://github.com/staugur/pcs.git#subdirectory=sdk"
27
+ ```
28
+
29
+ ## 版本号与发布
30
+
31
+ 版本号由 `setuptools_scm` 从 git 自动派生,**无需手改** `pcs/__init__.py` 里的
32
+ `__version__`:
33
+
34
+ - 打了 `v*` tag(如 `v0.1.0`)并推送 → 正式版 `0.1.0`;
35
+ - 推送改动落到 `sdk/` 目录(仅 `master` / `dev` 分支)→ 自动发布开发版,版本号形如
36
+ `0.1.0.dev12+gabcdef`(距最近 tag 的提交数 + commit sha,天然唯一、不撞车);
37
+ - 在 Actions 页手动 Run workflow → 开发版 `0.1.0.dev<时间戳>`(精确到秒,天然唯一、
38
+ 可直发 PyPI)。
39
+
40
+ > 仅 `sdk/` 目录的改动会触发自动发布;改 `src/`、`AGENTS.md`、`API.md` 等其他目录
41
+ > 不会触发,避免无关改动误发版本。同一 push 若同时命中多个触发条件(如打了 `v*` tag
42
+ > 且其提交刚好改了 `sdk/`),工作流只运行一次。
43
+
44
+ 发布由仓库的 GitHub Action(`.github/workflows/publish.yml`)完成:
45
+
46
+ - 推送 `v*` tag → 自动发布正式版到 PyPI;
47
+ - 推送改动落到 `sdk/**`(仅 `master` / `dev`)→ 自动发布开发版;
48
+ - 在 Actions 页手动 Run workflow → 快速发布开发版(无需打 tag)。
49
+
50
+ > 采用 OIDC trusted publishing(免密钥):首次发布前需在 PyPI 的 `pcs-sdk` 项目配置
51
+ > Trusted Publisher(Owner=`staugur`、Repository name=`pcs`、Workflow name=`publish.yml`、
52
+ > Environment name 留空),无需配置 `PYPI_API_TOKEN`。
53
+
54
+ ## 快速上手
55
+
56
+ ```python
57
+ from pcs import PcsClient
58
+
59
+ client = PcsClient("https://pcs.example.com", api_key="<API_KEY>")
60
+
61
+ # 费用:查询余额(记录不存在返回 None)
62
+ balance = client.fee_balance(uid="u1", module="blog")
63
+
64
+ # 费用:查询全部模块状态 / 流水 / 付费模块
65
+ client.fee_balances(uid="u1")
66
+ client.fee_logs(uid="u1", module="blog", limit=50)
67
+ client.fee_modules()
68
+
69
+ # 费用:充值(biz_no 务必幂等——重试复用原值,新动作换新值)
70
+ client.fee_recharge(
71
+ uid="u1", module="blog", amount=120, pay_type="prepaid",
72
+ pay_source="alipay", start_at=1790304000, end_at=1821840000,
73
+ level="pro", trade_no="202609010001",
74
+ biz_no="blog-2026-09-recharge-001",
75
+ )
76
+
77
+ # 费用:消费扣减(仅按量付费模块)
78
+ client.fee_consume(uid="u1", module="blog", amount=30,
79
+ biz_no="blog-2026-09-usage-001")
80
+
81
+ # 主机管理:列出已登记主机
82
+ client.host_hosts()
83
+
84
+ # 防火墙:列出放行记录(按主机过滤 / 分页:limit 上限 200)
85
+ client.host_allows(host_id=1)
86
+ client.host_allows(limit=20, offset=0)
87
+
88
+ # 防火墙:临时放行(到期时间 expire_at 为 10 位时间戳)
89
+ client.host_allow(host_id=1, ip="1.2.3.4", port="3306",
90
+ expire_at=1790304000 + 3600, remark="临时开库")
91
+
92
+ # 防火墙:撤销放行
93
+ client.host_revoke(allow_id=42)
94
+
95
+ # nginx 白名单:列出 / 新增 / 撤销(pcs 全权托管 conf,到期自动 reload)
96
+ client.host_nginx_allows(host_id=1)
97
+ client.host_nginx_allow(host_id=1, ip="1.2.3.4",
98
+ expire_at=1790304000 + 3600, remark="临时开站")
99
+ client.host_nginx_revoke(allow_id=42)
100
+ ```
101
+
102
+ ## 错误处理
103
+
104
+ 接口返回 `success=false` 或 HTTP 错误时抛出 `pcs.PcsApiError`,可读取
105
+ `code`(错误码,如 `PARAM_ERROR` / `INSUFFICIENT_BALANCE` / `IDEMPOTENCY_CONFLICT`)、
106
+ `message` 与 `status_code`:
107
+
108
+ ```python
109
+ from pcs import PcsClient, PcsApiError
110
+
111
+ try:
112
+ client.fee_consume(uid="u1", module="blog", amount=999, biz_no="x-1")
113
+ except PcsApiError as e:
114
+ print(e.code, e.status_code, e.message)
115
+ ```
116
+
117
+ ## 复用连接
118
+
119
+ `PcsClient` 默认内部持有 `requests.Session`(连接池复用)。需要自定义会话
120
+ (代理、证书、重试策略)时传入 `session=` 即可。
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ pcs/__init__.py
4
+ pcs/client.py
5
+ pcs_sdk.egg-info/PKG-INFO
6
+ pcs_sdk.egg-info/SOURCES.txt
7
+ pcs_sdk.egg-info/dependency_links.txt
8
+ pcs_sdk.egg-info/requires.txt
9
+ pcs_sdk.egg-info/scm_file_list.json
10
+ pcs_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ requests>=2.20
@@ -0,0 +1,8 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "pcs/__init__.py",
5
+ "pcs/client.py",
6
+ "pyproject.toml"
7
+ ]
8
+ }
@@ -0,0 +1 @@
1
+ pcs
@@ -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 = "pcs-sdk"
7
+ dynamic = ["version"]
8
+ description = "pcs API client built on requests"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = ["requests>=2.20"]
12
+ authors = [{ name = "staugur" }]
13
+
14
+ [tool.setuptools]
15
+ packages = ["pcs"]
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
+