mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-05 23:07:51 +08:00
optimize+注释
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
"""
|
||||
认证模块:用户注册、登录、邮箱验证码(支持 Redis / 数据库双存储与自动降级)
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from datetime import timedelta, timezone
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
import random
|
||||
|
||||
from app.api.dependencies import get_db
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
@@ -27,6 +31,7 @@ from app.schemas.auth_schema import (
|
||||
UserProfileResponse,
|
||||
)
|
||||
from app.utils.email_utils import send_html_email
|
||||
from app.utils.redis_client import get_redis_client
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -34,6 +39,7 @@ router = APIRouter()
|
||||
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
|
||||
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
|
||||
DEFAULT_CODE_SEND_COOLDOWN_SECONDS = 60
|
||||
|
||||
REGISTER_CODE_EXPIRE_MINUTES = int(
|
||||
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))
|
||||
)
|
||||
|
||||
# 可选值: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:
|
||||
"""统一邮箱格式:去空格、转小写,保证 Redis key 与数据库查询一致"""
|
||||
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:
|
||||
"""将同一邮箱、同一用途下未使用的旧验证码全部标记为已使用,避免重复使用"""
|
||||
db.query(EmailVerificationCode).filter(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
@@ -76,6 +243,7 @@ def _create_code_record(
|
||||
purpose: VerificationPurpose,
|
||||
expire_minutes: int,
|
||||
) -> Tuple[EmailVerificationCode, str]:
|
||||
"""在数据库中创建验证码记录,返回 (记录对象, 明文验证码)"""
|
||||
code = generate_verification_code()
|
||||
now = utcnow()
|
||||
code_record = EmailVerificationCode(
|
||||
@@ -89,13 +257,59 @@ def _create_code_record(
|
||||
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:
|
||||
"""
|
||||
防抖:限制同一邮箱同一用途验证码的发送频率,避免用户短时间连续点击。
|
||||
"""
|
||||
"""限制同一邮箱同一用途验证码的发送频率。"""
|
||||
if CODE_SEND_COOLDOWN_SECONDS <= 0:
|
||||
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 = (
|
||||
db.query(EmailVerificationCode)
|
||||
.filter(
|
||||
@@ -135,6 +349,7 @@ def _build_auth_response(user: AppUser) -> AuthTokenResponse:
|
||||
|
||||
@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()
|
||||
@@ -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")
|
||||
|
||||
_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)
|
||||
code_record, code = _create_code_record(
|
||||
db,
|
||||
@@ -149,13 +373,53 @@ async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Dep
|
||||
purpose=VerificationPurpose.REGISTER,
|
||||
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(
|
||||
to_email=email,
|
||||
subject=f"【{code}】InsightRadar 注册验证码",
|
||||
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:
|
||||
_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
|
||||
db.add(code_record)
|
||||
db.commit()
|
||||
@@ -169,6 +433,7 @@ async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Dep
|
||||
|
||||
@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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
_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)
|
||||
code_record, code = _create_code_record(
|
||||
db,
|
||||
@@ -183,13 +457,52 @@ async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(g
|
||||
purpose=VerificationPurpose.LOGIN,
|
||||
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(
|
||||
to_email=email,
|
||||
subject=f"【{code}】InsightRadar 登录验证码",
|
||||
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:
|
||||
_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
|
||||
db.add(code_record)
|
||||
db.commit()
|
||||
@@ -207,25 +520,46 @@ async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(g
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
||||
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户"""
|
||||
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()
|
||||
redis_result = _verify_code_with_redis(
|
||||
email,
|
||||
VerificationPurpose.REGISTER,
|
||||
payload.verification_code,
|
||||
strict=False, # Never be strict so we can fallback to DB if redis is down
|
||||
)
|
||||
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:
|
||||
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")
|
||||
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]
|
||||
user = AppUser(
|
||||
email=email,
|
||||
@@ -234,9 +568,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
||||
metadata_={"email_verified_at": now.isoformat()},
|
||||
)
|
||||
|
||||
code_record.is_used = True
|
||||
db.add(user)
|
||||
if code_record is not None:
|
||||
code_record.is_used = True
|
||||
db.add(code_record)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
@@ -245,6 +581,7 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
||||
|
||||
@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()
|
||||
|
||||
@@ -259,28 +596,48 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("/login/code", response_model=AuthTokenResponse)
|
||||
async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)):
|
||||
"""验证码登录:Redis 校验优先,失败则从数据库兜底"""
|
||||
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()
|
||||
redis_result = _verify_code_with_redis(
|
||||
email,
|
||||
VerificationPurpose.LOGIN,
|
||||
payload.verification_code,
|
||||
strict=False, # Never be strict so we can fallback to DB if redis is down
|
||||
)
|
||||
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:
|
||||
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")
|
||||
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
|
||||
db.add(code_record)
|
||||
|
||||
db.commit()
|
||||
|
||||
return _build_auth_response(user)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# 推送设置 API:管理用户的推送时间表和推送渠道
|
||||
# 关键约束:同一用户两条推送时间间隔至少 30 分钟
|
||||
from datetime import time as dt_time
|
||||
from typing import List
|
||||
|
||||
@@ -73,6 +74,7 @@ def _check_min_gap(
|
||||
existing = query.all()
|
||||
new_minutes = _time_to_minutes(new_time)
|
||||
|
||||
# 考虑跨午夜情况:如 23:50 与 00:10 实际只差 20 分钟
|
||||
for s in existing:
|
||||
old_minutes = _time_to_minutes(s.delivery_time)
|
||||
diff = abs(new_minutes - old_minutes)
|
||||
@@ -146,7 +148,7 @@ def create_delivery_schedule(
|
||||
_ensure_self_access(user_id, current_user)
|
||||
|
||||
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(
|
||||
user_id=user_id,
|
||||
delivery_time=parsed_time,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# app/api/endpoints/events.py
|
||||
# app/api/endpoints/events.py
|
||||
"""
|
||||
事件模块:统一事件列表、详情、搜索时间线(支持精确/语义/混合匹配)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -74,6 +77,7 @@ def list_unified_events(
|
||||
):
|
||||
"""查询统一事件列表,并附带平台趋势与标签信息。"""
|
||||
|
||||
# 短期内存缓存,减轻高并发下数据库压力
|
||||
cache_key = f"{min_hot}:{hours}:{sort_by}:{skip}:{limit}"
|
||||
current_time = time.time()
|
||||
if cache_key in _UNIFIED_EVENTS_CACHE:
|
||||
@@ -83,6 +87,7 @@ def list_unified_events(
|
||||
|
||||
time_limit = utcnow() - timedelta(hours=hours)
|
||||
|
||||
# 按热度、时间过滤,再关联平台趋势、排名轨迹、标签
|
||||
base_query = db.query(UnifiedEvent).filter(
|
||||
UnifiedEvent.hot_score >= min_hot,
|
||||
UnifiedEvent.created_at >= time_limit,
|
||||
@@ -328,6 +333,7 @@ def search_events_timeline(
|
||||
matched_event_ids: set[int] = set()
|
||||
matched_trend_points: list[tuple[int, str]] = []
|
||||
|
||||
# 遍历统一事件与平台趋势,按模式做精确/语义匹配
|
||||
for ev in all_recent_unified:
|
||||
text_matched = False
|
||||
if use_regex and pattern is not None:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
用户偏好模块:兴趣关键词的增删查、基于关键词的个性化事件推荐
|
||||
"""
|
||||
import time
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
@@ -140,7 +143,7 @@ def recommend_events(
|
||||
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
|
||||
_ensure_self_access(user_id, current_user)
|
||||
|
||||
# --- 1. 尝试从缓存读取 ---
|
||||
# 推荐结果缓存,避免频繁调用匹配服务
|
||||
cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}"
|
||||
current_time = time.time()
|
||||
|
||||
@@ -184,7 +187,7 @@ def recommend_events(
|
||||
data=result_data,
|
||||
)
|
||||
|
||||
# --- 2. 写入缓存 ---
|
||||
# 写入缓存,超过 2000 条时清空防止内存膨胀
|
||||
if len(_RECOMMEND_CACHE) > 2000:
|
||||
# 防止内存无限增长
|
||||
_RECOMMEND_CACHE.clear()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录
|
||||
# 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录,用于舆情监测
|
||||
from datetime import timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -39,6 +39,7 @@ def list_headline_revisions(
|
||||
"""
|
||||
time_limit = utcnow() - timedelta(hours=hours)
|
||||
|
||||
# 关联 TrendingEvent、InfoSource 获取平台名和链接
|
||||
rows = (
|
||||
db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url)
|
||||
.join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# app/api/endpoints/sources.py
|
||||
"""
|
||||
信息源模块:信息源的增删改查,供爬虫与后台管理使用
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
@@ -14,7 +17,7 @@ router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=InfoSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_info_source(source_in: InfoSourceCreate, db: Session = Depends(get_db)):
|
||||
"""新建一个信息源"""
|
||||
"""新建一个信息源(如微博热搜、知乎热榜等)"""
|
||||
return crud_source.create(db=db, obj_in=source_in)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 系统状态监控 API:返回爬虫集群运行概况
|
||||
# 系统状态监控 API:返回爬虫集群运行概况(信息源数、今日抓取量、最近同步时间等)
|
||||
from datetime import datetime, timedelta
|
||||
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)
|
||||
|
||||
# 信息源统计
|
||||
# 信息源统计:总数与启用数
|
||||
total_sources = db.query(func.count(InfoSource.id)).scalar() or 0
|
||||
active_sources = (
|
||||
db.query(func.count(InfoSource.id))
|
||||
@@ -36,7 +36,7 @@ def get_system_stats(db: Session = Depends(get_db)):
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# 今日任务统计
|
||||
# 今日任务统计:抓取条数、成功/失败任务数
|
||||
today_tasks = (
|
||||
db.query(DataSyncTask)
|
||||
.filter(DataSyncTask.created_at >= today_start)
|
||||
|
||||
@@ -60,7 +60,13 @@ def hash_verification_code(code: str) -> str:
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# app/crud/crud_source.py
|
||||
"""
|
||||
信息源 CRUD:对 InfoSource 的增删改查,供 API 与爬虫使用
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
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:
|
||||
"""更新信息源"""
|
||||
# 提取前端真正要求更新的字段
|
||||
"""更新信息源,仅更新前端传入的字段(exclude_unset=True)"""
|
||||
update_data = obj_in.model_dump(exclude_unset=True)
|
||||
|
||||
# 遍历更新模型对象的属性
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# database.py
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# SQLite 数据库文件位置
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/demo.db"
|
||||
load_dotenv()
|
||||
|
||||
# 数据库连接 URL,可从 .env 配置,默认 SQLite
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("SQLALCHEMY_DATABASE_URL", "sqlite:///./data/demo.db")
|
||||
|
||||
# 创建数据库引擎
|
||||
# 增加 timeout=30 允许连接在遇到 locked 时最多等待 30 秒,而不是直接报错
|
||||
|
||||
@@ -118,11 +118,11 @@ EVENT_CARD_TEMPLATE = """\
|
||||
|
||||
def _hot_level(score: int) -> tuple[str, str, str]:
|
||||
"""返回 (label, badge_class, hot_class)"""
|
||||
if score >= 50:
|
||||
return "全网沸腾", "badge-hot", " is-hot"
|
||||
if score >= 20:
|
||||
return "高度关注", "badge-warm", ""
|
||||
if score >= 10:
|
||||
return "全网沸腾", "badge-hot", " is-hot"
|
||||
if score >= 5:
|
||||
return "高度关注", "badge-warm", ""
|
||||
if score >= 3:
|
||||
return "上升中", "badge-normal", ""
|
||||
return "一般关注", "badge-tag", ""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 定时推送调度服务
|
||||
# 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送,
|
||||
# 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。
|
||||
# 推送优先级:有关键词且匹配 → 个性化简报;无关键词或无匹配 → 默认热点快报
|
||||
import logging
|
||||
import os
|
||||
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:
|
||||
"""检查用户是否仍在 30 分钟冷却期内。"""
|
||||
"""检查用户是否仍在冷却期内,避免短时间内重复推送"""
|
||||
row = (
|
||||
db.query(DeliveryHistory.created_at)
|
||||
.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)
|
||||
|
||||
# ——— 决策:匹配模式 or 默认模式 ———
|
||||
# 决策:有关键词且有匹配 → 匹配模式;否则 → 默认热点模式
|
||||
items: list = []
|
||||
is_default = False
|
||||
|
||||
@@ -411,7 +412,7 @@ async def check_and_deliver() -> None:
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# 用户本地时间对比(核心时区修正)
|
||||
# 将 UTC 转为用户本地时间,判断是否落在推送窗口内
|
||||
user_current = _user_local_time(now, user.timezone)
|
||||
if not _is_within_window(schedule.delivery_time, user_current):
|
||||
continue
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# app/services/fetcher_service.py
|
||||
"""
|
||||
抓取服务:从外部 API 拉取热搜/RSS 数据,做查重、向量聚类、入库
|
||||
热搜分支:语义聚类到 UnifiedEvent;RSS 分支:写入 NewsArticle
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
@@ -29,7 +33,7 @@ print("模型加载完成。")
|
||||
|
||||
|
||||
def generate_md5(text: str) -> str:
|
||||
"""生成32位MD5哈希值作为全局唯一指纹"""
|
||||
"""生成 32 位 MD5 作为 external_id,用于跨平台去重"""
|
||||
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
@@ -66,6 +70,7 @@ class UnifiedEventClusterer:
|
||||
self.event_ids.append(ev.id)
|
||||
|
||||
def match_or_create(self, title: str, embedding_json: str, new_vec: np.ndarray) -> int:
|
||||
"""语义相似则归入已有事件并累加热度,否则创建新 UnifiedEvent"""
|
||||
if self.event_vectors:
|
||||
# 批量矩阵计算相似度
|
||||
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
|
||||
|
||||
# 核心逻辑:查重后再决定是否调用模型
|
||||
# 查重:已存在则可能只需更新标题/排名;不存在则需聚类并新建
|
||||
if existing_event:
|
||||
# 场景 A1:老熟人
|
||||
if existing_event.current_headline != title:
|
||||
@@ -204,7 +209,7 @@ def process_source_data(db, source, items: list) -> int:
|
||||
if not valid_items:
|
||||
return 0
|
||||
|
||||
# 2. 批量数据库查重
|
||||
# 批量查重:按 external_id 判断是更新还是新增
|
||||
existing_events_dict = {}
|
||||
existing_articles_dict = {}
|
||||
|
||||
@@ -221,7 +226,7 @@ def process_source_data(db, source, items: list) -> int:
|
||||
).all()
|
||||
existing_articles_dict = {art.external_id: art for art in existing_articles}
|
||||
|
||||
# 3. 筛选出需要进行大模型向量运算的文本
|
||||
# 仅对需要算向量的标题做批量 embedding,避免重复计算
|
||||
texts_to_embed = []
|
||||
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
||||
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):
|
||||
clusterer = UnifiedEventClusterer(db)
|
||||
|
||||
# 5. 核心路由分流落库
|
||||
# 按来源类型分流:热搜/API → TrendingEvent + 聚类;RSS → NewsArticle
|
||||
for index, (item, external_id) in enumerate(valid_items, 1):
|
||||
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
||||
existing_event = existing_events_dict.get(external_id)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
匹配服务:根据用户兴趣关键词(精确 + 语义)推荐事件
|
||||
打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度加成
|
||||
"""
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -123,7 +127,7 @@ def recommend_events_for_user(
|
||||
else PREFERENCE_SEMANTIC_THRESHOLD
|
||||
)
|
||||
|
||||
# 读取用户兴趣词
|
||||
# 1. 读取用户兴趣词
|
||||
preferences = (
|
||||
db.query(UserTopicPreference)
|
||||
.filter(UserTopicPreference.user_id == user_id)
|
||||
@@ -136,7 +140,7 @@ def recommend_events_for_user(
|
||||
if not preference_keywords:
|
||||
return []
|
||||
|
||||
# 读取候选事件(先做时间和热度过滤,避免全表扫描)
|
||||
# 2. 读取候选事件(时间 + 热度过滤,避免全表扫描)
|
||||
time_limit = utcnow() - timedelta(hours=hours)
|
||||
events = (
|
||||
db.query(UnifiedEvent)
|
||||
@@ -177,7 +181,7 @@ def recommend_events_for_user(
|
||||
if not event_topics:
|
||||
return []
|
||||
|
||||
# 批量编码用户词和标签词,避免逐条调用模型
|
||||
# 3. 批量编码用户词与标签词,减少模型调用次数
|
||||
unique_preference_keywords = list(dict.fromkeys(preference_keywords))
|
||||
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)
|
||||
@@ -196,7 +200,7 @@ def recommend_events_for_user(
|
||||
semantic_hits: list[dict[str, Any]] = []
|
||||
score = 0.0
|
||||
|
||||
# 对事件标签逐个匹配用户兴趣
|
||||
# 对每个事件标签做精确匹配或语义匹配
|
||||
for topic_keyword, topic_relevance in topic_list:
|
||||
normalized_topic = _normalize_text(topic_keyword)
|
||||
topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# app/services/summary_service.py
|
||||
"""
|
||||
摘要服务:调用 LLM 生成统一标题、综合摘要、话题标签
|
||||
定时任务:对热度达标且未摘要的事件批量处理
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from datetime import timedelta
|
||||
@@ -36,7 +40,7 @@ deepseek_client = AsyncOpenAI(
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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]]:
|
||||
"""Parse topic keywords from LLM response; support list[str] and list[object]."""
|
||||
"""解析 LLM 返回的话题关键词,支持字符串或对象格式"""
|
||||
raw_topics = llm_result.get("topic_keywords") or []
|
||||
parsed: list[dict[str, Any]] = []
|
||||
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]]:
|
||||
"""Deduplicate semantically similar tags using embedding similarity."""
|
||||
"""用向量相似度去重同义标签,保留最具代表性的关键词"""
|
||||
if not topic_candidates:
|
||||
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:
|
||||
"""Replace EVENT tags for one unified event atomically within current transaction."""
|
||||
"""原子替换某事件的标签:先删旧再插新"""
|
||||
db.query(ExtractedTopic).filter(
|
||||
ExtractedTopic.target_type == TargetType.EVENT,
|
||||
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():
|
||||
"""Scheduled task: refresh summaries and topic tags for hot unified events."""
|
||||
"""定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签"""
|
||||
print(f"[{utcnow()}] Start unified summary generation task...")
|
||||
|
||||
# 先提取需要处理的事件 ID,尽早释放 session,不长期占用 db session
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
<!-- 根组件:路由出口,带页面切换淡入淡出动画 -->
|
||||
<template>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="page-fade" mode="out-in">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { pinia } from '@/stores'
|
||||
import { fetchApi } from '@/config/apiBase'
|
||||
|
||||
// 后端错误消息中英文映射。
|
||||
// 后端英文错误消息到中文的映射
|
||||
const MESSAGE_MAP: Record<string, string> = {
|
||||
'You can only operate your own resources': '只能操作你自己的资源',
|
||||
'Preference keyword already exists for this user': '该关键词已经订阅过了',
|
||||
|
||||
@@ -36,7 +36,7 @@ export function searchEventsTimeline(
|
||||
hours: number = 168,
|
||||
mode: 'exact' | 'semantic' | 'hybrid' = 'hybrid'
|
||||
): Promise<SearchTimelineResponse> {
|
||||
// JS 的 getTimezoneOffset: 本地 - UTC(东八区是 -480),这里转成 UTC+ 偏移分钟。
|
||||
// 获取客户端时区偏移:getTimezoneOffset 为 本地-UTC 分钟,东八区为 -480,转为 UTC+ 偏移
|
||||
const utcOffsetMinutes = -new Date().getTimezoneOffset()
|
||||
return apiGet<SearchTimelineResponse>('/events/search_timeline', {
|
||||
keyword,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer)
|
||||
*/
|
||||
import type {
|
||||
AuthTokenResponse,
|
||||
LoginPayload,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 品牌 Logo:雷达扫描风格 SVG,带呼吸灯与旋转动画 -->
|
||||
<template>
|
||||
<div class="brand-logo-container">
|
||||
<svg class="insight-logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -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,17 +4,17 @@ import { useThemeStore } from '@/stores/theme'
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
/**
|
||||
* 切换主题,使用 View Transitions API 实现高级扩散动画(如果浏览器支持)
|
||||
* 这种动画比之前像玩具一样的开关要高级得多,提供原生级的丝滑过渡
|
||||
* 切换主题:支持 View Transitions API 时使用点击位置扩散动画,
|
||||
* 否则直接切换
|
||||
*/
|
||||
function handleToggle(event: MouseEvent) {
|
||||
// 检查浏览器是否支持 document.startViewTransition 并且用户没有开启减弱动画
|
||||
// 检测浏览器是否支持 View Transitions 且用户未开启减弱动画
|
||||
const isAppearanceTransition = typeof document !== 'undefined' &&
|
||||
'startViewTransition' in document &&
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (!isAppearanceTransition) {
|
||||
// 降级处理:直接切换
|
||||
// 不支持时直接切换,无动画
|
||||
themeStore.toggleTheme()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 统一事件卡片:展示标题、摘要、平台来源、排名轨迹,悬停展开图表 -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* API 基础配置:自动探测内网/公网后端,失败时回退公网
|
||||
*/
|
||||
const API_PREFIX = '/api/v1'
|
||||
const LAN_BACKEND_ORIGIN = 'http://10.252.130.135:8000'
|
||||
const PUBLIC_BACKEND_ORIGIN = 'http://47.107.130.88:51290'
|
||||
@@ -42,6 +45,7 @@ function isLanHostname(hostname: string): boolean {
|
||||
return isPrivateIpv4(normalized)
|
||||
}
|
||||
|
||||
// 探测内网后端是否可用(请求 openapi.json)
|
||||
async function probeLanBackend(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
@@ -69,6 +73,7 @@ async function probeLanBackend(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前 hostname 与探测结果选择内网或公网 API 地址
|
||||
async function detectApiBaseUrl(): Promise<string> {
|
||||
if (ENV_API_BASE_URL) return ENV_API_BASE_URL
|
||||
if (typeof window === 'undefined') return PUBLIC_API_BASE_URL
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 仪表盘布局:侧边栏导航、主内容区、移动端抽屉 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 应用入口:初始化 Vue、Pinia、路由、主题
|
||||
*/
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
@@ -9,6 +12,7 @@ import { useThemeStore } from './stores/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 先挂载 Pinia,再初始化主题(依赖 Pinia)
|
||||
app.use(pinia)
|
||||
const themeStore = useThemeStore(pinia)
|
||||
themeStore.initTheme()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 路由配置:认证页(guestOnly)、仪表盘子路由(requiresAuth)
|
||||
*/
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { pinia } from '@/stores'
|
||||
@@ -59,6 +62,7 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
// 全局前置守卫:认证校验、未登录重定向
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore(pinia)
|
||||
authStore.restore()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 认证状态:Token、用户信息持久化,登录/注册/登出
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
@@ -12,6 +15,7 @@ interface PersistedAuthState {
|
||||
|
||||
const AUTH_STORAGE_KEY = 'insight-radar-auth'
|
||||
|
||||
// 从 localStorage 恢复登录状态
|
||||
function loadPersistedState(): PersistedAuthState | null {
|
||||
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 主题状态:深色/浅色切换,持久化到 localStorage,支持系统偏好
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- 关于页(占位) -->
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
<h1>关于 InsightRadar</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 主仪表盘:事件流、为你推荐、公关修改追踪、系统状态 -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 推送设置页:管理推送时间表与推送渠道(邮箱等) -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 概览页:展示当前账户、会话状态、认证接入说明 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 登录页:支持密码登录与邮箱验证码登录 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -8,6 +9,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
// 验证码重发冷却时间(秒)
|
||||
const CODE_RESEND_SECONDS = 60
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 注册页:邮箱验证码 + 密码,带密码强度提示 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -34,7 +35,7 @@ const canSendCode = computed(() => {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
|
||||
})
|
||||
|
||||
// === 新增:密码强度计算逻辑 ===
|
||||
// 密码强度:根据长度、字母、数字、特殊字符计算 0~4 档
|
||||
const passwordStrength = computed(() => {
|
||||
const pwd = form.password
|
||||
if (!pwd) return 0
|
||||
@@ -52,8 +53,6 @@ const strengthColor = computed(() => {
|
||||
return colors[passwordStrength.value]
|
||||
})
|
||||
|
||||
// ==========================
|
||||
|
||||
function startCooldown(seconds = CODE_RESEND_SECONDS) {
|
||||
countdown.value = Math.max(1, seconds)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 公关修改追踪页:展示热搜标题被偷偷修改的历史记录 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<!-- 事件追踪分析页:关键词搜索、时间热度图表、关联事件列表 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { searchEventsTimeline } from '@/api/events'
|
||||
import type { SearchTimelineResponse } from '@/types/event'
|
||||
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
|
||||
import CustomSelect from '@/components/CustomSelect.vue'
|
||||
|
||||
const keyword = ref('')
|
||||
const searchResult = ref<SearchTimelineResponse | null>(null)
|
||||
@@ -12,6 +14,22 @@ const hours = ref(2)
|
||||
const searchMode = ref<'exact' | 'semantic' | 'hybrid'>('hybrid')
|
||||
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(() => {
|
||||
if (!searchResult.value) return []
|
||||
if (!selectedTimeLabel.value) return searchResult.value.events
|
||||
@@ -51,6 +69,7 @@ const chartOptions = ref({
|
||||
easing: 'easeinout',
|
||||
speed: 800,
|
||||
},
|
||||
// 点击图表数据点:切换选中时间,再次点击则取消筛选
|
||||
events: {
|
||||
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) {
|
||||
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
|
||||
@@ -188,27 +207,16 @@ async function handleSearch() {
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="time-select-wrapper">
|
||||
<i class="fa-regular fa-clock select-icon"></i>
|
||||
<select v-model="hours" class="time-select">
|
||||
<option :value="2">最近 2 小时</option>
|
||||
<option :value="12">最近 12 小时</option>
|
||||
<option :value="24">最近 24 小时</option>
|
||||
<option :value="48">最近 48 小时</option>
|
||||
<option :value="168">最近 7 天</option>
|
||||
<option :value="360">最近 15 天</option>
|
||||
</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>
|
||||
<CustomSelect
|
||||
v-model="hours"
|
||||
:options="timeOptions"
|
||||
icon="fa-regular fa-clock"
|
||||
/>
|
||||
<CustomSelect
|
||||
v-model="searchMode"
|
||||
:options="modeOptions"
|
||||
icon="fa-solid fa-filter"
|
||||
/>
|
||||
<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-spinner fa-spin" v-else></i>
|
||||
@@ -359,6 +367,8 @@ async function handleSearch() {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
z-index: 10; /* 确保搜索框及下拉选择框显示在下方图表之上 */
|
||||
}
|
||||
|
||||
.search-box {
|
||||
@@ -470,61 +480,6 @@ async function handleSearch() {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -562,6 +517,8 @@ async function handleSearch() {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
position: relative;
|
||||
z-index: 1; /* 低于 top-panels,避免图表覆盖搜索框下拉 */
|
||||
}
|
||||
|
||||
.section-header {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 兴趣关键词页:添加/删除关键词,查看命中事件 -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user