""" 网站基础设置 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 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 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, }, "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, }, "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 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": "短信配置已更新"} # ============================================================ # 邮件测试 # ============================================================ @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}") try: msg = MIMEMultipart() msg["From"] = config.from_address msg["To"] = recipient msg["Subject"] = subject msg.attach(MIMEText(body, "plain")) if config.use_tls: server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) server.starttls() else: server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) server.login(config.username, config.password) server.sendmail(config.from_address, recipient, msg.as_string()) server.quit() 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}", ) 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)}")