From d15f2d2bade7aa6ef176d01e18cf0eb0bd33731f Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Wed, 4 Feb 2026 14:54:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../17_20260204143416_add_check_constraint.py | 120 ++++++++++++++++++ src/usecase/channel/create_channels.py | 18 +-- .../adapter/database/update_channel.go | 53 ++++++++ .../internal/usecase/fetch_channel_meta.go | 2 +- .../usecase/fetch_channel_meta_by_invite.go | 2 +- tg_parser/internal/usecase/fetch_channels.go | 2 + 6 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 migrations/models/17_20260204143416_add_check_constraint.py diff --git a/migrations/models/17_20260204143416_add_check_constraint.py b/migrations/models/17_20260204143416_add_check_constraint.py new file mode 100644 index 0000000..cea251d --- /dev/null +++ b/migrations/models/17_20260204143416_add_check_constraint.py @@ -0,0 +1,120 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + -- Drop old constraint (replaced by stricter one) + ALTER TABLE "channel" DROP CONSTRAINT IF EXISTS "channel_identity_required"; + + -- Add CHECK constraint: channel must have exactly one of username or invite_link + ALTER TABLE "channel" + ADD CONSTRAINT "check_channel_username_xor_invite_link" + CHECK ( + (username IS NOT NULL AND invite_link IS NULL) OR + (username IS NULL AND invite_link IS NOT NULL) + ); + + COMMENT ON CONSTRAINT "check_channel_username_xor_invite_link" ON "channel" + IS 'Ensures channel has exactly one identifier: username OR invite_link (not both, not neither)'; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" + DROP CONSTRAINT IF EXISTS "check_channel_username_xor_invite_link"; + """ + + +MODELS_STATE = ( + "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" + "cQREBB" +) diff --git a/src/usecase/channel/create_channels.py b/src/usecase/channel/create_channels.py index 9acef2f..50ed819 100644 --- a/src/usecase/channel/create_channels.py +++ b/src/usecase/channel/create_channels.py @@ -24,7 +24,7 @@ async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dt raise ValueError('Telegram channel not found by invite link') parsed_username = parser_response.username or None - is_private = channel_input.invite_link != "" + is_private = channel_input.invite_link != '' channel = await self.database.get_channel(telegram_id=parser_response.telegram_id) if not channel and parsed_username: @@ -34,9 +34,16 @@ async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dt if channel: status = 'updated' updated = False + # Канал стал публичным (появился username) if parsed_username is not None and channel.username != parsed_username: channel.username = parsed_username + channel.invite_link = None # Очищаем invite_link у публичных каналов updated = True + # Канал остаётся приватным или обновляется + elif parsed_username is None and channel_input.invite_link and channel.invite_link != channel_input.invite_link: + channel.invite_link = channel_input.invite_link + updated = True + if parser_response.title is not None and channel.title != parser_response.title: channel.title = parser_response.title updated = True @@ -49,12 +56,6 @@ async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dt if parser_response.pts is not None and channel.pts != parser_response.pts: channel.pts = parser_response.pts updated = True - if is_private and channel.pts != 0: - channel.pts = 0 - updated = True - if channel_input.invite_link and channel.invite_link != channel_input.invite_link: - channel.invite_link = channel_input.invite_link - updated = True if updated: await self.database.update_channel(channel) else: @@ -64,7 +65,8 @@ async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dt title=parser_response.title, access_hash=parser_response.access_hash, pts=0 if is_private else parser_response.pts, - invite_link=channel_input.invite_link, + # Для публичных каналов (есть username) invite_link не храним + invite_link=None if parsed_username else channel_input.invite_link, ) await self.database.create_channel(channel) diff --git a/tg_parser/internal/adapter/database/update_channel.go b/tg_parser/internal/adapter/database/update_channel.go index 34d6ff7..992ef17 100644 --- a/tg_parser/internal/adapter/database/update_channel.go +++ b/tg_parser/internal/adapter/database/update_channel.go @@ -10,6 +10,59 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +func (d *Database) UpdateChannelIfNotAccessible(ctx context.Context, channel domain.Channel) error { + query := `UPDATE channel +SET telegram_id = $2, + username = $3, + title = $4, + access_hash = $5, + pts = 0, + is_accessible = TRUE, + invite_link = $6, + updated_at = CURRENT_TIMESTAMP +WHERE telegram_id = $1 + AND is_accessible = FALSE;` + + txOrPool := transaction.TryExtractTX(ctx) + + username := strings.TrimSpace(channel.Username) + var usernameDTO pgtype.Text + if username != "" { + usernameDTO = pgtype.Text{String: username, Valid: true} + } else { + usernameDTO = pgtype.Text{Valid: false} + } + + inviteLink := strings.TrimSpace(channel.InviteLink) + var inviteLinkDTO pgtype.Text + if inviteLink != "" { + inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true} + } else { + inviteLinkDTO = pgtype.Text{Valid: false} + } + + result, err := txOrPool.Exec(ctx, query, + channel.TelegramID, + pgtype.Int8{Int64: channel.TelegramID, Valid: true}, + usernameDTO, + pgtype.Text{String: channel.Title, Valid: true}, + pgtype.Int8{Int64: channel.AccessHash, Valid: true}, + inviteLinkDTO, + ) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + // Log if no rows were updated (channel either doesn't exist or is already accessible) + rowsAffected := result.RowsAffected() + if rowsAffected == 0 { + // Channel doesn't exist or is already accessible - this is fine + return nil + } + + return nil +} + func (d *Database) UpdateChannel(ctx context.Context, channel domain.Channel) error { query := `UPDATE channel SET telegram_id = $2, diff --git a/tg_parser/internal/usecase/fetch_channel_meta.go b/tg_parser/internal/usecase/fetch_channel_meta.go index 0e790ce..cdf07de 100644 --- a/tg_parser/internal/usecase/fetch_channel_meta.go +++ b/tg_parser/internal/usecase/fetch_channel_meta.go @@ -13,7 +13,7 @@ func (uc *UseCase) FetchChannelMeta(ctx context.Context, username string) (domai return domain.Channel{}, fmt.Errorf("uc.telegram.ParseChannelMeta: %s", err) } - err = uc.database.UpdateChannelIfNotAccessible(ctx, channel.TelegramID) + err = uc.database.UpdateChannelIfNotAccessible(ctx, channel) if err != nil { return domain.Channel{}, fmt.Errorf("uc.database.UpdateChannelIfNotAccessible: %s", err) } diff --git a/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go index 6f65e11..12bdfdd 100644 --- a/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go +++ b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go @@ -13,7 +13,7 @@ func (uc *UseCase) FetchChannelMetaByInvite(ctx context.Context, inviteLink stri return domain.Channel{}, fmt.Errorf("uc.telegram.ParseChannelMetaByInvite: %s", err) } - err = uc.database.UpdateChannelIfNotAccessible(ctx, channel.TelegramID) + err = uc.database.UpdateChannelIfNotAccessible(ctx, channel) if err != nil { return domain.Channel{}, fmt.Errorf("uc.database.UpdateChannelIfNotAccessible: %s", err) } diff --git a/tg_parser/internal/usecase/fetch_channels.go b/tg_parser/internal/usecase/fetch_channels.go index 8ff62f8..a4d9a11 100644 --- a/tg_parser/internal/usecase/fetch_channels.go +++ b/tg_parser/internal/usecase/fetch_channels.go @@ -14,6 +14,7 @@ import ( func (uc *UseCase) FetchChannels(ctx context.Context) error { channels := uc.database.GetChannels(ctx) + log.Debug().Msg("start fetch channels") for _, c := range channels { if c.Pts == 0 { @@ -115,6 +116,7 @@ func (uc *UseCase) rejoinChannel(ctx context.Context, channel domain.Channel) { } func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) { + log.Debug().Stringer("ch", channel).Msg("init channel") var ( updated domain.Channel err error