diff --git a/ai_knowledge_base_v2/apps/backend/app/services/captcha_service.py b/ai_knowledge_base_v2/apps/backend/app/services/captcha_service.py index c8011ce..918185d 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/captcha_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/captcha_service.py @@ -12,6 +12,8 @@ from uuid import uuid4 from fastapi import HTTPException, status from PIL import Image, ImageDraw, ImageFilter, ImageFont +from app.services.redis_client import get_sync_redis_client + logger = logging.getLogger(__name__) CAPTCHA_EXPIRES_SECONDS = 300 @@ -34,10 +36,13 @@ class CaptchaService: cls._cleanup_expired() captcha_id = str(uuid4()) code = _random_code() - cls._records[captcha_id] = CaptchaRecord( - code=code, - expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS), - ) + if _set_redis_captcha(captcha_id, code): + cls._records.pop(captcha_id, None) + else: + cls._records[captcha_id] = CaptchaRecord( + code=code, + expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS), + ) return { "captchaId": captcha_id, "imageBase64": _render_captcha_image(code), @@ -46,6 +51,14 @@ class CaptchaService: @classmethod def verify(cls, captcha_id: str, captcha_code: str) -> None: + redis_available, redis_code = _get_redis_captcha(captcha_id) + if redis_available and redis_code is not None: + if redis_code.lower() != captcha_code.strip().lower(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误") + _delete_redis_captcha(captcha_id) + cls._records.pop(captcha_id, None) + return + record = cls._records.get(captcha_id) if record is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码不存在或已过期") @@ -69,6 +82,43 @@ def _random_code() -> str: return "".join(random.SystemRandom().choice(alphabet) for _ in range(CAPTCHA_LENGTH)) +def _captcha_key(captcha_id: str) -> str: + return f"auth:captcha:{captcha_id}" + + +def _set_redis_captcha(captcha_id: str, code: str) -> bool: + client = get_sync_redis_client() + if client is None: + return False + try: + return bool(client.set(_captcha_key(captcha_id), code, ex=CAPTCHA_EXPIRES_SECONDS)) + except Exception: + logger.warning("Redis captcha write failed for %s", captcha_id, exc_info=True) + return False + + +def _get_redis_captcha(captcha_id: str) -> tuple[bool, str | None]: + client = get_sync_redis_client() + if client is None: + return False, None + try: + value = client.get(_captcha_key(captcha_id)) + except Exception: + logger.warning("Redis captcha read failed for %s", captcha_id, exc_info=True) + return False, None + return True, value if isinstance(value, str) else None + + +def _delete_redis_captcha(captcha_id: str) -> None: + client = get_sync_redis_client() + if client is None: + return + try: + client.delete(_captcha_key(captcha_id)) + except Exception: + logger.warning("Redis captcha delete failed for %s", captcha_id, exc_info=True) + + def _render_captcha_image(code: str) -> str: image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa") draw = ImageDraw.Draw(image) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/redis_client.py b/ai_knowledge_base_v2/apps/backend/app/services/redis_client.py index b3f1d15..b3fdfc2 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/redis_client.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/redis_client.py @@ -2,6 +2,7 @@ from __future__ import annotations from functools import lru_cache +from redis import Redis as SyncRedis from redis.asyncio import Redis from app.core.config import get_settings @@ -15,6 +16,14 @@ def get_redis_client() -> Redis | None: return Redis.from_url(redis_url, decode_responses=True) +@lru_cache(maxsize=1) +def get_sync_redis_client() -> SyncRedis | None: + redis_url = get_settings().redis_url.strip() + if not redis_url: + return None + return SyncRedis.from_url(redis_url, decode_responses=True) + + async def ping_redis() -> bool: client = get_redis_client() if client is None: diff --git a/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py b/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py index 5b482d4..513de9f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import Session from app.core.config import get_settings from app.models.ai_config import SystemConfig +from app.services.redis_client import get_sync_redis_client logger = logging.getLogger(__name__) @@ -46,16 +47,25 @@ class SmsCodeService: code = config.mock_sms_code if config.mock_sms_enabled else _generate_sms_code() if not config.mock_sms_enabled: _send_aliyun_sms(phone, code, config) - cls._codes[phone] = SmsCodeRecord( - code=code, - expires_at=datetime.now(UTC) + timedelta(minutes=config.sms_code_expire_minutes), - ) + ttl_seconds = config.sms_code_expire_minutes * 60 + if _set_redis_code(phone, code, ttl_seconds): + cls._codes.pop(phone, None) + return + cls._codes[phone] = SmsCodeRecord(code=code, expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds)) @classmethod def verify_code(cls, db: Session, phone: str, code: str) -> None: cls._validate_phone(phone) - record = cls._codes.get(phone) config = _load_sms_config(db) + redis_available, redis_code = _get_redis_code(phone) + if redis_available and redis_code is not None: + if redis_code != code: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误") + _delete_redis_code(phone) + cls._codes.pop(phone, None) + return + + record = cls._codes.get(phone) if config.mock_sms_enabled and record is None and code == config.mock_sms_code: return if record is None: @@ -177,6 +187,43 @@ def _generate_sms_code() -> str: return f"{random.SystemRandom().randint(0, 999999):06d}" +def _sms_code_key(phone: str) -> str: + return f"auth:sms_code:{phone}" + + +def _set_redis_code(phone: str, code: str, ttl_seconds: int) -> bool: + client = get_sync_redis_client() + if client is None: + return False + try: + return bool(client.set(_sms_code_key(phone), code, ex=ttl_seconds)) + except Exception: + logger.warning("Redis SMS code write failed for %s", _mask_phone(phone), exc_info=True) + return False + + +def _get_redis_code(phone: str) -> tuple[bool, str | None]: + client = get_sync_redis_client() + if client is None: + return False, None + try: + value = client.get(_sms_code_key(phone)) + except Exception: + logger.warning("Redis SMS code read failed for %s", _mask_phone(phone), exc_info=True) + return False, None + return True, value if isinstance(value, str) else None + + +def _delete_redis_code(phone: str) -> None: + client = get_sync_redis_client() + if client is None: + return + try: + client.delete(_sms_code_key(phone)) + except Exception: + logger.warning("Redis SMS code delete failed for %s", _mask_phone(phone), exc_info=True) + + def _config_text(values: dict[str, str], key: str, default: str) -> str: value = values.get(key) return value.strip() if value is not None else default diff --git a/ai_knowledge_base_v2/development_records/2026-07-09-user-login-graphic-captcha.md b/ai_knowledge_base_v2/development_records/2026-07-09-user-login-graphic-captcha.md index 62f2409..1402733 100644 --- a/ai_knowledge_base_v2/development_records/2026-07-09-user-login-graphic-captcha.md +++ b/ai_knowledge_base_v2/development_records/2026-07-09-user-login-graphic-captcha.md @@ -67,3 +67,26 @@ - 手机号不存在时,发送短信验证码接口返回“手机号不在学员名单中,请联系管理员”,前端可见。 - 图形验证码通过且手机号存在时,mock 短信仍可正常发送。 - Docker 环境中图形验证码字体清晰,不能退化成过小或错位的默认字体。 + +## 2026-07-09 补充:验证码状态迁移到 Redis + +### 变更原因 + +图形验证码和短信验证码最初沿用进程内字典保存状态。单 worker 本地环境可以正常使用,但生产环境如果开启多个 backend worker 或多实例部署,就可能出现验证码由 A worker 生成、校验请求打到 B worker 后找不到验证码的问题。 + +### 处理决策 + +1. 图形验证码状态优先写入 Redis,key 为 `auth:captcha:{captcha_id}`,TTL 为 300 秒。 +2. 短信验证码状态优先写入 Redis,key 为 `auth:sms_code:{phone}`,TTL 使用系统配置中的短信验证码有效期。 +3. Redis 可用时,所有 worker 共享同一份验证码状态。 +4. Redis 未配置或临时不可用时,保留原进程内字典作为降级路径,避免本地开发环境完全不可用。 +5. 验证成功后删除对应 Redis key,保持验证码一次性使用。 +6. mock 短信开关开启时,仍保留“未发送但输入固定 mock 验证码可登录”的开发便利能力。 + +### 新增验收标准 + +- Docker dev 环境下,图形验证码和短信验证码应写入 Redis,并可通过 Redis key 观察到 TTL。 +- 发送短信验证码成功后,Redis 中应存在 `auth:sms_code:{phone}`。 +- 图形验证码生成后,Redis 中应存在 `auth:captcha:{captcha_id}`。 +- 验证码校验成功后,对应 Redis key 应被删除。 +- Redis 不可用时,单 worker 本地开发仍可使用内存降级路径。