lingxingapi 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of lingxingapi might be problematic. Click here for more details.
- lingxingapi/__init__.py +7 -0
- lingxingapi/ads/__init__.py +0 -0
- lingxingapi/ads/api.py +5946 -0
- lingxingapi/ads/param.py +192 -0
- lingxingapi/ads/route.py +134 -0
- lingxingapi/ads/schema.py +2615 -0
- lingxingapi/api.py +443 -0
- lingxingapi/base/__init__.py +0 -0
- lingxingapi/base/api.py +409 -0
- lingxingapi/base/param.py +59 -0
- lingxingapi/base/route.py +11 -0
- lingxingapi/base/schema.py +198 -0
- lingxingapi/basic/__init__.py +0 -0
- lingxingapi/basic/api.py +466 -0
- lingxingapi/basic/param.py +72 -0
- lingxingapi/basic/route.py +20 -0
- lingxingapi/basic/schema.py +212 -0
- lingxingapi/errors.py +143 -0
- lingxingapi/fba/__init__.py +0 -0
- lingxingapi/fba/api.py +1691 -0
- lingxingapi/fba/param.py +250 -0
- lingxingapi/fba/route.py +30 -0
- lingxingapi/fba/schema.py +987 -0
- lingxingapi/fields.py +50 -0
- lingxingapi/finance/__init__.py +0 -0
- lingxingapi/finance/api.py +3091 -0
- lingxingapi/finance/param.py +616 -0
- lingxingapi/finance/route.py +44 -0
- lingxingapi/finance/schema.py +1243 -0
- lingxingapi/product/__init__.py +0 -0
- lingxingapi/product/api.py +2643 -0
- lingxingapi/product/param.py +934 -0
- lingxingapi/product/route.py +49 -0
- lingxingapi/product/schema.py +1004 -0
- lingxingapi/purchase/__init__.py +0 -0
- lingxingapi/purchase/api.py +496 -0
- lingxingapi/purchase/param.py +126 -0
- lingxingapi/purchase/route.py +11 -0
- lingxingapi/purchase/schema.py +215 -0
- lingxingapi/sales/__init__.py +0 -0
- lingxingapi/sales/api.py +3200 -0
- lingxingapi/sales/param.py +723 -0
- lingxingapi/sales/route.py +70 -0
- lingxingapi/sales/schema.py +1718 -0
- lingxingapi/source/__init__.py +0 -0
- lingxingapi/source/api.py +1799 -0
- lingxingapi/source/param.py +176 -0
- lingxingapi/source/route.py +38 -0
- lingxingapi/source/schema.py +1011 -0
- lingxingapi/tools/__init__.py +0 -0
- lingxingapi/tools/api.py +291 -0
- lingxingapi/tools/param.py +73 -0
- lingxingapi/tools/route.py +8 -0
- lingxingapi/tools/schema.py +169 -0
- lingxingapi/utils.py +411 -0
- lingxingapi/warehourse/__init__.py +0 -0
- lingxingapi/warehourse/api.py +1778 -0
- lingxingapi/warehourse/param.py +506 -0
- lingxingapi/warehourse/route.py +28 -0
- lingxingapi/warehourse/schema.py +926 -0
- lingxingapi-1.0.0.dist-info/METADATA +67 -0
- lingxingapi-1.0.0.dist-info/RECORD +65 -0
- lingxingapi-1.0.0.dist-info/WHEEL +5 -0
- lingxingapi-1.0.0.dist-info/licenses/LICENSE +22 -0
- lingxingapi-1.0.0.dist-info/top_level.txt +1 -0
lingxingapi/base/api.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-c
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from typing_extensions import Self
|
|
4
|
+
from asyncio import sleep as _aio_sleep
|
|
5
|
+
from orjson import loads as _orjson_loads
|
|
6
|
+
from Crypto.Cipher._mode_ecb import EcbMode
|
|
7
|
+
from aiohttp import TCPConnector, ClientSession, ClientConnectorError, ClientTimeout
|
|
8
|
+
from lingxingapi import utils, errors
|
|
9
|
+
from lingxingapi.base import route, schema
|
|
10
|
+
|
|
11
|
+
# Type Aliases ---------------------------------------------------------------------------------------------------------
|
|
12
|
+
REQUEST_METHOD = Literal["GET", "POST", "PUT", "DELETE"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# API ------------------------------------------------------------------------------------------------------------------
|
|
16
|
+
class BaseAPI:
|
|
17
|
+
"""领星 API 基础类, 提供公共方法和属性, 用于子类继承
|
|
18
|
+
|
|
19
|
+
## Notice
|
|
20
|
+
请勿直接实例化此类
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# HTTP 会话
|
|
24
|
+
_session: ClientSession = None
|
|
25
|
+
# Token 令牌
|
|
26
|
+
_access_token: str = None
|
|
27
|
+
_refresh_token: str = None
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
app_id: str,
|
|
32
|
+
app_secret: str,
|
|
33
|
+
app_cipher: EcbMode,
|
|
34
|
+
timeout: int | float,
|
|
35
|
+
ignore_api_limit: bool,
|
|
36
|
+
ignore_api_limit_wait: int | float,
|
|
37
|
+
ignore_api_limit_retry: int,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""领星 API 基础类, 提供公共方法和属性供子类继承使用
|
|
40
|
+
|
|
41
|
+
## Notice
|
|
42
|
+
请勿直接实例化此类
|
|
43
|
+
|
|
44
|
+
:param app_id `<'str'>`: 应用ID, 用于鉴权
|
|
45
|
+
:param app_secret `<'str'>`: 应用密钥, 用于鉴权
|
|
46
|
+
:param app_cipher `<'EcbMode'>`: 基于 appId 创建的 AES-ECB 加密器
|
|
47
|
+
:param timeout `<'int/float'>`: 请求超时 (单位: 秒)
|
|
48
|
+
:param ignore_api_limit `<'bool'>`: 是否忽略 API 限流错误
|
|
49
|
+
|
|
50
|
+
- 如果设置为 `True`, 则在遇到限流错误时不会抛出异常, 而是会等待
|
|
51
|
+
`ignore_api_limit_wait` 秒后重试请求, 重试次数不超过 `ignore_api_limit_retry`
|
|
52
|
+
- 如果设置为 `False`, 则在遇到限流错误时直接抛出 `ApiLimitError` 异常
|
|
53
|
+
|
|
54
|
+
:param ignore_api_limit_wait `<'float'>`: 忽略 API 限流错误时的等待时间 (单位: 秒),
|
|
55
|
+
仅在 `ignore_api_limit` 为 `True` 时生效
|
|
56
|
+
|
|
57
|
+
:param ignore_api_limit_retry `<'int'>`: 忽略 API 限流错误时的最大重试次数,
|
|
58
|
+
仅在 `ignore_api_limit` 为 `True` 时生效, 若设置为 `-1` 则表示无限重试
|
|
59
|
+
"""
|
|
60
|
+
# API 凭证
|
|
61
|
+
self._app_id: str = app_id
|
|
62
|
+
self._app_secret: str = app_secret
|
|
63
|
+
self._app_cipher: EcbMode = app_cipher
|
|
64
|
+
# HTTP 会话
|
|
65
|
+
self._timeout: ClientTimeout = timeout
|
|
66
|
+
# API 限流
|
|
67
|
+
self._ignore_api_limit: bool = ignore_api_limit
|
|
68
|
+
self._ignore_api_limit_wait: float = ignore_api_limit_wait
|
|
69
|
+
self._ignore_api_limit_retry: int = ignore_api_limit_retry
|
|
70
|
+
self._infinite_retry: bool = ignore_api_limit_retry == -1
|
|
71
|
+
|
|
72
|
+
async def __aenter__(self) -> Self:
|
|
73
|
+
"""进入 API 客户端异步上下文管理器
|
|
74
|
+
|
|
75
|
+
:returns `<'API'>`: 返回 API 客户端实例
|
|
76
|
+
"""
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
80
|
+
"""退出 API 客户端异步上下文管理器, 并关闭 HTTP 会话"""
|
|
81
|
+
await self.close()
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
"""关闭 API 客户端的 HTTP 会话"""
|
|
85
|
+
if BaseAPI._session is not None:
|
|
86
|
+
await BaseAPI._session.close()
|
|
87
|
+
BaseAPI._session = None
|
|
88
|
+
|
|
89
|
+
# 公共 API --------------------------------------------------------------------------------------
|
|
90
|
+
# . Token 令牌
|
|
91
|
+
async def _AccessToken(self) -> schema.Token:
|
|
92
|
+
"""(Internal) 获取领星 API 的访问令牌
|
|
93
|
+
|
|
94
|
+
## Docs
|
|
95
|
+
- 授权: [获取 access-token和refresh-token](https://apidoc.lingxing.com/#/docs/Authorization/GetToken)
|
|
96
|
+
|
|
97
|
+
:returns `<'Token'>`: 返回接口的访问令牌
|
|
98
|
+
```python
|
|
99
|
+
{
|
|
100
|
+
# 接口的访问令牌
|
|
101
|
+
"access_token": "your_access_token",
|
|
102
|
+
# 用于续约 access_token 的更新令牌
|
|
103
|
+
"refresh_token": "your_refresh_token",
|
|
104
|
+
# 访问令牌的有效时间 (单位: 秒)
|
|
105
|
+
"expires_in": 3600
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
"""
|
|
109
|
+
data = await self._request(
|
|
110
|
+
"POST",
|
|
111
|
+
route.AUTH_GET_TOKEN,
|
|
112
|
+
{"appId": self._app_id, "appSecret": self._app_secret},
|
|
113
|
+
extract_data=True,
|
|
114
|
+
)
|
|
115
|
+
res = schema.Token.model_validate(data)
|
|
116
|
+
BaseAPI._access_token = res.access_token
|
|
117
|
+
BaseAPI._refresh_token = res.refresh_token
|
|
118
|
+
return res
|
|
119
|
+
|
|
120
|
+
async def _RefreshToken(self, refresh_token: str) -> schema.Token:
|
|
121
|
+
"""(Internal) 基于 refresh_token 刷新领星 API 的访问令牌
|
|
122
|
+
|
|
123
|
+
## Docs
|
|
124
|
+
- 授权: [续约接口令牌](https://apidoc.lingxing.com/#/docs/Authorization/RefreshToken)
|
|
125
|
+
|
|
126
|
+
:param refresh_token `<'str'>`: 用于续约 refresh_token 的令牌
|
|
127
|
+
:returns `<'Token'>`: 返回接口的访问令牌
|
|
128
|
+
```python
|
|
129
|
+
{
|
|
130
|
+
# 接口的访问令牌
|
|
131
|
+
"access_token": "your_access_token",
|
|
132
|
+
# 用于续约 access_token 的更新令牌
|
|
133
|
+
"refresh_token": "your_refresh_token",
|
|
134
|
+
# 访问令牌的有效时间 (单位: 秒)
|
|
135
|
+
"expires_in": 3600
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
"""
|
|
139
|
+
data = await self._request(
|
|
140
|
+
"POST",
|
|
141
|
+
route.AUTH_REFRESH_TOKEN,
|
|
142
|
+
{"appId": self._app_id, "refreshToken": str(refresh_token)},
|
|
143
|
+
extract_data=True,
|
|
144
|
+
)
|
|
145
|
+
res = schema.Token.model_validate(data)
|
|
146
|
+
BaseAPI._access_token = res.access_token
|
|
147
|
+
BaseAPI._refresh_token = res.refresh_token
|
|
148
|
+
return res
|
|
149
|
+
|
|
150
|
+
async def _UpdateToken(self) -> None:
|
|
151
|
+
"""(internal) 获取或刷新 access_token, 如果缓存了 refresh_token,
|
|
152
|
+
则优先基于 refresh_token 进行刷新
|
|
153
|
+
"""
|
|
154
|
+
# 如果存在 refresh token, 则使用它刷新 access token
|
|
155
|
+
if BaseAPI._refresh_token is not None:
|
|
156
|
+
try:
|
|
157
|
+
await self._RefreshToken(BaseAPI._refresh_token)
|
|
158
|
+
return None
|
|
159
|
+
except errors.RefreshTokenExpiredError:
|
|
160
|
+
pass
|
|
161
|
+
# 如果不存在 refresh token, 则重新获取 access token
|
|
162
|
+
await self._AccessToken()
|
|
163
|
+
|
|
164
|
+
# 核心 HTTP 逻辑 ---------------------------------------------------------------------------------
|
|
165
|
+
async def _request(
|
|
166
|
+
self,
|
|
167
|
+
method: REQUEST_METHOD,
|
|
168
|
+
url: str,
|
|
169
|
+
params: dict,
|
|
170
|
+
body: dict | None = None,
|
|
171
|
+
headers: dict | None = None,
|
|
172
|
+
extract_data: bool = False,
|
|
173
|
+
) -> dict | list:
|
|
174
|
+
"""(internal) 发送基础 HTTP 请求, 并对状态码与业务响应数据进行校验
|
|
175
|
+
|
|
176
|
+
:param method `<'str'>`: HTTP 请求方法, 如: `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`
|
|
177
|
+
:param url `<'str'>`: 业务请求路径, 如: `"/api/auth-server/oauth/access-token"`
|
|
178
|
+
:param params `<'dict'>`: 必填公共参数, 包含 (`app_key`, `access_token`, `timestamp`, `sign`)
|
|
179
|
+
:param body `<'dict/None'>`: 可选业务请求参数, 用于 POST/PUT 请求, 默认 `None`
|
|
180
|
+
:param headers `<'dict/None'>`: 可选请求头, 如: `{"X-API-VERSION": "2"}`, 默认 `None`
|
|
181
|
+
:param extract_data `<'bool'>`: 是否提取响应数据中的 `data` 字段并直接返回, 默认为 `False`
|
|
182
|
+
:returns `<'list/dict'>`: 返回解析并验证后响应数据
|
|
183
|
+
"""
|
|
184
|
+
# 确保 HTTP 会话可用
|
|
185
|
+
if BaseAPI._session is None or BaseAPI._session.closed:
|
|
186
|
+
BaseAPI._session = ClientSession(
|
|
187
|
+
route.API_SERVER,
|
|
188
|
+
headers={"Content-Type": "application/json"},
|
|
189
|
+
timeout=self._timeout,
|
|
190
|
+
connector=TCPConnector(limit=100),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# 发送请求
|
|
194
|
+
retry_count = 0
|
|
195
|
+
while True:
|
|
196
|
+
try:
|
|
197
|
+
async with BaseAPI._session.request(
|
|
198
|
+
method,
|
|
199
|
+
url,
|
|
200
|
+
params=params,
|
|
201
|
+
json=body,
|
|
202
|
+
headers=headers,
|
|
203
|
+
) as res:
|
|
204
|
+
# . 检查响应状态码
|
|
205
|
+
if res.status != 200:
|
|
206
|
+
raise errors.ServerError(
|
|
207
|
+
"领星API服务器响应错误", url, res.reason, res.status
|
|
208
|
+
)
|
|
209
|
+
# . 解析并验证响应数据
|
|
210
|
+
return self._handle_response_data(
|
|
211
|
+
url, await res.read(), extract_data
|
|
212
|
+
)
|
|
213
|
+
# fmt: off
|
|
214
|
+
except errors.ApiLimitError as err:
|
|
215
|
+
if self._ignore_api_limit and (self._infinite_retry or retry_count < self._ignore_api_limit_retry):
|
|
216
|
+
await _aio_sleep(self._ignore_api_limit_wait)
|
|
217
|
+
retry_count += 1
|
|
218
|
+
continue
|
|
219
|
+
raise err
|
|
220
|
+
except TimeoutError as err:
|
|
221
|
+
raise errors.ApiTimeoutError("领星 API 请求超时", url, self._timeout) from err
|
|
222
|
+
except ClientConnectorError as err:
|
|
223
|
+
raise errors.ServerError("领星 API 服务器无响应, 若无网络问题, 请检查账号 ID 白名单设置", url, err) from err
|
|
224
|
+
# fmt: on
|
|
225
|
+
|
|
226
|
+
async def _request_with_sign(
|
|
227
|
+
self,
|
|
228
|
+
method: REQUEST_METHOD,
|
|
229
|
+
url: str,
|
|
230
|
+
params: dict = None,
|
|
231
|
+
body: dict | None = None,
|
|
232
|
+
headers: dict | None = None,
|
|
233
|
+
extract_data: bool = False,
|
|
234
|
+
) -> dict | list:
|
|
235
|
+
"""(internal) 基于 params 和 body 生成签名, 并发送带签名的 HTTP 请求,自动处理过期 Access Token 的获取与刷新
|
|
236
|
+
|
|
237
|
+
:param method `<'str'>`: HTTP 请求方法, 如: `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`
|
|
238
|
+
:param url `<'str'>`: 业务请求路径, 如: `"/api/auth-server/oauth/access-token"`
|
|
239
|
+
:param params `<'dict'>`: 可选请求参数, 默认 `None`
|
|
240
|
+
:param body `<'dict/None'>`: 可选业务请参数, 默认 `None`
|
|
241
|
+
:param headers `<'dict/None'>`: 可选请求头, 如: `{"X-API-VERSION": "2"}`, 默认 `None`
|
|
242
|
+
:param extract_data `<'bool'>`: 是否提取响应数据中的 `data` 字段并直接返回, 默认为 `False`
|
|
243
|
+
:returns `<'list/dict'>`: 返回解析并验证后响应数据
|
|
244
|
+
"""
|
|
245
|
+
# 确保 access token 可用
|
|
246
|
+
if BaseAPI._access_token is None:
|
|
247
|
+
await self._UpdateToken()
|
|
248
|
+
|
|
249
|
+
# 构建参数
|
|
250
|
+
reqs_params = {
|
|
251
|
+
"app_key": self._app_id,
|
|
252
|
+
"access_token": BaseAPI._access_token,
|
|
253
|
+
"timestamp": utils.now_ts(),
|
|
254
|
+
}
|
|
255
|
+
if isinstance(params, dict):
|
|
256
|
+
reqs_params.update(params)
|
|
257
|
+
sign_params = {k: v for k, v in reqs_params.items()}
|
|
258
|
+
if isinstance(body, dict):
|
|
259
|
+
sign_params.update(body)
|
|
260
|
+
|
|
261
|
+
# 生成签名
|
|
262
|
+
sign = utils.generate_sign(sign_params, self._app_cipher)
|
|
263
|
+
reqs_params["sign"] = sign
|
|
264
|
+
|
|
265
|
+
# 发送请求
|
|
266
|
+
try:
|
|
267
|
+
return await self._request(
|
|
268
|
+
method,
|
|
269
|
+
url,
|
|
270
|
+
reqs_params,
|
|
271
|
+
body=body,
|
|
272
|
+
headers=headers,
|
|
273
|
+
extract_data=extract_data,
|
|
274
|
+
)
|
|
275
|
+
except errors.AccessTokenExpiredError:
|
|
276
|
+
await self._UpdateToken() # 刷新 Access Token
|
|
277
|
+
return await self._request_with_sign(method, url, params, body)
|
|
278
|
+
|
|
279
|
+
def _handle_response_data(
|
|
280
|
+
self,
|
|
281
|
+
url: str,
|
|
282
|
+
res_data: bytes,
|
|
283
|
+
extract_data: bool,
|
|
284
|
+
) -> dict | list:
|
|
285
|
+
"""解析并验证响应数据, 并数据中的 `data` 字段
|
|
286
|
+
|
|
287
|
+
:param url `<'str'>`: 对应业务请求路径, 用于构建错误信息
|
|
288
|
+
:param res_data `<'bytes'>`: 原始响应数据
|
|
289
|
+
:param extract_data `<'bool'>`: 是否提取响应数据中的 `data` 字段
|
|
290
|
+
:returns `<'dict/list'>`: 返回解析并验证后的响应数据中的 `data` 字段
|
|
291
|
+
"""
|
|
292
|
+
# 解析响应数据
|
|
293
|
+
try:
|
|
294
|
+
data: dict = _orjson_loads(res_data)
|
|
295
|
+
except Exception as err:
|
|
296
|
+
raise errors.ResponseDataError(
|
|
297
|
+
"响应数据解析错误, 可能不是有效的JSON格式", url, res_data
|
|
298
|
+
) from err
|
|
299
|
+
|
|
300
|
+
# 验证响应数据
|
|
301
|
+
code = data.get("code")
|
|
302
|
+
if code is None:
|
|
303
|
+
raise errors.ResponseDataError("响应数据错误, 缺少 code 字段", url, data)
|
|
304
|
+
if code not in (0, 1, "200"):
|
|
305
|
+
try:
|
|
306
|
+
errno: int = int(code)
|
|
307
|
+
except ValueError:
|
|
308
|
+
raise errors.ResponseDataError(
|
|
309
|
+
"响应数据错误, code 字段不是整数", url, data, code
|
|
310
|
+
)
|
|
311
|
+
# 常见错误码
|
|
312
|
+
if errno == 2001003:
|
|
313
|
+
raise errors.AccessTokenExpiredError(
|
|
314
|
+
"access token 过期, 请重新获取", url, data, code
|
|
315
|
+
)
|
|
316
|
+
if errno == 2001008:
|
|
317
|
+
raise errors.RefreshTokenExpiredError(
|
|
318
|
+
"refresh token过期, 请重新获取", url, data, code
|
|
319
|
+
)
|
|
320
|
+
if errno == 2001007:
|
|
321
|
+
raise errors.SignatureExpiredError(
|
|
322
|
+
"签名过期, 请重新发起请求", url, data, code
|
|
323
|
+
)
|
|
324
|
+
if errno == 3001008:
|
|
325
|
+
raise errors.TooManyRequestsError(
|
|
326
|
+
"接口请求太频繁触发限流", url, data, code
|
|
327
|
+
)
|
|
328
|
+
if errno == 103:
|
|
329
|
+
raise errors.ApiLimitError(
|
|
330
|
+
"请求速率过高导致被限流拦截", url, data, code
|
|
331
|
+
)
|
|
332
|
+
# 其他错误码
|
|
333
|
+
if errno in (400, 405, 500):
|
|
334
|
+
raise errors.InvalidApiUrlError(
|
|
335
|
+
"请求 url 或 params 不正确", url, data, code
|
|
336
|
+
)
|
|
337
|
+
if errno == 2001001:
|
|
338
|
+
raise errors.AppIdOrSecretError(
|
|
339
|
+
"appId 不存在, 请检查值有效性", url, data, code
|
|
340
|
+
)
|
|
341
|
+
if errno == 1001:
|
|
342
|
+
raise errors.AppIdOrSecretError(
|
|
343
|
+
"appSecret 中可能有特殊字符", url, data, code
|
|
344
|
+
)
|
|
345
|
+
if errno == 2001002:
|
|
346
|
+
raise errors.AppIdOrSecretError(
|
|
347
|
+
"appSecret 不正确, 请检查值有效性", url, data, code
|
|
348
|
+
)
|
|
349
|
+
if errno == 2001004:
|
|
350
|
+
raise errors.UnauthorizedApiError(
|
|
351
|
+
"请求的 api 被未授权, 请联系领星确认", url, data, code
|
|
352
|
+
)
|
|
353
|
+
if errno == 401:
|
|
354
|
+
raise errors.UnauthorizedApiError(
|
|
355
|
+
"api 授权被禁用, 请检查授权状态", url, data, code
|
|
356
|
+
)
|
|
357
|
+
if errno == 403:
|
|
358
|
+
raise errors.UnauthorizedApiError(
|
|
359
|
+
"api 授权失效, 请检查授权状态", url, data, code
|
|
360
|
+
)
|
|
361
|
+
if errno == 2001005:
|
|
362
|
+
raise errors.InvalidAccessTokenError(
|
|
363
|
+
"access token 不正确, 请检查有效性", url, data, code
|
|
364
|
+
)
|
|
365
|
+
if errno == 2001009:
|
|
366
|
+
raise errors.InvalidRefreshTokenError(
|
|
367
|
+
"refresh token 不正确, 请检查有效性", url, data, code
|
|
368
|
+
)
|
|
369
|
+
if errno == 2001006:
|
|
370
|
+
raise errors.InvalidSignatureError(
|
|
371
|
+
"接口签名不正确, 请检查生成签名的正确性", url, data, code
|
|
372
|
+
)
|
|
373
|
+
if errno == 102:
|
|
374
|
+
raise errors.InvalidParametersError("参数不合法", url, data, code)
|
|
375
|
+
if errno == 3001001:
|
|
376
|
+
# fmt: off
|
|
377
|
+
raise errors.InvalidParametersError(
|
|
378
|
+
"必传参数缺失, 公共参数必须包含 (access_token, app_key, timestamp, sign)",
|
|
379
|
+
url, data, code,
|
|
380
|
+
)
|
|
381
|
+
# fmt: on
|
|
382
|
+
if errno == 3001002:
|
|
383
|
+
raise errors.UnauthorizedRequestIpError(
|
|
384
|
+
"发起请求的 ip 未加入领星 api 白名单", url, data, code
|
|
385
|
+
)
|
|
386
|
+
# 未知错误码
|
|
387
|
+
raise errors.UnknownRequestError("未知的 api 错误", url, data, code)
|
|
388
|
+
|
|
389
|
+
# 是否成功 (特殊返回检查情况)
|
|
390
|
+
if not data.get("success", True):
|
|
391
|
+
raise errors.InvalidParametersError("参数不合法", url, data, code)
|
|
392
|
+
|
|
393
|
+
# 返回响应数据
|
|
394
|
+
return data if not extract_data else self._extract_data(data, url, code)
|
|
395
|
+
|
|
396
|
+
def _extract_data(self, res_data: dict, url: str, code: int | None = None) -> dict:
|
|
397
|
+
"""(internal) 提取响应数据中的 `data` 字段
|
|
398
|
+
|
|
399
|
+
:param res_data `<'dict'>`: 原始响应数据
|
|
400
|
+
:param url `<'str'>`: 对应业务请求路径, 用于构建错误信息
|
|
401
|
+
:param code `<'int'>`: 可选的状态码, 用于构建错误信息
|
|
402
|
+
:returns `<'dict'>`: 返回解析并验证后的响应数据中的 `data` 字段
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
return res_data["data"]
|
|
406
|
+
except KeyError:
|
|
407
|
+
raise errors.ResponseDataError(
|
|
408
|
+
"响应数据错误, 缺少 data 字段", url, res_data, code
|
|
409
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
from lingxingapi.fields import NonNegativeInt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# 基础模型 -----------------------------------------------------------------------------------------------------------------------
|
|
8
|
+
class Parameter(BaseModel):
|
|
9
|
+
model_config = ConfigDict(
|
|
10
|
+
populate_by_name=True,
|
|
11
|
+
str_strip_whitespace=True,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def model_dump_params(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
include=None,
|
|
18
|
+
exclude=None,
|
|
19
|
+
context=None,
|
|
20
|
+
by_alias=True,
|
|
21
|
+
exclude_unset=False,
|
|
22
|
+
exclude_defaults=False,
|
|
23
|
+
exclude_none=True,
|
|
24
|
+
round_trip=False,
|
|
25
|
+
warnings=True,
|
|
26
|
+
fallback=None,
|
|
27
|
+
serialize_as_any=False,
|
|
28
|
+
) -> dict:
|
|
29
|
+
"""将模型转换为字典, 并按字母顺序排序键 `<'dict'>`."""
|
|
30
|
+
res = self.model_dump(
|
|
31
|
+
mode="python",
|
|
32
|
+
include=include,
|
|
33
|
+
exclude=exclude,
|
|
34
|
+
context=context,
|
|
35
|
+
by_alias=by_alias,
|
|
36
|
+
exclude_unset=exclude_unset,
|
|
37
|
+
exclude_defaults=exclude_defaults,
|
|
38
|
+
exclude_none=exclude_none,
|
|
39
|
+
round_trip=round_trip,
|
|
40
|
+
warnings=warnings,
|
|
41
|
+
fallback=fallback,
|
|
42
|
+
serialize_as_any=serialize_as_any,
|
|
43
|
+
)
|
|
44
|
+
return dict(sorted(res.items()))
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def model_validate_params(cls, data: object) -> dict:
|
|
48
|
+
"""将传入的数据进行验证, 转换为字典, 且按字母顺序排序键"""
|
|
49
|
+
if isinstance(data, BaseModel) and not isinstance(data, cls):
|
|
50
|
+
data = data.model_dump()
|
|
51
|
+
return cls.model_validate(data).model_dump_params()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# 公用参数 -----------------------------------------------------------------------------------------------------------------------
|
|
55
|
+
class PageOffestAndLength(Parameter):
|
|
56
|
+
# 分页偏移量
|
|
57
|
+
offset: Optional[NonNegativeInt] = None
|
|
58
|
+
# 分页长度
|
|
59
|
+
length: Optional[NonNegativeInt] = None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
# fmt: off
|
|
4
|
+
# API 域名 ----------------------------------------------------------------------------------------------------------------------
|
|
5
|
+
API_SERVER: str = "https://openapi.lingxing.com"
|
|
6
|
+
|
|
7
|
+
# Token 令牌 --------------------------------------------------------------------------------------------------------------------
|
|
8
|
+
# https://apidoc.lingxing.com/#/docs/Authorization/GetToken
|
|
9
|
+
AUTH_GET_TOKEN: str = "/api/auth-server/oauth/access-token"
|
|
10
|
+
# https://apidoc.lingxing.com/#/docs/Authorization/RefreshToken
|
|
11
|
+
AUTH_REFRESH_TOKEN: str = "/api/auth-server/oauth/refresh"
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import datetime
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from typing_extensions import Self
|
|
5
|
+
from pydantic import BaseModel, Field, model_validator
|
|
6
|
+
from lingxingapi import errors
|
|
7
|
+
from lingxingapi.fields import StrOrNone2Blank
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Token 令牌 --------------------------------------------------------------------------------------------------------------------
|
|
11
|
+
class Token(BaseModel):
|
|
12
|
+
# 接口的访问令牌
|
|
13
|
+
access_token: str
|
|
14
|
+
# 用于续约 access_token 的更新令牌
|
|
15
|
+
refresh_token: str
|
|
16
|
+
# 访问令牌的有效时间 (单位: 秒)
|
|
17
|
+
expires_in: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# 公用 Schema -------------------------------------------------------------------------------------------------------------------
|
|
21
|
+
class TagInfo(BaseModel):
|
|
22
|
+
"""商品的标签信息."""
|
|
23
|
+
|
|
24
|
+
# 领星标签ID (GlobalTag.tag_id) [原字段 'global_tag_id']
|
|
25
|
+
tag_id: str = Field(validation_alias="global_tag_id")
|
|
26
|
+
# 领星标签名称 (GlobalTag.tag_name) [原字段 'tag_name']
|
|
27
|
+
tag_name: str = Field(validation_alias="tag_name")
|
|
28
|
+
# 领星标签颜色 (如: "#FF0000") [原字段 'color']
|
|
29
|
+
tag_color: str = Field(validation_alias="color")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AttachmentFile(BaseModel):
|
|
33
|
+
"""附件信息"""
|
|
34
|
+
|
|
35
|
+
# 文件ID
|
|
36
|
+
file_id: int
|
|
37
|
+
# 文件名称
|
|
38
|
+
file_name: str
|
|
39
|
+
# 文件类型 (0: 未知, 1: 图片, 2: 压缩包)
|
|
40
|
+
file_type: int = 0
|
|
41
|
+
# 文件链接
|
|
42
|
+
file_url: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SpuProductAttribute(BaseModel):
|
|
46
|
+
"""SPU 商品属性"""
|
|
47
|
+
|
|
48
|
+
# 属性ID
|
|
49
|
+
attr_id: int
|
|
50
|
+
# 属性名称
|
|
51
|
+
attr_name: str
|
|
52
|
+
# 属性值
|
|
53
|
+
attr_value: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CustomField(BaseModel):
|
|
57
|
+
"""自定义字段"""
|
|
58
|
+
|
|
59
|
+
# 自定义字段ID
|
|
60
|
+
field_id: int = Field(validation_alias="id")
|
|
61
|
+
# 自定义字段名称
|
|
62
|
+
field_name: str = Field(validation_alias="name")
|
|
63
|
+
# 自定义字段值
|
|
64
|
+
field_value: str = Field(validation_alias="val_text")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BaseResponse(BaseModel):
|
|
68
|
+
"""基础响应数据"""
|
|
69
|
+
|
|
70
|
+
# 状态码
|
|
71
|
+
code: int = 0
|
|
72
|
+
# 提示信息
|
|
73
|
+
message: Optional[StrOrNone2Blank] = None
|
|
74
|
+
# 错误信息
|
|
75
|
+
errors: Optional[list] = Field(None, validation_alias="error_details")
|
|
76
|
+
# 请求链路id
|
|
77
|
+
request_id: Optional[str] = None
|
|
78
|
+
# 响应时间
|
|
79
|
+
response_time: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
82
|
+
@model_validator(mode="after")
|
|
83
|
+
def _adjustments(self) -> Self:
|
|
84
|
+
# 设置默认数值
|
|
85
|
+
if self.message is None:
|
|
86
|
+
self.message = "success"
|
|
87
|
+
if self.errors is None:
|
|
88
|
+
self.errors = []
|
|
89
|
+
if self.request_id is None:
|
|
90
|
+
self.request_id = ""
|
|
91
|
+
if self.response_time is None:
|
|
92
|
+
# fmt: off
|
|
93
|
+
dt = datetime.datetime.now()
|
|
94
|
+
self.response_time = "%04d-%02d-%02d %02d:%02d:%02d" % (
|
|
95
|
+
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
|
|
96
|
+
)
|
|
97
|
+
# fmt: on
|
|
98
|
+
|
|
99
|
+
# 返回
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ResponseV1(BaseResponse):
|
|
104
|
+
"""响应数据 - 下划线命名"""
|
|
105
|
+
|
|
106
|
+
# 响应数据量
|
|
107
|
+
response_count: int = 0
|
|
108
|
+
# 总数据量
|
|
109
|
+
total_count: int = Field(0, validation_alias="total")
|
|
110
|
+
# 响应数据
|
|
111
|
+
data: Any = None
|
|
112
|
+
|
|
113
|
+
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
114
|
+
@model_validator(mode="after")
|
|
115
|
+
def _set_count(self) -> Self:
|
|
116
|
+
# 计算响应数据量
|
|
117
|
+
if isinstance(self.data, list):
|
|
118
|
+
self.response_count = len(self.data)
|
|
119
|
+
elif isinstance(self.data, (dict, BaseModel)):
|
|
120
|
+
self.response_count = 1
|
|
121
|
+
else:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
# 调整总数据量
|
|
125
|
+
if self.response_count > 0:
|
|
126
|
+
self.total_count = max(self.total_count, self.response_count)
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ResponseV1Token(ResponseV1):
|
|
131
|
+
"""响应数据 - 下划线命名 + 分页游标 (next_token)"""
|
|
132
|
+
|
|
133
|
+
# 分页游标
|
|
134
|
+
next_token: StrOrNone2Blank
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ResponseV1TraceId(ResponseV1):
|
|
138
|
+
"""响应数据 - request_id 替换为 traceId"""
|
|
139
|
+
|
|
140
|
+
# 请求链路id
|
|
141
|
+
request_id: Optional[str] = Field(None, validation_alias="traceId")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ResponseV2(ResponseV1):
|
|
145
|
+
"""响应数据 - 驼峰命名"""
|
|
146
|
+
|
|
147
|
+
# 错误信息
|
|
148
|
+
errors: Optional[list] = Field(None, validation_alias="errorDetails")
|
|
149
|
+
# 请求链路id
|
|
150
|
+
request_id: Optional[str] = Field(None, validation_alias="requestId")
|
|
151
|
+
# 响应时间
|
|
152
|
+
response_time: Optional[str] = Field(None, validation_alias="responseTime")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ResponseResult(BaseResponse):
|
|
156
|
+
"""响应结果"""
|
|
157
|
+
|
|
158
|
+
# 响应结果
|
|
159
|
+
data: Any = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# 特殊 Schema -------------------------------------------------------------------------------------------------------------------
|
|
163
|
+
class FlattenDataRecords(BaseModel):
|
|
164
|
+
"""从嵌套数据中提取列表数据 (records)
|
|
165
|
+
|
|
166
|
+
- 1. 从 data 字段中, 提取 total 并赋值至基础层
|
|
167
|
+
- 2. 从 data 字段中, 提取 records 并覆盖 data 字段
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
171
|
+
@model_validator(mode="before")
|
|
172
|
+
def _flatten_data(cls, data: dict) -> dict:
|
|
173
|
+
try:
|
|
174
|
+
inner: dict = data.pop("data", {})
|
|
175
|
+
data["total"] = max(inner.get("total", 0), data.get("total", 0))
|
|
176
|
+
data["data"] = inner.get("records", [])
|
|
177
|
+
except Exception:
|
|
178
|
+
raise errors.ResponseDataError(cls.__name__, data=data)
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class FlattenDataList(BaseModel):
|
|
183
|
+
"""从嵌套数据中提取列表数据 (list)
|
|
184
|
+
|
|
185
|
+
- 1. 从 data 字段中, 提取 total 并赋值至基础层
|
|
186
|
+
- 2. 从 data 字段中, 提取 list 并覆盖 data 字段
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
190
|
+
@model_validator(mode="before")
|
|
191
|
+
def _flatten_data(cls, data: dict) -> dict:
|
|
192
|
+
try:
|
|
193
|
+
inner: dict = data.pop("data", {})
|
|
194
|
+
data["total"] = max(inner.get("total", 0), data.get("total", 0))
|
|
195
|
+
data["data"] = inner.get("list", [])
|
|
196
|
+
except Exception:
|
|
197
|
+
raise errors.ResponseDataError(cls.__name__, data=data)
|
|
198
|
+
return data
|
|
File without changes
|