缓存优化

This commit is contained in:
stardrophere
2026-03-12 14:17:15 +08:00
parent 3d7d53f96f
commit 37791c7976
5 changed files with 82 additions and 18 deletions
+28 -2
View File
@@ -1,6 +1,7 @@
# app/api/endpoints/events.py # app/api/endpoints/events.py
import time
from datetime import timedelta from datetime import timedelta
from typing import List from typing import Dict, List, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,6 +27,10 @@ router = APIRouter()
# 排名轨迹最多返回多少个点,避免长时间跨度下数据过大 # 排名轨迹最多返回多少个点,避免长时间跨度下数据过大
MAX_RANKING_POINTS = 30 MAX_RANKING_POINTS = 30
# --- 轻量级接口缓存配置 ---
_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {}
CACHE_TTL_SECONDS = 60
# ---------------------------
@router.get("/unified", response_model=PaginatedUnifiedEventResponse) @router.get("/unified", response_model=PaginatedUnifiedEventResponse)
def list_unified_events( def list_unified_events(
@@ -37,6 +42,17 @@ def list_unified_events(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""分页返回统一事件,附带各平台热搜、排名轨迹和标签。""" """分页返回统一事件,附带各平台热搜、排名轨迹和标签。"""
# --- 1. 尝试从缓存读取 ---
cache_key = f"{min_hot}:{hours}:{sort_by}:{skip}:{limit}"
current_time = time.time()
if cache_key in _UNIFIED_EVENTS_CACHE:
expire_time, cached_data = _UNIFIED_EVENTS_CACHE[cache_key]
if current_time < expire_time:
return cached_data
# -----------------------
time_limit = utcnow() - timedelta(hours=hours) time_limit = utcnow() - timedelta(hours=hours)
# 先查总数,用于前端判断是否还有更多 # 先查总数,用于前端判断是否还有更多
@@ -149,7 +165,17 @@ def list_unified_events(
) )
has_more = (skip + limit) < total has_more = (skip + limit) < total
return PaginatedUnifiedEventResponse(total=total, has_more=has_more, data=results) response = PaginatedUnifiedEventResponse(total=total, has_more=has_more, data=results)
# --- 2. 写入缓存 ---
if len(_UNIFIED_EVENTS_CACHE) > 1000:
# 防止内存无限增长
_UNIFIED_EVENTS_CACHE.clear()
_UNIFIED_EVENTS_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
# ------------------
return response
@router.get("/unified/{event_id}", response_model=UnifiedEventResponse) @router.get("/unified/{event_id}", response_model=UnifiedEventResponse)
+35 -2
View File
@@ -1,4 +1,5 @@
from typing import List import time
from typing import Any, Dict, List, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -16,6 +17,16 @@ from app.services.matching_service import recommend_events_for_user
router = APIRouter() router = APIRouter()
# --- 轻量级接口缓存配置 ---
_RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {}
CACHE_TTL_SECONDS = 60
def _invalidate_user_cache(user_id: int):
"""清除某个用户的推荐结果缓存(当用户新增或删除关键词时调用)"""
keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")]
for k in keys_to_delete:
_RECOMMEND_CACHE.pop(k, None)
# ---------------------------
def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None: def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None:
"""校验路径 user_id 是否为当前登录用户本人。""" """校验路径 user_id 是否为当前登录用户本人。"""
@@ -79,6 +90,7 @@ def create_user_preference(
) )
db.refresh(db_obj) db.refresh(db_obj)
_invalidate_user_cache(user_id) # 失效推荐缓存
return db_obj return db_obj
@@ -107,6 +119,7 @@ def delete_user_preference(
db.delete(preference) db.delete(preference)
db.commit() db.commit()
_invalidate_user_cache(user_id) # 失效推荐缓存
return None return None
@@ -127,6 +140,16 @@ def recommend_events(
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。""" """基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
_ensure_self_access(user_id, current_user) _ensure_self_access(user_id, current_user)
# --- 1. 尝试从缓存读取 ---
cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}"
current_time = time.time()
if cache_key in _RECOMMEND_CACHE:
expire_time, cached_data = _RECOMMEND_CACHE[cache_key]
if current_time < expire_time:
return cached_data
# -----------------------
matched = recommend_events_for_user( matched = recommend_events_for_user(
db, db,
user_id=user_id, user_id=user_id,
@@ -155,8 +178,18 @@ def recommend_events(
) )
) )
return UserPreferenceRecommendationResponse( response = UserPreferenceRecommendationResponse(
user_id=user_id, user_id=user_id,
total=len(result_data), total=len(result_data),
data=result_data, data=result_data,
) )
# --- 2. 写入缓存 ---
if len(_RECOMMEND_CACHE) > 2000:
# 防止内存无限增长
_RECOMMEND_CACHE.clear()
_RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
# ------------------
return response
+7 -6
View File
@@ -2,6 +2,7 @@
# 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送, # 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送,
# 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。 # 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。
import logging import logging
import os
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, time as dt_time, timedelta, timezone, tzinfo from datetime import datetime, time as dt_time, timedelta, timezone, tzinfo
@@ -50,17 +51,17 @@ logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
# 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟) # 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟)
DELIVERY_WINDOW_MINUTES = 2 DELIVERY_WINDOW_MINUTES = int(os.getenv("DELIVERY_WINDOW_MINUTES", 2))
# 同一用户两次推送之间的最小间隔(分钟) # 同一用户两次推送之间的最小间隔(分钟)
MIN_PUSH_INTERVAL_MINUTES = 30 MIN_PUSH_INTERVAL_MINUTES = int(os.getenv("MIN_PUSH_INTERVAL_MINUTES", 30))
# 单次推送最多携带的事件数 # 单次推送最多携带的事件数
MAX_EVENTS_PER_PUSH = 12 MAX_EVENTS_PER_PUSH = int(os.getenv("MAX_EVENTS_PER_PUSH", 12))
# 默认模式热度阈值(无关键词或无匹配时使用) # 默认模式热度阈值(无关键词或无匹配时使用)
DEFAULT_MODE_HOT_THRESHOLD = 3 DEFAULT_MODE_HOT_THRESHOLD = int(os.getenv("DEFAULT_MODE_HOT_THRESHOLD", 3))
# 默认模式查询时间窗口(小时) # 默认模式查询时间窗口(小时)
DEFAULT_MODE_HOURS = 48 DEFAULT_MODE_HOURS = int(os.getenv("DEFAULT_MODE_HOURS", 24))
# 用户时区无效时的兜底时区 # 用户时区无效时的兜底时区
DEFAULT_FALLBACK_TIMEZONE = "Asia/Shanghai" DEFAULT_FALLBACK_TIMEZONE = os.getenv("DEFAULT_FALLBACK_TIMEZONE", "Asia/Shanghai")
# ========================================== # ==========================================
+4 -3
View File
@@ -147,9 +147,9 @@ function getPlatformColor(name: string): string {
} }
function getHotLevel(score: number): { label: string; color: string; bg: string } { function getHotLevel(score: number): { label: string; color: string; bg: string } {
if (score >= 50) return { label: '全网沸腾', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' } if (score >= 10) return { label: '全网沸腾', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }
if (score >= 20) return { label: '高度关注', color: '#f97316', bg: 'rgba(249,115,22,0.15)' } if (score >= 5) return { label: '高度关注', color: '#f97316', bg: 'rgba(249,115,22,0.15)' }
if (score >= 10) return { label: '上升中', color: '#3b82f6', bg: 'rgba(59,130,246,0.15)' } if (score >= 3) return { label: '上升中', color: '#3b82f6', bg: 'rgba(59,130,246,0.15)' }
return { label: '一般关注', color: '#6b7280', bg: 'rgba(107,114,128,0.15)' } return { label: '一般关注', color: '#6b7280', bg: 'rgba(107,114,128,0.15)' }
} }
@@ -1171,6 +1171,7 @@ watch(() => route.query.event, (newId) => {
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
font-size: 14px; font-size: 14px;
font-weight:500;
} }
.platform-info i { .platform-info i {
+8 -5
View File
@@ -82,11 +82,14 @@ const revisionChains = computed<RevisionChain[]>(() => {
const chains: RevisionChain[] = [] const chains: RevisionChain[] = []
for (const [event_id, items] of groups) { for (const [event_id, items] of groups) {
const first = items[0]
const last = items[items.length - 1]
if (!first || !last) continue
// 组内按时间升序 // 组内按时间升序
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at)) items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
// 拼接标题链,避免重复(相邻记录的 revised 与下一条 previous 通常相同) // 拼接标题链,避免重复(相邻记录的 revised 与下一条 previous 通常相同)
const titles: string[] = [items[0].previous_headline] const titles: string[] = [first.previous_headline]
const change_times: string[] = [] const change_times: string[] = []
for (const item of items) { for (const item of items) {
// 若链条末尾与本条 previous 不同,说明有断层,仍然追加 // 若链条末尾与本条 previous 不同,说明有断层,仍然追加
@@ -100,13 +103,13 @@ const revisionChains = computed<RevisionChain[]>(() => {
chains.push({ chains.push({
event_id, event_id,
source_name: items[0].source_name, source_name: first.source_name,
titles, titles,
change_times, change_times,
first_at: items[0].created_at, first_at: first.created_at,
last_at: items[items.length - 1].created_at, last_at: last.created_at,
change_count: items.length, change_count: items.length,
url: items[0].url, url: first.url,
}) })
} }