Merge pull request #1 from stardrophere/backend_optimize

v0.2
This commit is contained in:
csf123321
2026-03-27 23:57:54 +08:00
committed by GitHub
30 changed files with 642 additions and 687 deletions
+7 -1
View File
@@ -37,6 +37,9 @@ MANIFEST
*.manifest
*.spec
# uv
*.lock
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
@@ -183,4 +186,7 @@ cython_debug/
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
.cursorindexingignore
**/data/*
**/docker/*
+13
View File
@@ -0,0 +1,13 @@
.venv
.git
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.cache
.env
*.log
dist
build
+1
View File
@@ -0,0 +1 @@
3.11
+101 -383
View File
@@ -4,6 +4,7 @@
import json
import math
import os
import logging
from datetime import timedelta, timezone
from typing import Optional, Tuple
@@ -19,7 +20,7 @@ from app.core.security import (
verify_password,
verify_verification_code,
)
from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow
from app.models.models import AppUser, VerificationPurpose, utcnow
from app.schemas.auth_schema import (
AuthTokenResponse,
LoginCodeSendRequest,
@@ -32,9 +33,11 @@ from app.schemas.auth_schema import (
)
from app.utils.email_utils import send_html_email
from app.utils.redis_client import get_redis_client
from app.core.verification.email.verificationService import EmailVerificationService, get_verification_service, TooManyCodeRequestsError, CodeExpiredError, CodeInvalidError
router = APIRouter()
logger = logging.getLogger(__name__)
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
@@ -131,19 +134,12 @@ def _cache_code_in_redis(
"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
client.set(
_redis_code_key(email, purpose),
json.dumps(payload),
ex=max(1, expire_minutes * 60),
)
def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None:
@@ -178,166 +174,6 @@ def _clear_code_in_redis(email: str, purpose: VerificationPurpose) -> None:
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,
EmailVerificationCode.is_used.is_(False),
).update({EmailVerificationCode.is_used: True}, synchronize_session=False)
db.commit()
def _create_code_record(
db: Session,
*,
email: str,
purpose: VerificationPurpose,
expire_minutes: int,
) -> Tuple[EmailVerificationCode, str]:
"""在数据库中创建验证码记录,返回 (记录对象, 明文验证码)"""
code = generate_verification_code()
now = utcnow()
code_record = EmailVerificationCode(
email=email,
purpose=purpose,
code_hash=hash_verification_code(code),
expires_at=now + timedelta(minutes=expire_minutes),
)
db.add(code_record)
db.commit()
return code_record, code
def _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(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(EmailVerificationCode.created_at.desc())
.first()
)
if not latest_record:
return
now = utcnow()
record_time = latest_record.created_at
if record_time.tzinfo is None:
record_time = record_time.replace(tzinfo=timezone.utc)
elapsed_seconds = (now - record_time).total_seconds()
if elapsed_seconds >= CODE_SEND_COOLDOWN_SECONDS:
return
retry_after_seconds = max(1, math.ceil(CODE_SEND_COOLDOWN_SECONDS - elapsed_seconds))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {retry_after_seconds}s before requesting another verification code",
headers={"Retry-After": str(retry_after_seconds)},
)
def _build_auth_response(user: AppUser) -> AuthTokenResponse:
token, expires_in = create_access_token(user_id=user.id, email=user.email)
return AuthTokenResponse(
@@ -348,219 +184,115 @@ 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)):
"""发送注册验证码:先校验邮箱未注册、冷却期,再生成并发送"""
async def send_register_code(
payload: RegisterCodeSendRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
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")
_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,
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,
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already registered",
)
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(
code = service.send_code(email, VerificationPurpose.REGISTER)
await send_html_email(
to_email=email,
subject=f"{code}】InsightRadar 注册验证码",
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES),
html_content=_build_verification_email(
code, "注册", REGISTER_CODE_EXPIRE_MINUTES
),
)
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e))
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,
status_code=500,
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()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send verification code",
)
return MessageResponse(message="Verification code sent")
@router.post("/login/send-code", response_model=MessageResponse)
async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)):
"""发送登录验证码:仅对已注册用户发送"""
async def send_login_code(
payload: LoginCodeSendRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
_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,
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,
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Email is not registered",
)
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(
code = service.send_code(email, VerificationPurpose.LOGIN)
await send_html_email(
to_email=email,
subject=f"{code}】InsightRadar 登录验证码",
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES),
html_content=_build_verification_email(
code, "登录", LOGIN_CODE_EXPIRE_MINUTES
),
)
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=429, detail=str(e))
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,
status_code=500,
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()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send verification code",
)
return MessageResponse(message="Verification code sent")
@router.post(
"/register",
response_model=AuthTokenResponse,
status_code=status.HTTP_201_CREATED,
)
async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户"""
async def register(
payload: RegisterRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
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")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already registered",
)
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:
try:
service.verify_code(
email=email,
purpose=VerificationPurpose.REGISTER,
code=payload.verification_code,
)
except CodeExpiredError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code expired")
except CodeInvalidError:
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,
)
except TooManyCodeRequestsError:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts")
now = utcnow()
nickname = payload.nickname or email.split("@")[0]
user = AppUser(
email=email,
password_hash=hash_password(payload.password),
@@ -569,16 +301,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
)
db.add(user)
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
db.refresh(user)
return _build_auth_response(user)
@router.post("/login", response_model=AuthTokenResponse)
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""密码登录"""
@@ -595,49 +322,40 @@ 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 校验优先,失败则从数据库兜底"""
async def login_with_code(
payload: LoginWithCodeRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
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")
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,
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
try:
service.verify_code(
email=email,
purpose=VerificationPurpose.LOGIN,
code=payload.verification_code,
)
except CodeExpiredError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
except CodeInvalidError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
except TooManyCodeRequestsError:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts",
)
return _build_auth_response(user)
@@ -0,0 +1,80 @@
# app/verification/backends/memory.py
from functools import lru_cache
import time
import json
import threading
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
class MemoryRepository(VerificationRepository):
def __init__(self):
self._store = {}
self._lock = threading.Lock()
def _key(self, email: str, purpose: VerificationPurpose) -> str:
email = email.lower()
return f"verification:code:{purpose.value.lower()}:{email}"
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
key = self._key(email, purpose)
expire_at = time.time() + ttl
payload = {
"code_hash": code_hash
}
with self._lock:
self._store[key] = (json.dumps(payload), expire_at)
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
key = self._key(email, purpose)
with self._lock:
data = self._store.get(key)
if not data:
return None
value, expire_at = data
if time.time() > expire_at:
del self._store[key]
return None
try:
payload = json.loads(value)
stored_hash = payload.get("code_hash")
except Exception:
return None
if stored_hash == code_hash:
del self._store[key]
return True
else:
return False
def incr(self, key: str, ttl: int) -> int:
now = time.time()
with self._lock:
value, expire = self._store.get(key, (0, 0))
if now > expire:
value = 0
value += 1
self._store[key] = (value, now + ttl)
return value
@lru_cache
def get_memory_repo():
return MemoryRepository()
@@ -0,0 +1,95 @@
from functools import lru_cache
import os
import logging
import json
import redis
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
from app.utils.redis_client import get_redis_client
logger = logging.getLogger(__name__)
AUTH_CODE_REDIS_PREFIX = os.getenv(
"AUTH_CODE_REDIS_PREFIX", "insightradar:auth_code"
).strip()
class RedisRepository(VerificationRepository):
_compare_and_consume_lua = """
local val = redis.call("GET", KEYS[1])
if not val then
return nil
end
local data = cjson.decode(val)
if data["code_hash"] == ARGV[1] then
redis.call("DEL", KEYS[1])
return 1
else
return 0
end
"""
def __init__(self, client: redis.Redis):
self.client = client
self._compare_script = self.client.register_script(
self._compare_and_consume_lua
)
def _key(self, email: str, purpose: VerificationPurpose) -> str:
return f"{AUTH_CODE_REDIS_PREFIX}:{purpose.value.lower()}:{email}:code"
def set_code(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
ttl: int,
) -> None:
key = self._key(email, purpose)
payload = json.dumps({
"code_hash": code_hash,
})
self.client.set(key, payload, ex=ttl)
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
key = self._key(email, purpose)
result = self._compare_script(
keys=[key],
args=[code_hash],
)
if result is None:
return None
if result == 1:
return True
return False
def incr(self, key: str, ttl: int) -> int:
value = self.client.incr(key)
if value == 1:
self.client.expire(key, ttl)
return int(value) # type: ignore
@lru_cache
def get_redis_repo():
client = get_redis_client()
if client is None:
return None
return RedisRepository(client)
@@ -0,0 +1,62 @@
from functools import lru_cache
import logging
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
from app.core.verification.email.RespositoryImpl.MemoryRepository import get_memory_repo
from app.core.verification.email.RespositoryImpl.RedisRepository import get_redis_repo
logger = logging.getLogger(__name__)
class HybridRepository(VerificationRepository):
def __init__(self, redis_repo: Optional[VerificationRepository], memory_repo: VerificationRepository):
self.redis = redis_repo
self.memory = memory_repo
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
if self.redis:
try:
self.redis.set_code(email, purpose, code_hash, ttl)
return
except Exception as e:
logger.warning("Redis set_code failed, fallback to memory: %s", e)
self.memory.set_code(email, purpose, code_hash, ttl)
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
if self.redis:
try:
return self.redis.compare_and_consume(
email, purpose, code_hash
)
except Exception as e:
logger.warning(
"Redis compare_and_consume failed, fallback to memory: %s", e
)
return self.memory.compare_and_consume(email, purpose, code_hash)
def incr(self, key: str, ttl: int) -> int:
if self.redis:
try:
return self.redis.incr(key, ttl)
except Exception as e:
logger.warning("Redis incr failed, fallback to memory: %s", e)
return self.memory.incr(key, ttl)
@lru_cache
def get_verification_repository():
redis_repo = get_redis_repo()
memory_repo = get_memory_repo()
return HybridRepository(redis_repo, memory_repo)
@@ -0,0 +1,49 @@
from abc import abstractmethod, ABC
from typing import Optional
from app.models.models import VerificationPurpose
class VerificationRepository(ABC):
"""验证码持久层抽象基类"""
@abstractmethod
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
"""write the code into the storage
Args:
email (str): email of user
purpose (VerificationPurpose): the purpose of the code, such as, "login", "register"
code_hash (str): hash of the code
ttl (int): duration
"""
pass
@abstractmethod
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
"""consume the code atomically
Args:
email (str): email of user
purpose (VerificationPurpose): the purpose of the code, such as, "login", "register"
Returns:
Optional[str]: if success return the true
"""
pass
@abstractmethod
def incr(self, key: str, ttl: int) -> int:
"""create a counter in the storage, it will be delete after ttl
Args:
key (str): key of the counter
ttl (int): duration
"""
pass
@@ -0,0 +1,89 @@
from functools import lru_cache
import os
import logging
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
from app.core.security import generate_verification_code, hash_verification_code
from app.core.verification.email.RespositoryImpl.hybirdRepository import get_verification_repository
logger = logging.getLogger(__name__)
# 注册验证码有效期(分钟)
REGISTER_CODE_EXPIRE_MINUTES = os.getenv("REGISTER_CODE_EXPIRE_MINUTES", 5)
# 登录验证码有效期(分钟)
LOGIN_CODE_EXPIRE_MINUTES = os.getenv("LOGIN_CODE_EXPIRE_MINUTES",5)
# 同一邮箱发送验证码的冷却间隔(秒)
CODE_SEND_COOLDOWN_SECONDS = os.getenv("CODE_SEND_COOLDOWN_SECONDS",60)
CODE_VERIFICATE_ATTEMP_SECONDS = os.getenv("CODE_VERIFICATE_ATTEMP_SECONDS", 60)
CODE_VERIFICATE_ATTEMP_COUNT = os.getenv("CODE_VERIFICATE_ATTEMP_COUNT", 10)
class CodeExpiredError(Exception):
"""code has been expired"""
pass
class CodeInvalidError(Exception):
"""code is not right"""
pass
class TooManyCodeRequestsError(Exception):
"""User request too many times when they verificate the email"""
pass
def get_ttl(purpose: VerificationPurpose)->int:
if purpose == VerificationPurpose.LOGIN:
return int(LOGIN_CODE_EXPIRE_MINUTES) * 60
else:
return int(REGISTER_CODE_EXPIRE_MINUTES) * 60
class EmailVerificationService:
def __init__(self, repo: VerificationRepository) -> None:
self.repo = repo
def _cooldown_key(self, email: str, purpose: VerificationPurpose) -> str:
return f"verification:cooldown:{purpose.value}:{email.lower()}"
def send_code(self, email: str, purpose: VerificationPurpose) -> str:
email = email.lower()
count = self.repo.incr(self._cooldown_key(email, purpose), int(CODE_SEND_COOLDOWN_SECONDS))
if count > 1:
raise TooManyCodeRequestsError("Please wait before requesting another code")
code = generate_verification_code()
code_hash = hash_verification_code(code)
self.repo.set_code(email, purpose, code_hash, get_ttl(purpose))
return code
def verify_code(self,email: str, code: str, purpose: VerificationPurpose):
email = email.lower()
key = f"verification:attempts:{purpose.value.lower()}:{email}"
code_hash = hash_verification_code(code)
attempts = self.repo.incr(key, int(CODE_VERIFICATE_ATTEMP_SECONDS))
if attempts > int(CODE_VERIFICATE_ATTEMP_COUNT):
raise TooManyCodeRequestsError("Too many attempts")
stored = self.repo.compare_and_consume(email, purpose, code_hash)
if stored == False:
raise CodeInvalidError("Invalid code")
if not stored:
raise CodeExpiredError("Code expired or not found")
return True
@lru_cache
def get_verification_service():
repo = get_verification_repository()
return EmailVerificationService(repo)
+7 -7
View File
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
# 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见
logging.basicConfig(
level=logging.INFO,
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
@@ -38,9 +38,9 @@ scheduler = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
# 1. 数据库建表
print("正在初始化数据库表...")
logging.info("正在初始化数据库表...")
Base.metadata.create_all(bind=engine)
print("数据库表初始化完成!")
logging.info("数据库表初始化完成!")
# 2. 配置并启动定时任务
scheduler.add_job(
@@ -69,9 +69,9 @@ async def lifespan(app: FastAPI):
)
scheduler.start()
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
print("邮件推送调度已启动,每分钟检查一次")
logging.info(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
logging.info(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
logging.info("邮件推送调度已启动,每分钟检查一次")
# 为了测试方便,启动时立即执行一次
# await fetch_and_save_trending_data()
@@ -82,7 +82,7 @@ async def lifespan(app: FastAPI):
# 优雅关闭
scheduler.shutdown()
print("定时任务已安全关闭")
logging.info("定时任务已安全关闭")
# 初始化 FastAPI
-16
View File
@@ -321,22 +321,6 @@ class AppUser(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
__table_args__ = (
Index("idx_email_code_lookup", "email", "purpose", "is_used", "expires_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(150), index=True, nullable=False)
purpose: Mapped[VerificationPurpose] = mapped_column(Enum(VerificationPurpose), nullable=False)
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
is_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class UserPushEndpoint(Base):
"""
多渠道推送端点配置表 (高可用解耦设计)
+1 -1
View File
@@ -28,7 +28,7 @@ EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "")
print("正在加载 BAAI/bge-m3 向量模型...")
# 全局单例
embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True, device="cuda")
embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True)
print("模型加载完成。")
+6 -17
View File
@@ -1,27 +1,21 @@
from functools import lru_cache
import logging
import os
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from redis import 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
_redis_client: Optional["redis.Redis"] = None
_initialized = False
def get_redis_client() -> Optional["Redis"]:
@lru_cache
def get_redis_client() -> Optional["redis.Redis"]:
"""Return a singleton Redis client, or None when Redis is unavailable."""
global _redis_client, _initialized
@@ -31,12 +25,7 @@ def get_redis_client() -> Optional["Redis"]:
_initialized = True
if not REDIS_URL:
logger.info("REDIS_URL 未配置,验证码将回退到数据库存储")
_redis_client = None
return _redis_client
if redis is None:
logger.warning("未安装 redis 包,验证码将回退到数据库存储")
logger.info("REDIS_URL 未配置,验证码将回退到内存存储")
_redis_client = None
return _redis_client
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.11-slim AS builder
WORKDIR /insightradar
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
pip install --no-cache-dir uv && \
uv sync --frozen --no-dev
COPY app app
COPY main.py main.py
#-----------------------------------------------
FROM python:3.11-slim
WORKDIR /insightradar
# 👇 复制虚拟环境
COPY --from=builder /insightradar/.venv /insightradar/.venv
COPY app app
COPY main.py main.py
# 👇 关键:用 venv 里的 python
ENV PATH="/insightradar/.venv/bin:$PATH"
EXPOSE 8000
CMD ["python","main.py"]
View File
+71
View File
@@ -0,0 +1,71 @@
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"aiosmtplib==5.1.0",
"annotated-doc==0.0.4",
"annotated-types==0.7.0",
"anyio==4.12.1",
"apscheduler==3.11.2",
"certifi==2026.2.25",
"charset-normalizer==3.4.5",
"click==8.3.1",
"colorama==0.4.6",
"distro==1.9.0",
"fastapi==0.135.1",
"filelock==3.25.0",
"fsspec==2026.2.0",
"greenlet==3.3.2",
"h11==0.16.0",
"hf-xet==1.3.2",
"httpcore==1.0.9",
"httpx==0.28.1",
"huggingface-hub==1.6.0",
"idna==3.11",
"jinja2==3.1.6",
"jiter==0.13.0",
"joblib==1.5.3",
"markdown-it-py==4.0.0",
"markupsafe==3.0.3",
"mdurl==0.1.2",
"modelscope>=1.35.0",
"mpmath==1.3.0",
"networkx==3.6.1",
"numpy==2.4.3",
"openai==2.26.0",
"pillow==12.0.0",
"pydantic==2.12.5",
"pydantic-core==2.41.5",
"pygments==2.19.2",
"python-dotenv==1.2.2",
"pyyaml==6.0.3",
"redis==7.3.0",
"regex==2026.2.28",
"requests==2.32.5",
"rich==14.3.3",
"safetensors==0.7.0",
"scikit-learn==1.8.0",
"scipy==1.17.1",
"sentence-transformers==5.2.3",
"shellingham==1.5.4",
"sniffio==1.3.1",
"sqlalchemy==2.0.48",
"starlette==0.52.1",
"sympy==1.14.0",
"threadpoolctl==3.6.0",
"tokenizers==0.22.2",
"torch==2.10.0",
"torchvision==0.25.0",
"tqdm==4.67.3",
"transformers==5.3.0",
"typer==0.24.1",
"typing-extensions==4.15.0",
"typing-inspection==0.4.2",
"tzdata==2025.3",
"tzlocal==5.3.1",
"urllib3==2.6.3",
"uvicorn==0.41.0",
]
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
import os
def print_tree(root, prefix=""):
items = sorted(
name for name in os.listdir(root)
if name != "__pycache__"
)
total = len(items)
for i, name in enumerate(items):
path = os.path.join(root, name)
is_last = (i == total - 1)
connector = "└── " if is_last else "├── "
print(prefix + connector + name)
if os.path.isdir(path):
extension = " " if is_last else ""
print_tree(path, prefix + extension)
root_dir = r"E:\ScnuProject\InsightRadar\backend\app"
print(os.path.basename(root_dir) + "/")
print_tree(root_dir)
-57
View File
@@ -1,57 +0,0 @@
# from dotenv import load_dotenv
# import os
# import time
#
# print("step 1: loading env")
# load_dotenv()
#
# hf_token = os.getenv("HF_TOKEN")
# print("step 2:", "HF_TOKEN loaded" if hf_token else "No token")
#
# print("step 3: importing sentence-transformers")
# from sentence_transformers import SentenceTransformer
#
# print("step 4: start loading model")
# t0 = time.time()
# model = SentenceTransformer(r"E:\Models\bge-m3", local_files_only=True, device="cuda")
# print(f"step 5: model loaded in {time.time() - t0:.2f}s")
#
# print("step 6: importing sklearn/numpy")
# from sklearn.metrics.pairwise import cosine_similarity
# import numpy as np
#
# titles = [
# # A组:同品牌同产品,但含义不同
# "苹果发布新款iPhone,影像系统再次升级",
# "苹果推出全新iPhone,摄像头性能进一步增强",
# "苹果回应新款iPhone发热问题:将通过系统更新修复",
# "苹果下调部分旧款iPhone售价,新机型并未参与促销",
#
# # B组:看起来都像“苹果新闻”,但主题已变
# "苹果公司股价上涨,市值再创新高",
# "苹果供应链承压,部分零部件厂商下调全年预期",
# "苹果被曝缩减Vision产品产量,市场需求不及预期",
# "苹果发布新款MacBook,并未更新iPhone产品线",
#
# # C组:同样是“发布/推出”,但主体不同
# "华为发布新款手机,影像能力进一步提升",
# "小米推出全新手机,影像系统迎来升级",
# "OPPO发布年度旗舰机型,主打夜景拍摄",
# ]
#
# print("step 7: start encoding")
# t1 = time.time()
# embeddings = model.encode(
# titles,
# normalize_embeddings=True,
# show_progress_bar=True,
# batch_size=16
# )
# print(f"step 8: encode done in {time.time() - t1:.2f}s")
#
# sim = cosine_similarity(embeddings)
# print(np.round(sim, 4))
#
import secrets
print(secrets.token_urlsafe(64))
-34
View File
@@ -1,34 +0,0 @@
# Health
GET http://127.0.0.1:8000/
Accept: application/json
###
# Send register verification code
POST http://127.0.0.1:8000/api/v1/auth/register/send-code
Content-Type: application/json
{
"email": "demo@example.com"
}
###
# Register by verification code
POST http://127.0.0.1:8000/api/v1/auth/register
Content-Type: application/json
{
"email": "demo@example.com",
"password": "DemoPass123",
"verification_code": "123456",
"nickname": "demo_user"
}
###
# Login
POST http://127.0.0.1:8000/api/v1/auth/login
Content-Type: application/json
{
"email": "demo@example.com",
"password": "DemoPass123"
}
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BACKEND_ORIGIN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
-15
View File
@@ -69,7 +69,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1915,7 +1914,6 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1965,7 +1963,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -2549,7 +2546,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2619,7 +2615,6 @@
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
"license": "SEE LICENSE IN LICENSE",
"peer": true,
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3"
}
@@ -2741,7 +2736,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3048,7 +3042,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -3131,7 +3124,6 @@
"integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
@@ -3590,7 +3582,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -4187,7 +4178,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -4594,7 +4584,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4657,7 +4646,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4811,7 +4799,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5043,7 +5030,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5063,7 +5049,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
-1
View File
@@ -26,7 +26,6 @@ function handleToggle(event: MouseEvent) {
Math.max(y, innerHeight - y)
)
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
const transition = document.startViewTransition(() => {
themeStore.toggleTheme()
})
+6 -115
View File
@@ -1,121 +1,12 @@
/**
* 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'
const PROBE_TIMEOUT_MS = 1200
const LAN_API_BASE_URL = `${LAN_BACKEND_ORIGIN}${API_PREFIX}`
const PUBLIC_API_BASE_URL = `${PUBLIC_BACKEND_ORIGIN}${API_PREFIX}`
const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined
const EXPECTED_OPENAPI_PATHS = ['/api/v1/auth/login', '/api/v1/events/unified']
let detectedApiBaseUrl: string | null = ENV_API_BASE_URL ?? null
let detectPromise: Promise<string> | null = null
function normalizePath(path: string): string {
if (!path) return '/'
return path.startsWith('/') ? path : `/${path}`
}
function buildUrl(base: string, path: string): string {
return `${base}${normalizePath(path)}`
}
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split('.').map((part) => Number.parseInt(part, 10))
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
return false
export function buildUrl(path: string): string {
if (!path.startsWith('/')) {
path = '/' + path
}
const a = parts[0] as number
const b = parts[1] as number
if (a === 10) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
if (a === 127) return true
return false
return `${API_PREFIX}${path}`
}
function isLanHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase()
if (normalized === 'localhost' || normalized.endsWith('.local')) return true
return isPrivateIpv4(normalized)
}
// 探测内网后端是否可用(请求 openapi.json
async function probeLanBackend(): Promise<boolean> {
if (typeof window === 'undefined') return false
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
const response = await fetch(`${LAN_BACKEND_ORIGIN}/openapi.json`, {
method: 'GET',
cache: 'no-store',
signal: controller.signal,
})
if (!response.ok) return false
const data = (await response.json()) as { paths?: Record<string, unknown> }
const paths = data.paths
if (!paths || typeof paths !== 'object') return false
return EXPECTED_OPENAPI_PATHS.every((path) =>
Object.prototype.hasOwnProperty.call(paths, path),
)
} catch {
return false
} finally {
window.clearTimeout(timeout)
}
}
// 根据当前 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
if (!isLanHostname(window.location.hostname)) {
return PUBLIC_API_BASE_URL
}
const canUseLan = await probeLanBackend()
return canUseLan ? LAN_API_BASE_URL : PUBLIC_API_BASE_URL
}
function isLikelyNetworkError(error: unknown): boolean {
return error instanceof TypeError || (error instanceof DOMException && error.name === 'AbortError')
}
export async function getApiBaseUrl(): Promise<string> {
if (detectedApiBaseUrl) return detectedApiBaseUrl
if (!detectPromise) {
detectPromise = detectApiBaseUrl()
.then((url) => {
detectedApiBaseUrl = url
return url
})
.finally(() => {
detectPromise = null
})
}
return detectPromise
}
export async function fetchApi(path: string, init?: RequestInit): Promise<Response> {
const apiBaseUrl = await getApiBaseUrl()
const requestUrl = buildUrl(apiBaseUrl, path)
try {
return await fetch(requestUrl, init)
} catch (error) {
if (!ENV_API_BASE_URL && apiBaseUrl === LAN_API_BASE_URL && isLikelyNetworkError(error)) {
detectedApiBaseUrl = PUBLIC_API_BASE_URL
return fetch(buildUrl(PUBLIC_API_BASE_URL, path), init)
}
throw error
}
export async function fetchApi(path: string, init?: RequestInit) {
return fetch(buildUrl(path), init)
}
+7 -7
View File
@@ -88,8 +88,8 @@ const revisionChains = computed<RevisionChain[]>(() => {
// 组内按时间升序
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
const first = items[0]
const last = items[items.length - 1]
const first = items[0]!
const last = items[items.length - 1]!
// 拼接标题链,避免相邻记录重复
const titles: string[] = [first.previous_headline]
@@ -107,13 +107,13 @@ const revisionChains = computed<RevisionChain[]>(() => {
chains.push({
event_id,
source_name: first.source_name,
source_name: first.source_name!,
titles,
change_times,
first_at: first.created_at,
last_at: last.created_at,
first_at: first.created_at!,
last_at: last.created_at!,
change_count: items.length,
url: first.url,
url: first.url!,
})
}
@@ -242,7 +242,7 @@ onMounted(loadRevisions)
</span>
<p class="chain-title-text">{{ title }}</p>
<span v-if="idx < chain.change_times.length" class="chain-step-time">
{{ formatTime(chain.change_times[idx]) }}
{{ formatTime(chain.change_times[idx] ?? '') }}
</span>
</div>
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
+6 -5
View File
@@ -6,6 +6,7 @@ import { searchEventsTimeline } from '@/api/events'
import type { SearchTimelineResponse } from '@/types/event'
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
import CustomSelect from '@/components/CustomSelect.vue'
import type { ApexOptions } from 'apexcharts'
const keyword = ref('')
const searchResult = ref<SearchTimelineResponse | null>(null)
@@ -56,7 +57,7 @@ const filteredEvents = computed(() => {
})
// 热度时间线图表配置。
const chartOptions = ref({
const chartOptions = ref<ApexOptions>({
chart: {
type: 'area',
height: 350,
@@ -66,12 +67,12 @@ const chartOptions = ref({
},
animations: {
enabled: true,
easing: 'easeinout',
// easing: 'easeinout',
speed: 800,
},
// 点击图表数据点:切换选中时间,再次点击则取消筛选
events: {
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) {
markerClick: function(event: unknown, chartContext: unknown, { dataPointIndex }: never) {
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
if (selectedTimeLabel.value === clickedTime) {
@@ -368,7 +369,7 @@ async function handleSearch() {
gap: 24px;
align-items: stretch;
position: relative;
z-index: 10;
z-index: 10;
}
.search-box {
@@ -378,7 +379,7 @@ async function handleSearch() {
flex-direction: column;
justify-content: center;
position: relative;
z-index: 2;
z-index: 2;
}
.tips-box {
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
strictPort: true,
proxy: {
'/api': {
target: 'http://10.252.130.135:8000',
target: 'http://localhost:8000',
changeOrigin: true,
},
},