login+ai cluster

This commit is contained in:
stardrophere
2026-03-11 01:33:21 +08:00
parent 9fa07cfb07
commit 8ed819a580
39 changed files with 3392 additions and 610 deletions
+238
View File
@@ -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"""
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #222;">
<h2 style="margin-bottom: 12px;">InsightRadar Email Verification</h2>
<p>Your {purpose_text} verification code is:</p>
<p style="font-size: 28px; font-weight: bold; letter-spacing: 4px; color: #0b57d0;">{code}</p>
<p>The code is valid for {expire_minutes} minutes. Do not share it with others.</p>
</div>
"""
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)
+69
View File
@@ -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
+1 -1
View File
@@ -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
View File