Files
qihuaiflow/backend/python/app/api/admin/settings.py
T

607 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
网站基础设置 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...database import get_db, SessionLocal
from ...models import SiteSetting, EmailConfig, SmsConfig, User, AIModel
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import smtplib
import ssl
import hmac
import hashlib
import base64
import urllib.parse
import time as time_module
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from pydantic import BaseModel
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger("aiflow")
router = APIRouter()
# ============================================================
# Pydantic Models
# ============================================================
class EmailConfigUpdate(BaseModel):
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
from_address: Optional[str] = None
use_tls: Optional[bool] = None
use_ssl: Optional[bool] = None
class SmsConfigUpdate(BaseModel):
provider: Optional[str] = None
api_key: Optional[str] = None
api_secret: Optional[str] = None
sign_name: Optional[str] = None
template_code: Optional[str] = None
# ============================================================
# 网站设置
# ============================================================
@router.get("/site")
async def get_site_settings(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取网站设置(返回所有 key-value 对)"""
settings = db.query(SiteSetting).all()
result = {}
for s in settings:
result[s.key] = s.value
return {"code": 0, "data": result, "message": "ok"}
@router.put("/site")
async def update_site_settings(
settings: dict,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""批量更新网站设置"""
for key, value in settings.items():
existing = db.query(SiteSetting).filter(SiteSetting.key == key).first()
if existing:
existing.value = str(value) if value is not None else None
existing.updated_at = datetime.now()
else:
new_setting = SiteSetting(
id=str(uuid.uuid4()),
key=key,
value=str(value) if value is not None else None,
)
db.add(new_setting)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_site_settings",
target_type="site_settings",
detail=f"更新网站设置: {list(settings.keys())}",
)
return {"message": "网站设置已更新"}
# ============================================================
# 邮件配置
# ============================================================
@router.get("/email")
async def get_email_config(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取邮件配置"""
config = db.query(EmailConfig).first()
if not config:
return {"code": 0, "data": {
"smtp_host": "",
"smtp_port": 587,
"username": "",
"password": "",
"from_address": "",
"use_tls": True,
"use_ssl": False,
}, "message": "ok"}
return {"code": 0, "data": {
"id": config.id,
"smtp_host": config.smtp_host,
"smtp_port": config.smtp_port,
"username": config.username,
"password": config.password,
"from_address": config.from_address,
"use_tls": config.use_tls,
"use_ssl": bool(config.use_ssl) if config.use_ssl is not None else False,
}, "message": "ok"}
@router.put("/email")
async def update_email_config(
req: EmailConfigUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新邮件配置"""
config = db.query(EmailConfig).first()
if not config:
# 必填字段校验:不允许空值
if not req.smtp_host:
raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空")
if not req.username:
raise HTTPException(status_code=400, detail="SMTP用户名不能为空")
if not req.password:
raise HTTPException(status_code=400, detail="SMTP密码不能为空")
if not req.from_address:
raise HTTPException(status_code=400, detail="发件地址不能为空")
config = EmailConfig(
id=str(uuid.uuid4()),
smtp_host=req.smtp_host or "",
smtp_port=req.smtp_port or 587,
username=req.username or "",
password=req.password or "",
from_address=req.from_address or "",
use_tls=req.use_tls if req.use_tls is not None else True,
)
db.add(config)
else:
if req.smtp_host is not None:
if not req.smtp_host.strip():
raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空")
config.smtp_host = req.smtp_host
if req.smtp_port is not None:
config.smtp_port = req.smtp_port
if req.username is not None:
if not req.username.strip():
raise HTTPException(status_code=400, detail="SMTP用户名不能为空")
config.username = req.username
if req.password is not None:
if not req.password.strip():
raise HTTPException(status_code=400, detail="SMTP密码不能为空")
config.password = req.password
if req.from_address is not None:
if not req.from_address.strip():
raise HTTPException(status_code=400, detail="发件地址不能为空")
config.from_address = req.from_address
if req.use_tls is not None:
config.use_tls = req.use_tls
if req.use_ssl is not None:
config.use_ssl = req.use_ssl
config.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_email_config",
target_type="email_config",
detail="更新邮件配置",
)
return {"code": 0, "data": None, "message": "邮件配置已更新"}
# ============================================================
# 短信配置
# ============================================================
@router.get("/sms")
async def get_sms_config(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取短信配置"""
config = db.query(SmsConfig).first()
if not config:
return {"code": 0, "data": {
"provider": "",
"api_key": "",
"api_secret": "",
"sign_name": "",
"template_code": "",
}, "message": "ok"}
return {"code": 0, "data": {
"id": config.id,
"provider": config.provider,
"api_key": config.api_key,
"api_secret": config.api_secret,
"sign_name": config.sign_name,
"template_code": config.template_code,
}, "message": "ok"}
@router.put("/sms")
async def update_sms_config(
req: SmsConfigUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新短信配置"""
config = db.query(SmsConfig).first()
if not config:
config = SmsConfig(
id=str(uuid.uuid4()),
provider=req.provider or "",
api_key=req.api_key or "",
api_secret=req.api_secret or "",
sign_name=req.sign_name,
template_code=req.template_code,
)
db.add(config)
else:
if req.provider is not None:
config.provider = req.provider
if req.api_key is not None:
config.api_key = req.api_key
if req.api_secret is not None:
config.api_secret = req.api_secret
if req.sign_name is not None:
config.sign_name = req.sign_name
if req.template_code is not None:
config.template_code = req.template_code
config.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_sms_config",
target_type="sms_config",
detail="更新短信配置",
)
return {"message": "短信配置已更新"}
# ============================================================
# Logo 上传
# ============================================================
from fastapi import UploadFile, File
@router.post("/logo/upload")
async def upload_logo(
file: UploadFile = File(...),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""上传平台Logo,保存到本地data目录,返回访问URL"""
import os as _os
import shutil as _shutil
# Validate file type
allowed_types = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
if file.content_type not in allowed_types:
raise HTTPException(status_code=400, detail=f"不支持的文件类型: {file.content_type},仅支持 PNG/JPG/SVG/WEBP")
# Validate size (max 2MB)
contents = await file.read()
if len(contents) > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过 2MB")
# Save to /app/data/uploads/logos/
upload_dir = "/app/data/uploads/logos"
_os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename
ext = file.filename.split('.')[-1] if '.' in (file.filename or '') else 'png'
filename = f"{uuid.uuid4()}.{ext}"
filepath = _os.path.join(upload_dir, filename)
with open(filepath, 'wb') as f:
f.write(contents)
# Build URL
logo_url = f"/data/uploads/logos/{filename}"
# Update site setting
existing = db.query(SiteSetting).filter(SiteSetting.key == "logo").first()
if existing:
existing.value = logo_url
existing.updated_at = datetime.now()
else:
new_setting = SiteSetting(
id=str(uuid.uuid4()),
key="logo",
value=logo_url,
)
db.add(new_setting)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="upload_logo",
target_type="site_settings",
detail=f"上传Logo: {filename}",
)
return {
"code": 0,
"data": {"url": logo_url, "filename": filename},
"message": "Logo 上传成功",
}
# ============================================================
# 邮件测试
# ============================================================
@router.post("/email/test")
async def test_email_config(
test_to: str = Query(default=None, description="自定义测试收件地址"),
test_subject: str = Query(default=None, description="自定义测试邮件主题"),
test_body: str = Query(default=None, description="自定义测试邮件正文"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""使用当前配置发送测试邮件;可传参自定义收件人、主题和正文"""
config = db.query(EmailConfig).first()
if not config or not config.smtp_host:
raise HTTPException(status_code=400, detail="邮件配置未设置,请先保存邮件配置")
if not config.username:
raise HTTPException(status_code=400, detail="SMTP用户名未填写,请检查邮件配置")
if not config.password:
raise HTTPException(status_code=400, detail="SMTP密码未填写,请检查邮件配置")
if not config.from_address:
raise HTTPException(status_code=400, detail="发件地址未填写,请检查邮件配置")
# 优先级:传入参数 > 管理员邮箱 > 发件地址
admin = db.query(User).filter(User.id == admin_user.id).first()
recipient = test_to or (admin.email if admin and admin.email else None) or config.from_address
if not recipient:
raise HTTPException(status_code=400, detail="无法获取接收地址: 请填写收件邮箱或设置管理员邮箱")
subject = test_subject or "AIFlow 邮件配置测试"
body = test_body or f"这是一封来自 AIFlow 平台的测试邮件。\n\n发送时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\nSMTP服务器: {config.smtp_host}"
logger.info(f"测试邮件: SMTP={config.smtp_host}:{config.smtp_port}, "
f"from={config.from_address}, to={recipient}")
# 选择加密方式:use_ssl 优先;未显式设置时按端口推断(465=隐式SSL)
try:
use_ssl = bool(config.use_ssl) if config.use_ssl is not None else (config.smtp_port == 465)
except Exception:
use_ssl = (config.smtp_port == 465)
use_tls = bool(config.use_tls)
msg = MIMEMultipart()
msg["From"] = config.from_address
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
try:
server = None
try:
if use_ssl:
# 端口465等:隐式SSL(SMTPS),需 SMTP_SSL 而非 STARTTLS
server = smtplib.SMTP_SSL(
config.smtp_host, config.smtp_port, timeout=20,
context=ssl.create_default_context(),
)
elif use_tls:
# 端口587/25:先明文连接再 STARTTLS 升级
server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=20)
server.starttls(context=ssl.create_default_context())
else:
server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=20)
server.login(config.username, config.password)
server.sendmail(config.from_address, recipient, msg.as_string())
finally:
if server is not None:
try:
server.quit()
except Exception:
pass
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="test_email_config",
target_type="email_config",
detail=f"测试邮件发送成功 -> {recipient} via {config.smtp_host}:{config.smtp_port}",
)
return {"code": 0, "data": {"message": f"测试邮件已发送到 {recipient}", "recipient": recipient}, "message": "ok"}
except smtplib.SMTPAuthenticationError as e:
logger.error(f"SMTP认证失败: {e}")
raise HTTPException(status_code=400, detail=f"SMTP认证失败,请检查用户名和密码: {str(e)}")
except smtplib.SMTPConnectError as e:
logger.error(f"SMTP连接失败: {e}")
raise HTTPException(status_code=400, detail=f"无法连接到SMTP服务器 {config.smtp_host}:{config.smtp_port}: {str(e)}")
except smtplib.SMTPException as e:
logger.error(f"SMTP发送失败: {e}")
raise HTTPException(status_code=500, detail=f"邮件发送失败(SMTP): {str(e)}")
except OSError as e:
logger.error(f"邮件发送网络错误: {e}")
raise HTTPException(status_code=500, detail=f"网络连接错误: {str(e)}")
except BaseException as e:
logger.error(f"测试邮件发送失败(严重): {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}")
@router.post("/email/generate-content")
async def generate_email_content(
topic: str = Query(default="测试邮件"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""AI生成测试邮件内容:调用默认LLM生成邮件主题和正文"""
import httpx as httpx_lib
subjects = [
"【测试】AIFlow邮件服务配置验证",
"AIFlow平台 - 邮件投递测试",
"测试邮件:您的SMTP服务已就绪",
]
bodies = [
f"您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件,用于验证邮件服务配置是否正常。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队",
f"Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team",
f"【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确,能够成功投递邮件。\n\n—— AIFlow",
]
# 尝试使用默认LLM生成更好的内容
try:
default_model = db.query(AIModel).filter(
AIModel.is_default == True, AIModel.is_enabled == True
).first()
if default_model and default_model.endpoint:
async with httpx_lib.AsyncClient(timeout=15) as client:
headers = {}
if default_model.api_key:
headers["Authorization"] = f"Bearer {default_model.api_key}"
resp = await client.post(
default_model.endpoint,
json={
"model": default_model.name,
"messages": [
{"role": "system", "content": "你是一个专业的技术文档撰写者。请根据主题生成一封测试邮件的主题和正文。返回JSON格式: {\"subject\": \"邮件主题\", \"body\": \"邮件正文\"}"},
{"role": "user", "content": f"主题: {topic}"},
],
"max_tokens": 400,
},
headers=headers,
)
if resp.status_code == 200:
content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
try:
data = json.loads(content)
if data.get("subject") and data.get("body"):
return {"subject": data["subject"], "body": data["body"], "source": "ai"}
except:
# 尝试解析非JSON格式
lines = content.strip().split("\n")
if len(lines) >= 2:
return {"subject": lines[0].replace("主题:", "").strip(), "body": "\n".join(lines[1:]).replace("正文:", "").strip(), "source": "ai_partial"}
except Exception:
pass
import random
idx = random.randint(0, len(subjects) - 1)
return {"subject": subjects[idx], "body": bodies[idx], "source": "fallback"}
# ============================================================
# 短信测试
# ============================================================
def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str:
"""阿里云签名机制 (HMAC-SHA1)"""
sorted_keys = sorted(params.keys())
qs = "&".join([
f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}"
for k in sorted_keys
])
string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}"
key = f"{secret}&"
h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1)
return base64.b64encode(h.digest()).decode('utf-8')
@router.post("/sms/test")
async def test_sms_config(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""使用当前配置发送测试短信"""
config = db.query(SmsConfig).first()
if not config:
raise HTTPException(status_code=400, detail="短信配置未设置,请先保存短信配置")
if not config.api_key or not config.api_secret:
raise HTTPException(
status_code=400,
detail="未配置阿里云短信 AccessKey,请填写 API Key (AccessKey ID) 和 API Secret (AccessKey Secret)"
)
if not config.sign_name:
raise HTTPException(status_code=400, detail="未配置短信签名")
# 获取接收测试短信的手机号(管理员手机号)
admin = db.query(User).filter(User.id == admin_user.id).first()
phone = admin.phone if admin and admin.phone else None
if not phone:
raise HTTPException(status_code=400, detail="管理员未设置手机号,无法发送测试短信")
logger.info(f"测试短信: provider={config.provider}, sign={config.sign_name}, phone={phone}")
# 只有阿里云短信支持测试
if "阿里云" not in (config.provider or ""):
raise HTTPException(
status_code=400,
detail=f"当前短信服务商 '{config.provider}' 暂不支持在线测试,请直接在生产环境测试"
)
try:
# 阿里云短信参数
params = {
"AccessKeyId": config.api_key,
"Action": "SendSms",
"Format": "JSON",
"PhoneNumbers": phone,
"RegionId": "cn-hangzhou",
"SignName": config.sign_name,
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(uuid.uuid4()),
"SignatureVersion": "1.0",
"TemplateCode": config.template_code or "SMS_00000001",
"TemplateParam": '{"code":"123456"}',
"Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"Version": "2017-05-25",
}
params["Signature"] = _aliyun_sign(config.api_secret, params)
import requests as http_requests
resp = http_requests.post(
"https://dysmsapi.aliyuncs.com/",
data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
result = resp.json()
logger.info(f"阿里云短信API响应: {result}")
if result.get("Code") == "OK":
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="test_sms_config",
target_type="sms_config",
detail=f"测试短信发送成功 -> {phone}",
)
return {"message": f"测试短信已发送到 {phone}", "phone": phone, "biz_id": result.get("BizId")}
else:
error_msg = result.get("Message", result.get("Code", "未知错误"))
raise HTTPException(status_code=400, detail=f"阿里云短信发送失败: {error_msg}")
except HTTPException:
raise
except Exception as e:
logger.error(f"测试短信发送失败: {e}")
raise HTTPException(status_code=500, detail=f"短信发送失败: {str(e)}")