1 Commits
Author SHA1 Message Date
hz4th_coder 904a4d0d6c 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响应
2026-08-28 13:17:31 +08:00
4 changed files with 378 additions and 2 deletions
+11 -1
View File
@@ -2,7 +2,7 @@
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
**版本:v2.1.1**
**版本:v2.1.2**
## 功能特点
@@ -202,6 +202,16 @@ MODEL_ALIASES = {
3. 请求失败自动切换到下一个托管同一模型的提供商(保持请求模型不变)
4. 连续失败3次的提供商被熔断,冷却期(默认60秒)后自动半开恢复
## 邮件通知(auto 调用全部失败时)
某个 auto 配置被调用后所有提供商都失败(如全部熔断/停用),自动发邮件通知,方便及时处理。
- **配置入口**:后台「系统配置」页 → 邮件通知(SMTP 服务器/账号/密码/收件人/加密方式,均可在网页配置)
- **防轰炸**:默认最小间隔 300 秒,短时间内连续失败只发一封
- **测试**:配置页有「发送测试邮件」按钮
- **端点**`/api/admin/email`GET/PUT)、`/api/admin/email/test`POST
- **默认配置**`mail.tphai.com:587`plain)、收件人 `wlq@tphai.com`
## 项目结构
```
+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():
+30
View File
@@ -200,6 +200,36 @@ ROUTING_CONFIG = {
"cache_ttl_seconds": 3600, # 记忆有效期(秒)
}
# 邮件通知配置(auto 调用全部失败时发邮件通知)
EMAIL_CONFIG = {
"enabled": True, # 总开关
"notify_on_auto_failure": True, # auto 调用全部失败时发邮件
"smtp_host": "mail.tphai.com",
"smtp_port": 587,
"smtp_mode": "plain", # plain | starttls | ssl
"smtp_user": "hz4th_coder@tphai.com",
"smtp_password": "hz4th_coder@!",
"from_name": "LLM Proxy",
"from_addr": "hz4th_coder@tphai.com",
"to_addrs": ["wlq@tphai.com"],
"cooldown_seconds": 300, # 失败通知最小间隔(防轰炸)
}
def load_email_config():
"""加载邮件通知配置(运行时配置优先)"""
config = load_config()
return {**EMAIL_CONFIG, **config.get("email_config", {})}
def save_email_config(data):
"""保存邮件通知配置"""
config = load_config()
merged = {**EMAIL_CONFIG, **data}
config["email_config"] = merged
save_config(config)
return merged
def load_routing_config():
"""加载路由缓存配置(运行时配置优先)"""
+158
View File
@@ -110,6 +110,14 @@
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-1">邮件通知(auto 调用全部失败时通知)</h2>
<p class="text-sm text-gray-500 mb-4">某个 auto 配置被调用后所有提供商都失败,自动发送邮件到收件人</p>
<div id="emailConfigContent" class="space-y-4">
<p class="text-gray-500 text-sm">加载中...</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
<div class="space-y-4">
@@ -151,6 +159,7 @@
</p>
</div>
`;
loadEmailConfig();
}
async function toggleCacheModel() {
@@ -192,6 +201,155 @@
}
}
async function loadEmailConfig() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const container = document.getElementById('emailConfigContent');
container.innerHTML = `
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-700">启用邮件通知</span>
<button id="emailEnabledToggle" onclick="toggleEmailEnabled()" class="relative w-10 h-5 rounded-full transition ${e.enabled ? 'bg-green-500' : 'bg-gray-300'}">
<span id="emailEnabledKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.enabled ? 'left-5' : 'left-0.5'}"></span>
</button>
</div>
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-700">auto 失败时通知</span>
<button id="notifyToggle" onclick="toggleNotify()" class="relative w-10 h-5 rounded-full transition ${e.notify_on_auto_failure ? 'bg-green-500' : 'bg-gray-300'}">
<span id="notifyKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.notify_on_auto_failure ? 'left-5' : 'left-0.5'}"></span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 服务器</label>
<input type="text" id="emailHost" value="${e.smtp_host}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">端口</label>
<input type="number" id="emailPort" value="${e.smtp_port}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">加密方式</label>
<select id="emailMode" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="plain" ${e.smtp_mode==='plain'?'selected':''}>plain(无加密)</option>
<option value="starttls" ${e.smtp_mode==='starttls'?'selected':''}>STARTTLS</option>
<option value="ssl" ${e.smtp_mode==='ssl'?'selected':''}>SSL</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<input type="text" id="emailUser" value="${e.smtp_user}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码(留空不改)</label>
<input type="password" id="emailPass" value="${e.smtp_password}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件人名称</label>
<input type="text" id="emailFromName" value="${e.from_name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件地址</label>
<input type="text" id="emailFromAddr" value="${e.from_addr}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">收件人(逗号分隔)</label>
<input type="text" id="emailTo" value="${(e.to_addrs||[]).join(', ')}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">失败通知最小间隔(秒)</label>
<input type="number" id="emailCooldown" value="${e.cooldown_seconds}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<button onclick="saveEmail()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
<i class="ri-save-line mr-1"></i> 保存
</button>
<button onclick="testEmail(this)" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
<i class="ri-mail-send-line mr-1"></i> 发送测试邮件
</button>
</div>
`;
}
async function toggleEmailEnabled() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const newVal = !e.enabled;
const r = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newVal })
});
const d = await r.json();
if (d.success) {
const btn = document.getElementById('emailEnabledToggle');
const knob = document.getElementById('emailEnabledKnob');
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
} else { alert('保存失败: ' + (d.error || '')); }
}
async function toggleNotify() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const newVal = !e.notify_on_auto_failure;
const r = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_on_auto_failure: newVal })
});
const d = await r.json();
if (d.success) {
const btn = document.getElementById('notifyToggle');
const knob = document.getElementById('notifyKnob');
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
} else { alert('保存失败: ' + (d.error || '')); }
}
async function saveEmail() {
const data = {
smtp_host: document.getElementById('emailHost').value.trim(),
smtp_port: parseInt(document.getElementById('emailPort').value) || 25,
smtp_mode: document.getElementById('emailMode').value,
smtp_user: document.getElementById('emailUser').value.trim(),
from_name: document.getElementById('emailFromName').value.trim(),
from_addr: document.getElementById('emailFromAddr').value.trim(),
to_addrs: document.getElementById('emailTo').value,
cooldown_seconds: parseInt(document.getElementById('emailCooldown').value) || 300
};
const pass = document.getElementById('emailPass').value;
if (pass && pass !== '********') data.smtp_password = pass;
const res = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const d = await res.json();
if (d.success) {
alert('✅ 邮件配置已保存');
loadEmailConfig();
} else { alert('保存失败: ' + (d.error || '')); }
}
async function testEmail(btn) {
if (!btn) btn = event.target.closest('button');
btn.disabled = true; btn.innerHTML = '<i class="ri-loader-4-line animate-spin mr-1"></i> 发送中...';
try {
const res = await fetch('/api/admin/email/test', { method: 'POST' });
const d = await res.json();
if (d.success) { alert('✅ ' + d.message); }
else { alert('❌ ' + (d.error || '发送失败')); }
} catch (e) {
alert('❌ ' + e.message);
}
btn.disabled = false; btn.innerHTML = '<i class="ri-mail-send-line mr-1"></i> 发送测试邮件';
}
loadConfig();
</script>
</body>