diff --git a/.env.example b/.env.example index df408d7..764433a 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ SERVER_PORT=5000 # 服务器主机(默认0.0.0.0,接受所有连接) SERVER_HOST=0.0.0.0 +# Redis缓存地址(Docker内部自动连接,无需修改) +REDIS_URL=redis://redis:6379/0 + # 时区设置 TZ=Asia/Shanghai diff --git a/cache.py b/cache.py new file mode 100644 index 0000000..631a255 --- /dev/null +++ b/cache.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Redis缓存模块 +用于缓存账户列表、邮件数据等,加速页面加载 +""" + +import json +import logging +import os +from typing import Any, Optional + +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + +REDIS_URL = os.getenv('REDIS_URL', 'redis://redis:6379/0') + +# 缓存过期时间(秒) +TTL_ACCOUNTS = 300 # 账户列表 5分钟 +TTL_MESSAGES = 180 # 邮件列表 3分钟 +TTL_PAYMENT = 600 # 支付状态 10分钟 + + +class RedisCache: + """Redis缓存管理器""" + + def __init__(self): + self._redis: Optional[redis.Redis] = None + + async def connect(self): + try: + self._redis = redis.from_url(REDIS_URL, decode_responses=True) + await self._redis.ping() + logger.info(f"Redis连接成功: {REDIS_URL}") + except Exception as e: + logger.warning(f"Redis连接失败,将跳过缓存: {e}") + self._redis = None + + async def close(self): + if self._redis: + await self._redis.aclose() + + @property + def available(self) -> bool: + return self._redis is not None + + async def get(self, key: str) -> Optional[Any]: + if not self._redis: + return None + try: + val = await self._redis.get(key) + return json.loads(val) if val else None + except Exception as e: + logger.debug(f"Redis get 失败 [{key}]: {e}") + return None + + async def set(self, key: str, value: Any, ttl: int = 300): + if not self._redis: + return + try: + await self._redis.set(key, json.dumps(value, ensure_ascii=False), ex=ttl) + except Exception as e: + logger.debug(f"Redis set 失败 [{key}]: {e}") + + async def delete(self, key: str): + if not self._redis: + return + try: + await self._redis.delete(key) + except Exception: + pass + + async def delete_pattern(self, pattern: str): + """删除匹配模式的所有key""" + if not self._redis: + return + try: + async for key in self._redis.scan_iter(match=pattern, count=100): + await self._redis.delete(key) + except Exception: + pass + + # ---- 业务快捷方法 ---- + + def accounts_key(self) -> str: + return "cache:accounts" + + def messages_key(self, email: str, folder: str) -> str: + return f"cache:messages:{email}:{folder}" + + def payment_key(self) -> str: + return "cache:payment_status" + + async def invalidate_accounts(self): + await self.delete(self.accounts_key()) + + async def invalidate_messages(self, email: str): + await self.delete_pattern(f"cache:messages:{email}:*") + + async def invalidate_payment(self): + await self.delete(self.payment_key()) + + +cache = RedisCache() diff --git a/docker-compose.yml b/docker-compose.yml index efd244d..bfcc80d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,12 @@ services: environment: # 管理员令牌,可以通过环境变量设置 - ADMIN_TOKEN=${ADMIN_TOKEN:-admin123} + - REDIS_URL=redis://redis:6379/0 # 可选:设置时区 - TZ=Asia/Shanghai + depends_on: + redis: + condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5001/"] @@ -28,6 +32,24 @@ services: networks: - outlook-mail-network + redis: + image: redis:7-alpine + container_name: outlook-redis + command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + networks: + - outlook-mail-network + +volumes: + redis_data: + networks: outlook-mail-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/mail_api.py b/mail_api.py index 9fd97ee..015f5af 100644 --- a/mail_api.py +++ b/mail_api.py @@ -29,6 +29,7 @@ from models import ( ) from config import CLIENT_ID, ADMIN_TOKEN, DEFAULT_EMAIL_LIMIT, logger from imap_client import IMAPEmailClient +from cache import cache, TTL_ACCOUNTS, TTL_MESSAGES, TTL_PAYMENT # ============================================================================ # 辅助函数 @@ -309,10 +310,12 @@ async def lifespan(app: FastAPI): """应用程序生命周期管理""" logger.info("启动邮件管理系统...") logger.info("初始化数据库...") + await cache.connect() yield logger.info("正在关闭邮件管理系统...") try: await email_manager.cleanup_all() + await cache.close() db_manager.close() except Exception as e: logger.error(f"清理系统资源时出错: {e}") @@ -404,10 +407,12 @@ async def admin_js(): # ============================================================================ @app.get("/api/messages") -async def get_messages(email: str, top: int = None, folder: str = "INBOX") -> ApiResponse: +async def get_messages(email: str, top: int = None, folder: str = "INBOX", + refresh: bool = False) -> ApiResponse: """获取邮件列表(包含完整内容) - 优化:一次性返回邮件的完整信息,前端可以缓存 + 优化:一次性返回邮件的完整信息,Redis缓存加速 + refresh=true 时跳过缓存,强制从IMAP拉取 """ email = email.strip() @@ -418,9 +423,19 @@ async def get_messages(email: str, top: int = None, folder: str = "INBOX") -> Ap if top is None: top = await get_system_config_value('email_limit', DEFAULT_EMAIL_LIMIT) + # 尝试读缓存 + cache_key = cache.messages_key(email, folder) + if not refresh: + cached = await cache.get(cache_key) + if cached: + # 按请求的top截取 + return ApiResponse(success=True, data=cached[:top]) + try: # 使用优化后的get_messages,返回完整邮件内容 messages = await email_manager.get_messages(email, top, folder) + # 写入缓存 + await cache.set(cache_key, messages, TTL_MESSAGES) return ApiResponse(success=True, data=messages) except HTTPException as e: return ApiResponse(success=False, message=e.detail) @@ -612,28 +627,33 @@ async def get_accounts_detailed(q: Optional[str] = None, page_size: int = 10) -> ApiResponse: """分页返回完整账号信息(email, password, client_id, refresh_token)""" try: - accounts_dict = await load_accounts_config() - emails = sorted(accounts_dict.keys()) + # 尝试从缓存读取全量账户 + cached = await cache.get(cache.accounts_key()) + if cached: + all_items = cached + else: + accounts_dict = await load_accounts_config() + all_items = [] + for e in sorted(accounts_dict.keys()): + info = accounts_dict[e] + all_items.append({ + "email": e, + "password": info.get("password", ""), + "client_id": info.get("client_id", ""), + "refresh_token": info.get("refresh_token", "") + }) + await cache.set(cache.accounts_key(), all_items, TTL_ACCOUNTS) + # 搜索过滤 if q: q_lower = q.strip().lower() - emails = [e for e in emails if q_lower in e.lower()] + all_items = [i for i in all_items if q_lower in i["email"].lower()] - total = len(emails) + total = len(all_items) page = max(1, page) page_size = max(1, min(100, page_size)) start = (page - 1) * page_size - end = start + page_size - - items = [] - for e in emails[start:end]: - info = accounts_dict[e] - items.append({ - "email": e, - "password": info.get("password", ""), - "client_id": info.get("client_id", ""), - "refresh_token": info.get("refresh_token", "") - }) + items = all_items[start:start + page_size] return ApiResponse( success=True, @@ -669,6 +689,8 @@ async def delete_single_account(email: str) -> ApiResponse: pass del email_manager.clients[email] email_manager._accounts = None # 强制重新加载 + await cache.invalidate_accounts() + await cache.invalidate_messages(email) return ApiResponse(success=True, message=f"已删除账户 {email}") return ApiResponse(success=False, message="删除失败") except Exception as e: @@ -765,6 +787,7 @@ async def import_accounts_simple(request: dict) -> ApiResponse: errors.append(f"第{line_num}行处理失败: {str(ex)}") email_manager._accounts = None + await cache.invalidate_accounts() msg = f"导入完成:新增 {added},更新 {updated},跳过 {skipped}" if errors: msg += f",错误 {len(errors)}" @@ -1218,6 +1241,7 @@ async def check_claude_payment(): if i < total: await asyncio.sleep(0.5) + await cache.invalidate_payment() yield f"data: {json.dumps({'type': 'done', 'total': total})}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") @@ -1227,6 +1251,7 @@ async def check_claude_payment_single(email: str) -> ApiResponse: """检测单个账户的Claude支付状态""" email = email.strip() result = await _check_claude_payment_for_account(email) + await cache.invalidate_payment() if result.get('status') == 'error': return ApiResponse(success=False, message=result.get('message', '检测失败'), data=result) return ApiResponse(success=True, data=result) @@ -1241,13 +1266,18 @@ async def update_claude_payment_note(email: str, request: dict) -> ApiResponse: fields[key] = request[key] ok = await db_manager.update_claude_payment_note(email, **fields) if ok: + await cache.invalidate_payment() return ApiResponse(success=True, message="保存成功") return ApiResponse(success=False, message="保存失败") @app.get("/api/tools/claude-payment-status") async def get_claude_payment_status() -> ApiResponse: """获取所有账户的Claude支付缓存状态""" + cached = await cache.get(cache.payment_key()) + if cached: + return ApiResponse(success=True, data=cached) statuses = await db_manager.get_all_claude_payment_statuses() + await cache.set(cache.payment_key(), statuses, TTL_PAYMENT) return ApiResponse(success=True, data=statuses) # ============================================================================ diff --git a/requirements.txt b/requirements.txt index 31e700a..cedaacc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pydantic[email]==2.5.0 httpx==0.25.2 python-multipart==0.0.6 jinja2==3.1.2 -aiofiles==23.2.1 \ No newline at end of file +aiofiles==23.2.1 +redis[hiredis]==5.0.1 \ No newline at end of file diff --git a/static/index.html b/static/index.html index 6e0b3db..1fc61d7 100644 --- a/static/index.html +++ b/static/index.html @@ -171,6 +171,9 @@
加载邮件中...