Исправлены ошибки в ходе раскатки

This commit is contained in:
mi
2026-07-16 14:49:04 +03:00
parent caf6bf0516
commit d86e663690
35 changed files with 482 additions and 119 deletions
+4 -7
View File
@@ -2,16 +2,17 @@ import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.db import Base
from app.postgres import create_postgres_engine
from app.settings import get_settings
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
database_url = get_settings().database_url
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
target_metadata = Base.metadata
@@ -28,11 +29,7 @@ def do_run_migrations(connection) -> None:
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
connectable = create_postgres_engine(database_url, poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
@@ -7,6 +7,7 @@ Create Date: 2026-07-10
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from app.db import Base
@@ -75,12 +76,18 @@ def upgrade() -> None:
CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user
ON han_app.dialogs(user_id)
WHERE record_status='A'
AND status IN ('open','waiting_for_company','waiting_for_client');
AND status IN ('open','waiting_for_company','waiting_for_client')
"""
)
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact
ON han_app.client_profiles(bitrix_contact_id)
WHERE bitrix_contact_id IS NOT NULL AND record_status='A';
WHERE bitrix_contact_id IS NOT NULL AND record_status='A'
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
RETURNS trigger
LANGUAGE plpgsql
@@ -116,36 +123,48 @@ def upgrade() -> None:
ON CONFLICT (dedup_key) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities;
$$
"""
)
op.execute(
"DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities"
)
op.execute(
"""
CREATE TRIGGER trg_identity_contact_sync
AFTER INSERT OR UPDATE OF phone_number, record_status
ON han_app.user_identities
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync()
"""
)
op.execute(
"DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles"
)
op.execute(
"""
CREATE TRIGGER trg_profile_contact_sync
AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone,
foreign_phone, email, record_status
ON han_app.client_profiles
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync()
"""
)
for key, (value, value_type, public) in SEED.items():
bind.exec_driver_sql(
"""
bind.execute(
sa.text(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, record_status, updated_at)
VALUES (%s, %s, %s, %s, 'A', now())
VALUES (:key, :value, :value_type, :public, 'A', now())
ON CONFLICT (setting_key) DO UPDATE SET
setting_value = EXCLUDED.setting_value,
value_type = EXCLUDED.value_type,
is_public = EXCLUDED.is_public,
record_status = 'A',
updated_at = now()
""",
(key, value, value_type, public),
"""
),
{"key": key, "value": value, "value_type": value_type, "public": public},
)
+3 -2
View File
@@ -22,10 +22,11 @@ from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from app.postgres import create_postgres_engine
SCHEMA = "han_app"
@@ -347,7 +348,7 @@ class EntityExternalMapping(Base):
class Database:
def __init__(self, url: str) -> None:
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
self.engine: AsyncEngine = create_postgres_engine(url, pool_pre_ping=True)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
+6 -7
View File
@@ -101,7 +101,7 @@ async def refresh_settings_cache(app: FastAPI) -> None:
async with app.state.db.sessions() as db:
app.state.snapshot = await load_settings(db)
except Exception:
log.warning("settings.refresh_failed", event="settings.refresh_failed")
log.warning("settings.refresh_failed")
await asyncio.sleep(30)
@@ -126,12 +126,12 @@ async def lifespan(app: FastAPI):
async with app.state.db.sessions() as db:
app.state.snapshot = await load_settings(db)
except Exception:
structlog.get_logger().warning("settings_warmup_failed", event="settings.warmup_failed")
structlog.get_logger().warning("settings.warmup_failed")
settings_task = asyncio.create_task(refresh_settings_cache(app))
try:
await app.state.jwks.refresh()
except Exception:
structlog.get_logger().warning("jwks_warmup_failed", event="jwks.warmup_failed")
structlog.get_logger().warning("jwks.warmup_failed")
yield
settings_task.cancel()
with suppress(asyncio.CancelledError):
@@ -211,7 +211,6 @@ async def request_context(request: Request, call_next: Any) -> Response:
response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store")
log.info(
"request.complete",
event="request.complete",
status_code=response.status_code,
duration_ms=round((time.monotonic() - request.state.started_at) * 1000, 2),
)
@@ -262,7 +261,7 @@ async def http_error(request: Request, exc: StarletteHTTPException):
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception):
log.exception("request.failed", event="request.failed", error_code="internal_error")
log.exception("request.failed", error_code="internal_error")
return error_response(request, "internal_error", "Internal server error", 500)
@@ -622,7 +621,7 @@ async def dialogs_create(
},
)
except Exception:
log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed")
log.warning("idempotency.cache_write_failed")
return JSONResponse(json.loads(json.dumps(body, default=str)), status_code=status)
@@ -792,7 +791,7 @@ async def message_create(
},
)
except Exception:
log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed")
log.warning("idempotency.cache_write_failed")
return result
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
import asyncpg
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
def asyncpg_dsn(url: str) -> str:
if url.startswith("postgresql+asyncpg://"):
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
return url
def create_postgres_engine(url: str, **engine_options: Any) -> AsyncEngine:
dsn = asyncpg_dsn(url)
async def connect():
return await asyncpg.connect(dsn=dsn)
return create_async_engine(
"postgresql+asyncpg://",
async_creator=connect,
**engine_options,
)
@@ -5,6 +5,7 @@ from pydantic import TypeAdapter, ValidationError
from app.auth import canonical_phone
from app.integrations import CircuitBreaker, RateLimiter
from app.postgres import asyncpg_dsn
from app.schemas import (
FileMessageRequest,
MessageRequest,
@@ -15,6 +16,15 @@ from app.schemas import (
)
def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None:
url = (
"postgresql+asyncpg://user:password@db:5433/han_chat"
"?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem"
)
assert asyncpg_dsn(url) == url.replace("postgresql+asyncpg://", "postgresql://", 1)
def test_phone_claim_priority_and_e164_validation() -> None:
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
assert canonical_phone(claims) == "+74999591007"