diff --git a/.gitignore b/.gitignore
index 3f485da..ef505aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@ __pycache__/
.idea
# C extensions
*.so
-
+.vscode/
# Distribution / packaging
.Python
build/
diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py
index e69de29..fa5fd34 100644
--- a/backend/app/api/dependencies.py
+++ b/backend/app/api/dependencies.py
@@ -0,0 +1,13 @@
+# app/api/dependencies.py
+from app.database import SessionLocal
+
+def get_db():
+ """
+ FastAPI 依赖注入:为每个 HTTP 请求提供独立的数据库会话。
+ 请求处理完成后自动关闭,防止连接泄漏。
+ """
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
\ No newline at end of file
diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py
new file mode 100644
index 0000000..c143cee
--- /dev/null
+++ b/backend/app/api/endpoints/auth.py
@@ -0,0 +1,238 @@
+import os
+from datetime import timedelta
+from typing import Tuple
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.api.dependencies import get_db
+from app.core.security import (
+ create_access_token,
+ generate_verification_code,
+ hash_password,
+ hash_verification_code,
+ verify_password,
+ verify_verification_code,
+)
+from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow
+from app.schemas.auth_schema import (
+ AuthTokenResponse,
+ LoginCodeSendRequest,
+ LoginRequest,
+ LoginWithCodeRequest,
+ MessageResponse,
+ RegisterCodeSendRequest,
+ RegisterRequest,
+ UserProfileResponse,
+)
+from app.utils.email_utils import send_html_email
+
+
+router = APIRouter()
+
+REGISTER_CODE_EXPIRE_MINUTES = int(os.getenv("REGISTER_CODE_EXPIRE_MINUTES", "10"))
+LOGIN_CODE_EXPIRE_MINUTES = int(os.getenv("LOGIN_CODE_EXPIRE_MINUTES", "10"))
+
+
+def _normalize_email(email: str) -> str:
+ return email.strip().lower()
+
+
+def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str:
+ return f"""
+
+
InsightRadar Email Verification
+
Your {purpose_text} verification code is:
+
{code}
+
The code is valid for {expire_minutes} minutes. Do not share it with others.
+
+ """
+
+
+def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None:
+ db.query(EmailVerificationCode).filter(
+ EmailVerificationCode.email == email,
+ EmailVerificationCode.purpose == purpose,
+ EmailVerificationCode.is_used.is_(False),
+ ).update({EmailVerificationCode.is_used: True}, synchronize_session=False)
+ db.commit()
+
+
+def _create_code_record(
+ db: Session,
+ *,
+ email: str,
+ purpose: VerificationPurpose,
+ expire_minutes: int,
+) -> Tuple[EmailVerificationCode, str]:
+ code = generate_verification_code()
+ now = utcnow()
+ code_record = EmailVerificationCode(
+ email=email,
+ purpose=purpose,
+ code_hash=hash_verification_code(code),
+ expires_at=now + timedelta(minutes=expire_minutes),
+ )
+ db.add(code_record)
+ db.commit()
+ return code_record, code
+
+
+def _build_auth_response(user: AppUser) -> AuthTokenResponse:
+ token, expires_in = create_access_token(user_id=user.id, email=user.email)
+ return AuthTokenResponse(
+ access_token=token,
+ expires_in=expires_in,
+ user=UserProfileResponse.model_validate(user),
+ )
+
+
+@router.post("/register/send-code", response_model=MessageResponse)
+async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)):
+ email = _normalize_email(payload.email)
+
+ existing_user = db.query(AppUser).filter(AppUser.email == email).first()
+ if existing_user:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
+
+ _invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
+ code_record, code = _create_code_record(
+ db,
+ email=email,
+ purpose=VerificationPurpose.REGISTER,
+ expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
+ )
+
+ email_sent = await send_html_email(
+ to_email=email,
+ subject="InsightRadar Registration Code",
+ html_content=_build_verification_email(code, "registration", REGISTER_CODE_EXPIRE_MINUTES),
+ )
+ if not email_sent:
+ code_record.is_used = True
+ db.add(code_record)
+ db.commit()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to send verification code",
+ )
+
+ return MessageResponse(message="Verification code sent")
+
+
+@router.post("/login/send-code", response_model=MessageResponse)
+async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)):
+ email = _normalize_email(payload.email)
+ user = db.query(AppUser).filter(AppUser.email == email).first()
+
+ if not user:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
+
+ _invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
+ code_record, code = _create_code_record(
+ db,
+ email=email,
+ purpose=VerificationPurpose.LOGIN,
+ expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
+ )
+
+ email_sent = await send_html_email(
+ to_email=email,
+ subject="InsightRadar Login Code",
+ html_content=_build_verification_email(code, "login", LOGIN_CODE_EXPIRE_MINUTES),
+ )
+ if not email_sent:
+ code_record.is_used = True
+ db.add(code_record)
+ db.commit()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to send verification code",
+ )
+
+ return MessageResponse(message="Verification code sent")
+
+
+@router.post(
+ "/register",
+ response_model=AuthTokenResponse,
+ status_code=status.HTTP_201_CREATED,
+)
+async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
+ email = _normalize_email(payload.email)
+ existing_user = db.query(AppUser).filter(AppUser.email == email).first()
+ if existing_user:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
+
+ now = utcnow()
+ code_record = db.query(EmailVerificationCode).filter(
+ EmailVerificationCode.email == email,
+ EmailVerificationCode.purpose == VerificationPurpose.REGISTER,
+ EmailVerificationCode.is_used.is_(False),
+ EmailVerificationCode.expires_at >= now,
+ ).order_by(EmailVerificationCode.created_at.desc()).first()
+
+ if not code_record:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
+
+ if not verify_verification_code(payload.verification_code, code_record.code_hash):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
+
+ nickname = payload.nickname or email.split("@")[0]
+ user = AppUser(
+ email=email,
+ password_hash=hash_password(payload.password),
+ nickname=nickname,
+ metadata_={"email_verified_at": now.isoformat()},
+ )
+
+ code_record.is_used = True
+ db.add(user)
+ db.add(code_record)
+ db.commit()
+ db.refresh(user)
+
+ return _build_auth_response(user)
+
+
+@router.post("/login", response_model=AuthTokenResponse)
+async def login(payload: LoginRequest, db: Session = Depends(get_db)):
+ email = _normalize_email(payload.email)
+ user = db.query(AppUser).filter(AppUser.email == email).first()
+
+ if not user or not user.password_hash:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
+
+ if not verify_password(payload.password, user.password_hash):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
+
+ return _build_auth_response(user)
+
+
+@router.post("/login/code", response_model=AuthTokenResponse)
+async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)):
+ email = _normalize_email(payload.email)
+ user = db.query(AppUser).filter(AppUser.email == email).first()
+
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
+
+ now = utcnow()
+ code_record = db.query(EmailVerificationCode).filter(
+ EmailVerificationCode.email == email,
+ EmailVerificationCode.purpose == VerificationPurpose.LOGIN,
+ EmailVerificationCode.is_used.is_(False),
+ EmailVerificationCode.expires_at >= now,
+ ).order_by(EmailVerificationCode.created_at.desc()).first()
+
+ if not code_record:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
+
+ if not verify_verification_code(payload.verification_code, code_record.code_hash):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
+
+ code_record.is_used = True
+ db.add(code_record)
+ db.commit()
+
+ return _build_auth_response(user)
diff --git a/backend/app/api/endpoints/events.py b/backend/app/api/endpoints/events.py
new file mode 100644
index 0000000..f09f161
--- /dev/null
+++ b/backend/app/api/endpoints/events.py
@@ -0,0 +1,69 @@
+# app/api/endpoints/events.py
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.orm import Session
+from datetime import timedelta
+from typing import List
+
+from app.api.dependencies import get_db
+from app.models.models import UnifiedEvent, TrendingEvent, InfoSource, RankingLog, utcnow
+# 导入你上传的 Schema
+from app.schemas.event_schema import UnifiedEventResponse, PlatformTrendResponse
+
+router = APIRouter()
+
+
+@router.get("/unified", response_model=List[UnifiedEventResponse])
+def list_unified_events(
+ min_hot: int = Query(5, description="热度过滤阈值"),
+ hours: int = Query(24, description="查询过去 X 小时的数据"),
+ db: Session = Depends(get_db)
+):
+ """
+ 获取聚合大事件列表,完全适配前端 template.html 所需的数据结构
+ """
+ # 计算时间水位线
+ time_limit = utcnow() - timedelta(hours=hours)
+
+ # 1. 查询大事件(按热度降序,且满足时间范围)
+ events = db.query(UnifiedEvent).filter(
+ UnifiedEvent.hot_score >= min_hot,
+ UnifiedEvent.created_at >= time_limit
+ ).order_by(UnifiedEvent.hot_score.desc()).all()
+
+ results = []
+ for ev in events:
+ # 2. 联表查询:获取该大事件下关联的所有平台及其具体热搜信息
+ trends = db.query(TrendingEvent, InfoSource.source_name).join(
+ InfoSource, TrendingEvent.source_id == InfoSource.id
+ ).filter(TrendingEvent.unified_event_id == ev.id).all()
+
+ platform_list = []
+ for trend, s_name in trends:
+ # 3. 获取排名历史轨迹 (用于前端渲染)
+ # 这里的排序顺序 asc 保证了数组从旧到新
+ logs = db.query(RankingLog.ranking_position).filter(
+ RankingLog.event_id == trend.id,
+ RankingLog.observed_at >= time_limit
+ ).order_by(RankingLog.observed_at.asc()).all()
+
+ # 组装符合 PlatformTrendResponse 结构的字典
+ platform_list.append(PlatformTrendResponse(
+ source_id=trend.source_id,
+ platform_name=s_name,
+ headline=trend.current_headline,
+ url=trend.event_url,
+ current_ranking=trend.current_ranking,
+ ranking_history=[log[0] for log in logs]
+ ))
+
+ # 4. 组装符合 UnifiedEventResponse 结构的字典
+ results.append(UnifiedEventResponse(
+ event_id=ev.id,
+ unified_title=ev.unified_title if ev.unified_title else "暂无标题",
+ summary=ev.ai_comprehensive_summary,
+ hot_score=ev.hot_score,
+ created_at=ev.created_at,
+ platforms=platform_list
+ ))
+
+ return results
diff --git a/backend/app/api/endpoints/sources.py b/backend/app/api/endpoints/sources.py
index eda2606..d086738 100644
--- a/backend/app/api/endpoints/sources.py
+++ b/backend/app/api/endpoints/sources.py
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
-from app.schemas.schemas import (
+from app.schemas.source_schema import (
InfoSourceCreate, InfoSourceUpdate, InfoSourceResponse, PaginatedResponse
)
from app.crud import crud_source
diff --git a/backend/app/api/endpoints/trends.py b/backend/app/api/endpoints/trends.py
deleted file mode 100644
index e69de29..0000000
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index 000e9bd..70239de 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -1,8 +1,12 @@
# app/api/router.py
from fastapi import APIRouter
-from app.api.endpoints import sources
+from app.api.endpoints import auth, sources, events
api_router = APIRouter()
# 信息源管理
-api_router.include_router(sources.router, prefix="/sources", tags=["信息源管理"])
\ No newline at end of file
+api_router.include_router(sources.router, prefix="/sources", tags=["信息源管理"])
+
+# 注册大事件相关的路由
+api_router.include_router(events.router, prefix="/events", tags=["Unified Events"])
+api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..cf545f3
--- /dev/null
+++ b/backend/app/core/security.py
@@ -0,0 +1,79 @@
+import base64
+import hashlib
+import hmac
+import json
+import os
+import secrets
+import time
+from typing import Tuple
+
+
+PASSWORD_HASH_ITERATIONS = int(os.getenv("PASSWORD_HASH_ITERATIONS", "120000"))
+AUTH_SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "change-this-secret-in-env")
+AUTH_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "10080"))
+
+
+def hash_password(password: str) -> str:
+ salt = secrets.token_hex(16)
+ digest = hashlib.pbkdf2_hmac(
+ "sha256",
+ password.encode("utf-8"),
+ salt.encode("utf-8"),
+ PASSWORD_HASH_ITERATIONS,
+ )
+ return (
+ f"pbkdf2_sha256${PASSWORD_HASH_ITERATIONS}${salt}$"
+ f"{base64.urlsafe_b64encode(digest).decode('utf-8')}"
+ )
+
+
+def verify_password(plain_password: str, password_hash: str) -> bool:
+ try:
+ algorithm, iterations, salt, expected = password_hash.split("$", 3)
+ if algorithm != "pbkdf2_sha256":
+ return False
+
+ digest = hashlib.pbkdf2_hmac(
+ "sha256",
+ plain_password.encode("utf-8"),
+ salt.encode("utf-8"),
+ int(iterations),
+ )
+ calculated = base64.urlsafe_b64encode(digest).decode("utf-8")
+ return hmac.compare_digest(calculated, expected)
+ except Exception:
+ return False
+
+
+def generate_verification_code(length: int = 6) -> str:
+ return "".join(secrets.choice("0123456789") for _ in range(length))
+
+
+def hash_verification_code(code: str) -> str:
+ return hashlib.sha256(code.encode("utf-8")).hexdigest()
+
+
+def verify_verification_code(code: str, expected_hash: str) -> bool:
+ return hmac.compare_digest(hash_verification_code(code), expected_hash)
+
+
+def _urlsafe_b64encode(raw: bytes) -> str:
+ return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
+
+
+def create_access_token(user_id: int, email: str) -> Tuple[str, int]:
+ expires_in = AUTH_TOKEN_EXPIRE_MINUTES * 60
+ payload = {
+ "sub": str(user_id),
+ "email": email,
+ "exp": int(time.time()) + expires_in,
+ }
+ payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
+ encoded_payload = _urlsafe_b64encode(payload_bytes)
+ signature = hmac.new(
+ AUTH_SECRET_KEY.encode("utf-8"),
+ encoded_payload.encode("utf-8"),
+ hashlib.sha256,
+ ).digest()
+ token = f"{encoded_payload}.{_urlsafe_b64encode(signature)}"
+ return token, expires_in
diff --git a/backend/app/crud/crud_source.py b/backend/app/crud/crud_source.py
index 6bcaa54..a8dbfe4 100644
--- a/backend/app/crud/crud_source.py
+++ b/backend/app/crud/crud_source.py
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
from typing import List, Optional
from app.models.models import InfoSource
-from app.schemas.schemas import InfoSourceCreate, InfoSourceUpdate
+from app.schemas.source_schema import InfoSourceCreate, InfoSourceUpdate
def get(db: Session, source_id: int) -> Optional[InfoSource]:
diff --git a/backend/app/main.py b/backend/app/main.py
index 221589f..861479c 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,6 +6,7 @@ from dotenv import load_dotenv
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.services.fetcher_service import fetch_and_save_trending_data
+from app.services.summary_service import generate_unified_summaries
from app.database import engine
from app.models.models import Base
@@ -14,6 +15,7 @@ from app.api.router import api_router
load_dotenv()
CRAWL_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", 10))
+SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30))
scheduler = AsyncIOScheduler()
@@ -36,15 +38,27 @@ async def lifespan(app: FastAPI):
id='trending_fetch_job',
replace_existing=True
)
+
+ # 平台摘要
+ scheduler.add_job(
+ generate_unified_summaries,
+ 'interval',
+ minutes=SUMMARY_INTERVAL,
+ id='ai_summary_job',
+ replace_existing=True
+ )
scheduler.start()
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
+ print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
# 为了测试方便,启动时立即执行一次
await fetch_and_save_trending_data()
+ await generate_unified_summaries()
+
yield # 此时 FastAPI 开始接受请求
- # 3. 优雅关闭
+ # 优雅关闭
scheduler.shutdown()
print("定时任务已安全关闭")
diff --git a/backend/app/models/models.py b/backend/app/models/models.py
index 69736ba..30bab54 100644
--- a/backend/app/models/models.py
+++ b/backend/app/models/models.py
@@ -29,35 +29,40 @@ BigIntType = BigInteger().with_variant(Integer, "sqlite")
class SourceType(str, enum.Enum):
"""信息源的抓取方式"""
- HOT_TREND = "HOT_TREND" # 热搜榜单类
- RSS_FEED = "RSS_FEED" # 传统RSS订阅
- API = "API" # 接口抓取类
+ HOT_TREND = "HOT_TREND" # 热搜榜单类 (如微博热搜)
+ RSS_FEED = "RSS_FEED" # 传统RSS订阅 (如36氪、纽约时报)
+ API = "API" # 接口直接接入类
class TargetType(str, enum.Enum):
"""
多态目标类型 (Polymorphic Target)
- 用于标记一条评论或一个标签到底是挂载在哪个实体下的。
+ 用于标记一条评论、标签或推送记录,到底是挂载在哪个实体下的。
"""
- EVENT = "EVENT" # 挂载在单个热搜事件下
- TREND = "TREND" # 挂载在宏观趋势下
- ARTICLE = "ARTICLE" # 挂载在具体新闻文章下
+ EVENT = "EVENT" # 挂载在AI聚合后的大事件下
+ TREND = "TREND" # 挂载在单个平台的热搜条目下
+ ARTICLE = "ARTICLE" # 挂载在具体的长篇新闻文章下
class TaskStatus(str, enum.Enum):
- """后台任务状态"""
- SUCCESS = "SUCCESS"
- ERROR = "ERROR"
+ """后台爬虫/推送任务的执行状态"""
+ SUCCESS = "SUCCESS" # 执行成功
+ ERROR = "ERROR" # 发生报错
class GenderType(str, enum.Enum):
- """用户性别枚举"""
+ """用户性别枚举,常用于给AI提供Prompt背景信息以生成个性化摘要"""
MALE = "MALE"
FEMALE = "FEMALE"
OTHER = "OTHER"
UNKNOWN = "UNKNOWN"
+class VerificationPurpose(str, enum.Enum):
+ REGISTER = "REGISTER"
+ LOGIN = "LOGIN"
+
+
def utcnow():
"""
获取带UTC时区的当前时间 (最佳实践)
@@ -77,10 +82,14 @@ class InfoSource(Base):
__tablename__ = "info_sources"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
- source_name: Mapped[str] = mapped_column(String(100), comment="信息源名称")
- source_type: Mapped[SourceType] = mapped_column(Enum(SourceType))
- home_url: Mapped[Optional[str]] = mapped_column(String(255))
- is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+ # 信息源的展示名称,例如 "微博热搜", "今日头条"
+ source_name: Mapped[str] = mapped_column(String(100), comment="信息源中文名称")
+ # 抓取类型,决定爬虫调用哪个解析逻辑
+ source_type: Mapped[SourceType] = mapped_column(Enum(SourceType), comment="抓取方式枚举")
+ # 极其重要:原意存官网链接,但实际开发中常借用来存放 API的专属标识(如 'weibo', 'toutiao')
+ home_url: Mapped[Optional[str]] = mapped_column(String(255), comment="官网链接或API的平台标识ID")
+ # 爬虫开关:如果某平台封禁了我们,可以直接置为False,爬虫将自动跳过该平台
+ is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否开启定时抓取")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -91,19 +100,22 @@ class InfoSource(Base):
# ==========================================
class UnifiedEvent(Base):
"""
- AI 统一事件表
- 核心业务逻辑:比如微博热搜叫“苹果发布会”,知乎热搜叫“iPhone 16 测评”,
- 它们在子表(TrendingEvent)是两条记录,但通过 AI 语义向量对比后,
- 会将它们统一挂载到这个表的一个 UnifiedEvent ID 下,实现跨平台事件聚合。
+ AI 统一事件表 (核心大脑)
+ 逻辑:微博的“苹果发布会”和知乎的“iPhone 16 测评”,通过语义相似度碰撞后,统一归入此表的一行记录中。
"""
__tablename__ = "unified_events"
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- unified_title: Mapped[str] = mapped_column(String(255), comment="AI统一标题")
- ai_comprehensive_summary: Mapped[Optional[str]] = mapped_column(Text, comment="AI全局深度总结")
+ # 经过AI润色去重后的中立、客观的标准大标题
+ unified_title: Mapped[str] = mapped_column(String(255), comment="AI生成的客观统一大标题")
+ # AI阅读子新闻后生成的千字长文摘要,直接用于早报推送
+ ai_comprehensive_summary: Mapped[Optional[str]] = mapped_column(Text, comment="AI综合全网子新闻生成的深度总结")
- center_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="中心向量") # 用于高维空间相似度计算
- hot_score: Mapped[int] = mapped_column(Integer, default=0, comment="聚合热度得分")
+ # [高阶字段] 将文本转化成高维浮点数向量,爬虫抓到新新闻时,跟这个向量算余弦相似度来判断是不是同一个事件
+ center_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="该事件簇的中心语义向量")
+ # 事件热度值:挂载的平台越多、相关评论越多,分数越高,用于首页的热榜排序
+ hot_score: Mapped[int] = mapped_column(Integer, default=0, comment="聚合热度得分(分数越高排名越靠前)")
+ last_summarized_trends_count: Mapped[int] = mapped_column(Integer, default=0, comment="用于判断是否需要重新调用LLM")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -115,51 +127,71 @@ class UnifiedEvent(Base):
class TrendingEvent(Base):
"""
各平台热搜数据明细表
+ 存放从爬虫直接拉取下来的最原始的热搜数据。
"""
__tablename__ = "trending_events"
__table_args__ = (
- # 联合唯一索引:同一个来源(比如微博)的同一条外部ID(MD5)只能存在一条记录,防重插核心保障
+ # 联合唯一索引:同一个来源的同一个哈希只能存一条,完美实现 UPSERT (去重更新)
UniqueConstraint("source_id", "external_id", name="idx_unique_external_trend"),
)
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"))
- unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"))
+ # 关联:这条热搜是从哪个平台(InfoSource)抓来的
+ source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
+ # 关联:它被AI归类到了哪个大事件(UnifiedEvent)之下 (可为空,表示未归类的新鲜事)
+ unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"),
+ comment="所属的聚合大事件ID")
- external_id: Mapped[str] = mapped_column(String(32), comment="32位MD5哈希指纹防重")
- title_embedding: Mapped[Optional[str]] = mapped_column(Text)
+ # 极其核心:将第三方易变的标题/URL,强制压平为32位不变的 MD5 字符串,用作唯一防重指纹
+ external_id: Mapped[str] = mapped_column(String(32), comment="通过平台ID+原始ID生成的32位MD5防重指纹")
+ # 这条特定热搜标题的独立语义向量,用于和 unified_events 做碰撞
+ title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="标题的语义向量")
- icon_url: Mapped[Optional[str]] = mapped_column(String(500))
- current_headline: Mapped[str] = mapped_column(String(255))
- event_url: Mapped[Optional[str]] = mapped_column(String(500))
- app_link: Mapped[Optional[str]] = mapped_column(String(500))
- current_ranking: Mapped[Optional[int]] = mapped_column(Integer)
- brief_snippet: Mapped[Optional[str]] = mapped_column(Text)
+ # 爬虫抓下来的热搜配图、带'爆'或'热'字的角标链接
+ icon_url: Mapped[Optional[str]] = mapped_column(String(500), comment="热榜附带的小图标或配图链接")
+ # 最新标题 (注意:小编随时可能改标题,所以绝不能放入唯一索引)
+ current_headline: Mapped[str] = mapped_column(String(255), comment="当前最新的热搜标题")
+ # 该热点在PC/H5端的访问链接
+ event_url: Mapped[Optional[str]] = mapped_column(String(500), comment="浏览器访问链接")
+ # 该热点专门用于手机App唤醒的 DeepLink (如 sinaweibo://...)
+ app_link: Mapped[Optional[str]] = mapped_column(String(500), comment="移动端App唤醒专属链接")
+ # 本次抓取时,它在平台上的名次 (如 1, 2, 3)
+ current_ranking: Mapped[Optional[int]] = mapped_column(Integer, comment="当前最新排名(可能随时上下浮动)")
+ # 有些平台在热搜底下会配一句话简介
+ brief_snippet: Mapped[Optional[str]] = mapped_column(Text, comment="平台自带的一句话简介或导语")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class NewsArticle(Base):
- """新闻文章明细表 (与 TrendingEvent 类似,但侧重长文本阅读)"""
+ """
+ 新闻文章明细表 (长篇资讯)
+ 与 TrendingEvent 类似,但它主要用来存放 36氪、纽约时报等长篇正文,用于提供深度的阅读素材。
+ """
__tablename__ = "news_articles"
__table_args__ = (
UniqueConstraint("source_id", "external_id", name="idx_unique_external_article"),
)
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"))
- unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"))
+ source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
+ unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"),
+ comment="深度文章也可归入大事件分析")
- external_id: Mapped[str] = mapped_column(String(32))
- title_embedding: Mapped[Optional[str]] = mapped_column(Text)
+ external_id: Mapped[str] = mapped_column(String(32), comment="RSS原文生成的MD5防重指纹")
+ title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="新闻标题/摘要的语义向量")
- cover_image_url: Mapped[Optional[str]] = mapped_column(String(500))
- article_title: Mapped[str] = mapped_column(String(255))
- article_url: Mapped[Optional[str]] = mapped_column(String(500))
- author_name: Mapped[Optional[str]] = mapped_column(String(100))
- original_summary: Mapped[Optional[str]] = mapped_column(Text)
- publish_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
+ # 新闻文章的封面大图,很适合前端做瀑布流展示
+ cover_image_url: Mapped[Optional[str]] = mapped_column(String(500), comment="新闻封面大图链接")
+ article_title: Mapped[str] = mapped_column(String(255), comment="新闻原文标题")
+ article_url: Mapped[Optional[str]] = mapped_column(String(500), comment="新闻原文链接")
+ # 作者或发布机构 (如 "澎湃新闻", "虎嗅作者X")
+ author_name: Mapped[Optional[str]] = mapped_column(String(100), comment="作者或发布机构名称")
+ # RSS原文中附带的长摘要,甚至是完整的 HTML 格式正文
+ original_summary: Mapped[Optional[str]] = mapped_column(Text, comment="原文自带的长摘要或正文片段")
+ # 新闻在平台上的真实发布时间 (可能比我们爬取的时间要早几天)
+ publish_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), comment="新闻实际发布的时间")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -171,33 +203,40 @@ class NewsArticle(Base):
class HeadlineRevision(Base):
"""
标题修订历史表
- 用于记录平台方暗戳戳修改热搜词条的行为(例如公关介入改标题)。
+ 当系统通过哈希发现某条新闻是老熟人,但标题发生了改变时,会自动往这里插一条记录。
+ 常用于公关监测(看看谁半夜偷偷改了标题)。
"""
__tablename__ = "headline_revisions"
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"))
- previous_headline: Mapped[str] = mapped_column(String(255))
- revised_headline: Mapped[str] = mapped_column(String(255))
+ # 属于哪一条被修改的热搜
+ event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"), comment="关联的热搜ID")
+ previous_headline: Mapped[str] = mapped_column(String(255), comment="修改前的旧标题")
+ revised_headline: Mapped[str] = mapped_column(String(255), comment="修改后的新标题")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
+ comment="系统发现被修改的时间")
class RankingLog(Base):
"""
热搜排名时间序列化日志
- 每一次抓取都会生成一条记录,可以用于前端绘制热搜“排名起伏折线图”。
+ 每次爬虫运行(例如每10分钟),都会往这里塞一堆数据,记录某热搜当时的具体名次。
+ 前端可以通过这张表画出非常漂亮的“名次起伏折线图(K线图)”。
"""
__tablename__ = "ranking_logs"
__table_args__ = (
- # 针对时间序列查询优化的复合索引,加速类似 "查询某事件在过去24小时内的排名变化" 的操作
+ # 复合索引,加速 "查询某事件在某段时间内的走势"
Index("idx_event_time", "event_id", "observed_at"),
)
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"))
- ranking_position: Mapped[int] = mapped_column(Integer)
- observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"), comment="关联的热搜ID")
+ # 当时它在第几名
+ ranking_position: Mapped[int] = mapped_column(Integer, comment="当时抓取时的排名名次")
+ # 爬虫看到它的那一瞬间的时间
+ observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
+ comment="观察到该名次的准确时间")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -205,44 +244,49 @@ class RankingLog(Base):
# ==========================================
# 模块五:多态话题与多态评论
# ==========================================
-# 【设计模式】:多态设计
-# 通过 target_type (存表名/类型) + target_id (存主键ID) 的组合,
-# 让这两个表既能挂载在"单一热搜"下,也能挂载在"新闻文章"下,甚至挂在"统一大事件"下,避免了建立无数个外键的冗余。
-
class ExtractedTopic(Base):
- """AI 提取的核心话题标签表"""
+ """
+ AI 提取的核心话题标签表
+ 设计模式(多态):一条标签("AI")既能打在大事件上,也能打在单篇文章上。
+ """
__tablename__ = "extracted_topics"
__table_args__ = (
Index("idx_topic_keyword", "topic_keyword"),
- # 多态查询索引,加速 target_type + target_id 的组合查询
Index("idx_polymorphic_topics", "target_type", "target_id"),
)
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- target_type: Mapped[TargetType] = mapped_column(Enum(TargetType))
- target_id: Mapped[int] = mapped_column(BigIntType)
- topic_keyword: Mapped[str] = mapped_column(String(100))
- relevance_score: Mapped[Optional[float]] = mapped_column(Float)
+ target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="挂载目标的类型(大事件/热点/文章)")
+ target_id: Mapped[int] = mapped_column(BigIntType, comment="对应的具体主键ID")
+ # 提取出的标签词,例如 "自动驾驶", "马斯克"
+ topic_keyword: Mapped[str] = mapped_column(String(100), comment="提取出的核心关键词汇")
+ # AI 认为这个词和这篇文章的相关程度(0~100),方便以后做精准度过滤
+ relevance_score: Mapped[Optional[float]] = mapped_column(Float, comment="AI计算的相关度得分")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class DiscussionComment(Base):
- """全平台统一评论表"""
+ """
+ 全平台统一评论库
+ 不论是微博网友的短评,还是新闻网站的长评,全部扔进这张多态表。
+ """
__tablename__ = "discussion_comments"
__table_args__ = (
Index("idx_polymorphic_comments", "target_type", "target_id"),
)
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- target_type: Mapped[TargetType] = mapped_column(Enum(TargetType))
- target_id: Mapped[int] = mapped_column(BigIntType)
+ target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="被评论内容的类型")
+ target_id: Mapped[int] = mapped_column(BigIntType, comment="被评论内容的主键ID")
- commenter_name: Mapped[Optional[str]] = mapped_column(String(100))
- comment_content: Mapped[str] = mapped_column(Text)
- likes_count: Mapped[int] = mapped_column(Integer, default=0)
- external_comment_id: Mapped[Optional[str]] = mapped_column(String(32))
- comment_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
+ commenter_name: Mapped[Optional[str]] = mapped_column(String(100), comment="发评人昵称")
+ comment_content: Mapped[str] = mapped_column(Text, comment="评论正文内容")
+ # 这条评论本身获得的点赞数,可用于筛选出“神评论”一并推送给用户
+ likes_count: Mapped[int] = mapped_column(Integer, default=0, comment="评论被点赞的数量")
+ # 防复抓:用第三方平台原生评论ID做的MD5哈希
+ external_comment_id: Mapped[Optional[str]] = mapped_column(String(32), comment="第三方评论ID的MD5指纹")
+ comment_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), comment="评论实际发布时间")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -251,29 +295,53 @@ class DiscussionComment(Base):
# 模块六:用户画像与多渠道高可用推送系统
# ==========================================
class AppUser(Base):
- """系统核心用户表"""
+ """
+ 系统核心用户表
+ 不仅存放密码,还包含用户的基础画像,供大模型做个性化阅读推荐。
+ """
__tablename__ = "app_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- email: Mapped[str] = mapped_column(String(150), unique=True, index=True)
- password_hash: Mapped[Optional[str]] = mapped_column(String(255))
+ email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱")
+ password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)")
- nickname: Mapped[Optional[str]] = mapped_column(String(100))
- avatar_url: Mapped[Optional[str]] = mapped_column(String(500))
- gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN)
+ nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称")
+ avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址")
+ 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扩展字段: 存放灵活多变的前端用户偏好设置")
- timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai")
+ # 时区对于定时推送系统极其重要!保证纽约的用户和北京的用户都能在早晨8点收到新闻。
+ timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
+
+
+class EmailVerificationCode(Base):
+ __tablename__ = "email_verification_codes"
+ __table_args__ = (
+ Index("idx_email_code_lookup", "email", "purpose", "is_used", "expires_at"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ email: Mapped[str] = mapped_column(String(150), index=True, nullable=False)
+ purpose: Mapped[VerificationPurpose] = mapped_column(Enum(VerificationPurpose), nullable=False)
+ code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+ is_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class UserPushEndpoint(Base):
"""
- 多渠道推送端点配置表
- 一个用户可能绑定了邮箱(EMAIL)和微信(WECHAT),支持配置降级重试(priority_level)。
+ 多渠道推送端点配置表 (高可用解耦设计)
+ 一个用户可以配置好几个推送渠道(邮箱、微信、钉钉),
+ 万一主渠道今天报错了,系统会自动按优先级(priority)降级寻找备用渠道重发。
"""
__tablename__ = "user_push_endpoints"
__table_args__ = (
@@ -281,63 +349,76 @@ class UserPushEndpoint(Base):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
- channel_type: Mapped[str] = mapped_column(String(50), comment="如 EMAIL, WECHAT")
- channel_account: Mapped[str] = mapped_column(String(255))
- is_active: Mapped[bool] = mapped_column(Boolean, default=True)
- priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="1最高,降级重试")
+ 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_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)")
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道")
+ # 高可用容灾:比如 1 代表必须先发微信,如果报错了,再去找 priority=2 的邮箱补发
+ priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class UserTopicPreference(Base):
- """用户订阅的兴趣标签库"""
+ """
+ 用户订阅的兴趣标签库
+ 当这里的标签和 ExtractedTopic 表里的标签匹配上时,就会触发相关新闻的推送。
+ """
__tablename__ = "user_topic_preferences"
__table_args__ = (
+ # 联合防抖限制:防止用户在界面卡顿时连点两次,订阅了两个同样的词
UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
- interested_keyword: Mapped[str] = mapped_column(String(100))
+ user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
+ interested_keyword: Mapped[str] = mapped_column(String(100), comment="用户填写的感兴趣标签(如'马斯克')")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class UserDeliverySchedule(Base):
- """用户勿扰/定时推送时间表"""
+ """
+ 用户专属的定时推送时间表
+ 如果用户设定了早上 08:30,后台的定时任务就会在每天 08:30 精准地把匹配到的聚合新闻发出去。
+ """
__tablename__ = "user_delivery_schedules"
__table_args__ = (
UniqueConstraint("user_id", "delivery_time", name="idx_unique_schedule"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
- delivery_time: Mapped[time] = mapped_column(Time, comment="如 08:30:00")
- is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
+ delivery_time: Mapped[time] = mapped_column(Time, comment="每天期望收到推送的具体时间")
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用此时段")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class DeliveryHistory(Base):
"""
- 推送历史防刷表
- 核心用途:一旦给某个用户推送过某条新闻/事件,就记录在这里。
- 下次再触发推荐时,检查这个表,防止给同一个用户反复发送相同的内容。
+ 推送历史防刷表 (推送系统的绝对底线)
+ 核心业务逻辑:一旦大事件或者文章通过某个渠道成功发给用户,就记一笔帐。
+ 明天如果这条旧新闻还在热搜上,系统查一下这个表,发现发过了,直接抛弃,绝不轰炸用户。
"""
__tablename__ = "delivery_history"
__table_args__ = (
+ # 终极去重约束:一个用户,针对同一篇新闻,永远只允许存在一条记录
UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"),
)
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
- target_type: Mapped[TargetType] = mapped_column(Enum(TargetType))
- target_id: Mapped[int] = mapped_column(BigIntType)
- status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus))
+ user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户")
+ target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型")
+ target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID")
+ # 记录这次推送是彻底成功了,还是由于渠道网络问题失败了
+ status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
+ comment="记录或实际推送的准确时间")
# ==========================================
@@ -345,15 +426,17 @@ class DeliveryHistory(Base):
# ==========================================
class DataSyncTask(Base):
"""
- 数据同步健康度监控表
- 这就是爬虫脚本每次运行都要写入记录的地方,用于后台 Dashboard 监控爬虫健康状态和错误堆栈。
+ 数据同步健康度监控表 (运维巡检专用)
+ 爬虫每跑完一个平台的轮询,就在这里打卡上报。
+ 方便后台画出爬虫成功率的饼图,一旦 error_trace 堆积,能迅速报警排查。
"""
__tablename__ = "data_sync_tasks"
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
- source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"))
- items_fetched: Mapped[int] = mapped_column(Integer, default=0)
- task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus))
- error_trace: Mapped[Optional[str]] = mapped_column(Text)
+ source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源")
+ items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数")
+ task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态")
+ # 如果代码意外崩溃、或是遭遇403/502,把 Python的 traceback 堆栈原封不动存进这里
+ error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间")
diff --git a/backend/app/prompts/summary_prompts.py b/backend/app/prompts/summary_prompts.py
new file mode 100644
index 0000000..e69d1f5
--- /dev/null
+++ b/backend/app/prompts/summary_prompts.py
@@ -0,0 +1,14 @@
+SUMMARY_SYSTEM_PROMPT = "你是一个输出严格 JSON 格式的后台引擎。"
+
+SUMMARY_USER_PROMPT_TEMPLATE = """
+你是一个专业的新闻聚合编辑。请根据以下同一个大事件在不同平台的热搜标题,
+为该事件生成一个客观、吸睛的【统一大标题】,以及一段【多平台视角的综合摘要】。
+
+要求:
+1. 摘要结构类似:"该事件在多平台发酵。微博侧重讨论...,知乎硬核解析...,科技媒体关注..."。
+2. 提炼出各平台的讨论侧重点,不要简单罗列标题。
+3. 必须以严格的 JSON 格式返回,只包含 "unified_title" 和 "ai_comprehensive_summary" 两个字段,不要有多余的说明。
+
+各平台热搜标题数据:
+{platform_data_text}
+"""
diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py
new file mode 100644
index 0000000..5a5417d
--- /dev/null
+++ b/backend/app/schemas/auth_schema.py
@@ -0,0 +1,56 @@
+from datetime import datetime
+from typing import Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.models.models import GenderType
+
+
+EMAIL_PATTERN = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
+
+
+class RegisterCodeSendRequest(BaseModel):
+ email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
+
+
+class LoginCodeSendRequest(BaseModel):
+ email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
+
+
+class RegisterRequest(BaseModel):
+ email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
+ password: str = Field(..., min_length=8, max_length=128)
+ verification_code: str = Field(..., pattern=r"^\d{6}$")
+ nickname: Optional[str] = Field(default=None, max_length=100)
+
+
+class LoginRequest(BaseModel):
+ email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
+ password: str = Field(..., min_length=8, max_length=128)
+
+
+class LoginWithCodeRequest(BaseModel):
+ email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
+ verification_code: str = Field(..., pattern=r"^\d{6}$")
+
+
+class UserProfileResponse(BaseModel):
+ id: int
+ email: str
+ nickname: Optional[str]
+ avatar_url: Optional[str]
+ gender: GenderType
+ created_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class AuthTokenResponse(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+ expires_in: int
+ user: UserProfileResponse
+
+
+class MessageResponse(BaseModel):
+ message: str
diff --git a/backend/app/schemas/event_schema.py b/backend/app/schemas/event_schema.py
new file mode 100644
index 0000000..2a48517
--- /dev/null
+++ b/backend/app/schemas/event_schema.py
@@ -0,0 +1,23 @@
+# app/schemas/event_schema.py
+from pydantic import BaseModel
+from typing import List, Optional
+from datetime import datetime
+
+
+class PlatformTrendResponse(BaseModel):
+ source_id: int
+ platform_name: str # 平台名称,如 "微博热搜"
+ headline: str # 平台对应的具体热搜标题
+ url: Optional[str] # 跳转链接
+ current_ranking: Optional[int] # 当前排名
+ ranking_history: List[int] # 排名历史轨迹,如 [50, 45, 20, 5, 1],供 ApexCharts 渲染
+
+
+class UnifiedEventResponse(BaseModel):
+ event_id: int
+ unified_title: str # AI 生成的统一大标题
+ summary: Optional[str] # AI 生成的摘要
+ hot_score: int # 总热度值
+ created_at: datetime # 事件发现时间
+ platforms: List[PlatformTrendResponse] # 挂载的各个平台子热搜
+ # tags: List[str] = [] # 如果后续打通了 ExtractedTopic,可以在这里返回标签
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/source_schema.py
similarity index 98%
rename from backend/app/schemas/schemas.py
rename to backend/app/schemas/source_schema.py
index 98e9115..621c7cf 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/source_schema.py
@@ -1,4 +1,4 @@
-# app/schemas/schemas.py
+# app/schemas/source_schema.py
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional
from datetime import datetime
diff --git a/backend/app/services/fetcher_service.py b/backend/app/services/fetcher_service.py
index a6b7423..b41a15e 100644
--- a/backend/app/services/fetcher_service.py
+++ b/backend/app/services/fetcher_service.py
@@ -1,62 +1,222 @@
# app/services/fetcher_service.py
import os
import hashlib
-import httpx
-from dotenv import load_dotenv
+from datetime import timedelta
+import httpx
+import json
+import numpy as np
+from dotenv import load_dotenv
+from sklearn.metrics.pairwise import cosine_similarity
+from sentence_transformers import SentenceTransformer
from app.database import SessionLocal
from app.models.models import (
InfoSource, TrendingEvent, NewsArticle, DataSyncTask, TaskStatus,
- HeadlineRevision, RankingLog, SourceType, utcnow
+ HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent
)
-# ==========================================
-# 环境变量与全局配置
-# ==========================================
# 加载环境变量
load_dotenv()
-
-# 从环境变量获取 API 基础地址,如果没有配置则提供默认回退地址
+hf_token = os.getenv("HF_TOKEN")
+SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", 0.72))
API_BASE_URL = os.getenv("API_BASE_URL", "https://newsnow.busiyi.world/api/s")
+EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "")
+
+print("正在加载 BAAI/bge-m3 向量模型...")
+# 全局单例
+embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True, device="cuda")
+print("模型加载完成。")
def generate_md5(text: str) -> str:
- """
- 生成32位MD5哈希值
- 维护说明:
- 各个平台(微博、知乎、微信等)返回的原始 ID 格式千奇百怪(有长数字、有UUID、有URL)。
- 为了方便数据库建立统一的高性能唯一索引(UniqueConstraint),
- 我们统一将其转为长度固定的 32 位 MD5 字符串作为 external_id。
- """
+ """生成32位MD5哈希值作为全局唯一指纹"""
return hashlib.md5(text.encode('utf-8')).hexdigest()
+def generate_embedding_json(text: str) -> str:
+ """辅助函数:调用大模型生成向量,并序列化为 JSON 字符串"""
+ raw_vec = embedder_model.encode([text], normalize_embeddings=True)[0]
+ truncated_vec = [round(float(x), 5) for x in raw_vec]
+ return json.dumps(truncated_vec, separators=(',', ':'))
+
+
+def match_or_create_unified_event(db, title: str, embedding_json: str) -> int:
+ """
+ 辅助函数:大事件聚类中枢。
+ 拿着新计算的向量去数据库里碰,碰到了就返回老 ID,碰不到就建新的。
+ """
+ # 提取刚算出来的向量
+ new_vec = np.array(json.loads(embedding_json))
+
+ # 只取最近 3 天的活跃大事件进行比对
+ three_days_ago = utcnow() - timedelta(days=3)
+ recent_events = db.query(UnifiedEvent).filter(
+ UnifiedEvent.created_at >= three_days_ago
+ ).order_by(UnifiedEvent.created_at.desc()).limit(200).all()
+
+ if recent_events:
+ valid_events = [ev for ev in recent_events if ev.center_embedding]
+ if valid_events:
+ event_vectors = [json.loads(ev.center_embedding) for ev in valid_events]
+
+ # 批量矩阵计算相似度
+ sim_scores = cosine_similarity([new_vec], event_vectors)[0]
+ max_idx = np.argmax(sim_scores)
+
+ if sim_scores[max_idx] >= SIMILARITY_THRESHOLD:
+ matched_event = valid_events[max_idx]
+
+ matched_event.hot_score += 1
+ return matched_event.id
+
+ # 没匹配到,创建一个新的统一大事件
+ new_unified = UnifiedEvent(
+ unified_title=title,
+ center_embedding=embedding_json,
+ hot_score=1 # 初始热度
+ )
+ db.add(new_unified)
+ db.flush() # 获取自增的主键 ID
+ return new_unified.id
+
+
+def process_hot_trend_item(db, source, item, index: int, external_id: str):
+ """
+ 处理【热搜/短新闻】的业务逻辑,现已加入 AI 聚类功能
+ """
+ title = item.get("title")
+ item_url = item.get("url", "")
+
+ existing_event = db.query(TrendingEvent).filter(
+ TrendingEvent.source_id == source.id,
+ TrendingEvent.external_id == external_id
+ ).first()
+
+ event_to_log = None
+
+ # 核心逻辑:查重后再决定是否调用模型
+ if existing_event:
+ # 场景 A1:老熟人
+ if existing_event.current_headline != title:
+ # 标题被暗改,此时需要重新算一次 Embedding
+ new_embedding_json = generate_embedding_json(title)
+
+ revision = HeadlineRevision(
+ event_id=existing_event.id,
+ previous_headline=existing_event.current_headline,
+ revised_headline=title
+ )
+ db.add(revision)
+ existing_event.current_headline = title
+ existing_event.title_embedding = new_embedding_json # 更新为新标题的语义向量
+ # 注:这里不改变它所属的 unified_event_id,因为大体还是同一件事
+
+ existing_event.current_ranking = index
+ existing_event.event_url = item_url
+ event_to_log = existing_event
+
+ else:
+ # 场景 A2:这是一条彻底的全新热搜
+ # 1. 计算向量
+ new_embedding_json = generate_embedding_json(title)
+
+ # 2. 扔进聚类中枢找归宿
+ matched_event_id = match_or_create_unified_event(db, title, new_embedding_json)
+
+ # 3. 落库
+ new_event = TrendingEvent(
+ source_id=source.id,
+ external_id=external_id,
+ current_headline=title,
+ event_url=item_url,
+ current_ranking=index,
+ title_embedding=new_embedding_json, # 存入向量
+ unified_event_id=matched_event_id # 挂载到大事件下
+ )
+ db.add(new_event)
+ db.flush()
+ event_to_log = new_event
+
+ # 强制记录排名轨迹
+ rank_log = RankingLog(
+ event_id=event_to_log.id,
+ ranking_position=index
+ )
+ db.add(rank_log)
+
+
+def process_rss_feed_item(db, source, item, external_id: str):
+ """
+ 处理【长文章/传统订阅】分支的核心业务逻辑 (写入 NewsArticle 表)
+ """
+ title = item.get("title")
+ item_url = item.get("url", "")
+
+ existing_article = db.query(NewsArticle).filter(
+ NewsArticle.source_id == source.id,
+ NewsArticle.external_id == external_id
+ ).first()
+
+ if existing_article:
+ # 文章若存在,仅更新基础字段
+ existing_article.article_title = title
+ existing_article.article_url = item_url
+ else:
+ # 全新文章入库
+ new_article = NewsArticle(
+ source_id=source.id,
+ external_id=external_id,
+ article_title=title,
+ article_url=item_url,
+ )
+ db.add(new_article)
+
+
+def process_source_data(db, source, items: list) -> int:
+ """
+ 数据清洗与路由分发层:
+ 遍历 API 返回的 items,生成唯一指纹,并路由到不同的处理模块。
+ 返回成功处理的条目数量。
+ """
+ saved_count = 0
+ platform_id = source.home_url
+
+ for index, item in enumerate(items, 1):
+ title = item.get("title")
+ if not title:
+ continue
+
+ item_url = item.get("url", "")
+
+ # ID 兜底策略:接口ID -> URL -> Title
+ raw_id = item.get("id") or item_url or title
+ external_id = generate_md5(f"{platform_id}_{raw_id}")
+
+ # 核心路由分流
+ if source.source_type in (SourceType.HOT_TREND, SourceType.API):
+ process_hot_trend_item(db, source, item, index, external_id)
+ elif source.source_type == SourceType.RSS_FEED:
+ process_rss_feed_item(db, source, item, external_id)
+
+ saved_count += 1
+
+ return saved_count
+
+
async def fetch_and_save_trending_data():
"""
- 核心定时任务:从数据库读取信息源 -> 抓取API -> 解析 -> 根据业务类型分流存入对应的数据库表
-
- 执行流程:
- 1. 查询所有配置为“已启用”的信息源 (is_enabled == True)。
- 2. 伪装 HTTP 请求头,规避目标服务器的反爬机制。
- 3. 遍历解析数据,生成 MD5 唯一指纹进行全局去重。
- 4. 核心路由分流:
- - 若源为 HOT_TREND/API,按热搜逻辑处理(记录名次轨迹、标题变更)。
- - 若源为 RSS_FEED,按长文章逻辑处理(忽略名次,直接落库)。
- 5. 严格的事务管理:成功则统一提交,报错则回滚业务数据并独立提交错误日志。
+ 调度层:负责网络请求、数据库事务管理和异常监控隔离。
"""
print(f"[{utcnow()}] 开始执行定时抓取任务...")
- # 使用上下文管理器确保数据库连接池正确获取和归还连接
with SessionLocal() as db:
- # 1. 动态获取抓取源。
- # 优势:在后台修改数据库的信息源开关,下一次定时任务立刻生效,无需重启服务。
+ # 获取启用的信息源
sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all()
if not sources:
print("没有找到启用的信息源,任务结束。")
return
- # 2. 伪装成真实的浏览器 HTTP 请求头
- # 维护注意:如果抓取接口返回 403 Forbidden,通常是这里的反爬策略失效了,需要更新 User-Agent 或 Cookie
+ # 伪装请求头,规避反爬
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",
"Accept": "application/json, text/plain, */*",
@@ -64,157 +224,40 @@ async def fetch_and_save_trending_data():
"Origin": "https://newsnow.busiyi.world"
}
- # 复用异步 HTTP 客户端,比每次循环新建 Client 性能更高
async with httpx.AsyncClient(timeout=15.0, headers=custom_headers) as client:
for source in sources:
- # platform_id 对应第三方接口的入参标识,如 "weibo", "zhihu" 等
platform_id = source.home_url
if not platform_id:
continue
- # ==========================================
- # 【技术债预警 / TODO】
- # 目前无论 source_type 是什么,都统一请求了这个 JSON API。
- # 未来如果加入了真正的外部 RSS 订阅源(返回的是 XML 格式),
- # 这里需要增加判断逻辑:如果是 RSS_FEED,应当使用 feedparser 库去解析 XML,而不是用 httpx 获取 JSON。
- # ==========================================
url = f"{API_BASE_URL}?id={platform_id}&latest"
- # 初始化本次特定信息源抓取任务的系统监控日志
+ # 初始化监控日志
task_log = DataSyncTask(source_id=source.id, items_fetched=0)
try:
- # 发起请求并校验 HTTP 状态码 (非 2xx 会抛出异常进入 except 块)
+ # 发起网络请求
response = await client.get(url)
response.raise_for_status()
data_json = response.json()
-
items = data_json.get("items", [])
- saved_count = 0
- for index, item in enumerate(items, 1):
- title = item.get("title")
- if not title:
- continue
+ # 调用数据处理层
+ saved_count = process_source_data(db, source, items)
- item_url = item.get("url", "")
-
- # 3. ID 兜底与去重策略
- # 优先用接口自带的 ID -> 没有则用 URL 代替 -> 最差情况用 title 兜底
- raw_id = item.get("id") or item_url or title
- # 组合“平台标识+原始ID”算出全局唯一的 MD5 外部标识
- external_id = generate_md5(f"{platform_id}_{raw_id}")
-
- # ==========================================
- # 4. 核心数据分流路由
- # 根据信息源的业务类型,将数据推入不同的物理表
- # ==========================================
-
- if source.source_type in (SourceType.HOT_TREND, SourceType.API):
- # --------------------------------------------------
- # 分支 A:热搜/短新闻逻辑 -> 写入 TrendingEvent 表
- # --------------------------------------------------
- existing_event = db.query(TrendingEvent).filter(
- TrendingEvent.source_id == source.id,
- TrendingEvent.external_id == external_id
- ).first()
-
- event_to_log = None # 临时指针,用于后续绑定排名轨迹
-
- if existing_event:
- # 场景 A1:该热搜已经在数据库中
-
- # 监控并记录“标题暗改”(常见于热搜公关介入)
- if existing_event.current_headline != title:
- revision = HeadlineRevision(
- event_id=existing_event.id,
- previous_headline=existing_event.current_headline,
- revised_headline=title
- )
- db.add(revision)
- existing_event.current_headline = title # 覆盖为主表最新标题
-
- # 更新当前的实时排名和 URL
- existing_event.current_ranking = index
- existing_event.event_url = item_url
- event_to_log = existing_event
- else:
- # 场景 A2:发现全新热搜
- new_event = TrendingEvent(
- source_id=source.id,
- external_id=external_id,
- current_headline=title,
- event_url=item_url,
- current_ranking=index,
- )
- db.add(new_event)
- # db.flush() 是关键:它将数据推给数据库生成了自增的主键 ID,但尚未最终 commit。
- # 拿到合法的 event_to_log.id
- db.flush()
- event_to_log = new_event
-
- # 排名轨迹强制记录
- # 只要抓到了热搜(无论新旧),必须打点记录当前名次,用于前端绘制排名趋势图
- rank_log = RankingLog(
- event_id=event_to_log.id,
- ranking_position=index
- )
- db.add(rank_log)
-
- elif source.source_type == SourceType.RSS_FEED:
- # --------------------------------------------------
- # 分支 B:长文章/传统订阅逻辑 -> 写入 NewsArticle 表
- # --------------------------------------------------
- existing_article = db.query(NewsArticle).filter(
- NewsArticle.source_id == source.id,
- NewsArticle.external_id == external_id
- ).first()
-
- if existing_article:
- # 文章如果已存在,通常只需要更新基础字段(文章一般不涉及排名起伏)
- existing_article.article_title = title
- existing_article.article_url = item_url
- # 预留位置:如果以后接口返回了摘要,可以在这里 update existing_article.original_summary
- else:
- # 全新文章入库
- new_article = NewsArticle(
- source_id=source.id,
- external_id=external_id,
- article_title=title,
- article_url=item_url,
- # original_summary=item.get("desc", ""),
- # author_name=item.get("author", "")
- )
- db.add(new_article)
-
-
-
- saved_count += 1
-
- # --------------------------------------------------
- # 5. 业务事务成功提交
- # --------------------------------------------------
- # 只有当前平台(source)的所有 item 都顺畅走完,才标记成功
+ # 业务事务成功提交
task_log.items_fetched = saved_count
task_log.task_status = TaskStatus.SUCCESS
db.add(task_log)
-
- # 统一将当前信息源爬取的所有业务数据持久化到硬盘
db.commit()
print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据")
except Exception as e:
- # --------------------------------------------------
- # 6. 异常拦截与错误隔离机制
- # --------------------------------------------------
- # 回滚本次抓取的全部脏数据,
+ # 异常拦截与错误隔离
db.rollback()
- # 错误日志记下来
task_log.task_status = TaskStatus.ERROR
task_log.error_trace = str(e)
db.add(task_log)
-
-
db.commit()
- print(f"[{source.source_name}] 抓取失败: {e}")
\ No newline at end of file
+ print(f"[{source.source_name}] 抓取失败: {e}")
diff --git a/backend/app/services/summary_service.py b/backend/app/services/summary_service.py
new file mode 100644
index 0000000..f8d5ef0
--- /dev/null
+++ b/backend/app/services/summary_service.py
@@ -0,0 +1,104 @@
+# app/services/summary_service.py
+import os
+import json
+from datetime import timedelta
+from openai import AsyncOpenAI
+
+from app.database import SessionLocal
+from app.models.models import UnifiedEvent, TrendingEvent, InfoSource, utcnow
+from app.prompts.summary_prompts import (
+ SUMMARY_SYSTEM_PROMPT,
+ SUMMARY_USER_PROMPT_TEMPLATE,
+)
+
+HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
+AI_API_KEY = os.getenv("AI_API_KEY", '')
+
+# 1. 初始化异步客户端 (全局复用)
+deepseek_client = AsyncOpenAI(
+ api_key=AI_API_KEY,
+ base_url="https://api.deepseek.com"
+)
+
+
+async def call_llm_for_summary(platform_data_text: str) -> dict:
+ """调用 DeepSeek 生成统一标题和多平台视角摘要"""
+ prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(
+ platform_data_text=platform_data_text
+ )
+
+ # await
+ response = await deepseek_client.chat.completions.create(
+ model="deepseek-chat",
+ messages=[
+ {"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
+ {"role": "user", "content": prompt}
+ ],
+ response_format={"type": "json_object"},
+ temperature=1
+ )
+
+ result_text = response.choices[0].message.content
+ return json.loads(result_text)
+
+
+async def generate_unified_summaries():
+ """定时任务:扫描高热度事件并生成/更新摘要"""
+ print(f"[{utcnow()}] 开始执行 DeepSeek 摘要生成任务...")
+
+ with SessionLocal() as db:
+ recent_threshold = utcnow() - timedelta(days=3)
+
+ # 必须满足:热度达标 AND (当前热度 > 上次生成摘要时的热度) AND 近期活跃
+ events = db.query(UnifiedEvent).filter(
+ UnifiedEvent.hot_score >= HOT_SCORE_THRESHOLD,
+ UnifiedEvent.hot_score > UnifiedEvent.last_summarized_trends_count,
+ UnifiedEvent.created_at >= recent_threshold
+ ).all()
+
+ if not events:
+ print("当前没有需要更新摘要的大事件,任务结束。")
+ return
+
+ for event in events:
+ # 联合查询获取该事件在各平台的子新闻
+ trends = db.query(TrendingEvent, InfoSource.source_name) \
+ .join(InfoSource, TrendingEvent.source_id == InfoSource.id) \
+ .filter(TrendingEvent.unified_event_id == event.id) \
+ .all()
+
+ if not trends:
+ continue
+
+ # 按平台归类标题并去重
+ platform_dict = {}
+ for trend_record, source_name in trends:
+ if source_name not in platform_dict:
+ platform_dict[source_name] = set()
+ platform_dict[source_name].add(trend_record.current_headline)
+
+ # 组装给大模型的 Prompt 数据
+ prompt_lines = [f"【{platform}】: {', '.join(headlines)}" for platform, headlines in platform_dict.items()]
+ platform_data_text = "\n".join(prompt_lines)
+
+ try:
+ # 调用封装好的异步函数
+ llm_result = await call_llm_for_summary(platform_data_text)
+
+ if "unified_title" in llm_result:
+ event.unified_title = llm_result["unified_title"]
+ if "ai_comprehensive_summary" in llm_result:
+ event.ai_comprehensive_summary = llm_result["ai_comprehensive_summary"]
+
+ # 成功后更新水位线
+ # 将最后一次总结时的热搜数量,更新为当前最新的 hot_score
+ event.last_summarized_trends_count = event.hot_score
+
+ print(f"成功更新大事件 ID {event.id} 的深度摘要 (当前热度: {event.hot_score})。")
+
+ except Exception as e:
+ print(f"大事件 ID {event.id} 摘要生成失败: {e}")
+ continue
+
+ # 提交事务
+ db.commit()
diff --git a/backend/app/crud/__init__.py b/backend/app/utils/__init__.py
similarity index 100%
rename from backend/app/crud/__init__.py
rename to backend/app/utils/__init__.py
diff --git a/backend/app/utils/email_utils.py b/backend/app/utils/email_utils.py
new file mode 100644
index 0000000..5f22ea0
--- /dev/null
+++ b/backend/app/utils/email_utils.py
@@ -0,0 +1,49 @@
+# app/utils/email_utils.py
+import os
+from email.message import EmailMessage
+import aiosmtplib
+from dotenv import load_dotenv
+import asyncio
+
+load_dotenv()
+
+SMTP_HOST = os.getenv("SMTP_HOST", "smtp.qiye.aliyun.com")
+SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
+SMTP_USER = os.getenv("SMTP_USER", "noreply@yourdomain.com")
+SMTP_PASS = os.getenv("SMTP_PASS", "your_password")
+
+
+async def send_html_email(
+ to_email: str,
+ subject: str,
+ html_content: str,
+ sender_name: str = "AI 新闻早报",
+ sender_email: str = None
+) -> bool:
+ """底层纯异步发送邮件工具"""
+ # 如果未指定发送者邮箱,默认使用环境配置中的认证邮箱
+ if sender_email is None:
+ sender_email = SMTP_USER
+
+ message = EmailMessage()
+ # 动态拼接 From 字段
+ message["From"] = f"{sender_name} <{sender_email}>"
+ message["To"] = to_email
+ message["Subject"] = subject
+
+ # 设定内容为 HTML
+ message.set_content(html_content, subtype="html")
+
+ try:
+ await aiosmtplib.send(
+ message,
+ hostname=SMTP_HOST,
+ port=SMTP_PORT,
+ username=SMTP_USER,
+ password=SMTP_PASS,
+ use_tls=True,
+ )
+ return True
+ except Exception as e:
+ print(f"邮件发送至 {to_email} 失败: {str(e)}")
+ return False
diff --git a/backend/run.py b/backend/run.py
index b22127e..868c2d2 100644
--- a/backend/run.py
+++ b/backend/run.py
@@ -2,11 +2,11 @@
import uvicorn
if __name__ == "__main__":
- # 调用 uvicorn.run() 启动服务
+ # 启动服务
uvicorn.run(
app="app.main:app",
host="0.0.0.0",
port=8000,
- reload=True,
+ # reload=True,
workers=1
)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 7905b05..174064d 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,85 +1,24 @@
-
-
-
-
-
-
-
-
-
- Home
- About
-
-
-
-
-
+
+
+
+
+
-
diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css
index 36fb845..239310c 100644
--- a/frontend/src/assets/main.css
+++ b/frontend/src/assets/main.css
@@ -1,35 +1,240 @@
-@import './base.css';
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
+
+/* =========================================
+ 1. 现代 SaaS 风格主题变量
+========================================= */
+:root {
+ /* 明亮模式 - 极简白与浅灰 */
+ --bg-base: #f8fafc;
+ --bg-surface: #ffffff;
+ --bg-input: #f1f5f9;
+
+ --border-subtle: #e2e8f0;
+ --border-strong: #cbd5e1;
+
+ --text-primary: #0f172a;
+ --text-secondary: #64748b;
+ --text-placeholder: #94a3b8;
+
+ --brand-primary: #4f46e5;
+ --brand-primary-hover: #4338ca;
+ --brand-primary-alpha: rgba(79, 70, 229, 0.1);
+
+ --status-error: #ef4444;
+ --status-success: #10b981;
+
+ /* 现代柔和扩散阴影 */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
+
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+}
+
+html.dark {
+ /* 暗黑模式 - 深邃黑与暗石板色 */
+ --bg-base: #020617;
+ --bg-surface: #0f172a;
+ --bg-input: #1e293b;
+
+ --border-subtle: #1e293b;
+ --border-strong: #334155;
+
+ --text-primary: #f8fafc;
+ --text-secondary: #94a3b8;
+ --text-placeholder: #475569;
+
+ --brand-primary: #6366f1;
+ --brand-primary-hover: #818cf8;
+ --brand-primary-alpha: rgba(99, 102, 241, 0.15);
+
+ --status-error: #f87171;
+
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
+}
+
+/* =========================================
+ 2. 全局重置与排版基准
+========================================= */
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+ font-family: 'Inter', 'Noto Sans SC', sans-serif;
+ background-color: var(--bg-base);
+ color: var(--text-primary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
#app {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- font-weight: normal;
+ min-height: 100vh;
+ background: transparent;
+ position: relative;
+ z-index: 1;
}
-a,
-.green {
+a {
+ color: inherit;
text-decoration: none;
- color: hsla(160, 100%, 37%, 1);
- transition: 0.4s;
- padding: 3px;
}
-@media (hover: hover) {
- a:hover {
- background-color: hsla(160, 100%, 37%, 0.2);
+button {
+ cursor: pointer;
+ border: none;
+ background: none;
+ font-family: inherit;
+}
+
+/* =========================================
+ 高级背景环境光与数据网格
+========================================= */
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: -2;
+ /* 绘制点阵网格 */
+ background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
+ background-size: 24px 24px;
+ /* 使用遮罩让网格在四周自然淡出,避免边缘生硬 */
+ mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
+ -webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
+ opacity: 0.4;
+ pointer-events: none;
+}
+
+html.dark body::before {
+ opacity: 0.15;
+}
+
+/* 极弱的雷达扫射呼吸环境光 */
+body::after {
+ content: '';
+ position: fixed;
+ top: -50%;
+ left: -20%;
+ right: -20%;
+ height: 100vh;
+ z-index: -3;
+ background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 60%);
+ opacity: 0.6;
+ pointer-events: none;
+ animation: ambient-pulse 8s ease-in-out infinite alternate;
+}
+
+@keyframes ambient-pulse {
+ 0% {
+ transform: scale(1);
+ opacity: 0.4;
+ }
+ 100% {
+ transform: scale(1.05);
+ opacity: 0.7;
}
}
-@media (min-width: 1024px) {
- body {
- display: flex;
- place-items: center;
- }
-
- #app {
- display: grid;
- grid-template-columns: 1fr 1fr;
- padding: 0 2rem;
- }
+/* =========================================
+ 3. 现代表单控件体系
+========================================= */
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.input-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.input-field {
+ width: 100%;
+ padding: 12px 14px;
+ background-color: var(--bg-input);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-size: 15px;
+ transition: all 0.2s ease;
+}
+
+.input-field::placeholder {
+ color: var(--text-placeholder);
+}
+
+.input-field:hover {
+ border-color: var(--border-strong);
+}
+
+.input-field:focus {
+ outline: none;
+ border-color: var(--brand-primary);
+ box-shadow: 0 0 0 3px var(--brand-primary-alpha);
+ background-color: var(--bg-surface);
+}
+
+.input-action-btn {
+ position: absolute;
+ right: 12px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--brand-primary);
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+
+.input-action-btn:hover:not(:disabled) {
+ background: var(--brand-primary-alpha);
+}
+
+.input-action-btn:disabled {
+ color: var(--text-placeholder);
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ width: 100%;
+ padding: 12px;
+ background-color: var(--brand-primary);
+ color: #ffffff;
+ font-size: 15px;
+ font-weight: 600;
+ border-radius: var(--radius-md);
+ transition: all 0.2s ease;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: var(--brand-primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-primary:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+.btn-primary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
}
diff --git a/frontend/src/auth.api.ts b/frontend/src/auth.api.ts
new file mode 100644
index 0000000..53993a2
--- /dev/null
+++ b/frontend/src/auth.api.ts
@@ -0,0 +1,80 @@
+import type {
+ AuthTokenResponse,
+ LoginPayload,
+ LoginWithCodePayload,
+ MessageResponse,
+ RegisterPayload,
+} from './auth.types'
+
+const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1'
+
+type JsonValue = object | null
+
+const MESSAGE_MAP: Record = {
+ 'Email is already registered': '该邮箱已注册',
+ 'Email is not registered': '该邮箱未注册',
+ 'Failed to send verification code': '验证码发送失败,请稍后重试',
+ 'Verification code sent': '验证码已发送',
+ 'Verification code does not exist or expired': '验证码不存在或已过期',
+ 'Invalid verification code': '验证码错误',
+ 'Invalid email or password': '邮箱或密码错误',
+ 'Invalid email or verification code': '邮箱或验证码错误',
+}
+
+function localizeMessage(message: string): string {
+ return MESSAGE_MAP[message] ?? message
+}
+
+async function request(path: string, payload: JsonValue): Promise {
+ const response = await fetch(`${API_BASE_URL}${path}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: payload ? JSON.stringify(payload) : undefined,
+ })
+
+ const raw = await response.text()
+ let data: Record = {}
+ if (raw) {
+ try {
+ data = JSON.parse(raw) as Record
+ } catch {
+ data = {}
+ }
+ }
+
+ if (!response.ok) {
+ const detail = data.detail
+ if (typeof detail === 'string') {
+ throw new Error(localizeMessage(detail))
+ }
+ throw new Error('请求失败,请稍后重试')
+ }
+
+ if (typeof data.message === 'string') {
+ data.message = localizeMessage(data.message)
+ }
+
+ return data as T
+}
+
+export function sendRegisterCode(email: string): Promise {
+ return request('/auth/register/send-code', { email })
+}
+
+export function sendLoginCode(email: string): Promise {
+ return request('/auth/login/send-code', { email })
+}
+
+export function register(payload: RegisterPayload): Promise {
+ return request('/auth/register', payload)
+}
+
+export function login(payload: LoginPayload): Promise {
+ return request('/auth/login', payload)
+}
+
+export function loginWithCode(payload: LoginWithCodePayload): Promise {
+ return request('/auth/login/code', payload)
+}
diff --git a/frontend/src/auth.types.ts b/frontend/src/auth.types.ts
new file mode 100644
index 0000000..845ea0c
--- /dev/null
+++ b/frontend/src/auth.types.ts
@@ -0,0 +1,36 @@
+export interface UserProfile {
+ id: number
+ email: string
+ nickname: string | null
+ avatar_url: string | null
+ gender: string
+ created_at: string
+}
+
+export interface AuthTokenResponse {
+ access_token: string
+ token_type: string
+ expires_in: number
+ user: UserProfile
+}
+
+export interface MessageResponse {
+ message: string
+}
+
+export interface LoginPayload {
+ email: string
+ password: string
+}
+
+export interface LoginWithCodePayload {
+ email: string
+ verification_code: string
+}
+
+export interface RegisterPayload {
+ email: string
+ password: string
+ verification_code: string
+ nickname?: string
+}
diff --git a/frontend/src/components/BrandLogo.vue b/frontend/src/components/BrandLogo.vue
new file mode 100644
index 0000000..dd0990c
--- /dev/null
+++ b/frontend/src/components/BrandLogo.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue
deleted file mode 100644
index d174cf8..0000000
--- a/frontend/src/components/HelloWorld.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
{{ msg }}
-
- You’ve successfully created a project with
- Vite +
- Vue 3 . What's next?
-
-
-
-
-
diff --git a/frontend/src/components/TheWelcome.vue b/frontend/src/components/TheWelcome.vue
deleted file mode 100644
index 8b731d9..0000000
--- a/frontend/src/components/TheWelcome.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
-
-
-
-
- Documentation
-
- Vue’s
- official documentation
- provides you with all information you need to get started.
-
-
-
-
-
-
- Tooling
-
- This project is served and bundled with
- Vite . The
- recommended IDE setup is
- VSCode
- +
- Vue - Official . If you need to test your components and web pages, check out
- Vitest
- and
- Cypress
- /
- Playwright .
-
-
-
- More instructions are available in
- README.md .
-
-
-
-
-
-
- Ecosystem
-
- Get official tools and libraries for your project:
- Pinia ,
- Vue Router ,
- Vue Test Utils , and
- Vue Dev Tools . If
- you need more resources, we suggest paying
- Awesome Vue
- a visit.
-
-
-
-
-
-
- Community
-
- Got stuck? Ask your question on
- Vue Land
- (our official Discord server), or
- StackOverflow . You should also follow the official
- @vuejs.org
- Bluesky account or the
- @vuejs
- X account for latest news in the Vue world.
-
-
-
-
-
-
- Support Vue
-
- As an independent project, Vue relies on community backing for its sustainability. You can help
- us by
- becoming a sponsor .
-
-
diff --git a/frontend/src/components/ThemeToggle.vue b/frontend/src/components/ThemeToggle.vue
new file mode 100644
index 0000000..70b165a
--- /dev/null
+++ b/frontend/src/components/ThemeToggle.vue
@@ -0,0 +1,392 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ themeStore.isDark ? '浅色模式' : '暗黑模式' }}
+
+
+
+
diff --git a/frontend/src/components/WelcomeItem.vue b/frontend/src/components/WelcomeItem.vue
deleted file mode 100644
index 6d7086a..0000000
--- a/frontend/src/components/WelcomeItem.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
-
-
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 5dcad83..793c39b 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,14 +1,18 @@
import './assets/main.css'
import { createApp } from 'vue'
-import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
+import { pinia } from './stores'
+import { useThemeStore } from './stores/theme'
const app = createApp(App)
-app.use(createPinia())
+app.use(pinia)
+const themeStore = useThemeStore(pinia)
+themeStore.initTheme()
+
app.use(router)
app.mount('#app')
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 3e49915..03787ab 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -1,5 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router'
-import HomeView from '../views/HomeView.vue'
+
+import { pinia } from '@/stores'
+import { useAuthStore } from '@/stores/auth'
+import HomeView from '@/views/HomeView.vue'
+import LoginView from '@/views/LoginView.vue'
+import RegisterView from '@/views/RegisterView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -8,16 +13,47 @@ const router = createRouter({
path: '/',
name: 'home',
component: HomeView,
+ meta: {
+ requiresAuth: true,
+ },
},
{
- path: '/about',
- name: 'about',
- // route level code-splitting
- // this generates a separate chunk (About.[hash].js) for this route
- // which is lazy-loaded when the route is visited.
- component: () => import('../views/AboutView.vue'),
+ path: '/login',
+ name: 'login',
+ component: LoginView,
+ meta: {
+ guestOnly: true,
+ },
+ },
+ {
+ path: '/register',
+ name: 'register',
+ component: RegisterView,
+ meta: {
+ guestOnly: true,
+ },
},
],
})
+router.beforeEach((to) => {
+ const authStore = useAuthStore(pinia)
+ authStore.restore()
+
+ if (to.meta.requiresAuth && !authStore.isAuthenticated) {
+ return {
+ name: 'login',
+ query: {
+ redirect: to.fullPath,
+ },
+ }
+ }
+
+ if (to.meta.guestOnly && authStore.isAuthenticated) {
+ return { name: 'home' }
+ }
+
+ return true
+})
+
export default router
diff --git a/frontend/src/router/route-meta.d.ts b/frontend/src/router/route-meta.d.ts
new file mode 100644
index 0000000..99f8492
--- /dev/null
+++ b/frontend/src/router/route-meta.d.ts
@@ -0,0 +1,8 @@
+import 'vue-router'
+
+declare module 'vue-router' {
+ interface RouteMeta {
+ requiresAuth?: boolean
+ guestOnly?: boolean
+ }
+}
diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts
new file mode 100644
index 0000000..8313f08
--- /dev/null
+++ b/frontend/src/stores/auth.ts
@@ -0,0 +1,164 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+
+import { login, loginWithCode, register, sendLoginCode, sendRegisterCode } from '@/auth.api'
+import type { LoginPayload, LoginWithCodePayload, RegisterPayload, UserProfile } from '@/auth.types'
+
+interface PersistedAuthState {
+ accessToken: string
+ expiresAt: number
+ user: UserProfile
+}
+
+const AUTH_STORAGE_KEY = 'insight-radar-auth'
+
+function loadPersistedState(): PersistedAuthState | null {
+ const raw = localStorage.getItem(AUTH_STORAGE_KEY)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as PersistedAuthState
+ if (!parsed.accessToken || !parsed.expiresAt || !parsed.user) {
+ return null
+ }
+ return parsed
+ } catch {
+ return null
+ }
+}
+
+export const useAuthStore = defineStore('auth', () => {
+ const persisted = loadPersistedState()
+
+ const accessToken = ref(persisted?.accessToken ?? null)
+ const expiresAt = ref(persisted?.expiresAt ?? null)
+ const user = ref(persisted?.user ?? null)
+ const loading = ref(false)
+
+ const isExpired = computed(() => {
+ if (!expiresAt.value) {
+ return true
+ }
+ return Date.now() >= expiresAt.value
+ })
+
+ const isAuthenticated = computed(() => Boolean(accessToken.value && user.value && !isExpired.value))
+
+ function persist() {
+ if (!accessToken.value || !expiresAt.value || !user.value) {
+ localStorage.removeItem(AUTH_STORAGE_KEY)
+ return
+ }
+
+ const payload: PersistedAuthState = {
+ accessToken: accessToken.value,
+ expiresAt: expiresAt.value,
+ user: user.value,
+ }
+ localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(payload))
+ }
+
+ function setSession(params: { accessToken: string; expiresIn: number; user: UserProfile }) {
+ accessToken.value = params.accessToken
+ expiresAt.value = Date.now() + params.expiresIn * 1000
+ user.value = params.user
+ persist()
+ }
+
+ function clearSession() {
+ accessToken.value = null
+ expiresAt.value = null
+ user.value = null
+ persist()
+ }
+
+ async function loginWithPassword(payload: LoginPayload) {
+ loading.value = true
+ try {
+ const data = await login(payload)
+ setSession({
+ accessToken: data.access_token,
+ expiresIn: data.expires_in,
+ user: data.user,
+ })
+ return data
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function loginWithVerificationCode(payload: LoginWithCodePayload) {
+ loading.value = true
+ try {
+ const data = await loginWithCode(payload)
+ setSession({
+ accessToken: data.access_token,
+ expiresIn: data.expires_in,
+ user: data.user,
+ })
+ return data
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function registerAccount(payload: RegisterPayload) {
+ loading.value = true
+ try {
+ const data = await register(payload)
+ setSession({
+ accessToken: data.access_token,
+ expiresIn: data.expires_in,
+ user: data.user,
+ })
+ return data
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function sendCode(email: string) {
+ loading.value = true
+ try {
+ return await sendRegisterCode(email)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function sendLoginVerificationCode(email: string) {
+ loading.value = true
+ try {
+ return await sendLoginCode(email)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function restore() {
+ if (isExpired.value) {
+ clearSession()
+ }
+ }
+
+ function logout() {
+ clearSession()
+ }
+
+ return {
+ accessToken,
+ expiresAt,
+ user,
+ loading,
+ isAuthenticated,
+ loginWithPassword,
+ loginWithVerificationCode,
+ registerAccount,
+ sendCode,
+ sendLoginVerificationCode,
+ restore,
+ logout,
+ }
+})
diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts
new file mode 100644
index 0000000..c569ef7
--- /dev/null
+++ b/frontend/src/stores/index.ts
@@ -0,0 +1,3 @@
+import { createPinia } from 'pinia'
+
+export const pinia = createPinia()
diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts
new file mode 100644
index 0000000..b7a7de0
--- /dev/null
+++ b/frontend/src/stores/theme.ts
@@ -0,0 +1,66 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+
+type ThemeMode = 'dark' | 'light'
+
+const THEME_STORAGE_KEY = 'insight-radar-theme'
+
+export const useThemeStore = defineStore('theme', () => {
+ const mode = ref('light')
+ const initialized = ref(false)
+ let transitionTimer: number | null = null
+
+ const isDark = computed(() => mode.value === 'dark')
+
+ function applyTheme(nextMode: ThemeMode) {
+ mode.value = nextMode
+ document.documentElement.classList.toggle('dark', nextMode === 'dark')
+ localStorage.setItem(THEME_STORAGE_KEY, nextMode)
+ }
+
+ function runTransition(nextMode: ThemeMode) {
+ const root = document.documentElement
+ root.dataset.themeTransition = nextMode === 'dark' ? 'to-dark' : 'to-light'
+ root.classList.add('theme-switching')
+
+ if (transitionTimer) {
+ window.clearTimeout(transitionTimer)
+ }
+
+ transitionTimer = window.setTimeout(() => {
+ root.classList.remove('theme-switching')
+ delete root.dataset.themeTransition
+ transitionTimer = null
+ }, 760)
+ }
+
+ function initTheme() {
+ if (initialized.value) {
+ return
+ }
+
+ const savedTheme = localStorage.getItem(THEME_STORAGE_KEY)
+ if (savedTheme === 'dark' || savedTheme === 'light') {
+ applyTheme(savedTheme)
+ initialized.value = true
+ return
+ }
+
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
+ applyTheme(prefersDark ? 'dark' : 'light')
+ initialized.value = true
+ }
+
+ function toggleTheme() {
+ const nextMode: ThemeMode = isDark.value ? 'light' : 'dark'
+ runTransition(nextMode)
+ applyTheme(nextMode)
+ }
+
+ return {
+ mode,
+ isDark,
+ initTheme,
+ toggleTheme,
+ }
+})
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue
index d5c0217..cb92c9b 100644
--- a/frontend/src/views/HomeView.vue
+++ b/frontend/src/views/HomeView.vue
@@ -1,9 +1,300 @@
-
-
-
+
+
+
+
+
+
+
+
+
+ 当前账户
+ {{ displayName }}
+ {{ authStore.user?.email }}
+
+
+
+
+ 会话状态
+ {{ authStore.isAuthenticated ? '安全连接中' : '未登录' }}
+ 有效期至:{{ tokenExpiryText }}
+
+
+
+
+
开发者接入
+
认证体系已就绪
+
在请求您的业务接口时,请在 Headers 中携带如下凭证:
+
+ Authorization: Bearer {token}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue
new file mode 100644
index 0000000..ee2c753
--- /dev/null
+++ b/frontend/src/views/LoginView.vue
@@ -0,0 +1,447 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue
new file mode 100644
index 0000000..6148f79
--- /dev/null
+++ b/frontend/src/views/RegisterView.vue
@@ -0,0 +1,414 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4217010..76d3f5b 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -10,6 +10,14 @@ export default defineConfig({
vue(),
vueDevTools(),
],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:8000',
+ changeOrigin: true,
+ },
+ },
+ },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))