Add Aliyun SMS settings and refresh admin login

This commit is contained in:
2026-07-09 11:02:26 +08:00
parent d78d0ea75f
commit c86b517063
7 changed files with 410 additions and 35 deletions

View File

@@ -18,11 +18,11 @@ class AuthService:
def send_sms_code(cls, db: Session, phone: str) -> None:
user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user)
SmsCodeService.send_code(phone)
SmsCodeService.send_code(db, phone)
@classmethod
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
SmsCodeService.verify_code(phone, code)
SmsCodeService.verify_code(db, phone, code)
user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user)

View File

@@ -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:]}"