From 37791c79762a35140bb84e0e69e40733c3b018ff Mon Sep 17 00:00:00 2001 From: stardrophere <1925008984@qq.com> Date: Thu, 12 Mar 2026 14:17:15 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=93=E5=AD=98=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/events.py | 30 +++++++++++++++++-- backend/app/api/endpoints/preferences.py | 37 ++++++++++++++++++++++-- backend/app/services/delivery_service.py | 13 +++++---- frontend/src/views/DashboardView.vue | 7 +++-- frontend/src/views/RevisionsView.vue | 13 +++++---- 5 files changed, 82 insertions(+), 18 deletions(-) diff --git a/backend/app/api/endpoints/events.py b/backend/app/api/endpoints/events.py index d7f994d..0533ef8 100644 --- a/backend/app/api/endpoints/events.py +++ b/backend/app/api/endpoints/events.py @@ -1,6 +1,7 @@ # app/api/endpoints/events.py +import time from datetime import timedelta -from typing import List +from typing import Dict, List, Tuple from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session @@ -26,6 +27,10 @@ router = APIRouter() # 排名轨迹最多返回多少个点,避免长时间跨度下数据过大 MAX_RANKING_POINTS = 30 +# --- 轻量级接口缓存配置 --- +_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {} +CACHE_TTL_SECONDS = 60 +# --------------------------- @router.get("/unified", response_model=PaginatedUnifiedEventResponse) def list_unified_events( @@ -37,6 +42,17 @@ def list_unified_events( 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) # 先查总数,用于前端判断是否还有更多 @@ -149,7 +165,17 @@ def list_unified_events( ) 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) diff --git a/backend/app/api/endpoints/preferences.py b/backend/app/api/endpoints/preferences.py index 3ac1e9f..2e517fe 100644 --- a/backend/app/api/endpoints/preferences.py +++ b/backend/app/api/endpoints/preferences.py @@ -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 sqlalchemy.exc import IntegrityError @@ -16,6 +17,16 @@ from app.services.matching_service import recommend_events_for_user 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: """校验路径 user_id 是否为当前登录用户本人。""" @@ -79,6 +90,7 @@ def create_user_preference( ) db.refresh(db_obj) + _invalidate_user_cache(user_id) # 失效推荐缓存 return db_obj @@ -107,6 +119,7 @@ def delete_user_preference( db.delete(preference) db.commit() + _invalidate_user_cache(user_id) # 失效推荐缓存 return None @@ -127,6 +140,16 @@ def recommend_events( """基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。""" _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( db, user_id=user_id, @@ -155,8 +178,18 @@ def recommend_events( ) ) - return UserPreferenceRecommendationResponse( + response = UserPreferenceRecommendationResponse( user_id=user_id, total=len(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 diff --git a/backend/app/services/delivery_service.py b/backend/app/services/delivery_service.py index c1a2c3a..91e0e93 100644 --- a/backend/app/services/delivery_service.py +++ b/backend/app/services/delivery_service.py @@ -2,6 +2,7 @@ # 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送, # 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。 import logging +import os from logging.handlers import TimedRotatingFileHandler from dataclasses import dataclass, field from datetime import datetime, time as dt_time, timedelta, timezone, tzinfo @@ -50,17 +51,17 @@ logger.setLevel(logging.INFO) 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") # ========================================== diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index d0b661c..c7c2e74 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -147,9 +147,9 @@ function getPlatformColor(name: string): 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 >= 20) 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 >= 10) return { label: '全网沸腾', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' } + if (score >= 5) return { label: '高度关注', color: '#f97316', bg: 'rgba(249,115,22,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)' } } @@ -1171,6 +1171,7 @@ watch(() => route.query.event, (newId) => { gap: 10px; min-width: 0; font-size: 14px; + font-weight:500; } .platform-info i { diff --git a/frontend/src/views/RevisionsView.vue b/frontend/src/views/RevisionsView.vue index 1ba29f1..676ac2e 100644 --- a/frontend/src/views/RevisionsView.vue +++ b/frontend/src/views/RevisionsView.vue @@ -82,11 +82,14 @@ const revisionChains = computed(() => { const chains: RevisionChain[] = [] 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)) // 拼接标题链,避免重复(相邻记录的 revised 与下一条 previous 通常相同) - const titles: string[] = [items[0].previous_headline] + const titles: string[] = [first.previous_headline] const change_times: string[] = [] for (const item of items) { // 若链条末尾与本条 previous 不同,说明有断层,仍然追加 @@ -100,13 +103,13 @@ const revisionChains = computed(() => { chains.push({ event_id, - source_name: items[0].source_name, + source_name: first.source_name, titles, change_times, - first_at: items[0].created_at, - last_at: items[items.length - 1].created_at, + first_at: first.created_at, + last_at: last.created_at, change_count: items.length, - url: items[0].url, + url: first.url, }) }