KairoCore 1.0.0__py3-none-any.whl → 1.2.1__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 KairoCore might be problematic. Click here for more details.
- KairoCore/__init__.py +9 -2
- KairoCore/app.py +7 -2
- KairoCore/common/errors.py +39 -1
- KairoCore/docs/CodeGenerateDoc.md +58 -0
- KairoCore/docs/FileUploadDoc.md +142 -0
- KairoCore/docs/HttpSessionDoc.md +170 -0
- KairoCore/docs/TokenUseDoc.md +349 -0
- KairoCore/docs/UseDoc.md +174 -0
- KairoCore/example/your_project_name/action/api_key_admin.py +42 -0
- KairoCore/example/your_project_name/action/auth.py +105 -0
- KairoCore/example/your_project_name/action/file_upload.py +71 -0
- KairoCore/example/your_project_name/action/http_demo.py +64 -0
- KairoCore/example/your_project_name/action/protected_demo.py +85 -0
- KairoCore/example/your_project_name/schema/auth.py +14 -0
- KairoCore/extensions/baidu/yijian.py +0 -0
- KairoCore/utils/auth.py +629 -0
- KairoCore/utils/kc_http.py +260 -0
- KairoCore/utils/kc_upload.py +218 -0
- KairoCore/utils/panic.py +21 -1
- KairoCore/utils/router.py +19 -1
- {kairocore-1.0.0.dist-info → kairocore-1.2.1.dist-info}/METADATA +5 -1
- {kairocore-1.0.0.dist-info → kairocore-1.2.1.dist-info}/RECORD +24 -9
- {kairocore-1.0.0.dist-info → kairocore-1.2.1.dist-info}/WHEEL +0 -0
- {kairocore-1.0.0.dist-info → kairocore-1.2.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Dict, Optional, Union, Mapping
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from ..utils.log import get_logger
|
|
7
|
+
from ..common.errors import (
|
|
8
|
+
KCHT_INIT_ERROR,
|
|
9
|
+
KCHT_REQUEST_ERROR,
|
|
10
|
+
KCHT_TIMEOUT_ERROR,
|
|
11
|
+
KCHT_STATUS_ERROR,
|
|
12
|
+
KCHT_PARSE_ERROR,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
logger = get_logger()
|
|
16
|
+
|
|
17
|
+
class KcHttpResponse:
|
|
18
|
+
"""
|
|
19
|
+
统一的 HTTP 响应封装
|
|
20
|
+
|
|
21
|
+
字段:
|
|
22
|
+
- status_code: int 状态码
|
|
23
|
+
- headers: Dict[str, str] 响应头
|
|
24
|
+
- data: Any 解析后的数据(按 Content-Type 自动解析 json / text / bytes)
|
|
25
|
+
- raw: httpx.Response 原始响应对象,保留供高级使用
|
|
26
|
+
|
|
27
|
+
说明:
|
|
28
|
+
- 当解析失败时抛出 KCHT_PARSE_ERROR,调用方应捕获并按需处理。
|
|
29
|
+
- is_ok() 用于快速判断 2xx 响应。
|
|
30
|
+
"""
|
|
31
|
+
def __init__(self, resp: httpx.Response):
|
|
32
|
+
# 基本属性直接从原始响应复制
|
|
33
|
+
self.status_code = resp.status_code
|
|
34
|
+
self.headers = dict(resp.headers)
|
|
35
|
+
self.raw = resp
|
|
36
|
+
# 尝试解析响应数据
|
|
37
|
+
self.data = None
|
|
38
|
+
try:
|
|
39
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
40
|
+
if "application/json" in content_type:
|
|
41
|
+
self.data = resp.json() # 自动 JSON 解析
|
|
42
|
+
elif "text/" in content_type or content_type == "":
|
|
43
|
+
self.data = resp.text # 文本或未声明类型,按文本处理
|
|
44
|
+
else:
|
|
45
|
+
self.data = resp.content # 其他类型按二进制处理
|
|
46
|
+
except Exception as e:
|
|
47
|
+
# 解析失败统一包装为 PARSE_ERROR,便于上层捕获
|
|
48
|
+
raise KCHT_PARSE_ERROR.msg_format(str(e))
|
|
49
|
+
|
|
50
|
+
def is_ok(self) -> bool:
|
|
51
|
+
"""是否为 2xx 状态。"""
|
|
52
|
+
return 200 <= self.status_code < 300
|
|
53
|
+
|
|
54
|
+
class KcHttpSession:
|
|
55
|
+
"""
|
|
56
|
+
异步 HTTP 会话类(基于 httpx.AsyncClient)。
|
|
57
|
+
|
|
58
|
+
能力:
|
|
59
|
+
- 连接池与超时配置
|
|
60
|
+
- 带退避的重试(超时/服务端错误)
|
|
61
|
+
- 统一的异常与日志
|
|
62
|
+
- 便捷的请求方法与下载方法
|
|
63
|
+
|
|
64
|
+
典型用法:
|
|
65
|
+
- 在应用启动时创建实例,并在关闭时调用 close() 释放资源
|
|
66
|
+
- 或通过 async with 语法在局部作用域中使用
|
|
67
|
+
"""
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
base_url: Optional[str] = None,
|
|
71
|
+
timeout: float = 10.0,
|
|
72
|
+
max_keepalive: int = 10,
|
|
73
|
+
retries: int = 2,
|
|
74
|
+
retry_backoff: float = 0.5,
|
|
75
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
76
|
+
verify: Union[bool, str] = True,
|
|
77
|
+
proxies: Optional[Union[str, Dict[str, str]]] = None,
|
|
78
|
+
):
|
|
79
|
+
try:
|
|
80
|
+
# 基础配置与参数归一化
|
|
81
|
+
self.base_url = base_url
|
|
82
|
+
self.timeout = httpx.Timeout(timeout)
|
|
83
|
+
self.retries = max(0, retries)
|
|
84
|
+
self.retry_backoff = max(0.0, retry_backoff)
|
|
85
|
+
self.headers = dict(headers or {}) # 会话级默认请求头(可用于鉴权)
|
|
86
|
+
self.verify = verify
|
|
87
|
+
self.proxies = proxies
|
|
88
|
+
# 连接池配置(max_keepalive 用于并发连接与复用)
|
|
89
|
+
limits = httpx.Limits(max_keepalive_connections=max_keepalive, max_connections=max_keepalive)
|
|
90
|
+
# 创建底层异步客户端
|
|
91
|
+
self._client: Optional[httpx.AsyncClient] = httpx.AsyncClient(
|
|
92
|
+
base_url=self.base_url,
|
|
93
|
+
timeout=self.timeout,
|
|
94
|
+
headers=self.headers,
|
|
95
|
+
verify=self.verify,
|
|
96
|
+
proxies=self.proxies,
|
|
97
|
+
limits=limits,
|
|
98
|
+
follow_redirects=True, # 默认为允许跟随重定向
|
|
99
|
+
)
|
|
100
|
+
logger.info(f"KcHttpSession 初始化完成 base_url={self.base_url}, timeout={timeout}, retries={self.retries}")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
# 初始化失败统一包装为 INIT_ERROR
|
|
103
|
+
raise KCHT_INIT_ERROR.msg_format(str(e))
|
|
104
|
+
|
|
105
|
+
async def __aenter__(self):
|
|
106
|
+
"""支持 async with 用法。"""
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
110
|
+
"""退出时自动关闭底层客户端。"""
|
|
111
|
+
await self.close()
|
|
112
|
+
|
|
113
|
+
async def close(self):
|
|
114
|
+
"""关闭会话,释放连接池资源。"""
|
|
115
|
+
if self._client:
|
|
116
|
+
await self._client.aclose()
|
|
117
|
+
logger.info("KcHttpSession 已关闭")
|
|
118
|
+
|
|
119
|
+
async def _request(
|
|
120
|
+
self,
|
|
121
|
+
method: str,
|
|
122
|
+
url: str,
|
|
123
|
+
params: Optional[Dict[str, Any]] = None,
|
|
124
|
+
data: Optional[Union[Dict[str, Any], str, bytes]] = None,
|
|
125
|
+
json: Optional[Any] = None,
|
|
126
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
127
|
+
timeout: Optional[float] = None,
|
|
128
|
+
) -> KcHttpResponse:
|
|
129
|
+
"""
|
|
130
|
+
底层请求方法(带重试与退避)。
|
|
131
|
+
|
|
132
|
+
流程:
|
|
133
|
+
1) 合并会话级与方法级的 headers
|
|
134
|
+
2) 处理超时参数(方法级优先)
|
|
135
|
+
3) 循环重试:
|
|
136
|
+
- 5xx 作为服务端错误可重试
|
|
137
|
+
- 4xx 客户端错误不重试,直接抛出状态异常
|
|
138
|
+
- 超时按配置重试,最后一次抛出超时异常
|
|
139
|
+
4) 请求成功后封装为 KcHttpResponse 返回
|
|
140
|
+
"""
|
|
141
|
+
attempt = 0
|
|
142
|
+
last_exc: Optional[Exception] = None
|
|
143
|
+
# 合并请求头:方法级覆盖会话级
|
|
144
|
+
req_headers = dict(self.headers)
|
|
145
|
+
if headers:
|
|
146
|
+
req_headers.update(headers)
|
|
147
|
+
# 处理方法级超时
|
|
148
|
+
req_timeout = self.timeout if timeout is None else httpx.Timeout(timeout)
|
|
149
|
+
while attempt <= self.retries:
|
|
150
|
+
try:
|
|
151
|
+
logger.debug(f"HTTP {method} {url} attempt={attempt} params={params} headers={req_headers}")
|
|
152
|
+
resp = await self._client.request(
|
|
153
|
+
method,
|
|
154
|
+
url,
|
|
155
|
+
params=params,
|
|
156
|
+
data=data,
|
|
157
|
+
json=json,
|
|
158
|
+
headers=req_headers,
|
|
159
|
+
timeout=req_timeout,
|
|
160
|
+
)
|
|
161
|
+
# 状态码检查:500+ 作为可重试的服务端错误,400-499 直接抛出客户端错误
|
|
162
|
+
if resp.status_code >= 500:
|
|
163
|
+
raise httpx.HTTPStatusError("server error", request=resp.request, response=resp)
|
|
164
|
+
elif resp.status_code >= 400:
|
|
165
|
+
raise httpx.HTTPStatusError("client error", request=resp.request, response=resp)
|
|
166
|
+
result = KcHttpResponse(resp)
|
|
167
|
+
return result
|
|
168
|
+
except httpx.TimeoutException as e:
|
|
169
|
+
# 超时:记录最后异常并决定是否结束重试
|
|
170
|
+
last_exc = e
|
|
171
|
+
logger.warning(f"HTTP 超时: {method} {url} attempt={attempt} err={e}")
|
|
172
|
+
if attempt >= self.retries:
|
|
173
|
+
raise KCHT_TIMEOUT_ERROR.msg_format(str(e))
|
|
174
|
+
except httpx.HTTPStatusError as e:
|
|
175
|
+
# 状态码异常:4xx 不重试;5xx 可根据 attempt 决定重试
|
|
176
|
+
last_exc = e
|
|
177
|
+
status = getattr(e.response, "status_code", None)
|
|
178
|
+
logger.warning(f"HTTP 状态异常: {method} {url} status={status} attempt={attempt} err={e}")
|
|
179
|
+
if attempt >= self.retries or (status and 400 <= status < 500):
|
|
180
|
+
# 客户端错误不重试
|
|
181
|
+
raise KCHT_STATUS_ERROR.msg_format(f"status={status}: {str(e)}")
|
|
182
|
+
except httpx.HTTPError as e:
|
|
183
|
+
# httpx 的其他错误(网络异常等)
|
|
184
|
+
last_exc = e
|
|
185
|
+
logger.error(f"HTTP 请求异常: {method} {url} attempt={attempt} err={e}")
|
|
186
|
+
if attempt >= self.retries:
|
|
187
|
+
raise KCHT_REQUEST_ERROR.msg_format(str(e))
|
|
188
|
+
except Exception as e:
|
|
189
|
+
# 未知异常统一包装为 REQUEST_ERROR
|
|
190
|
+
last_exc = e
|
|
191
|
+
logger.error(f"未知请求异常: {method} {url} attempt={attempt} err={e}")
|
|
192
|
+
if attempt >= self.retries:
|
|
193
|
+
raise KCHT_REQUEST_ERROR.msg_format(str(e))
|
|
194
|
+
# 退避等待(逐次递增),继续下一次尝试
|
|
195
|
+
attempt += 1
|
|
196
|
+
await asyncio.sleep(self.retry_backoff * attempt)
|
|
197
|
+
# 正常不会走到这里,兜底抛出最后一个异常信息
|
|
198
|
+
raise KCHT_REQUEST_ERROR.msg_format(str(last_exc) if last_exc else "未知错误")
|
|
199
|
+
|
|
200
|
+
# 公开方法
|
|
201
|
+
async def get(self, url: str, params: Optional[Dict[str, Any]] = None, headers: Optional[Mapping[str, str]] = None, timeout: Optional[float] = None) -> KcHttpResponse:
|
|
202
|
+
"""GET 请求。"""
|
|
203
|
+
return await self._request("GET", url, params=params, headers=headers, timeout=timeout)
|
|
204
|
+
|
|
205
|
+
async def post(self, url: str, data: Optional[Union[Dict[str, Any], str, bytes]] = None, json: Optional[Any] = None, headers: Optional[Mapping[str, str]] = None, timeout: Optional[float] = None) -> KcHttpResponse:
|
|
206
|
+
"""POST 请求(支持 form/data/raw 或 JSON)。"""
|
|
207
|
+
return await self._request("POST", url, data=data, json=json, headers=headers, timeout=timeout)
|
|
208
|
+
|
|
209
|
+
async def put(self, url: str, data: Optional[Union[Dict[str, Any], str, bytes]] = None, json: Optional[Any] = None, headers: Optional[Mapping[str, str]] = None, timeout: Optional[float] = None) -> KcHttpResponse:
|
|
210
|
+
"""PUT 请求(支持 form/data/raw 或 JSON)。"""
|
|
211
|
+
return await self._request("PUT", url, data=data, json=json, headers=headers, timeout=timeout)
|
|
212
|
+
|
|
213
|
+
async def delete(self, url: str, params: Optional[Dict[str, Any]] = None, headers: Optional[Mapping[str, str]] = None, timeout: Optional[float] = None) -> KcHttpResponse:
|
|
214
|
+
"""DELETE 请求(支持查询参数)。"""
|
|
215
|
+
return await self._request("DELETE", url, params=params, headers=headers, timeout=timeout)
|
|
216
|
+
|
|
217
|
+
async def download(self, url: str, save_path: str, chunk_size: int = 1024 * 64, headers: Optional[Mapping[str, str]] = None, timeout: Optional[float] = None) -> str:
|
|
218
|
+
"""流式下载文件到指定路径,返回保存路径。
|
|
219
|
+
|
|
220
|
+
- 使用 httpx.AsyncClient.stream 进行边读边写,适合大文件
|
|
221
|
+
- chunk_size 默认 64KB,可按网络与磁盘性能调整
|
|
222
|
+
- 支持方法级 headers 与超时覆盖会话级配置
|
|
223
|
+
- 4xx 直接视为状态异常;其他错误分别包装为超时/请求错误
|
|
224
|
+
"""
|
|
225
|
+
# 合并请求头与超时设置
|
|
226
|
+
req_headers = dict(self.headers)
|
|
227
|
+
if headers:
|
|
228
|
+
req_headers.update(headers)
|
|
229
|
+
req_timeout = self.timeout if timeout is None else httpx.Timeout(timeout)
|
|
230
|
+
try:
|
|
231
|
+
async with self._client.stream("GET", url, headers=req_headers, timeout=req_timeout) as resp:
|
|
232
|
+
if resp.status_code >= 400:
|
|
233
|
+
# 客户端或服务端错误直接抛出状态异常
|
|
234
|
+
raise KCHT_STATUS_ERROR.msg_format(f"status={resp.status_code}")
|
|
235
|
+
with open(save_path, "wb") as f:
|
|
236
|
+
async for chunk in resp.aiter_bytes(chunk_size):
|
|
237
|
+
f.write(chunk)
|
|
238
|
+
logger.info(f"下载完成: {url} -> {save_path}")
|
|
239
|
+
return save_path
|
|
240
|
+
except httpx.TimeoutException as e:
|
|
241
|
+
# 超时错误单独包装,便于调用层区分
|
|
242
|
+
raise KCHT_TIMEOUT_ERROR.msg_format(str(e))
|
|
243
|
+
except httpx.HTTPError as e:
|
|
244
|
+
# 其他 HTTP 错误统一包装为请求错误
|
|
245
|
+
raise KCHT_REQUEST_ERROR.msg_format(str(e))
|
|
246
|
+
except Exception as e:
|
|
247
|
+
# 未知错误统一包装为请求错误
|
|
248
|
+
raise KCHT_REQUEST_ERROR.msg_format(str(e))
|
|
249
|
+
|
|
250
|
+
# FastAPI 生命周期集成示例(可选)
|
|
251
|
+
# 在 app.py 或 main.py 中:
|
|
252
|
+
# from .utils.kc_http import KcHttpSession
|
|
253
|
+
# kc_http = KcHttpSession(base_url="https://api.example.com", timeout=10, retries=2)
|
|
254
|
+
# app.state.kc_http = kc_http
|
|
255
|
+
# @app.on_event("startup")
|
|
256
|
+
# async def startup_event():
|
|
257
|
+
# pass
|
|
258
|
+
# @app.on_event("shutdown")
|
|
259
|
+
# async def shutdown_event():
|
|
260
|
+
# await app.state.kc_http.close()
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KcUploader: 文件上传/下载/导入/导出工具类
|
|
3
|
+
|
|
4
|
+
提供四类能力:
|
|
5
|
+
- save_upload_file: 处理 FastAPI UploadFile(multipart/form-data)并流式写入磁盘
|
|
6
|
+
- save_base64: 处理 Base64 字符串并写入磁盘
|
|
7
|
+
- export_to_base64: 将本地文件内容导出为 Base64 字符串(便于前端或第三方传输)
|
|
8
|
+
- build_download_response: 构建用于浏览器下载的响应对象(FileResponse)
|
|
9
|
+
|
|
10
|
+
异常处理:统一使用 common.errors 中定义的 Panic 常量
|
|
11
|
+
日志:统一使用项目日志器
|
|
12
|
+
安全约束:所有保存操作使用 os.path.basename 规避路径穿越,仅允许纯文件名
|
|
13
|
+
"""
|
|
14
|
+
# 导入类型注解与标准库
|
|
15
|
+
from typing import Optional, Dict
|
|
16
|
+
import os # 文件系统操作(目录创建、路径拼接、文件读写)
|
|
17
|
+
import base64 # Base64 编解码
|
|
18
|
+
|
|
19
|
+
# FastAPI 类型:用于接收前端上传的文件
|
|
20
|
+
from fastapi import UploadFile
|
|
21
|
+
|
|
22
|
+
# 项目内的错误常量与日志工具
|
|
23
|
+
from ..common.errors import (
|
|
24
|
+
KCU_SAVE_DIR_EMPTY_ERROR, # 保存目录为空
|
|
25
|
+
KCU_MKDIR_ERROR, # 目录创建失败
|
|
26
|
+
KCU_FILENAME_EMPTY_ERROR, # 文件名为空
|
|
27
|
+
KCU_PARAM_MISSING_ERROR, # 通用参数缺失错误
|
|
28
|
+
KCU_BASE64_PARSE_ERROR, # Base64 解析失败
|
|
29
|
+
KCU_UPLOAD_SAVE_ERROR, # 上传保存失败
|
|
30
|
+
KCU_BASE64_SAVE_ERROR, # Base64 保存失败
|
|
31
|
+
)
|
|
32
|
+
from ..utils.panic import Panic
|
|
33
|
+
from ..utils.log import get_logger
|
|
34
|
+
import mimetypes # 推断 MIME 类型,用于下载响应的 Content-Type
|
|
35
|
+
from fastapi.responses import FileResponse # 用于构建浏览器下载响应
|
|
36
|
+
|
|
37
|
+
logger = get_logger()
|
|
38
|
+
|
|
39
|
+
class KcUploader:
|
|
40
|
+
"""
|
|
41
|
+
文件上传/下载相关的通用工具类。
|
|
42
|
+
|
|
43
|
+
方法总览:
|
|
44
|
+
- save_upload_file(file, target_dir, filename): 保存 multipart/form-data 上传的文件
|
|
45
|
+
- save_base64(content_base64, filename, target_dir): 保存 Base64 字符串为文件
|
|
46
|
+
- export_to_base64(src_path): 将本地文件导出为 Base64 字符串
|
|
47
|
+
- build_download_response(src_path, download_name, media_type, inline): 构建浏览器下载响应
|
|
48
|
+
|
|
49
|
+
使用建议:
|
|
50
|
+
- 实例化时可指定 default_target_dir(默认 /tmp),也可在方法调用时传入 target_dir 覆盖
|
|
51
|
+
- 建议在路由层对可下载/可保存的目录做白名单限制
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, default_target_dir: str = "/tmp"):
|
|
55
|
+
# 默认保存目录,可在具体调用时覆盖
|
|
56
|
+
self.default_target_dir = default_target_dir
|
|
57
|
+
|
|
58
|
+
def _ensure_dir(self, dir_path: str) -> None:
|
|
59
|
+
# 校验目录参数
|
|
60
|
+
if not dir_path:
|
|
61
|
+
raise KCU_SAVE_DIR_EMPTY_ERROR
|
|
62
|
+
try:
|
|
63
|
+
# 确保目录存在(不存在则创建)
|
|
64
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
# 创建目录失败时抛出统一错误
|
|
67
|
+
raise KCU_MKDIR_ERROR.msg_format(str(e))
|
|
68
|
+
|
|
69
|
+
def _safe_join(self, target_dir: Optional[str], filename: Optional[str]) -> str:
|
|
70
|
+
# 取调用传入目录或默认目录
|
|
71
|
+
dir_path = target_dir or self.default_target_dir
|
|
72
|
+
# 确保目录存在与可写
|
|
73
|
+
self._ensure_dir(dir_path)
|
|
74
|
+
# 仅使用纯文件名,防止路径穿越
|
|
75
|
+
name = os.path.basename(filename or "")
|
|
76
|
+
if not name:
|
|
77
|
+
raise KCU_FILENAME_EMPTY_ERROR
|
|
78
|
+
# 拼接安全的保存路径
|
|
79
|
+
return os.path.join(dir_path, name)
|
|
80
|
+
|
|
81
|
+
async def save_upload_file(self, file: UploadFile, target_dir: Optional[str] = None, filename: Optional[str] = None) -> Dict[str, str]:
|
|
82
|
+
"""
|
|
83
|
+
保存 multipart/form-data 上传的文件。
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
file (UploadFile): FastAPI UploadFile 对象
|
|
87
|
+
target_dir (str, optional): 保存目录,默认使用初始化的 default_target_dir
|
|
88
|
+
filename (str, optional): 自定义文件名(仅文件名,不含路径)。不提供则使用原始文件名
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Dict[str, str]: {"saved": 保存路径, "size": 写入字节数}
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
# 校验上传文件与文件名
|
|
95
|
+
if not file or not file.filename:
|
|
96
|
+
raise KCU_FILENAME_EMPTY_ERROR
|
|
97
|
+
# 生成安全的保存路径
|
|
98
|
+
save_path = self._safe_join(target_dir, filename or file.filename)
|
|
99
|
+
size = 0
|
|
100
|
+
# 以流式方式写入,避免一次性加载大文件至内存
|
|
101
|
+
with open(save_path, "wb") as f:
|
|
102
|
+
while True:
|
|
103
|
+
# 1MB/chunk 读取上传内容
|
|
104
|
+
chunk = await file.read(1024 * 1024)
|
|
105
|
+
if not chunk:
|
|
106
|
+
break
|
|
107
|
+
size += len(chunk)
|
|
108
|
+
f.write(chunk)
|
|
109
|
+
logger.info(f"文件上传保存成功: path={save_path}, size={size}")
|
|
110
|
+
return {"saved": save_path, "size": str(size)}
|
|
111
|
+
except Panic:
|
|
112
|
+
# 上层已定义的业务中断异常,直接抛出以便统一处理
|
|
113
|
+
raise
|
|
114
|
+
except Exception as e:
|
|
115
|
+
# 其他异常统一包装为上传保存失败
|
|
116
|
+
raise KCU_UPLOAD_SAVE_ERROR.msg_format(str(e))
|
|
117
|
+
|
|
118
|
+
async def save_base64(self, content_base64: str, filename: str, target_dir: Optional[str] = None) -> Dict[str, str]:
|
|
119
|
+
"""
|
|
120
|
+
保存 Base64 编码的文件内容。
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
content_base64 (str): Base64 字符串
|
|
124
|
+
filename (str): 保存文件名(仅文件名,不含路径)
|
|
125
|
+
target_dir (str, optional): 保存目录
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Dict[str, str]: {"saved": 保存路径, "size": 写入字节数}
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
# 基本参数校验
|
|
132
|
+
if not content_base64 or not filename:
|
|
133
|
+
raise KCU_PARAM_MISSING_ERROR
|
|
134
|
+
# 生成安全的保存路径
|
|
135
|
+
save_path = self._safe_join(target_dir, filename)
|
|
136
|
+
try:
|
|
137
|
+
# Base64 → bytes
|
|
138
|
+
file_bytes = base64.b64decode(content_base64)
|
|
139
|
+
except Exception:
|
|
140
|
+
# Base64 内容不合法
|
|
141
|
+
raise KCU_BASE64_PARSE_ERROR
|
|
142
|
+
# 写入文件
|
|
143
|
+
with open(save_path, "wb") as f:
|
|
144
|
+
f.write(file_bytes)
|
|
145
|
+
size = len(file_bytes)
|
|
146
|
+
logger.info(f"Base64 文件保存成功: path={save_path}, size={size}")
|
|
147
|
+
return {"saved": save_path, "size": str(size)}
|
|
148
|
+
except Panic:
|
|
149
|
+
raise
|
|
150
|
+
except Exception as e:
|
|
151
|
+
# 其他异常统一包装为 Base64 保存失败
|
|
152
|
+
raise KCU_BASE64_SAVE_ERROR.msg_format(str(e))
|
|
153
|
+
|
|
154
|
+
async def export_to_base64(self, src_path: str) -> Dict[str, str]:
|
|
155
|
+
"""
|
|
156
|
+
将本地文件内容导出为 Base64 字符串(便于通过 JSON 接口或第三方传输)。
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
# 校验源文件路径
|
|
160
|
+
if not src_path:
|
|
161
|
+
raise KCU_PARAM_MISSING_ERROR
|
|
162
|
+
if not os.path.exists(src_path):
|
|
163
|
+
raise KCU_UPLOAD_SAVE_ERROR.msg_format("file not exist")
|
|
164
|
+
# 读取二进制并编码为 Base64
|
|
165
|
+
with open(src_path, "rb") as f:
|
|
166
|
+
data = f.read()
|
|
167
|
+
content_b64 = base64.b64encode(data).decode("ascii")
|
|
168
|
+
logger.info(f"文件导出为 Base64 成功: path={src_path}, size={len(data)}")
|
|
169
|
+
return {"path": src_path, "size": str(len(data)), "content_base64": content_b64}
|
|
170
|
+
except Panic:
|
|
171
|
+
raise
|
|
172
|
+
except Exception as e:
|
|
173
|
+
# 统一包装为上传保存错误(文件访问/IO 异常等)
|
|
174
|
+
raise KCU_UPLOAD_SAVE_ERROR.msg_format(str(e))
|
|
175
|
+
|
|
176
|
+
# 新增:构建用于浏览器下载的响应
|
|
177
|
+
async def build_download_response(
|
|
178
|
+
self,
|
|
179
|
+
src_path: str,
|
|
180
|
+
download_name: Optional[str] = None,
|
|
181
|
+
media_type: Optional[str] = None,
|
|
182
|
+
inline: bool = False,
|
|
183
|
+
) -> FileResponse:
|
|
184
|
+
"""
|
|
185
|
+
构建使浏览器触发下载的响应对象(支持设置文件名、Content-Type 与是否内联)。
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
src_path (str): 本地文件路径
|
|
189
|
+
download_name (str, optional): 下载时展示的文件名,默认取源文件名
|
|
190
|
+
media_type (str, optional): MIME 类型,不传则根据扩展名推断
|
|
191
|
+
inline (bool, optional): 是否内联显示(默认 False,即作为附件下载)
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
FileResponse: 可直接在路由中 return 的响应
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
# 基本校验:路径存在且可读
|
|
198
|
+
if not src_path:
|
|
199
|
+
raise KCU_PARAM_MISSING_ERROR
|
|
200
|
+
if not os.path.exists(src_path):
|
|
201
|
+
raise KCU_UPLOAD_SAVE_ERROR.msg_format("file not exist")
|
|
202
|
+
# 下载文件名:优先使用调用者指定,否则取源文件名
|
|
203
|
+
name = download_name or os.path.basename(src_path)
|
|
204
|
+
# 推断 Content-Type:不传则根据扩展名推断,兜底为 application/octet-stream
|
|
205
|
+
ctype = media_type
|
|
206
|
+
if not ctype:
|
|
207
|
+
guessed, _ = mimetypes.guess_type(name)
|
|
208
|
+
ctype = guessed or "application/octet-stream"
|
|
209
|
+
# Content-Disposition: attachment 触发下载;inline 尝试预览显示(如图片/PDF)
|
|
210
|
+
disposition = "inline" if inline else "attachment"
|
|
211
|
+
headers = {"Content-Disposition": f'{disposition}; filename="{name}"'}
|
|
212
|
+
logger.info(f"构建下载响应: path={src_path}, name={name}, inline={inline}")
|
|
213
|
+
return FileResponse(src_path, media_type=ctype, headers=headers)
|
|
214
|
+
except Panic:
|
|
215
|
+
raise
|
|
216
|
+
except Exception as e:
|
|
217
|
+
# 统一包装为上传保存错误(路径/IO 异常等)
|
|
218
|
+
raise KCU_UPLOAD_SAVE_ERROR.msg_format(str(e))
|
KairoCore/utils/panic.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, List, Dict
|
|
1
|
+
from typing import Any, List, Dict, Awaitable, TypeVar
|
|
2
2
|
from fastapi import FastAPI as KarioCore
|
|
3
3
|
from fastapi.responses import JSONResponse
|
|
4
4
|
from fastapi.exceptions import RequestValidationError, HTTPException
|
|
@@ -9,6 +9,8 @@ from ..utils.log import get_logger
|
|
|
9
9
|
|
|
10
10
|
logger = get_logger()
|
|
11
11
|
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
12
14
|
class Panic(Exception):
|
|
13
15
|
"""
|
|
14
16
|
自定义业务异常类,用于处理应用程序中的业务逻辑错误
|
|
@@ -189,3 +191,21 @@ def register_exception_handlers(app: KarioCore):
|
|
|
189
191
|
}
|
|
190
192
|
)
|
|
191
193
|
|
|
194
|
+
# 通用路由异常包装,确保接口层统一返回 KCFU_* 异常
|
|
195
|
+
async def exec_with_route_error(awaitable: Awaitable[T], error_const: Panic) -> T:
|
|
196
|
+
# 参数校验:awaitable 必须为可等待对象,error_const 必须为 Panic 实例
|
|
197
|
+
# 惰性导入集中定义的 Panic 常量,避免模块级循环依赖
|
|
198
|
+
from ..common.errors import (
|
|
199
|
+
KCP_EXEC_AWAITABLE_TYPE_ERROR,
|
|
200
|
+
KCP_EXEC_PANIC_CONST_TYPE_ERROR,
|
|
201
|
+
)
|
|
202
|
+
if not hasattr(awaitable, "__await"):
|
|
203
|
+
raise KCP_EXEC_AWAITABLE_TYPE_ERROR
|
|
204
|
+
if not isinstance(error_const, Panic):
|
|
205
|
+
raise KCP_EXEC_PANIC_CONST_TYPE_ERROR
|
|
206
|
+
try:
|
|
207
|
+
return await awaitable
|
|
208
|
+
except Panic:
|
|
209
|
+
raise
|
|
210
|
+
except Exception as e:
|
|
211
|
+
raise error_const.msg_format(str(e)) from e
|
KairoCore/utils/router.py
CHANGED
|
@@ -2,6 +2,8 @@ import os
|
|
|
2
2
|
import importlib.util
|
|
3
3
|
import inspect
|
|
4
4
|
import functools
|
|
5
|
+
from fastapi import FastAPI as KarioCore
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
7
|
from typing import Any, Callable, get_type_hints, Optional
|
|
6
8
|
from fastapi import FastAPI, APIRouter, params
|
|
7
9
|
from fastapi.responses import FileResponse
|
|
@@ -10,13 +12,29 @@ from ..utils.log import get_logger
|
|
|
10
12
|
|
|
11
13
|
app_logger = get_logger()
|
|
12
14
|
# --- 定义允许的参数名称 ---
|
|
13
|
-
ALLOWED_PARAM_NAMES = {"query", "body"}
|
|
15
|
+
ALLOWED_PARAM_NAMES = {"query", "body", "file"}
|
|
14
16
|
# --- 定义参数到 FastAPI 依赖类型的映射 ---
|
|
15
17
|
PARAM_TO_DEPENDENCY_TYPE = {
|
|
16
18
|
"query": params.Query,
|
|
17
19
|
"body": params.Body,
|
|
20
|
+
"file": params.File,
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
def add_cors_middleware(app: KarioCore) -> None:
|
|
24
|
+
"""
|
|
25
|
+
为应用添加CORS中间件以解决跨域问题
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
app (KarioCore): KairoCore应用实例
|
|
29
|
+
"""
|
|
30
|
+
app.add_middleware(
|
|
31
|
+
CORSMiddleware,
|
|
32
|
+
allow_origins=["*"], # 在生产环境中应该指定具体的域名
|
|
33
|
+
allow_credentials=True,
|
|
34
|
+
allow_methods=["*"],
|
|
35
|
+
allow_headers=["*"],
|
|
36
|
+
)
|
|
37
|
+
|
|
20
38
|
def _create_enforced_wrapper(original_func: Callable, allowed_param_names: set):
|
|
21
39
|
"""
|
|
22
40
|
创建一个包装函数,该函数强制执行允许的参数名称
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: KairoCore
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: 时机,即是智能 —— 为关键时刻而生的核心API引擎。
|
|
5
5
|
Author-email: Fatosy <fatosy_code@163.com>
|
|
6
6
|
License: MIT
|
|
@@ -27,6 +27,10 @@ Requires-Dist: cryptography>=45.0.7
|
|
|
27
27
|
Requires-Dist: pytz>=2025.2
|
|
28
28
|
Requires-Dist: aiozk>=0.32.0
|
|
29
29
|
Requires-Dist: jinja2>=3.1.6
|
|
30
|
+
Requires-Dist: httpx>=0.28.1
|
|
31
|
+
Requires-Dist: starlette>=0.47.3
|
|
32
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
33
|
+
Requires-Dist: cryptography>=45.0.7
|
|
30
34
|
|
|
31
35
|
# zWebApi
|
|
32
36
|
一个功能丰富、开箱即用的 Python Web 框架,基于 FastAPI 构建。它旨在通过约定优于配置的原则,简化 API 开发流程,提供自动路由、统一异常处理、日志记录和可扩展工具集。
|
|
@@ -1,16 +1,26 @@
|
|
|
1
|
-
KairoCore/__init__.py,sha256=
|
|
2
|
-
KairoCore/app.py,sha256=
|
|
1
|
+
KairoCore/__init__.py,sha256=B53ucNBXlGMXwumrvs7icBINQwz8PE3suODDSDumZJg,878
|
|
2
|
+
KairoCore/app.py,sha256=TQkNgKIuUps8nYFTWqj9Zn7X8FYeqXxQU9HxTvgi9Hk,1220
|
|
3
3
|
KairoCore/code_generate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
KairoCore/code_generate/code_generator.py,sha256=HoYh4nNrLRl_k1OXwESWHZuJy7xxqCEJ8-KP-ck3D9U,14346
|
|
5
5
|
KairoCore/code_generate/generate_page.html,sha256=ZDIcfwcMkkOKyOj1TVUtJz_fOBvQL7pWZl5aQwOkwoY,31733
|
|
6
6
|
KairoCore/code_generate/generator_router.py,sha256=nx3zs7TynQq4cs_FQ_HiympiWuAmT7L1ut1o73gzjGU,1836
|
|
7
7
|
KairoCore/common/consts.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
KairoCore/common/errors.py,sha256=
|
|
8
|
+
KairoCore/common/errors.py,sha256=KZ3h6jirFfmxUYwzoMJL4_IzealnCCi-0qv6gTWmNJU,3766
|
|
9
9
|
KairoCore/common/http.py,sha256=YKj7M1vjRu8mos_XvoIEzYYfH-O9_-g9EIvqaXQGN9U,3177
|
|
10
10
|
KairoCore/db_tools/kc_mysql.py,sha256=o7ko8q8uv7PO1tWEsfEBTwyZt34MxRYC32x3aINjRRI,21633
|
|
11
11
|
KairoCore/db_tools/kc_redis.py,sha256=kQgl7SovxNK7XKmFrbQ_M9zRC0UaE_aUZR66Nq76fVo,15049
|
|
12
12
|
KairoCore/db_tools/kc_zookeeper.py,sha256=wXw6EkNQPlGFZeXcQL6fxcAjC1i7Eqt4hE8CcdJIU3E,21182
|
|
13
|
+
KairoCore/docs/CodeGenerateDoc.md,sha256=EwWvqoaGN72wGKBOCRrPMLgH0cLqSNRI7SslrmVTymU,2437
|
|
14
|
+
KairoCore/docs/FileUploadDoc.md,sha256=64Gc8yinO54F4YzgpZUpXjR69UObFlBUKTvwQ1LFPDQ,4855
|
|
15
|
+
KairoCore/docs/HttpSessionDoc.md,sha256=I9Lu9zEve74uUIY_LzIjn_E1macUsLn39DCwWJt4M1A,5767
|
|
16
|
+
KairoCore/docs/TokenUseDoc.md,sha256=J0UX81TwdfP0AuMHhZx3VvnWxrXtULLpHITabEVEMF8,11885
|
|
17
|
+
KairoCore/docs/UseDoc.md,sha256=OVrhRvOSEH11Ec--HPgk4Y9LrVORYkFTsJbQxpUeDuA,5206
|
|
13
18
|
KairoCore/example/your_project_name/main.py,sha256=JmTP5uB7lL3lPVJaOCU938rJ0x9GJgVdiylN6C1eHvw,150
|
|
19
|
+
KairoCore/example/your_project_name/action/api_key_admin.py,sha256=wJHBg1xBdaTLr8Gc8OviuMcbrAiz5sBJ7PFdjPWu234,1715
|
|
20
|
+
KairoCore/example/your_project_name/action/auth.py,sha256=zbiQjrX_rDXHf3_JJFN4RKARt4yNz2d_gKZey2SCF1c,4109
|
|
21
|
+
KairoCore/example/your_project_name/action/file_upload.py,sha256=iiJSt9I_u0r9n0CoDJSWr0Rlfu1m7s5rso7dUOuQYbk,2939
|
|
22
|
+
KairoCore/example/your_project_name/action/http_demo.py,sha256=BxGQtWmUucTP40o8Z0zCJHg2iGwLJm_wNsHp3xQw7OY,2934
|
|
23
|
+
KairoCore/example/your_project_name/action/protected_demo.py,sha256=1Zd3E5wUmYWivFy2EJEVl1KLNOZ6YfFqarkeYrqKUcU,2975
|
|
14
24
|
KairoCore/example/your_project_name/action/user.py,sha256=ckvEVFRQXMHH5oTI3cuNpqEHMFGmDe_VfVmxdjvH-MU,1902
|
|
15
25
|
KairoCore/example/your_project_name/common/consts.py,sha256=tPndbDTFIUSWIrrxRJ20SKXLqn7UX6BjOx3GG9mRDhg,55
|
|
16
26
|
KairoCore/example/your_project_name/common/errors.py,sha256=bUD1VAdS9XOZKaYGwiIoQMGKUSnJ74ZogXW6pJS7-58,163
|
|
@@ -18,7 +28,9 @@ KairoCore/example/your_project_name/dao/__init__.py,sha256=47DEQpj8HBSa-_TImW-5J
|
|
|
18
28
|
KairoCore/example/your_project_name/dao/user.py,sha256=vG7tIKiNqV9XaEyDficOysyHYPaeYKSEaZalIYAvCtE,3736
|
|
19
29
|
KairoCore/example/your_project_name/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
30
|
KairoCore/example/your_project_name/domain/user.py,sha256=flSGde-62HfL7WWajAaHKRemzIXHD5l7jwtscEUVG1k,2455
|
|
31
|
+
KairoCore/example/your_project_name/schema/auth.py,sha256=vfvfU-QEq-9nFbPHxPes0Q1_0327MWgyd6D1LrIEwo8,310
|
|
21
32
|
KairoCore/example/your_project_name/schema/user.py,sha256=tyjJT5UpZHhFphWq_CukpfEem3xOPi1-kCh6ZhV1Yj0,2738
|
|
33
|
+
KairoCore/extensions/baidu/yijian.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
34
|
KairoCore/imgs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
35
|
KairoCore/imgs/code_generator.png,sha256=hja21o4WE2KvO8GUYNQlGH4bwNdoWmTbYe4NYggwiWg,452411
|
|
24
36
|
KairoCore/imgs/favicon.ico,sha256=3QYUzKzoYYSoxvTdypRIu-s-7E5k82GNVe7vmaoJM8g,964
|
|
@@ -31,14 +43,17 @@ KairoCore/imgs/logo-pixel-64.jpg,sha256=QVRPPdW35FWM4DLs4IR9Rih1pQCAWUuryPiHoDev
|
|
|
31
43
|
KairoCore/imgs/logo-pixel.png,sha256=UGWJoY3m93eI3ZSGuLr7_0GY_LZ_v9pOMdaab618xoc,1781142
|
|
32
44
|
KairoCore/imgs/logo.png,sha256=0CkWFgPrYPvc3X6F8GuN-8CPAxzQ8pgRbMbgBb1GO6w,1074473
|
|
33
45
|
KairoCore/imgs/pay.png,sha256=PbEIWER5GAXFNXge_-LM6syYnQwP_A9hr79MfXGDc_U,341875
|
|
46
|
+
KairoCore/utils/auth.py,sha256=LvdSnY7IDLGldQHnaSgVlTx-7f7iTNqeC9WewGjaHBA,25806
|
|
47
|
+
KairoCore/utils/kc_http.py,sha256=Nkc1m1ySwTjrtyeCpx5h5s1hZA0PKNPDxywV2z0B0UI,11859
|
|
34
48
|
KairoCore/utils/kc_re.py,sha256=Yl-cAvpu_XPS6HJ2E3uIHyjVxzx5_tciL-BlrO5-_Jo,1817
|
|
35
49
|
KairoCore/utils/kc_timer.py,sha256=N7MCnG02K1S_l0cWl0S6aF5yYox7Ze6VEcQBb3HMvcI,12261
|
|
50
|
+
KairoCore/utils/kc_upload.py,sha256=wk7wGwBQNIwOSM5RsrwrPRPjX42L-nlNDeAK18brLKM,9682
|
|
36
51
|
KairoCore/utils/log.py,sha256=a6W0Q7Wnwv8xeHC4SAny37i7tH-5MaxhdKxLB8kkzJk,4950
|
|
37
|
-
KairoCore/utils/panic.py,sha256=
|
|
38
|
-
KairoCore/utils/router.py,sha256=
|
|
52
|
+
KairoCore/utils/panic.py,sha256=MMEgK_8iMTTaulRLkZ2uPIvCx2b_J522YSkoYxj-i6M,7252
|
|
53
|
+
KairoCore/utils/router.py,sha256=fVD0VZmlYp_7b5lb3MifvluL9eHXMGP2JrfNxSSIDo8,19114
|
|
39
54
|
KairoCore/utils/sql_tool.py,sha256=WtmeAAcmzVJktRx_Es_w0RN73QsUZlV7C-E5J6d0Zn8,8809
|
|
40
55
|
imgs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
41
|
-
kairocore-1.
|
|
42
|
-
kairocore-1.
|
|
43
|
-
kairocore-1.
|
|
44
|
-
kairocore-1.
|
|
56
|
+
kairocore-1.2.1.dist-info/METADATA,sha256=QXmLxQs3jRr6CeWK_jUuTNN7z2AOtLt3pLdXnzijSJE,13198
|
|
57
|
+
kairocore-1.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
58
|
+
kairocore-1.2.1.dist-info/top_level.txt,sha256=N1Rj81TOK7s9p9oB4ej0Q4MNCdcdgrn-JopszDuvAvA,15
|
|
59
|
+
kairocore-1.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|