v2.1.2: auto调用全部失败时邮件通知 + 网页配置邮箱

- 新增邮件通知: auto 配置被调用后所有提供商都失败(含无可用提供商)时自动发邮件
- 通知路径: chat_completions 400/503 + 通用能力端点(生图/语音/视频) + ASR端点
- 防轰炸: cooldown_seconds(默认300s)最小间隔, 连续失败只发一封(实测3次失败1封)
- 网页配置: 系统配置页新增「邮件通知」卡片, 支持 SMTP服务器/端口/加密(plain/starttls/ssl)/账号密码/发件人/收件人/间隔 + 发送测试邮件按钮
- API: GET/PUT /api/admin/email(密码掩码), POST /api/admin/email/test
- 默认SMTP: mail.tphai.com:587 plain, 收件人 wlq@tphai.com
- 修复: config页loadEmailConfig竞态(卡片未渲染就填充); 邮件失败不影响API响应
This commit is contained in:
2026-08-28 13:17:31 +08:00
parent d387d5ba09
commit 904a4d0d6c
4 changed files with 378 additions and 2 deletions
+179 -1
View File
@@ -16,6 +16,10 @@ import time
import random
import hashlib
import logging
import smtplib
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr
from datetime import datetime, date
from pathlib import Path
import sys
@@ -30,6 +34,7 @@ from config.settings import (
load_config, save_config, get_provider, add_provider, update_provider,
delete_provider, update_priority, update_model_alias, delete_model_alias,
add_auto_profile, update_auto_profile, delete_auto_profile, load_routing_config, save_routing_config,
load_email_config, save_email_config,
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
)
@@ -37,7 +42,7 @@ app = Flask(__name__, template_folder='templates')
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启)
CORS(app)
VERSION = "2.1.1"
VERSION = "2.1.2"
# 数据目录和统计文件
DATA_DIR = Path(__file__).parent / 'data'
@@ -101,6 +106,86 @@ def remember_history_model(fp, provider_name, model):
with history_cache_lock:
history_model_cache[fp] = {'provider': provider_name, 'model': model, 'ts': time.time()}
# ============ 邮件通知(auto 调用全部失败时发邮件) ============
_email_last_sent = 0
_email_lock = threading.Lock()
def _smtp_connect(cfg):
"""建立 SMTP 连接(支持 plain / starttls / ssl"""
host = cfg.get('smtp_host', '')
port = int(cfg.get('smtp_port', 25) or 25)
mode = cfg.get('smtp_mode', 'plain')
if mode == 'ssl':
server = smtplib.SMTP_SSL(host, port, timeout=15)
else:
server = smtplib.SMTP(host, port, timeout=15)
if mode == 'starttls':
server.starttls()
if cfg.get('smtp_user'):
server.login(cfg.get('smtp_user'), cfg.get('smtp_password', ''))
return server
def send_email(subject, body, cfg=None, html=False):
"""发送邮件,返回 {'success': bool, 'error'?: str}"""
cfg = cfg or load_email_config()
to_addrs = cfg.get('to_addrs') or []
if isinstance(to_addrs, str):
to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()]
if not to_addrs:
return {'success': False, 'error': '未配置收件人'}
msg = MIMEText(body, 'html' if html else 'plain', 'utf-8')
msg['Subject'] = Header(subject, 'utf-8')
from_addr = cfg.get('from_addr') or cfg.get('smtp_user') or ''
msg['From'] = formataddr((str(Header(cfg.get('from_name', ''), 'utf-8')), from_addr))
msg['To'] = ', '.join(to_addrs)
server = _smtp_connect(cfg)
try:
server.sendmail(from_addr, to_addrs, msg.as_string())
return {'success': True}
finally:
try:
server.quit()
except Exception:
pass
def notify_auto_failure(model, capability, last_error):
"""auto 调用全部失败时发送邮件通知(带冷却防轰炸)"""
global _email_last_sent
cfg = load_email_config()
if not cfg.get('enabled') or not cfg.get('notify_on_auto_failure'):
return
with _email_lock:
now = time.time()
if now - _email_last_sent < int(cfg.get('cooldown_seconds', 300) or 300):
return
_email_last_sent = now
try:
cap_label = CAPABILITY_DEFS.get(capability, capability)
subject = f"[LLM Proxy] Auto 调用全部失败: {model}"
body = (
f"时间: {datetime.now().isoformat()}\n"
f"Auto 配置: {model}\n"
f"能力: {cap_label}\n"
f"最后错误: {last_error}\n\n"
f"提示: 系统已尝试所有可用提供商仍未成功,请登录后台 (http://<IP>:16003/admin) 检查提供商状态。"
)
r = send_email(subject, body, cfg)
if r['success']:
logger.warning(f"Auto failure notification sent for {model} (capability={capability})")
else:
logger.error(f"Auto failure email send failed: {r.get('error')}")
except Exception as e:
logger.error(f"Failed to send auto failure email: {e}")
# 提供商状态缓存
provider_status = {}
@@ -664,6 +749,8 @@ def chat_completions():
if not provider:
increment_stats(model, 'unknown', success=False, error=f'No provider for model: {model}')
if is_auto_model(model):
notify_auto_failure(model, capability, f"No available provider (capability: {capability})")
return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400
request_provider = provider['name']
@@ -743,6 +830,8 @@ def chat_completions():
break
increment_stats(model, request_provider or 'unknown', success=False, error=str(last_error))
if is_auto_model(model):
notify_auto_failure(model, capability, str(last_error))
return jsonify({"error": {"message": f"All providers failed. Last error: {last_error}", "type": "api_error"}}), 503
except Exception as e:
@@ -811,6 +900,8 @@ def _generic_capability_endpoint(capability, path, error_msg="No available provi
break
if not provider:
if is_auto_model(model):
notify_auto_failure(model, capability, error_msg)
return jsonify({"error": {"message": f"{error_msg} (capability: {capability})", "type": "invalid_request_error"}}), 400
data['model'] = resolved_model if resolved_model else data.get('model')
@@ -857,6 +948,8 @@ def audio_transcriptions():
break
if not provider:
if is_auto_model(model):
notify_auto_failure(model, 'audio_in', "No ASR provider available")
return jsonify({"error": {"message": "No ASR provider available (capability: audio_in)", "type": "invalid_request_error"}}), 400
response = proxy_raw(provider, 'audio/transcriptions')
@@ -1695,6 +1788,91 @@ def api_admin_routing_put():
return jsonify({'success': True, 'routing_config': result})
# ============ 后台管理 API:邮件通知 ============
def _mask_email_config(cfg):
"""返回给前端时掩码密码"""
out = dict(cfg)
out['smtp_password'] = '********' if cfg.get('smtp_password') else ''
return out
@app.route('/api/admin/email', methods=['GET'])
def api_admin_email_get():
"""获取邮件通知配置(密码掩码)"""
return jsonify(_mask_email_config(load_email_config()))
@app.route('/api/admin/email', methods=['PUT'])
def api_admin_email_put():
"""更新邮件通知配置"""
data = request.get_json()
if not data:
return jsonify({'error': 'Invalid request body'}), 400
cfg = load_email_config()
update = {}
for key in ['enabled', 'notify_on_auto_failure', 'smtp_host', 'smtp_port', 'smtp_mode',
'smtp_user', 'from_name', 'from_addr', 'to_addrs', 'cooldown_seconds']:
if key not in data:
continue
v = data[key]
if key in ('enabled', 'notify_on_auto_failure'):
v = bool(v)
elif key == 'smtp_port':
try:
v = int(v)
except:
continue
elif key == 'cooldown_seconds':
try:
v = max(0, int(v))
except:
continue
elif key == 'to_addrs':
if isinstance(v, str):
v = [a.strip() for a in v.split(',') if a.strip()]
elif isinstance(v, list):
v = [str(a).strip() for a in v if str(a).strip()]
elif key == 'smtp_mode':
if v not in ('plain', 'starttls', 'ssl'):
continue
update[key] = v
# 密码:掩码占位表示未修改
if 'smtp_password' in data and data['smtp_password'] and data['smtp_password'] != '********':
update['smtp_password'] = data['smtp_password']
if not update:
return jsonify({'error': 'No valid fields'}), 400
result = save_email_config(update)
return jsonify({'success': True, 'email_config': _mask_email_config(result)})
@app.route('/api/admin/email/test', methods=['POST'])
def api_admin_email_test():
"""发送测试邮件(使用当前配置)"""
cfg = load_email_config()
to_addrs = cfg.get('to_addrs') or []
if isinstance(to_addrs, str):
to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()]
if not to_addrs:
return jsonify({'success': False, 'error': '请先配置收件人'}), 400
try:
r = send_email(
"[LLM Proxy] 测试邮件",
"这是一封来自 LLM Proxy 的测试邮件,说明邮件通知配置正常。",
cfg
)
if r['success']:
return jsonify({'success': True, 'message': f'测试邮件已发送到 {to_addrs}'})
return jsonify({'success': False, 'error': r.get('error', '发送失败')}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
# ============ 后台管理 API:对话 ============
def load_chats():