Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /build
|
||||
RUN pip install --no-cache-dir --upgrade pip build
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
RUN python -m build --wheel
|
||||
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/dist/*.whl /tmp/
|
||||
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
|
||||
COPY alembic.ini ./
|
||||
COPY migrations ./migrations
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-proxy-headers"]
|
||||
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_table_schema = sms
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN SMS service."""
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
|
||||
import asyncpg
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
SCHEMA = "sms"
|
||||
|
||||
|
||||
class Channel(StrEnum):
|
||||
SMS = "SMS"
|
||||
|
||||
|
||||
class SendStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
ACCEPTED = "accepted"
|
||||
REJECTED = "rejected"
|
||||
FAILED = "failed"
|
||||
UNCERTAIN = "uncertain"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class DeliveryStatus(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
UNDELIVERED = "undelivered"
|
||||
UNSENT = "unsent"
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class SmsTemplate(Base):
|
||||
__tablename__ = "sms_template"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
|
||||
Index(
|
||||
"uq_template_active",
|
||||
"code",
|
||||
"channel",
|
||||
"locale",
|
||||
unique=True,
|
||||
postgresql_where=text("is_active"),
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
channel: Mapped[Channel] = mapped_column(
|
||||
Enum(
|
||||
Channel,
|
||||
name="sms_channel",
|
||||
schema=SCHEMA,
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
)
|
||||
)
|
||||
locale: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
body_template: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
placeholders: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||
sender_name: Mapped[str | None] = mapped_column(String(64))
|
||||
max_parts: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class SmsSetting(Base):
|
||||
__tablename__ = "sms_setting"
|
||||
__table_args__ = {"schema": SCHEMA}
|
||||
|
||||
setting_key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
setting_value: Mapped[object] = mapped_column(JSONB, nullable=False)
|
||||
value_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SmsOutboundMessage(Base):
|
||||
__tablename__ = "sms_outbound_message"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
|
||||
Index(
|
||||
"uq_outbound_provider_message",
|
||||
"provider",
|
||||
"provider_message_id",
|
||||
unique=True,
|
||||
postgresql_where=text("provider_message_id IS NOT NULL"),
|
||||
),
|
||||
Index("ix_outbound_phone_created", "phone_e164", text("created_at DESC")),
|
||||
Index(
|
||||
"ix_outbound_requester_process_created",
|
||||
"requester_service",
|
||||
"process",
|
||||
text("created_at DESC"),
|
||||
),
|
||||
Index("ix_outbound_customer_ref", "customer_ref"),
|
||||
Index("ix_outbound_send_created", "send_status", "created_at"),
|
||||
Index("ix_outbound_delivery_updated", "delivery_status", "updated_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
requester_service: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
process: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
phone_e164: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
phone_digits: Mapped[str] = mapped_column(String(15), nullable=False)
|
||||
phone_masked: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.sms_template.id"), nullable=False
|
||||
)
|
||||
template_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
body_rendered: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
substitutions: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
||||
send_status: Mapped[SendStatus] = mapped_column(
|
||||
Enum(
|
||||
SendStatus,
|
||||
name="sms_send_status",
|
||||
schema=SCHEMA,
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
delivery_status: Mapped[DeliveryStatus] = mapped_column(
|
||||
Enum(
|
||||
DeliveryStatus,
|
||||
name="sms_delivery_status",
|
||||
schema=SCHEMA,
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
provider_message_id: Mapped[str | None] = mapped_column(String(128))
|
||||
provider_external_id: Mapped[str | None] = mapped_column(String(128))
|
||||
customer_ref: Mapped[str | None] = mapped_column(String(128))
|
||||
idempotency_key: Mapped[str] = mapped_column(String(192), nullable=False)
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
request_id: Mapped[str | None] = mapped_column(String(128))
|
||||
traceparent: Mapped[str | None] = mapped_column(String(55))
|
||||
provider_http_status: Mapped[int | None] = mapped_column(Integer)
|
||||
provider_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
provider_error_message: Mapped[str | None] = mapped_column(String(256))
|
||||
sender_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
message_ttl_sec: Mapped[int | None] = mapped_column(Integer)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
worker_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
parts: Mapped[int | None] = mapped_column(Integer)
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(14, 4))
|
||||
currency: Mapped[str | None] = mapped_column(String(3))
|
||||
callback_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class SmsCallbackEvent(Base):
|
||||
__tablename__ = "sms_callback_event"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"message_uuid",
|
||||
"callback_event",
|
||||
"status",
|
||||
"status_time",
|
||||
name="uq_callback_event",
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
message_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
callback_event: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
def asyncpg_dsn(url: str) -> str:
|
||||
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
|
||||
|
||||
def create_postgres_engine(url: str) -> AsyncEngine:
|
||||
dsn = asyncpg_dsn(url)
|
||||
|
||||
async def connect() -> asyncpg.Connection:
|
||||
return await asyncpg.connect(dsn=dsn)
|
||||
|
||||
return create_async_engine(
|
||||
"postgresql+asyncpg://", async_creator=connect, pool_pre_ping=True
|
||||
)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.engine = create_postgres_engine(url)
|
||||
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||
|
||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||
async with self.sessions() as session:
|
||||
yield session
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import phonenumbers
|
||||
|
||||
from app.db import DeliveryStatus, SendStatus
|
||||
|
||||
GSM_BASIC = (
|
||||
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ "
|
||||
"!\"#¤%&'()*+,-./0123456789:;<=>?"
|
||||
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
|
||||
)
|
||||
GSM_EXTENDED = "^{}\\[~]|€"
|
||||
PHONE_RE = re.compile(r"^\+[1-9]\d{7,14}$")
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
def __init__(
|
||||
self, code: str, status: int, message: str, details: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def normalize_phone(value: str) -> tuple[str, str, str]:
|
||||
if not PHONE_RE.fullmatch(value):
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
|
||||
try:
|
||||
parsed = phonenumbers.parse(value, None)
|
||||
except phonenumbers.NumberParseException:
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164") from None
|
||||
if not phonenumbers.is_valid_number(parsed):
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
|
||||
normalized = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
if normalized != value:
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be canonical E.164")
|
||||
digits = normalized[1:]
|
||||
masked = f"+{digits[:1]}{'*' * max(0, len(digits) - 5)}{digits[-4:]}"
|
||||
return normalized, digits, masked
|
||||
|
||||
|
||||
def request_fingerprint(payload: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def destination_hmac(phone_e164: str, key: bytes) -> str:
|
||||
return hmac.new(key, phone_e164.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def sms_parts(body: str) -> int:
|
||||
if not body or "\ufeff" in body or "\x00" in body:
|
||||
raise DomainError("sms_request_invalid", 422, "Rendered message contains invalid text")
|
||||
gsm_units = 0
|
||||
for char in body:
|
||||
if char in GSM_BASIC:
|
||||
gsm_units += 1
|
||||
elif char in GSM_EXTENDED:
|
||||
gsm_units += 2
|
||||
else:
|
||||
total = len(body.encode("utf-16-be")) // 2
|
||||
return 1 if total <= 70 else (total + 66) // 67
|
||||
return 1 if gsm_units <= 160 else (gsm_units + 152) // 153
|
||||
|
||||
|
||||
def render_template(
|
||||
body_template: str,
|
||||
placeholders: list[str],
|
||||
substitutions: dict[str, Any],
|
||||
max_parts: int,
|
||||
) -> str:
|
||||
expected = set(placeholders)
|
||||
supplied = set(substitutions)
|
||||
if expected != supplied:
|
||||
raise DomainError(
|
||||
"sms_request_invalid",
|
||||
422,
|
||||
"Substitutions do not match template placeholders",
|
||||
{"missing": sorted(expected - supplied), "unknown": sorted(supplied - expected)},
|
||||
)
|
||||
parsed = {
|
||||
field_name
|
||||
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
|
||||
if field_name is not None
|
||||
and not format_spec
|
||||
and not conversion
|
||||
and field_name.isidentifier()
|
||||
}
|
||||
if parsed != expected or any(
|
||||
format_spec or conversion
|
||||
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
|
||||
if field_name is not None
|
||||
):
|
||||
raise DomainError("sms_request_invalid", 422, "Template placeholder contract is invalid")
|
||||
body = body_template.format_map({key: str(value) for key, value in substitutions.items()})
|
||||
if len(body.encode("utf-8")) > 2048 or sms_parts(body) > max_parts:
|
||||
raise DomainError("sms_request_invalid", 422, "Rendered message exceeds template limit")
|
||||
return body
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderResult:
|
||||
send_status: SendStatus
|
||||
http_status: int | None = None
|
||||
message_uuid: str | None = None
|
||||
external_id: str | None = None
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
retry_safe: bool = False
|
||||
contract_violation: bool = False
|
||||
|
||||
|
||||
DELIVERY_RANK = {
|
||||
DeliveryStatus.UNKNOWN: 0,
|
||||
DeliveryStatus.SENT: 1,
|
||||
DeliveryStatus.DELIVERED: 2,
|
||||
DeliveryStatus.UNDELIVERED: 2,
|
||||
DeliveryStatus.UNSENT: 2,
|
||||
}
|
||||
|
||||
|
||||
def delivery_transition(current: DeliveryStatus, incoming: str) -> DeliveryStatus | None:
|
||||
try:
|
||||
target = DeliveryStatus(incoming.lower())
|
||||
except ValueError:
|
||||
return None
|
||||
if DELIVERY_RANK[target] < DELIVERY_RANK[current]:
|
||||
return current
|
||||
if DELIVERY_RANK[target] == DELIVERY_RANK[current] and target != current:
|
||||
return current
|
||||
return target
|
||||
|
||||
|
||||
def parse_status_time(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise DomainError("callback_invalid", 422, "Invalid callback status_time") from None
|
||||
if parsed.tzinfo is None:
|
||||
raise DomainError("callback_invalid", 422, "Callback status_time requires timezone")
|
||||
return parsed
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated, Any
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from fastapi import Body, Depends, FastAPI, Header, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.db import Database, SmsTemplate
|
||||
from app.domain import DomainError
|
||||
from app.metrics import CALLBACK_LAG, CALLBACK_TOTAL
|
||||
from app.schemas import CallbackItem, ErrorEnvelope, MessageResponse, SendRequest, SendResponse
|
||||
from app.service import (
|
||||
apply_callback,
|
||||
create_order,
|
||||
load_runtime_settings,
|
||||
read_message,
|
||||
)
|
||||
from app.settings import get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
logging.basicConfig(level=level, format="%(message)s")
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
yield
|
||||
await app.state.db.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="HAN SMS Service",
|
||||
version="1.0.0",
|
||||
openapi_version="3.1.0",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_context(request: Request, call_next: Any) -> Response:
|
||||
supplied = request.headers.get("X-Request-ID", "").strip()
|
||||
request_id = supplied[:128] if supplied and supplied.isprintable() else str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
started = time.monotonic()
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
route=request.url.path,
|
||||
**{"service.name": "sms-service"},
|
||||
)
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
log.info(
|
||||
"request.complete",
|
||||
status_code=response.status_code,
|
||||
duration_ms=round((time.monotonic() - started) * 1000, 2),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def error_response(
|
||||
request: Request,
|
||||
code: str,
|
||||
message: str,
|
||||
status: int,
|
||||
details: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"request_id": getattr(request.state, "request_id", str(uuid.uuid4())),
|
||||
"details": details or {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(DomainError)
|
||||
async def domain_error(request: Request, exc: DomainError) -> JSONResponse:
|
||||
response = error_response(request, exc.code, exc.message, exc.status, exc.details)
|
||||
if "retry_after" in exc.details:
|
||||
response.headers["Retry-After"] = str(exc.details["retry_after"])
|
||||
return response
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
details = [
|
||||
{"field": ".".join(str(part) for part in item["loc"][1:]), "type": item["type"]}
|
||||
for item in exc.errors()
|
||||
]
|
||||
log.warning("request.validation_failed", details=details)
|
||||
return error_response(
|
||||
request, "sms_request_invalid", "SMS request validation failed", 422, details
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
code = "not_found" if exc.status_code == 404 else "method_not_allowed"
|
||||
return error_response(request, code, "Resource was not found", exc.status_code)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
|
||||
log.exception("request.failed", error_code="internal_error")
|
||||
return error_response(request, "internal_error", "Internal server error", 500)
|
||||
|
||||
|
||||
async def session(request: Request):
|
||||
async for value in request.app.state.db.session():
|
||||
yield value
|
||||
|
||||
|
||||
Session = Annotated[AsyncSession, Depends(session)]
|
||||
|
||||
|
||||
async def bearer_auth(request: Request) -> None:
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed")
|
||||
supplied = authorization.removeprefix("Bearer ").strip()
|
||||
expected = request.app.state.settings.service_token.get_secret_value()
|
||||
if not supplied or not hmac.compare_digest(supplied, expected):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed")
|
||||
|
||||
|
||||
InternalAuth = Annotated[None, Depends(bearer_auth)]
|
||||
|
||||
|
||||
def basic_auth(request: Request) -> None:
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
encoded = (
|
||||
authorization.removeprefix("Basic ").strip() if authorization.startswith("Basic ") else ""
|
||||
)
|
||||
try:
|
||||
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed") from None
|
||||
settings = request.app.state.settings
|
||||
valid_user = hmac.compare_digest(username, settings.callback_username.get_secret_value())
|
||||
valid_password = hmac.compare_digest(password, settings.callback_password.get_secret_value())
|
||||
if not (valid_user and valid_password):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed")
|
||||
|
||||
|
||||
CallbackAuth = Annotated[None, Depends(basic_auth)]
|
||||
|
||||
|
||||
@app.get("/health/live", tags=["health"])
|
||||
async def live() -> dict[str, str]:
|
||||
return {"status": "live"}
|
||||
|
||||
|
||||
@app.get("/health/ready", tags=["health"])
|
||||
async def ready(db: Session) -> JSONResponse:
|
||||
components = {
|
||||
"postgres": "failed",
|
||||
"schema": "failed",
|
||||
"settings": "failed",
|
||||
"template": "failed",
|
||||
}
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
components["postgres"] = "ok"
|
||||
revision = await db.scalar(text("SELECT version_num FROM sms.alembic_version LIMIT 1"))
|
||||
if revision != "0002_seed":
|
||||
raise RuntimeError("unexpected sms schema revision")
|
||||
components["schema"] = "ok"
|
||||
runtime = await load_runtime_settings(db)
|
||||
components["settings"] = "ok"
|
||||
template_count = await db.scalar(
|
||||
select(func.count(SmsTemplate.id)).where(
|
||||
SmsTemplate.code == "auth_otp",
|
||||
SmsTemplate.is_active.is_(True),
|
||||
SmsTemplate.approved_at.is_not(None),
|
||||
(SmsTemplate.sender_name.is_not(None))
|
||||
| (text(":sender <> ''").bindparams(sender=runtime.default_sender_name)),
|
||||
)
|
||||
)
|
||||
if template_count != 1:
|
||||
raise RuntimeError("active approved auth_otp template is missing")
|
||||
components["template"] = "ok"
|
||||
except Exception:
|
||||
log.warning("readiness.failed")
|
||||
failed = "failed" in components.values()
|
||||
return JSONResponse(
|
||||
{"status": "not_ready" if failed else "ready", "components": components},
|
||||
status_code=503 if failed else 200,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/metrics", include_in_schema=False)
|
||||
async def metrics() -> Response:
|
||||
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/sms/v1/send",
|
||||
response_model=SendResponse,
|
||||
responses={
|
||||
401: {"model": ErrorEnvelope},
|
||||
409: {"model": ErrorEnvelope},
|
||||
422: {"model": ErrorEnvelope},
|
||||
429: {"model": ErrorEnvelope},
|
||||
503: {"model": ErrorEnvelope},
|
||||
},
|
||||
tags=["internal"],
|
||||
)
|
||||
async def send(
|
||||
body: SendRequest,
|
||||
request: Request,
|
||||
db: Session,
|
||||
_auth: InternalAuth,
|
||||
x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None,
|
||||
traceparent: Annotated[
|
||||
str | None,
|
||||
Header(pattern=r"^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$"),
|
||||
] = None,
|
||||
) -> JSONResponse:
|
||||
result, created = await create_order(
|
||||
db,
|
||||
body,
|
||||
x_request_id,
|
||||
traceparent,
|
||||
request.app.state.settings.service_token.get_secret_value().encode(),
|
||||
)
|
||||
return JSONResponse(result.model_dump(mode="json"), status_code=202 if created else 200)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/sms/v1/messages/{sms_message_id}",
|
||||
response_model=MessageResponse,
|
||||
responses={401: {"model": ErrorEnvelope}, 404: {"model": ErrorEnvelope}},
|
||||
tags=["internal"],
|
||||
)
|
||||
async def message(sms_message_id: uuid.UUID, db: Session, _auth: InternalAuth) -> MessageResponse:
|
||||
return await read_message(db, sms_message_id)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/callbacks/idgtl/sms",
|
||||
status_code=204,
|
||||
responses={401: {"model": ErrorEnvelope}, 422: {"model": ErrorEnvelope}},
|
||||
tags=["callback"],
|
||||
)
|
||||
async def callback(
|
||||
payload: Annotated[list[dict[str, Any]], Body(min_length=1, max_length=1000)],
|
||||
request: Request,
|
||||
db: Session,
|
||||
_auth: CallbackAuth,
|
||||
) -> Response:
|
||||
valid_count = 0
|
||||
for raw in payload:
|
||||
try:
|
||||
item = CallbackItem.model_validate(raw)
|
||||
except ValidationError:
|
||||
CALLBACK_TOTAL.labels("idgtl", "invalid").inc()
|
||||
log.warning("callback.rejected", reason="schema_invalid")
|
||||
continue
|
||||
accepted = await apply_callback(db, item)
|
||||
CALLBACK_TOTAL.labels("idgtl", "accepted" if accepted else "rejected").inc()
|
||||
if accepted:
|
||||
valid_count += 1
|
||||
lag = max(0.0, (datetime_now() - item.status_time).total_seconds())
|
||||
CALLBACK_LAG.labels("idgtl", item.status.lower()).observe(lag)
|
||||
await db.commit()
|
||||
return Response(status_code=204, headers={"X-Callback-Items-Accepted": str(valid_count)})
|
||||
|
||||
|
||||
def datetime_now():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0", # noqa: S104 - required container listener
|
||||
port=settings.api_port,
|
||||
proxy_headers=False,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from prometheus_client import Counter, Gauge, Histogram
|
||||
|
||||
SEND_TOTAL = Counter(
|
||||
"sms_send_total",
|
||||
"Provider send outcomes",
|
||||
("provider", "send_status"),
|
||||
)
|
||||
PROVIDER_LATENCY = Histogram(
|
||||
"sms_provider_request_duration_seconds",
|
||||
"Provider request latency",
|
||||
("provider",),
|
||||
)
|
||||
UNCERTAIN_TOTAL = Counter(
|
||||
"sms_uncertain_total",
|
||||
"Ambiguous provider outcomes",
|
||||
("provider",),
|
||||
)
|
||||
CALLBACK_TOTAL = Counter(
|
||||
"sms_callback_total",
|
||||
"Callback items",
|
||||
("provider", "result"),
|
||||
)
|
||||
CALLBACK_LAG = Histogram(
|
||||
"sms_callback_lag_seconds",
|
||||
"Callback status-to-receipt lag",
|
||||
("provider", "status"),
|
||||
)
|
||||
PENDING_AGE = Gauge(
|
||||
"sms_pending_oldest_age_seconds",
|
||||
"Age of oldest pending message",
|
||||
)
|
||||
JOURNAL_ROWS = Gauge(
|
||||
"sms_journal_rows",
|
||||
"Outbound journal row count",
|
||||
)
|
||||
SETTINGS_VALID = Gauge(
|
||||
"sms_settings_valid",
|
||||
"Whether cached technical settings are valid",
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import SendStatus, SmsOutboundMessage
|
||||
from app.domain import ProviderResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdgtlConfig:
|
||||
base_url: str
|
||||
api_key: str
|
||||
callback_url: str
|
||||
callback_username: str
|
||||
callback_password: str
|
||||
connect_timeout_ms: int
|
||||
request_timeout_ms: int
|
||||
callback_enabled: bool
|
||||
|
||||
|
||||
def callback_url_with_credentials(config: IdgtlConfig) -> str:
|
||||
parts = urlsplit(config.callback_url)
|
||||
credentials = (
|
||||
f"{quote(config.callback_username, safe='')}:{quote(config.callback_password, safe='')}"
|
||||
)
|
||||
host = parts.hostname or ""
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
return urlunsplit((parts.scheme, f"{credentials}@{host}", parts.path, parts.query, ""))
|
||||
|
||||
|
||||
def build_payload(message: SmsOutboundMessage, config: IdgtlConfig) -> list[dict[str, object]]:
|
||||
item: dict[str, object] = {
|
||||
"channelType": "SMS",
|
||||
"senderName": message.sender_name,
|
||||
"destination": message.phone_digits,
|
||||
"content": message.body_rendered,
|
||||
"externalMessageId": str(message.id),
|
||||
"ttl": message.message_ttl_sec,
|
||||
}
|
||||
if config.callback_enabled:
|
||||
item["callbackUrl"] = callback_url_with_credentials(config)
|
||||
item["callbackEvents"] = ["delivered", "sent"]
|
||||
return [item]
|
||||
|
||||
|
||||
def classify_response(response: httpx.Response, expected_external_id: str) -> ProviderResult:
|
||||
if response.status_code != 200:
|
||||
if 400 <= response.status_code < 500:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
response.status_code,
|
||||
error_code=f"http_{response.status_code}",
|
||||
error_message="provider_rejected",
|
||||
)
|
||||
return ProviderResult(
|
||||
SendStatus.UNCERTAIN,
|
||||
response.status_code,
|
||||
error_code=f"http_{response.status_code}",
|
||||
error_message="provider_result_uncertain",
|
||||
)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code="malformed_json",
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if isinstance(payload, dict) and items is None:
|
||||
items = payload.get("messages") or payload.get("results") or payload.get("response")
|
||||
errors = payload.get("errors") if isinstance(payload, dict) else None
|
||||
if errors is not False or not isinstance(items, list) or len(items) != 1:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code="invalid_response",
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
item = items[0]
|
||||
if not isinstance(item, dict):
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code="invalid_item",
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
message_uuid = item.get("messageUuid")
|
||||
external_id = item.get("externalMessageId")
|
||||
try:
|
||||
uuid.UUID(str(message_uuid))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
message_uuid = None
|
||||
valid = item.get("code") == 201 and message_uuid and external_id == expected_external_id
|
||||
if not valid:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code=str(item.get("code") or "invalid_item"),
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
return ProviderResult(
|
||||
SendStatus.ACCEPTED,
|
||||
200,
|
||||
message_uuid=str(message_uuid),
|
||||
external_id=str(external_id),
|
||||
)
|
||||
|
||||
|
||||
class IdgtlClient:
|
||||
def __init__(self, client: httpx.AsyncClient, config: IdgtlConfig) -> None:
|
||||
self.client = client
|
||||
self.config = config
|
||||
|
||||
async def send(self, message: SmsOutboundMessage) -> ProviderResult:
|
||||
timeout = httpx.Timeout(
|
||||
self.config.request_timeout_ms / 1000,
|
||||
connect=self.config.connect_timeout_ms / 1000,
|
||||
)
|
||||
try:
|
||||
headers = {"Authorization": f"Basic {self.config.api_key}"}
|
||||
if message.request_id:
|
||||
headers["X-Request-ID"] = message.request_id
|
||||
if message.traceparent:
|
||||
headers["traceparent"] = message.traceparent
|
||||
response = await self.client.post(
|
||||
f"{self.config.base_url.rstrip('/')}/api/v1/message",
|
||||
headers=headers,
|
||||
json=build_payload(message, self.config),
|
||||
timeout=timeout,
|
||||
)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout):
|
||||
return ProviderResult(
|
||||
SendStatus.FAILED,
|
||||
error_code="connect_failure",
|
||||
error_message="provider_connect_failure",
|
||||
retry_safe=True,
|
||||
)
|
||||
except (httpx.ReadTimeout, httpx.WriteError, httpx.ReadError, httpx.RemoteProtocolError):
|
||||
return ProviderResult(
|
||||
SendStatus.UNCERTAIN,
|
||||
error_code="ambiguous_transport_failure",
|
||||
error_message="provider_result_uncertain",
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return ProviderResult(
|
||||
SendStatus.UNCERTAIN,
|
||||
error_code="transport_failure",
|
||||
error_message="provider_result_uncertain",
|
||||
)
|
||||
return classify_response(response, str(message.id))
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class SendRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
idempotency_key: str = Field(min_length=8, max_length=192)
|
||||
template_code: Literal["auth_otp"]
|
||||
locale: Literal["ru"]
|
||||
phone_e164: str = Field(min_length=9, max_length=16)
|
||||
substitutions: dict[str, str | int] = Field(min_length=1, max_length=16)
|
||||
customer_ref: str = Field(min_length=1, max_length=128)
|
||||
message_ttl_sec: int = Field(ge=60, le=86400)
|
||||
|
||||
|
||||
class SendResponse(BaseModel):
|
||||
sms_message_id: uuid.UUID
|
||||
ordered_at: datetime
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
sms_message_id: uuid.UUID
|
||||
ordered_at: datetime
|
||||
updated_at: datetime
|
||||
requester_service: str
|
||||
process: str
|
||||
channel: str
|
||||
provider: str
|
||||
phone_masked: str
|
||||
template_code: str
|
||||
customer_ref: str | None
|
||||
send_status: str
|
||||
delivery_status: str
|
||||
provider_message_id: str | None
|
||||
accepted_at: datetime | None
|
||||
sent_at: datetime | None
|
||||
delivered_at: datetime | None
|
||||
attempt_count: int
|
||||
provider_error_code: str | None
|
||||
|
||||
|
||||
class CallbackItem(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
channel_type: str = Field(validation_alias=AliasChoices("channel_type", "channelType"))
|
||||
message_uuid: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
validation_alias=AliasChoices("message_uuid", "messageUuid"),
|
||||
)
|
||||
external_message_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
validation_alias=AliasChoices("external_message_id", "externalMessageId"),
|
||||
)
|
||||
callback_event: str = Field(
|
||||
min_length=1,
|
||||
max_length=32,
|
||||
validation_alias=AliasChoices("callback_event", "callbackEvent", "event"),
|
||||
)
|
||||
status: str = Field(min_length=1, max_length=32)
|
||||
status_time: datetime = Field(validation_alias=AliasChoices("status_time", "statusTime"))
|
||||
error_code: str | None = Field(
|
||||
default=None, validation_alias=AliasChoices("error_code", "errorCode")
|
||||
)
|
||||
parts: int | None = Field(default=None, ge=0)
|
||||
price: Decimal | None = Field(default=None, ge=0)
|
||||
currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||
|
||||
@field_validator("status_time")
|
||||
@classmethod
|
||||
def require_timezone(cls, value: datetime) -> datetime:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError("status_time requires a timezone")
|
||||
return value
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
request_id: str
|
||||
details: dict[str, Any] | list[dict[str, Any]]
|
||||
|
||||
|
||||
class ErrorEnvelope(BaseModel):
|
||||
error: ErrorDetail
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import (
|
||||
Channel,
|
||||
DeliveryStatus,
|
||||
SendStatus,
|
||||
SmsCallbackEvent,
|
||||
SmsOutboundMessage,
|
||||
SmsSetting,
|
||||
SmsTemplate,
|
||||
)
|
||||
from app.domain import (
|
||||
DomainError,
|
||||
delivery_transition,
|
||||
destination_hmac,
|
||||
normalize_phone,
|
||||
render_template,
|
||||
request_fingerprint,
|
||||
)
|
||||
from app.schemas import CallbackItem, MessageResponse, SendRequest, SendResponse
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeSettings:
|
||||
default_sender_name: str
|
||||
connect_timeout_ms: int
|
||||
request_timeout_ms: int
|
||||
callback_enabled: bool
|
||||
poll_interval_ms: int
|
||||
lease_seconds: int
|
||||
|
||||
|
||||
SETTING_RULES: dict[str, tuple[type, int | None, int | None]] = {
|
||||
"provider.idgtl.default_sender_name": (str, 1, 64),
|
||||
"provider.idgtl.connect_timeout_ms": (int, 100, 30_000),
|
||||
"provider.idgtl.request_timeout_ms": (int, 1_000, 120_000),
|
||||
"provider.idgtl.callback_enabled": (bool, None, None),
|
||||
"worker.poll_interval_ms": (int, 100, 60_000),
|
||||
"worker.lease_seconds": (int, 10, 600),
|
||||
}
|
||||
|
||||
|
||||
async def load_runtime_settings(db: AsyncSession) -> RuntimeSettings:
|
||||
rows = (
|
||||
await db.execute(select(SmsSetting).where(SmsSetting.setting_key.in_(SETTING_RULES)))
|
||||
).scalars()
|
||||
values = {row.setting_key: row.setting_value for row in rows}
|
||||
if values.keys() != SETTING_RULES.keys():
|
||||
raise RuntimeError("required sms settings are missing")
|
||||
for key, (expected_type, minimum, maximum) in SETTING_RULES.items():
|
||||
value = values[key]
|
||||
if type(value) is not expected_type: # bool is an int subclass
|
||||
raise RuntimeError(f"invalid sms setting type: {key}")
|
||||
if isinstance(value, (int, str)):
|
||||
size = value if isinstance(value, int) else len(value)
|
||||
if minimum is not None and size < minimum:
|
||||
raise RuntimeError(f"sms setting below minimum: {key}")
|
||||
if maximum is not None and size > maximum:
|
||||
raise RuntimeError(f"sms setting above maximum: {key}")
|
||||
sender = str(values["provider.idgtl.default_sender_name"])
|
||||
if sender.startswith("__"):
|
||||
raise RuntimeError("provider sender name is not configured")
|
||||
return RuntimeSettings(
|
||||
default_sender_name=sender,
|
||||
connect_timeout_ms=cast(int, values["provider.idgtl.connect_timeout_ms"]),
|
||||
request_timeout_ms=cast(int, values["provider.idgtl.request_timeout_ms"]),
|
||||
callback_enabled=cast(bool, values["provider.idgtl.callback_enabled"]),
|
||||
poll_interval_ms=cast(int, values["worker.poll_interval_ms"]),
|
||||
lease_seconds=cast(int, values["worker.lease_seconds"]),
|
||||
)
|
||||
|
||||
|
||||
def fingerprint_payload(body: SendRequest, phone_e164: str) -> dict[str, Any]:
|
||||
return {
|
||||
"idempotency_key": body.idempotency_key,
|
||||
"template_code": body.template_code,
|
||||
"locale": body.locale,
|
||||
"phone_e164": phone_e164,
|
||||
"substitutions": body.substitutions,
|
||||
"customer_ref": body.customer_ref,
|
||||
"message_ttl_sec": body.message_ttl_sec,
|
||||
}
|
||||
|
||||
|
||||
def validate_otp_request(body: SendRequest) -> None:
|
||||
code = body.substitutions.get("code")
|
||||
ttl_min = body.substitutions.get("ttl_min")
|
||||
if (
|
||||
not isinstance(code, str)
|
||||
or not code.isascii()
|
||||
or not code.isdigit()
|
||||
or not 4 <= len(code) <= 10
|
||||
or body.message_ttl_sec % 60 != 0
|
||||
or str(ttl_min) != str(body.message_ttl_sec // 60)
|
||||
):
|
||||
raise DomainError(
|
||||
"sms_request_invalid", 422, "OTP substitutions and message TTL are inconsistent"
|
||||
)
|
||||
|
||||
|
||||
def send_response(message: SmsOutboundMessage) -> SendResponse:
|
||||
return SendResponse(sms_message_id=message.id, ordered_at=message.requested_at)
|
||||
|
||||
|
||||
async def existing_order(
|
||||
db: AsyncSession, idempotency_key: str, fingerprint: str
|
||||
) -> SmsOutboundMessage | None:
|
||||
message = await db.scalar(
|
||||
select(SmsOutboundMessage).where(
|
||||
SmsOutboundMessage.requester_service == "keycloak",
|
||||
SmsOutboundMessage.idempotency_key == idempotency_key,
|
||||
)
|
||||
)
|
||||
if message and message.request_fingerprint != fingerprint:
|
||||
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
||||
return message
|
||||
|
||||
|
||||
async def enforce_rate_limit(db: AsyncSession, phone_e164: str, destination_key: bytes) -> None:
|
||||
digest = destination_hmac(phone_e164, destination_key)
|
||||
lock_id = int.from_bytes(bytes.fromhex(digest[:16]), byteorder="big", signed=True)
|
||||
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_id})
|
||||
since = datetime.now(UTC) - timedelta(minutes=10)
|
||||
count = await db.scalar(
|
||||
select(func.count(SmsOutboundMessage.id)).where(
|
||||
SmsOutboundMessage.requester_service == "keycloak",
|
||||
SmsOutboundMessage.phone_e164 == phone_e164,
|
||||
SmsOutboundMessage.created_at >= since,
|
||||
)
|
||||
)
|
||||
if (count or 0) >= 5:
|
||||
raise DomainError(
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
"Rate limit exceeded",
|
||||
{"retry_after": 600},
|
||||
)
|
||||
|
||||
|
||||
async def create_order(
|
||||
db: AsyncSession,
|
||||
body: SendRequest,
|
||||
request_id: str | None,
|
||||
traceparent: str | None,
|
||||
destination_key: bytes,
|
||||
) -> tuple[SendResponse, bool]:
|
||||
validate_otp_request(body)
|
||||
phone_e164, phone_digits, phone_masked = normalize_phone(body.phone_e164)
|
||||
fingerprint = request_fingerprint(fingerprint_payload(body, phone_e164))
|
||||
existing = await existing_order(db, body.idempotency_key, fingerprint)
|
||||
if existing:
|
||||
return send_response(existing), False
|
||||
|
||||
runtime = await load_runtime_settings(db)
|
||||
template = await db.scalar(
|
||||
select(SmsTemplate).where(
|
||||
SmsTemplate.code == body.template_code,
|
||||
SmsTemplate.channel == Channel.SMS,
|
||||
SmsTemplate.locale == body.locale,
|
||||
SmsTemplate.is_active.is_(True),
|
||||
SmsTemplate.approved_at.is_not(None),
|
||||
)
|
||||
)
|
||||
if not template:
|
||||
raise DomainError("sms_service_unavailable", 503, "SMS service is unavailable")
|
||||
sender = template.sender_name or runtime.default_sender_name
|
||||
rendered = render_template(
|
||||
template.body_template, template.placeholders, body.substitutions, template.max_parts
|
||||
)
|
||||
await enforce_rate_limit(db, phone_e164, destination_key)
|
||||
now = datetime.now(UTC)
|
||||
message = SmsOutboundMessage(
|
||||
id=uuid.uuid4(),
|
||||
requested_at=now,
|
||||
updated_at=now,
|
||||
requester_service="keycloak",
|
||||
process="auth_otp",
|
||||
channel="SMS",
|
||||
provider="idgtl",
|
||||
phone_e164=phone_e164,
|
||||
phone_digits=phone_digits,
|
||||
phone_masked=phone_masked,
|
||||
template_id=template.id,
|
||||
template_code=template.code,
|
||||
body_rendered=rendered,
|
||||
substitutions=body.substitutions,
|
||||
send_status=SendStatus.PENDING,
|
||||
delivery_status=DeliveryStatus.UNKNOWN,
|
||||
customer_ref=body.customer_ref,
|
||||
idempotency_key=body.idempotency_key,
|
||||
request_fingerprint=fingerprint,
|
||||
request_id=request_id,
|
||||
traceparent=traceparent,
|
||||
sender_name=sender,
|
||||
message_ttl_sec=body.message_ttl_sec,
|
||||
attempt_count=0,
|
||||
next_attempt_at=now,
|
||||
)
|
||||
db.add(message)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
concurrent = await existing_order(db, body.idempotency_key, fingerprint)
|
||||
if concurrent:
|
||||
return send_response(concurrent), False
|
||||
raise
|
||||
return send_response(message), True
|
||||
|
||||
|
||||
def message_response(message: SmsOutboundMessage) -> MessageResponse:
|
||||
return MessageResponse(
|
||||
sms_message_id=message.id,
|
||||
ordered_at=message.requested_at,
|
||||
updated_at=message.updated_at,
|
||||
requester_service=message.requester_service,
|
||||
process=message.process,
|
||||
channel=message.channel,
|
||||
provider=message.provider,
|
||||
phone_masked=message.phone_masked,
|
||||
template_code=message.template_code,
|
||||
customer_ref=message.customer_ref,
|
||||
send_status=message.send_status.value,
|
||||
delivery_status=message.delivery_status.value,
|
||||
provider_message_id=message.provider_message_id,
|
||||
accepted_at=message.accepted_at,
|
||||
sent_at=message.sent_at,
|
||||
delivered_at=message.delivered_at,
|
||||
attempt_count=message.attempt_count,
|
||||
provider_error_code=message.provider_error_code,
|
||||
)
|
||||
|
||||
|
||||
async def read_message(db: AsyncSession, message_id: uuid.UUID) -> MessageResponse:
|
||||
message = await db.scalar(
|
||||
select(SmsOutboundMessage).where(
|
||||
SmsOutboundMessage.id == message_id,
|
||||
SmsOutboundMessage.requester_service == "keycloak",
|
||||
)
|
||||
)
|
||||
if not message:
|
||||
raise DomainError("not_found", 404, "Resource was not found")
|
||||
return message_response(message)
|
||||
|
||||
|
||||
async def apply_callback(db: AsyncSession, item: CallbackItem) -> bool:
|
||||
if item.channel_type.upper() != "SMS":
|
||||
log.warning("callback.rejected", reason="wrong_channel")
|
||||
return False
|
||||
message = await db.scalar(
|
||||
select(SmsOutboundMessage)
|
||||
.where(
|
||||
SmsOutboundMessage.provider == "idgtl",
|
||||
SmsOutboundMessage.provider_message_id == item.message_uuid,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if not message or item.external_message_id != str(message.id):
|
||||
digest = hashlib.sha256(item.message_uuid.encode()).hexdigest()[:16]
|
||||
log.warning(
|
||||
"callback.rejected", reason="unknown_or_conflicting_message", message_hash=digest
|
||||
)
|
||||
return False
|
||||
target = delivery_transition(message.delivery_status, item.status)
|
||||
if target is None:
|
||||
log.warning("callback.rejected", reason="unknown_status", sms_message_id=str(message.id))
|
||||
return False
|
||||
inserted = await db.scalar(
|
||||
pg_insert(SmsCallbackEvent)
|
||||
.values(
|
||||
id=uuid.uuid4(),
|
||||
message_uuid=item.message_uuid,
|
||||
callback_event=item.callback_event.lower(),
|
||||
status=item.status.lower(),
|
||||
status_time=item.status_time,
|
||||
)
|
||||
.on_conflict_do_nothing(constraint="uq_callback_event")
|
||||
.returning(SmsCallbackEvent.id)
|
||||
)
|
||||
if inserted is None:
|
||||
return True
|
||||
now = datetime.now(UTC)
|
||||
message.delivery_status = target
|
||||
message.callback_last_at = now
|
||||
message.updated_at = now
|
||||
message.provider_error_code = item.error_code
|
||||
message.parts = item.parts if item.parts is not None else message.parts
|
||||
message.price = item.price if item.price is not None else message.price
|
||||
message.currency = item.currency if item.currency is not None else message.currency
|
||||
if target == DeliveryStatus.SENT and message.sent_at is None:
|
||||
message.sent_at = item.status_time
|
||||
elif target == DeliveryStatus.DELIVERED and message.delivered_at is None:
|
||||
message.delivered_at = item.status_time
|
||||
return True
|
||||
@@ -0,0 +1,53 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import AnyHttpUrl, Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
||||
|
||||
database_url: str = Field(alias="SMS_DATABASE_URL")
|
||||
service_token: SecretStr = Field(alias="SMS_SERVICE_TOKEN", min_length=32)
|
||||
idgtl_base_url: AnyHttpUrl = Field(
|
||||
default=AnyHttpUrl("https://direct.i-dgtl.ru"), alias="IDGTL_SMS_BASE_URL"
|
||||
)
|
||||
idgtl_api_key: SecretStr | None = Field(default=None, alias="IDGTL_SMS_API_KEY")
|
||||
callback_public_url: AnyHttpUrl = Field(alias="IDGTL_SMS_CALLBACK_PUBLIC_URL")
|
||||
callback_username: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_USERNAME")
|
||||
callback_password: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_PASSWORD")
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
api_port: int = Field(default=8080, alias="SMS_API_PORT", ge=1, le=65535)
|
||||
|
||||
@field_validator(
|
||||
"service_token",
|
||||
"callback_username",
|
||||
"callback_password",
|
||||
)
|
||||
@classmethod
|
||||
def reject_placeholders(cls, value: SecretStr) -> SecretStr:
|
||||
raw = value.get_secret_value().strip()
|
||||
if not raw or raw.lower() in {"changeme", "secret", "token", "<secret>"}:
|
||||
raise ValueError("secret is missing or is a placeholder")
|
||||
return value
|
||||
|
||||
@field_validator("idgtl_api_key")
|
||||
@classmethod
|
||||
def reject_api_key_placeholder(cls, value: SecretStr | None) -> SecretStr | None:
|
||||
if value is None:
|
||||
return None
|
||||
return cls.reject_placeholders(value)
|
||||
|
||||
@field_validator("callback_public_url")
|
||||
@classmethod
|
||||
def callback_must_be_https(cls, value: AnyHttpUrl) -> AnyHttpUrl:
|
||||
if value.scheme != "https":
|
||||
raise ValueError("callback URL must use HTTPS")
|
||||
if value.username or value.password:
|
||||
raise ValueError("callback URL must not contain credentials")
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
|
||||
from app.db import Database, SendStatus, SmsOutboundMessage
|
||||
from app.metrics import (
|
||||
JOURNAL_ROWS,
|
||||
PENDING_AGE,
|
||||
PROVIDER_LATENCY,
|
||||
SEND_TOTAL,
|
||||
SETTINGS_VALID,
|
||||
UNCERTAIN_TOTAL,
|
||||
)
|
||||
from app.provider import IdgtlClient, IdgtlConfig
|
||||
from app.service import RuntimeSettings, load_runtime_settings
|
||||
from app.settings import Settings, get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
MAX_CONNECT_ATTEMPTS = 3
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
logging.basicConfig(level=level, format="%(message)s")
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def reconcile_expired_leases(db: Database) -> int:
|
||||
now = datetime.now(UTC)
|
||||
async with db.sessions.begin() as session:
|
||||
result = await session.execute(
|
||||
update(SmsOutboundMessage)
|
||||
.where(
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING,
|
||||
SmsOutboundMessage.attempt_count > 0,
|
||||
SmsOutboundMessage.worker_locked_until < now,
|
||||
)
|
||||
.values(
|
||||
send_status=SendStatus.UNCERTAIN,
|
||||
worker_locked_until=None,
|
||||
next_attempt_at=None,
|
||||
updated_at=now,
|
||||
provider_error_code="worker_lease_expired",
|
||||
provider_error_message="provider_result_uncertain",
|
||||
)
|
||||
.returning(SmsOutboundMessage.id)
|
||||
)
|
||||
ids = list(result.scalars())
|
||||
for message_id in ids:
|
||||
SEND_TOTAL.labels("idgtl", SendStatus.UNCERTAIN.value).inc()
|
||||
UNCERTAIN_TOTAL.labels("idgtl").inc()
|
||||
log.error("worker.lease_expired", sms_message_id=str(message_id))
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def lease_message(db: Database, runtime: RuntimeSettings) -> SmsOutboundMessage | None:
|
||||
now = datetime.now(UTC)
|
||||
eligible = or_(
|
||||
and_(
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING,
|
||||
SmsOutboundMessage.attempt_count == 0,
|
||||
),
|
||||
and_(
|
||||
SmsOutboundMessage.send_status == SendStatus.FAILED,
|
||||
SmsOutboundMessage.attempt_count < MAX_CONNECT_ATTEMPTS,
|
||||
),
|
||||
)
|
||||
async with db.sessions.begin() as session:
|
||||
message = await session.scalar(
|
||||
select(SmsOutboundMessage)
|
||||
.where(
|
||||
eligible,
|
||||
SmsOutboundMessage.next_attempt_at <= now,
|
||||
or_(
|
||||
SmsOutboundMessage.worker_locked_until.is_(None),
|
||||
SmsOutboundMessage.worker_locked_until < now,
|
||||
),
|
||||
)
|
||||
.order_by(SmsOutboundMessage.next_attempt_at, SmsOutboundMessage.created_at)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(1)
|
||||
)
|
||||
if message:
|
||||
message.send_status = SendStatus.PENDING
|
||||
message.attempt_count += 1
|
||||
message.last_attempt_at = now
|
||||
message.worker_locked_until = now + timedelta(seconds=runtime.lease_seconds)
|
||||
message.updated_at = now
|
||||
return message
|
||||
|
||||
|
||||
async def save_result(db: Database, message_id, result, attempt_count: int) -> None:
|
||||
now = datetime.now(UTC)
|
||||
status = result.send_status
|
||||
next_attempt = None
|
||||
if result.retry_safe and attempt_count < MAX_CONNECT_ATTEMPTS:
|
||||
next_attempt = now + timedelta(seconds=(2**attempt_count) + random.uniform(0, 1)) # noqa: S311
|
||||
async with db.sessions.begin() as session:
|
||||
values = {
|
||||
"send_status": status,
|
||||
"provider_http_status": result.http_status,
|
||||
"provider_message_id": result.message_uuid,
|
||||
"provider_external_id": result.external_id,
|
||||
"provider_error_code": result.error_code,
|
||||
"provider_error_message": result.error_message,
|
||||
"worker_locked_until": None,
|
||||
"next_attempt_at": next_attempt,
|
||||
"updated_at": now,
|
||||
}
|
||||
if status == SendStatus.ACCEPTED:
|
||||
values["accepted_at"] = now
|
||||
await session.execute(
|
||||
update(SmsOutboundMessage)
|
||||
.where(
|
||||
SmsOutboundMessage.id == message_id,
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING,
|
||||
SmsOutboundMessage.attempt_count == attempt_count,
|
||||
)
|
||||
.values(**values)
|
||||
)
|
||||
SEND_TOTAL.labels("idgtl", status.value).inc()
|
||||
if status == SendStatus.UNCERTAIN:
|
||||
UNCERTAIN_TOTAL.labels("idgtl").inc()
|
||||
if result.contract_violation:
|
||||
log.error("provider.contract_violation", sms_message_id=str(message_id))
|
||||
|
||||
|
||||
def provider_config(settings: Settings, runtime: RuntimeSettings) -> IdgtlConfig:
|
||||
if settings.idgtl_api_key is None:
|
||||
raise RuntimeError("IDGTL_SMS_API_KEY is required by sms-worker")
|
||||
return IdgtlConfig(
|
||||
base_url=str(settings.idgtl_base_url),
|
||||
api_key=settings.idgtl_api_key.get_secret_value(),
|
||||
callback_url=str(settings.callback_public_url),
|
||||
callback_username=settings.callback_username.get_secret_value(),
|
||||
callback_password=settings.callback_password.get_secret_value(),
|
||||
connect_timeout_ms=runtime.connect_timeout_ms,
|
||||
request_timeout_ms=runtime.request_timeout_ms,
|
||||
callback_enabled=runtime.callback_enabled,
|
||||
)
|
||||
|
||||
|
||||
async def update_queue_metrics(db: Database) -> None:
|
||||
async with db.sessions() as session:
|
||||
oldest = await session.scalar(
|
||||
select(func.min(SmsOutboundMessage.created_at)).where(
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING
|
||||
)
|
||||
)
|
||||
count = await session.scalar(select(func.count(SmsOutboundMessage.id)))
|
||||
age = max(0.0, (datetime.now(UTC) - oldest).total_seconds()) if oldest else 0.0
|
||||
PENDING_AGE.set(age)
|
||||
JOURNAL_ROWS.set(count or 0)
|
||||
|
||||
|
||||
async def worker_loop(stop: asyncio.Event) -> None:
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
db = Database(settings.database_url)
|
||||
async with httpx.AsyncClient() as http:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await reconcile_expired_leases(db)
|
||||
async with db.sessions() as session:
|
||||
runtime = await load_runtime_settings(session)
|
||||
SETTINGS_VALID.set(1)
|
||||
message = await lease_message(db, runtime)
|
||||
if message is None:
|
||||
await update_queue_metrics(db)
|
||||
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
|
||||
continue
|
||||
client = IdgtlClient(http, provider_config(settings, runtime))
|
||||
started = time.monotonic()
|
||||
result = await client.send(message)
|
||||
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
|
||||
await save_result(db, message.id, result, message.attempt_count)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
SETTINGS_VALID.set(0)
|
||||
log.exception("worker.iteration_failed")
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
for name in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(name, stop.set)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
try:
|
||||
loop.run_until_complete(worker_loop(stop))
|
||||
finally:
|
||||
loop.close()
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.db import Base, create_postgres_engine
|
||||
from app.settings import get_settings
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
database_url = get_settings().database_url
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
version_table_schema="sms",
|
||||
include_schemas=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
version_table_schema="sms",
|
||||
include_schemas=True,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = create_postgres_engine(database_url)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_async_migrations())
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Create SMS journal schema objects.
|
||||
|
||||
Revision ID: 0001_initial
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0001_initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
SCHEMA = "sms"
|
||||
channel = postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA, create_type=False)
|
||||
send_status = postgresql.ENUM(
|
||||
"pending",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"failed",
|
||||
"uncertain",
|
||||
"skipped",
|
||||
name="sms_send_status",
|
||||
schema=SCHEMA,
|
||||
create_type=False,
|
||||
)
|
||||
delivery_status = postgresql.ENUM(
|
||||
"unknown",
|
||||
"sent",
|
||||
"delivered",
|
||||
"undelivered",
|
||||
"unsent",
|
||||
name="sms_delivery_status",
|
||||
schema=SCHEMA,
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA).create(bind)
|
||||
postgresql.ENUM(
|
||||
"pending",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"failed",
|
||||
"uncertain",
|
||||
"skipped",
|
||||
name="sms_send_status",
|
||||
schema=SCHEMA,
|
||||
).create(bind)
|
||||
postgresql.ENUM(
|
||||
"unknown",
|
||||
"sent",
|
||||
"delivered",
|
||||
"undelivered",
|
||||
"unsent",
|
||||
name="sms_delivery_status",
|
||||
schema=SCHEMA,
|
||||
).create(bind)
|
||||
|
||||
op.create_table(
|
||||
"sms_template",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("code", sa.String(64), nullable=False),
|
||||
sa.Column("channel", channel, nullable=False),
|
||||
sa.Column("locale", sa.String(16), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("body_template", sa.Text(), nullable=False),
|
||||
sa.Column("placeholders", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("sender_name", sa.String(64)),
|
||||
sa.Column("max_parts", sa.SmallInteger(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True)),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("created_by", sa.String(64), nullable=False),
|
||||
sa.CheckConstraint("version > 0", name="ck_template_version_positive"),
|
||||
sa.CheckConstraint("max_parts BETWEEN 1 AND 10", name="ck_template_max_parts"),
|
||||
sa.UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_template_active",
|
||||
"sms_template",
|
||||
["code", "channel", "locale"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("is_active"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sms_setting",
|
||||
sa.Column("setting_key", sa.String(128), primary_key=True),
|
||||
sa.Column("setting_value", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("value_type", sa.String(16), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"value_type IN ('string','integer','boolean')", name="ck_setting_value_type"
|
||||
),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sms_outbound_message",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("sent_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True)),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("requester_service", sa.String(64), nullable=False),
|
||||
sa.Column("process", sa.String(64), nullable=False),
|
||||
sa.Column("channel", sa.String(16), nullable=False),
|
||||
sa.Column("provider", sa.String(32), nullable=False),
|
||||
sa.Column("phone_e164", sa.String(16), nullable=False),
|
||||
sa.Column("phone_digits", sa.String(15), nullable=False),
|
||||
sa.Column("phone_masked", sa.String(32), nullable=False),
|
||||
sa.Column(
|
||||
"template_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey(f"{SCHEMA}.sms_template.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("template_code", sa.String(64), nullable=False),
|
||||
sa.Column("body_rendered", sa.Text(), nullable=False),
|
||||
sa.Column("substitutions", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("send_status", send_status, nullable=False),
|
||||
sa.Column("delivery_status", delivery_status, nullable=False),
|
||||
sa.Column("provider_message_id", sa.String(128)),
|
||||
sa.Column("provider_external_id", sa.String(128)),
|
||||
sa.Column("customer_ref", sa.String(128)),
|
||||
sa.Column("idempotency_key", sa.String(192), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(64), nullable=False),
|
||||
sa.Column("request_id", sa.String(128)),
|
||||
sa.Column("traceparent", sa.String(55)),
|
||||
sa.Column("provider_http_status", sa.Integer()),
|
||||
sa.Column("provider_error_code", sa.String(64)),
|
||||
sa.Column("provider_error_message", sa.String(256)),
|
||||
sa.Column("sender_name", sa.String(64), nullable=False),
|
||||
sa.Column("message_ttl_sec", sa.Integer()),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_attempt_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("next_attempt_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("worker_locked_until", sa.DateTime(timezone=True)),
|
||||
sa.Column("parts", sa.Integer()),
|
||||
sa.Column("price", sa.Numeric(14, 4)),
|
||||
sa.Column("currency", sa.String(3)),
|
||||
sa.Column("callback_last_at", sa.DateTime(timezone=True)),
|
||||
sa.CheckConstraint("channel = 'SMS'", name="ck_outbound_channel"),
|
||||
sa.CheckConstraint("provider = 'idgtl'", name="ck_outbound_provider"),
|
||||
sa.CheckConstraint("process = 'auth_otp'", name="ck_outbound_process"),
|
||||
sa.CheckConstraint("message_ttl_sec BETWEEN 60 AND 86400", name="ck_outbound_ttl"),
|
||||
sa.CheckConstraint("attempt_count >= 0", name="ck_outbound_attempts"),
|
||||
sa.UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_outbound_provider_message",
|
||||
"sms_outbound_message",
|
||||
["provider", "provider_message_id"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("provider_message_id IS NOT NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_phone_created",
|
||||
"sms_outbound_message",
|
||||
["phone_e164", sa.text("created_at DESC")],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_requester_process_created",
|
||||
"sms_outbound_message",
|
||||
["requester_service", "process", sa.text("created_at DESC")],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_customer_ref", "sms_outbound_message", ["customer_ref"], schema=SCHEMA
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_send_created",
|
||||
"sms_outbound_message",
|
||||
["send_status", "created_at"],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_delivery_updated",
|
||||
"sms_outbound_message",
|
||||
["delivery_status", "updated_at"],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sms_callback_event",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("message_uuid", sa.String(128), nullable=False),
|
||||
sa.Column("callback_event", sa.String(32), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("status_time", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"received_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"message_uuid", "callback_event", "status", "status_time", name="uq_callback_event"
|
||||
),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sms_callback_event", schema=SCHEMA)
|
||||
op.drop_table("sms_outbound_message", schema=SCHEMA)
|
||||
op.drop_table("sms_setting", schema=SCHEMA)
|
||||
op.drop_table("sms_template", schema=SCHEMA)
|
||||
delivery_status.drop(op.get_bind())
|
||||
send_status.drop(op.get_bind())
|
||||
channel.drop(op.get_bind())
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Seed versioned technical settings and OTP template placeholder.
|
||||
|
||||
Revision ID: 0002_seed
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0002_seed"
|
||||
down_revision = "0001_initial"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TEMPLATE_ID = uuid.UUID("5ac2a77e-590c-4b24-87d8-baa0f1240cd1")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO sms.sms_template (
|
||||
id, code, channel, locale, version, body_template, placeholders,
|
||||
sender_name, max_parts, is_active, approved_at, created_by
|
||||
) VALUES (
|
||||
:id, 'auth_otp', 'SMS', 'ru', 1,
|
||||
'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.',
|
||||
'["code","ttl_min"]'::jsonb, NULL, 1, true, NULL, 'migration'
|
||||
)
|
||||
ON CONFLICT (code, channel, locale, version) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"id": TEMPLATE_ID},
|
||||
)
|
||||
settings = (
|
||||
(
|
||||
"provider.idgtl.default_sender_name",
|
||||
'"__SET_ME_AFTER_PROVIDER_APPROVAL__"',
|
||||
"string",
|
||||
"Provider-approved default sender name",
|
||||
),
|
||||
(
|
||||
"provider.idgtl.connect_timeout_ms",
|
||||
"3000",
|
||||
"integer",
|
||||
"Direct connection timeout in milliseconds",
|
||||
),
|
||||
(
|
||||
"provider.idgtl.request_timeout_ms",
|
||||
"70000",
|
||||
"integer",
|
||||
"Direct total request timeout in milliseconds",
|
||||
),
|
||||
(
|
||||
"provider.idgtl.callback_enabled",
|
||||
"true",
|
||||
"boolean",
|
||||
"Include delivery callback in provider requests",
|
||||
),
|
||||
(
|
||||
"worker.poll_interval_ms",
|
||||
"500",
|
||||
"integer",
|
||||
"Queue polling interval in milliseconds",
|
||||
),
|
||||
(
|
||||
"worker.lease_seconds",
|
||||
"90",
|
||||
"integer",
|
||||
"Exclusive provider-call lease duration",
|
||||
),
|
||||
)
|
||||
for key, value, value_type, description in settings:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO sms.sms_setting (
|
||||
setting_key, setting_value, value_type, description
|
||||
) VALUES (:key, CAST(:value AS jsonb), :value_type, :description)
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value_type": value_type,
|
||||
"description": description,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text("DELETE FROM sms.sms_template WHERE id = :id").bindparams(id=TEMPLATE_ID))
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM sms.sms_setting
|
||||
WHERE setting_key IN (
|
||||
'provider.idgtl.default_sender_name',
|
||||
'provider.idgtl.connect_timeout_ms',
|
||||
'provider.idgtl.request_timeout_ms',
|
||||
'provider.idgtl.callback_enabled',
|
||||
'worker.poll_interval_ms',
|
||||
'worker.lease_seconds'
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,314 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HAN SMS Service
|
||||
version: 1.0.0
|
||||
description: Durable internal SMS ordering and i-Digital delivery callbacks.
|
||||
servers:
|
||||
- url: http://sms-service:8080
|
||||
paths:
|
||||
/internal/sms/v1/send:
|
||||
post:
|
||||
operationId: orderSms
|
||||
security:
|
||||
- serviceBearer: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RequestId"
|
||||
- $ref: "#/components/parameters/Traceparent"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SendRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: New order durably committed; provider has not necessarily been called.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SendResponse"
|
||||
"200":
|
||||
description: Idempotent replay of an existing order.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SendResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"409":
|
||||
$ref: "#/components/responses/IdempotencyConflict"
|
||||
"422":
|
||||
$ref: "#/components/responses/InvalidRequest"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
/internal/sms/v1/messages/{sms_message_id}:
|
||||
get:
|
||||
operationId: readSmsOrder
|
||||
security:
|
||||
- serviceBearer: []
|
||||
parameters:
|
||||
- name: sms_message_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- $ref: "#/components/parameters/RequestId"
|
||||
responses:
|
||||
"200":
|
||||
description: Redacted message diagnostics; never contains OTP, body, or full phone.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Message"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
/callbacks/idgtl/sms:
|
||||
post:
|
||||
operationId: acceptIdgtlCallback
|
||||
security:
|
||||
- callbackBasic: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 1000
|
||||
items:
|
||||
$ref: "#/components/schemas/IdgtlCallbackItem"
|
||||
responses:
|
||||
"204":
|
||||
description: Valid callback items committed; invalid items were safely ignored.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"422":
|
||||
$ref: "#/components/responses/InvalidRequest"
|
||||
webhooks:
|
||||
idgtlDeliveryStatus:
|
||||
post:
|
||||
summary: The same payload accepted at /callbacks/idgtl/sms.
|
||||
security:
|
||||
- callbackBasic: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IdgtlCallbackItem"
|
||||
responses:
|
||||
"204":
|
||||
description: Callback committed.
|
||||
components:
|
||||
securitySchemes:
|
||||
serviceBearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: opaque-service-token
|
||||
callbackBasic:
|
||||
type: http
|
||||
scheme: basic
|
||||
parameters:
|
||||
RequestId:
|
||||
name: X-Request-ID
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 128
|
||||
Traceparent:
|
||||
name: traceparent
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[\\da-f]{2}-[\\da-f]{32}-[\\da-f]{16}-[\\da-f]{2}$"
|
||||
schemas:
|
||||
SendRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- idempotency_key
|
||||
- template_code
|
||||
- locale
|
||||
- phone_e164
|
||||
- substitutions
|
||||
- customer_ref
|
||||
- message_ttl_sec
|
||||
properties:
|
||||
idempotency_key:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 192
|
||||
template_code:
|
||||
const: auth_otp
|
||||
locale:
|
||||
const: ru
|
||||
phone_e164:
|
||||
type: string
|
||||
pattern: "^\\+[1-9]\\d{7,14}$"
|
||||
substitutions:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [code, ttl_min]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
pattern: "^\\d{4,10}$"
|
||||
ttl_min:
|
||||
oneOf:
|
||||
- type: string
|
||||
pattern: "^\\d{1,3}$"
|
||||
- type: integer
|
||||
minimum: 1
|
||||
maximum: 1440
|
||||
customer_ref:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
message_ttl_sec:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 86400
|
||||
SendResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [sms_message_id, ordered_at]
|
||||
properties:
|
||||
sms_message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
ordered_at:
|
||||
type: string
|
||||
format: date-time
|
||||
Message:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: Deliberately excludes phone_e164, body_rendered, and substitutions.
|
||||
required:
|
||||
- sms_message_id
|
||||
- ordered_at
|
||||
- updated_at
|
||||
- requester_service
|
||||
- process
|
||||
- channel
|
||||
- provider
|
||||
- phone_masked
|
||||
- template_code
|
||||
- send_status
|
||||
- delivery_status
|
||||
- attempt_count
|
||||
properties:
|
||||
sms_message_id: {type: string, format: uuid}
|
||||
ordered_at: {type: string, format: date-time}
|
||||
updated_at: {type: string, format: date-time}
|
||||
requester_service: {const: keycloak}
|
||||
process: {const: auth_otp}
|
||||
channel: {const: SMS}
|
||||
provider: {const: idgtl}
|
||||
phone_masked: {type: string}
|
||||
template_code: {const: auth_otp}
|
||||
customer_ref: {type: [string, "null"]}
|
||||
send_status:
|
||||
enum: [pending, accepted, rejected, failed, uncertain, skipped]
|
||||
delivery_status:
|
||||
enum: [unknown, sent, delivered, undelivered, unsent]
|
||||
provider_message_id: {type: [string, "null"]}
|
||||
accepted_at: {type: [string, "null"], format: date-time}
|
||||
sent_at: {type: [string, "null"], format: date-time}
|
||||
delivered_at: {type: [string, "null"], format: date-time}
|
||||
attempt_count: {type: integer, minimum: 0}
|
||||
provider_error_code: {type: [string, "null"]}
|
||||
IdgtlCallbackItem:
|
||||
type: object
|
||||
required:
|
||||
- channelType
|
||||
- messageUuid
|
||||
- externalMessageId
|
||||
- callbackEvent
|
||||
- status
|
||||
- statusTime
|
||||
properties:
|
||||
channelType:
|
||||
const: SMS
|
||||
messageUuid:
|
||||
type: string
|
||||
externalMessageId:
|
||||
type: string
|
||||
callbackEvent:
|
||||
type: string
|
||||
status:
|
||||
enum: [sent, delivered, undelivered, unsent]
|
||||
statusTime:
|
||||
type: string
|
||||
format: date-time
|
||||
errorCode:
|
||||
type: [string, "null"]
|
||||
parts:
|
||||
type: [integer, "null"]
|
||||
minimum: 0
|
||||
price:
|
||||
type: [number, "null"]
|
||||
minimum: 0
|
||||
currency:
|
||||
type: [string, "null"]
|
||||
minLength: 3
|
||||
maxLength: 3
|
||||
Error:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [code, message, request_id, details]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
request_id: {type: string}
|
||||
details:
|
||||
oneOf:
|
||||
- type: object
|
||||
- type: array
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Missing or invalid credentials.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
IdempotencyConflict:
|
||||
description: The key was already used with another meaningful payload.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
InvalidRequest:
|
||||
description: Strict request or callback validation failed.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
RateLimited:
|
||||
description: Caller and destination rate limit exceeded.
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: {type: integer}
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
Unavailable:
|
||||
description: The order could not be durably committed.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
NotFound:
|
||||
description: Message was not found in the caller scope.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
@@ -0,0 +1,59 @@
|
||||
[project]
|
||||
name = "han-sms-service"
|
||||
version = "0.1.0"
|
||||
description = "HAN Chat durable SMS delivery service"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"phonenumbers>=9,<10",
|
||||
"prometheus-client>=0.22,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"structlog>=25,<26",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"aiosqlite>=0.21,<1",
|
||||
"mypy>=1.16,<2",
|
||||
"pytest>=8.4,<9",
|
||||
"pytest-asyncio>=1.0,<2",
|
||||
"pyyaml>=6,<7",
|
||||
"ruff>=0.12,<1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
han-sms-api = "app.main:run"
|
||||
han-sms-worker = "app.worker:run"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
|
||||
ignore = ["S101"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
check_untyped_defs = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
ignore_missing_imports = true
|
||||
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
|
||||
exclude = ["migrations/"]
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def test_static_contract_is_openapi_31_and_redacted() -> None:
|
||||
contract = yaml.safe_load(
|
||||
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert contract["openapi"] == "3.1.0"
|
||||
paths = contract["paths"]
|
||||
assert "/internal/sms/v1/send" in paths
|
||||
assert "/internal/sms/v1/messages/{sms_message_id}" in paths
|
||||
assert "/callbacks/idgtl/sms" in paths
|
||||
message_fields = contract["components"]["schemas"]["Message"]["properties"]
|
||||
assert {"phone_e164", "body_rendered", "substitutions"}.isdisjoint(message_fields)
|
||||
assert "idgtlDeliveryStatus" in contract["webhooks"]
|
||||
|
||||
|
||||
def test_send_contract_distinguishes_new_and_replayed_order() -> None:
|
||||
contract = yaml.safe_load(
|
||||
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
responses = contract["paths"]["/internal/sms/v1/send"]["post"]["responses"]
|
||||
assert {"200", "202", "401", "409", "422", "429", "503"} <= responses.keys()
|
||||
@@ -0,0 +1,35 @@
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.domain import DomainError
|
||||
from app.main import basic_auth, bearer_auth
|
||||
|
||||
|
||||
def request_with(authorization: str):
|
||||
settings = SimpleNamespace(
|
||||
service_token=SecretStr("s" * 43),
|
||||
callback_username=SecretStr("callback-user"),
|
||||
callback_password=SecretStr("callback-password"),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
headers={"Authorization": authorization},
|
||||
app=SimpleNamespace(state=SimpleNamespace(settings=settings)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_api_requires_exact_bearer_token() -> None:
|
||||
await bearer_auth(request_with(f"Bearer {'s' * 43}"))
|
||||
with pytest.raises(DomainError) as error:
|
||||
await bearer_auth(request_with("Bearer wrong"))
|
||||
assert error.value.code == "unauthorized"
|
||||
|
||||
|
||||
def test_callback_requires_exact_basic_credentials() -> None:
|
||||
encoded = base64.b64encode(b"callback-user:callback-password").decode()
|
||||
basic_auth(request_with(f"Basic {encoded}"))
|
||||
with pytest.raises(DomainError):
|
||||
basic_auth(request_with("Basic invalid"))
|
||||
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
|
||||
from app.db import DeliveryStatus
|
||||
from app.domain import (
|
||||
DomainError,
|
||||
delivery_transition,
|
||||
normalize_phone,
|
||||
render_template,
|
||||
request_fingerprint,
|
||||
sms_parts,
|
||||
)
|
||||
|
||||
|
||||
def test_phone_is_canonical_and_masked() -> None:
|
||||
e164, digits, masked = normalize_phone("+79001234567")
|
||||
assert e164 == "+79001234567"
|
||||
assert digits == "79001234567"
|
||||
assert masked == "+7******4567"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phone", ["79001234567", "+012345678", "+7900", "+7999999999999999"])
|
||||
def test_invalid_phone_is_rejected(phone: str) -> None:
|
||||
with pytest.raises(DomainError) as error:
|
||||
normalize_phone(phone)
|
||||
assert error.value.code == "sms_request_invalid"
|
||||
|
||||
|
||||
def test_strict_template_render() -> None:
|
||||
result = render_template(
|
||||
"Код входа: {code}. Действителен {ttl_min} мин.",
|
||||
["code", "ttl_min"],
|
||||
{"code": "482193", "ttl_min": 1},
|
||||
1,
|
||||
)
|
||||
assert result == "Код входа: 482193. Действителен 1 мин."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"substitutions",
|
||||
[
|
||||
{"code": "482193"},
|
||||
{"code": "482193", "ttl_min": 1, "extra": "forbidden"},
|
||||
],
|
||||
)
|
||||
def test_template_rejects_placeholder_mismatch(substitutions) -> None:
|
||||
with pytest.raises(DomainError):
|
||||
render_template(
|
||||
"Код: {code}; TTL: {ttl_min}",
|
||||
["code", "ttl_min"],
|
||||
substitutions,
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
def test_template_rejects_format_expressions() -> None:
|
||||
with pytest.raises(DomainError):
|
||||
render_template("{code!r}", ["code"], {"code": "123456"}, 1)
|
||||
|
||||
|
||||
def test_sms_parts_supports_gsm_and_unicode() -> None:
|
||||
assert sms_parts("A" * 160) == 1
|
||||
assert sms_parts("A" * 161) == 2
|
||||
assert sms_parts("Я" * 70) == 1
|
||||
assert sms_parts("Я" * 71) == 2
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical() -> None:
|
||||
first = request_fingerprint({"b": 2, "a": {"y": 2, "x": 1}})
|
||||
second = request_fingerprint({"a": {"x": 1, "y": 2}, "b": 2})
|
||||
assert first == second
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current", "incoming", "expected"),
|
||||
[
|
||||
(DeliveryStatus.UNKNOWN, "sent", DeliveryStatus.SENT),
|
||||
(DeliveryStatus.SENT, "delivered", DeliveryStatus.DELIVERED),
|
||||
(DeliveryStatus.DELIVERED, "sent", DeliveryStatus.DELIVERED),
|
||||
(DeliveryStatus.UNDELIVERED, "sent", DeliveryStatus.UNDELIVERED),
|
||||
(DeliveryStatus.DELIVERED, "unsent", DeliveryStatus.DELIVERED),
|
||||
(DeliveryStatus.UNKNOWN, "bogus", None),
|
||||
],
|
||||
)
|
||||
def test_delivery_status_is_monotonic(current, incoming, expected) -> None:
|
||||
assert delivery_transition(current, incoming) == expected
|
||||
@@ -0,0 +1,90 @@
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.db import SendStatus
|
||||
from app.provider import IdgtlConfig, callback_url_with_credentials, classify_response
|
||||
|
||||
|
||||
def response(status: int, payload=None) -> httpx.Response:
|
||||
request = httpx.Request("POST", "https://direct.example/api/v1/message")
|
||||
if payload is None:
|
||||
return httpx.Response(status, request=request)
|
||||
return httpx.Response(status, json=payload, request=request)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 402, 403, 422])
|
||||
def test_explicit_business_rejections_are_not_retried(status: int) -> None:
|
||||
result = classify_response(response(status), "message-id")
|
||||
assert result.send_status == SendStatus.REJECTED
|
||||
assert result.retry_safe is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503, 504])
|
||||
def test_ambiguous_http_results_are_uncertain(status: int) -> None:
|
||||
result = classify_response(response(status), "message-id")
|
||||
assert result.send_status == SendStatus.UNCERTAIN
|
||||
assert result.retry_safe is False
|
||||
|
||||
|
||||
def test_exact_success_contract() -> None:
|
||||
message_uuid = str(uuid.uuid4())
|
||||
result = classify_response(
|
||||
response(
|
||||
200,
|
||||
{
|
||||
"errors": False,
|
||||
"response": [
|
||||
{
|
||||
"code": 201,
|
||||
"messageUuid": message_uuid,
|
||||
"externalMessageId": "message-id",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
"message-id",
|
||||
)
|
||||
assert result.send_status == SendStatus.ACCEPTED
|
||||
assert result.message_uuid == message_uuid
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"errors": True, "response": []},
|
||||
{"errors": False, "response": []},
|
||||
{"errors": False, "response": [{"code": 200}]},
|
||||
{
|
||||
"errors": False,
|
||||
"response": [
|
||||
{
|
||||
"code": 201,
|
||||
"messageUuid": str(uuid.uuid4()),
|
||||
"externalMessageId": "wrong",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_malformed_200_is_rejected_contract_violation(payload) -> None:
|
||||
result = classify_response(response(200, payload), "message-id")
|
||||
assert result.send_status == SendStatus.REJECTED
|
||||
assert result.contract_violation is True
|
||||
|
||||
|
||||
def test_callback_credentials_are_url_encoded() -> None:
|
||||
config = IdgtlConfig(
|
||||
base_url="https://direct.example",
|
||||
api_key="api-key",
|
||||
callback_url="https://tohin.ru/callbacks/idgtl/sms",
|
||||
callback_username="user@example",
|
||||
callback_password="p:a/ss", # noqa: S106 - synthetic URL-encoding fixture
|
||||
connect_timeout_ms=3000,
|
||||
request_timeout_ms=70000,
|
||||
callback_enabled=True,
|
||||
)
|
||||
assert callback_url_with_credentials(config) == (
|
||||
"https://user%40example:p%3Aa%2Fss@tohin.ru/callbacks/idgtl/sms"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain import DomainError
|
||||
from app.schemas import CallbackItem, SendRequest
|
||||
from app.service import validate_otp_request
|
||||
|
||||
|
||||
def valid_send(**overrides) -> SendRequest:
|
||||
payload = {
|
||||
"idempotency_key": "keycloak:challenge:01JABCDEF",
|
||||
"template_code": "auth_otp",
|
||||
"locale": "ru",
|
||||
"phone_e164": "+79001234567",
|
||||
"substitutions": {"code": "482193", "ttl_min": "1"},
|
||||
"customer_ref": "01JABCDEF",
|
||||
"message_ttl_sec": 60,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return SendRequest.model_validate(payload)
|
||||
|
||||
|
||||
def test_send_request_is_strict() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
valid_send(extra="forbidden")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("substitutions", "ttl"),
|
||||
[
|
||||
({"code": "12ab", "ttl_min": "1"}, 60),
|
||||
({"code": "123456", "ttl_min": "2"}, 60),
|
||||
({"code": "123456", "ttl_min": "1"}, 61),
|
||||
],
|
||||
)
|
||||
def test_otp_substitutions_match_ttl(substitutions, ttl) -> None:
|
||||
with pytest.raises(DomainError):
|
||||
validate_otp_request(valid_send(substitutions=substitutions, message_ttl_sec=ttl))
|
||||
|
||||
|
||||
def test_callback_accepts_provider_camel_case() -> None:
|
||||
item = CallbackItem.model_validate(
|
||||
{
|
||||
"channelType": "SMS",
|
||||
"messageUuid": "provider-id",
|
||||
"externalMessageId": "internal-id",
|
||||
"callbackEvent": "delivered",
|
||||
"status": "delivered",
|
||||
"statusTime": "2026-07-22T12:00:00Z",
|
||||
}
|
||||
)
|
||||
assert item.channel_type == "SMS"
|
||||
assert item.status_time.tzinfo is not None
|
||||
Reference in New Issue
Block a user