mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-06 03:07:50 +08:00
Compare commits
15 Commits
dev_backup
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a5f5ee9ea | |||
| f68b674eb2 | |||
| 136d49d2d5 | |||
| 0c325ec1e7 | |||
| 3e84f65cdc | |||
| 9c64d52e1b | |||
| a10a5a176b | |||
| d4a8f59fd8 | |||
| 1b8fadc0c9 | |||
| 210bb3b9ea | |||
| ca796a5fd2 | |||
| b18901a2d5 | |||
| 2335b62384 | |||
| a424185854 | |||
| 8b5fb44ded |
@@ -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 \
|
||||
.
|
||||
@@ -37,6 +37,9 @@ MANIFEST
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# uv
|
||||
*.lock
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
@@ -184,3 +187,6 @@ cython_debug/
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
**/data/*
|
||||
**/docker/*
|
||||
@@ -0,0 +1,13 @@
|
||||
.venv
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.cache
|
||||
.env
|
||||
*.log
|
||||
dist
|
||||
build
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -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
|
||||
|
||||
|
||||
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,
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is already registered",
|
||||
)
|
||||
code_hash = code_record.code_hash
|
||||
else:
|
||||
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
|
||||
code_record, code = _create_code_record(
|
||||
db,
|
||||
email=email,
|
||||
purpose=VerificationPurpose.REGISTER,
|
||||
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
code_hash = code_record.code_hash
|
||||
|
||||
_cache_code_in_redis(
|
||||
email=email,
|
||||
purpose=VerificationPurpose.REGISTER,
|
||||
code_hash=code_hash,
|
||||
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
_set_send_cooldown_in_redis(email, VerificationPurpose.REGISTER)
|
||||
|
||||
try:
|
||||
email_sent = await send_html_email(
|
||||
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,
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Email is not registered",
|
||||
)
|
||||
code_hash = code_record.code_hash
|
||||
else:
|
||||
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
|
||||
code_record, code = _create_code_record(
|
||||
db,
|
||||
email=email,
|
||||
purpose=VerificationPurpose.LOGIN,
|
||||
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
code_hash = code_record.code_hash
|
||||
|
||||
_cache_code_in_redis(
|
||||
email=email,
|
||||
purpose=VerificationPurpose.LOGIN,
|
||||
code_hash=code_hash,
|
||||
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
_set_send_cooldown_in_redis(email, VerificationPurpose.LOGIN)
|
||||
|
||||
try:
|
||||
email_sent = await send_html_email(
|
||||
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")
|
||||
|
||||
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
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is already registered",
|
||||
)
|
||||
code_record = None
|
||||
|
||||
if redis_result is False:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
|
||||
|
||||
if redis_result is None:
|
||||
# 即使在 _is_redis_only() 模式下,也去数据库兜底查找
|
||||
# 这样如果Redis挂了时代码回退到了DB,验证时也能从DB拿出来。
|
||||
code_record = _get_latest_valid_code_record(
|
||||
db,
|
||||
try:
|
||||
service.verify_code(
|
||||
email=email,
|
||||
purpose=VerificationPurpose.REGISTER,
|
||||
code=payload.verification_code,
|
||||
)
|
||||
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):
|
||||
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")
|
||||
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
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or verification code",
|
||||
)
|
||||
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,
|
||||
try:
|
||||
service.verify_code(
|
||||
email=email,
|
||||
purpose=VerificationPurpose.LOGIN,
|
||||
code=payload.verification_code,
|
||||
)
|
||||
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,
|
||||
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",
|
||||
)
|
||||
|
||||
if code_record is not None:
|
||||
code_record.is_used = True
|
||||
db.add(code_record)
|
||||
|
||||
db.commit()
|
||||
|
||||
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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
多渠道推送端点配置表 (高可用解耦设计)
|
||||
|
||||
@@ -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("模型加载完成。")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
}
|
||||
Vendored
+9
@@ -1 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BACKEND_ORIGIN: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
Generated
-15
@@ -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",
|
||||
|
||||
@@ -26,7 +26,6 @@ function handleToggle(event: MouseEvent) {
|
||||
Math.max(y, innerHeight - y)
|
||||
)
|
||||
|
||||
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
|
||||
const transition = document.startViewTransition(() => {
|
||||
themeStore.toggleTheme()
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
export function buildUrl(path: string): string {
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path
|
||||
}
|
||||
return `${API_PREFIX}${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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://10.252.130.135:8000',
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user