This commit is contained in:
2025-11-28 21:20:40 +08:00
commit f940b95b67
73 changed files with 15721 additions and 0 deletions

1
utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Utils package

37
utils/admin_auth.py Normal file
View File

@@ -0,0 +1,37 @@
from functools import wraps
from flask import session, jsonify, redirect, url_for, request
from models import Admin
import pyotp
def admin_required(f):
"""管理员权限装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'admin_id' not in session:
# 如果是API请求返回JSON
if request.path.startswith('/admin/api/'):
return jsonify({'success': False, 'message': '请先登录'}), 401
# 如果是页面请求,重定向到登录页
return redirect(url_for('admin.login'))
return f(*args, **kwargs)
return decorated_function
def verify_2fa(admin: Admin, code: str) -> bool:
"""验证2FA代码"""
if not admin.is_2fa_enabled or not admin.totp_secret:
return True
totp = pyotp.TOTP(admin.totp_secret)
return totp.verify(code, valid_window=1)
def generate_2fa_secret() -> str:
"""生成2FA密钥"""
return pyotp.random_base32()
def get_2fa_qrcode_url(admin: Admin, secret: str) -> str:
"""获取2FA二维码URL"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(
name=admin.username,
issuer_name='短视频解析平台'
)

152
utils/email.py Normal file
View File

@@ -0,0 +1,152 @@
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr
from email.header import Header
from models import SMTPConfig
import random
class EmailService:
"""邮件服务类"""
@staticmethod
def get_smtp_config():
"""获取可用的SMTP配置负载均衡"""
configs = SMTPConfig.query.filter_by(is_enabled=True).all()
if not configs:
raise Exception("没有可用的SMTP配置")
# 加权随机选择
total_weight = sum(c.weight for c in configs)
if total_weight == 0:
return random.choice(configs)
rand = random.uniform(0, total_weight)
current = 0
for config in configs:
current += config.weight
if rand <= current:
return config
return configs[-1]
@staticmethod
def _do_send(smtp_config, to_email: str, subject: str, content: str, html: bool = True):
"""实际发送邮件的内部方法"""
server = None
try:
print(f"[SMTP调试] 开始发送邮件")
print(f"[SMTP调试] 服务器: {smtp_config.host}:{smtp_config.port}")
print(f"[SMTP调试] 用户名: {smtp_config.username}")
print(f"[SMTP调试] 使用TLS: {smtp_config.use_tls}")
print(f"[SMTP调试] 发件人: {smtp_config.from_email}")
msg = MIMEMultipart('alternative')
from_email = smtp_config.from_email or smtp_config.username
from_name = smtp_config.from_name or smtp_config.username
msg['From'] = formataddr((str(Header(from_name, 'utf-8')), from_email))
msg['To'] = to_email
msg['Subject'] = Header(subject, 'utf-8')
if html:
msg.attach(MIMEText(content, 'html', 'utf-8'))
else:
msg.attach(MIMEText(content, 'plain', 'utf-8'))
print(f"[SMTP调试] 开始连接服务器...")
if smtp_config.port == 465:
print(f"[SMTP调试] 使用 SSL 模式(端口 465")
server = smtplib.SMTP_SSL(smtp_config.host, smtp_config.port, timeout=30)
elif smtp_config.use_tls:
print(f"[SMTP调试] 使用 STARTTLS 模式(端口 {smtp_config.port}")
server = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=30)
server.ehlo()
context = ssl.create_default_context()
server.starttls(context=context)
server.ehlo()
else:
print(f"[SMTP调试] 使用普通 SMTP 模式(端口 {smtp_config.port}")
server = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=30)
print(f"[SMTP调试] 连接成功,开始登录...")
server.login(smtp_config.username, smtp_config.password)
print(f"[SMTP调试] 登录成功,开始发送邮件...")
server.sendmail(from_email, [to_email], msg.as_string())
print(f"[SMTP调试] 邮件发送成功!")
# 更新发送统计
smtp_config.send_count += 1
from models import db
db.session.commit()
return True
finally:
if server:
try:
server.quit()
except:
pass
@staticmethod
def send_email(to_email: str, subject: str, content: str, html: bool = True):
"""发送邮件(支持故障自动转移)"""
configs = SMTPConfig.query.filter_by(is_enabled=True).all()
if not configs:
raise Exception("没有可用的SMTP配置")
# 按权重排序,优先尝试高权重的配置
configs.sort(key=lambda x: x.weight, reverse=True)
last_error = None
tried_ids = []
for smtp_config in configs:
tried_ids.append(smtp_config.id)
try:
return EmailService._do_send(smtp_config, to_email, subject, content, html)
except Exception as e:
last_error = str(e)
print(f"[SMTP调试] {smtp_config.name} 发送失败: {last_error}")
# 更新失败统计
smtp_config.fail_count += 1
from models import db
db.session.commit()
# 如果还有其他配置,继续尝试
remaining = len(configs) - len(tried_ids)
if remaining > 0:
print(f"[SMTP调试] 尝试下一个SMTP配置剩余 {remaining}")
continue
# 所有配置都失败了
raise Exception(f"所有SMTP配置均发送失败最后错误: {last_error}")
@staticmethod
def send_verification_code(to_email: str, code: str, purpose: str):
"""发送验证码邮件"""
purpose_text = {
'register': '注册账号',
'reset_password': '重置密码',
'forgot_password': '找回密码'
}.get(purpose, '验证')
subject = f"【短视频解析平台】{purpose_text}验证码"
content = f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2>验证码</h2>
<p>您正在进行<strong>{purpose_text}</strong>操作,验证码为:</p>
<h1 style="color: #4CAF50; letter-spacing: 5px;">{code}</h1>
<p>验证码有效期为10分钟请勿泄露给他人。</p>
<hr>
<p style="color: #999; font-size: 12px;">如果这不是您的操作,请忽略此邮件。</p>
</body>
</html>
"""
return EmailService.send_email(to_email, subject, content, html=True)

139
utils/health_check.py Normal file
View File

@@ -0,0 +1,139 @@
from models import ParserAPI, HealthCheckConfig, HealthCheckLog
from models import db
from parsers.factory import ParserFactory
from utils.email import EmailService
from datetime import datetime
import time
class HealthChecker:
"""健康检查器"""
@staticmethod
def check_api(api: ParserAPI, test_url: str) -> dict:
"""检查单个API"""
start_time = time.time()
try:
# 创建解析器
parser = ParserFactory.create_parser(api)
# 执行解析
result = parser.parse(test_url)
# 计算响应时间
response_time = int((time.time() - start_time) * 1000)
# 检查结果是否有效
if result and result.get('video_url'):
return {
'success': True,
'response_time': response_time,
'error': None
}
else:
return {
'success': False,
'response_time': response_time,
'error': '解析结果无效'
}
except Exception as e:
response_time = int((time.time() - start_time) * 1000)
return {
'success': False,
'response_time': response_time,
'error': str(e)
}
@staticmethod
def check_platform(platform: str):
"""检查指定平台的所有API"""
# 获取健康检查配置
config = HealthCheckConfig.query.filter_by(
platform=platform,
is_enabled=True
).first()
if not config:
return
# 获取该平台的所有API
apis = ParserAPI.query.filter_by(platform=platform).all()
failed_apis = []
for api in apis:
# 执行健康检查
result = HealthChecker.check_api(api, config.test_url)
# 记录日志
log = HealthCheckLog(
parser_api_id=api.id,
status='success' if result['success'] else 'failed',
response_time=result['response_time'],
error_message=result['error']
)
db.session.add(log)
# 更新API状态
api.last_check_at = datetime.utcnow()
if result['success']:
api.health_status = True
api.fail_count = 0
else:
api.fail_count += 1
# 连续失败3次标记为不健康
if api.fail_count >= 3:
api.health_status = False
failed_apis.append({
'name': api.name,
'error': result['error']
})
db.session.commit()
# 发送告警邮件
if failed_apis and config.alert_email:
HealthChecker.send_alert_email(platform, failed_apis, config.alert_email)
@staticmethod
def send_alert_email(platform: str, failed_apis: list, alert_email: str):
"""发送告警邮件"""
subject = f"【短视频解析平台】{platform}接口健康检查告警"
content = f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2>接口健康检查告警</h2>
<p>以下{platform}解析接口健康检查失败:</p>
<ul>
"""
for api in failed_apis:
content += f"<li><strong>{api['name']}</strong>: {api['error']}</li>"
content += """
</ul>
<p>请及时检查并处理。</p>
<hr>
<p style="color: #999; font-size: 12px;">此邮件由系统自动发送,请勿回复。</p>
</body>
</html>
"""
try:
EmailService.send_email(alert_email, subject, content, html=True)
except Exception as e:
print(f"发送告警邮件失败: {str(e)}")
@staticmethod
def check_all_platforms():
"""检查所有平台"""
configs = HealthCheckConfig.query.filter_by(is_enabled=True).all()
for config in configs:
try:
HealthChecker.check_platform(config.platform)
except Exception as e:
print(f"检查{config.platform}平台失败: {str(e)}")

89
utils/limiter.py Normal file
View File

@@ -0,0 +1,89 @@
from datetime import date
from models import DailyParseStat, UserGroup
from models import db
class RateLimiter:
"""限流器"""
@staticmethod
def check_limit(user_id=None, ip_address=None):
"""检查是否超过限制"""
today = date.today()
if user_id:
# 已登录用户
stat = DailyParseStat.query.filter_by(
user_id=user_id,
date=today
).first()
from models import User
user = User.query.get(user_id)
group = UserGroup.query.get(user.group_id)
limit = group.daily_limit
current_count = stat.parse_count if stat else 0
return {
'allowed': current_count < limit,
'current': current_count,
'limit': limit,
'remaining': max(0, limit - current_count)
}
else:
# 游客
stat = DailyParseStat.query.filter_by(
ip_address=ip_address,
date=today
).first()
from models import SiteConfig
config = SiteConfig.query.filter_by(config_key='guest_daily_limit').first()
limit = int(config.config_value) if config else 5
current_count = stat.parse_count if stat else 0
return {
'allowed': current_count < limit,
'current': current_count,
'limit': limit,
'remaining': max(0, limit - current_count)
}
@staticmethod
def increment_count(user_id=None, ip_address=None, success=True):
"""增加计数"""
today = date.today()
if user_id:
stat = DailyParseStat.query.filter_by(
user_id=user_id,
date=today
).first()
if not stat:
stat = DailyParseStat(user_id=user_id, date=today)
db.session.add(stat)
stat.parse_count = (stat.parse_count or 0) + 1
if success:
stat.success_count = (stat.success_count or 0) + 1
else:
stat.fail_count = (stat.fail_count or 0) + 1
else:
stat = DailyParseStat.query.filter_by(
ip_address=ip_address,
date=today
).first()
if not stat:
stat = DailyParseStat(ip_address=ip_address, date=today)
db.session.add(stat)
stat.parse_count = (stat.parse_count or 0) + 1
if success:
stat.success_count = (stat.success_count or 0) + 1
else:
stat.fail_count = (stat.fail_count or 0) + 1
db.session.commit()

141
utils/queue.py Normal file
View File

@@ -0,0 +1,141 @@
import json
import time
from datetime import datetime
from typing import Dict, Optional
# 内存队列当Redis不可用时使用
_memory_queue = []
_memory_processing = {}
_memory_results = {}
def get_redis_client():
"""获取Redis客户端"""
try:
from app import redis_client
return redis_client
except:
return None
class ParseQueue:
"""解析队列管理器"""
QUEUE_KEY = "parse_queue"
PROCESSING_KEY = "parse_processing"
RESULT_KEY_PREFIX = "parse_result:"
@staticmethod
def add_task(task_id: str, video_url: str, user_id: Optional[int] = None, ip_address: str = ""):
"""添加任务到队列"""
task = {
'task_id': task_id,
'video_url': video_url,
'user_id': user_id,
'ip_address': ip_address,
'created_at': datetime.utcnow().isoformat(),
'status': 'queued'
}
redis_client = get_redis_client()
if redis_client:
redis_client.rpush(ParseQueue.QUEUE_KEY, json.dumps(task))
else:
# 使用内存队列
_memory_queue.append(task)
return task_id
@staticmethod
def get_task() -> Optional[Dict]:
"""从队列获取任务"""
redis_client = get_redis_client()
if redis_client:
task_json = redis_client.lpop(ParseQueue.QUEUE_KEY)
if task_json:
task = json.loads(task_json)
redis_client.hset(ParseQueue.PROCESSING_KEY, task['task_id'], json.dumps(task))
return task
else:
# 使用内存队列
if _memory_queue:
task = _memory_queue.pop(0)
_memory_processing[task['task_id']] = task
return task
return None
@staticmethod
def complete_task(task_id: str, result: Dict):
"""完成任务"""
redis_client = get_redis_client()
if redis_client:
redis_client.hdel(ParseQueue.PROCESSING_KEY, task_id)
redis_client.setex(
f"{ParseQueue.RESULT_KEY_PREFIX}{task_id}",
3600,
json.dumps(result)
)
else:
# 使用内存
_memory_processing.pop(task_id, None)
_memory_results[task_id] = result
@staticmethod
def get_result(task_id: str) -> Optional[Dict]:
"""获取任务结果"""
redis_client = get_redis_client()
if redis_client:
result_json = redis_client.get(f"{ParseQueue.RESULT_KEY_PREFIX}{task_id}")
if result_json:
return json.loads(result_json)
else:
# 使用内存
return _memory_results.get(task_id)
return None
@staticmethod
def get_queue_length() -> int:
"""获取队列长度"""
redis_client = get_redis_client()
if redis_client:
return redis_client.llen(ParseQueue.QUEUE_KEY)
else:
return len(_memory_queue)
@staticmethod
def get_processing_count() -> int:
"""获取正在处理的任务数"""
redis_client = get_redis_client()
if redis_client:
return redis_client.hlen(ParseQueue.PROCESSING_KEY)
else:
return len(_memory_processing)
@staticmethod
def get_queue_status() -> Dict:
"""获取队列状态"""
return {
'queued': ParseQueue.get_queue_length(),
'processing': ParseQueue.get_processing_count()
}
class ConcurrencyController:
"""并发控制器"""
@staticmethod
def can_process() -> bool:
"""检查是否可以处理新任务"""
from models import SiteConfig
config = SiteConfig.query.filter_by(config_key='max_concurrent').first()
max_concurrent = int(config.config_value) if config else 3
processing_count = ParseQueue.get_processing_count()
return processing_count < max_concurrent
@staticmethod
def wait_for_slot(timeout: int = 60) -> bool:
"""等待可用槽位"""
start_time = time.time()
while time.time() - start_time < timeout:
if ConcurrencyController.can_process():
return True
time.sleep(0.5)
return False

23
utils/security.py Normal file
View File

@@ -0,0 +1,23 @@
import bcrypt
import random
import string
from datetime import datetime, timedelta
def hash_password(password: str) -> str:
"""加密密码"""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password(password: str, hashed: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
def generate_verification_code(length: int = 6) -> str:
"""生成验证码"""
return ''.join(random.choices(string.digits, k=length))
def get_client_ip(request):
"""获取客户端IP"""
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0]
return request.remote_addr