15 Commits

Author SHA1 Message Date
csf123321 8a5f5ee9ea Update docker-image.yml 2026-03-28 00:49:56 +08:00
csf123321 f68b674eb2 Update docker-image.yml 2026-03-28 00:46:33 +08:00
csf123321 136d49d2d5 Update docker-image.yml 2026-03-28 00:17:30 +08:00
csf123321 0c325ec1e7 Update docker-image.yml
logout docker
2026-03-28 00:12:35 +08:00
csf123321 3e84f65cdc Merge pull request #1 from stardrophere/backend_optimize
v0.2
2026-03-27 23:57:54 +08:00
csf123321 9c64d52e1b Create docker-image.yml 2026-03-27 23:53:08 +08:00
csf123321 a10a5a176b 后端的docker构建 2026-03-27 17:46:32 +08:00
csf123321 d4a8f59fd8 修复前端无法编译的问题 2026-03-27 13:04:02 +08:00
csf123321 1b8fadc0c9 修改前端请求配置 2026-03-27 13:03:21 +08:00
csf123321 210bb3b9ea 放爆破 2026-03-26 02:12:29 +08:00
csf123321 ca796a5fd2 修改只有一次验证机会的bug 2026-03-26 02:04:41 +08:00
csf123321 b18901a2d5 彻底删除数据库记录验证码 2026-03-26 01:48:55 +08:00
csf123321 2335b62384 修改前端api配置 2026-03-24 15:46:39 +08:00
csf123321 a424185854 修改为完全uv项目 2026-03-20 00:39:04 +08:00
csf123321 8b5fb44ded 添加uv管理项目 2026-03-16 16:31:56 +08:00
31 changed files with 677 additions and 687 deletions
+35
View File
@@ -0,0 +1,35 @@
name: Docker Image CI
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Logout Docker (avoid wrong credentials)
run: docker logout || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v7
- name: uv lock
working-directory: backend
run: uv lock
- name: Build Docker Image (with BuildKit)
working-directory: backend
run: |
docker build \
--progress=plain \
-t insightradar-backend:${{ github.ref_name }} \
-t insightradar-backend:latest \
.
+7 -1
View File
@@ -37,6 +37,9 @@ MANIFEST
*.manifest *.manifest
*.spec *.spec
# uv
*.lock
# Installer logs # Installer logs
pip-log.txt pip-log.txt
pip-delete-this-directory.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 # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files # refer to https://docs.cursor.com/context/ignore-files
.cursorignore .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 json
import math import math
import os import os
import logging
from datetime import timedelta, timezone from datetime import timedelta, timezone
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -19,7 +20,7 @@ from app.core.security import (
verify_password, verify_password,
verify_verification_code, 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 ( from app.schemas.auth_schema import (
AuthTokenResponse, AuthTokenResponse,
LoginCodeSendRequest, LoginCodeSendRequest,
@@ -32,9 +33,11 @@ from app.schemas.auth_schema import (
) )
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 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() router = APIRouter()
logger = logging.getLogger(__name__)
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10 DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10 DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
@@ -131,19 +134,12 @@ def _cache_code_in_redis(
"code_hash": code_hash, "code_hash": code_hash,
"created_at": utcnow().isoformat(), "created_at": utcnow().isoformat(),
} }
try:
client.set( client.set(
_redis_code_key(email, purpose), _redis_code_key(email, purpose),
json.dumps(payload), json.dumps(payload),
ex=max(1, expire_minutes * 60), 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: 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 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: def _build_auth_response(user: AppUser) -> AuthTokenResponse:
token, expires_in = create_access_token(user_id=user.id, email=user.email) token, expires_in = create_access_token(user_id=user.id, email=user.email)
return AuthTokenResponse( return AuthTokenResponse(
@@ -348,219 +184,115 @@ 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),
service: EmailVerificationService = Depends(get_verification_service),
):
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,
_enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER) detail="Email is already registered",
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,
) )
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: try:
email_sent = await send_html_email( code = service.send_code(email, VerificationPurpose.REGISTER)
await send_html_email(
to_email=email, to_email=email,
subject=f"{code}】InsightRadar 注册验证码", subject=f"{code}】InsightRadar 注册验证码",
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES), html_content=_build_verification_email(
code, "注册", REGISTER_CODE_EXPIRE_MINUTES
),
) )
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e))
except Exception as 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( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=500,
detail=f"Failed to send verification code: {e}", 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") return MessageResponse(message="Verification code sent")
@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),
service: EmailVerificationService = Depends(get_verification_service),
):
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_404_NOT_FOUND, detail="Email is not registered") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
_enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN) detail="Email is not registered",
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,
) )
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: try:
email_sent = await send_html_email( code = service.send_code(email, VerificationPurpose.LOGIN)
await send_html_email(
to_email=email, to_email=email,
subject=f"{code}】InsightRadar 登录验证码", subject=f"{code}】InsightRadar 登录验证码",
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES), html_content=_build_verification_email(
code, "登录", LOGIN_CODE_EXPIRE_MINUTES
),
) )
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=429, detail=str(e))
except Exception as 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( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=500,
detail=f"Failed to send verification code: {e}", 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") return MessageResponse(message="Verification code sent")
@router.post( @router.post(
"/register", "/register",
response_model=AuthTokenResponse, response_model=AuthTokenResponse,
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(
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户""" payload: RegisterRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
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",
)
redis_result = _verify_code_with_redis( try:
email, service.verify_code(
VerificationPurpose.REGISTER, email=email,
payload.verification_code, purpose=VerificationPurpose.REGISTER,
strict=False, # Never be strict so we can fallback to DB if redis is down code=payload.verification_code,
) )
code_record = None except CodeExpiredError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code expired")
if redis_result is False: except CodeInvalidError:
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")
except TooManyCodeRequestsError:
if redis_result is None: raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts")
# 即使在 _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() now = utcnow()
nickname = payload.nickname or email.split("@")[0] nickname = payload.nickname or email.split("@")[0]
user = AppUser( user = AppUser(
email=email, email=email,
password_hash=hash_password(payload.password), password_hash=hash_password(payload.password),
@@ -569,16 +301,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
) )
db.add(user) db.add(user)
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)
return _build_auth_response(user) return _build_auth_response(user)
@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)):
"""密码登录""" """密码登录"""
@@ -595,49 +322,40 @@ 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(
"""验证码登录:Redis 校验优先,失败则从数据库兜底""" payload: LoginWithCodeRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
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,
redis_result = _verify_code_with_redis( detail="Invalid email or verification code",
email,
VerificationPurpose.LOGIN,
payload.verification_code,
strict=False, # Never be strict so we can fallback to DB if redis is down
)
code_record = None
if redis_result is False:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
if redis_result is None:
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
)
if not code_record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
if not verify_verification_code(payload.verification_code, code_record.code_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
else:
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
) )
if code_record is not None: try:
code_record.is_used = True service.verify_code(
db.add(code_record) email=email,
purpose=VerificationPurpose.LOGIN,
db.commit() 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) 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 日志可见 # 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
) )
@@ -38,9 +38,9 @@ scheduler = AsyncIOScheduler()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 1. 数据库建表 # 1. 数据库建表
print("正在初始化数据库表...") logging.info("正在初始化数据库表...")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
print("数据库表初始化完成!") logging.info("数据库表初始化完成!")
# 2. 配置并启动定时任务 # 2. 配置并启动定时任务
scheduler.add_job( scheduler.add_job(
@@ -69,9 +69,9 @@ async def lifespan(app: FastAPI):
) )
scheduler.start() scheduler.start()
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次") logging.info(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次") logging.info(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
print("邮件推送调度已启动,每分钟检查一次") logging.info("邮件推送调度已启动,每分钟检查一次")
# 为了测试方便,启动时立即执行一次 # 为了测试方便,启动时立即执行一次
# await fetch_and_save_trending_data() # await fetch_and_save_trending_data()
@@ -82,7 +82,7 @@ async def lifespan(app: FastAPI):
# 优雅关闭 # 优雅关闭
scheduler.shutdown() scheduler.shutdown()
print("定时任务已安全关闭") logging.info("定时任务已安全关闭")
# 初始化 FastAPI # 初始化 FastAPI
-16
View File
@@ -321,22 +321,6 @@ class AppUser(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) 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): class UserPushEndpoint(Base):
""" """
多渠道推送端点配置表 (高可用解耦设计) 多渠道推送端点配置表 (高可用解耦设计)
+1 -1
View File
@@ -28,7 +28,7 @@ EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "")
print("正在加载 BAAI/bge-m3 向量模型...") 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("模型加载完成。") print("模型加载完成。")
+6 -17
View File
@@ -1,27 +1,21 @@
from functools import lru_cache
import logging import logging
import os import os
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
import redis
if TYPE_CHECKING:
from redis import Redis
logger = logging.getLogger(__name__) 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_URL = os.getenv("REDIS_URL", "").strip()
REDIS_CONNECT_TIMEOUT_SECONDS = float(os.getenv("REDIS_CONNECT_TIMEOUT_SECONDS", "2")) 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_SOCKET_TIMEOUT_SECONDS = float(os.getenv("REDIS_SOCKET_TIMEOUT_SECONDS", "2"))
_redis_client: Optional["Redis"] = None _redis_client: Optional["redis.Redis"] = None
_initialized = False _initialized = False
@lru_cache
def get_redis_client() -> Optional["Redis"]: def get_redis_client() -> Optional["redis.Redis"]:
"""Return a singleton Redis client, or None when Redis is unavailable.""" """Return a singleton Redis client, or None when Redis is unavailable."""
global _redis_client, _initialized global _redis_client, _initialized
@@ -31,12 +25,7 @@ def get_redis_client() -> Optional["Redis"]:
_initialized = True _initialized = True
if not REDIS_URL: if not REDIS_URL:
logger.info("REDIS_URL 未配置,验证码将回退到数据库存储") logger.info("REDIS_URL 未配置,验证码将回退到内存存储")
_redis_client = None
return _redis_client
if redis is None:
logger.warning("未安装 redis 包,验证码将回退到数据库存储")
_redis_client = None _redis_client = None
return _redis_client 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" />
/// <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==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1915,7 +1914,6 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1965,7 +1963,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -2549,7 +2546,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2619,7 +2615,6 @@
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==", "integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"peer": true,
"dependencies": { "dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3" "@yr/monotone-cubic-spline": "^1.0.3"
} }
@@ -2741,7 +2736,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3048,7 +3042,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
@@ -3131,7 +3124,6 @@
"integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==", "integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -3590,7 +3582,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
@@ -4187,7 +4178,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^7.7.7" "@vue/devtools-api": "^7.7.7"
}, },
@@ -4594,7 +4584,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4657,7 +4646,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4811,7 +4799,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -5043,7 +5030,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5063,7 +5049,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "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) Math.max(y, innerHeight - y)
) )
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
const transition = document.startViewTransition(() => { const transition = document.startViewTransition(() => {
themeStore.toggleTheme() themeStore.toggleTheme()
}) })
+6 -115
View File
@@ -1,121 +1,12 @@
/**
* API 基础配置:自动探测内网/公网后端,失败时回退公网
*/
const API_PREFIX = '/api/v1' 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}` export function buildUrl(path: string): string {
const PUBLIC_API_BASE_URL = `${PUBLIC_BACKEND_ORIGIN}${API_PREFIX}` if (!path.startsWith('/')) {
const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined path = '/' + path
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
} }
return `${API_PREFIX}${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
} }
function isLanHostname(hostname: string): boolean { export async function fetchApi(path: string, init?: RequestInit) {
const normalized = hostname.toLowerCase() return fetch(buildUrl(path), init)
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
}
} }
+7 -7
View File
@@ -88,8 +88,8 @@ const revisionChains = computed<RevisionChain[]>(() => {
// 组内按时间升序 // 组内按时间升序
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at)) items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
const first = items[0] const first = items[0]!
const last = items[items.length - 1] const last = items[items.length - 1]!
// 拼接标题链,避免相邻记录重复 // 拼接标题链,避免相邻记录重复
const titles: string[] = [first.previous_headline] const titles: string[] = [first.previous_headline]
@@ -107,13 +107,13 @@ const revisionChains = computed<RevisionChain[]>(() => {
chains.push({ chains.push({
event_id, event_id,
source_name: first.source_name, source_name: first.source_name!,
titles, titles,
change_times, change_times,
first_at: first.created_at, first_at: first.created_at!,
last_at: last.created_at, last_at: last.created_at!,
change_count: items.length, change_count: items.length,
url: first.url, url: first.url!,
}) })
} }
@@ -242,7 +242,7 @@ onMounted(loadRevisions)
</span> </span>
<p class="chain-title-text">{{ title }}</p> <p class="chain-title-text">{{ title }}</p>
<span v-if="idx < chain.change_times.length" class="chain-step-time"> <span v-if="idx < chain.change_times.length" class="chain-step-time">
{{ formatTime(chain.change_times[idx]) }} {{ formatTime(chain.change_times[idx] ?? '') }}
</span> </span>
</div> </div>
<div v-if="idx < chain.titles.length - 1" class="chain-arrow"> <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 type { SearchTimelineResponse } from '@/types/event'
import UnifiedEventCard from '@/components/UnifiedEventCard.vue' import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
import CustomSelect from '@/components/CustomSelect.vue' import CustomSelect from '@/components/CustomSelect.vue'
import type { ApexOptions } from 'apexcharts'
const keyword = ref('') const keyword = ref('')
const searchResult = ref<SearchTimelineResponse | null>(null) const searchResult = ref<SearchTimelineResponse | null>(null)
@@ -56,7 +57,7 @@ const filteredEvents = computed(() => {
}) })
// 热度时间线图表配置。 // 热度时间线图表配置。
const chartOptions = ref({ const chartOptions = ref<ApexOptions>({
chart: { chart: {
type: 'area', type: 'area',
height: 350, height: 350,
@@ -66,12 +67,12 @@ const chartOptions = ref({
}, },
animations: { animations: {
enabled: true, enabled: true,
easing: 'easeinout', // easing: 'easeinout',
speed: 800, speed: 800,
}, },
// 点击图表数据点:切换选中时间,再次点击则取消筛选 // 点击图表数据点:切换选中时间,再次点击则取消筛选
events: { events: {
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) { markerClick: function(event: unknown, chartContext: unknown, { dataPointIndex }: never) {
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) { if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
if (selectedTimeLabel.value === clickedTime) { if (selectedTimeLabel.value === clickedTime) {
@@ -368,7 +369,7 @@ async function handleSearch() {
gap: 24px; gap: 24px;
align-items: stretch; align-items: stretch;
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
.search-box { .search-box {
@@ -378,7 +379,7 @@ async function handleSearch() {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.tips-box { .tips-box {
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://10.252.130.135:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
}, },
}, },