Files
QihuAI Design 771c8456b8 chore: normalize line endings to LF via .gitattributes
Root-cause fix for mixed CRLF/LF in the repo (no .gitattributes previously).
Adds '* text=auto eol=lf' and explicit binary rules so all future checkouts
are LF. Renormalizes all tracked text files (no logical content change).
Backup tags: image qihuaiflow:backup-lineend-20260725-121632, git backup-lineend-pre-20260725-121632.
2026-07-25 12:16:39 +08:00

625 lines
24 KiB
Python

"""
网站基础设置 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
# 短信宝 (SMSBao)
username: Optional[str] = None
password: Optional[str] = None
goods_id: 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": "",
"username": "",
"password": "",
"goods_id": "",
}, "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,
"username": config.username,
"password": config.password,
"goods_id": config.goods_id,
}, "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,
username=req.username,
password=req.password,
goods_id=req.goods_id,
)
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
if req.username is not None:
config.username = req.username
if req.password is not None:
config.password = req.password
if req.goods_id is not None:
config.goods_id = req.goods_id
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')
# 短信宝 (SMSBao) 辅助
def _smsbao_md5(password: str) -> str:
"""短信宝密码:登录密码的 MD5(32位,不区分大小写)"""
return hashlib.md5(password.encode("utf-8")).hexdigest()
SMSBAO_ERRORS = {
"0": "发送成功",
"30": "密码错误",
"40": "账号不存在",
"41": "余额不足",
"43": "IP地址限制",
"50": "内容含有敏感词",
"51": "手机号码不正确",
}
@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}")
# 短信宝 (SMSBao)
if config.provider == "短信宝":
if not config.username or not config.password:
raise HTTPException(status_code=400, detail="未配置短信宝用户名或密码,请先在配置中填写")
sign = config.sign_name or ""
if "【" not in sign:
sign = f"【{sign}】" if sign else "【七虎AI】"
content = f"{sign}测试短信:您的验证码是123456,若非本人操作请忽略。"
pwd_md5 = _smsbao_md5(config.password)
params = {"u": config.username, "p": pwd_md5, "m": phone, "c": content}
if config.goods_id:
params["g"] = config.goods_id
import requests as http_requests
try:
resp = http_requests.get(
"https://api.smsbao.com/sms", params=params, timeout=10,
proxies={"http": None, "https": None},
)
code = resp.text.strip().split("\n")[0]
except Exception as e:
logger.error(f"短信宝请求失败: {e}")
raise HTTPException(status_code=500, detail=f"短信宝请求失败: {str(e)}")
if code == "0":
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}
msg = SMSBAO_ERRORS.get(code, f"未知错误码 {code}")
raise HTTPException(status_code=400, detail=f"短信宝发送失败: {msg} (code={code})")
# 只有阿里云短信支持测试
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)}")
# ============================================================
# 短信宝余额查询
# ============================================================
@router.post("/sms/balance")
async def query_sms_balance(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""查询短信宝账户余额(发送条数, 剩余条数)"""
config = db.query(SmsConfig).first()
if not config or config.provider != "短信宝":
raise HTTPException(status_code=400, detail="仅短信宝支持余额查询,请先选择短信宝并保存账号")
if not config.username or not config.password:
raise HTTPException(status_code=400, detail="未配置短信宝用户名或密码")
import requests as http_requests
pwd_md5 = _smsbao_md5(config.password)
try:
resp = http_requests.get(
"https://www.smsbao.com/query",
params={"u": config.username, "p": pwd_md5},
timeout=10,
proxies={"http": None, "https": None},
)
lines = resp.text.strip().split("\n")
except Exception as e:
logger.error(f"短信宝余额查询请求失败: {e}")
raise HTTPException(status_code=500, detail=f"短信宝余额查询失败: {str(e)}")
code = lines[0].strip() if lines else ""
if code != "0":
msg = SMSBAO_ERRORS.get(code, f"未知错误码 {code}")
raise HTTPException(status_code=400, detail=f"查询失败: {msg}")
sent = remain = 0
if len(lines) >= 2:
parts = lines[1].split(",")
try:
sent = int(parts[0])
except (ValueError, IndexError):
sent = 0
try:
remain = int(parts[1])
except (ValueError, IndexError):
remain = 0
return {"code": 0, "data": {"sent": sent, "remain": remain}, "message": "ok"}