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 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,23 +357,72 @@ 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)
|
||||||
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
|
|
||||||
code_record, code = _create_code_record(
|
code_record = None
|
||||||
db,
|
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,
|
||||||
|
email=email,
|
||||||
|
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,
|
email=email,
|
||||||
purpose=VerificationPurpose.REGISTER,
|
purpose=VerificationPurpose.REGISTER,
|
||||||
|
code_hash=code_hash,
|
||||||
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
||||||
)
|
)
|
||||||
|
_set_send_cooldown_in_redis(email, VerificationPurpose.REGISTER)
|
||||||
|
|
||||||
email_sent = await send_html_email(
|
try:
|
||||||
to_email=email,
|
email_sent = await send_html_email(
|
||||||
subject=f"【{code}】InsightRadar 注册验证码",
|
to_email=email,
|
||||||
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES),
|
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:
|
if not email_sent:
|
||||||
code_record.is_used = True
|
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
|
||||||
db.add(code_record)
|
client = _get_redis_for_codes()
|
||||||
db.commit()
|
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()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to send verification code",
|
detail="Failed to send verification code",
|
||||||
@@ -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,23 +441,71 @@ 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)
|
||||||
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
|
|
||||||
code_record, code = _create_code_record(
|
code_record = None
|
||||||
db,
|
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,
|
||||||
|
email=email,
|
||||||
|
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,
|
email=email,
|
||||||
purpose=VerificationPurpose.LOGIN,
|
purpose=VerificationPurpose.LOGIN,
|
||||||
|
code_hash=code_hash,
|
||||||
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
||||||
)
|
)
|
||||||
|
_set_send_cooldown_in_redis(email, VerificationPurpose.LOGIN)
|
||||||
|
|
||||||
email_sent = await send_html_email(
|
try:
|
||||||
to_email=email,
|
email_sent = await send_html_email(
|
||||||
subject=f"【{code}】InsightRadar 登录验证码",
|
to_email=email,
|
||||||
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES),
|
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:
|
if not email_sent:
|
||||||
code_record.is_used = True
|
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
|
||||||
db.add(code_record)
|
client = _get_redis_for_codes()
|
||||||
db.commit()
|
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()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to send verification code",
|
detail="Failed to send verification code",
|
||||||
@@ -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 not code_record:
|
if redis_result is False:
|
||||||
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")
|
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]
|
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)
|
||||||
db.add(code_record)
|
if code_record is not None:
|
||||||
|
code_record.is_used = True
|
||||||
|
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 not code_record:
|
if redis_result is False:
|
||||||
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
|
||||||
|
|
||||||
code_record.is_used = True
|
if redis_result is None:
|
||||||
db.add(code_record)
|
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()
|
db.commit()
|
||||||
|
|
||||||
return _build_auth_response(user)
|
return _build_auth_response(user)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# 遍历更新模型对象的属性
|
# 遍历更新模型对象的属性
|
||||||
|
|||||||
@@ -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 秒,而不是直接报错
|
||||||
|
|||||||
@@ -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", ""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# app/services/fetcher_service.py
|
# app/services/fetcher_service.py
|
||||||
|
"""
|
||||||
|
抓取服务:从外部 API 拉取热搜/RSS 数据,做查重、向量聚类、入库
|
||||||
|
热搜分支:语义聚类到 UnifiedEvent;RSS 分支:写入 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<transition name="page-fade" mode="out-in">
|
<transition name="page-fade" mode="out-in">
|
||||||
|
|||||||
@@ -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': '该关键词已经订阅过了',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer)
|
||||||
|
*/
|
||||||
import type {
|
import type {
|
||||||
AuthTokenResponse,
|
AuthTokenResponse,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
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'
|
||||||
|
|||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 主题状态:深色/浅色切换,持久化到 localStorage,支持系统偏好
|
||||||
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,3 +1,4 @@
|
|||||||
|
<!-- 推送设置页:管理推送时间表与推送渠道(邮箱等) -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
<!-- 公关修改追踪页:展示热搜标题被偷偷修改的历史记录 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
<!-- 兴趣关键词页:添加/删除关键词,查看命中事件 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user