From 8b5fb44ded31ab8eac2182849d35406643970877 Mon Sep 17 00:00:00 2001 From: csf123321 Date: Mon, 16 Mar 2026 16:31:56 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E6=B7=BB=E5=8A=A0uv=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.python-version | 1 + backend/{run.py => main.py} | 0 backend/pyproject.toml | 7 +++++++ 3 files changed, 8 insertions(+) create mode 100644 backend/.python-version rename backend/{run.py => main.py} (100%) create mode 100644 backend/pyproject.toml diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/backend/run.py b/backend/main.py similarity index 100% rename from backend/run.py rename to backend/main.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..aa95454 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "backend" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] From a4241858547d55e7820c9c27f197196334889f0c Mon Sep 17 00:00:00 2001 From: csf123321 Date: Fri, 20 Mar 2026 00:39:04 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=BA=E5=AE=8C?= =?UTF-8?q?=E5=85=A8uv=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ backend/pyproject.toml | 66 ++++++++++++++++++++++++++++++++++++++- backend/requirements.txt | Bin 2270 -> 2246 bytes 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ef505aa..99d44d9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ MANIFEST *.manifest *.spec +# uv +*.lock + # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/backend/pyproject.toml b/backend/pyproject.toml index aa95454..d53646a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,4 +4,68 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.11" -dependencies = [] +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", +] diff --git a/backend/requirements.txt b/backend/requirements.txt index 4514c51f7ef9bc10fbb508a7c5de44be9b61a445..9db89a9cd168414e084a11b640d338ef4c16cd7c 100644 GIT binary patch delta 16 Xcmca7cua7E2m9m@cCF1_>>-Q*HJb%@ delta 40 lcmX>mcu#PH2RpYmLo!1tgCT Date: Tue, 24 Mar 2026 15:46:39 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AFapi?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/fetcher_service.py | 2 +- frontend/env.d.ts | 9 +++++++++ frontend/package-lock.json | 15 --------------- frontend/src/config/apiBase.ts | 6 ++++-- frontend/vite.config.ts | 2 +- 5 files changed, 15 insertions(+), 19 deletions(-) diff --git a/backend/app/services/fetcher_service.py b/backend/app/services/fetcher_service.py index 4cc71cc..271b579 100644 --- a/backend/app/services/fetcher_service.py +++ b/backend/app/services/fetcher_service.py @@ -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("模型加载完成。") diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 11f02fe..c1e8614 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -1 +1,10 @@ /// +/// + +interface ImportMetaEnv { + readonly VITE_BACKEND_ORIGIN: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b36115a..219a35c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/config/apiBase.ts b/frontend/src/config/apiBase.ts index 6833e0b..6459e6b 100644 --- a/frontend/src/config/apiBase.ts +++ b/frontend/src/config/apiBase.ts @@ -2,10 +2,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 LAN_BACKEND_ORIGIN = import.meta.env.VITE_BACKEND_ORIGIN +const PUBLIC_BACKEND_ORIGIN = import.meta.env.VITE_BACKEND_ORIGIN const PROBE_TIMEOUT_MS = 1200 +console.log(LAN_BACKEND_ORIGIN); + 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 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 313158b..6d1a94a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ strictPort: true, proxy: { '/api': { - target: 'http://10.252.130.135:8000', + target: 'http://localhost:8000', changeOrigin: true, }, }, From b18901a2d576392cbbdca89a41462a8494c4d88e Mon Sep 17 00:00:00 2001 From: csf123321 Date: Thu, 26 Mar 2026 01:48:55 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E8=AE=B0=E5=BD=95=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/auth.py | 484 ++++-------------- backend/app/core/verification/__init__.py | 0 .../email/RespositoryImpl/MemoryRepository.py | 71 +++ .../email/RespositoryImpl/RedisRepository.py | 88 ++++ .../email/RespositoryImpl/__init__.py | 0 .../email/RespositoryImpl/hybirdRepository.py | 54 ++ .../app/core/verification/email/__init__.py | 0 .../email/verificationRepository.py | 44 ++ .../verification/email/verificationService.py | 79 +++ backend/app/main.py | 2 +- backend/app/models/models.py | 16 - backend/app/utils/redis_client.py | 23 +- 12 files changed, 444 insertions(+), 417 deletions(-) create mode 100644 backend/app/core/verification/__init__.py create mode 100644 backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py create mode 100644 backend/app/core/verification/email/RespositoryImpl/RedisRepository.py create mode 100644 backend/app/core/verification/email/RespositoryImpl/__init__.py create mode 100644 backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py create mode 100644 backend/app/core/verification/email/__init__.py create mode 100644 backend/app/core/verification/email/verificationRepository.py create mode 100644 backend/app/core/verification/email/verificationService.py diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 8895a1c..e09d9a9 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -4,6 +4,7 @@ import json import math import os +import logging from datetime import timedelta, timezone from typing import Optional, Tuple @@ -19,7 +20,7 @@ from app.core.security import ( verify_password, verify_verification_code, ) -from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow +from app.models.models import AppUser, VerificationPurpose, utcnow from app.schemas.auth_schema import ( AuthTokenResponse, LoginCodeSendRequest, @@ -32,9 +33,11 @@ from app.schemas.auth_schema import ( ) from app.utils.email_utils import send_html_email from app.utils.redis_client import get_redis_client +from app.core.verification.email.verificationService import EmailVerificationService, get_verification_service, TooManyCodeRequestsError, CodeExpiredError, CodeInvalidError router = APIRouter() +logger = logging.getLogger(__name__) DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10 DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10 @@ -131,19 +134,12 @@ def _cache_code_in_redis( "code_hash": code_hash, "created_at": utcnow().isoformat(), } - try: - client.set( - _redis_code_key(email, purpose), - json.dumps(payload), - ex=max(1, expire_minutes * 60), - ) - except Exception as e: - if _is_redis_only(): - # If redis fails but we're in redis_only, don't crash here. - # We already generated the code hash, but we won't cache it in redis. - # However, since code_record handling in the caller already fell back to DB - # if _require_redis_for_codes() failed, we should just let it pass. - pass + + client.set( + _redis_code_key(email, purpose), + json.dumps(payload), + ex=max(1, expire_minutes * 60), + ) def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None: @@ -178,166 +174,6 @@ def _clear_code_in_redis(email: str, purpose: VerificationPurpose) -> None: pass -def _verify_code_with_redis( - email: str, - purpose: VerificationPurpose, - code: str, - *, - strict: bool = False, -) -> Optional[bool]: - """ - Redis 验证码校验。 - 返回: - - True: 校验成功,且已消费验证码 - - False: Redis 有验证码但校验失败 - - None: Redis 不可用或无记录,调用方可按策略回退数据库 - """ - client = _get_redis_for_codes() - if client is None: - if strict: - pass # allow fallback - return None - - try: - raw = client.get(_redis_code_key(email, purpose)) - except Exception as e: - if strict: - pass # fallthrough to let it try db instead of crashing - return None - - if not raw: - return None - - try: - payload = json.loads(raw) - expected_hash = str(payload.get("code_hash", "")) - except Exception: - # 不要轻易清除,可能是数据格式异常 - return None - - if not expected_hash: - return None - - if not verify_verification_code(code, expected_hash): - # 注意:校验失败时不要直接清空 Redis,可能用户只是输错了 - return False - - _clear_code_in_redis(email, purpose) - return True - - -def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None: - """将同一邮箱、同一用途下未使用的旧验证码全部标记为已使用,避免重复使用""" - db.query(EmailVerificationCode).filter( - EmailVerificationCode.email == email, - EmailVerificationCode.purpose == purpose, - EmailVerificationCode.is_used.is_(False), - ).update({EmailVerificationCode.is_used: True}, synchronize_session=False) - db.commit() - - -def _create_code_record( - db: Session, - *, - email: str, - purpose: VerificationPurpose, - expire_minutes: int, -) -> Tuple[EmailVerificationCode, str]: - """在数据库中创建验证码记录,返回 (记录对象, 明文验证码)""" - code = generate_verification_code() - now = utcnow() - code_record = EmailVerificationCode( - email=email, - purpose=purpose, - code_hash=hash_verification_code(code), - expires_at=now + timedelta(minutes=expire_minutes), - ) - db.add(code_record) - db.commit() - return code_record, code - - -def _get_latest_valid_code_record( - db: Session, - *, - email: str, - purpose: VerificationPurpose, -): - """从数据库获取该邮箱该用途下最新且未过期、未使用的验证码记录""" - now = utcnow() - return ( - db.query(EmailVerificationCode) - .filter( - EmailVerificationCode.email == email, - EmailVerificationCode.purpose == purpose, - EmailVerificationCode.is_used.is_(False), - EmailVerificationCode.expires_at >= now, - ) - .order_by(EmailVerificationCode.created_at.desc()) - .first() - ) - - -def _enforce_code_send_cooldown(db: Session, email: str, purpose: VerificationPurpose) -> None: - """限制同一邮箱同一用途验证码的发送频率。""" - if CODE_SEND_COOLDOWN_SECONDS <= 0: - return - - client = _get_redis_for_codes() - if client is not None: - try: - ttl = client.ttl(_redis_cooldown_key(email, purpose)) - if ttl is not None and ttl > 0: - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=f"Please wait {ttl}s before requesting another verification code", - headers={"Retry-After": str(ttl)}, - ) - if _is_redis_only(): - return - except HTTPException: - raise - except Exception: - # redis failed during cooldown check, fallback to DB - pass - - if _is_redis_only(): - # Even if redis_only, we allow it to fallthrough if it's down. - # This aligns with our fallback logic. - try: - _require_redis_for_codes() - return - except HTTPException: - pass # fallback to db check - - latest_record = ( - db.query(EmailVerificationCode) - .filter( - EmailVerificationCode.email == email, - EmailVerificationCode.purpose == purpose, - ) - .order_by(EmailVerificationCode.created_at.desc()) - .first() - ) - if not latest_record: - return - - now = utcnow() - record_time = latest_record.created_at - if record_time.tzinfo is None: - record_time = record_time.replace(tzinfo=timezone.utc) - elapsed_seconds = (now - record_time).total_seconds() - if elapsed_seconds >= CODE_SEND_COOLDOWN_SECONDS: - return - - retry_after_seconds = max(1, math.ceil(CODE_SEND_COOLDOWN_SECONDS - elapsed_seconds)) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=f"Please wait {retry_after_seconds}s before requesting another verification code", - headers={"Retry-After": str(retry_after_seconds)}, - ) - - def _build_auth_response(user: AppUser) -> AuthTokenResponse: token, expires_in = create_access_token(user_id=user.id, email=user.email) return AuthTokenResponse( @@ -348,219 +184,115 @@ def _build_auth_response(user: AppUser) -> AuthTokenResponse: @router.post("/register/send-code", response_model=MessageResponse) -async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)): - """发送注册验证码:先校验邮箱未注册、冷却期,再生成并发送""" +async def send_register_code( + payload: RegisterCodeSendRequest, + db: Session = Depends(get_db), + service: EmailVerificationService = Depends(get_verification_service), +): email = _normalize_email(payload.email) existing_user = db.query(AppUser).filter(AppUser.email == email).first() if existing_user: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") - - _enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER) - - code_record = None - if _is_redis_only(): - try: - _require_redis_for_codes() - code = generate_verification_code() - code_hash = hash_verification_code(code) - except HTTPException: - # If redis is down, temporarily fallback to DB even in redis_only mode - _invalidate_unused_codes(db, email, VerificationPurpose.REGISTER) - code_record, code = _create_code_record( - db, - email=email, - purpose=VerificationPurpose.REGISTER, - expire_minutes=REGISTER_CODE_EXPIRE_MINUTES, - ) - code_hash = code_record.code_hash - else: - _invalidate_unused_codes(db, email, VerificationPurpose.REGISTER) - code_record, code = _create_code_record( - db, - email=email, - purpose=VerificationPurpose.REGISTER, - expire_minutes=REGISTER_CODE_EXPIRE_MINUTES, + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email is already registered", ) - code_hash = code_record.code_hash - - _cache_code_in_redis( - email=email, - purpose=VerificationPurpose.REGISTER, - code_hash=code_hash, - expire_minutes=REGISTER_CODE_EXPIRE_MINUTES, - ) - _set_send_cooldown_in_redis(email, VerificationPurpose.REGISTER) try: - email_sent = await send_html_email( + code = service.send_code(email, VerificationPurpose.REGISTER) + + await send_html_email( to_email=email, subject=f"【{code}】InsightRadar 注册验证码", - html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES), + html_content=_build_verification_email( + code, "注册", REGISTER_CODE_EXPIRE_MINUTES + ), ) + + except TooManyCodeRequestsError as e: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e)) + except Exception as e: - _clear_code_in_redis(email, VerificationPurpose.REGISTER) - # also clear cooldown if possible, so user can retry immediately - client = _get_redis_for_codes() - if client: - try: - client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER)) - except Exception: - pass raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=500, detail=f"Failed to send verification code: {e}", ) - if not email_sent: - _clear_code_in_redis(email, VerificationPurpose.REGISTER) - client = _get_redis_for_codes() - if client: - try: - client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER)) - except Exception: - pass - if code_record is not None: - code_record.is_used = True - db.add(code_record) - db.commit() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to send verification code", - ) return MessageResponse(message="Verification code sent") @router.post("/login/send-code", response_model=MessageResponse) -async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)): - """发送登录验证码:仅对已注册用户发送""" +async def send_login_code( + payload: LoginCodeSendRequest, + db: Session = Depends(get_db), + service: EmailVerificationService = Depends(get_verification_service), +): email = _normalize_email(payload.email) + user = db.query(AppUser).filter(AppUser.email == email).first() - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered") - - _enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN) - - code_record = None - if _is_redis_only(): - try: - _require_redis_for_codes() - code = generate_verification_code() - code_hash = hash_verification_code(code) - except HTTPException: - # If redis is down, temporarily fallback to DB even in redis_only mode - _invalidate_unused_codes(db, email, VerificationPurpose.LOGIN) - code_record, code = _create_code_record( - db, - email=email, - purpose=VerificationPurpose.LOGIN, - expire_minutes=LOGIN_CODE_EXPIRE_MINUTES, - ) - code_hash = code_record.code_hash - else: - _invalidate_unused_codes(db, email, VerificationPurpose.LOGIN) - code_record, code = _create_code_record( - db, - email=email, - purpose=VerificationPurpose.LOGIN, - expire_minutes=LOGIN_CODE_EXPIRE_MINUTES, + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Email is not registered", ) - code_hash = code_record.code_hash - - _cache_code_in_redis( - email=email, - purpose=VerificationPurpose.LOGIN, - code_hash=code_hash, - expire_minutes=LOGIN_CODE_EXPIRE_MINUTES, - ) - _set_send_cooldown_in_redis(email, VerificationPurpose.LOGIN) try: - email_sent = await send_html_email( + code = service.send_code(email, VerificationPurpose.LOGIN) + + await send_html_email( to_email=email, subject=f"【{code}】InsightRadar 登录验证码", - html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES), + html_content=_build_verification_email( + code, "登录", LOGIN_CODE_EXPIRE_MINUTES + ), ) + + except TooManyCodeRequestsError as e: + raise HTTPException(status_code=429, detail=str(e)) + except Exception as e: - _clear_code_in_redis(email, VerificationPurpose.LOGIN) - client = _get_redis_for_codes() - if client: - try: - client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN)) - except Exception: - pass raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=500, detail=f"Failed to send verification code: {e}", ) - if not email_sent: - _clear_code_in_redis(email, VerificationPurpose.LOGIN) - client = _get_redis_for_codes() - if client: - try: - client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN)) - except Exception: - pass - if code_record is not None: - code_record.is_used = True - db.add(code_record) - db.commit() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to send verification code", - ) return MessageResponse(message="Verification code sent") - @router.post( "/register", response_model=AuthTokenResponse, status_code=status.HTTP_201_CREATED, ) -async def register(payload: RegisterRequest, db: Session = Depends(get_db)): - """用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户""" +async def register( + payload: RegisterRequest, + db: Session = Depends(get_db), + service: EmailVerificationService = Depends(get_verification_service), +): email = _normalize_email(payload.email) + existing_user = db.query(AppUser).filter(AppUser.email == email).first() if existing_user: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email is already registered", + ) - redis_result = _verify_code_with_redis( - email, - VerificationPurpose.REGISTER, - payload.verification_code, - strict=False, # Never be strict so we can fallback to DB if redis is down - ) - code_record = None - - if redis_result is False: + try: + service.verify_code( + email=email, + purpose=VerificationPurpose.REGISTER, + code=payload.verification_code, + ) + except CodeExpiredError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code expired") + except CodeInvalidError: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code") - - if redis_result is None: - # 即使在 _is_redis_only() 模式下,也去数据库兜底查找 - # 这样如果Redis挂了时代码回退到了DB,验证时也能从DB拿出来。 - code_record = _get_latest_valid_code_record( - db, - email=email, - purpose=VerificationPurpose.REGISTER, - ) - if not code_record: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired") - - if not verify_verification_code(payload.verification_code, code_record.code_hash): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code") - else: - # Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。 - # 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。 - code_record = _get_latest_valid_code_record( - db, - email=email, - purpose=VerificationPurpose.REGISTER, - ) + except TooManyCodeRequestsError: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts") now = utcnow() nickname = payload.nickname or email.split("@")[0] + user = AppUser( email=email, password_hash=hash_password(payload.password), @@ -569,16 +301,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)): ) db.add(user) - if code_record is not None: - code_record.is_used = True - db.add(code_record) - db.commit() db.refresh(user) return _build_auth_response(user) - @router.post("/login", response_model=AuthTokenResponse) async def login(payload: LoginRequest, db: Session = Depends(get_db)): """密码登录""" @@ -595,49 +322,40 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)): @router.post("/login/code", response_model=AuthTokenResponse) -async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)): - """验证码登录:Redis 校验优先,失败则从数据库兜底""" +async def login_with_code( + payload: LoginWithCodeRequest, + db: Session = Depends(get_db), + service: EmailVerificationService = Depends(get_verification_service), +): email = _normalize_email(payload.email) + user = db.query(AppUser).filter(AppUser.email == email).first() - if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code") - - redis_result = _verify_code_with_redis( - email, - VerificationPurpose.LOGIN, - payload.verification_code, - strict=False, # Never be strict so we can fallback to DB if redis is down - ) - code_record = None - - if redis_result is False: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code") - - if redis_result is None: - code_record = _get_latest_valid_code_record( - db, - email=email, - purpose=VerificationPurpose.LOGIN, - ) - if not code_record: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired") - - if not verify_verification_code(payload.verification_code, code_record.code_hash): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code") - else: - # Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。 - # 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。 - code_record = _get_latest_valid_code_record( - db, - email=email, - purpose=VerificationPurpose.LOGIN, + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or verification code", ) - if code_record is not None: - code_record.is_used = True - db.add(code_record) - - db.commit() + try: + service.verify_code( + email=email, + purpose=VerificationPurpose.LOGIN, + code=payload.verification_code, + ) + except CodeExpiredError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or verification code", + ) + except CodeInvalidError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or verification code", + ) + except TooManyCodeRequestsError: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many attempts", + ) return _build_auth_response(user) diff --git a/backend/app/core/verification/__init__.py b/backend/app/core/verification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py b/backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py new file mode 100644 index 0000000..211a9fa --- /dev/null +++ b/backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py @@ -0,0 +1,71 @@ +# 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 consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: + 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 + + del self._store[key] + + try: + payload = json.loads(value) + return payload.get("code_hash") + except Exception: + return None + + 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() diff --git a/backend/app/core/verification/email/RespositoryImpl/RedisRepository.py b/backend/app/core/verification/email/RespositoryImpl/RedisRepository.py new file mode 100644 index 0000000..c2e15a8 --- /dev/null +++ b/backend/app/core/verification/email/RespositoryImpl/RedisRepository.py @@ -0,0 +1,88 @@ +from functools import lru_cache +import os +import logging +import json +import datetime +import redis +from typing import Optional, TYPE_CHECKING + +from app.models.models import VerificationPurpose +from app.core.verification.email.verificationRepository import VerificationRepository +from app.utils.redis_client import get_redis_client +from app.core.security import hash_verification_code + +logger = logging.getLogger(__name__) + +AUTH_CODE_REDIS_PREFIX = os.getenv("AUTH_CODE_REDIS_PREFIX", "insightradar:auth_code").strip() + + +class RedisRepository(VerificationRepository): + _consume_lua = """ local val = redis.call("GET", KEYS[1]) if val then redis.call("DEL", KEYS[1]) end return val """ + + + def __init__(self, client: redis.Redis): + self.client = client + self._consume_script = self.client.register_script(self._consume_lua) + + + + def _key(self, email, purpose): + 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: + """store the code into the redis + + Args: + email (str): email of user + purpose (VerificationPurpose): purpose of the code, such as "login", "register" + code_hash: the hash of the code + ttl: duration of the code + + """ + key = self._key(email, purpose) + + payload = json.dumps({ + "code_hash": code_hash, + "exp": datetime.datetime.now().timestamp() + }) + + self.client.set(key, payload, ex=ttl) + + def consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: + """consume the code of email + + Args: + email (str): email of user + purpose (VerificationPurpose): purpose of the code, such as "login", "register" + + Returns: + _type_: if email has a code which has not been consumed, return the code, else return None + """ + + key = self._key(email, purpose) + data = self._consume_script(keys=[key]) + + if not data: + return None + + try: + payload = json.loads(data) # type: ignore + return payload.get("code_hash") + except Exception: + return None + + def incr(self, key: str, ttl: int) -> int: + super().incr(key, ttl) + value = self.client.incr(key) + + if value == 1: + self.client.expire(key, ttl) + + return value # type: ignore + +@lru_cache +def get_redis_repo(): + client = get_redis_client() + if client is None: + return None + return RedisRepository(client) diff --git a/backend/app/core/verification/email/RespositoryImpl/__init__.py b/backend/app/core/verification/email/RespositoryImpl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py b/backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py new file mode 100644 index 0000000..5d2e67d --- /dev/null +++ b/backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py @@ -0,0 +1,54 @@ +# app/verification/backends/hybrid.py + +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 consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: + if self.redis: + try: + return self.redis.consume_code(email, purpose) + except Exception as e: + logger.warning("Redis consume_code failed, fallback to memory: %s", e) + + return self.memory.consume_code(email, purpose) + + 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) \ No newline at end of file diff --git a/backend/app/core/verification/email/__init__.py b/backend/app/core/verification/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/verification/email/verificationRepository.py b/backend/app/core/verification/email/verificationRepository.py new file mode 100644 index 0000000..9a74524 --- /dev/null +++ b/backend/app/core/verification/email/verificationRepository.py @@ -0,0 +1,44 @@ +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 consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: + """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 code, else return None + """ + 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 + \ No newline at end of file diff --git a/backend/app/core/verification/email/verificationService.py b/backend/app/core/verification/email/verificationService.py new file mode 100644 index 0000000..230911e --- /dev/null +++ b/backend/app/core/verification/email/verificationService.py @@ -0,0 +1,79 @@ +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) + +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() + + stored_hash: Optional[str] = self.repo.consume_code(email, purpose) + + if not stored_hash: + raise CodeExpiredError("Code expired or not found") + + if stored_hash != hash_verification_code(code): + raise CodeInvalidError("Invalid code") + + return True + +@lru_cache +def get_verification_service(): + repo = get_verification_repository() + return EmailVerificationService(repo) diff --git a/backend/app/main.py b/backend/app/main.py index 1362664..896d8e5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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", ) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 30bab54..a70942d 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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): """ 多渠道推送端点配置表 (高可用解耦设计) diff --git a/backend/app/utils/redis_client.py b/backend/app/utils/redis_client.py index e86df80..ca5c4cd 100644 --- a/backend/app/utils/redis_client.py +++ b/backend/app/utils/redis_client.py @@ -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 From ca796a5fd2d4a9f76ae225d67a67dac2cefac938 Mon Sep 17 00:00:00 2001 From: csf123321 Date: Thu, 26 Mar 2026 02:04:41 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E9=AA=8C=E8=AF=81=E6=9C=BA=E4=BC=9A=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../email/RespositoryImpl/MemoryRepository.py | 23 +++-- .../email/RespositoryImpl/RedisRepository.py | 97 ++++++++++--------- .../email/RespositoryImpl/hybirdRepository.py | 18 +++- .../email/verificationRepository.py | 9 +- .../verification/email/verificationService.py | 11 ++- 5 files changed, 94 insertions(+), 64 deletions(-) diff --git a/backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py b/backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py index 211a9fa..926579a 100644 --- a/backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py +++ b/backend/app/core/verification/email/RespositoryImpl/MemoryRepository.py @@ -29,7 +29,12 @@ class MemoryRepository(VerificationRepository): with self._lock: self._store[key] = (json.dumps(payload), expire_at) - def consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: + def compare_and_consume( + self, + email: str, + purpose: VerificationPurpose, + code_hash: str, + ) -> Optional[bool]: key = self._key(email, purpose) with self._lock: @@ -44,13 +49,17 @@ class MemoryRepository(VerificationRepository): del self._store[key] return None - del self._store[key] + try: + payload = json.loads(value) + stored_hash = payload.get("code_hash") + except Exception: + return None - try: - payload = json.loads(value) - return 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() diff --git a/backend/app/core/verification/email/RespositoryImpl/RedisRepository.py b/backend/app/core/verification/email/RespositoryImpl/RedisRepository.py index c2e15a8..679f8bc 100644 --- a/backend/app/core/verification/email/RespositoryImpl/RedisRepository.py +++ b/backend/app/core/verification/email/RespositoryImpl/RedisRepository.py @@ -2,83 +2,90 @@ from functools import lru_cache import os import logging import json -import datetime import redis -from typing import Optional, TYPE_CHECKING +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 -from app.core.security import hash_verification_code logger = logging.getLogger(__name__) -AUTH_CODE_REDIS_PREFIX = os.getenv("AUTH_CODE_REDIS_PREFIX", "insightradar:auth_code").strip() +AUTH_CODE_REDIS_PREFIX = os.getenv( + "AUTH_CODE_REDIS_PREFIX", "insightradar:auth_code" +).strip() class RedisRepository(VerificationRepository): - _consume_lua = """ local val = redis.call("GET", KEYS[1]) if val then redis.call("DEL", KEYS[1]) end return val """ + _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._consume_script = self.client.register_script(self._consume_lua) - - + self._compare_script = self.client.register_script( + self._compare_and_consume_lua + ) - def _key(self, email, purpose): + 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: - """store the code into the redis - - Args: - email (str): email of user - purpose (VerificationPurpose): purpose of the code, such as "login", "register" - code_hash: the hash of the code - ttl: duration of the 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, - "exp": datetime.datetime.now().timestamp() }) self.client.set(key, payload, ex=ttl) - def consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: - """consume the code of email - - Args: - email (str): email of user - purpose (VerificationPurpose): purpose of the code, such as "login", "register" - - Returns: - _type_: if email has a code which has not been consumed, return the code, else return None - """ - + def compare_and_consume( + self, + email: str, + purpose: VerificationPurpose, + code_hash: str, + ) -> Optional[bool]: key = self._key(email, purpose) - data = self._consume_script(keys=[key]) - if not data: - return None + result = self._compare_script( + keys=[key], + args=[code_hash], + ) + + if result is None: + return None + + if result == 1: + return True + + return False - try: - payload = json.loads(data) # type: ignore - return payload.get("code_hash") - except Exception: - return None - def incr(self, key: str, ttl: int) -> int: - super().incr(key, ttl) value = self.client.incr(key) - + if value == 1: self.client.expire(key, ttl) - - return value # type: ignore + + return int(value) # type: ignore @lru_cache def get_redis_repo(): diff --git a/backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py b/backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py index 5d2e67d..3b0eeae 100644 --- a/backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py +++ b/backend/app/core/verification/email/RespositoryImpl/hybirdRepository.py @@ -1,4 +1,3 @@ -# app/verification/backends/hybrid.py from functools import lru_cache import logging @@ -28,14 +27,23 @@ class HybridRepository(VerificationRepository): self.memory.set_code(email, purpose, code_hash, ttl) - def consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: + def compare_and_consume( + self, + email: str, + purpose: VerificationPurpose, + code_hash: str, + ) -> Optional[bool]: if self.redis: try: - return self.redis.consume_code(email, purpose) + return self.redis.compare_and_consume( + email, purpose, code_hash + ) except Exception as e: - logger.warning("Redis consume_code failed, fallback to memory: %s", e) + logger.warning( + "Redis compare_and_consume failed, fallback to memory: %s", e + ) - return self.memory.consume_code(email, purpose) + return self.memory.compare_and_consume(email, purpose, code_hash) def incr(self, key: str, ttl: int) -> int: if self.redis: diff --git a/backend/app/core/verification/email/verificationRepository.py b/backend/app/core/verification/email/verificationRepository.py index 9a74524..d1bc994 100644 --- a/backend/app/core/verification/email/verificationRepository.py +++ b/backend/app/core/verification/email/verificationRepository.py @@ -20,7 +20,12 @@ class VerificationRepository(ABC): @abstractmethod - def consume_code(self, email: str, purpose: VerificationPurpose) -> Optional[str]: + def compare_and_consume( + self, + email: str, + purpose: VerificationPurpose, + code_hash: str, + ) -> Optional[bool]: """consume the code atomically Args: @@ -28,7 +33,7 @@ class VerificationRepository(ABC): purpose (VerificationPurpose): the purpose of the code, such as, "login", "register" Returns: - Optional[str]: if success return the code, else return None + Optional[str]: if success return the true """ pass diff --git a/backend/app/core/verification/email/verificationService.py b/backend/app/core/verification/email/verificationService.py index 230911e..eb5636c 100644 --- a/backend/app/core/verification/email/verificationService.py +++ b/backend/app/core/verification/email/verificationService.py @@ -62,15 +62,16 @@ class EmailVerificationService: def verify_code(self,email: str, code: str, purpose: VerificationPurpose): email = email.lower() + code_hash = hash_verification_code(code) - stored_hash: Optional[str] = self.repo.consume_code(email, purpose) + stored = self.repo.compare_and_consume(email, purpose, code_hash) - if not stored_hash: - raise CodeExpiredError("Code expired or not found") - - if stored_hash != hash_verification_code(code): + if stored == False: raise CodeInvalidError("Invalid code") + if not stored: + raise CodeExpiredError("Code expired or not found") + return True @lru_cache From 210bb3b9eac9a964e4c791a708edfc26f669490b Mon Sep 17 00:00:00 2001 From: csf123321 Date: Thu, 26 Mar 2026 02:12:29 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E6=94=BE=E7=88=86=E7=A0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/verification/email/verificationService.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/app/core/verification/email/verificationService.py b/backend/app/core/verification/email/verificationService.py index eb5636c..24cc626 100644 --- a/backend/app/core/verification/email/verificationService.py +++ b/backend/app/core/verification/email/verificationService.py @@ -19,6 +19,10 @@ 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 @@ -62,8 +66,13 @@ class EmailVerificationService: 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: From 1b8fadc0c91cbc339621a81418a5688916cc4a10 Mon Sep 17 00:00:00 2001 From: csf123321 Date: Fri, 27 Mar 2026 13:03:21 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/config/apiBase.ts | 123 ++------------------------------- 1 file changed, 6 insertions(+), 117 deletions(-) diff --git a/frontend/src/config/apiBase.ts b/frontend/src/config/apiBase.ts index 6459e6b..2233ae0 100644 --- a/frontend/src/config/apiBase.ts +++ b/frontend/src/config/apiBase.ts @@ -1,123 +1,12 @@ -/** - * API 基础配置:自动探测内网/公网后端,失败时回退公网 - */ const API_PREFIX = '/api/v1' -const LAN_BACKEND_ORIGIN = import.meta.env.VITE_BACKEND_ORIGIN -const PUBLIC_BACKEND_ORIGIN = import.meta.env.VITE_BACKEND_ORIGIN -const PROBE_TIMEOUT_MS = 1200 -console.log(LAN_BACKEND_ORIGIN); - -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 | null = null - -function normalizePath(path: string): string { - if (!path) return '/' - return path.startsWith('/') ? path : `/${path}` -} - -function buildUrl(base: string, path: string): string { - return `${base}${normalizePath(path)}` -} - -function isPrivateIpv4(hostname: string): boolean { - const parts = hostname.split('.').map((part) => Number.parseInt(part, 10)) - if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) { - return false +export function buildUrl(path: string): string { + if (!path.startsWith('/')) { + path = '/' + path } - - const a = parts[0] as number - const b = parts[1] as number - if (a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 127) return true - return false + return `${API_PREFIX}${path}` } -function isLanHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase() - if (normalized === 'localhost' || normalized.endsWith('.local')) return true - return isPrivateIpv4(normalized) -} - -// 探测内网后端是否可用(请求 openapi.json) -async function probeLanBackend(): Promise { - 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 } - 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 { - 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 { - 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 { - 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) } From d4a8f59fd8a26a288c0694487b239f3918eeb5bb Mon Sep 17 00:00:00 2001 From: csf123321 Date: Fri, 27 Mar 2026 13:04:02 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E7=BC=96=E8=AF=91=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/requirements.txt | Bin 2246 -> 0 bytes backend/test.py | 27 ----------- backend/test1.py | 57 ------------------------ backend/test_main.http | 34 -------------- frontend/src/components/ThemeToggle.vue | 1 - frontend/src/views/RevisionsView.vue | 14 +++--- frontend/src/views/SearchView.vue | 11 ++--- 7 files changed, 13 insertions(+), 131 deletions(-) delete mode 100644 backend/requirements.txt delete mode 100644 backend/test.py delete mode 100644 backend/test1.py delete mode 100644 backend/test_main.http diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 9db89a9cd168414e084a11b640d338ef4c16cd7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2246 zcmai#PjAyu5XAS4#79Y0CvAZPhh7ntihANyrAeBkZIWU;rQyQ^^V{)@69ht*tJJ%1 zc6Rp9`}K1i%h*H{t7zjnp7h^ceAIIuCsFA=jkob8#$p*zMMm9<;VkMz8Kn;#hZ%R0mR3mj zdchhh(l9R41F!SWZ=oz|ahWNXLFQE}|8Zvj;|L!%$~H_aUvz2^wz=v zr{u`#i;Owd*+$W7d0~b12fbGJUT3h5rIDGVP^Ad20x`&m+=)|Iz<{iJoJKh4?7AH1 z?^ttjnCV$3TAOUZF&2>)id^k;s8TmGd=C|PyZZOrv7#ci%gVrQ$NzRWeW=|9< zwkq>-FR<;%} z>h~PFn!`$LSQWG3ivIKzqFJ5N?6quoJw(??bIx7DCasL@ze zeij=x^h8*mvoa^ecO!*SW*Of9eI-QUx CJwWvU diff --git a/backend/test.py b/backend/test.py deleted file mode 100644 index 42d436b..0000000 --- a/backend/test.py +++ /dev/null @@ -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) - - diff --git a/backend/test1.py b/backend/test1.py deleted file mode 100644 index 194177e..0000000 --- a/backend/test1.py +++ /dev/null @@ -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)) diff --git a/backend/test_main.http b/backend/test_main.http deleted file mode 100644 index 65ff565..0000000 --- a/backend/test_main.http +++ /dev/null @@ -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" -} diff --git a/frontend/src/components/ThemeToggle.vue b/frontend/src/components/ThemeToggle.vue index f010353..912142b 100644 --- a/frontend/src/components/ThemeToggle.vue +++ b/frontend/src/components/ThemeToggle.vue @@ -26,7 +26,6 @@ function handleToggle(event: MouseEvent) { Math.max(y, innerHeight - y) ) - // @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错 const transition = document.startViewTransition(() => { themeStore.toggleTheme() }) diff --git a/frontend/src/views/RevisionsView.vue b/frontend/src/views/RevisionsView.vue index a28acda..b0844e7 100644 --- a/frontend/src/views/RevisionsView.vue +++ b/frontend/src/views/RevisionsView.vue @@ -88,8 +88,8 @@ const revisionChains = computed(() => { // 组内按时间升序 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(() => { 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)

{{ title }}

- {{ formatTime(chain.change_times[idx]) }} + {{ formatTime(chain.change_times[idx] ?? '') }}
diff --git a/frontend/src/views/SearchView.vue b/frontend/src/views/SearchView.vue index e0b8679..edce50a 100644 --- a/frontend/src/views/SearchView.vue +++ b/frontend/src/views/SearchView.vue @@ -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(null) @@ -56,7 +57,7 @@ const filteredEvents = computed(() => { }) // 热度时间线图表配置。 -const chartOptions = ref({ +const chartOptions = ref({ chart: { type: 'area', height: 350, @@ -66,12 +67,12 @@ const chartOptions = ref({ }, animations: { enabled: true, - easing: 'easeinout', + // easing: 'easeinout', speed: 800, }, // 点击图表数据点:切换选中时间,再次点击则取消筛选 events: { - markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) { + markerClick: function(event: unknown, chartContext: unknown, { dataPointIndex }: never) { if (searchResult.value && searchResult.value.timeline[dataPointIndex]) { const clickedTime = searchResult.value.timeline[dataPointIndex].time_label if (selectedTimeLabel.value === clickedTime) { @@ -368,7 +369,7 @@ async function handleSearch() { gap: 24px; align-items: stretch; position: relative; - z-index: 10; + z-index: 10; } .search-box { @@ -378,7 +379,7 @@ async function handleSearch() { flex-direction: column; justify-content: center; position: relative; - z-index: 2; + z-index: 2; } .tips-box { From a10a5a176bb6f7122eabbed20f5df0d1ec5b06dd Mon Sep 17 00:00:00 2001 From: csf123321 Date: Fri, 27 Mar 2026 17:46:32 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E7=9A=84docker=E6=9E=84?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- backend/.dockerignore | 13 +++++++++++++ backend/app/main.py | 12 ++++++------ backend/dockerfile | 31 +++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/dockerfile diff --git a/.gitignore b/.gitignore index 99d44d9..c630f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -186,4 +186,7 @@ cython_debug/ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore -.cursorindexingignore \ No newline at end of file +.cursorindexingignore + +**/data/* +**/docker/* \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..ddec2af --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +.venv +.git +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.cache +.env +*.log +dist +build \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 896d8e5..b74b4ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/dockerfile b/backend/dockerfile new file mode 100644 index 0000000..3fc42e6 --- /dev/null +++ b/backend/dockerfile @@ -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"] \ No newline at end of file