sycommon-python-lib 0.1.46__py3-none-any.whl → 0.1.57b1__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.
- sycommon/config/Config.py +29 -4
- sycommon/config/LangfuseConfig.py +15 -0
- sycommon/config/RerankerConfig.py +1 -0
- sycommon/config/SentryConfig.py +13 -0
- sycommon/database/async_base_db_service.py +36 -0
- sycommon/database/async_database_service.py +96 -0
- sycommon/llm/__init__.py +0 -0
- sycommon/llm/embedding.py +204 -0
- sycommon/llm/get_llm.py +37 -0
- sycommon/llm/llm_logger.py +126 -0
- sycommon/llm/llm_tokens.py +119 -0
- sycommon/llm/struct_token.py +192 -0
- sycommon/llm/sy_langfuse.py +103 -0
- sycommon/llm/usage_token.py +117 -0
- sycommon/logging/async_sql_logger.py +65 -0
- sycommon/logging/kafka_log.py +200 -434
- sycommon/logging/logger_levels.py +23 -0
- sycommon/middleware/context.py +2 -0
- sycommon/middleware/exception.py +10 -16
- sycommon/middleware/timeout.py +2 -1
- sycommon/middleware/traceid.py +179 -51
- sycommon/notice/__init__.py +0 -0
- sycommon/notice/uvicorn_monitor.py +200 -0
- sycommon/rabbitmq/rabbitmq_client.py +267 -290
- sycommon/rabbitmq/rabbitmq_pool.py +277 -465
- sycommon/rabbitmq/rabbitmq_service.py +23 -891
- sycommon/rabbitmq/rabbitmq_service_client_manager.py +211 -0
- sycommon/rabbitmq/rabbitmq_service_connection_monitor.py +73 -0
- sycommon/rabbitmq/rabbitmq_service_consumer_manager.py +285 -0
- sycommon/rabbitmq/rabbitmq_service_core.py +117 -0
- sycommon/rabbitmq/rabbitmq_service_producer_manager.py +238 -0
- sycommon/sentry/__init__.py +0 -0
- sycommon/sentry/sy_sentry.py +35 -0
- sycommon/services.py +144 -115
- sycommon/synacos/feign.py +18 -7
- sycommon/synacos/feign_client.py +26 -8
- sycommon/synacos/nacos_client_base.py +119 -0
- sycommon/synacos/nacos_config_manager.py +107 -0
- sycommon/synacos/nacos_heartbeat_manager.py +144 -0
- sycommon/synacos/nacos_service.py +65 -769
- sycommon/synacos/nacos_service_discovery.py +157 -0
- sycommon/synacos/nacos_service_registration.py +270 -0
- sycommon/tools/env.py +62 -0
- sycommon/tools/merge_headers.py +117 -0
- sycommon/tools/snowflake.py +238 -23
- {sycommon_python_lib-0.1.46.dist-info → sycommon_python_lib-0.1.57b1.dist-info}/METADATA +18 -11
- sycommon_python_lib-0.1.57b1.dist-info/RECORD +89 -0
- sycommon_python_lib-0.1.46.dist-info/RECORD +0 -59
- {sycommon_python_lib-0.1.46.dist-info → sycommon_python_lib-0.1.57b1.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.1.46.dist-info → sycommon_python_lib-0.1.57b1.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.1.46.dist-info → sycommon_python_lib-0.1.57b1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def setup_logger_levels():
|
|
5
|
+
"""配置各模块的日志级别,抑制无关INFO/DEBUG日志"""
|
|
6
|
+
# Nacos 客户端:仅输出WARNING及以上(屏蔽INFO级的心跳/注册日志)
|
|
7
|
+
logging.getLogger("nacos.client").setLevel(logging.WARNING)
|
|
8
|
+
|
|
9
|
+
# Kafka Python客户端:屏蔽INFO级的连接/版本检测日志
|
|
10
|
+
logging.getLogger("kafka.conn").setLevel(logging.WARNING)
|
|
11
|
+
logging.getLogger("kafka.producer").setLevel(logging.WARNING)
|
|
12
|
+
|
|
13
|
+
# Uvicorn/FastAPI:屏蔽启动/应用初始化的INFO日志(保留ERROR/WARNING)
|
|
14
|
+
# logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
|
15
|
+
# logging.getLogger("uvicorn.access").setLevel(logging.WARNING) # 屏蔽访问日志
|
|
16
|
+
# logging.getLogger("uvicorn.error").setLevel(logging.ERROR) # 仅保留错误
|
|
17
|
+
|
|
18
|
+
# 自定义的root日志(如同步数据库/监听器初始化):屏蔽INFO
|
|
19
|
+
logging.getLogger("root").setLevel(logging.WARNING)
|
|
20
|
+
|
|
21
|
+
# RabbitMQ相关日志(如果有专属日志器)
|
|
22
|
+
logging.getLogger("pika").setLevel(logging.WARNING) # 若使用pika客户端
|
|
23
|
+
logging.getLogger("rabbitmq").setLevel(logging.WARNING)
|
sycommon/middleware/context.py
CHANGED
sycommon/middleware/exception.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from fastapi import Request, HTTPException
|
|
2
2
|
from fastapi.responses import JSONResponse
|
|
3
3
|
from pydantic import ValidationError
|
|
4
|
-
import
|
|
4
|
+
from sycommon.logging.kafka_log import SYLogger
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def setup_exception_handler(app, config: dict):
|
|
@@ -15,7 +15,7 @@ def setup_exception_handler(app, config: dict):
|
|
|
15
15
|
int_MaxBytes = int(MaxBytes) / 1024 / 1024
|
|
16
16
|
return JSONResponse(
|
|
17
17
|
content={
|
|
18
|
-
'code': 413, 'error': f'File size exceeds the allowed limit of {int_MaxBytes}MB.'},
|
|
18
|
+
'code': 413, 'error': f'File size exceeds the allowed limit of {int_MaxBytes}MB.', 'traceId': SYLogger.get_trace_id()},
|
|
19
19
|
status_code=413
|
|
20
20
|
)
|
|
21
21
|
|
|
@@ -27,7 +27,8 @@ def setup_exception_handler(app, config: dict):
|
|
|
27
27
|
content={
|
|
28
28
|
"code": exc.status_code,
|
|
29
29
|
"message": exc.detail,
|
|
30
|
-
"path": str(request.url.path)
|
|
30
|
+
"path": str(request.url.path),
|
|
31
|
+
"traceId": SYLogger.get_trace_id()
|
|
31
32
|
}
|
|
32
33
|
)
|
|
33
34
|
|
|
@@ -39,7 +40,8 @@ def setup_exception_handler(app, config: dict):
|
|
|
39
40
|
content={
|
|
40
41
|
"code": 400,
|
|
41
42
|
"message": "参数验证失败",
|
|
42
|
-
"details": exc.errors()
|
|
43
|
+
"details": exc.errors(),
|
|
44
|
+
"traceId": SYLogger.get_trace_id()
|
|
43
45
|
}
|
|
44
46
|
)
|
|
45
47
|
|
|
@@ -55,30 +57,22 @@ def setup_exception_handler(app, config: dict):
|
|
|
55
57
|
status_code=exc.code,
|
|
56
58
|
content={
|
|
57
59
|
"code": exc.code,
|
|
58
|
-
"message": exc.message
|
|
60
|
+
"message": exc.message,
|
|
61
|
+
"traceId": SYLogger.get_trace_id()
|
|
59
62
|
}
|
|
60
63
|
)
|
|
61
64
|
|
|
62
65
|
# 5. 全局异常处理器(捕获所有未处理的异常)
|
|
63
66
|
@app.exception_handler(Exception)
|
|
64
67
|
async def global_exception_handler(request: Request, exc: Exception):
|
|
65
|
-
# 记录详细错误信息
|
|
66
|
-
error_msg = f"请求路径: {request.url}\n"
|
|
67
|
-
error_msg += f"错误类型: {type(exc).__name__}\n"
|
|
68
|
-
error_msg += f"错误信息: {str(exc)}\n"
|
|
69
|
-
error_msg += f"堆栈信息: {traceback.format_exc()}"
|
|
70
|
-
|
|
71
|
-
# 使用你的日志服务记录错误
|
|
72
|
-
from sycommon.logging.kafka_log import SYLogger
|
|
73
|
-
SYLogger.error(error_msg)
|
|
74
|
-
|
|
75
68
|
# 返回统一格式的错误响应(生产环境可选择不返回详细信息)
|
|
76
69
|
return JSONResponse(
|
|
77
70
|
status_code=500,
|
|
78
71
|
content={
|
|
79
72
|
"code": 500,
|
|
80
73
|
"message": "服务器内部错误,请稍后重试",
|
|
81
|
-
"detail": str(exc) if config.get('DEBUG', False) else "Internal Server Error"
|
|
74
|
+
"detail": str(exc) if config.get('DEBUG', False) else "Internal Server Error",
|
|
75
|
+
"traceId": SYLogger.get_trace_id()
|
|
82
76
|
}
|
|
83
77
|
)
|
|
84
78
|
|
sycommon/middleware/timeout.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import time
|
|
3
3
|
from fastapi import Request
|
|
4
4
|
from fastapi.responses import JSONResponse
|
|
5
|
+
from sycommon.logging.kafka_log import SYLogger
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def setup_request_timeout_middleware(app, config: dict):
|
|
@@ -14,6 +15,6 @@ def setup_request_timeout_middleware(app, config: dict):
|
|
|
14
15
|
response = await call_next(request)
|
|
15
16
|
duration = time.time() - request.state.start_time
|
|
16
17
|
if duration > REQUEST_TIMEOUT:
|
|
17
|
-
return JSONResponse(content={'code': 1, 'error': 'Request timed out'}, status_code=504)
|
|
18
|
+
return JSONResponse(content={'code': 1, 'error': 'Request timed out', 'traceId': SYLogger.get_trace_id()}, status_code=504)
|
|
18
19
|
return response
|
|
19
20
|
return app
|
sycommon/middleware/traceid.py
CHANGED
|
@@ -3,52 +3,61 @@ import re
|
|
|
3
3
|
from typing import Dict, Any
|
|
4
4
|
from fastapi import Request, Response
|
|
5
5
|
from sycommon.logging.kafka_log import SYLogger
|
|
6
|
+
from sycommon.tools.merge_headers import merge_headers
|
|
6
7
|
from sycommon.tools.snowflake import Snowflake
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def setup_trace_id_handler(app):
|
|
10
11
|
@app.middleware("http")
|
|
11
12
|
async def trace_id_and_log_middleware(request: Request, call_next):
|
|
12
|
-
#
|
|
13
|
-
trace_id = request.headers.get("x-
|
|
13
|
+
# ========== 1. 请求阶段:获取/生成 TraceID ==========
|
|
14
|
+
trace_id = request.headers.get("x-traceid-header")
|
|
14
15
|
if not trace_id:
|
|
15
|
-
trace_id = Snowflake.
|
|
16
|
+
trace_id = Snowflake.id
|
|
16
17
|
|
|
17
|
-
# 设置 trace_id
|
|
18
|
+
# 设置 trace_id 到日志上下文
|
|
18
19
|
token = SYLogger.set_trace_id(trace_id)
|
|
20
|
+
header_token = SYLogger.set_headers(request.headers.raw)
|
|
19
21
|
|
|
20
22
|
# 获取请求参数
|
|
21
23
|
query_params = dict(request.query_params)
|
|
22
24
|
request_body: Dict[str, Any] = {}
|
|
23
25
|
files_info: Dict[str, str] = {}
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
json_content_types = [
|
|
28
|
+
"application/json",
|
|
29
|
+
"text/plain;charset=utf-8",
|
|
30
|
+
"text/plain"
|
|
31
|
+
]
|
|
26
32
|
content_type = request.headers.get("content-type", "").lower()
|
|
33
|
+
is_json_content = any(ct in content_type for ct in json_content_types)
|
|
27
34
|
|
|
28
|
-
if
|
|
35
|
+
if is_json_content and request.method in ["POST", "PUT", "PATCH"]:
|
|
29
36
|
try:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
# 兼容纯文本格式的 JSON
|
|
38
|
+
if "text/plain" in content_type:
|
|
39
|
+
raw_text = await request.text(encoding="utf-8")
|
|
40
|
+
request_body = json.loads(raw_text)
|
|
41
|
+
else:
|
|
42
|
+
request_body = await request.json()
|
|
43
|
+
except Exception:
|
|
44
|
+
try:
|
|
45
|
+
request_body = await request.json()
|
|
46
|
+
except Exception as e:
|
|
47
|
+
request_body = {"error": f"JSON parse failed: {str(e)}"}
|
|
33
48
|
|
|
34
49
|
elif "multipart/form-data" in content_type and request.method in ["POST", "PUT"]:
|
|
35
50
|
try:
|
|
36
|
-
# 从请求头中提取boundary
|
|
37
51
|
boundary = None
|
|
38
52
|
if "boundary=" in content_type:
|
|
39
53
|
boundary = content_type.split("boundary=")[1].strip()
|
|
40
54
|
boundary = boundary.encode('ascii')
|
|
41
55
|
|
|
42
56
|
if boundary:
|
|
43
|
-
# 读取原始请求体
|
|
44
57
|
body = await request.body()
|
|
45
|
-
|
|
46
|
-
# 尝试从原始请求体中提取文件名
|
|
47
58
|
parts = body.split(boundary)
|
|
48
59
|
for part in parts:
|
|
49
60
|
part_str = part.decode('utf-8', errors='ignore')
|
|
50
|
-
|
|
51
|
-
# 使用正则表达式查找文件名
|
|
52
61
|
filename_match = re.search(
|
|
53
62
|
r'filename="([^"]+)"', part_str)
|
|
54
63
|
if filename_match:
|
|
@@ -62,105 +71,224 @@ def setup_trace_id_handler(app):
|
|
|
62
71
|
request_body = {
|
|
63
72
|
"error": f"Failed to process form data: {str(e)}"}
|
|
64
73
|
|
|
65
|
-
#
|
|
74
|
+
# 构建请求日志
|
|
66
75
|
request_message = {
|
|
76
|
+
"traceId": trace_id,
|
|
67
77
|
"method": request.method,
|
|
68
78
|
"url": str(request.url),
|
|
69
79
|
"query_params": query_params,
|
|
70
80
|
"request_body": request_body,
|
|
71
81
|
"uploaded_files": files_info if files_info else None
|
|
72
82
|
}
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
SYLogger.info(json.dumps(request_message, ensure_ascii=False))
|
|
84
|
+
|
|
85
|
+
# 标记位:默认认为会发生异常
|
|
86
|
+
# 这样如果中途代码报错跳转到 except,finally 就不会 reset,保留 trace_id 给 Exception Handler
|
|
87
|
+
had_exception = True
|
|
75
88
|
|
|
76
89
|
try:
|
|
77
|
-
# 处理请求
|
|
90
|
+
# ========== 2. 处理请求 ==========
|
|
78
91
|
response = await call_next(request)
|
|
79
92
|
|
|
80
|
-
|
|
93
|
+
# ========== 3. 响应处理阶段 ==========
|
|
94
|
+
# 注意:此阶段发生的任何异常都会被下方的 except 捕获
|
|
95
|
+
# 从而保证 trace_id 不被清除,能够透传
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
response_content_type = response.headers.get(
|
|
98
|
+
"content-type", "").lower()
|
|
99
|
+
|
|
100
|
+
# 处理 SSE (Server-Sent Events)
|
|
101
|
+
if "text/event-stream" in response_content_type:
|
|
102
|
+
try:
|
|
103
|
+
response.headers["x-traceid-header"] = trace_id
|
|
104
|
+
expose_headers = response.headers.get(
|
|
105
|
+
"access-control-expose-headers", "")
|
|
106
|
+
if expose_headers:
|
|
107
|
+
if "x-traceid-header" not in expose_headers.lower():
|
|
108
|
+
response.headers[
|
|
109
|
+
"access-control-expose-headers"] = f"{expose_headers}, x-traceid-header"
|
|
110
|
+
else:
|
|
111
|
+
response.headers["access-control-expose-headers"] = "x-traceid-header"
|
|
112
|
+
|
|
113
|
+
# SSE 必须移除 Content-Length
|
|
114
|
+
headers_lower = {
|
|
115
|
+
k.lower(): k for k in response.headers.keys()}
|
|
116
|
+
if "content-length" in headers_lower:
|
|
117
|
+
del response.headers[headers_lower["content-length"]]
|
|
118
|
+
except AttributeError:
|
|
119
|
+
# 流式响应头只读处理
|
|
120
|
+
new_headers = dict(response.headers) if hasattr(
|
|
121
|
+
response.headers, 'items') else {}
|
|
122
|
+
new_headers["x-traceid-header"] = trace_id
|
|
123
|
+
if "access-control-expose-headers" in new_headers:
|
|
124
|
+
if "x-traceid-header" not in new_headers["access-control-expose-headers"].lower():
|
|
125
|
+
new_headers["access-control-expose-headers"] += ", x-traceid-header"
|
|
126
|
+
else:
|
|
127
|
+
new_headers["access-control-expose-headers"] = "x-traceid-header"
|
|
128
|
+
new_headers.pop("content-length", None)
|
|
129
|
+
response.init_headers(new_headers)
|
|
130
|
+
|
|
131
|
+
# SSE 不处理 Body,直接返回
|
|
132
|
+
had_exception = False
|
|
88
133
|
return response
|
|
89
134
|
|
|
90
|
-
#
|
|
135
|
+
# 处理非 SSE 响应
|
|
136
|
+
# 备份 CORS 头
|
|
137
|
+
cors_headers = {}
|
|
138
|
+
cors_header_keys = [
|
|
139
|
+
"access-control-allow-origin",
|
|
140
|
+
"access-control-allow-methods",
|
|
141
|
+
"access-control-allow-headers",
|
|
142
|
+
"access-control-expose-headers",
|
|
143
|
+
"access-control-allow-credentials",
|
|
144
|
+
"access-control-max-age"
|
|
145
|
+
]
|
|
146
|
+
for key in cors_header_keys:
|
|
147
|
+
for k in response.headers.keys():
|
|
148
|
+
if k.lower() == key:
|
|
149
|
+
cors_headers[key] = response.headers[k]
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
# 合并 Headers
|
|
153
|
+
merged_headers = merge_headers(
|
|
154
|
+
source_headers=request.headers,
|
|
155
|
+
target_headers=response.headers,
|
|
156
|
+
keep_keys=None,
|
|
157
|
+
delete_keys={'content-length', 'accept', 'content-type'}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 强制加入 x-traceid-header
|
|
161
|
+
merged_headers["x-traceid-header"] = trace_id
|
|
162
|
+
merged_headers.update(cors_headers)
|
|
163
|
+
|
|
164
|
+
# 更新暴露头
|
|
165
|
+
expose_headers = merged_headers.get(
|
|
166
|
+
"access-control-expose-headers", "")
|
|
167
|
+
if expose_headers:
|
|
168
|
+
if "x-traceid-header" not in expose_headers.lower():
|
|
169
|
+
merged_headers["access-control-expose-headers"] = f"{expose_headers}, x-traceid-header"
|
|
170
|
+
else:
|
|
171
|
+
merged_headers["access-control-expose-headers"] = "x-traceid-header"
|
|
172
|
+
|
|
173
|
+
# 应用 Headers
|
|
174
|
+
if hasattr(response.headers, 'clear'):
|
|
175
|
+
response.headers.clear()
|
|
176
|
+
for k, v in merged_headers.items():
|
|
177
|
+
response.headers[k] = v
|
|
178
|
+
elif hasattr(response, "init_headers"):
|
|
179
|
+
response.init_headers(merged_headers)
|
|
180
|
+
else:
|
|
181
|
+
for k, v in merged_headers.items():
|
|
182
|
+
try:
|
|
183
|
+
response.headers[k] = v
|
|
184
|
+
except (AttributeError, KeyError):
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# 处理响应体
|
|
91
188
|
response_body = b""
|
|
92
189
|
try:
|
|
93
|
-
# 收集所有响应块
|
|
94
190
|
async for chunk in response.body_iterator:
|
|
95
191
|
response_body += chunk
|
|
96
192
|
|
|
97
193
|
content_disposition = response.headers.get(
|
|
98
|
-
"
|
|
194
|
+
"content-disposition", "").lower()
|
|
99
195
|
|
|
100
|
-
#
|
|
101
|
-
if "application/json" in
|
|
196
|
+
# JSON 响应体注入 traceId
|
|
197
|
+
if "application/json" in response_content_type and not content_disposition.startswith("attachment"):
|
|
102
198
|
try:
|
|
103
199
|
data = json.loads(response_body)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
data
|
|
200
|
+
new_body = response_body
|
|
201
|
+
if isinstance(data, dict):
|
|
202
|
+
data["traceId"] = trace_id
|
|
203
|
+
new_body = json.dumps(
|
|
204
|
+
data, ensure_ascii=False).encode()
|
|
107
205
|
|
|
108
|
-
#
|
|
206
|
+
# 重建 Response 以更新 Body 和 Content-Length
|
|
109
207
|
response = Response(
|
|
110
208
|
content=new_body,
|
|
111
209
|
status_code=response.status_code,
|
|
112
210
|
headers=dict(response.headers),
|
|
113
211
|
media_type=response.media_type
|
|
114
212
|
)
|
|
115
|
-
|
|
116
|
-
response.headers["
|
|
213
|
+
response.headers["content-length"] = str(len(new_body))
|
|
214
|
+
response.headers["x-traceid-header"] = trace_id
|
|
215
|
+
# 恢复 CORS
|
|
216
|
+
for k, v in cors_headers.items():
|
|
217
|
+
response.headers[k] = v
|
|
117
218
|
except json.JSONDecodeError:
|
|
118
|
-
#
|
|
219
|
+
# 非 JSON 或解析失败,仅更新长度
|
|
119
220
|
response = Response(
|
|
120
221
|
content=response_body,
|
|
121
222
|
status_code=response.status_code,
|
|
122
223
|
headers=dict(response.headers),
|
|
123
224
|
media_type=response.media_type
|
|
124
225
|
)
|
|
125
|
-
response.headers["
|
|
226
|
+
response.headers["content-length"] = str(
|
|
126
227
|
len(response_body))
|
|
228
|
+
response.headers["x-traceid-header"] = trace_id
|
|
229
|
+
for k, v in cors_headers.items():
|
|
230
|
+
response.headers[k] = v
|
|
127
231
|
else:
|
|
128
|
-
# 非JSON
|
|
232
|
+
# 非 JSON 响应
|
|
129
233
|
response = Response(
|
|
130
234
|
content=response_body,
|
|
131
235
|
status_code=response.status_code,
|
|
132
236
|
headers=dict(response.headers),
|
|
133
237
|
media_type=response.media_type
|
|
134
238
|
)
|
|
135
|
-
response.headers["
|
|
239
|
+
response.headers["content-length"] = str(
|
|
136
240
|
len(response_body))
|
|
241
|
+
response.headers["x-traceid-header"] = trace_id
|
|
242
|
+
for k, v in cors_headers.items():
|
|
243
|
+
response.headers[k] = v
|
|
137
244
|
except StopAsyncIteration:
|
|
138
245
|
pass
|
|
139
246
|
|
|
140
|
-
#
|
|
247
|
+
# 构建响应日志
|
|
141
248
|
response_message = {
|
|
249
|
+
"traceId": trace_id,
|
|
142
250
|
"status_code": response.status_code,
|
|
143
251
|
"response_body": response_body.decode('utf-8', errors='ignore'),
|
|
144
252
|
}
|
|
145
|
-
|
|
146
|
-
response_message, ensure_ascii=False)
|
|
147
|
-
SYLogger.info(response_message_str)
|
|
253
|
+
SYLogger.info(json.dumps(response_message, ensure_ascii=False))
|
|
148
254
|
|
|
149
|
-
|
|
255
|
+
# 兜底:确保 Header 必有 TraceId
|
|
256
|
+
try:
|
|
257
|
+
response.headers["x-traceid-header"] = trace_id
|
|
258
|
+
except AttributeError:
|
|
259
|
+
new_headers = dict(response.headers) if hasattr(
|
|
260
|
+
response.headers, 'items') else {}
|
|
261
|
+
new_headers["x-traceid-header"] = trace_id
|
|
262
|
+
if hasattr(response, "init_headers"):
|
|
263
|
+
response.init_headers(new_headers)
|
|
150
264
|
|
|
265
|
+
# 如果执行到这里,说明一切正常,标记为无异常
|
|
266
|
+
had_exception = False
|
|
151
267
|
return response
|
|
268
|
+
|
|
152
269
|
except Exception as e:
|
|
270
|
+
# ========== 4. 异常处理阶段 ==========
|
|
271
|
+
# 记录中间件层面的异常日志
|
|
153
272
|
error_message = {
|
|
154
|
-
"
|
|
273
|
+
"traceId": trace_id,
|
|
274
|
+
"error": f"Middleware Error: {str(e)}",
|
|
155
275
|
"query_params": query_params,
|
|
156
276
|
"request_body": request_body,
|
|
157
277
|
"uploaded_files": files_info if files_info else None
|
|
158
278
|
}
|
|
159
|
-
|
|
160
|
-
SYLogger.error(
|
|
279
|
+
# 使用 SYLogger.error,由于处于 except 块,会自动捕获堆栈
|
|
280
|
+
SYLogger.error(error_message)
|
|
281
|
+
|
|
282
|
+
# 关键:重新抛出异常,让 Global Exception Handler 接管
|
|
283
|
+
# 此时 had_exception 仍为 True,finally 不会 reset,trace_id 得以保留
|
|
161
284
|
raise
|
|
285
|
+
|
|
162
286
|
finally:
|
|
163
|
-
#
|
|
164
|
-
|
|
287
|
+
# ========== 5. 清理阶段 ==========
|
|
288
|
+
# 只有在没有任何异常的情况下(had_exception=False),才手动清除上下文
|
|
289
|
+
if not had_exception:
|
|
290
|
+
SYLogger.reset_trace_id(token)
|
|
291
|
+
SYLogger.reset_headers(header_token)
|
|
292
|
+
# 如果 had_exception 为 True,这里什么都不做,保留 ContextVar 供 Exception Handler 读取
|
|
165
293
|
|
|
166
294
|
return app
|
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import sys
|
|
3
|
+
import traceback
|
|
4
|
+
import aiohttp
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
|
9
|
+
from sycommon.config.Config import Config
|
|
10
|
+
from sycommon.logging.kafka_log import SYLogger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def send_wechat_markdown_msg(
|
|
14
|
+
content: str,
|
|
15
|
+
webhook: str = None
|
|
16
|
+
) -> Optional[dict]:
|
|
17
|
+
"""
|
|
18
|
+
异步发送企业微信Markdown格式的WebHook消息
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
content: Markdown格式的消息内容(支持企业微信支持的markdown语法)
|
|
22
|
+
webhook: 完整的企业微信WebHook URL(默认值包含key)
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
接口返回的JSON数据(dict),失败返回None
|
|
26
|
+
"""
|
|
27
|
+
# 设置请求头
|
|
28
|
+
headers = {
|
|
29
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# 构造请求体(Markdown格式)
|
|
33
|
+
payload = {
|
|
34
|
+
"msgtype": "markdown",
|
|
35
|
+
"markdown": {
|
|
36
|
+
"content": content
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
async with aiohttp.ClientSession() as session:
|
|
42
|
+
async with session.post(
|
|
43
|
+
url=webhook,
|
|
44
|
+
data=json.dumps(payload, ensure_ascii=False),
|
|
45
|
+
headers=headers,
|
|
46
|
+
timeout=aiohttp.ClientTimeout(total=10)
|
|
47
|
+
) as response:
|
|
48
|
+
status = response.status
|
|
49
|
+
response_text = await response.text()
|
|
50
|
+
response_data = json.loads(
|
|
51
|
+
response_text) if response_text else {}
|
|
52
|
+
|
|
53
|
+
if status == 200 and response_data.get("errcode") == 0:
|
|
54
|
+
SYLogger.info(f"消息发送成功: {response_data}")
|
|
55
|
+
return response_data
|
|
56
|
+
else:
|
|
57
|
+
SYLogger.info(
|
|
58
|
+
f"消息发送失败 - 状态码: {status}, 响应: {response_data}")
|
|
59
|
+
return None
|
|
60
|
+
except Exception as e:
|
|
61
|
+
SYLogger.info(f"错误:未知异常 - {str(e)}")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def send_webhook(error_info: dict = None, webhook: str = None):
|
|
66
|
+
"""
|
|
67
|
+
发送服务启动结果的企业微信通知
|
|
68
|
+
Args:
|
|
69
|
+
error_info: 错误信息字典(启动失败时必填),包含:error_type, error_msg, stack_trace, elapsed_time
|
|
70
|
+
webhook: 完整的企业微信WebHook URL(覆盖默认值)
|
|
71
|
+
"""
|
|
72
|
+
# 获取服务名和环境(兼容配置读取失败)
|
|
73
|
+
try:
|
|
74
|
+
service_name = Config().config.get('Name', "未知服务")
|
|
75
|
+
env = Config().config.get('Nacos', {}).get('namespaceId', '未知环境')
|
|
76
|
+
webHook = Config().config.get('llm', {}).get('WebHook', '未知环境')
|
|
77
|
+
except Exception as e:
|
|
78
|
+
service_name = "未知服务"
|
|
79
|
+
env = "未知环境"
|
|
80
|
+
webHook = None
|
|
81
|
+
SYLogger.info(f"读取配置失败: {str(e)}")
|
|
82
|
+
|
|
83
|
+
start_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
84
|
+
|
|
85
|
+
# 启动失败的通知内容(包含详细错误信息)
|
|
86
|
+
error_type = error_info.get("error_type", "未知错误")
|
|
87
|
+
error_msg = error_info.get("error_msg", "无错误信息")
|
|
88
|
+
stack_trace = error_info.get("stack_trace", "无堆栈信息")[:500] # 限制长度避免超限
|
|
89
|
+
elapsed_time = error_info.get("elapsed_time", 0)
|
|
90
|
+
|
|
91
|
+
markdown_content = f"""### {service_name}服务启动失败告警 ⚠️
|
|
92
|
+
> 环境: <font color="warning">{env}</font>
|
|
93
|
+
> 启动时间: <font color="comment">{start_time}</font>
|
|
94
|
+
> 耗时: <font color="comment">{elapsed_time:.2f}秒</font>
|
|
95
|
+
> 错误类型: <font color="danger">{error_type}</font>
|
|
96
|
+
> 错误信息: <font color="danger">{error_msg}</font>
|
|
97
|
+
> 错误堆栈: {stack_trace}"""
|
|
98
|
+
|
|
99
|
+
if webhook or webHook:
|
|
100
|
+
result = await send_wechat_markdown_msg(
|
|
101
|
+
content=markdown_content,
|
|
102
|
+
webhook=webhook or webHook
|
|
103
|
+
)
|
|
104
|
+
SYLogger.info(f"通知发送结果: {result}")
|
|
105
|
+
else:
|
|
106
|
+
SYLogger.info("未设置企业微信WebHook")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run(*args, webhook: str = None, **kwargs):
|
|
110
|
+
"""
|
|
111
|
+
带企业微信告警的Uvicorn启动监控
|
|
112
|
+
调用方式1(默认配置):uvicorn_monitor.run("app:app", **app.state.config)
|
|
113
|
+
调用方式2(自定义webhook):uvicorn_monitor.run("app:app", webhook="完整URL", **app.state.config)
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
*args: 传递给uvicorn.run的位置参数(如"app:app")
|
|
117
|
+
webhook: 完整的企业微信WebHook URL(可选,覆盖默认值)
|
|
118
|
+
**kwargs: 传递给uvicorn.run的关键字参数(如app.state.config)
|
|
119
|
+
"""
|
|
120
|
+
# 判断环境
|
|
121
|
+
env = Config().config.get('Nacos', {}).get('namespaceId', '未知环境')
|
|
122
|
+
if env == "prod":
|
|
123
|
+
import uvicorn
|
|
124
|
+
uvicorn.run(*args, **kwargs)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# 记录启动开始时间
|
|
128
|
+
start_time = datetime.now()
|
|
129
|
+
|
|
130
|
+
if webhook:
|
|
131
|
+
# 脱敏展示webhook(隐藏key的后半部分)
|
|
132
|
+
parsed = urlparse(webhook)
|
|
133
|
+
query = parse_qs(parsed.query)
|
|
134
|
+
if 'key' in query and query['key'][0]:
|
|
135
|
+
key = query['key'][0]
|
|
136
|
+
masked_key = key[:8] + "****" if len(key) > 8 else key + "****"
|
|
137
|
+
query['key'] = [masked_key]
|
|
138
|
+
masked_query = urlencode(query, doseq=True)
|
|
139
|
+
masked_webhook = urlunparse(
|
|
140
|
+
(parsed.scheme, parsed.netloc, parsed.path, parsed.params, masked_query, parsed.fragment))
|
|
141
|
+
SYLogger.info(f"自定义企业微信WebHook: {masked_webhook}")
|
|
142
|
+
|
|
143
|
+
# 初始化错误信息
|
|
144
|
+
error_info = None
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
import uvicorn
|
|
148
|
+
# 执行启动(如果启动成功,此方法会阻塞,不会执行后续except)
|
|
149
|
+
uvicorn.run(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
# 处理用户手动中断(不算启动失败)
|
|
153
|
+
elapsed = (datetime.now() - start_time).total_seconds()
|
|
154
|
+
SYLogger.info(f"\n{'='*50}")
|
|
155
|
+
SYLogger.info(f"ℹ️ 应用被用户手动中断")
|
|
156
|
+
SYLogger.info(f"启动耗时: {elapsed:.2f} 秒")
|
|
157
|
+
SYLogger.info(f"{'='*50}\n")
|
|
158
|
+
sys.exit(0)
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
# 捕获启动失败异常,收集错误信息
|
|
162
|
+
elapsed = (datetime.now() - start_time).total_seconds()
|
|
163
|
+
# 捕获堆栈信息
|
|
164
|
+
stack_trace = traceback.format_exc()
|
|
165
|
+
|
|
166
|
+
# 构造错误信息字典
|
|
167
|
+
error_info = {
|
|
168
|
+
"error_type": type(e).__name__,
|
|
169
|
+
"error_msg": str(e),
|
|
170
|
+
"stack_trace": stack_trace,
|
|
171
|
+
"elapsed_time": elapsed
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# 打印错误信息
|
|
175
|
+
SYLogger.info(f"\n{'='*50}")
|
|
176
|
+
SYLogger.info(f"🚨 应用启动失败!")
|
|
177
|
+
SYLogger.info(f"失败时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
178
|
+
SYLogger.info(f"错误类型: {type(e).__name__}")
|
|
179
|
+
SYLogger.info(f"错误信息: {str(e)}")
|
|
180
|
+
SYLogger.info(f"\n📝 错误堆栈(关键):")
|
|
181
|
+
SYLogger.info(f"-"*50)
|
|
182
|
+
traceback.print_exc(file=sys.stdout)
|
|
183
|
+
SYLogger.info(f"\n⏱️ 启动耗时: {elapsed:.2f} 秒")
|
|
184
|
+
SYLogger.info(f"{'='*50}\n")
|
|
185
|
+
|
|
186
|
+
finally:
|
|
187
|
+
# 运行异步通知函数,传递自定义的webhook参数
|
|
188
|
+
try:
|
|
189
|
+
asyncio.run(send_webhook(
|
|
190
|
+
error_info=error_info,
|
|
191
|
+
webhook=webhook
|
|
192
|
+
))
|
|
193
|
+
except Exception as e:
|
|
194
|
+
SYLogger.info(f"错误:异步通知失败 - {str(e)}")
|
|
195
|
+
# 启动失败时退出程序
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# 兼容旧调用方式(可选)
|
|
200
|
+
run_uvicorn_with_monitor = run
|