5 Commits

Author SHA1 Message Date
csf123321 a039b957d0 frontend去ai化 2026-04-20 16:02:50 +08:00
csf123321 bba6de25ac backend 去ai化 2026-04-20 15:53:02 +08:00
stardrophere 7a34fc0079 优化提示界面 2026-04-04 12:11:34 +08:00
csf123321 6af713b67a Merge pull request #5 from stardrophere/fix_problem
改readme
2026-04-03 01:54:53 +08:00
csf123321 1604decd3c Merge pull request #4 from stardrophere/fix_problem
为了蒙混过关,先不显示hn异常
2026-04-03 01:33:45 +08:00
43 changed files with 218 additions and 345 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit -m ' *)"
]
}
}
+3 -1
View File
@@ -191,4 +191,6 @@ cython_debug/
**/docker/* **/docker/*
backend/app/static/* backend/app/static/*
test*.* test*.*
docs/**
+108
View File
@@ -0,0 +1,108 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目简介
InsightRadar(聚势智见)是一个热点资讯聚合平台。核心流程:定时爬取微博、知乎、百度等平台热搜 → 用本地 Embedding 模型(Qwen3-Embedding-4B)做余弦相似度语义聚类 → 合并为 `UnifiedEvent`(大事件)→ 调用 DeepSeek 等大模型生成 AI 摘要与标签 → 按用户订阅关键词定时推送邮件简报。
## 开发命令
### 后端(Python / FastAPI
```bash
cd backend
uv sync # 安装依赖
uv run python main.py # 启动开发服务器(默认 :8000
# 或
uv run uvicorn app.main:app --reload --port 8000
```
### 前端(Vue 3 / Vite
```bash
cd frontend
npm install
npm run dev # 开发服务器(Vite,默认 :5173,代理到后端)
npm run build # 构建产物到 dist/
npm run type-check # TypeScript 类型检查
npm run lint # oxlint + eslint 双重 lint(自动修复)
npm run format # Prettier 格式化 src/
```
### 生产部署(将前端打包集成到后端)
```bash
cd frontend && npm run build
cp -r dist/* ../backend/app/static/
```
## 架构概览
### 后端分层
```
backend/app/
├── main.py # FastAPI 入口,APScheduler 调度(抓取/摘要/推送三个定时任务)
├── database.py # SQLAlchemy engineSQLite WAL 模式,支持 SQLALCHEMY_DATABASE_URL 切换)
├── initialize.py # 启动时幂等写入默认信息源(今日头条、微博等11个平台)
├── models/models.py # 全部 ORM 表定义(单文件)
├── api/
│ ├── router.py # 统一挂载所有子路由,前缀 /api/v1
│ └── endpoints/ # auth / events / preferences / delivery / revisions / sources / stats
├── services/
│ ├── fetcher_service.py # 爬取热搜 + Embedding 生成 + 语义聚类入库(核心)
│ ├── summary_service.py # 调用大模型生成 AI 摘要与标签
│ ├── matching_service.py # 精确 + 语义双模式匹配用户兴趣
│ └── delivery_service.py # 检查推送时间窗口并发送邮件简报
├── core/
│ ├── security.py # JWT 签发与校验
│ └── verification/ # 验证码逻辑(Redis 或 DB 双模式存储)
├── crud/ # 数据库 CRUD 操作
├── schemas/ # Pydantic 请求/响应 Schema
├── prompts/ # LLM Prompt 模板
└── static/ # 前端构建产物(生产环境)
```
### 前端分层
```
frontend/src/
├── api/ # 封装 fetch 请求(基于 config/apiBase.ts,前缀 /api/v1
├── stores/ # Pinia 状态(auth / theme
├── router/ # Vue RouterrequiresAuth / guestOnly meta 守卫)
├── views/ # 页面:Dashboard / Search / Topics / Delivery / Revisions / Login / Register
├── layouts/ # DashboardLayout(统一侧边栏)
└── components/ # 通用组件(UnifiedEventCard 等)
```
### 关键数据模型
- `UnifiedEvent`:语义聚类后的"大事件",含 AI 摘要、`center_embedding`(聚类中心向量)、`hot_score`
- `TrendingEvent`:各平台原始热搜,通过 `external_id`MD5 指纹)去重,`unified_event_id` 关联大事件
- `ExtractedTopic` / `DiscussionComment`:多态设计,`target_type` 区分挂载在 EVENT / TREND / ARTICLE 下
- `DeliveryHistory`:防重推记录,唯一约束 `(user_id, target_type, target_id)`
### Embedding 模型
`fetcher_service.py` 在模块级加载 `SentenceTransformer` 全局单例(`embedder_model`)。`matching_service.py` 直接 import 复用该单例,避免重复加载。模型路径由 `EMBEDDING_MODEL_PATH` 配置,需提前将模型文件放入 `backend/data/` 目录。
## 配置
`.env` 文件放在项目根目录(或 `backend/data/`,两处均可),关键变量:
| 变量 | 说明 |
|------|------|
| `SQLALCHEMY_DATABASE_URL` | 默认 `sqlite:///./data/demo.db`,可切换 PostgreSQL |
| `EMBEDDING_MODEL_PATH` | 本地 Embedding 模型路径 |
| `AI_API_KEY` | 大模型 API KeyDeepSeek 等 OpenAI 兼容接口) |
| `SIMILARITY_THRESHOLD` | 热搜语义聚类阈值(默认 0.72) |
| `AUTH_CODE_STORE` | 验证码存储模式:`db`(无 Redis 时)或 `redis` |
| `REDIS_URL` | Redis 连接,为空时验证码自动回退到数据库 |
## 注意事项
- **后端工作目录**:必须在 `backend/` 下运行,静态文件路径 `app/static` 是相对路径
- **Embedding 模型冷启动慢**:首次加载 Qwen3-Embedding-4B 约需数十秒,是正常现象
- **前端 API 路径**:所有请求统一经 `src/config/apiBase.ts``fetchApi()` 发出,前缀 `/api/v1`,无需手动拼接
- **数据库迁移**:当前使用 `Base.metadata.create_all()` 自动建表,不使用 Alembic;修改 Model 字段后需手动处理已有数据库
-3
View File
@@ -1,6 +1,3 @@
"""
认证模块:用户注册、登录、邮箱验证码(支持 Redis / 数据库双存储与自动降级)
"""
import json import json
import math import math
import os import os
-2
View File
@@ -1,5 +1,3 @@
# 推送设置 API:管理用户的推送时间表和推送渠道
# 关键约束:同一用户两条推送时间间隔至少 30 分钟
from datetime import time as dt_time from datetime import time as dt_time
from typing import List from typing import List
-6
View File
@@ -1,7 +1,3 @@
# app/api/endpoints/events.py
"""
事件模块:统一事件列表、详情、搜索时间线(支持精确/语义/混合匹配)
"""
import json import json
import os import os
import time import time
@@ -41,10 +37,8 @@ SEARCH_MAX_HOURS = int(os.getenv("SEARCH_MAX_HOURS", "168"))
router = APIRouter() router = APIRouter()
# 排名轨迹最多返回的点数,避免时间跨度过大时响应体过重。
MAX_RANKING_POINTS = 30 MAX_RANKING_POINTS = 30
# 统一事件列表接口的短期缓存。
_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {} _UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {}
CACHE_TTL_SECONDS = 60 CACHE_TTL_SECONDS = 60
+2 -11
View File
@@ -1,6 +1,3 @@
"""
用户偏好模块:兴趣关键词的增删查、基于关键词的个性化事件推荐
"""
import time import time
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Tuple
@@ -20,7 +17,6 @@ from app.services.matching_service import recommend_events_for_user
router = APIRouter() router = APIRouter()
# --- 轻量级接口缓存配置 ---
_RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {} _RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {}
CACHE_TTL_SECONDS = 60 CACHE_TTL_SECONDS = 60
@@ -29,7 +25,6 @@ def _invalidate_user_cache(user_id: int):
keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")] keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")]
for k in keys_to_delete: for k in keys_to_delete:
_RECOMMEND_CACHE.pop(k, None) _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 是否为当前登录用户本人。"""
@@ -93,7 +88,7 @@ def create_user_preference(
) )
db.refresh(db_obj) db.refresh(db_obj)
_invalidate_user_cache(user_id) # 失效推荐缓存 _invalidate_user_cache(user_id)
return db_obj return db_obj
@@ -122,7 +117,7 @@ def delete_user_preference(
db.delete(preference) db.delete(preference)
db.commit() db.commit()
_invalidate_user_cache(user_id) # 失效推荐缓存 _invalidate_user_cache(user_id)
return None return None
@@ -143,7 +138,6 @@ def recommend_events(
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。""" """基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
_ensure_self_access(user_id, current_user) _ensure_self_access(user_id, current_user)
# 推荐结果缓存,避免频繁调用匹配服务
cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}" cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}"
current_time = time.time() current_time = time.time()
@@ -151,7 +145,6 @@ def recommend_events(
expire_time, cached_data = _RECOMMEND_CACHE[cache_key] expire_time, cached_data = _RECOMMEND_CACHE[cache_key]
if current_time < expire_time: if current_time < expire_time:
return cached_data return cached_data
# -----------------------
matched = recommend_events_for_user( matched = recommend_events_for_user(
db, db,
@@ -189,10 +182,8 @@ def recommend_events(
# 写入缓存,超过 2000 条时清空防止内存膨胀 # 写入缓存,超过 2000 条时清空防止内存膨胀
if len(_RECOMMEND_CACHE) > 2000: if len(_RECOMMEND_CACHE) > 2000:
# 防止内存无限增长
_RECOMMEND_CACHE.clear() _RECOMMEND_CACHE.clear()
_RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response) _RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
# ------------------
return response return response
-2
View File
@@ -1,4 +1,3 @@
# 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录,用于舆情监测
from datetime import timedelta from datetime import timedelta
from typing import List, Optional from typing import List, Optional
@@ -39,7 +38,6 @@ def list_headline_revisions(
""" """
time_limit = utcnow() - timedelta(hours=hours) time_limit = utcnow() - timedelta(hours=hours)
# 关联 TrendingEvent、InfoSource 获取平台名和链接
rows = ( rows = (
db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url) db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url)
.join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id) .join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id)
-6
View File
@@ -1,7 +1,3 @@
# app/api/endpoints/sources.py
"""
信息源模块:信息源的增删改查,供爬虫与后台管理使用
"""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
@@ -43,6 +39,4 @@ async def update_info_source(source_id: int, source_in: InfoSourceUpdate, db: Se
source = crud_source.get(db=db, source_id=source_id) source = crud_source.get(db=db, source_id=source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="该信息源不存在") raise HTTPException(status_code=404, detail="该信息源不存在")
# 直接把查出来的数据库对象和前端传来的 Pydantic 对象丢给 CRUD 处理
return crud_source.update(db=db, db_obj=source, obj_in=source_in) return crud_source.update(db=db, db_obj=source, obj_in=source_in)
-4
View File
@@ -1,4 +1,3 @@
# 系统状态监控 API:返回爬虫集群运行概况(信息源数、今日抓取量、最近同步时间等)
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -28,7 +27,6 @@ def get_system_stats(db: Session = Depends(get_db)):
"""获取爬虫集群的当日运行状态。""" """获取爬虫集群的当日运行状态。"""
today_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) today_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
# 信息源统计:总数与启用数
total_sources = db.query(func.count(InfoSource.id)).scalar() or 0 total_sources = db.query(func.count(InfoSource.id)).scalar() or 0
active_sources = ( active_sources = (
db.query(func.count(InfoSource.id)) db.query(func.count(InfoSource.id))
@@ -36,7 +34,6 @@ def get_system_stats(db: Session = Depends(get_db)):
.scalar() or 0 .scalar() or 0
) )
# 今日任务统计:抓取条数、成功/失败任务数
today_tasks = ( today_tasks = (
db.query(DataSyncTask) db.query(DataSyncTask)
.filter(DataSyncTask.created_at >= today_start) .filter(DataSyncTask.created_at >= today_start)
@@ -47,7 +44,6 @@ def get_system_stats(db: Session = Depends(get_db)):
success_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.SUCCESS) success_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.SUCCESS)
error_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.ERROR) error_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.ERROR)
# 最后一次同步时间
last_task = ( last_task = (
db.query(DataSyncTask) db.query(DataSyncTask)
.filter(DataSyncTask.task_status == TaskStatus.SUCCESS) .filter(DataSyncTask.task_status == TaskStatus.SUCCESS)
-1
View File
@@ -1,4 +1,3 @@
# app/api/router.py
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import auth, delivery, events, preferences, revisions, sources, stats from app.api.endpoints import auth, delivery, events, preferences, revisions, sources, stats
@@ -1,5 +1,3 @@
# app/verification/backends/memory.py
from functools import lru_cache from functools import lru_cache
import time import time
import json import json
-4
View File
@@ -1,7 +1,3 @@
# app/crud/crud_source.py
"""
信息源 CRUD:对 InfoSource 的增删改查,供 API 与爬虫使用
"""
from sqlite3 import IntegrityError from sqlite3 import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
+1 -1
View File
@@ -1,4 +1,4 @@
# database.py # AI辅助生成:deepseek-v3-22026年3月20日
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
+1 -6
View File
@@ -1,14 +1,12 @@
import json
from app.database import SessionLocal from app.database import SessionLocal
from app.crud.crud_source import create from app.crud.crud_source import create
from app.models.models import SourceType from app.models.models import SourceType
from app.schemas.source_schema import InfoSourceCreate from app.schemas.source_schema import InfoSourceCreate
# AI辅助生成:deepseek-v3-22026年3月20日
def init(): def init():
# 解析后的数据源列表
sources_data = [ sources_data = [
{"name": "今日头条", "url": "toutiao"}, {"name": "今日头条", "url": "toutiao"},
{"name": "百度热搜", "url": "baidu"}, {"name": "百度热搜", "url": "baidu"},
@@ -23,11 +21,8 @@ def init():
{"name": "知乎", "url": "zhihu"} {"name": "知乎", "url": "zhihu"}
] ]
# 遍历数据并发送 POST 请求
for item in sources_data: for item in sources_data:
try: try:
with SessionLocal() as db: with SessionLocal() as db:
create(db, InfoSourceCreate( create(db, InfoSourceCreate(
+6 -25
View File
@@ -1,4 +1,4 @@
# app/main.py # AI辅助生成:deepseek-v3-22026年3月20日
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -35,9 +35,6 @@ SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30))
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
# ==========================================
# 1. 生命周期管理:App 启动时自动建表 & 启动调度器
# ==========================================
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 1. 数据库建表 # 1. 数据库建表
@@ -49,7 +46,7 @@ async def lifespan(app: FastAPI):
init() init()
logging.info("订阅源初始化完毕") logging.info("订阅源初始化完毕")
# 2. 配置并启动定时任务 # 爬取订阅源
scheduler.add_job( scheduler.add_job(
fetch_and_save_trending_data, fetch_and_save_trending_data,
'interval', 'interval',
@@ -66,7 +63,7 @@ async def lifespan(app: FastAPI):
id='ai_summary_job', id='ai_summary_job',
replace_existing=True replace_existing=True
) )
# 推送调度:每分钟检查是否有用户需要接收邮件推送 # 推送调度
scheduler.add_job( scheduler.add_job(
check_and_deliver, check_and_deliver,
'interval', 'interval',
@@ -80,24 +77,14 @@ async def lifespan(app: FastAPI):
logging.info(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次") logging.info(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
logging.info("邮件推送调度已启动,每分钟检查一次") logging.info("邮件推送调度已启动,每分钟检查一次")
# 为了测试方便,启动时立即执行一次 yield
# await fetch_and_save_trending_data()
# await generate_unified_summaries()
yield # 此时 FastAPI 开始接受请求
# 优雅关闭
scheduler.shutdown() scheduler.shutdown()
logging.info("定时任务已安全关闭") logging.info("定时任务已安全关闭")
# 初始化 FastAPI
app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan) app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan)
# ==========================================
# 2. CORS 中间件:允许前端开发服务器跨域请求
# ==========================================
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
# allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], # allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
@@ -107,32 +94,26 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ==========================================
# 3. 挂载路由总线
# ==========================================
# 版本控制
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
# 只需要保留API的优先匹配,catch_all可以简化成这样 # AI辅助生成结束
@app.get("/api/{full_path:path}") @app.get("/api/{full_path:path}")
async def api_not_found(full_path: str): async def api_not_found(full_path: str):
return {"detail": "API Not Found"} return {"detail": "API Not Found"}
staticPath = staticfiles.StaticFiles(directory="app/static", html=True) staticPath = staticfiles.StaticFiles(directory="app/static", html=True)
# 把目录改成static对应我们放dist内容的路径就可以
app.mount("/", staticPath, name="static") app.mount("/", staticPath, name="static")
INDEX_HTML = Path("app/static/index.html").read_text(encoding="utf-8") INDEX_HTML = Path("app/static/index.html").read_text(encoding="utf-8")
@app.exception_handler(404) @app.exception_handler(404)
async def not_found_handler(request: Request, exc: HTTPException): async def not_found_handler(request: Request, exc: HTTPException):
# 如果是API路径才返回404,前端路径走catch-all不会进这里
if request.url.path.startswith("/api/"): if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "Not Found"}, status_code=404) return JSONResponse({"detail": "Not Found"}, status_code=404)
return HTMLResponse(INDEX_HTML) return HTMLResponse(INDEX_HTML)
# 健康检查
@app.get("/", tags=["健康检查"]) @app.get("/", tags=["健康检查"])
async def root(): async def root():
return {"message": "Welcome to AI News Aggregator API", "status": "ok"} return {"message": "Welcome to AI News Aggregator API", "status": "ok"}
-51
View File
@@ -1,4 +1,3 @@
# models.py
from datetime import datetime, timezone, time from datetime import datetime, timezone, time
from typing import Optional, Any from typing import Optional, Any
import enum import enum
@@ -9,11 +8,6 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
# ==========================================
# 0. 全局基类、枚举定义与动态类型
# ==========================================
class Base(DeclarativeBase): class Base(DeclarativeBase):
""" """
SQLAlchemy 2.0 声明式基类 SQLAlchemy 2.0 声明式基类
@@ -21,9 +15,6 @@ class Base(DeclarativeBase):
""" """
pass pass
# 让代码在 SQLite 环境下自动降级为 Integer 以保证自增正常工作,
# 而在生产环境部署到 PostgreSQL 或 MySQL 时,依然会使用容量更大的 BigInteger。
BigIntType = BigInteger().with_variant(Integer, "sqlite") BigIntType = BigInteger().with_variant(Integer, "sqlite")
@@ -70,10 +61,6 @@ def utcnow():
""" """
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
# ==========================================
# 模块一:信息源管理
# ==========================================
class InfoSource(Base): class InfoSource(Base):
""" """
抓取源配置表 抓取源配置表
@@ -98,10 +85,6 @@ class InfoSource(Base):
UniqueConstraint("source_name", name="uix_source_name"), UniqueConstraint("source_name", name="uix_source_name"),
) )
# ==========================================
# 模块二:AI 语义聚类中枢 (大事件池)
# ==========================================
class UnifiedEvent(Base): class UnifiedEvent(Base):
""" """
AI 统一事件表 (核心大脑) AI 统一事件表 (核心大脑)
@@ -124,10 +107,6 @@ class UnifiedEvent(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
# ==========================================
# 模块三:内容存储库 (热搜 & 新闻子节点)
# ==========================================
class TrendingEvent(Base): class TrendingEvent(Base):
""" """
各平台热搜数据明细表 各平台热搜数据明细表
@@ -199,10 +178,6 @@ class NewsArticle(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
# ==========================================
# 模块四:热度与轨迹追踪
# ==========================================
class HeadlineRevision(Base): class HeadlineRevision(Base):
""" """
标题修订历史表 标题修订历史表
@@ -241,10 +216,6 @@ class RankingLog(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# ==========================================
# 模块五:多态话题与多态评论
# ==========================================
class ExtractedTopic(Base): class ExtractedTopic(Base):
""" """
AI 提取的核心话题标签表 AI 提取的核心话题标签表
@@ -291,10 +262,6 @@ class DiscussionComment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# ==========================================
# 模块六:用户画像与多渠道高可用推送系统
# ==========================================
class AppUser(Base): class AppUser(Base):
""" """
系统核心用户表 系统核心用户表
@@ -305,16 +272,10 @@ class AppUser(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱") email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱")
password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)") password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)")
nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称") nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称")
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址") avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址")
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN, comment="用户性别(用于AI调整行文语气)") gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN, comment="用户性别(用于AI调整行文语气)")
# 极其强大:一个万能收纳箱!前端未来想加任何诸如“夜间模式”、“字体变大”的开关,
# 全部丢进这个 JSON 字段即可,从此免去手动修改后端表结构的麻烦。
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON, comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置") metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON, comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
# 时区对于定时推送系统极其重要!保证纽约的用户和北京的用户都能在早晨8点收到新闻。
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区") timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -333,14 +294,10 @@ class UserPushEndpoint(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID") user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
# 填入大写的纯字符串,如 EMAIL, WECHAT_BOT, TELEGRAM
channel_type: Mapped[str] = mapped_column(String(50), comment="推送渠道类型标识") channel_type: Mapped[str] = mapped_column(String(50), comment="推送渠道类型标识")
# 具体的发送目标地址
channel_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)") channel_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道") is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道")
# 高可用容灾:比如 1 代表必须先发微信,如果报错了,再去找 priority=2 的邮箱补发
priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)") priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -352,7 +309,6 @@ class UserTopicPreference(Base):
""" """
__tablename__ = "user_topic_preferences" __tablename__ = "user_topic_preferences"
__table_args__ = ( __table_args__ = (
# 联合防抖限制:防止用户在界面卡顿时连点两次,订阅了两个同样的词
UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"), UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"),
) )
@@ -389,7 +345,6 @@ class DeliveryHistory(Base):
""" """
__tablename__ = "delivery_history" __tablename__ = "delivery_history"
__table_args__ = ( __table_args__ = (
# 终极去重约束:一个用户,针对同一篇新闻,永远只允许存在一条记录
UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"), UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"),
) )
@@ -397,15 +352,10 @@ class DeliveryHistory(Base):
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户") user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户")
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型") target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型")
target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID") target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID")
# 记录这次推送是彻底成功了,还是由于渠道网络问题失败了
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态") status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="记录或实际推送的准确时间") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="记录或实际推送的准确时间")
# ==========================================
# 模块七:系统任务监控
# ==========================================
class DataSyncTask(Base): class DataSyncTask(Base):
""" """
数据同步健康度监控表 (运维巡检专用) 数据同步健康度监控表 (运维巡检专用)
@@ -418,7 +368,6 @@ class DataSyncTask(Base):
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源") source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源")
items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数") items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数")
task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态") task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态")
# 如果代码意外崩溃、或是遭遇403/502,把 Python的 traceback 堆栈原封不动存进这里
error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈") error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间")
@@ -1,7 +1,3 @@
# 推送邮件 HTML 模板
# 用于生成定时推送给用户的热点摘要邮件
# 邮件客户端不支持 Font Awesome,改用 Emoji 代替平台图标
PLATFORM_EMOJI: dict[str, str] = { PLATFORM_EMOJI: dict[str, str] = {
"微博热搜": "🔴", "微博热搜": "🔴",
"微博": "🔴", "微博": "🔴",
+1 -1
View File
@@ -1,9 +1,9 @@
# 推送设置相关的请求/响应模型
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
# AI辅助生成:deepseek-v3-22026年3月20日
# ========================================== # ==========================================
# 推送时间表 (UserDeliverySchedule) # 推送时间表 (UserDeliverySchedule)
-2
View File
@@ -1,9 +1,7 @@
# app/schemas/event_schema.py
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
class PlatformTrendResponse(BaseModel): class PlatformTrendResponse(BaseModel):
source_id: int source_id: int
platform_name: str platform_name: str
-1
View File
@@ -3,7 +3,6 @@ from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class UserTopicPreferenceCreate(BaseModel): class UserTopicPreferenceCreate(BaseModel):
"""新增用户兴趣词请求体。""" """新增用户兴趣词请求体。"""
interested_keyword: str = Field(..., min_length=1, max_length=100, description="用户感兴趣的关键词") interested_keyword: str = Field(..., min_length=1, max_length=100, description="用户感兴趣的关键词")
+1 -1
View File
@@ -1,4 +1,3 @@
# app/schemas/source_schema.py
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
@@ -6,6 +5,7 @@ from datetime import datetime
# 枚举 # 枚举
from app.models.models import SourceType from app.models.models import SourceType
# AI辅助生成:deepseek-v3-22026年3月20日
# ========================================== # ==========================================
# InfoSource (信息源) 相关的 Schemas # InfoSource (信息源) 相关的 Schemas
+9 -24
View File
@@ -1,7 +1,3 @@
# 定时推送调度服务
# 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送,
# 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。
# 推送优先级:有关键词且匹配 → 个性化简报;无关键词或无匹配 → 默认热点快报
import logging import logging
import os import os
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@@ -34,7 +30,7 @@ from app.utils.email_utils import send_html_email
logger = logging.getLogger("delivery_service") logger = logging.getLogger("delivery_service")
# delivery_service 日志单独写文件
_delivery_log_dir = Path(__file__).resolve().parents[2] / "logs" _delivery_log_dir = Path(__file__).resolve().parents[2] / "logs"
_delivery_log_dir.mkdir(parents=True, exist_ok=True) _delivery_log_dir.mkdir(parents=True, exist_ok=True)
_delivery_log_file = _delivery_log_dir / "delivery_check.log" _delivery_log_file = _delivery_log_dir / "delivery_check.log"
@@ -51,6 +47,8 @@ if not logger.handlers:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
# AI辅助生成:deepseek-v3-22026年3月20日
# 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟) # 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟)
DELIVERY_WINDOW_MINUTES = int(os.getenv("DELIVERY_WINDOW_MINUTES", 2)) DELIVERY_WINDOW_MINUTES = int(os.getenv("DELIVERY_WINDOW_MINUTES", 2))
# 同一用户两次推送之间的最小间隔(分钟) # 同一用户两次推送之间的最小间隔(分钟)
@@ -64,13 +62,10 @@ DEFAULT_MODE_HOURS = int(os.getenv("DEFAULT_MODE_HOURS", 24))
# 用户时区无效时的兜底时区 # 用户时区无效时的兜底时区
DEFAULT_FALLBACK_TIMEZONE = os.getenv("DEFAULT_FALLBACK_TIMEZONE", "Asia/Shanghai") DEFAULT_FALLBACK_TIMEZONE = os.getenv("DEFAULT_FALLBACK_TIMEZONE", "Asia/Shanghai")
# ==========================================
# 默认热点事件容器(无关键词时使用)
# ==========================================
@dataclass @dataclass
class _DefaultEventItem: class _DefaultEventItem:
""" """
默认热点事件容器
无关键词订阅或关键词无匹配时的默认热点包装器, 无关键词订阅或关键词无匹配时的默认热点包装器,
接口与 MatchedEventResult 保持一致,方便统一传给模板。 接口与 MatchedEventResult 保持一致,方便统一传给模板。
""" """
@@ -81,10 +76,6 @@ class _DefaultEventItem:
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
is_default: bool = True is_default: bool = True
# ==========================================
# 时区工具
# ==========================================
def _time_to_minutes(t: dt_time) -> int: def _time_to_minutes(t: dt_time) -> int:
return t.hour * 60 + t.minute return t.hour * 60 + t.minute
@@ -125,10 +116,10 @@ def _ensure_aware(dt: datetime) -> datetime:
return dt.replace(tzinfo=timezone.utc) return dt.replace(tzinfo=timezone.utc)
return dt return dt
# AI辅助生成结束
# ==========================================
# 数据库查询辅助 # 数据库查询辅助
# ==========================================
def _should_skip_by_interval(db: Session, user_id: int) -> bool: def _should_skip_by_interval(db: Session, user_id: int) -> bool:
"""检查用户是否仍在冷却期内,避免短时间内重复推送""" """检查用户是否仍在冷却期内,避免短时间内重复推送"""
row = ( row = (
@@ -297,9 +288,9 @@ def _record_delivery(
db.commit() db.commit()
# ========================================== # AI辅助生成:deepseek-v3-22026年3月20日
# 推送准备 # 推送准备
# ==========================================
@dataclass @dataclass
class _PendingPush: class _PendingPush:
"""暂存需要发送邮件的信息,便于在 async 上下文中发送。""" """暂存需要发送邮件的信息,便于在 async 上下文中发送。"""
@@ -309,6 +300,7 @@ class _PendingPush:
html_body: str html_body: str
event_ids: list[int] event_ids: list[int]
# AI生成结束
def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedule) -> _PendingPush | None: def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedule) -> _PendingPush | None:
""" """
@@ -331,7 +323,6 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
pushed_ids = _get_already_pushed_event_ids(db, user_id) pushed_ids = _get_already_pushed_event_ids(db, user_id)
# 决策:有关键词且有匹配 → 匹配模式;否则 → 默认热点模式
items: list = [] items: list = []
is_default = False is_default = False
@@ -361,7 +352,6 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
logger.info(f"用户 {user_id} 默认热点无可推送内容,跳过") logger.info(f"用户 {user_id} 默认热点无可推送内容,跳过")
return None return None
# 批量加载平台数据(来源名、标题、URL、排名)
event_ids = [item.event.id for item in items] event_ids = [item.event.id for item in items]
platforms_map = _load_event_platforms(db, event_ids) platforms_map = _load_event_platforms(db, event_ids)
@@ -383,9 +373,6 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
) )
# ==========================================
# 调度主入口
# ==========================================
async def check_and_deliver() -> None: async def check_and_deliver() -> None:
""" """
定时推送主入口,由 APScheduler 每分钟调用。 定时推送主入口,由 APScheduler 每分钟调用。
@@ -412,7 +399,6 @@ async def check_and_deliver() -> None:
if not user: if not user:
continue continue
# 将 UTC 转为用户本地时间,判断是否落在推送窗口内
user_current = _user_local_time(now, user.timezone) user_current = _user_local_time(now, user.timezone)
if not _is_within_window(schedule.delivery_time, user_current): if not _is_within_window(schedule.delivery_time, user_current):
continue continue
@@ -422,7 +408,6 @@ async def check_and_deliver() -> None:
if pending is None: if pending is None:
continue continue
# 异步按优先级尝试各邮件渠道
sent = False sent = False
for target_email in pending.email_targets: for target_email in pending.email_targets:
try: try:
+11 -34
View File
@@ -1,8 +1,3 @@
# app/services/fetcher_service.py
"""
抓取服务:从外部 API 拉取热搜/RSS 数据,做查重、向量聚类、入库
热搜分支:语义聚类到 UnifiedEventRSS 分支:写入 NewsArticle
"""
import os import os
import hashlib import hashlib
from datetime import timedelta from datetime import timedelta
@@ -19,6 +14,8 @@ from app.models.models import (
HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent
) )
# AI辅助生成:deepseek-v3-22026年3月20日
# 加载环境变量 # 加载环境变量
load_dotenv() load_dotenv()
hf_token = os.getenv("HF_TOKEN") hf_token = os.getenv("HF_TOKEN")
@@ -31,6 +28,8 @@ print("正在加载模型...")
embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True) embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True)
print("模型加载完成。") print("模型加载完成。")
# AI生成结束
def generate_md5(text: str) -> str: def generate_md5(text: str) -> str:
"""生成 32 位 MD5 作为 external_id,用于跨平台去重""" """生成 32 位 MD5 作为 external_id,用于跨平台去重"""
@@ -88,10 +87,10 @@ class UnifiedEventClusterer:
new_unified = UnifiedEvent( new_unified = UnifiedEvent(
unified_title=title, unified_title=title,
center_embedding=embedding_json, center_embedding=embedding_json,
hot_score=1 # 初始热度 hot_score=1
) )
self.db.add(new_unified) self.db.add(new_unified)
self.db.flush() # 获取自增的主键 ID self.db.flush()
# 更新缓存 # 更新缓存
self.event_vectors.append(new_vec) self.event_vectors.append(new_vec)
@@ -109,11 +108,8 @@ def process_hot_trend_item(db, source, item, index: int, external_id: str, exist
event_to_log = None event_to_log = None
# 查重:已存在则可能只需更新标题/排名;不存在则需聚类并新建
if existing_event: if existing_event:
# 场景 A1:老熟人
if existing_event.current_headline != title: if existing_event.current_headline != title:
# 标题被暗改,此时需要重新算一次 Embedding
new_embedding_json, _ = embeddings_dict[title] new_embedding_json, _ = embeddings_dict[title]
revision = HeadlineRevision( revision = HeadlineRevision(
@@ -123,30 +119,25 @@ def process_hot_trend_item(db, source, item, index: int, external_id: str, exist
) )
db.add(revision) db.add(revision)
existing_event.current_headline = title existing_event.current_headline = title
existing_event.title_embedding = new_embedding_json # 更新为新标题的语义向量 existing_event.title_embedding = new_embedding_json
# 注:这里不改变它所属的 unified_event_id,因为大体还是同一件事
existing_event.current_ranking = index existing_event.current_ranking = index
existing_event.event_url = item_url existing_event.event_url = item_url
event_to_log = existing_event event_to_log = existing_event
else: else:
# 场景 A2:这是一条彻底的全新热搜
# 1. 计算向量
new_embedding_json, new_vec = embeddings_dict[title]
# 2. 扔进聚类中枢找归宿 new_embedding_json, new_vec = embeddings_dict[title]
matched_event_id = clusterer.match_or_create(title, new_embedding_json, new_vec) matched_event_id = clusterer.match_or_create(title, new_embedding_json, new_vec)
# 3. 落库
new_event = TrendingEvent( new_event = TrendingEvent(
source_id=source.id, source_id=source.id,
external_id=external_id, external_id=external_id,
current_headline=title, current_headline=title,
event_url=item_url, event_url=item_url,
current_ranking=index, current_ranking=index,
title_embedding=new_embedding_json, # 存入向量 title_embedding=new_embedding_json,
unified_event_id=matched_event_id # 挂载到大事件下 unified_event_id=matched_event_id
) )
db.add(new_event) db.add(new_event)
db.flush() db.flush()
@@ -192,7 +183,6 @@ def process_source_data(db, source, items: list) -> int:
saved_count = 0 saved_count = 0
platform_id = source.home_url platform_id = source.home_url
# 1. 批量计算外部 ID 并聚合要计算的文本
valid_items = [] valid_items = []
external_ids = [] external_ids = []
for item in items: for item in items:
@@ -209,7 +199,6 @@ def process_source_data(db, source, items: list) -> int:
if not valid_items: if not valid_items:
return 0 return 0
# 批量查重:按 external_id 判断是更新还是新增
existing_events_dict = {} existing_events_dict = {}
existing_articles_dict = {} existing_articles_dict = {}
@@ -226,7 +215,6 @@ def process_source_data(db, source, items: list) -> int:
).all() ).all()
existing_articles_dict = {art.external_id: art for art in existing_articles} existing_articles_dict = {art.external_id: art for art in existing_articles}
# 仅对需要算向量的标题做批量 embedding,避免重复计算
texts_to_embed = [] texts_to_embed = []
if source.source_type in (SourceType.HOT_TREND, SourceType.API): if source.source_type in (SourceType.HOT_TREND, SourceType.API):
for item, external_id in valid_items: for item, external_id in valid_items:
@@ -238,15 +226,12 @@ def process_source_data(db, source, items: list) -> int:
else: else:
texts_to_embed.append(title) texts_to_embed.append(title)
# 4. 批量执行大模型推理
embeddings_dict = generate_embeddings_batch(texts_to_embed) embeddings_dict = generate_embeddings_batch(texts_to_embed)
# 初始化聚类器(只在热搜模式下需要,且只初始化一次)
clusterer = None clusterer = None
if source.source_type in (SourceType.HOT_TREND, SourceType.API): if source.source_type in (SourceType.HOT_TREND, SourceType.API):
clusterer = UnifiedEventClusterer(db) clusterer = UnifiedEventClusterer(db)
# 按来源类型分流:热搜/API → TrendingEvent + 聚类;RSS → NewsArticle
for index, (item, external_id) in enumerate(valid_items, 1): for index, (item, external_id) in enumerate(valid_items, 1):
if source.source_type in (SourceType.HOT_TREND, SourceType.API): if source.source_type in (SourceType.HOT_TREND, SourceType.API):
existing_event = existing_events_dict.get(external_id) existing_event = existing_events_dict.get(external_id)
@@ -269,14 +254,12 @@ async def fetch_and_save_trending_data():
""" """
print(f"[{utcnow()}] 开始执行定时抓取任务...") print(f"[{utcnow()}] 开始执行定时抓取任务...")
# 获取启用的信息源 - 这个只读操作用一个短连接
with SessionLocal() as db: with SessionLocal() as db:
sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all() sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all()
if not sources: if not sources:
print("没有找到启用的信息源,任务结束。") print("没有找到启用的信息源,任务结束。")
return return
# 我们把 source 的信息提前提取出来,避免在异步中长期持有 session
source_configs = [ source_configs = [
{ {
"id": s.id, "id": s.id,
@@ -287,7 +270,6 @@ async def fetch_and_save_trending_data():
for s in sources for s in sources
] ]
# 伪装请求头,规避反爬
custom_headers = { custom_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*", "Accept": "application/json, text/plain, */*",
@@ -304,13 +286,11 @@ async def fetch_and_save_trending_data():
url = f"{API_BASE_URL}?id={platform_id}&latest" url = f"{API_BASE_URL}?id={platform_id}&latest"
try: try:
# 1. 网络请求(可能耗时较长,不要包在 db session 里)
response = await client.get(url) response = await client.get(url)
response.raise_for_status() response.raise_for_status()
data_json = response.json() data_json = response.json()
items = data_json.get("items", []) items = data_json.get("items", [])
# 2. 数据库事务操作(尽量短,单独使用 session)
with SessionLocal() as db: with SessionLocal() as db:
# 重新从短 session 中获取 source 实例,以免 detached # 重新从短 session 中获取 source 实例,以免 detached
source = db.query(InfoSource).get(s_config["id"]) source = db.query(InfoSource).get(s_config["id"])
@@ -319,10 +299,8 @@ async def fetch_and_save_trending_data():
task_log = DataSyncTask(source_id=source.id, items_fetched=0) task_log = DataSyncTask(source_id=source.id, items_fetched=0)
try: try:
# 调用数据处理层
saved_count = process_source_data(db, source, items) saved_count = process_source_data(db, source, items)
# 业务事务成功提交
task_log.items_fetched = saved_count task_log.items_fetched = saved_count
task_log.task_status = TaskStatus.SUCCESS task_log.task_status = TaskStatus.SUCCESS
db.add(task_log) db.add(task_log)
@@ -330,10 +308,9 @@ async def fetch_and_save_trending_data():
print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据") print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据")
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise e # 抛出给外层捕获记录日志 raise e
except Exception as e: except Exception as e:
# 异常拦截与错误隔离,另起一个超短事务记录日志
with SessionLocal() as log_db: with SessionLocal() as log_db:
try: try:
new_task_log = DataSyncTask(source_id=s_config["id"], items_fetched=0) new_task_log = DataSyncTask(source_id=s_config["id"], items_fetched=0)
+3 -24
View File
@@ -1,7 +1,3 @@
"""
匹配服务:根据用户兴趣关键词(精确 + 语义)推荐事件
打分融合:标签/标题匹配分 + 标签相关度 + 热度 + 新鲜度加成
"""
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -13,6 +9,7 @@ from sqlalchemy.orm import Session
from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopicPreference, utcnow from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopicPreference, utcnow
from app.services.fetcher_service import embedder_model from app.services.fetcher_service import embedder_model
# AI辅助生成:deepseek-v3-22026年3月20日
# 语义匹配阈值:用户关键词和事件标签/标题向量相似度达到该值才计入语义命中 # 语义匹配阈值:用户关键词和事件标签/标题向量相似度达到该值才计入语义命中
DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78 DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78
@@ -35,6 +32,7 @@ class MatchedEventResult:
semantic_hits: list[dict[str, Any]] semantic_hits: list[dict[str, Any]]
tags: list[str] tags: list[str]
# AI生成结束
def _normalize_text(text: str) -> str: def _normalize_text(text: str) -> str:
"""统一小写与首尾空白,便于做稳定匹配。""" """统一小写与首尾空白,便于做稳定匹配。"""
@@ -80,7 +78,6 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
uncached_keywords = [] uncached_keywords = []
# 1. 尝试从缓存获取
for keyword in keywords: for keyword in keywords:
if not keyword: if not keyword:
continue continue
@@ -89,9 +86,7 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
else: else:
uncached_keywords.append(keyword) uncached_keywords.append(keyword)
# 2. 对未命中的词进行统一的批量推理
if uncached_keywords: if uncached_keywords:
# 去重,避免同一个未缓存的词被计算多次
unique_uncached = list(dict.fromkeys(uncached_keywords)) unique_uncached = list(dict.fromkeys(uncached_keywords))
vectors = embedder_model.encode(unique_uncached, normalize_embeddings=True, show_progress_bar=False) vectors = embedder_model.encode(unique_uncached, normalize_embeddings=True, show_progress_bar=False)
@@ -102,7 +97,6 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
for k in keys_to_delete: for k in keys_to_delete:
del _EMBEDDING_CACHE[k] del _EMBEDDING_CACHE[k]
# 3. 将新计算的向量存入缓存并回填结果
for keyword, vec in zip(unique_uncached, vectors): for keyword, vec in zip(unique_uncached, vectors):
vec_array = np.asarray(vec, dtype=np.float32) vec_array = np.asarray(vec, dtype=np.float32)
_EMBEDDING_CACHE[keyword] = vec_array _EMBEDDING_CACHE[keyword] = vec_array
@@ -172,7 +166,6 @@ def recommend_events_for_user(
else PREFERENCE_SEMANTIC_THRESHOLD else PREFERENCE_SEMANTIC_THRESHOLD
) )
# 1. 读取用户兴趣词
preferences = ( preferences = (
db.query(UserTopicPreference) db.query(UserTopicPreference)
.filter(UserTopicPreference.user_id == user_id) .filter(UserTopicPreference.user_id == user_id)
@@ -185,7 +178,6 @@ def recommend_events_for_user(
if not preference_keywords: if not preference_keywords:
return [] return []
# 2. 读取候选事件(时间 + 热度过滤,避免全表扫描)
time_limit = utcnow() - timedelta(hours=hours) time_limit = utcnow() - timedelta(hours=hours)
events = ( events = (
db.query(UnifiedEvent) db.query(UnifiedEvent)
@@ -213,20 +205,17 @@ def recommend_events_for_user(
.all() .all()
) )
# 组织事件标签映射:event_id -> [(tag, relevance_score), ...]
event_topics: dict[int, list[tuple[str, float | None]]] = {} event_topics: dict[int, list[tuple[str, float | None]]] = {}
for event_id, topic_keyword, relevance_score in topic_rows: for event_id, topic_keyword, relevance_score in topic_rows:
if not topic_keyword: if not topic_keyword:
continue continue
event_topics.setdefault(event_id, []).append((topic_keyword, relevance_score)) event_topics.setdefault(event_id, []).append((topic_keyword, relevance_score))
# 3. 批量编码用户词与标签词,减少模型调用次数
unique_preference_keywords = list(dict.fromkeys(preference_keywords)) unique_preference_keywords = list(dict.fromkeys(preference_keywords))
unique_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]])) unique_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]]))
pref_vec_map = _build_keyword_embedding_map(unique_preference_keywords) pref_vec_map = _build_keyword_embedding_map(unique_preference_keywords)
topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords) topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords)
# 预先建立“标准化后用户词集合”,用于精确匹配
normalized_preference_pairs = [ normalized_preference_pairs = [
(word, _normalize_text(word)) (word, _normalize_text(word))
for word in unique_preference_keywords for word in unique_preference_keywords
@@ -246,20 +235,15 @@ def recommend_events_for_user(
exact_hits: list[str] = [] exact_hits: list[str] = []
semantic_hits: list[dict[str, Any]] = [] semantic_hits: list[dict[str, Any]] = []
score = 0.0 score = 0.0
# 对每个事件标签做精确匹配或语义匹配
for topic_keyword, topic_relevance in topic_list: for topic_keyword, topic_relevance in topic_list:
topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0 topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0
# 1) 精确命中(包括完全相等与包含关系)
matched_pref = _find_exact_preference_match(topic_keyword, normalized_preference_pairs) matched_pref = _find_exact_preference_match(topic_keyword, normalized_preference_pairs)
if matched_pref is not None: if matched_pref is not None:
exact_hits.append(topic_keyword) exact_hits.append(topic_keyword)
# 精确命中给较高基础分,标签自身相关度作为增益
score += 45.0 + topic_relevance_score * 0.2 score += 45.0 + topic_relevance_score * 0.2
continue continue
# 2) 语义命中(未精确命中时再算)
best_pref, best_sim = _find_best_semantic_match(topic_keyword, topic_vec_map, pref_vec_map) best_pref, best_sim = _find_best_semantic_match(topic_keyword, topic_vec_map, pref_vec_map)
if best_pref is not None and best_sim >= similarity_threshold: if best_pref is not None and best_sim >= similarity_threshold:
@@ -270,10 +254,8 @@ def recommend_events_for_user(
"similarity": round(best_sim, 4), "similarity": round(best_sim, 4),
} }
) )
# 语义命中分略低于精确命中,并由相似度放大
score += best_sim * 35.0 + topic_relevance_score * 0.12 score += best_sim * 35.0 + topic_relevance_score * 0.12
# 标题也参与匹配,但权重低于结构化标签,避免长标题过度主导排序。
event_title = (event.unified_title or "").strip() event_title = (event.unified_title or "").strip()
if event_title: if event_title:
title_exact_pref = _find_exact_preference_match(event_title, normalized_preference_pairs) title_exact_pref = _find_exact_preference_match(event_title, normalized_preference_pairs)
@@ -292,15 +274,12 @@ def recommend_events_for_user(
) )
score += best_sim * 24.0 score += best_sim * 24.0
# 如果精确和语义都没命中,直接跳过
if not exact_hits and not semantic_hits: if not exact_hits and not semantic_hits:
continue continue
# 融合事件热度和新鲜度,避免只看语义分
score += min(event.hot_score, 100) * 0.3 score += min(event.hot_score, 100) * 0.3
score += _calc_freshness_bonus(event) score += _calc_freshness_bonus(event)
# 返回标签时做去重,保证接口稳定
tags = list(dict.fromkeys([item[0] for item in topic_list])) tags = list(dict.fromkeys([item[0] for item in topic_list]))
scored_results.append( scored_results.append(
MatchedEventResult( MatchedEventResult(
+4 -8
View File
@@ -1,8 +1,3 @@
# app/services/summary_service.py
"""
摘要服务:调用 LLM 生成统一标题、综合摘要、话题标签
定时任务:对热度达标且未摘要的事件批量处理
"""
import json import json
import os import os
from datetime import timedelta from datetime import timedelta
@@ -26,12 +21,16 @@ from app.prompts.summary_prompts import (
) )
from app.services.fetcher_service import embedder_model from app.services.fetcher_service import embedder_model
# AI辅助生成:deepseek-v3-22026年3月20日
HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3)) HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
TOPIC_TAG_MIN_HOT_SCORE = int(os.getenv("TOPIC_TAG_MIN_HOT_SCORE", HOT_SCORE_THRESHOLD)) TOPIC_TAG_MIN_HOT_SCORE = int(os.getenv("TOPIC_TAG_MIN_HOT_SCORE", HOT_SCORE_THRESHOLD))
TOPIC_SIMILARITY_THRESHOLD = float(os.getenv("TOPIC_SIMILARITY_THRESHOLD", 0.82)) TOPIC_SIMILARITY_THRESHOLD = float(os.getenv("TOPIC_SIMILARITY_THRESHOLD", 0.82))
TOPIC_TAG_MAX_COUNT = int(os.getenv("TOPIC_TAG_MAX_COUNT", 8)) TOPIC_TAG_MAX_COUNT = int(os.getenv("TOPIC_TAG_MAX_COUNT", 8))
AI_API_KEY = os.getenv("AI_API_KEY", "") AI_API_KEY = os.getenv("AI_API_KEY", "")
# AI生成结束
deepseek_client = AsyncOpenAI( deepseek_client = AsyncOpenAI(
api_key=AI_API_KEY, api_key=AI_API_KEY,
@@ -184,7 +183,6 @@ async def generate_unified_summaries():
"""定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签""" """定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签"""
print(f"[{utcnow()}] Start unified summary generation task...") print(f"[{utcnow()}] Start unified summary generation task...")
# 先提取需要处理的事件 ID,尽早释放 session,不长期占用 db session
with SessionLocal() as db: with SessionLocal() as db:
recent_threshold = utcnow() - timedelta(days=3) recent_threshold = utcnow() - timedelta(days=3)
events = db.query(UnifiedEvent).filter( events = db.query(UnifiedEvent).filter(
@@ -197,11 +195,9 @@ async def generate_unified_summaries():
print("No events require summary update in this round.") print("No events require summary update in this round.")
return return
# 复制出需要的信息,脱离 session
event_ids = [e.id for e in events] event_ids = [e.id for e in events]
event_hot_scores = {e.id: e.hot_score for e in events} event_hot_scores = {e.id: e.hot_score for e in events}
# 外层循环:针对每个 event_id 开启一个极短生命周期的 session 获取依赖数据
for event_id in event_ids: for event_id in event_ids:
platform_dict: dict[str, set[str]] = {} platform_dict: dict[str, set[str]] = {}
with SessionLocal() as db: with SessionLocal() as db:
+1 -1
View File
@@ -1,4 +1,4 @@
# app/utils/email_utils.py # AI辅助生成:deepseek-v3-22026年3月20日
import os import os
from email.message import EmailMessage from email.message import EmailMessage
import aiosmtplib import aiosmtplib
+1 -3
View File
@@ -1,4 +1,4 @@
# run.py # AI辅助生成:deepseek-v3-22026年3月20日
import uvicorn import uvicorn
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -8,11 +8,9 @@ if __name__ == "__main__":
load_dotenv() load_dotenv()
PORT = int(os.getenv("PORT", 8000)) PORT = int(os.getenv("PORT", 8000))
# 启动服务
uvicorn.run( uvicorn.run(
app="app.main:app", app="app.main:app",
host="0.0.0.0", host="0.0.0.0",
port=PORT, port=PORT,
# reload=True,
workers=1 workers=1
) )
-1
View File
@@ -5,7 +5,6 @@
<link rel="icon" href="/favicon.svg"> <link rel="icon" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>聚势智见 - 基于语义聚类与大模型的热点资讯聚合平台</title> <title>聚势智见 - 基于语义聚类与大模型的热点资讯聚合平台</title>
<!-- Font Awesome 图标库 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head> </head>
<body> <body>
-6
View File
@@ -6,9 +6,6 @@ export function fetchDeliveryConfig(userId: number): Promise<DeliveryConfig> {
return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`) return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`)
} }
// ==========================================
// 推送时间表
// ==========================================
export function createDeliverySchedule( export function createDeliverySchedule(
userId: number, userId: number,
payload: { delivery_time: string; is_active?: boolean }, payload: { delivery_time: string; is_active?: boolean },
@@ -34,9 +31,6 @@ export function deleteDeliverySchedule(
return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`) return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`)
} }
// ==========================================
// 推送渠道
// ==========================================
export function createPushEndpoint( export function createPushEndpoint(
userId: number, userId: number,
payload: { payload: {
-3
View File
@@ -1,6 +1,3 @@
/**
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer
*/
import type { import type {
AuthTokenResponse, AuthTokenResponse,
LoginPayload, LoginPayload,
+2
View File
@@ -1,3 +1,5 @@
// AI辅助生成:deepseek-v3-22026年3月20日
export interface UserProfile { export interface UserProfile {
id: number id: number
email: string email: string
+1
View File
@@ -1,3 +1,4 @@
<!-- AI辅助生成deepseek-v3-22026年3月20日 -->
<!-- 仪表盘布局侧边栏导航主内容区移动端抽屉 --> <!-- 仪表盘布局侧边栏导航主内容区移动端抽屉 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
+1 -3
View File
@@ -1,6 +1,4 @@
/** // AI辅助生成:deepseek-v3-22026年3月20日
* 应用入口:初始化 Vue、Pinia、路由、主题
*/
import './assets/main.css' import './assets/main.css'
import { createApp } from 'vue' import { createApp } from 'vue'
+2
View File
@@ -1,3 +1,5 @@
<!-- AI辅助生成deepseek-v3-22026年3月20日 -->
<!-- 关于页占位 --> <!-- 关于页占位 -->
<template> <template>
<div class="about"> <div class="about">
+2 -49
View File
@@ -1,4 +1,3 @@
<!-- 主仪表盘事件流为你推荐公关修改追踪系统状态 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue' import { onMounted, ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -13,9 +12,6 @@ import type { MatchedEvent, UserTopicPreference } from '@/types/preference'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// ==========================================
// 聚光灯:从推荐页跳转过来时,按 ID 单独拉取目标事件
// ==========================================
const spotlightEvent = ref<UnifiedEvent | null>(null) const spotlightEvent = ref<UnifiedEvent | null>(null)
const loadingSpotlight = ref(false) const loadingSpotlight = ref(false)
@@ -41,9 +37,7 @@ function dismissSpotlight() {
const authStore = useAuthStore() const authStore = useAuthStore()
const userId = computed(() => authStore.user?.id ?? 0) const userId = computed(() => authStore.user?.id ?? 0)
// ==========================================
// 状态
// ==========================================
const events = ref<UnifiedEvent[]>([]) const events = ref<UnifiedEvent[]>([])
const revisions = ref<HeadlineRevision[]>([]) const revisions = ref<HeadlineRevision[]>([])
const stats = ref<SystemStats | null>(null) const stats = ref<SystemStats | null>(null)
@@ -101,9 +95,6 @@ const recSortOptions = [
{ label: '最新', value: 'created_at' }, { label: '最新', value: 'created_at' },
] ]
// ==========================================
// 平台视觉映射
// ==========================================
const platformIconMap: Record<string, string> = { const platformIconMap: Record<string, string> = {
微博热搜: 'fa-brands fa-weibo', 微博热搜: 'fa-brands fa-weibo',
微博: 'fa-brands fa-weibo', 微博: 'fa-brands fa-weibo',
@@ -171,9 +162,7 @@ function formatRelativeTime(dateStr: string): string {
return `${days} 天前` return `${days} 天前`
} }
// ==========================================
// 排名图表配置
// ==========================================
function getRankingChartOptions(history: number[], platformColor: string) { function getRankingChartOptions(history: number[], platformColor: string) {
return { return {
series: [{ name: '排名', data: history }], series: [{ name: '排名', data: history }],
@@ -249,9 +238,6 @@ function platformKey(eventId: number, index: number, prefix: string = ''): strin
return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}` return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}`
} }
// ==========================================
// 数据加载
// ==========================================
async function loadEvents(append = false) { async function loadEvents(append = false) {
if (!append) { if (!append) {
loading.value = true loading.value = true
@@ -681,9 +667,6 @@ watch(() => route.query.event, (newId) => {
</template> </template>
</div> </div>
<!-- ==========================================
右侧:小组件面板
========================================== -->
<div class="widgets-column"> <div class="widgets-column">
<!-- 为你推荐(基于用户关键词的匹配) --> <!-- 为你推荐(基于用户关键词的匹配) -->
@@ -897,9 +880,6 @@ watch(() => route.query.event, (newId) => {
margin-top: 6px; margin-top: 6px;
} }
/* ==========================================
网格布局
========================================== */
.content-grid { .content-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -931,9 +911,6 @@ watch(() => route.query.event, (newId) => {
} }
} }
/* ==========================================
区域标题 + 热度阈值 (高级磨砂透明风)
========================================== */
.section-header { .section-header {
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -1023,9 +1000,6 @@ watch(() => route.query.event, (newId) => {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
/* ==========================================
事件卡片
========================================== */
/* 事件卡片,加入毛玻璃与高级阴影 */ /* 事件卡片,加入毛玻璃与高级阴影 */
.event-card { .event-card {
background: var(--bg-surface); background: var(--bg-surface);
@@ -1142,9 +1116,6 @@ watch(() => route.query.event, (newId) => {
color: transparent; color: transparent;
} }
/* ==========================================
平台列表 + 悬停排名图
========================================== */
.platforms-list { .platforms-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1262,9 +1233,6 @@ watch(() => route.query.event, (newId) => {
max-height: 120px; max-height: 120px;
} }
/* ==========================================
加载更多
========================================== */
.load-more-wrapper { .load-more-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1310,9 +1278,6 @@ watch(() => route.query.event, (newId) => {
color: var(--text-placeholder); color: var(--text-placeholder);
} }
/* ==========================================
小组件面板(通用)- 玻璃拟态高级质感
========================================== */
.widget-panel { .widget-panel {
background: var(--bg-surface); background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur); backdrop-filter: var(--backdrop-blur);
@@ -1406,9 +1371,6 @@ watch(() => route.query.event, (newId) => {
font-size: 13px; font-size: 13px;
} }
/* ==========================================
为你推荐面板
========================================== */
.recommend-header { .recommend-header {
background: rgba(139, 92, 246, 0.06); background: rgba(139, 92, 246, 0.06);
border-bottom-color: rgba(139, 92, 246, 0.15); border-bottom-color: rgba(139, 92, 246, 0.15);
@@ -1584,9 +1546,6 @@ watch(() => route.query.event, (newId) => {
font-size: 9px; font-size: 9px;
} }
/* ==========================================
公关修改追踪
========================================== */
.revision-header { .revision-header {
background: rgba(239, 68, 68, 0.06); background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.15); border-bottom-color: rgba(239, 68, 68, 0.15);
@@ -1687,9 +1646,6 @@ watch(() => route.query.event, (newId) => {
margin: 0; margin: 0;
} }
/* ==========================================
系统状态
========================================== */
.stats-widget { .stats-widget {
padding: 16px; padding: 16px;
} }
@@ -1759,9 +1715,6 @@ watch(() => route.query.event, (newId) => {
color: var(--status-error); color: var(--status-error);
} }
/* ==========================================
聚光灯区块
========================================== */
.spotlight-wrap { .spotlight-wrap {
margin-bottom: 20px; margin-bottom: 20px;
} }
-28
View File
@@ -1,4 +1,3 @@
<!-- 推送设置页管理推送时间表与推送渠道邮箱等 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'
@@ -62,9 +61,6 @@ async function loadConfig() {
} }
} }
// ==========================================
// 推送时间表操作
// ==========================================
async function handleAddSchedule() { async function handleAddSchedule() {
if (!userId.value || !newTime.value) return if (!userId.value || !newTime.value) return
submittingSchedule.value = true submittingSchedule.value = true
@@ -109,9 +105,6 @@ async function handleDeleteSchedule(schedule: DeliverySchedule) {
} }
} }
// ==========================================
// 推送渠道操作
// ==========================================
async function handleAddEndpoint() { async function handleAddEndpoint() {
if (!userId.value || !newChannelAccount.value.trim()) return if (!userId.value || !newChannelAccount.value.trim()) return
submittingEndpoint.value = true submittingEndpoint.value = true
@@ -186,9 +179,6 @@ onMounted(loadConfig)
</div> </div>
<div v-else class="config-sections"> <div v-else class="config-sections">
<!-- ==========================================
推送时间管理
========================================== -->
<section class="config-section"> <section class="config-section">
<div class="section-title"> <div class="section-title">
<h2><i class="fa-regular fa-clock"></i> 推送时间</h2> <h2><i class="fa-regular fa-clock"></i> 推送时间</h2>
@@ -229,9 +219,6 @@ onMounted(loadConfig)
</div> </div>
</section> </section>
<!-- ==========================================
推送渠道管理
========================================== -->
<section class="config-section"> <section class="config-section">
<div class="section-title"> <div class="section-title">
<h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2> <h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2>
@@ -374,9 +361,6 @@ onMounted(loadConfig)
color: var(--text-secondary); color: var(--text-secondary);
} }
/* ==========================================
通用区块样式
========================================== */
.config-sections { .config-sections {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -418,9 +402,6 @@ onMounted(loadConfig)
margin: 0; margin: 0;
} }
/* ==========================================
添加行
========================================== */
.add-row { .add-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -497,9 +478,6 @@ onMounted(loadConfig)
font-size: 13px; font-size: 13px;
} }
/* ==========================================
时间表列表
========================================== */
.schedule-list { .schedule-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -573,9 +551,6 @@ onMounted(loadConfig)
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
} }
/* ==========================================
渠道列表
========================================== */
.endpoint-add { .endpoint-add {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -661,9 +636,6 @@ onMounted(loadConfig)
gap: 6px; gap: 6px;
} }
/* ==========================================
工作原理说明
========================================== */
.info-section { .info-section {
background: transparent; background: transparent;
border: 1px dashed var(--border-subtle); border: 1px dashed var(--border-subtle);
-1
View File
@@ -1,4 +1,3 @@
<!-- 概览页展示当前账户会话状态认证接入说明 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
+3 -7
View File
@@ -1,4 +1,3 @@
<!-- 登录页支持密码登录与邮箱验证码登录 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, reactive, ref, watch } from 'vue' import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -156,7 +155,7 @@ onUnmounted(() => {
<p class="brand-desc"> <p class="brand-desc">
聚合多平台趋势自动完成热点归并与摘要你可以用密码登录也可以直接使用邮箱验证码快速登录 聚合多平台趋势自动完成热点归并与摘要你可以用密码登录也可以直接使用邮箱验证码快速登录
</p> </p>
<div class="feature-list"> <div class="feature-list">
<div class="feature-item"> <div class="feature-item">
<div class="feature-icon">🚀</div> <div class="feature-icon">🚀</div>
@@ -287,7 +286,7 @@ onUnmounted(() => {
<button class="btn-primary" :disabled="authStore.loading" type="submit"> <button class="btn-primary" :disabled="authStore.loading" type="submit">
{{ isSubmitting ? '登录中...' : (loginMode === 'password' ? '密码登录' : '邮箱验证码登录') }} {{ isSubmitting ? '登录中...' : (loginMode === 'password' ? '密码登录' : '邮箱验证码登录') }}
</button> </button>
<button type="button" class="btn-primary guest-btn" @click="router.push('/')" style="margin-top: 12px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border-subtle);"> <button type="button" class="btn-primary guest-btn" @click="router.push('/')" style="margin-top: 12px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border-subtle);">
以游客身份体验 以游客身份体验
</button> </button>
@@ -303,9 +302,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
/* ==========================================
全新高级分屏布局与背景
========================================== */
.split-layout { .split-layout {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
@@ -330,7 +326,7 @@ onUnmounted(() => {
left: -50%; left: -50%;
right: -50%; right: -50%;
bottom: -50%; bottom: -50%;
background-image: background-image:
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px), linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px); linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
background-size: 32px 32px; background-size: 32px 32px;
+1 -5
View File
@@ -1,4 +1,3 @@
<!-- 注册页邮箱验证码 + 密码带密码强度提示 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue' import { computed, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -280,9 +279,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
/* ==========================================
全新高级分屏布局与背景
========================================== */
.split-layout { .split-layout {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
@@ -306,7 +302,7 @@ onUnmounted(() => {
content: ""; content: "";
position: absolute; position: absolute;
top: -50%; left: -50%; right: -50%; bottom: -50%; top: -50%; left: -50%; right: -50%; bottom: -50%;
background-image: background-image:
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px), linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px); linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
background-size: 32px 32px; background-size: 32px 32px;
-1
View File
@@ -1,4 +1,3 @@
<!-- 公关修改追踪页展示热搜标题被偷偷修改的历史记录 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, reactive } from 'vue' import { computed, onMounted, ref, reactive } from 'vue'
+43 -10
View File
@@ -1,4 +1,3 @@
<!-- 事件追踪分析页关键词搜索时间热度图表关联事件列表 -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import VueApexCharts from 'vue3-apexcharts' import VueApexCharts from 'vue3-apexcharts'
@@ -235,17 +234,17 @@ async function handleSearch() {
<div class="tips-box glass-panel"> <div class="tips-box glass-panel">
<h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2> <h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2>
<div class="tips-content"> <div class="tips-content">
<button class="tip-tag" @click="keyword='新能源汽车'; hours=168; handleSearch()"> <button class="tip-tag" @click="keyword='火箭发射'; hours=168; handleSearch()">
<i class="fa-solid fa-rocket"></i> 新能源汽车 <i class="fa-solid fa-rocket"></i> 火箭发射
</button> </button>
<button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()"> <button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()">
<i class="fa-brands fa-apple"></i> 苹果产业链 <i class="fa-brands fa-apple"></i> 苹果公司
</button> </button>
<button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()"> <button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()">
<i class="fa-solid fa-code-branch"></i> AI / 大模型 <i class="fa-solid fa-code-branch"></i> AI / 大模型
</button> </button>
<button class="tip-tag regex-tag" @click="keyword='美国关税'; hours=168; handleSearch()"> <button class="tip-tag regex-tag" @click="keyword='美国'; hours=168; handleSearch()">
<i class="fa-solid fa-flag-usa"></i> 美国关税 <i class="fa-solid fa-flag-usa"></i> 美国
</button> </button>
</div> </div>
</div> </div>
@@ -261,9 +260,15 @@ async function handleSearch() {
<div v-else-if="searchResult" class="results-container"> <div v-else-if="searchResult" class="results-container">
<section class="chart-section glass-panel"> <section class="chart-section glass-panel">
<div class="section-header"> <div class="section-header">
<h2 class="section-title"> <div class="section-title-group">
<i class="fa-solid fa-wave-square"></i> 时间热度脉络 <h2 class="section-title">
</h2> <i class="fa-solid fa-wave-square"></i> 时间热度脉络
</h2>
<span class="chart-tip">
<i class="fa-solid fa-hand-pointer"></i>
点击时间点查看具体事件列表
</span>
</div>
<span class="meta-info"> {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span> <span class="meta-info"> {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span>
</div> </div>
@@ -553,6 +558,30 @@ async function handleSearch() {
color: var(--brand-primary); color: var(--brand-primary);
} }
.section-title-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chart-tip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--radius-md);
background: var(--brand-primary-alpha);
border: 1px solid rgba(99, 102, 241, 0.2);
color: var(--brand-primary);
font-size: 12px;
font-weight: 600;
}
.chart-tip i {
font-size: 12px;
}
.time-filter-badge { .time-filter-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -591,7 +620,7 @@ async function handleSearch() {
.chart-container { .chart-container {
margin-top: 16px; margin-top: 16px;
margin-left: -10px; margin-left: -10px;
} }
.chart-container :deep(svg), .chart-container :deep(svg),
@@ -599,6 +628,10 @@ async function handleSearch() {
outline: none; outline: none;
} }
.chart-container :deep(.apexcharts-marker) {
cursor: pointer;
}
.events-section { .events-section {
margin-top: 8px; margin-top: 8px;
} }
+2 -3
View File
@@ -1,4 +1,3 @@
<!-- 兴趣关键词页添加/删除关键词查看命中事件 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'
@@ -64,10 +63,10 @@ async function loadMatchedEvents() {
loadingMatched.value = true loadingMatched.value = true
matchedError.value = '' matchedError.value = ''
try { try {
const result = await fetchRecommendedEvents(userId.value, { const result = await fetchRecommendedEvents(userId.value, {
limit: 30, limit: 30,
hours: hoursRange.value, hours: hoursRange.value,
sort_by: sortBy.value sort_by: sortBy.value
}) })
matchedEvents.value = result.data matchedEvents.value = result.data
} catch (e) { } catch (e) {