Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
@@ -10,6 +10,7 @@ from sqlalchemy import func, or_
from sqlalchemy.dialects.postgresql import insert
from app.db import AppSetting, Database
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
@@ -32,8 +33,12 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
value_type = raw.get("type")
if value_type not in VALUE_TYPES:
raise ValueError(f"{key}: unsupported type {value_type!r}")
if key in OTP_SETTING_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public")
description = raw.get("description")
if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string")
@@ -47,6 +52,7 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
"record_status": "A",
}
)
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
return rows
+11 -2
View File
@@ -58,6 +58,7 @@ from app.schemas import (
ConsentsRequest,
MessageRequest,
OpenLinesInbox,
OtpSettingsResponse,
SessionStartRequest,
decode_cursor,
encode_cursor,
@@ -415,7 +416,7 @@ async def ready(request: Request, db: Session):
try:
await db.execute(text("SELECT 1"))
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
if revision != "0004_device_otp":
if revision != "0005_otp_settings":
raise RuntimeError("unexpected database revision")
await load_settings(db)
components["postgres"] = "ok"
@@ -963,7 +964,12 @@ async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings:
return JSONResponse(body, status_code=status)
@app.get("/internal/settings/v1/otp", tags=["internal"])
@app.get(
"/internal/settings/v1/otp",
tags=["internal"],
response_model=OtpSettingsResponse,
responses={304: {"description": "Cached settings are still current"}},
)
async def otp_settings(
request: Request,
settings: SnapshotDep,
@@ -980,6 +986,9 @@ async def otp_settings(
"max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"),
"min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"),
"max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"),
"code_length": settings.integer("otp.phone.code_length"),
"ttl_seconds": settings.integer("otp.phone.ttl_seconds"),
"sms_order_timeout_ms": settings.integer("otp.phone.sms_order_timeout_ms"),
"version": settings.version,
"cache_ttl_seconds": 60,
}, headers=headers)
@@ -0,0 +1,44 @@
from collections.abc import Mapping
OTP_SETTING_KEYS = {
"otp.phone.max_send_attempts_per_24h",
"otp.phone.min_seconds_between_attempts",
"otp.phone.max_verify_attempts",
"otp.phone.code_length",
"otp.phone.ttl_seconds",
"otp.phone.sms_order_timeout_ms",
}
def validate_otp_settings(values: Mapping[str, str]) -> None:
parsed: dict[str, int] = {}
for key in OTP_SETTING_KEYS:
raw = values.get(key)
if raw is None:
continue
try:
value = int(raw)
except (TypeError, ValueError) as error:
raise ValueError(f"{key}: integer value expected") from error
if str(value) != raw:
raise ValueError(f"{key}: canonical integer value expected")
parsed[key] = value
positive = OTP_SETTING_KEYS - {"otp.phone.min_seconds_between_attempts"}
for key in positive:
if key in parsed and parsed[key] <= 0:
raise ValueError(f"{key}: value must be positive")
if parsed.get("otp.phone.min_seconds_between_attempts", 0) < 0:
raise ValueError("otp.phone.min_seconds_between_attempts: value must be non-negative")
code_length = parsed.get("otp.phone.code_length")
if code_length is not None and not 4 <= code_length <= 10:
raise ValueError("otp.phone.code_length: value must be between 4 and 10")
ttl_seconds = parsed.get("otp.phone.ttl_seconds")
if ttl_seconds is not None and (
not 60 <= ttl_seconds <= 900 or ttl_seconds % 60 != 0
):
raise ValueError(
"otp.phone.ttl_seconds: value must be between 60 and 900 and divisible by 60"
)
@@ -14,6 +14,17 @@ class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class OtpSettingsResponse(StrictModel):
max_send_attempts_per_24h: int = Field(strict=True, gt=0)
min_seconds_between_attempts: int = Field(strict=True, ge=0)
max_verify_attempts: int = Field(strict=True, gt=0)
code_length: int = Field(strict=True, ge=4, le=10)
ttl_seconds: int = Field(strict=True, ge=60, le=900, multiple_of=60)
sms_order_timeout_ms: int = Field(strict=True, gt=0)
version: str = Field(min_length=1, max_length=64)
cache_ttl_seconds: int = Field(strict=True, gt=0)
class Device(StrictModel):
platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64)
+26 -4
View File
@@ -37,6 +37,7 @@ from app.integrations import (
SafetyClient,
fresh_openlines_payload,
)
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.realtime import RealtimeFanout
from app.schemas import (
AttachmentCompleteRequest,
@@ -84,7 +85,7 @@ REQUIRED_SETTINGS = {
"ux.session.idle_timeout_minutes",
"security.cors.allowed_origins",
"security.public_cache.max_age_seconds",
}
} | OTP_SETTING_KEYS
@dataclass(frozen=True, slots=True)
@@ -126,9 +127,11 @@ class AuditContext:
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
rows = (
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
rows = list(
(
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
)
values = {row.setting_key: row.setting_value for row in rows}
missing = REQUIRED_SETTINGS - values.keys()
if missing:
@@ -138,6 +141,25 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
"Required settings are unavailable",
{"missing": sorted(missing)},
)
try:
invalid_metadata = sorted(
row.setting_key
for row in rows
if row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
)
if invalid_metadata:
raise ValueError(
f"OTP settings must have integer type and be private: {invalid_metadata}"
)
validate_otp_settings(values)
except ValueError as error:
raise DomainError(
"dependency_unavailable",
503,
"OTP settings are invalid",
{"reason": str(error)},
) from error
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
return SettingsSnapshot(values, version)