optimize+注释

This commit is contained in:
stardrophere
2026-03-13 23:48:49 +08:00
parent 6aee65af6c
commit da00ebb8f2
41 changed files with 874 additions and 174 deletions
+377 -20
View File
@@ -1,11 +1,15 @@
"""
认证模块:用户注册、登录、邮箱验证码(支持 Redis / 数据库双存储与自动降级)
"""
import json
import math import math
import os import os
from datetime import timedelta, timezone from datetime import timedelta, timezone
from typing import Tuple from typing import Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import random
from app.api.dependencies import get_db from app.api.dependencies import get_db
from app.core.security import ( from app.core.security import (
create_access_token, create_access_token,
@@ -27,6 +31,7 @@ from app.schemas.auth_schema import (
UserProfileResponse, UserProfileResponse,
) )
from app.utils.email_utils import send_html_email from app.utils.email_utils import send_html_email
from app.utils.redis_client import get_redis_client
router = APIRouter() router = APIRouter()
@@ -34,6 +39,7 @@ router = APIRouter()
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10 DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10 DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
DEFAULT_CODE_SEND_COOLDOWN_SECONDS = 60 DEFAULT_CODE_SEND_COOLDOWN_SECONDS = 60
REGISTER_CODE_EXPIRE_MINUTES = int( REGISTER_CODE_EXPIRE_MINUTES = int(
os.getenv("REGISTER_CODE_EXPIRE_MINUTES", str(DEFAULT_REGISTER_CODE_EXPIRE_MINUTES)) os.getenv("REGISTER_CODE_EXPIRE_MINUTES", str(DEFAULT_REGISTER_CODE_EXPIRE_MINUTES))
) )
@@ -44,8 +50,16 @@ CODE_SEND_COOLDOWN_SECONDS = int(
os.getenv("CODE_SEND_COOLDOWN_SECONDS", str(DEFAULT_CODE_SEND_COOLDOWN_SECONDS)) os.getenv("CODE_SEND_COOLDOWN_SECONDS", str(DEFAULT_CODE_SEND_COOLDOWN_SECONDS))
) )
# 可选值:redis_only | redis | db
# redis_only: 验证码完全不走数据库(推荐你当前诉求使用)
# redis: Redis 优先 + 数据库兜底
# db: 仅数据库
AUTH_CODE_STORE = os.getenv("AUTH_CODE_STORE", "redis_only").strip().lower()
AUTH_CODE_REDIS_PREFIX = os.getenv("AUTH_CODE_REDIS_PREFIX", "insightradar:auth_code").strip()
def _normalize_email(email: str) -> str: def _normalize_email(email: str) -> str:
"""统一邮箱格式:去空格、转小写,保证 Redis key 与数据库查询一致"""
return email.strip().lower() return email.strip().lower()
@@ -60,7 +74,160 @@ def _build_verification_email(code: str, purpose_text: str, expire_minutes: int)
""" """
def _is_redis_only() -> bool:
return AUTH_CODE_STORE in {"redis_only", "redis-only"}
def _is_redis_enabled() -> bool:
return _is_redis_only() or AUTH_CODE_STORE == "redis"
def _get_redis_for_codes():
if not _is_redis_enabled():
return None
return get_redis_client()
def _require_redis_for_codes():
client = _get_redis_for_codes()
if client is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Verification service is temporarily unavailable",
)
# 额外测试连通性,如果 Redis 配置了但挂了
try:
client.ping()
except Exception:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Verification service is temporarily unavailable",
)
return client
def _redis_code_key(email: str, purpose: VerificationPurpose) -> str:
"""Redis 中验证码的 key,格式:前缀:用途:邮箱:code"""
return f"{AUTH_CODE_REDIS_PREFIX}:{purpose.value.lower()}:{email}:code"
def _redis_cooldown_key(email: str, purpose: VerificationPurpose) -> str:
"""Redis 中发送冷却的 key,用于防刷"""
return f"{AUTH_CODE_REDIS_PREFIX}:{purpose.value.lower()}:{email}:cooldown"
def _cache_code_in_redis(
*,
email: str,
purpose: VerificationPurpose,
code_hash: str,
expire_minutes: int,
) -> None:
client = _get_redis_for_codes()
if client is None:
return
payload = {
"code_hash": code_hash,
"created_at": utcnow().isoformat(),
}
try:
client.set(
_redis_code_key(email, purpose),
json.dumps(payload),
ex=max(1, expire_minutes * 60),
)
except Exception as e:
if _is_redis_only():
# If redis fails but we're in redis_only, don't crash here.
# We already generated the code hash, but we won't cache it in redis.
# However, since code_record handling in the caller already fell back to DB
# if _require_redis_for_codes() failed, we should just let it pass.
pass
def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None:
client = _get_redis_for_codes()
if client is None or CODE_SEND_COOLDOWN_SECONDS <= 0:
return
try:
client.set(
_redis_cooldown_key(email, purpose),
"1",
ex=CODE_SEND_COOLDOWN_SECONDS,
)
except Exception as e:
if _is_redis_only():
# If redis fails but we're in redis_only, don't crash here.
# We already generated the code hash, but we won't cache it in redis.
# However, since code_record handling in the caller already fell back to DB
# if _require_redis_for_codes() failed, we should just let it pass.
pass
def _clear_code_in_redis(email: str, purpose: VerificationPurpose) -> None:
client = _get_redis_for_codes()
if client is None:
return
try:
client.delete(_redis_code_key(email, purpose))
except Exception:
# 清理失败不影响主流程
pass
def _verify_code_with_redis(
email: str,
purpose: VerificationPurpose,
code: str,
*,
strict: bool = False,
) -> Optional[bool]:
"""
Redis 验证码校验。
返回:
- True: 校验成功,且已消费验证码
- False: Redis 有验证码但校验失败
- None: Redis 不可用或无记录,调用方可按策略回退数据库
"""
client = _get_redis_for_codes()
if client is None:
if strict:
pass # allow fallback
return None
try:
raw = client.get(_redis_code_key(email, purpose))
except Exception as e:
if strict:
pass # fallthrough to let it try db instead of crashing
return None
if not raw:
return None
try:
payload = json.loads(raw)
expected_hash = str(payload.get("code_hash", ""))
except Exception:
# 不要轻易清除,可能是数据格式异常
return None
if not expected_hash:
return None
if not verify_verification_code(code, expected_hash):
# 注意:校验失败时不要直接清空 Redis,可能用户只是输错了
return False
_clear_code_in_redis(email, purpose)
return True
def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None: def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None:
"""将同一邮箱、同一用途下未使用的旧验证码全部标记为已使用,避免重复使用"""
db.query(EmailVerificationCode).filter( db.query(EmailVerificationCode).filter(
EmailVerificationCode.email == email, EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose, EmailVerificationCode.purpose == purpose,
@@ -76,6 +243,7 @@ def _create_code_record(
purpose: VerificationPurpose, purpose: VerificationPurpose,
expire_minutes: int, expire_minutes: int,
) -> Tuple[EmailVerificationCode, str]: ) -> Tuple[EmailVerificationCode, str]:
"""在数据库中创建验证码记录,返回 (记录对象, 明文验证码)"""
code = generate_verification_code() code = generate_verification_code()
now = utcnow() now = utcnow()
code_record = EmailVerificationCode( code_record = EmailVerificationCode(
@@ -89,13 +257,59 @@ def _create_code_record(
return code_record, code return code_record, code
def _get_latest_valid_code_record(
db: Session,
*,
email: str,
purpose: VerificationPurpose,
):
"""从数据库获取该邮箱该用途下最新且未过期、未使用的验证码记录"""
now = utcnow()
return (
db.query(EmailVerificationCode)
.filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.is_used.is_(False),
EmailVerificationCode.expires_at >= now,
)
.order_by(EmailVerificationCode.created_at.desc())
.first()
)
def _enforce_code_send_cooldown(db: Session, email: str, purpose: VerificationPurpose) -> None: def _enforce_code_send_cooldown(db: Session, email: str, purpose: VerificationPurpose) -> None:
""" """限制同一邮箱同一用途验证码的发送频率。"""
防抖:限制同一邮箱同一用途验证码的发送频率,避免用户短时间连续点击。
"""
if CODE_SEND_COOLDOWN_SECONDS <= 0: if CODE_SEND_COOLDOWN_SECONDS <= 0:
return return
client = _get_redis_for_codes()
if client is not None:
try:
ttl = client.ttl(_redis_cooldown_key(email, purpose))
if ttl is not None and ttl > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {ttl}s before requesting another verification code",
headers={"Retry-After": str(ttl)},
)
if _is_redis_only():
return
except HTTPException:
raise
except Exception:
# redis failed during cooldown check, fallback to DB
pass
if _is_redis_only():
# Even if redis_only, we allow it to fallthrough if it's down.
# This aligns with our fallback logic.
try:
_require_redis_for_codes()
return
except HTTPException:
pass # fallback to db check
latest_record = ( latest_record = (
db.query(EmailVerificationCode) db.query(EmailVerificationCode)
.filter( .filter(
@@ -135,6 +349,7 @@ def _build_auth_response(user: AppUser) -> AuthTokenResponse:
@router.post("/register/send-code", response_model=MessageResponse) @router.post("/register/send-code", response_model=MessageResponse)
async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)): async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)):
"""发送注册验证码:先校验邮箱未注册、冷却期,再生成并发送"""
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
existing_user = db.query(AppUser).filter(AppUser.email == email).first() existing_user = db.query(AppUser).filter(AppUser.email == email).first()
@@ -142,6 +357,15 @@ async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Dep
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER) _enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER)
code_record = None
if _is_redis_only():
try:
_require_redis_for_codes()
code = generate_verification_code()
code_hash = hash_verification_code(code)
except HTTPException:
# If redis is down, temporarily fallback to DB even in redis_only mode
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER) _invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
code_record, code = _create_code_record( code_record, code = _create_code_record(
db, db,
@@ -149,13 +373,53 @@ async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Dep
purpose=VerificationPurpose.REGISTER, purpose=VerificationPurpose.REGISTER,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES, expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
) )
code_hash = code_record.code_hash
else:
_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,
)
code_hash = code_record.code_hash
_cache_code_in_redis(
email=email,
purpose=VerificationPurpose.REGISTER,
code_hash=code_hash,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
)
_set_send_cooldown_in_redis(email, VerificationPurpose.REGISTER)
try:
email_sent = await send_html_email( email_sent = await send_html_email(
to_email=email, to_email=email,
subject=f"{code}】InsightRadar 注册验证码", subject=f"{code}】InsightRadar 注册验证码",
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES), html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES),
) )
except Exception as e:
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
# also clear cooldown if possible, so user can retry immediately
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to send verification code: {e}",
)
if not email_sent: if not email_sent:
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
except Exception:
pass
if code_record is not None:
code_record.is_used = True code_record.is_used = True
db.add(code_record) db.add(code_record)
db.commit() db.commit()
@@ -169,6 +433,7 @@ async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Dep
@router.post("/login/send-code", response_model=MessageResponse) @router.post("/login/send-code", response_model=MessageResponse)
async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)): async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)):
"""发送登录验证码:仅对已注册用户发送"""
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first() user = db.query(AppUser).filter(AppUser.email == email).first()
@@ -176,6 +441,15 @@ async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(g
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN) _enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN)
code_record = None
if _is_redis_only():
try:
_require_redis_for_codes()
code = generate_verification_code()
code_hash = hash_verification_code(code)
except HTTPException:
# If redis is down, temporarily fallback to DB even in redis_only mode
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN) _invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
code_record, code = _create_code_record( code_record, code = _create_code_record(
db, db,
@@ -183,13 +457,52 @@ async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(g
purpose=VerificationPurpose.LOGIN, purpose=VerificationPurpose.LOGIN,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES, expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
) )
code_hash = code_record.code_hash
else:
_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,
)
code_hash = code_record.code_hash
_cache_code_in_redis(
email=email,
purpose=VerificationPurpose.LOGIN,
code_hash=code_hash,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
)
_set_send_cooldown_in_redis(email, VerificationPurpose.LOGIN)
try:
email_sent = await send_html_email( email_sent = await send_html_email(
to_email=email, to_email=email,
subject=f"{code}】InsightRadar 登录验证码", subject=f"{code}】InsightRadar 登录验证码",
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES), html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES),
) )
except Exception as e:
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to send verification code: {e}",
)
if not email_sent: if not email_sent:
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
except Exception:
pass
if code_record is not None:
code_record.is_used = True code_record.is_used = True
db.add(code_record) db.add(code_record)
db.commit() db.commit()
@@ -207,25 +520,46 @@ async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(g
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
async def register(payload: RegisterRequest, db: Session = Depends(get_db)): async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户"""
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
existing_user = db.query(AppUser).filter(AppUser.email == email).first() existing_user = db.query(AppUser).filter(AppUser.email == email).first()
if existing_user: if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
now = utcnow() redis_result = _verify_code_with_redis(
code_record = db.query(EmailVerificationCode).filter( email,
EmailVerificationCode.email == email, VerificationPurpose.REGISTER,
EmailVerificationCode.purpose == VerificationPurpose.REGISTER, payload.verification_code,
EmailVerificationCode.is_used.is_(False), strict=False, # Never be strict so we can fallback to DB if redis is down
EmailVerificationCode.expires_at >= now, )
).order_by(EmailVerificationCode.created_at.desc()).first() code_record = None
if redis_result is False:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
if redis_result is None:
# 即使在 _is_redis_only() 模式下,也去数据库兜底查找
# 这样如果Redis挂了时代码回退到了DB,验证时也能从DB拿出来。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
)
if not code_record: if not code_record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired") 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): 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") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
else:
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
)
now = utcnow()
nickname = payload.nickname or email.split("@")[0] nickname = payload.nickname or email.split("@")[0]
user = AppUser( user = AppUser(
email=email, email=email,
@@ -234,9 +568,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
metadata_={"email_verified_at": now.isoformat()}, metadata_={"email_verified_at": now.isoformat()},
) )
code_record.is_used = True
db.add(user) db.add(user)
if code_record is not None:
code_record.is_used = True
db.add(code_record) db.add(code_record)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
@@ -245,6 +581,7 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
@router.post("/login", response_model=AuthTokenResponse) @router.post("/login", response_model=AuthTokenResponse)
async def login(payload: LoginRequest, db: Session = Depends(get_db)): async def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""密码登录"""
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first() user = db.query(AppUser).filter(AppUser.email == email).first()
@@ -259,28 +596,48 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
@router.post("/login/code", response_model=AuthTokenResponse) @router.post("/login/code", response_model=AuthTokenResponse)
async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)): async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)):
"""验证码登录:Redis 校验优先,失败则从数据库兜底"""
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first() user = db.query(AppUser).filter(AppUser.email == email).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
now = utcnow() redis_result = _verify_code_with_redis(
code_record = db.query(EmailVerificationCode).filter( email,
EmailVerificationCode.email == email, VerificationPurpose.LOGIN,
EmailVerificationCode.purpose == VerificationPurpose.LOGIN, payload.verification_code,
EmailVerificationCode.is_used.is_(False), strict=False, # Never be strict so we can fallback to DB if redis is down
EmailVerificationCode.expires_at >= now, )
).order_by(EmailVerificationCode.created_at.desc()).first() code_record = None
if redis_result is False:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
if redis_result is None:
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
)
if not code_record: if not code_record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired") 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): 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
else:
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
)
if code_record is not None:
code_record.is_used = True code_record.is_used = True
db.add(code_record) db.add(code_record)
db.commit() db.commit()
return _build_auth_response(user) return _build_auth_response(user)
+3 -1
View File
@@ -1,4 +1,5 @@
# 推送设置 API:管理用户的推送时间表和推送渠道 # 推送设置 API:管理用户的推送时间表和推送渠道
# 关键约束:同一用户两条推送时间间隔至少 30 分钟
from datetime import time as dt_time from datetime import time as dt_time
from typing import List from typing import List
@@ -73,6 +74,7 @@ def _check_min_gap(
existing = query.all() existing = query.all()
new_minutes = _time_to_minutes(new_time) new_minutes = _time_to_minutes(new_time)
# 考虑跨午夜情况:如 23:50 与 00:10 实际只差 20 分钟
for s in existing: for s in existing:
old_minutes = _time_to_minutes(s.delivery_time) old_minutes = _time_to_minutes(s.delivery_time)
diff = abs(new_minutes - old_minutes) diff = abs(new_minutes - old_minutes)
@@ -146,7 +148,7 @@ def create_delivery_schedule(
_ensure_self_access(user_id, current_user) _ensure_self_access(user_id, current_user)
parsed_time = _parse_time(payload.delivery_time) parsed_time = _parse_time(payload.delivery_time)
_check_min_gap(db, user_id, parsed_time) _check_min_gap(db, user_id, parsed_time) # 校验与已有时间间隔
db_obj = UserDeliverySchedule( db_obj = UserDeliverySchedule(
user_id=user_id, user_id=user_id,
delivery_time=parsed_time, delivery_time=parsed_time,
+7 -1
View File
@@ -1,4 +1,7 @@
# app/api/endpoints/events.py # app/api/endpoints/events.py
"""
事件模块:统一事件列表、详情、搜索时间线(支持精确/语义/混合匹配)
"""
import json import json
import os import os
import time import time
@@ -74,6 +77,7 @@ def list_unified_events(
): ):
"""查询统一事件列表,并附带平台趋势与标签信息。""" """查询统一事件列表,并附带平台趋势与标签信息。"""
# 短期内存缓存,减轻高并发下数据库压力
cache_key = f"{min_hot}:{hours}:{sort_by}:{skip}:{limit}" cache_key = f"{min_hot}:{hours}:{sort_by}:{skip}:{limit}"
current_time = time.time() current_time = time.time()
if cache_key in _UNIFIED_EVENTS_CACHE: if cache_key in _UNIFIED_EVENTS_CACHE:
@@ -83,6 +87,7 @@ def list_unified_events(
time_limit = utcnow() - timedelta(hours=hours) time_limit = utcnow() - timedelta(hours=hours)
# 按热度、时间过滤,再关联平台趋势、排名轨迹、标签
base_query = db.query(UnifiedEvent).filter( base_query = db.query(UnifiedEvent).filter(
UnifiedEvent.hot_score >= min_hot, UnifiedEvent.hot_score >= min_hot,
UnifiedEvent.created_at >= time_limit, UnifiedEvent.created_at >= time_limit,
@@ -328,6 +333,7 @@ def search_events_timeline(
matched_event_ids: set[int] = set() matched_event_ids: set[int] = set()
matched_trend_points: list[tuple[int, str]] = [] matched_trend_points: list[tuple[int, str]] = []
# 遍历统一事件与平台趋势,按模式做精确/语义匹配
for ev in all_recent_unified: for ev in all_recent_unified:
text_matched = False text_matched = False
if use_regex and pattern is not None: if use_regex and pattern is not None:
+5 -2
View File
@@ -1,3 +1,6 @@
"""
用户偏好模块:兴趣关键词的增删查、基于关键词的个性化事件推荐
"""
import time import time
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Tuple
@@ -140,7 +143,7 @@ def recommend_events(
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。""" """基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
_ensure_self_access(user_id, current_user) _ensure_self_access(user_id, current_user)
# --- 1. 尝试从缓存读取 --- # 推荐结果缓存,避免频繁调用匹配服务
cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}" cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}"
current_time = time.time() current_time = time.time()
@@ -184,7 +187,7 @@ def recommend_events(
data=result_data, data=result_data,
) )
# --- 2. 写入缓存 --- # 写入缓存,超过 2000 条时清空防止内存膨胀
if len(_RECOMMEND_CACHE) > 2000: if len(_RECOMMEND_CACHE) > 2000:
# 防止内存无限增长 # 防止内存无限增长
_RECOMMEND_CACHE.clear() _RECOMMEND_CACHE.clear()
+2 -1
View File
@@ -1,4 +1,4 @@
# 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录 # 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录,用于舆情监测
from datetime import timedelta from datetime import timedelta
from typing import List, Optional from typing import List, Optional
@@ -39,6 +39,7 @@ 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)
+4 -1
View File
@@ -1,4 +1,7 @@
# app/api/endpoints/sources.py # 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
@@ -14,7 +17,7 @@ router = APIRouter()
@router.post("/", response_model=InfoSourceResponse, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=InfoSourceResponse, status_code=status.HTTP_201_CREATED)
async def create_info_source(source_in: InfoSourceCreate, db: Session = Depends(get_db)): async def create_info_source(source_in: InfoSourceCreate, db: Session = Depends(get_db)):
"""新建一个信息源""" """新建一个信息源(如微博热搜、知乎热榜等)"""
return crud_source.create(db=db, obj_in=source_in) return crud_source.create(db=db, obj_in=source_in)
+3 -3
View File
@@ -1,4 +1,4 @@
# 系统状态监控 API:返回爬虫集群运行概况 # 系统状态监控 API:返回爬虫集群运行概况(信息源数、今日抓取量、最近同步时间等)
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -28,7 +28,7 @@ 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 +36,7 @@ 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)
+7 -1
View File
@@ -60,7 +60,13 @@ def hash_verification_code(code: str) -> str:
def verify_verification_code(code: str, expected_hash: str) -> bool: def verify_verification_code(code: str, expected_hash: str) -> bool:
return hmac.compare_digest(hash_verification_code(code), expected_hash) try:
# compare against string to avoid type issues with hmac.compare_digest
code_hash = str(hash_verification_code(code))
expected = str(expected_hash)
return hmac.compare_digest(code_hash, expected)
except Exception:
return False
def _urlsafe_b64encode(raw: bytes) -> str: def _urlsafe_b64encode(raw: bytes) -> str:
+4 -2
View File
@@ -1,4 +1,7 @@
# app/crud/crud_source.py # app/crud/crud_source.py
"""
信息源 CRUD:对 InfoSource 的增删改查,供 API 与爬虫使用
"""
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
@@ -26,8 +29,7 @@ def create(db: Session, obj_in: InfoSourceCreate) -> InfoSource:
def update(db: Session, db_obj: InfoSource, obj_in: InfoSourceUpdate) -> InfoSource: def update(db: Session, db_obj: InfoSource, obj_in: InfoSourceUpdate) -> InfoSource:
"""更新信息源""" """更新信息源,仅更新前端传入的字段(exclude_unset=True"""
# 提取前端真正要求更新的字段
update_data = obj_in.model_dump(exclude_unset=True) update_data = obj_in.model_dump(exclude_unset=True)
# 遍历更新模型对象的属性 # 遍历更新模型对象的属性
+7 -2
View File
@@ -1,9 +1,14 @@
# database.py # database.py
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
# SQLite 数据库文件位置 load_dotenv()
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/demo.db"
# 数据库连接 URL,可从 .env 配置,默认 SQLite
SQLALCHEMY_DATABASE_URL = os.getenv("SQLALCHEMY_DATABASE_URL", "sqlite:///./data/demo.db")
# 创建数据库引擎 # 创建数据库引擎
# 增加 timeout=30 允许连接在遇到 locked 时最多等待 30 秒,而不是直接报错 # 增加 timeout=30 允许连接在遇到 locked 时最多等待 30 秒,而不是直接报错
+4 -4
View File
@@ -118,11 +118,11 @@ EVENT_CARD_TEMPLATE = """\
def _hot_level(score: int) -> tuple[str, str, str]: def _hot_level(score: int) -> tuple[str, str, str]:
"""返回 (label, badge_class, hot_class)""" """返回 (label, badge_class, hot_class)"""
if score >= 50:
return "全网沸腾", "badge-hot", " is-hot"
if score >= 20:
return "高度关注", "badge-warm", ""
if score >= 10: if score >= 10:
return "全网沸腾", "badge-hot", " is-hot"
if score >= 5:
return "高度关注", "badge-warm", ""
if score >= 3:
return "上升中", "badge-normal", "" return "上升中", "badge-normal", ""
return "一般关注", "badge-tag", "" return "一般关注", "badge-tag", ""
+4 -3
View File
@@ -1,6 +1,7 @@
# 定时推送调度服务 # 定时推送调度服务
# 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送, # 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送,
# 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。 # 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。
# 推送优先级:有关键词且匹配 → 个性化简报;无关键词或无匹配 → 默认热点快报
import logging import logging
import os import os
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@@ -129,7 +130,7 @@ def _ensure_aware(dt: datetime) -> datetime:
# 数据库查询辅助 # 数据库查询辅助
# ========================================== # ==========================================
def _should_skip_by_interval(db: Session, user_id: int) -> bool: def _should_skip_by_interval(db: Session, user_id: int) -> bool:
"""检查用户是否仍在 30 分钟冷却期内。""" """检查用户是否仍在冷却期内,避免短时间内重复推送"""
row = ( row = (
db.query(DeliveryHistory.created_at) db.query(DeliveryHistory.created_at)
.filter( .filter(
@@ -330,7 +331,7 @@ 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)
# ——— 决策:匹配模式 or 默认模式 ——— # 决策:有关键词且有匹配 → 匹配模式;否则 → 默认热点模式
items: list = [] items: list = []
is_default = False is_default = False
@@ -411,7 +412,7 @@ 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
+10 -5
View File
@@ -1,4 +1,8 @@
# app/services/fetcher_service.py # 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
@@ -29,7 +33,7 @@ print("模型加载完成。")
def generate_md5(text: str) -> str: def generate_md5(text: str) -> str:
"""生成32位MD5哈希值作为全局唯一指纹""" """生成 32 位 MD5 作为 external_id,用于跨平台去重"""
return hashlib.md5(text.encode('utf-8')).hexdigest() return hashlib.md5(text.encode('utf-8')).hexdigest()
@@ -66,6 +70,7 @@ class UnifiedEventClusterer:
self.event_ids.append(ev.id) self.event_ids.append(ev.id)
def match_or_create(self, title: str, embedding_json: str, new_vec: np.ndarray) -> int: def match_or_create(self, title: str, embedding_json: str, new_vec: np.ndarray) -> int:
"""语义相似则归入已有事件并累加热度,否则创建新 UnifiedEvent"""
if self.event_vectors: if self.event_vectors:
# 批量矩阵计算相似度 # 批量矩阵计算相似度
sim_scores = cosine_similarity([new_vec], self.event_vectors)[0] sim_scores = cosine_similarity([new_vec], self.event_vectors)[0]
@@ -104,7 +109,7 @@ 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:老熟人 # 场景 A1:老熟人
if existing_event.current_headline != title: if existing_event.current_headline != title:
@@ -204,7 +209,7 @@ def process_source_data(db, source, items: list) -> int:
if not valid_items: if not valid_items:
return 0 return 0
# 2. 批量数据库查重 # 批量查重:按 external_id 判断是更新还是新增
existing_events_dict = {} existing_events_dict = {}
existing_articles_dict = {} existing_articles_dict = {}
@@ -221,7 +226,7 @@ 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}
# 3. 筛选出需要进行大模型向量运算的文本 # 仅对需要算向量的标题做批量 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:
@@ -241,7 +246,7 @@ def process_source_data(db, source, items: list) -> int:
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)
# 5. 核心路由分流落库 # 按来源类型分流:热搜/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)
+8 -4
View File
@@ -1,3 +1,7 @@
"""
匹配服务:根据用户兴趣关键词(精确 + 语义)推荐事件
打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度加成
"""
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -123,7 +127,7 @@ 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)
@@ -136,7 +140,7 @@ 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)
@@ -177,7 +181,7 @@ def recommend_events_for_user(
if not event_topics: if not event_topics:
return [] return []
# 批量编码用户词标签词,避免逐条调用模型 # 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)
@@ -196,7 +200,7 @@ def recommend_events_for_user(
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:
normalized_topic = _normalize_text(topic_keyword) normalized_topic = _normalize_text(topic_keyword)
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
+9 -5
View File
@@ -1,4 +1,8 @@
# app/services/summary_service.py # app/services/summary_service.py
"""
摘要服务:调用 LLM 生成统一标题、综合摘要、话题标签
定时任务:对热度达标且未摘要的事件批量处理
"""
import json import json
import os import os
from datetime import timedelta from datetime import timedelta
@@ -36,7 +40,7 @@ deepseek_client = AsyncOpenAI(
async def call_llm_for_summary(platform_data_text: str) -> dict: async def call_llm_for_summary(platform_data_text: str) -> dict:
"""Call LLM for unified title, summary and topic candidates.""" """调用 LLM 生成统一标题、综合摘要、话题候选词"""
prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(platform_data_text=platform_data_text) prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(platform_data_text=platform_data_text)
response = await deepseek_client.chat.completions.create( response = await deepseek_client.chat.completions.create(
@@ -66,7 +70,7 @@ def _normalize_score(raw_score: Any) -> float | None:
def parse_topic_keywords(llm_result: dict) -> list[dict[str, Any]]: def parse_topic_keywords(llm_result: dict) -> list[dict[str, Any]]:
"""Parse topic keywords from LLM response; support list[str] and list[object].""" """解析 LLM 返回的话题关键词,支持字符串或对象格式"""
raw_topics = llm_result.get("topic_keywords") or [] raw_topics = llm_result.get("topic_keywords") or []
parsed: list[dict[str, Any]] = [] parsed: list[dict[str, Any]] = []
seen: set[str] = set() seen: set[str] = set()
@@ -103,7 +107,7 @@ def parse_topic_keywords(llm_result: dict) -> list[dict[str, Any]]:
def normalize_topic_keywords(topic_candidates: list[dict[str, Any]]) -> list[dict[str, Any]]: def normalize_topic_keywords(topic_candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Deduplicate semantically similar tags using embedding similarity.""" """用向量相似度去重同义标签,保留最具代表性的关键词"""
if not topic_candidates: if not topic_candidates:
return [] return []
@@ -159,7 +163,7 @@ def normalize_topic_keywords(topic_candidates: list[dict[str, Any]]) -> list[dic
def replace_event_topics(db, event_id: int, normalized_topics: list[dict[str, Any]]) -> None: def replace_event_topics(db, event_id: int, normalized_topics: list[dict[str, Any]]) -> None:
"""Replace EVENT tags for one unified event atomically within current transaction.""" """原子替换某事件的标签:先删旧再插新"""
db.query(ExtractedTopic).filter( db.query(ExtractedTopic).filter(
ExtractedTopic.target_type == TargetType.EVENT, ExtractedTopic.target_type == TargetType.EVENT,
ExtractedTopic.target_id == event_id, ExtractedTopic.target_id == event_id,
@@ -177,7 +181,7 @@ def replace_event_topics(db, event_id: int, normalized_topics: list[dict[str, An
async def generate_unified_summaries(): async def generate_unified_summaries():
"""Scheduled task: refresh summaries and topic tags for hot unified events.""" """定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签"""
print(f"[{utcnow()}] Start unified summary generation task...") print(f"[{utcnow()}] Start unified summary generation task...")
# 先提取需要处理的事件 ID,尽早释放 session,不长期占用 db session # 先提取需要处理的事件 ID,尽早释放 session,不长期占用 db session
+57
View File
@@ -0,0 +1,57 @@
import logging
import os
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from redis import Redis
logger = logging.getLogger(__name__)
try:
import redis # type: ignore
except ImportError: # pragma: no cover
redis = None # type: ignore
REDIS_URL = os.getenv("REDIS_URL", "").strip()
REDIS_CONNECT_TIMEOUT_SECONDS = float(os.getenv("REDIS_CONNECT_TIMEOUT_SECONDS", "2"))
REDIS_SOCKET_TIMEOUT_SECONDS = float(os.getenv("REDIS_SOCKET_TIMEOUT_SECONDS", "2"))
_redis_client: Optional["Redis"] = None
_initialized = False
def get_redis_client() -> Optional["Redis"]:
"""Return a singleton Redis client, or None when Redis is unavailable."""
global _redis_client, _initialized
if _initialized:
return _redis_client
_initialized = True
if not REDIS_URL:
logger.info("REDIS_URL 未配置,验证码将回退到数据库存储")
_redis_client = None
return _redis_client
if redis is None:
logger.warning("未安装 redis 包,验证码将回退到数据库存储")
_redis_client = None
return _redis_client
try:
_redis_client = redis.Redis.from_url(
REDIS_URL,
decode_responses=True,
socket_connect_timeout=REDIS_CONNECT_TIMEOUT_SECONDS,
socket_timeout=REDIS_SOCKET_TIMEOUT_SECONDS,
health_check_interval=30,
)
_redis_client.ping()
logger.info("Redis 连接成功,验证码将优先使用 Redis")
except Exception as exc: # pragma: no cover
logger.warning("Redis 连接失败,将回退到数据库存储。error=%s", exc)
_redis_client = None
return _redis_client
@@ -0,0 +1,38 @@
2026-03-11 18:27:39,440 [INFO] delivery_service: 推送调度检查 @ UTC 10:27
2026-03-11 18:28:39,441 [INFO] delivery_service: 推送调度检查 @ UTC 10:28
2026-03-11 18:28:39,445 [INFO] delivery_service: 用户 1 未配置关键词,使用默认热点模式
2026-03-11 18:28:40,429 [INFO] delivery_service: 用户 1 邮件发送成功 → 1925008984@qq.com
2026-03-11 18:29:39,433 [INFO] delivery_service: 推送调度检查 @ UTC 10:29
2026-03-11 18:29:39,433 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-11 18:30:39,431 [INFO] delivery_service: 推送调度检查 @ UTC 10:30
2026-03-11 18:30:39,432 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-11 18:31:39,430 [INFO] delivery_service: 推送调度检查 @ UTC 10:31
2026-03-11 18:33:50,301 [INFO] delivery_service: 推送调度检查 @ UTC 10:33
2026-03-11 18:34:50,295 [INFO] delivery_service: 推送调度检查 @ UTC 10:34
2026-03-11 18:35:50,303 [INFO] delivery_service: 推送调度检查 @ UTC 10:35
2026-03-11 18:36:50,303 [INFO] delivery_service: 推送调度检查 @ UTC 10:36
2026-03-11 18:37:50,301 [INFO] delivery_service: 推送调度检查 @ UTC 10:37
2026-03-11 18:38:50,302 [INFO] delivery_service: 推送调度检查 @ UTC 10:38
2026-03-11 18:39:50,302 [INFO] delivery_service: 推送调度检查 @ UTC 10:39
2026-03-11 18:40:50,304 [INFO] delivery_service: 推送调度检查 @ UTC 10:40
2026-03-11 18:41:50,308 [INFO] delivery_service: 推送调度检查 @ UTC 10:41
2026-03-11 18:42:50,562 [INFO] delivery_service: 推送调度检查 @ UTC 10:42
2026-03-11 18:43:50,296 [INFO] delivery_service: 推送调度检查 @ UTC 10:43
2026-03-11 18:44:50,297 [INFO] delivery_service: 推送调度检查 @ UTC 10:44
2026-03-11 18:45:50,299 [INFO] delivery_service: 推送调度检查 @ UTC 10:45
2026-03-11 18:46:50,301 [INFO] delivery_service: 推送调度检查 @ UTC 10:46
2026-03-11 18:47:50,306 [INFO] delivery_service: 推送调度检查 @ UTC 10:47
2026-03-11 18:48:50,299 [INFO] delivery_service: 推送调度检查 @ UTC 10:48
2026-03-11 18:49:50,304 [INFO] delivery_service: 推送调度检查 @ UTC 10:49
2026-03-11 18:50:50,306 [INFO] delivery_service: 推送调度检查 @ UTC 10:50
2026-03-11 18:51:50,309 [INFO] delivery_service: 推送调度检查 @ UTC 10:51
2026-03-11 18:52:50,321 [INFO] delivery_service: 推送调度检查 @ UTC 10:52
2026-03-11 18:53:50,298 [INFO] delivery_service: 推送调度检查 @ UTC 10:53
2026-03-11 18:54:50,298 [INFO] delivery_service: 推送调度检查 @ UTC 10:54
2026-03-11 18:55:50,303 [INFO] delivery_service: 推送调度检查 @ UTC 10:55
2026-03-11 18:56:50,298 [INFO] delivery_service: 推送调度检查 @ UTC 10:56
2026-03-11 20:32:14,068 [INFO] delivery_service: 用户 5 关键词匹配,推送 3 条事件
2026-03-11 20:32:15,198 [INFO] delivery_service: 用户 5 邮件发送成功 → 2170308303@qq.com
2026-03-11 20:33:13,661 [INFO] delivery_service: 用户 5 仍在 30 分钟冷却期内,跳过
2026-03-11 20:34:13,657 [INFO] delivery_service: 用户 5 仍在 30 分钟冷却期内,跳过
2026-03-11 20:35:13,662 [INFO] delivery_service: 用户 5 仍在 30 分钟冷却期内,跳过
@@ -0,0 +1,14 @@
2026-03-12 08:28:37,678 [INFO] delivery_service: 用户 1 关键词匹配,推送 6 条事件
2026-03-12 08:28:39,467 [INFO] delivery_service: 用户 1 邮件发送成功 → 1925008984@qq.com
2026-03-12 08:29:37,533 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-12 08:30:37,536 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-12 08:31:37,532 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-12 08:32:37,531 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-12 18:27:29,895 [INFO] delivery_service: 用户 1 关键词匹配,推送 6 条事件
2026-03-12 18:27:31,306 [INFO] delivery_service: 用户 1 邮件发送成功 → 1925008984@qq.com
2026-03-12 18:28:29,849 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-12 18:29:29,849 [INFO] delivery_service: 用户 1 仍在 30 分钟冷却期内,跳过
2026-03-12 20:32:49,821 [INFO] delivery_service: 用户 5 关键词匹配,推送 6 条事件
2026-03-12 20:32:51,028 [INFO] delivery_service: 用户 5 邮件发送成功 → 2170308303@qq.com
2026-03-12 20:33:49,671 [INFO] delivery_service: 用户 5 仍在 30 分钟冷却期内,跳过
2026-03-12 20:34:49,680 [INFO] delivery_service: 用户 5 仍在 30 分钟冷却期内,跳过
+1
View File
@@ -1,3 +1,4 @@
<!-- 根组件路由出口带页面切换淡入淡出动画 -->
<template> <template>
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<transition name="page-fade" mode="out-in"> <transition name="page-fade" mode="out-in">
+1 -1
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { pinia } from '@/stores' import { pinia } from '@/stores'
import { fetchApi } from '@/config/apiBase' import { fetchApi } from '@/config/apiBase'
// 后端错误消息中英文映射 // 后端英文错误消息到中文的映射
const MESSAGE_MAP: Record<string, string> = { const MESSAGE_MAP: Record<string, string> = {
'You can only operate your own resources': '只能操作你自己的资源', 'You can only operate your own resources': '只能操作你自己的资源',
'Preference keyword already exists for this user': '该关键词已经订阅过了', 'Preference keyword already exists for this user': '该关键词已经订阅过了',
+1 -1
View File
@@ -36,7 +36,7 @@ export function searchEventsTimeline(
hours: number = 168, hours: number = 168,
mode: 'exact' | 'semantic' | 'hybrid' = 'hybrid' mode: 'exact' | 'semantic' | 'hybrid' = 'hybrid'
): Promise<SearchTimelineResponse> { ): Promise<SearchTimelineResponse> {
// JS 的 getTimezoneOffset: 本地 - UTC东八区 -480),这里转成 UTC+ 偏移分钟。 // 获取客户端时区偏移:getTimezoneOffset 本地-UTC 分钟,东八区 -480,转为 UTC+ 偏移
const utcOffsetMinutes = -new Date().getTimezoneOffset() const utcOffsetMinutes = -new Date().getTimezoneOffset()
return apiGet<SearchTimelineResponse>('/events/search_timeline', { return apiGet<SearchTimelineResponse>('/events/search_timeline', {
keyword, keyword,
+3
View File
@@ -1,3 +1,6 @@
/**
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer
*/
import type { import type {
AuthTokenResponse, AuthTokenResponse,
LoginPayload, LoginPayload,
+1
View File
@@ -1,3 +1,4 @@
<!-- 品牌 Logo雷达扫描风格 SVG带呼吸灯与旋转动画 -->
<template> <template>
<div class="brand-logo-container"> <div class="brand-logo-container">
<svg class="insight-logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="insight-logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+201
View File
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
modelValue: string | number
options: { label: string; value: string | number }[]
icon: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void
}>()
const isOpen = ref(false)
const selectRef = ref<HTMLElement | null>(null)
const toggle = () => {
isOpen.value = !isOpen.value
}
const selectOption = (value: string | number) => {
emit('update:modelValue', value)
isOpen.value = false
}
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.value && !selectRef.value.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div class="custom-select-wrapper" ref="selectRef">
<i :class="['select-icon', icon]"></i>
<div class="select-trigger" :class="{ 'is-open': isOpen }" @click="toggle">
<span class="selected-label">
{{ options.find(o => o.value === modelValue)?.label }}
</span>
<i class="fa-solid fa-chevron-down select-arrow"></i>
</div>
<transition name="dropdown">
<ul v-if="isOpen" class="select-dropdown">
<li
v-for="option in options"
:key="option.value"
class="select-option"
:class="{ 'is-active': option.value === modelValue }"
@click="selectOption(option.value)"
>
{{ option.label }}
</li>
</ul>
</transition>
</div>
</template>
<style scoped>
.custom-select-wrapper {
position: relative;
display: flex;
align-items: center;
min-width: 140px;
}
.select-icon {
position: absolute;
left: 14px;
color: var(--text-placeholder);
font-size: 14px;
pointer-events: none;
z-index: 2;
transition: color 0.2s;
}
.select-arrow {
position: absolute;
right: 12px;
color: var(--text-placeholder);
font-size: 11px;
pointer-events: none;
z-index: 2;
transition: color 0.2s, transform 0.2s;
}
.select-trigger {
width: 100%;
padding: 12px 34px 12px 38px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background-color: var(--bg-input);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
user-select: none;
white-space: nowrap;
}
.select-trigger:hover {
border-color: var(--border-strong);
background-color: var(--bg-surface);
}
.select-trigger.is-open {
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
background-color: var(--bg-surface);
}
.select-trigger.is-open .select-arrow {
transform: rotate(180deg);
color: var(--brand-primary);
}
.custom-select-wrapper:hover .select-icon,
.custom-select-wrapper:hover .select-arrow {
color: var(--brand-primary);
}
.select-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 100%;
min-width: 100%;
margin: 0;
padding: 6px;
list-style: none;
background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur, blur(12px));
-webkit-backdrop-filter: var(--backdrop-blur, blur(12px));
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
z-index: 100;
max-height: 250px;
overflow-y: auto;
}
.select-option {
padding: 10px 12px;
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
margin-bottom: 2px;
white-space: nowrap;
}
.select-option:last-child {
margin-bottom: 0;
}
.select-option:hover {
background-color: var(--bg-input);
color: var(--text-primary);
}
.select-option.is-active {
background-color: var(--brand-primary-alpha);
color: var(--brand-primary);
font-weight: 600;
}
/* 滚动条美化 */
.select-dropdown::-webkit-scrollbar {
width: 4px;
}
.select-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.select-dropdown::-webkit-scrollbar-thumb {
background-color: var(--border-strong);
border-radius: 4px;
}
/* 过渡动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
+4 -4
View File
@@ -4,17 +4,17 @@ import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore() const themeStore = useThemeStore()
/** /**
* 切换主题,使用 View Transitions API 实现高级扩散动画(如果浏览器支持) * 切换主题:支持 View Transitions API 时使用点击位置扩散动画,
* 这种动画比之前像玩具一样的开关要高级得多,提供原生级的丝滑过渡 * 否则直接切换
*/ */
function handleToggle(event: MouseEvent) { function handleToggle(event: MouseEvent) {
// 检浏览器是否支持 document.startViewTransition 且用户没有开启减弱动画 // 检浏览器是否支持 View Transitions 且用户开启减弱动画
const isAppearanceTransition = typeof document !== 'undefined' && const isAppearanceTransition = typeof document !== 'undefined' &&
'startViewTransition' in document && 'startViewTransition' in document &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches !window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!isAppearanceTransition) { if (!isAppearanceTransition) {
// 降级处理:直接切换 // 不支持时直接切换,无动画
themeStore.toggleTheme() themeStore.toggleTheme()
return return
} }
@@ -1,3 +1,4 @@
<!-- 统一事件卡片展示标题摘要平台来源排名轨迹悬停展开图表 -->
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
+5
View File
@@ -1,3 +1,6 @@
/**
* API 基础配置:自动探测内网/公网后端,失败时回退公网
*/
const API_PREFIX = '/api/v1' const API_PREFIX = '/api/v1'
const LAN_BACKEND_ORIGIN = 'http://10.252.130.135:8000' const LAN_BACKEND_ORIGIN = 'http://10.252.130.135:8000'
const PUBLIC_BACKEND_ORIGIN = 'http://47.107.130.88:51290' const PUBLIC_BACKEND_ORIGIN = 'http://47.107.130.88:51290'
@@ -42,6 +45,7 @@ function isLanHostname(hostname: string): boolean {
return isPrivateIpv4(normalized) return isPrivateIpv4(normalized)
} }
// 探测内网后端是否可用(请求 openapi.json
async function probeLanBackend(): Promise<boolean> { async function probeLanBackend(): Promise<boolean> {
if (typeof window === 'undefined') return false if (typeof window === 'undefined') return false
@@ -69,6 +73,7 @@ async function probeLanBackend(): Promise<boolean> {
} }
} }
// 根据当前 hostname 与探测结果选择内网或公网 API 地址
async function detectApiBaseUrl(): Promise<string> { async function detectApiBaseUrl(): Promise<string> {
if (ENV_API_BASE_URL) return ENV_API_BASE_URL if (ENV_API_BASE_URL) return ENV_API_BASE_URL
if (typeof window === 'undefined') return PUBLIC_API_BASE_URL if (typeof window === 'undefined') return PUBLIC_API_BASE_URL
+1
View File
@@ -1,3 +1,4 @@
<!-- 仪表盘布局侧边栏导航主内容区移动端抽屉 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
+4
View File
@@ -1,3 +1,6 @@
/**
* 应用入口:初始化 Vue、Pinia、路由、主题
*/
import './assets/main.css' import './assets/main.css'
import { createApp } from 'vue' import { createApp } from 'vue'
@@ -9,6 +12,7 @@ import { useThemeStore } from './stores/theme'
const app = createApp(App) const app = createApp(App)
// 先挂载 Pinia,再初始化主题(依赖 Pinia)
app.use(pinia) app.use(pinia)
const themeStore = useThemeStore(pinia) const themeStore = useThemeStore(pinia)
themeStore.initTheme() themeStore.initTheme()
+4
View File
@@ -1,3 +1,6 @@
/**
* 路由配置:认证页(guestOnly)、仪表盘子路由(requiresAuth
*/
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { pinia } from '@/stores' import { pinia } from '@/stores'
@@ -59,6 +62,7 @@ const router = createRouter({
], ],
}) })
// 全局前置守卫:认证校验、未登录重定向
router.beforeEach((to) => { router.beforeEach((to) => {
const authStore = useAuthStore(pinia) const authStore = useAuthStore(pinia)
authStore.restore() authStore.restore()
+4
View File
@@ -1,3 +1,6 @@
/**
* 认证状态:Token、用户信息持久化,登录/注册/登出
*/
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -12,6 +15,7 @@ interface PersistedAuthState {
const AUTH_STORAGE_KEY = 'insight-radar-auth' const AUTH_STORAGE_KEY = 'insight-radar-auth'
// 从 localStorage 恢复登录状态
function loadPersistedState(): PersistedAuthState | null { function loadPersistedState(): PersistedAuthState | null {
const raw = localStorage.getItem(AUTH_STORAGE_KEY) const raw = localStorage.getItem(AUTH_STORAGE_KEY)
if (!raw) { if (!raw) {
+3
View File
@@ -1,3 +1,6 @@
/**
* 主题状态:深色/浅色切换,持久化到 localStorage,支持系统偏好
*/
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
+2 -1
View File
@@ -1,6 +1,7 @@
<!-- 关于页占位 -->
<template> <template>
<div class="about"> <div class="about">
<h1>This is an about page</h1> <h1>关于 InsightRadar</h1>
</div> </div>
</template> </template>
+1
View File
@@ -1,3 +1,4 @@
<!-- 主仪表盘事件流为你推荐公关修改追踪系统状态 -->
<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'
+1
View File
@@ -1,3 +1,4 @@
<!-- 推送设置页管理推送时间表与推送渠道邮箱等 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'
+1
View File
@@ -1,3 +1,4 @@
<!-- 概览页展示当前账户会话状态认证接入说明 -->
<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'
+2
View File
@@ -1,3 +1,4 @@
<!-- 登录页支持密码登录与邮箱验证码登录 -->
<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'
@@ -8,6 +9,7 @@ import { useAuthStore } from '@/stores/auth'
type LoginMode = 'password' | 'code' type LoginMode = 'password' | 'code'
// 验证码重发冷却时间(秒)
const CODE_RESEND_SECONDS = 60 const CODE_RESEND_SECONDS = 60
const authStore = useAuthStore() const authStore = useAuthStore()
+2 -3
View File
@@ -1,3 +1,4 @@
<!-- 注册页邮箱验证码 + 密码带密码强度提示 -->
<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'
@@ -34,7 +35,7 @@ const canSendCode = computed(() => {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email) return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
}) })
// === 新增:密码强度计算逻辑 === // 密码强度:根据长度、字母、数字、特殊字符计算 0~4 档
const passwordStrength = computed(() => { const passwordStrength = computed(() => {
const pwd = form.password const pwd = form.password
if (!pwd) return 0 if (!pwd) return 0
@@ -52,8 +53,6 @@ const strengthColor = computed(() => {
return colors[passwordStrength.value] return colors[passwordStrength.value]
}) })
// ==========================
function startCooldown(seconds = CODE_RESEND_SECONDS) { function startCooldown(seconds = CODE_RESEND_SECONDS) {
countdown.value = Math.max(1, seconds) countdown.value = Math.max(1, seconds)
if (countdownTimer) clearInterval(countdownTimer) if (countdownTimer) clearInterval(countdownTimer)
+1
View File
@@ -1,3 +1,4 @@
<!-- 公关修改追踪页展示热搜标题被偷偷修改的历史记录 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
+33 -76
View File
@@ -1,9 +1,11 @@
<!-- 事件追踪分析页关键词搜索时间热度图表关联事件列表 -->
<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'
import { searchEventsTimeline } from '@/api/events' import { searchEventsTimeline } from '@/api/events'
import type { SearchTimelineResponse } from '@/types/event' import type { SearchTimelineResponse } from '@/types/event'
import UnifiedEventCard from '@/components/UnifiedEventCard.vue' import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
import CustomSelect from '@/components/CustomSelect.vue'
const keyword = ref('') const keyword = ref('')
const searchResult = ref<SearchTimelineResponse | null>(null) const searchResult = ref<SearchTimelineResponse | null>(null)
@@ -12,6 +14,22 @@ const hours = ref(2)
const searchMode = ref<'exact' | 'semantic' | 'hybrid'>('hybrid') const searchMode = ref<'exact' | 'semantic' | 'hybrid'>('hybrid')
const selectedTimeLabel = ref<string | null>(null) const selectedTimeLabel = ref<string | null>(null)
const timeOptions = [
{ label: '最近 2 小时', value: 2 },
{ label: '最近 12 小时', value: 12 },
{ label: '最近 24 小时', value: 24 },
{ label: '最近 48 小时', value: 48 },
{ label: '最近 7 天', value: 168 },
{ label: '最近 15 天', value: 360 }
]
const modeOptions = [
{ label: '混合匹配', value: 'hybrid' },
{ label: '关键词匹配', value: 'exact' },
{ label: '语义匹配', value: 'semantic' }
]
// 根据选中的时间点筛选事件:点击图表节点时只显示该时间点关联的事件
const filteredEvents = computed(() => { const filteredEvents = computed(() => {
if (!searchResult.value) return [] if (!searchResult.value) return []
if (!selectedTimeLabel.value) return searchResult.value.events if (!selectedTimeLabel.value) return searchResult.value.events
@@ -51,6 +69,7 @@ const chartOptions = ref({
easing: 'easeinout', easing: 'easeinout',
speed: 800, speed: 800,
}, },
// 点击图表数据点:切换选中时间,再次点击则取消筛选
events: { events: {
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) { markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) {
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) { if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
@@ -188,27 +207,16 @@ async function handleSearch() {
class="search-input" class="search-input"
/> />
</div> </div>
<div class="time-select-wrapper"> <CustomSelect
<i class="fa-regular fa-clock select-icon"></i> v-model="hours"
<select v-model="hours" class="time-select"> :options="timeOptions"
<option :value="2">最近 2 小时</option> icon="fa-regular fa-clock"
<option :value="12">最近 12 小时</option> />
<option :value="24">最近 24 小时</option> <CustomSelect
<option :value="48">最近 48 小时</option> v-model="searchMode"
<option :value="168">最近 7 </option> :options="modeOptions"
<option :value="360">最近 15 </option> icon="fa-solid fa-filter"
</select> />
<i class="fa-solid fa-chevron-down select-arrow"></i>
</div>
<div class="time-select-wrapper">
<i class="fa-solid fa-filter select-icon"></i>
<select v-model="searchMode" class="time-select">
<option value="hybrid">混合匹配</option>
<option value="exact">关键词匹配</option>
<option value="semantic">语义匹配</option>
</select>
<i class="fa-solid fa-chevron-down select-arrow"></i>
</div>
<button class="btn btn-primary" @click="handleSearch" :disabled="loading || !keyword.trim()"> <button class="btn btn-primary" @click="handleSearch" :disabled="loading || !keyword.trim()">
<i class="fa-solid fa-bolt" v-if="!loading"></i> <i class="fa-solid fa-bolt" v-if="!loading"></i>
<i class="fa-solid fa-spinner fa-spin" v-else></i> <i class="fa-solid fa-spinner fa-spin" v-else></i>
@@ -359,6 +367,8 @@ async function handleSearch() {
display: flex; display: flex;
gap: 24px; gap: 24px;
align-items: stretch; align-items: stretch;
position: relative;
z-index: 10; /* 确保搜索框及下拉选择框显示在下方图表之上 */
} }
.search-box { .search-box {
@@ -470,61 +480,6 @@ async function handleSearch() {
background-color: var(--bg-surface); background-color: var(--bg-surface);
} }
.time-select-wrapper {
position: relative;
display: flex;
align-items: center;
min-width: 160px;
}
.select-icon {
position: absolute;
left: 14px;
color: var(--text-placeholder);
font-size: 15px;
pointer-events: none;
z-index: 1;
}
.select-arrow {
position: absolute;
right: 14px;
color: var(--text-placeholder);
font-size: 12px;
pointer-events: none;
}
.time-select {
width: 100%;
padding: 14px 36px 14px 40px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background-color: var(--bg-input);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
appearance: none;
-webkit-appearance: none;
}
.time-select:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
background-color: var(--bg-surface);
}
.time-select-wrapper:hover .time-select {
border-color: var(--brand-primary);
}
.time-select-wrapper:hover .select-icon,
.time-select-wrapper:hover .select-arrow {
color: var(--brand-primary);
}
.btn-primary { .btn-primary {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -562,6 +517,8 @@ async function handleSearch() {
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
animation: fadeIn 0.4s ease-out; animation: fadeIn 0.4s ease-out;
position: relative;
z-index: 1; /* 低于 top-panels,避免图表覆盖搜索框下拉 */
} }
.section-header { .section-header {
+1
View File
@@ -1,3 +1,4 @@
<!-- 兴趣关键词页添加/删除关键词查看命中事件 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'