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.
Files changed (51) hide show
  1. sycommon/config/Config.py +29 -4
  2. sycommon/config/LangfuseConfig.py +15 -0
  3. sycommon/config/RerankerConfig.py +1 -0
  4. sycommon/config/SentryConfig.py +13 -0
  5. sycommon/database/async_base_db_service.py +36 -0
  6. sycommon/database/async_database_service.py +96 -0
  7. sycommon/llm/__init__.py +0 -0
  8. sycommon/llm/embedding.py +204 -0
  9. sycommon/llm/get_llm.py +37 -0
  10. sycommon/llm/llm_logger.py +126 -0
  11. sycommon/llm/llm_tokens.py +119 -0
  12. sycommon/llm/struct_token.py +192 -0
  13. sycommon/llm/sy_langfuse.py +103 -0
  14. sycommon/llm/usage_token.py +117 -0
  15. sycommon/logging/async_sql_logger.py +65 -0
  16. sycommon/logging/kafka_log.py +200 -434
  17. sycommon/logging/logger_levels.py +23 -0
  18. sycommon/middleware/context.py +2 -0
  19. sycommon/middleware/exception.py +10 -16
  20. sycommon/middleware/timeout.py +2 -1
  21. sycommon/middleware/traceid.py +179 -51
  22. sycommon/notice/__init__.py +0 -0
  23. sycommon/notice/uvicorn_monitor.py +200 -0
  24. sycommon/rabbitmq/rabbitmq_client.py +267 -290
  25. sycommon/rabbitmq/rabbitmq_pool.py +277 -465
  26. sycommon/rabbitmq/rabbitmq_service.py +23 -891
  27. sycommon/rabbitmq/rabbitmq_service_client_manager.py +211 -0
  28. sycommon/rabbitmq/rabbitmq_service_connection_monitor.py +73 -0
  29. sycommon/rabbitmq/rabbitmq_service_consumer_manager.py +285 -0
  30. sycommon/rabbitmq/rabbitmq_service_core.py +117 -0
  31. sycommon/rabbitmq/rabbitmq_service_producer_manager.py +238 -0
  32. sycommon/sentry/__init__.py +0 -0
  33. sycommon/sentry/sy_sentry.py +35 -0
  34. sycommon/services.py +144 -115
  35. sycommon/synacos/feign.py +18 -7
  36. sycommon/synacos/feign_client.py +26 -8
  37. sycommon/synacos/nacos_client_base.py +119 -0
  38. sycommon/synacos/nacos_config_manager.py +107 -0
  39. sycommon/synacos/nacos_heartbeat_manager.py +144 -0
  40. sycommon/synacos/nacos_service.py +65 -769
  41. sycommon/synacos/nacos_service_discovery.py +157 -0
  42. sycommon/synacos/nacos_service_registration.py +270 -0
  43. sycommon/tools/env.py +62 -0
  44. sycommon/tools/merge_headers.py +117 -0
  45. sycommon/tools/snowflake.py +238 -23
  46. {sycommon_python_lib-0.1.46.dist-info → sycommon_python_lib-0.1.57b1.dist-info}/METADATA +18 -11
  47. sycommon_python_lib-0.1.57b1.dist-info/RECORD +89 -0
  48. sycommon_python_lib-0.1.46.dist-info/RECORD +0 -59
  49. {sycommon_python_lib-0.1.46.dist-info → sycommon_python_lib-0.1.57b1.dist-info}/WHEEL +0 -0
  50. {sycommon_python_lib-0.1.46.dist-info → sycommon_python_lib-0.1.57b1.dist-info}/entry_points.txt +0 -0
  51. {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)
@@ -1,3 +1,5 @@
1
1
  import contextvars
2
2
 
3
3
  current_trace_id = contextvars.ContextVar("trace_id", default=None)
4
+
5
+ current_headers = contextvars.ContextVar("headers", default=None)
@@ -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 traceback
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
 
@@ -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
@@ -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
- # 生成或获取 traceId
13
- trace_id = request.headers.get("x-traceId-header")
13
+ # ========== 1. 请求阶段:获取/生成 TraceID ==========
14
+ trace_id = request.headers.get("x-traceid-header")
14
15
  if not trace_id:
15
- trace_id = Snowflake.next_id()
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 "application/json" in content_type and request.method in ["POST", "PUT", "PATCH"]:
35
+ if is_json_content and request.method in ["POST", "PUT", "PATCH"]:
29
36
  try:
30
- request_body = await request.json()
31
- except Exception as e:
32
- request_body = {"error": f"Failed to parse JSON: {str(e)}"}
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
- request_message_str = json.dumps(request_message, ensure_ascii=False)
74
- SYLogger.info(request_message_str)
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
- content_type = response.headers.get("Content-Type", "")
93
+ # ========== 3. 响应处理阶段 ==========
94
+ # 注意:此阶段发生的任何异常都会被下方的 except 捕获
95
+ # 从而保证 trace_id 不被清除,能够透传
81
96
 
82
- # 处理 SSE 响应
83
- if "text/event-stream" in content_type:
84
- # 流式响应不能有Content-Length,移除它
85
- if "Content-Length" in response.headers:
86
- del response.headers["Content-Length"]
87
- response.headers["x-traceId-header"] = trace_id
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
- "Content-Disposition", "")
194
+ "content-disposition", "").lower()
99
195
 
100
- # 判断是否能添加 trace_id
101
- if "application/json" in content_type and not content_disposition.startswith("attachment"):
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
- data["traceId"] = trace_id
105
- new_body = json.dumps(
106
- data, ensure_ascii=False).encode()
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
- # 创建新响应,确保Content-Length正确
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
- # 显式设置正确的Content-Length
116
- response.headers["Content-Length"] = str(len(new_body))
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
- # 如果不是JSON,恢复原始响应体并更新长度
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["Content-Length"] = str(
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["Content-Length"] = str(
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
- response_message_str = json.dumps(
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
- response.headers["x-traceId-header"] = trace_id
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
- "error": str(e),
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
- error_message_str = json.dumps(error_message, ensure_ascii=False)
160
- SYLogger.error(error_message_str)
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
- SYLogger.reset_trace_id(token)
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