Add Redis caching, email refresh button, optimize page loading
- Add Redis service (docker-compose) for caching accounts, messages, payment status - Cache accounts list (5min), messages (3min), payment status (10min) - Auto-invalidate cache on import/delete/payment-check/note-update - Add refresh button to email list panel (force re-fetch from IMAP) - Messages API supports refresh=true param to bypass cache - New cache.py module with RedisCache class Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
104
cache.py
Normal file
104
cache.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
driver: bridge
|
||||
|
||||
64
mail_api.py
64
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)
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
aiofiles==23.2.1
|
||||
redis[hiredis]==5.0.1
|
||||
@@ -171,6 +171,9 @@
|
||||
<div class="email-list-header">
|
||||
<span id="emailListTitle">邮件列表</span>
|
||||
<span class="email-count-badge" id="emailCountBadge" style="display:none;">0</span>
|
||||
<button class="btn-icon email-refresh-btn" id="refreshEmailsBtn" title="刷新邮件">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="email-list-content" id="emailListContent">
|
||||
<div class="empty-state">
|
||||
|
||||
@@ -211,6 +211,7 @@ class MailManager {
|
||||
document.getElementById('backToAccounts').addEventListener('click', () => this.showAccountView());
|
||||
document.getElementById('mobileMailToggle').addEventListener('click', () => this.toggleMobileMailList());
|
||||
document.getElementById('mobileOverlay').addEventListener('click', () => this.closeMobileMailList());
|
||||
document.getElementById('refreshEmailsBtn').addEventListener('click', () => this.refreshEmails());
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
@@ -495,7 +496,19 @@ class MailManager {
|
||||
// 邮件查看 — 数据
|
||||
// ====================================================================
|
||||
|
||||
async loadEmails() {
|
||||
async refreshEmails() {
|
||||
const btn = document.getElementById('refreshEmailsBtn');
|
||||
btn.classList.add('spinning');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await this.loadEmails(true);
|
||||
} finally {
|
||||
btn.classList.remove('spinning');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadEmails(refresh = false) {
|
||||
const container = document.getElementById('emailListContent');
|
||||
container.innerHTML = '<div class="loading-state"><div class="spinner-border spinner-border-sm"></div><p>加载邮件中...</p></div>';
|
||||
|
||||
@@ -505,6 +518,7 @@ class MailManager {
|
||||
folder: this.currentFolder,
|
||||
top: 20
|
||||
});
|
||||
if (refresh) params.set('refresh', 'true');
|
||||
const resp = await fetch(`/api/messages?${params}`);
|
||||
const result = await resp.json();
|
||||
|
||||
|
||||
@@ -731,6 +731,19 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.email-refresh-btn {
|
||||
margin-left: auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.email-refresh-btn.spinning i {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.email-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user