|
|
|
|
@@ -2,11 +2,19 @@ from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import random
|
|
|
|
|
from re import fullmatch
|
|
|
|
|
|
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
from app.models.ai_config import SystemConfig
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
@@ -15,26 +23,40 @@ class SmsCodeRecord:
|
|
|
|
|
expires_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class SmsProviderConfig:
|
|
|
|
|
mock_sms_enabled: bool
|
|
|
|
|
mock_sms_code: str
|
|
|
|
|
sms_code_expire_minutes: int
|
|
|
|
|
aliyun_sms_access_key_id: str
|
|
|
|
|
aliyun_sms_access_key_secret: str
|
|
|
|
|
aliyun_sms_sign_name: str
|
|
|
|
|
aliyun_sms_template_code: str
|
|
|
|
|
aliyun_sms_template_param_key: str
|
|
|
|
|
aliyun_sms_endpoint: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SmsCodeService:
|
|
|
|
|
_codes: dict[str, SmsCodeRecord] = {}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def send_code(cls, phone: str) -> None:
|
|
|
|
|
def send_code(cls, db: Session, phone: str) -> None:
|
|
|
|
|
cls._validate_phone(phone)
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
if not settings.mock_sms_enabled:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="真实短信服务尚未接入")
|
|
|
|
|
config = _load_sms_config(db)
|
|
|
|
|
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=settings.mock_sms_code,
|
|
|
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=settings.sms_code_expire_minutes),
|
|
|
|
|
code=code,
|
|
|
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=config.sms_code_expire_minutes),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def verify_code(cls, phone: str, code: str) -> None:
|
|
|
|
|
def verify_code(cls, db: Session, phone: str, code: str) -> None:
|
|
|
|
|
cls._validate_phone(phone)
|
|
|
|
|
record = cls._codes.get(phone)
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
if settings.mock_sms_enabled and record is None and code == settings.mock_sms_code:
|
|
|
|
|
config = _load_sms_config(db)
|
|
|
|
|
if config.mock_sms_enabled and record is None and code == config.mock_sms_code:
|
|
|
|
|
return
|
|
|
|
|
if record is None:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码不存在或已过期")
|
|
|
|
|
@@ -49,3 +71,135 @@ class SmsCodeService:
|
|
|
|
|
def _validate_phone(phone: str) -> None:
|
|
|
|
|
if fullmatch(r"1[3-9]\d{9}", phone) is None:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号格式不正确")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_sms_config(db: Session) -> SmsProviderConfig:
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
rows = db.scalars(select(SystemConfig)).all()
|
|
|
|
|
values = {row.config_key: row.config_value for row in rows}
|
|
|
|
|
return SmsProviderConfig(
|
|
|
|
|
mock_sms_enabled=_config_bool(values, "mock_sms_enabled", settings.mock_sms_enabled),
|
|
|
|
|
mock_sms_code=_config_text(values, "mock_sms_code", settings.mock_sms_code),
|
|
|
|
|
sms_code_expire_minutes=_config_int(
|
|
|
|
|
values,
|
|
|
|
|
"sms_code_expire_minutes",
|
|
|
|
|
settings.sms_code_expire_minutes,
|
|
|
|
|
minimum=1,
|
|
|
|
|
maximum=60,
|
|
|
|
|
),
|
|
|
|
|
aliyun_sms_access_key_id=_config_text(
|
|
|
|
|
values,
|
|
|
|
|
"aliyun_sms_access_key_id",
|
|
|
|
|
settings.aliyun_sms_access_key_id,
|
|
|
|
|
),
|
|
|
|
|
aliyun_sms_access_key_secret=_config_text(
|
|
|
|
|
values,
|
|
|
|
|
"aliyun_sms_access_key_secret",
|
|
|
|
|
settings.aliyun_sms_access_key_secret,
|
|
|
|
|
),
|
|
|
|
|
aliyun_sms_sign_name=_config_text(values, "aliyun_sms_sign_name", settings.aliyun_sms_sign_name),
|
|
|
|
|
aliyun_sms_template_code=_config_text(
|
|
|
|
|
values,
|
|
|
|
|
"aliyun_sms_template_code",
|
|
|
|
|
settings.aliyun_sms_template_code,
|
|
|
|
|
),
|
|
|
|
|
aliyun_sms_template_param_key=_config_text(
|
|
|
|
|
values,
|
|
|
|
|
"aliyun_sms_template_param_key",
|
|
|
|
|
settings.aliyun_sms_template_param_key,
|
|
|
|
|
)
|
|
|
|
|
or "code",
|
|
|
|
|
aliyun_sms_endpoint=_config_text(values, "aliyun_sms_endpoint", settings.aliyun_sms_endpoint)
|
|
|
|
|
or "dysmsapi.aliyuncs.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _send_aliyun_sms(phone: str, code: str, config: SmsProviderConfig) -> None:
|
|
|
|
|
missing = [
|
|
|
|
|
name
|
|
|
|
|
for name, value in (
|
|
|
|
|
("AccessKey ID", config.aliyun_sms_access_key_id),
|
|
|
|
|
("AccessKey Secret", config.aliyun_sms_access_key_secret),
|
|
|
|
|
("短信签名", config.aliyun_sms_sign_name),
|
|
|
|
|
("模板 Code", config.aliyun_sms_template_code),
|
|
|
|
|
)
|
|
|
|
|
if not value
|
|
|
|
|
]
|
|
|
|
|
if missing:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail=f"阿里云短信配置不完整:请补充{','.join(missing)}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient
|
|
|
|
|
from alibabacloud_dysmsapi20170525 import models as dysms_models
|
|
|
|
|
from alibabacloud_tea_openapi import models as open_api_models
|
|
|
|
|
from alibabacloud_tea_util import models as util_models
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
logger.exception("Aliyun SMS SDK import failed")
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="阿里云短信 SDK 未安装,请联系管理员检查部署依赖",
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
client_config = open_api_models.Config(
|
|
|
|
|
access_key_id=config.aliyun_sms_access_key_id,
|
|
|
|
|
access_key_secret=config.aliyun_sms_access_key_secret,
|
|
|
|
|
)
|
|
|
|
|
client_config.endpoint = config.aliyun_sms_endpoint
|
|
|
|
|
request = dysms_models.SendSmsRequest(
|
|
|
|
|
phone_numbers=phone,
|
|
|
|
|
sign_name=config.aliyun_sms_sign_name,
|
|
|
|
|
template_code=config.aliyun_sms_template_code,
|
|
|
|
|
template_param=json.dumps({config.aliyun_sms_template_param_key: code}, ensure_ascii=False),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response = DysmsapiClient(client_config).send_sms_with_options(request, util_models.RuntimeOptions())
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("Aliyun SMS send failed for %s: %s", _mask_phone(phone), exc.__class__.__name__)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
|
|
|
detail="短信发送失败,请稍后再试",
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
body = getattr(response, "body", None)
|
|
|
|
|
response_code = getattr(body, "code", None)
|
|
|
|
|
if response_code not in (None, "OK"):
|
|
|
|
|
logger.warning("Aliyun SMS rejected for %s: %s", _mask_phone(phone), response_code)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
|
|
|
detail="短信发送失败,请稍后再试",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _generate_sms_code() -> str:
|
|
|
|
|
return f"{random.SystemRandom().randint(0, 999999):06d}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _config_bool(values: dict[str, str], key: str, default: bool) -> bool:
|
|
|
|
|
value = values.get(key)
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _config_int(values: dict[str, str], key: str, default: int, *, minimum: int, maximum: int) -> int:
|
|
|
|
|
value = values.get(key)
|
|
|
|
|
if value is not None:
|
|
|
|
|
try:
|
|
|
|
|
return max(minimum, min(maximum, int(float(value.strip()))))
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mask_phone(phone: str) -> str:
|
|
|
|
|
if len(phone) != 11:
|
|
|
|
|
return "***"
|
|
|
|
|
return f"{phone[:3]}****{phone[-4:]}"
|
|
|
|
|
|