sycommon-python-lib 0.1.59__py3-none-any.whl → 0.2.0b0__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/notice/uvicorn_monitor.py +70 -82
- sycommon/rabbitmq/rabbitmq_client.py +99 -43
- sycommon/rabbitmq/rabbitmq_pool.py +120 -59
- sycommon/synacos/nacos_service_discovery.py +9 -6
- sycommon/tests/test_email.py +172 -0
- sycommon/tools/syemail.py +173 -0
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b0.dist-info}/METADATA +2 -1
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b0.dist-info}/RECORD +11 -9
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b0.dist-info}/WHEEL +1 -1
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b0.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b0.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
1
|
import sys
|
|
3
2
|
import traceback
|
|
4
3
|
import aiohttp
|
|
5
4
|
import asyncio
|
|
6
|
-
import json
|
|
7
5
|
from typing import Optional
|
|
6
|
+
from datetime import datetime
|
|
8
7
|
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
|
9
8
|
from sycommon.config.Config import Config
|
|
10
9
|
from sycommon.logging.kafka_log import SYLogger
|
|
@@ -16,19 +15,7 @@ async def send_wechat_markdown_msg(
|
|
|
16
15
|
) -> Optional[dict]:
|
|
17
16
|
"""
|
|
18
17
|
异步发送企业微信Markdown格式的WebHook消息
|
|
19
|
-
|
|
20
|
-
Args:
|
|
21
|
-
content: Markdown格式的消息内容(支持企业微信支持的markdown语法)
|
|
22
|
-
webhook: 完整的企业微信WebHook URL(默认值包含key)
|
|
23
|
-
|
|
24
|
-
Returns:
|
|
25
|
-
接口返回的JSON数据(dict),失败返回None
|
|
26
18
|
"""
|
|
27
|
-
# 设置请求头
|
|
28
|
-
headers = {
|
|
29
|
-
"Content-Type": "application/json; charset=utf-8"
|
|
30
|
-
}
|
|
31
|
-
|
|
32
19
|
# 构造请求体(Markdown格式)
|
|
33
20
|
payload = {
|
|
34
21
|
"msgtype": "markdown",
|
|
@@ -38,54 +25,54 @@ async def send_wechat_markdown_msg(
|
|
|
38
25
|
}
|
|
39
26
|
|
|
40
27
|
try:
|
|
28
|
+
# 使用 json 参数自动处理序列化和 Content-Type
|
|
41
29
|
async with aiohttp.ClientSession() as session:
|
|
42
30
|
async with session.post(
|
|
43
31
|
url=webhook,
|
|
44
|
-
|
|
45
|
-
headers=headers,
|
|
32
|
+
json=payload,
|
|
46
33
|
timeout=aiohttp.ClientTimeout(total=10)
|
|
47
34
|
) as response:
|
|
48
|
-
|
|
49
|
-
response_text = await response.text()
|
|
50
|
-
response_data = json.loads(
|
|
51
|
-
response_text) if response_text else {}
|
|
35
|
+
response_data = await response.json()
|
|
52
36
|
|
|
53
|
-
if status == 200 and response_data.get("errcode") == 0:
|
|
37
|
+
if response.status == 200 and response_data.get("errcode") == 0:
|
|
54
38
|
SYLogger.info(f"消息发送成功: {response_data}")
|
|
55
39
|
return response_data
|
|
56
40
|
else:
|
|
57
|
-
SYLogger.
|
|
58
|
-
f"消息发送失败 - 状态码: {status}, 响应: {response_data}")
|
|
41
|
+
SYLogger.warning(
|
|
42
|
+
f"消息发送失败 - 状态码: {response.status}, 响应: {response_data}")
|
|
59
43
|
return None
|
|
60
44
|
except Exception as e:
|
|
61
|
-
SYLogger.
|
|
45
|
+
SYLogger.error(f"发送企业微信消息异常: {str(e)}")
|
|
62
46
|
return None
|
|
63
47
|
|
|
64
48
|
|
|
65
49
|
async def send_webhook(error_info: dict = None, webhook: str = None):
|
|
66
50
|
"""
|
|
67
51
|
发送服务启动结果的企业微信通知
|
|
68
|
-
Args:
|
|
69
|
-
error_info: 错误信息字典(启动失败时必填),包含:error_type, error_msg, stack_trace, elapsed_time
|
|
70
|
-
webhook: 完整的企业微信WebHook URL(覆盖默认值)
|
|
71
52
|
"""
|
|
72
|
-
#
|
|
53
|
+
# 获取服务名和环境(增加默认值保护)
|
|
73
54
|
try:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
config = Config().config
|
|
56
|
+
service_name = config.get('Name', "未知服务")
|
|
57
|
+
env = config.get('Nacos', {}).get('namespaceId', '未知环境')
|
|
58
|
+
# 注意:这里使用了大写开头的 WebHook,请确保配置文件中键名一致
|
|
59
|
+
webHook = config.get('llm', {}).get('WebHook')
|
|
77
60
|
except Exception as e:
|
|
78
61
|
service_name = "未知服务"
|
|
79
62
|
env = "未知环境"
|
|
80
63
|
webHook = None
|
|
81
|
-
SYLogger.
|
|
64
|
+
SYLogger.warning(f"读取配置失败: {str(e)}")
|
|
82
65
|
|
|
83
66
|
start_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
84
67
|
|
|
68
|
+
# 如果没有错误信息,就不发送失败告警
|
|
69
|
+
if not error_info:
|
|
70
|
+
return
|
|
71
|
+
|
|
85
72
|
# 启动失败的通知内容(包含详细错误信息)
|
|
86
73
|
error_type = error_info.get("error_type", "未知错误")
|
|
87
74
|
error_msg = error_info.get("error_msg", "无错误信息")
|
|
88
|
-
stack_trace = error_info.get("stack_trace", "无堆栈信息")[:
|
|
75
|
+
stack_trace = error_info.get("stack_trace", "无堆栈信息")[:1000] # 适当增加长度限制
|
|
89
76
|
elapsed_time = error_info.get("elapsed_time", 0)
|
|
90
77
|
|
|
91
78
|
markdown_content = f"""### {service_name}服务启动失败告警 ⚠️
|
|
@@ -103,22 +90,20 @@ async def send_webhook(error_info: dict = None, webhook: str = None):
|
|
|
103
90
|
)
|
|
104
91
|
SYLogger.info(f"通知发送结果: {result}")
|
|
105
92
|
else:
|
|
106
|
-
SYLogger.info("未设置企业微信WebHook")
|
|
93
|
+
SYLogger.info("未设置企业微信WebHook,跳过告警发送")
|
|
107
94
|
|
|
108
95
|
|
|
109
96
|
def run(*args, webhook: str = None, **kwargs):
|
|
110
97
|
"""
|
|
111
98
|
带企业微信告警的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
99
|
"""
|
|
120
100
|
# 判断环境
|
|
121
|
-
|
|
101
|
+
try:
|
|
102
|
+
env = Config().config.get('Nacos', {}).get('namespaceId', 'dev')
|
|
103
|
+
except:
|
|
104
|
+
env = 'dev'
|
|
105
|
+
|
|
106
|
+
# 如果是生产环境,通常不需要这种启动脚本进行告警干扰,或者你需要根据实际情况调整
|
|
122
107
|
if env == "prod":
|
|
123
108
|
import uvicorn
|
|
124
109
|
uvicorn.run(*args, **kwargs)
|
|
@@ -128,42 +113,44 @@ def run(*args, webhook: str = None, **kwargs):
|
|
|
128
113
|
start_time = datetime.now()
|
|
129
114
|
|
|
130
115
|
if webhook:
|
|
131
|
-
# 脱敏展示webhook
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
key
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
116
|
+
# 脱敏展示webhook
|
|
117
|
+
try:
|
|
118
|
+
parsed = urlparse(webhook)
|
|
119
|
+
query = parse_qs(parsed.query)
|
|
120
|
+
if 'key' in query and query['key'][0]:
|
|
121
|
+
key = query['key'][0]
|
|
122
|
+
masked_key = key[:8] + "****" if len(key) > 8 else key + "****"
|
|
123
|
+
query['key'] = [masked_key]
|
|
124
|
+
masked_query = urlencode(query, doseq=True)
|
|
125
|
+
masked_webhook = urlunparse(
|
|
126
|
+
(parsed.scheme, parsed.netloc, parsed.path, parsed.params, masked_query, parsed.fragment))
|
|
127
|
+
SYLogger.info(f"自定义企业微信WebHook: {masked_webhook}")
|
|
128
|
+
except Exception:
|
|
129
|
+
SYLogger.info("自定义Webhook格式解析失败,但会尝试使用")
|
|
142
130
|
|
|
143
131
|
# 初始化错误信息
|
|
144
132
|
error_info = None
|
|
145
133
|
|
|
146
134
|
try:
|
|
147
135
|
import uvicorn
|
|
148
|
-
#
|
|
136
|
+
# 执行启动
|
|
149
137
|
uvicorn.run(*args, **kwargs)
|
|
150
138
|
|
|
151
139
|
except KeyboardInterrupt:
|
|
152
|
-
#
|
|
140
|
+
# 处理用户手动中断
|
|
153
141
|
elapsed = (datetime.now() - start_time).total_seconds()
|
|
154
142
|
SYLogger.info(f"\n{'='*50}")
|
|
155
143
|
SYLogger.info(f"ℹ️ 应用被用户手动中断")
|
|
156
144
|
SYLogger.info(f"启动耗时: {elapsed:.2f} 秒")
|
|
157
145
|
SYLogger.info(f"{'='*50}\n")
|
|
146
|
+
# 手动中断不需要错误告警,也不需要 sys.exit(1)
|
|
158
147
|
sys.exit(0)
|
|
159
148
|
|
|
160
149
|
except Exception as e:
|
|
161
|
-
#
|
|
150
|
+
# 捕获启动失败异常
|
|
162
151
|
elapsed = (datetime.now() - start_time).total_seconds()
|
|
163
|
-
# 捕获堆栈信息
|
|
164
152
|
stack_trace = traceback.format_exc()
|
|
165
153
|
|
|
166
|
-
# 构造错误信息字典
|
|
167
154
|
error_info = {
|
|
168
155
|
"error_type": type(e).__name__,
|
|
169
156
|
"error_msg": str(e),
|
|
@@ -171,30 +158,31 @@ def run(*args, webhook: str = None, **kwargs):
|
|
|
171
158
|
"elapsed_time": elapsed
|
|
172
159
|
}
|
|
173
160
|
|
|
174
|
-
#
|
|
175
|
-
SYLogger.
|
|
176
|
-
SYLogger.
|
|
177
|
-
SYLogger.
|
|
178
|
-
SYLogger.
|
|
179
|
-
SYLogger.
|
|
180
|
-
SYLogger.
|
|
181
|
-
SYLogger.
|
|
161
|
+
# 打印详细错误
|
|
162
|
+
SYLogger.error(f"\n{'='*50}")
|
|
163
|
+
SYLogger.error(f"🚨 应用启动失败!")
|
|
164
|
+
SYLogger.error(f"失败时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
165
|
+
SYLogger.error(f"错误类型: {type(e).__name__}")
|
|
166
|
+
SYLogger.error(f"错误信息: {str(e)}")
|
|
167
|
+
SYLogger.error(f"\n📝 错误堆栈:")
|
|
168
|
+
SYLogger.error(f"-"*50)
|
|
182
169
|
traceback.print_exc(file=sys.stdout)
|
|
183
|
-
SYLogger.
|
|
184
|
-
SYLogger.
|
|
170
|
+
SYLogger.error(f"\n⏱️ 启动耗时: {elapsed:.2f} 秒")
|
|
171
|
+
SYLogger.error(f"{'='*50}\n")
|
|
185
172
|
|
|
186
173
|
finally:
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
174
|
+
# 创建新的事件循环来发送通知,防止与 Uvicorn 的循环冲突
|
|
175
|
+
if error_info:
|
|
176
|
+
try:
|
|
177
|
+
loop = asyncio.new_event_loop()
|
|
178
|
+
asyncio.set_event_loop(loop)
|
|
179
|
+
loop.run_until_complete(send_webhook(
|
|
180
|
+
error_info=error_info,
|
|
181
|
+
webhook=webhook
|
|
182
|
+
))
|
|
183
|
+
loop.close()
|
|
184
|
+
except Exception as e:
|
|
185
|
+
SYLogger.error(f"错误:异步通知发送失败 - {str(e)}")
|
|
186
|
+
|
|
187
|
+
# 只有确实有错误时才以状态码 1 退出
|
|
188
|
+
sys.exit(1)
|
|
@@ -117,7 +117,7 @@ class RabbitMQClient:
|
|
|
117
117
|
logger.info(f"队列重建成功: {self.queue_name}")
|
|
118
118
|
|
|
119
119
|
async def connect(self) -> None:
|
|
120
|
-
"""
|
|
120
|
+
"""连接方法"""
|
|
121
121
|
if self._closed:
|
|
122
122
|
raise RuntimeError("客户端已关闭,无法重新连接")
|
|
123
123
|
|
|
@@ -125,9 +125,10 @@ class RabbitMQClient:
|
|
|
125
125
|
await self._connect_condition.acquire()
|
|
126
126
|
|
|
127
127
|
try:
|
|
128
|
-
# ===== 阶段 A:
|
|
128
|
+
# ===== 阶段 A: 检查状态与排队 =====
|
|
129
129
|
if await self.is_connected:
|
|
130
|
-
self._connect_condition.
|
|
130
|
+
if self._connect_condition.locked():
|
|
131
|
+
self._connect_condition.release()
|
|
131
132
|
return
|
|
132
133
|
|
|
133
134
|
if self._connecting:
|
|
@@ -135,19 +136,16 @@ class RabbitMQClient:
|
|
|
135
136
|
logger.debug("连接正在进行中,等待现有连接完成...")
|
|
136
137
|
await asyncio.wait_for(self._connect_condition.wait(), timeout=60.0)
|
|
137
138
|
except asyncio.TimeoutError:
|
|
138
|
-
|
|
139
|
-
raise RuntimeError("等待连接超时")
|
|
139
|
+
logger.warning("等待前序连接超时,当前协程将尝试强制接管并重连...")
|
|
140
140
|
|
|
141
|
+
# 唤醒后再次检查
|
|
141
142
|
if await self.is_connected:
|
|
142
|
-
self._connect_condition.
|
|
143
|
+
if self._connect_condition.locked():
|
|
144
|
+
self._connect_condition.release()
|
|
143
145
|
return
|
|
144
|
-
else:
|
|
145
|
-
self._connect_condition.release()
|
|
146
|
-
raise RuntimeError("等待重连后,连接状态依然无效")
|
|
147
146
|
|
|
148
|
-
# ===== 阶段 B:
|
|
147
|
+
# ===== 阶段 B: 标记开始连接并释放锁 =====
|
|
149
148
|
self._connecting = True
|
|
150
|
-
# 【关键】释放锁,允许其他协程进入等待逻辑
|
|
151
149
|
self._connect_condition.release()
|
|
152
150
|
|
|
153
151
|
except Exception as e:
|
|
@@ -155,13 +153,18 @@ class RabbitMQClient:
|
|
|
155
153
|
self._connect_condition.release()
|
|
156
154
|
raise
|
|
157
155
|
|
|
158
|
-
# === 阶段 C: 执行耗时的连接逻辑 (
|
|
156
|
+
# === 阶段 C: 执行耗时的连接逻辑 (无锁状态) ===
|
|
157
|
+
connection_failed = False
|
|
158
|
+
was_consuming = False
|
|
159
|
+
|
|
160
|
+
# 用于追踪状态,避免在 except 中访问 self._x 导致的竞态
|
|
161
|
+
old_channel = self._channel
|
|
162
|
+
|
|
159
163
|
try:
|
|
160
|
-
# --- 步骤 1:
|
|
161
|
-
# 必须在清理前记录状态
|
|
164
|
+
# --- 步骤 1: 记录状态并清理旧资源 ---
|
|
162
165
|
was_consuming = self._consumer_tag is not None
|
|
163
166
|
|
|
164
|
-
#
|
|
167
|
+
# 清理旧连接回调
|
|
165
168
|
if self._channel_conn:
|
|
166
169
|
try:
|
|
167
170
|
if self._channel_conn.close_callbacks:
|
|
@@ -169,7 +172,14 @@ class RabbitMQClient:
|
|
|
169
172
|
except Exception:
|
|
170
173
|
pass
|
|
171
174
|
|
|
172
|
-
#
|
|
175
|
+
# 显式关闭旧 Channel(这是 Client 自己创建的资源,必须关)
|
|
176
|
+
if old_channel and not old_channel.is_closed:
|
|
177
|
+
try:
|
|
178
|
+
await old_channel.close()
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
# 重置引用
|
|
173
183
|
self._channel = None
|
|
174
184
|
self._channel_conn = None
|
|
175
185
|
self._exchange = None
|
|
@@ -177,52 +187,68 @@ class RabbitMQClient:
|
|
|
177
187
|
self._consumer_tag = None
|
|
178
188
|
|
|
179
189
|
# --- 步骤 2: 获取新连接 ---
|
|
190
|
+
# 注意:如果这里抛出异常,说明 Pool 层面连接失败
|
|
180
191
|
self._channel, self._channel_conn = await self.connection_pool.acquire_channel()
|
|
181
192
|
|
|
182
|
-
#
|
|
193
|
+
# --- 步骤 3: 设置回调 ---
|
|
194
|
+
loop = asyncio.get_running_loop()
|
|
195
|
+
|
|
183
196
|
def on_conn_closed(conn, exc):
|
|
197
|
+
if self._closed:
|
|
198
|
+
return
|
|
184
199
|
logger.warning(f"检测到底层连接关闭: {exc}")
|
|
185
|
-
|
|
186
|
-
asyncio.create_task(self._safe_reconnect())
|
|
200
|
+
asyncio.run_coroutine_threadsafe(self._safe_reconnect(), loop)
|
|
187
201
|
|
|
188
202
|
if self._channel_conn:
|
|
189
203
|
self._channel_conn.close_callbacks.add(on_conn_closed)
|
|
190
204
|
|
|
191
|
-
# --- 步骤
|
|
205
|
+
# --- 步骤 4: 重建基础资源 ---
|
|
192
206
|
await self._rebuild_resources()
|
|
193
207
|
|
|
194
|
-
# --- 步骤 4: 恢复消费 ---
|
|
195
|
-
if was_consuming and self._message_handler:
|
|
196
|
-
logger.info("🔄 检测到重连前处于消费状态,尝试自动恢复消费...")
|
|
197
|
-
try:
|
|
198
|
-
# 直接调用 start_consuming 来恢复,它内部包含了完整的队列检查和绑定逻辑
|
|
199
|
-
self._consumer_tag = await self.start_consuming()
|
|
200
|
-
logger.info(f"✅ 消费已自动恢复: {self._consumer_tag}")
|
|
201
|
-
except Exception as e:
|
|
202
|
-
logger.error(f"❌ 自动恢复消费失败: {e}")
|
|
203
|
-
self._consumer_tag = None
|
|
204
|
-
|
|
205
|
-
logger.info("客户端连接初始化完成")
|
|
206
|
-
|
|
207
208
|
except Exception as e:
|
|
209
|
+
connection_failed = True
|
|
208
210
|
logger.error(f"客户端连接失败: {str(e)}", exc_info=True)
|
|
209
|
-
|
|
211
|
+
|
|
212
|
+
# 清理引用
|
|
210
213
|
if self._channel_conn and self._channel_conn.close_callbacks:
|
|
211
214
|
self._channel_conn.close_callbacks.clear()
|
|
215
|
+
|
|
212
216
|
self._channel = None
|
|
213
217
|
self._channel_conn = None
|
|
218
|
+
self._exchange = None
|
|
214
219
|
self._queue = None
|
|
215
220
|
self._consumer_tag = None
|
|
221
|
+
|
|
222
|
+
# 不要手动关闭 Pool 返回的连接,只置空引用。
|
|
216
223
|
raise
|
|
217
224
|
|
|
218
225
|
finally:
|
|
219
|
-
# === 阶段 D:
|
|
220
|
-
|
|
226
|
+
# === 阶段 D: 恢复消费与收尾 (重新加锁) ===
|
|
227
|
+
# 确保一定会获取锁
|
|
221
228
|
try:
|
|
229
|
+
await self._connect_condition.acquire()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# 只有连接完全成功,且之前在消费,才尝试恢复消费
|
|
235
|
+
if not connection_failed and was_consuming and self._message_handler:
|
|
236
|
+
logger.info("🔄 检测到重连前处于消费状态,尝试自动恢复消费...")
|
|
237
|
+
try:
|
|
238
|
+
self._consumer_tag = await self.start_consuming()
|
|
239
|
+
logger.info(f"✅ 消费已自动恢复: {self._consumer_tag}")
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f"❌ 自动恢复消费失败: {e}")
|
|
242
|
+
# 如果消费恢复失败,视为连接状态不完整,置空 Exchange
|
|
243
|
+
self._consumer_tag = None
|
|
244
|
+
self._exchange = None
|
|
245
|
+
finally:
|
|
246
|
+
# 最终状态复位
|
|
222
247
|
self._connecting = False
|
|
223
248
|
self._connect_condition.notify_all()
|
|
224
|
-
|
|
225
|
-
self._connect_condition.
|
|
249
|
+
|
|
250
|
+
if self._connect_condition.locked():
|
|
251
|
+
self._connect_condition.release()
|
|
226
252
|
|
|
227
253
|
async def _safe_reconnect(self):
|
|
228
254
|
"""安全重连任务(仅用于被动监听连接关闭)"""
|
|
@@ -313,6 +339,11 @@ class RabbitMQClient:
|
|
|
313
339
|
self._consumer_tag = None
|
|
314
340
|
|
|
315
341
|
async def _handle_publish_failure(self):
|
|
342
|
+
# 如果当前正在重连,或者已经关闭,直接返回,避免冲突
|
|
343
|
+
if self._connecting or self._closed:
|
|
344
|
+
logger.warning("⚠️ 正在重连或已关闭,跳过故障转移触发")
|
|
345
|
+
return
|
|
346
|
+
|
|
316
347
|
try:
|
|
317
348
|
logger.info("检测到发布异常,强制连接池切换节点...")
|
|
318
349
|
await self.connection_pool.force_reconnect()
|
|
@@ -387,9 +418,12 @@ class RabbitMQClient:
|
|
|
387
418
|
raise RuntimeError(f"消息发布最终失败: {last_exception}")
|
|
388
419
|
|
|
389
420
|
async def close(self) -> None:
|
|
421
|
+
"""关闭客户端(整合版:修复连接泄漏与死锁)"""
|
|
422
|
+
# 1. 先标记关闭,这会阻止 _safe_reconnect 和后续的 connect 逻辑
|
|
390
423
|
self._closed = True
|
|
391
424
|
logger.info("开始关闭RabbitMQ客户端...")
|
|
392
425
|
|
|
426
|
+
# 2. 取消可能存在的后台重连任务
|
|
393
427
|
if self._current_reconnect_task and not self._current_reconnect_task.done():
|
|
394
428
|
self._current_reconnect_task.cancel()
|
|
395
429
|
try:
|
|
@@ -397,21 +431,43 @@ class RabbitMQClient:
|
|
|
397
431
|
except asyncio.CancelledError:
|
|
398
432
|
pass
|
|
399
433
|
|
|
434
|
+
# 3. 停止消费
|
|
400
435
|
await self.stop_consuming()
|
|
401
436
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
437
|
+
# 4. 【关键步骤】处理 _connect_condition 锁
|
|
438
|
+
# 我们必须获取这个锁,以防止正在进行的 connect() 在我们清理资源时还在操作
|
|
439
|
+
# 但如果 connect 卡在 wait(),我们需要强制唤醒它
|
|
440
|
+
try:
|
|
441
|
+
# 尝试获取锁,设置超时防止死锁(虽然理论上我们即将 notify_all,但为了保险)
|
|
442
|
+
await asyncio.wait_for(self._connect_condition.acquire(), timeout=2.0)
|
|
443
|
+
except asyncio.TimeoutError:
|
|
444
|
+
logger.warning("获取连接锁超时,强制清理资源...")
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
# 清理回调,防止在关闭过程中触发重连
|
|
448
|
+
if self._channel_conn:
|
|
449
|
+
try:
|
|
450
|
+
if self._channel_conn.close_callbacks:
|
|
451
|
+
self._channel_conn.close_callbacks.clear()
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
406
454
|
|
|
455
|
+
# 置空资源引用
|
|
407
456
|
self._channel = None
|
|
408
457
|
self._channel_conn = None
|
|
409
458
|
self._exchange = None
|
|
410
459
|
self._queue = None
|
|
411
460
|
self._message_handler = None
|
|
461
|
+
self._conn_close_callback = None
|
|
412
462
|
|
|
413
|
-
|
|
463
|
+
finally:
|
|
464
|
+
# 【核心修复】无论是否成功获取锁,都要强制重置状态并唤醒所有等待者
|
|
465
|
+
# 这会让卡在 connect() 阶段 A 的 wait() 的协程醒来,发现 _closed=True 后抛出异常退出
|
|
414
466
|
self._connecting = False
|
|
415
467
|
self._connect_condition.notify_all()
|
|
416
468
|
|
|
469
|
+
# 确保锁被释放(如果持有)
|
|
470
|
+
if self._connect_condition.locked():
|
|
471
|
+
self._connect_condition.release()
|
|
472
|
+
|
|
417
473
|
logger.info("客户端已关闭")
|
|
@@ -141,21 +141,43 @@ class RabbitMQConnectionPool:
|
|
|
141
141
|
|
|
142
142
|
async def _ensure_main_channel(self) -> RobustChannel:
|
|
143
143
|
"""
|
|
144
|
-
确保主通道有效
|
|
145
|
-
修复逻辑:
|
|
146
|
-
1. 如果连接对象不存在或已关闭 -> 重建连接
|
|
147
|
-
2. 如果连接对象存在但处于重连中 -> 等待其 ready
|
|
148
|
-
3. 如果通道不存在或已关闭 -> 重建通道
|
|
144
|
+
确保主通道有效 (Channel 创建失败时的自动重连)
|
|
149
145
|
"""
|
|
150
146
|
async with self._lock:
|
|
151
147
|
if self._is_shutdown:
|
|
152
148
|
raise RuntimeError("客户端已关闭")
|
|
153
149
|
|
|
154
|
-
# --- 阶段A
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
# --- 阶段 A: 连接检查与重建 ---
|
|
151
|
+
# 1. 显式检查连接对象状态
|
|
152
|
+
connection_is_dead = False
|
|
153
|
+
if self._connection is None:
|
|
154
|
+
connection_is_dead = True
|
|
155
|
+
else:
|
|
156
|
+
try:
|
|
157
|
+
if self._connection.is_closed:
|
|
158
|
+
logger.info("🔌 连接已关闭,准备重连...")
|
|
159
|
+
connection_is_dead = True
|
|
160
|
+
else:
|
|
161
|
+
# 2. 核心修复:针对 fail_fast=1 的预防性检查
|
|
162
|
+
# 尝试调用 connect() 进行探活。
|
|
163
|
+
# 在 fail_fast=1 模式下,如果底层连接已死,connect() 会迅速抛出异常
|
|
164
|
+
try:
|
|
165
|
+
# 设置较短超时,避免阻塞太久
|
|
166
|
+
await asyncio.wait_for(self._connection.connect(timeout=1), timeout=15)
|
|
167
|
+
# 如果走到这里,说明连接是好的(或者是它成功恢复了)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
# 捕获 RuntimeError (Connection closed) 或其他异常
|
|
170
|
+
logger.warning(f"⚠️ 连接探活失败 ({e}),判定为死连接,强制重建...")
|
|
171
|
+
connection_is_dead = True
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.warning(f"⚠️ 检查连接状态异常 ({e}),判定为死连接")
|
|
174
|
+
connection_is_dead = True
|
|
175
|
+
|
|
176
|
+
# 3. 如果连接判定为死亡,执行彻底的清理和重建
|
|
177
|
+
if connection_is_dead:
|
|
157
178
|
await self._cleanup_resources()
|
|
158
179
|
|
|
180
|
+
# 重建连接逻辑
|
|
159
181
|
retry_hosts = self.hosts.copy()
|
|
160
182
|
random.shuffle(retry_hosts)
|
|
161
183
|
last_error = None
|
|
@@ -170,15 +192,29 @@ class RabbitMQConnectionPool:
|
|
|
170
192
|
temp_conn = None
|
|
171
193
|
|
|
172
194
|
try:
|
|
173
|
-
|
|
195
|
+
# 使用 connect_robust 创建新连接
|
|
196
|
+
conn_url = (
|
|
197
|
+
f"amqp://{self.username}:{self.password}@{host}:{self.port}/"
|
|
198
|
+
f"{self.virtualhost}?name={self.app_name}&heartbeat={self.heartbeat}"
|
|
199
|
+
f"&reconnect_interval={self.reconnect_interval}&fail_fast=1"
|
|
200
|
+
)
|
|
201
|
+
# 这里必须创建新对象,不能复用旧对象
|
|
202
|
+
temp_conn = await asyncio.wait_for(
|
|
203
|
+
connect_robust(conn_url),
|
|
204
|
+
timeout=self.connection_timeout + 5
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# 新连接建立成功
|
|
174
208
|
self._connection = temp_conn
|
|
175
209
|
temp_conn = None
|
|
176
210
|
self._initialized = True
|
|
177
211
|
last_error = None
|
|
178
|
-
logger.info(f"
|
|
212
|
+
logger.info(f"✅ [CONNECT_OK] 新连接建立成功: {host}")
|
|
179
213
|
break
|
|
214
|
+
|
|
180
215
|
except Exception as e:
|
|
181
|
-
logger.warning(
|
|
216
|
+
logger.warning(
|
|
217
|
+
f"⚠️ [RECONNECT_RETRY] 节点 {host} 连接失败: {e}")
|
|
182
218
|
if temp_conn is not None:
|
|
183
219
|
try:
|
|
184
220
|
await temp_conn.close()
|
|
@@ -190,84 +226,99 @@ class RabbitMQConnectionPool:
|
|
|
190
226
|
if last_error:
|
|
191
227
|
self._connection = None
|
|
192
228
|
self._initialized = False
|
|
193
|
-
logger.error("💥 [
|
|
229
|
+
logger.error("💥 [FATAL] 所有节点连接尝试均失败")
|
|
194
230
|
raise ConnectionError("所有 RabbitMQ 节点连接失败") from last_error
|
|
195
|
-
else:
|
|
196
|
-
# 情况2:连接对象存在,但可能处于“连接丢失,正在重连”的状态
|
|
197
|
-
# 【关键修复】显式调用 connect() 确保连接真正就绪
|
|
198
|
-
# 如果连接已经好,这会立即返回;如果正在重连,这会等待完成
|
|
199
|
-
try:
|
|
200
|
-
logger.debug(f"⏳ [WAIT_CONN] 等待连接就绪...")
|
|
201
|
-
await self._connection.connect(timeout=5) # 设置一个短暂超时避免卡死太久
|
|
202
|
-
logger.debug(f"✅ [WAIT_CONN_OK] 连接已就绪")
|
|
203
|
-
except asyncio.TimeoutError:
|
|
204
|
-
logger.warning("⚠️ [WAIT_CONN_TIMEOUT] 等待连接就绪超时,强制重连")
|
|
205
|
-
await self._cleanup_resources()
|
|
206
|
-
raise
|
|
207
|
-
except Exception as e:
|
|
208
|
-
logger.error(f"❌ [WAIT_CONN_FAIL] 连接不可用: {e}")
|
|
209
|
-
await self._cleanup_resources()
|
|
210
|
-
raise
|
|
211
231
|
|
|
212
|
-
# --- 阶段B
|
|
232
|
+
# --- 阶段 B: 通道恢复逻辑 (包含关键的自愈逻辑) ---
|
|
233
|
+
# 此时 self._connection 必须是有效的(新创建的,或者验证通过的)
|
|
213
234
|
if self._channel is None or self._channel.is_closed:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
235
|
+
|
|
236
|
+
# [修复 3] 使用 while 循环来应对“连接创建瞬间又断开”或“探活漏网”的极端情况
|
|
237
|
+
max_channel_attempts = 2
|
|
238
|
+
for attempt in range(max_channel_attempts):
|
|
239
|
+
try:
|
|
240
|
+
# 尝试创建通道 (这是你报错的地方)
|
|
241
|
+
self._channel = await self._connection.channel()
|
|
242
|
+
await self._channel.set_qos(prefetch_count=self.prefetch_count)
|
|
243
|
+
logger.info(f"✅ [CHANNEL_OK] 主通道已就绪")
|
|
244
|
+
break # 成功则跳出循环
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.warning(
|
|
248
|
+
f"⚠️ [CHANNEL_RETRY] 第 {attempt + 1} 次尝试创建通道失败: {e}")
|
|
249
|
+
|
|
250
|
+
# 只有第一次尝试失败时才尝试重建连接,避免无限循环
|
|
251
|
+
if attempt < max_channel_attempts - 1:
|
|
252
|
+
logger.warning("🔄 检测到通道创建异常(可能是死连接),触发连接重建...")
|
|
253
|
+
try:
|
|
254
|
+
await self._cleanup_resources()
|
|
255
|
+
# 这里我们不通过外部循环重建,而是直接强制重建一次
|
|
256
|
+
# 简化的重建逻辑:使用当前 host 重试
|
|
257
|
+
conn_url = (
|
|
258
|
+
f"amqp://{self.username}:{self.password}@{self._current_host}:{self.port}/"
|
|
259
|
+
f"{self.virtualhost}?name={self.app_name}&heartbeat={self.heartbeat}"
|
|
260
|
+
f"&reconnect_interval={self.reconnect_interval}&fail_fast=1"
|
|
261
|
+
)
|
|
262
|
+
self._connection = await asyncio.wait_for(
|
|
263
|
+
connect_robust(conn_url),
|
|
264
|
+
timeout=self.connection_timeout + 5
|
|
265
|
+
)
|
|
266
|
+
self._initialized = True
|
|
267
|
+
logger.info("✅ [RECOVER] 连接已重建,将再次尝试创建通道...")
|
|
268
|
+
# continue 进入下一次循环,再次尝试创建 channel
|
|
269
|
+
except Exception as reconnect_err:
|
|
270
|
+
logger.error(
|
|
271
|
+
f"❌ [RECOVER_FAIL] 连接重建失败: {reconnect_err}")
|
|
272
|
+
# 重建失败,抛出原始异常
|
|
273
|
+
raise e
|
|
274
|
+
else:
|
|
275
|
+
# 最后一次尝试也失败了,抛出异常
|
|
276
|
+
logger.error("❌ [CHANNEL_FAIL] 经过重试后仍然无法创建通道")
|
|
277
|
+
raise e
|
|
224
278
|
|
|
225
279
|
return self._channel
|
|
226
280
|
|
|
227
281
|
async def init_pools(self):
|
|
228
282
|
"""初始化入口"""
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if self._initialized:
|
|
233
|
-
return
|
|
283
|
+
# 快速检查
|
|
284
|
+
if self._initialized:
|
|
285
|
+
return
|
|
234
286
|
|
|
235
|
-
# 在 try 之前声明变量,确保 except 块能访问
|
|
236
287
|
conn_created_in_this_try = None
|
|
237
288
|
|
|
238
289
|
try:
|
|
239
290
|
# 锁外创建连接,减少锁持有时间
|
|
240
291
|
init_host = random.choice(self.hosts)
|
|
241
292
|
conn = await self._create_connection_impl(init_host)
|
|
242
|
-
|
|
243
|
-
# 记录本次创建的连接
|
|
244
293
|
conn_created_in_this_try = conn
|
|
245
294
|
|
|
246
295
|
async with self._lock:
|
|
247
296
|
if self._is_shutdown:
|
|
248
297
|
raise RuntimeError("客户端已关闭")
|
|
249
298
|
|
|
299
|
+
# 双重检查:防止在锁外等待时,状态已改变或被其他协程初始化
|
|
300
|
+
if self._initialized:
|
|
301
|
+
logger.info("🚀 [INIT_SKIP] 其他协程已完成初始化")
|
|
302
|
+
if conn_created_in_this_try:
|
|
303
|
+
await conn_created_in_this_try.close()
|
|
304
|
+
return
|
|
305
|
+
|
|
250
306
|
# 提交新资源
|
|
251
307
|
self._connection = conn
|
|
252
308
|
self._channel = await self._connection.channel()
|
|
253
309
|
await self._channel.set_qos(prefetch_count=self.prefetch_count)
|
|
254
310
|
self._initialized = True
|
|
255
311
|
|
|
256
|
-
# 所有权转移成功,清空临时引用,防止 finally 重复关闭
|
|
257
312
|
conn_created_in_this_try = None
|
|
258
|
-
|
|
259
313
|
logger.info(f"🚀 [INIT_OK] 连接池初始化完成: {init_host}")
|
|
260
314
|
|
|
261
315
|
except Exception as e:
|
|
262
316
|
logger.error(f"💥 [INIT_FAIL] 初始化异常: {str(e)}")
|
|
263
|
-
|
|
264
|
-
# 这里现在可以合法访问 conn_created_in_this_try
|
|
265
317
|
if conn_created_in_this_try is not None:
|
|
266
318
|
try:
|
|
267
319
|
await conn_created_in_this_try.close()
|
|
268
320
|
except Exception:
|
|
269
321
|
pass
|
|
270
|
-
|
|
271
322
|
if not self._is_shutdown:
|
|
272
323
|
await self.close()
|
|
273
324
|
raise
|
|
@@ -383,18 +434,28 @@ class RabbitMQConnectionPool:
|
|
|
383
434
|
|
|
384
435
|
async def close(self):
|
|
385
436
|
"""资源销毁"""
|
|
386
|
-
|
|
437
|
+
try:
|
|
438
|
+
# 设置超时,防止因锁异常导致无法关闭
|
|
439
|
+
await asyncio.wait_for(self._lock.acquire(), timeout=5.0)
|
|
440
|
+
except asyncio.TimeoutError:
|
|
441
|
+
logger.error("⚠️ [CLOSE_TIMEOUT] 获取锁超时,强制标记关闭")
|
|
442
|
+
# 如果拿不到锁,我们只能标记状态,无法安全清理连接
|
|
443
|
+
self._is_shutdown = True
|
|
444
|
+
self._initialized = False
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
try:
|
|
387
448
|
if self._is_shutdown:
|
|
388
449
|
return
|
|
389
450
|
self._is_shutdown = True
|
|
390
451
|
self._initialized = False
|
|
391
452
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
453
|
+
logger.info("🛑 [CLOSE] 开始关闭连接池...")
|
|
454
|
+
await self._cleanup_resources()
|
|
455
|
+
logger.info("🏁 [CLOSE] 连接池已关闭")
|
|
456
|
+
finally:
|
|
457
|
+
if self._lock.locked():
|
|
458
|
+
self._lock.release()
|
|
398
459
|
|
|
399
460
|
async def declare_queue(self, queue_name: str, **kwargs) -> AbstractQueue:
|
|
400
461
|
channel, _ = await self.acquire_channel()
|
|
@@ -53,20 +53,23 @@ class NacosServiceDiscovery:
|
|
|
53
53
|
instance for instance in all_instances
|
|
54
54
|
if instance.get('enabled', True)
|
|
55
55
|
]
|
|
56
|
+
|
|
57
|
+
if not all_instances:
|
|
58
|
+
SYLogger.info(f"nacos:筛选后 {service_name} 无可用实例")
|
|
59
|
+
return []
|
|
60
|
+
|
|
56
61
|
SYLogger.info(
|
|
57
62
|
f"nacos:共发现 {len(all_instances)} 个 {service_name} 服务实例")
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if version_to_use:
|
|
64
|
+
if target_version:
|
|
62
65
|
same_version_instances = [
|
|
63
66
|
instance for instance in all_instances
|
|
64
|
-
if instance.get('metadata', {}).get('version') ==
|
|
67
|
+
if instance.get('metadata', {}).get('version') == target_version
|
|
65
68
|
]
|
|
66
69
|
|
|
67
70
|
if same_version_instances:
|
|
68
71
|
SYLogger.info(
|
|
69
|
-
f"nacos:筛选出 {len(same_version_instances)} 个与当前版本({
|
|
72
|
+
f"nacos:筛选出 {len(same_version_instances)} 个与当前版本({target_version})匹配的实例")
|
|
70
73
|
return same_version_instances
|
|
71
74
|
|
|
72
75
|
no_version_instances = [
|
|
@@ -76,7 +79,7 @@ class NacosServiceDiscovery:
|
|
|
76
79
|
|
|
77
80
|
if no_version_instances:
|
|
78
81
|
SYLogger.info(
|
|
79
|
-
f"nacos:未找到相同版本({
|
|
82
|
+
f"nacos:未找到相同版本({target_version})的实例,筛选出 {len(no_version_instances)} 个无版本号的实例")
|
|
80
83
|
return no_version_instances
|
|
81
84
|
else:
|
|
82
85
|
no_version_instances = [
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import smtplib
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
from email.mime.multipart import MIMEMultipart
|
|
7
|
+
from email.header import Header
|
|
8
|
+
from email.mime.base import MIMEBase
|
|
9
|
+
from email import encoders
|
|
10
|
+
from src.sycommon.config.Config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SYEmail:
|
|
14
|
+
"""盛业邮件发送工具类"""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def _get_config() -> dict:
|
|
18
|
+
"""获取邮件配置"""
|
|
19
|
+
return Config().config.get("AIEmailConfig", {})
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
async def send(
|
|
23
|
+
to: List[str],
|
|
24
|
+
subject: str,
|
|
25
|
+
content: str,
|
|
26
|
+
attachments: Optional[List[str]] = None,
|
|
27
|
+
verbose: bool = False
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
异步发送邮件
|
|
31
|
+
|
|
32
|
+
:param to: 收件人列表
|
|
33
|
+
:param subject: 邮件主题
|
|
34
|
+
:param content: 邮件正文
|
|
35
|
+
:param attachments: 附件路径列表 (可选)
|
|
36
|
+
:param verbose: 是否打印详细日志
|
|
37
|
+
:return: 是否发送成功
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
# 1. 构建邮件对象
|
|
41
|
+
message = SYEmail._build_message(
|
|
42
|
+
to, subject, content, attachments
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# 2. 发送邮件 (网络阻塞型,扔到线程池运行)
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
success = await loop.run_in_executor(
|
|
48
|
+
None,
|
|
49
|
+
SYEmail._send_smtp,
|
|
50
|
+
message,
|
|
51
|
+
to,
|
|
52
|
+
verbose
|
|
53
|
+
)
|
|
54
|
+
return success
|
|
55
|
+
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"❌ 异步邮件调度异常: {e}")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _build_message(to: List[str], subject: str, content: str, attachments: Optional[List[str]] = None):
|
|
62
|
+
"""构建邮件对象"""
|
|
63
|
+
config = SYEmail._get_config()
|
|
64
|
+
|
|
65
|
+
message = MIMEMultipart()
|
|
66
|
+
message['From'] = Header(config.get('sender_name', ''), 'utf-8')
|
|
67
|
+
message['To'] = Header(", ".join(to), 'utf-8')
|
|
68
|
+
message['Subject'] = Header(subject, 'utf-8')
|
|
69
|
+
message.attach(MIMEText(content, 'plain', 'utf-8'))
|
|
70
|
+
|
|
71
|
+
if attachments:
|
|
72
|
+
for file_path in attachments:
|
|
73
|
+
if os.path.exists(file_path):
|
|
74
|
+
try:
|
|
75
|
+
with open(file_path, 'rb') as f:
|
|
76
|
+
# 使用 octet-stream 通用流类型
|
|
77
|
+
attachment = MIMEBase(
|
|
78
|
+
'application', 'octet-stream')
|
|
79
|
+
attachment.set_payload(f.read())
|
|
80
|
+
encoders.encode_base64(attachment)
|
|
81
|
+
|
|
82
|
+
# 解决文件名中文乱码问题
|
|
83
|
+
filename = os.path.basename(file_path)
|
|
84
|
+
attachment.add_header(
|
|
85
|
+
'Content-Disposition',
|
|
86
|
+
'attachment',
|
|
87
|
+
filename=('utf-8', '', filename)
|
|
88
|
+
)
|
|
89
|
+
message.attach(attachment)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(f"⚠️ 读取附件失败 [{file_path}]: {e}")
|
|
92
|
+
else:
|
|
93
|
+
print(f"⚠️ 附件文件不存在: {file_path}")
|
|
94
|
+
return message
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _send_smtp(message, to_list: List[str], verbose: bool) -> bool:
|
|
98
|
+
"""执行 SMTP 发送 (在线程池中运行)"""
|
|
99
|
+
config = SYEmail._get_config()
|
|
100
|
+
smtp_obj = None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if verbose:
|
|
104
|
+
print(
|
|
105
|
+
f"正在连接 {config.get('smtp_server')}:{config.get('smtp_port')} ...")
|
|
106
|
+
|
|
107
|
+
# 1. 创建连接
|
|
108
|
+
smtp_obj = smtplib.SMTP(
|
|
109
|
+
config.get('smtp_server'),
|
|
110
|
+
config.get('smtp_port'),
|
|
111
|
+
timeout=10
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if verbose:
|
|
115
|
+
smtp_obj.set_debuglevel(1)
|
|
116
|
+
|
|
117
|
+
# 2. 直接进行登录认证
|
|
118
|
+
if verbose:
|
|
119
|
+
print(f"正在使用账号 {config.get('smtp_user')} 登录...")
|
|
120
|
+
|
|
121
|
+
smtp_obj.login(config.get('smtp_user'), config.get('smtp_pass'))
|
|
122
|
+
|
|
123
|
+
if verbose:
|
|
124
|
+
print("✅ 登录成功")
|
|
125
|
+
|
|
126
|
+
# 3. 发送邮件
|
|
127
|
+
smtp_obj.sendmail(
|
|
128
|
+
config.get('sender_email'),
|
|
129
|
+
to_list,
|
|
130
|
+
message.as_string()
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if verbose:
|
|
134
|
+
print("✅ 邮件发送成功!")
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
except smtplib.SMTPAuthenticationError:
|
|
138
|
+
print("❌ 认证失败:请检查账号密码。")
|
|
139
|
+
except smtplib.SMTPException as e:
|
|
140
|
+
print(f"❌ SMTP 协议错误: {e}")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
print(f"❌ 发送邮件时发生未知错误: {e}")
|
|
143
|
+
finally:
|
|
144
|
+
if smtp_obj:
|
|
145
|
+
try:
|
|
146
|
+
smtp_obj.quit()
|
|
147
|
+
except:
|
|
148
|
+
pass
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# 使用示例
|
|
153
|
+
async def main():
|
|
154
|
+
"""异步主函数测试"""
|
|
155
|
+
print("开始发送邮件...")
|
|
156
|
+
|
|
157
|
+
success = await SYEmail.send(
|
|
158
|
+
to=["Osulcode.xiao@syholdings.com"],
|
|
159
|
+
subject="异步邮件测试",
|
|
160
|
+
content="这是一封通过异步方式发送的测试邮件。",
|
|
161
|
+
attachments=["test_file.txt"],
|
|
162
|
+
verbose=True
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if success:
|
|
166
|
+
print("\n🎉 任务完成")
|
|
167
|
+
else:
|
|
168
|
+
print("\n⚠️ 任务失败")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import smtplib
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
from email.mime.multipart import MIMEMultipart
|
|
7
|
+
from email.header import Header
|
|
8
|
+
from email.mime.base import MIMEBase
|
|
9
|
+
from email import encoders
|
|
10
|
+
from src.sycommon.config.Config import Config
|
|
11
|
+
from src.sycommon.logging.kafka_log import SYLogger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SYEmail:
|
|
15
|
+
"""盛业邮件发送工具类"""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _get_config() -> dict:
|
|
19
|
+
"""获取邮件配置"""
|
|
20
|
+
return Config().config.get("AIEmailConfig", {})
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
async def send(
|
|
24
|
+
to: List[str],
|
|
25
|
+
subject: str,
|
|
26
|
+
content: str,
|
|
27
|
+
attachments: Optional[List[str]] = None,
|
|
28
|
+
verbose: bool = False
|
|
29
|
+
) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
异步发送邮件
|
|
32
|
+
|
|
33
|
+
:param to: 收件人列表
|
|
34
|
+
:param subject: 邮件主题
|
|
35
|
+
:param content: 邮件正文
|
|
36
|
+
:param attachments: 附件路径列表 (可选)
|
|
37
|
+
:param verbose: 是否打印详细日志
|
|
38
|
+
:return: 是否发送成功
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
# 1. 构建邮件对象
|
|
42
|
+
message = SYEmail._build_message(
|
|
43
|
+
to, subject, content, attachments
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 2. 发送邮件 (网络阻塞型,扔到线程池运行)
|
|
47
|
+
loop = asyncio.get_running_loop()
|
|
48
|
+
success = await loop.run_in_executor(
|
|
49
|
+
None,
|
|
50
|
+
SYEmail._send_smtp,
|
|
51
|
+
message,
|
|
52
|
+
to,
|
|
53
|
+
verbose
|
|
54
|
+
)
|
|
55
|
+
return success
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
SYLogger.error(f"❌ 异步邮件调度异常: {e}")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _build_message(to: List[str], subject: str, content: str, attachments: Optional[List[str]] = None):
|
|
63
|
+
"""构建邮件对象"""
|
|
64
|
+
config = SYEmail._get_config()
|
|
65
|
+
|
|
66
|
+
message = MIMEMultipart()
|
|
67
|
+
message['From'] = Header(config.get('sender_name', ''), 'utf-8')
|
|
68
|
+
message['To'] = Header(", ".join(to), 'utf-8')
|
|
69
|
+
message['Subject'] = Header(subject, 'utf-8')
|
|
70
|
+
message.attach(MIMEText(content, 'plain', 'utf-8'))
|
|
71
|
+
|
|
72
|
+
if attachments:
|
|
73
|
+
for file_path in attachments:
|
|
74
|
+
if os.path.exists(file_path):
|
|
75
|
+
try:
|
|
76
|
+
with open(file_path, 'rb') as f:
|
|
77
|
+
# 使用 octet-stream 通用流类型
|
|
78
|
+
attachment = MIMEBase(
|
|
79
|
+
'application', 'octet-stream')
|
|
80
|
+
attachment.set_payload(f.read())
|
|
81
|
+
encoders.encode_base64(attachment)
|
|
82
|
+
|
|
83
|
+
# 解决文件名中文乱码问题
|
|
84
|
+
filename = os.path.basename(file_path)
|
|
85
|
+
attachment.add_header(
|
|
86
|
+
'Content-Disposition',
|
|
87
|
+
'attachment',
|
|
88
|
+
filename=('utf-8', '', filename)
|
|
89
|
+
)
|
|
90
|
+
message.attach(attachment)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
SYLogger.error(f"⚠️ 读取附件失败 [{file_path}]: {e}")
|
|
93
|
+
else:
|
|
94
|
+
SYLogger.error(f"⚠️ 附件文件不存在: {file_path}")
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _send_smtp(message, to_list: List[str], verbose: bool) -> bool:
|
|
99
|
+
"""执行 SMTP 发送 (在线程池中运行)"""
|
|
100
|
+
config = SYEmail._get_config()
|
|
101
|
+
smtp_obj = None
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
if verbose:
|
|
105
|
+
SYLogger.info(
|
|
106
|
+
f"正在连接 {config.get('smtp_server')}:{config.get('smtp_port')} ...")
|
|
107
|
+
|
|
108
|
+
# 1. 创建连接
|
|
109
|
+
smtp_obj = smtplib.SMTP(
|
|
110
|
+
config.get('smtp_server'),
|
|
111
|
+
config.get('smtp_port'),
|
|
112
|
+
timeout=10
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if verbose:
|
|
116
|
+
smtp_obj.set_debuglevel(1)
|
|
117
|
+
|
|
118
|
+
# 2. 直接进行登录认证
|
|
119
|
+
if verbose:
|
|
120
|
+
SYLogger.info(f"正在使用账号 {config.get('smtp_user')} 登录...")
|
|
121
|
+
|
|
122
|
+
smtp_obj.login(config.get('smtp_user'), config.get('smtp_pass'))
|
|
123
|
+
|
|
124
|
+
if verbose:
|
|
125
|
+
SYLogger.info("✅ 登录成功")
|
|
126
|
+
|
|
127
|
+
# 3. 发送邮件
|
|
128
|
+
smtp_obj.sendmail(
|
|
129
|
+
config.get('sender_email'),
|
|
130
|
+
to_list,
|
|
131
|
+
message.as_string()
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if verbose:
|
|
135
|
+
SYLogger.info("✅ 邮件发送成功!")
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
except smtplib.SMTPAuthenticationError:
|
|
139
|
+
SYLogger.error("❌ 认证失败:请检查账号密码。")
|
|
140
|
+
except smtplib.SMTPException as e:
|
|
141
|
+
SYLogger.error(f"❌ SMTP 协议错误: {e}")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
SYLogger.error(f"❌ 发送邮件时发生未知错误: {e}")
|
|
144
|
+
finally:
|
|
145
|
+
if smtp_obj:
|
|
146
|
+
try:
|
|
147
|
+
smtp_obj.quit()
|
|
148
|
+
except:
|
|
149
|
+
pass
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# 使用示例
|
|
154
|
+
# async def main():
|
|
155
|
+
# """异步主函数测试"""
|
|
156
|
+
# print("开始发送邮件...")
|
|
157
|
+
|
|
158
|
+
# success = await SYEmail.send(
|
|
159
|
+
# to=["Osulcode.xiao@syholdings.com"],
|
|
160
|
+
# subject="异步邮件测试",
|
|
161
|
+
# content="这是一封通过异步方式发送的测试邮件。",
|
|
162
|
+
# attachments=["test_file.txt"],
|
|
163
|
+
# verbose=True
|
|
164
|
+
# )
|
|
165
|
+
|
|
166
|
+
# if success:
|
|
167
|
+
# print("\n🎉 任务完成")
|
|
168
|
+
# else:
|
|
169
|
+
# print("\n⚠️ 任务失败")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# if __name__ == "__main__":
|
|
173
|
+
# asyncio.run(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sycommon-python-lib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0b0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -21,6 +21,7 @@ Requires-Dist: nacos-sdk-python<3.0,>=2.0.9
|
|
|
21
21
|
Requires-Dist: psutil>=7.2.1
|
|
22
22
|
Requires-Dist: pydantic>=2.12.5
|
|
23
23
|
Requires-Dist: python-dotenv>=1.2.1
|
|
24
|
+
Requires-Dist: python-multipart>=0.0.21
|
|
24
25
|
Requires-Dist: pyyaml>=6.0.3
|
|
25
26
|
Requires-Dist: sentry-sdk[fastapi]>=2.49.0
|
|
26
27
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.45
|
|
@@ -50,9 +50,9 @@ sycommon/models/mqmsg_model.py,sha256=Zo-LsDMFuF1Vkx9ZmwBC9E7TrCw-7nAQD2TE4v9-6F
|
|
|
50
50
|
sycommon/models/mqsend_config.py,sha256=NQX9dc8PpuquMG36GCVhJe8omAW1KVXXqr6lSRU6D7I,268
|
|
51
51
|
sycommon/models/sso_user.py,sha256=i1WAN6k5sPcPApQEdtjpWDy7VrzWLpOrOQewGLGoGIw,2702
|
|
52
52
|
sycommon/notice/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
|
-
sycommon/notice/uvicorn_monitor.py,sha256=
|
|
54
|
-
sycommon/rabbitmq/rabbitmq_client.py,sha256=
|
|
55
|
-
sycommon/rabbitmq/rabbitmq_pool.py,sha256=
|
|
53
|
+
sycommon/notice/uvicorn_monitor.py,sha256=PrC-OFRE71mL8TcbfdkJRKbjwGAbgsWtyBPWIw1qs08,6753
|
|
54
|
+
sycommon/rabbitmq/rabbitmq_client.py,sha256=qmdBFzoyxd1or_FKwQo255OZa3Li2-ariN6ibrbrcvw,18496
|
|
55
|
+
sycommon/rabbitmq/rabbitmq_pool.py,sha256=uzuYwVMrghLmJb2uaqP3RKOVmjiVDFhgUTGKU7puLOY,19997
|
|
56
56
|
sycommon/rabbitmq/rabbitmq_service.py,sha256=XSHo9HuIJ_lq-vizRh4xJVdZr_2zLqeLhot09qb0euA,2025
|
|
57
57
|
sycommon/rabbitmq/rabbitmq_service_client_manager.py,sha256=IP9TMFeG5LSrwFPEmOy1ce4baPxBUZnWJZR3nN_-XR4,8009
|
|
58
58
|
sycommon/rabbitmq/rabbitmq_service_connection_monitor.py,sha256=uvoMuJDzJ9i63uVRq1NKFV10CvkbGnTMyEoq2rgjQx8,3013
|
|
@@ -73,17 +73,19 @@ sycommon/synacos/nacos_client_base.py,sha256=KZgQAg9Imfr_TfM-4LXdtrnTdJ-beu6bcNJ
|
|
|
73
73
|
sycommon/synacos/nacos_config_manager.py,sha256=Cff-4gpp0aD7sQVi-nEvDO4BWqK9abEDDDJ9qXKFQgs,4399
|
|
74
74
|
sycommon/synacos/nacos_heartbeat_manager.py,sha256=G80_pOn37WdO_HpYUiAfpwMqAxW0ff0Bnw0NEuge9v0,5568
|
|
75
75
|
sycommon/synacos/nacos_service.py,sha256=BezQ1eDIYwBPE567Po_Qh1Ki_z9WmhZy1J1NiTPbdHY,6118
|
|
76
|
-
sycommon/synacos/nacos_service_discovery.py,sha256=
|
|
76
|
+
sycommon/synacos/nacos_service_discovery.py,sha256=rcnMTX8e6VJmu2LwW5lrubt2UQNpu4e8P7GVcOi79Cg,6926
|
|
77
77
|
sycommon/synacos/nacos_service_registration.py,sha256=plg2PmH8CWgmVnPtiIXBxtj-3BpyMdSzKr1wyWRdzh4,10968
|
|
78
78
|
sycommon/synacos/param.py,sha256=KcfSkxnXOa0TGmCjY8hdzU9pzUsA8-4PeyBKWI2-568,1765
|
|
79
|
+
sycommon/tests/test_email.py,sha256=-oPtYVGQzJ3Cv-op3ZNRMeyYOF-UNidGJC35CHRgjGQ,5442
|
|
79
80
|
sycommon/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
80
81
|
sycommon/tools/docs.py,sha256=OPj2ETheuWjXLyaXtaZPbwmJKfJaYXV5s4XMVAUNrms,1607
|
|
81
82
|
sycommon/tools/env.py,sha256=Ah-tBwG2C0_hwLGFebVQgKdWWXCjTzBuF23gCkLHYy4,2437
|
|
82
83
|
sycommon/tools/merge_headers.py,sha256=u9u8_1ZIuGIminWsw45YJ5qnsx9MB-Fot0VPge7itPw,4941
|
|
83
84
|
sycommon/tools/snowflake.py,sha256=xQlYXwYnI85kSJ1rZ89gMVBhzemP03xrMPVX9vVa3MY,9228
|
|
85
|
+
sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,5612
|
|
84
86
|
sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
|
|
85
|
-
sycommon_python_lib-0.
|
|
86
|
-
sycommon_python_lib-0.
|
|
87
|
-
sycommon_python_lib-0.
|
|
88
|
-
sycommon_python_lib-0.
|
|
89
|
-
sycommon_python_lib-0.
|
|
87
|
+
sycommon_python_lib-0.2.0b0.dist-info/METADATA,sha256=ZfRx2Yi36wFwxEhsycG6Pi53lzKkNXhjdP82hJjITr8,7372
|
|
88
|
+
sycommon_python_lib-0.2.0b0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
89
|
+
sycommon_python_lib-0.2.0b0.dist-info/entry_points.txt,sha256=q_h2nbvhhmdnsOUZEIwpuoDjaNfBF9XqppDEmQn9d_A,46
|
|
90
|
+
sycommon_python_lib-0.2.0b0.dist-info/top_level.txt,sha256=98CJ-cyM2WIKxLz-Pf0AitWLhJyrfXvyY8slwjTXNuc,17
|
|
91
|
+
sycommon_python_lib-0.2.0b0.dist-info/RECORD,,
|
{sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b0.dist-info}/top_level.txt
RENAMED
|
File without changes
|