From 30774f8c3910f08911ff703fec4a8b32f0e48fb4 Mon Sep 17 00:00:00 2001 From: A Date: Tue, 21 Jul 2026 15:45:16 +0800 Subject: [PATCH] fix: sync StarGift private message references --- ..._star_gift_private_box_local_refs.down.sql | 4 + ...24_star_gift_private_box_local_refs.up.sql | 186 ++++++++++++++++++ internal/store/postgres/message_send.go | 46 ++++- ...star_gift_collectibles_integration_test.go | 4 + .../postgres/star_gift_craft_projection.go | 5 +- .../store/postgres/star_gift_entitlements.go | 5 + .../store/postgres/star_gift_lifecycle.go | 9 +- .../star_gift_lifecycle_integration_test.go | 74 +++++++ ...ft_lifecycle_migration_integration_test.go | 4 +- .../postgres/star_gift_lifecycle_test.go | 96 +++++++++ .../postgres/star_gift_private_projection.go | 151 ++++++++++++++ internal/store/postgres/star_gift_upgrade.go | 11 +- 12 files changed, 580 insertions(+), 15 deletions(-) create mode 100644 deploy/migrations/0124_star_gift_private_box_local_refs.down.sql create mode 100644 deploy/migrations/0124_star_gift_private_box_local_refs.up.sql create mode 100644 internal/store/postgres/star_gift_lifecycle_test.go create mode 100644 internal/store/postgres/star_gift_private_projection.go diff --git a/deploy/migrations/0124_star_gift_private_box_local_refs.down.sql b/deploy/migrations/0124_star_gift_private_box_local_refs.down.sql new file mode 100644 index 00000000..0029550e --- /dev/null +++ b/deploy/migrations/0124_star_gift_private_box_local_refs.down.sql @@ -0,0 +1,4 @@ +-- This migration emits durable per-user edit_message events. Reverting the +-- repaired ids or rewinding pts would reintroduce cross-account references and +-- create holes in updates.getDifference, so rollback intentionally preserves +-- both the corrected snapshots and their update facts. diff --git a/deploy/migrations/0124_star_gift_private_box_local_refs.up.sql b/deploy/migrations/0124_star_gift_private_box_local_refs.up.sql new file mode 100644 index 00000000..27e127a7 --- /dev/null +++ b/deploy/migrations/0124_star_gift_private_box_local_refs.up.sql @@ -0,0 +1,186 @@ +-- Private message box ids are account-local. Repair user-owned Star Gift +-- service actions that copied the owner's msg_id into both participants' +-- message boxes, and publish durable edit_message facts for already-visible +-- incorrect projections. + +CREATE TEMP TABLE star_gift_box_media_repairs ( + owner_user_id bigint NOT NULL, + box_id integer NOT NULL, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + repaired_media jsonb NOT NULL, + PRIMARY KEY (owner_user_id, box_id) +) ON COMMIT DROP; + +-- An upgrade action points back to the original ordinary gift. user saved_id +-- is a management identity, not a conversation message link: only the current +-- gift owner's box may carry it. The other participant must omit the field. +INSERT INTO star_gift_box_media_repairs ( + owner_user_id, box_id, peer_type, peer_id, repaired_media +) +SELECT unique_box.owner_user_id, + unique_box.box_id, + unique_box.peer_type, + unique_box.peer_id, + CASE + WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set( + unique_box.media, + '{service_action,star_gift_unique,saved_id}', + to_jsonb(gift.msg_id::bigint), + true + ) + ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}' + END +FROM peer_star_gifts gift +JOIN message_boxes upgrade_owner + ON upgrade_owner.owner_user_id = gift.owner_peer_id + AND upgrade_owner.box_id = gift.upgrade_msg_id +JOIN message_boxes unique_box + ON unique_box.message_sender_id = upgrade_owner.message_sender_id + AND unique_box.private_message_id = upgrade_owner.private_message_id +WHERE gift.owner_peer_type = 'user' + AND gift.unique_gift_id IS NOT NULL + AND gift.msg_id > 0 + AND gift.upgrade_msg_id > 0 + AND NOT unique_box.deleted + AND unique_box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND unique_box.media #>> '{service_action,star_gift_unique,upgrade}' = 'true' + AND unique_box.media IS DISTINCT FROM CASE + WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set( + unique_box.media, + '{service_action,star_gift_unique,saved_id}', + to_jsonb(gift.msg_id::bigint), + true + ) + ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}' + END +ON CONFLICT (owner_user_id, box_id) DO UPDATE +SET repaired_media = EXCLUDED.repaired_media; + +-- For every other user-target unique action (transfer, resale, offer accept, +-- craft), the action message itself is the new user saved-gift identity. +-- saved_id is a channel-only field there and must be absent from every box. +INSERT INTO star_gift_box_media_repairs ( + owner_user_id, box_id, peer_type, peer_id, repaired_media +) +SELECT box.owner_user_id, + box.box_id, + box.peer_type, + box.peer_id, + box.media #- '{service_action,star_gift_unique,saved_id}' +FROM message_boxes box +WHERE NOT box.deleted + AND box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user' + AND COALESCE((box.media #>> '{service_action,star_gift_unique,upgrade}')::boolean, false) = false + AND box.media #> '{service_action,star_gift_unique,saved_id}' IS NOT NULL +ON CONFLICT (owner_user_id, box_id) DO UPDATE +SET repaired_media = EXCLUDED.repaired_media; + +-- A separate prepaid-upgrade action points to the same ordinary gift. +-- Telegram defines gift_msg_id as receiver-only, so retain it only in the +-- owner's service-message box and remove it from the payer's outgoing copy. +INSERT INTO star_gift_box_media_repairs ( + owner_user_id, box_id, peer_type, peer_id, repaired_media +) +SELECT prepay_box.owner_user_id, + prepay_box.box_id, + prepay_box.peer_type, + prepay_box.peer_id, + CASE + WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set( + prepay_box.media, + '{service_action,star_gift,gift_msg_id}', + to_jsonb(gift.msg_id::bigint), + true + ) + ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}' + END +FROM peer_star_gifts gift +JOIN message_boxes prepay_owner + ON prepay_owner.owner_user_id = gift.owner_peer_id + AND prepay_owner.media #>> '{service_action,kind}' = 'star_gift' + AND prepay_owner.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true' + AND prepay_owner.media #>> '{service_action,star_gift,upgrade_separate}' = 'true' + AND (prepay_owner.media #>> '{service_action,star_gift,gift_msg_id}')::integer = gift.msg_id + AND (prepay_owner.media #>> '{service_action,star_gift,gift_id}')::bigint = gift.gift_id +JOIN message_boxes prepay_box + ON prepay_box.message_sender_id = prepay_owner.message_sender_id + AND prepay_box.private_message_id = prepay_owner.private_message_id +WHERE gift.owner_peer_type = 'user' + AND gift.msg_id > 0 + AND NOT prepay_box.deleted + AND prepay_box.media IS DISTINCT FROM CASE + WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set( + prepay_box.media, + '{service_action,star_gift,gift_msg_id}', + to_jsonb(gift.msg_id::bigint), + true + ) + ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}' + END +ON CONFLICT (owner_user_id, box_id) DO UPDATE +SET repaired_media = EXCLUDED.repaired_media; + +DO $$ +DECLARE + repair record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; +BEGIN + FOR repair IN + SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media + FROM star_gift_box_media_repairs + ORDER BY owner_user_id, box_id + LOOP + INSERT INTO user_update_watermarks (user_id, contiguous_pts) + VALUES (repair.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = repair.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE message_boxes + SET media = repair.repaired_media, + pts = next_pts + WHERE owner_user_id = repair.owner_user_id + AND box_id = repair.box_id + AND NOT deleted; + + INSERT INTO user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + repair.owner_user_id, next_pts, 1, event_date, 'edit_message', + repair.box_id, repair.peer_type, repair.peer_id + ); + + INSERT INTO dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES (repair.owner_user_id, next_pts, 'edit_message', 0, 0); + END LOOP; +END +$$; + +-- private_messages is the logical shared envelope and cannot contain either +-- participant's local message id. User-visible history/difference always reads +-- the per-owner message_boxes snapshots repaired above. +UPDATE private_messages +SET media = media + #- '{service_action,star_gift,saved_id}' + #- '{service_action,star_gift,gift_msg_id}' + #- '{service_action,star_gift,upgrade_msg_id}' +WHERE media #>> '{service_action,kind}' = 'star_gift' + AND ( + media #> '{service_action,star_gift,peer_user_id}' IS NOT NULL + OR media #>> '{service_action,star_gift,to,Type}' = 'user' + ); + +UPDATE private_messages +SET media = media #- '{service_action,star_gift_unique,saved_id}' +WHERE media #>> '{service_action,kind}' = 'star_gift_unique' + AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user'; diff --git a/internal/store/postgres/message_send.go b/internal/store/postgres/message_send.go index 3ad81aa5..70d83abd 100644 --- a/internal/store/postgres/message_send.go +++ b/internal/store/postgres/message_send.go @@ -117,8 +117,19 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva } type privateSendTxHooks struct { - before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error - after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error + before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error + projectMedia func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) + after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error +} + +// privateSendMediaProjection separates the logical private-message payload +// from the two account-local message-box projections. Most messages use the +// same media for all three fields. Service actions that carry message ids must +// project those ids per account because box ids are not shared by both users. +type privateSendMediaProjection struct { + Shared *domain.MessageMedia + Sender *domain.MessageMedia + Recipient *domain.MessageMedia } func (s *MessageStore) sendPrivateTextWithHooks(ctx context.Context, req domain.SendPrivateTextRequest, hooks privateSendTxHooks) (res domain.SendPrivateTextResult, err error) { @@ -228,7 +239,22 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP return domain.SendPrivateTextResult{}, err } } - mediaJSON, err := encodeMessageMedia(req.Media) + media := privateSendMediaProjection{Shared: req.Media, Sender: req.Media, Recipient: req.Media} + if hooks.projectMedia != nil { + media, err = hooks.projectMedia(ctx, tx, &req) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + } + sharedMediaJSON, err := encodeMessageMedia(media.Shared) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + senderMediaJSON, err := encodeMessageMedia(media.Sender) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + recipientMediaJSON, err := encodeMessageMedia(media.Recipient) if err != nil { return domain.SendPrivateTextResult{}, err } @@ -255,7 +281,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP TtlPeriod: int32(ttlPeriod), ExpiresAt: int32(expiresAt), EntitiesJson: entities, - MediaJson: mediaJSON, + MediaJson: sharedMediaJSON, ReplyMarkupJson: replyMarkupJSON, RichMessageJson: richMessageJSON, ViaBotID: req.ViaBotID, @@ -315,7 +341,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP ExpiresAt: int32(expiresAt), EntitiesJson: entities, Pts: int32(senderPts), - MediaJson: mediaJSON, + MediaJson: senderMediaJSON, ReplyMarkupJson: replyMarkupJSON, RichMessageJson: richMessageJSON, ViaBotID: req.ViaBotID, @@ -323,7 +349,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP Effect: req.Effect, // voice/round 在发送者自己的副本上也保持"未听",直到对端 // readMessageContents 触发 sender 侧清除;发给自己无人可听,恒已读。 - MediaUnread: req.Media.HasUnreadPayload() && !selfMessage, + MediaUnread: media.Sender.HasUnreadPayload() && !selfMessage, ReactionUnread: false, } applyCreateMessageBoxMetadata(&senderArg, senderMeta) @@ -334,7 +360,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP sender := messageFromBoxRow(senderRow) sender.RandomID = req.RandomID // 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。 - if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, req.Media, req.Entities); err != nil { + if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, media.Sender, req.Entities); err != nil { return domain.SendPrivateTextResult{}, err } if err := qtx.UpsertOutboxDialog(ctx, sqlcgen.UpsertOutboxDialogParams{ @@ -388,13 +414,13 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP ExpiresAt: int32(expiresAt), EntitiesJson: entities, Pts: int32(recipientPts), - MediaJson: mediaJSON, + MediaJson: recipientMediaJSON, ReplyMarkupJson: replyMarkupJSON, RichMessageJson: richMessageJSON, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, Effect: req.Effect, - MediaUnread: req.Media.HasUnreadPayload(), + MediaUnread: media.Recipient.HasUnreadPayload(), ReactionUnread: false, } applyCreateMessageBoxMetadata(&recipientArg, recipientMeta) @@ -405,7 +431,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP recipient = messageFromBoxRow(recipientRow) recipient.RandomID = req.RandomID // 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。 - if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, req.Media, req.Entities); err != nil { + if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, media.Recipient, req.Entities); err != nil { return domain.SendPrivateTextResult{}, err } if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{ diff --git a/internal/store/postgres/star_gift_collectibles_integration_test.go b/internal/store/postgres/star_gift_collectibles_integration_test.go index 1a9d6288..a9ee78a3 100644 --- a/internal/store/postgres/star_gift_collectibles_integration_test.go +++ b/internal/store/postgres/star_gift_collectibles_integration_test.go @@ -119,6 +119,10 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { if uniqueAction.SavedID != int64(saved.MsgID) { t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID) } + senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique + if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 { + t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction) + } ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || diff --git a/internal/store/postgres/star_gift_craft_projection.go b/internal/store/postgres/star_gift_craft_projection.go index eed8a216..3ea1d1c9 100644 --- a/internal/store/postgres/star_gift_craft_projection.go +++ b/internal/store/postgres/star_gift_craft_projection.go @@ -149,7 +149,10 @@ WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxI return nil, 0, fmt.Errorf("enqueue craft input edit: %w", err) } if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 { - privateMediaJSON = mediaJSON + privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media) + if err != nil { + return nil, 0, err + } } edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) } diff --git a/internal/store/postgres/star_gift_entitlements.go b/internal/store/postgres/star_gift_entitlements.go index b5f71d1d..dd261b1a 100644 --- a/internal/store/postgres/star_gift_entitlements.go +++ b/internal/store/postgres/star_gift_entitlements.go @@ -134,6 +134,11 @@ VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.F locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, "" result.Saved, result.Balance = locked, balance return nil + }, projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) { + if result.Saved.Owner.Type != domain.PeerTypeUser { + return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil + } + return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID) }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { if req.Owner.Type != domain.PeerTypeChannel { return nil diff --git a/internal/store/postgres/star_gift_lifecycle.go b/internal/store/postgres/star_gift_lifecycle.go index d54c4c4d..80b64069 100644 --- a/internal/store/postgres/star_gift_lifecycle.go +++ b/internal/store/postgres/star_gift_lifecycle.go @@ -1148,8 +1148,15 @@ func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int } func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction { + savedID := saved.SavedID + if to.Type == domain.PeerTypeUser { + // For a user-owned transferred gift the action message itself becomes + // inputSavedStarGiftUser.msg_id. A channel saved_id belongs to a different + // identity namespace and must never leak into the recipient's user view. + savedID = 0 + } return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to, - SavedID: saved.SavedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt, + SavedID: savedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt} } diff --git a/internal/store/postgres/star_gift_lifecycle_integration_test.go b/internal/store/postgres/star_gift_lifecycle_integration_test.go index 8bff57f0..fdec70bd 100644 --- a/internal/store/postgres/star_gift_lifecycle_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_integration_test.go @@ -99,6 +99,39 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) { if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9850 { t.Fatalf("prepay upgrade = %+v err %v", prepaid, err) } + prepaySenderAction := prepaid.Send.SenderMessage.Media.ServiceAction.StarGift + prepayOwnerAction := prepaid.Send.RecipientMessage.Media.ServiceAction.StarGift + if prepaySenderAction == nil || prepayOwnerAction == nil || + prepaySenderAction.GiftMsgID != 0 || + prepayOwnerAction.GiftMsgID != purchased.Send.RecipientMessage.ID { + t.Fatalf("prepay gift_msg_id is not owner-only: sender=%+v owner=%+v purchase=%+v", + prepaySenderAction, prepayOwnerAction, purchased.Send) + } + prepaySenderDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, buyer.ID, prepaid.Send.SenderMessage.Pts-1, 1) + if err != nil || len(prepaySenderDifference) != 1 || prepaySenderDifference[0].Message.Media == nil || + prepaySenderDifference[0].Message.Media.ServiceAction == nil || + prepaySenderDifference[0].Message.Media.ServiceAction.StarGift == nil || + prepaySenderDifference[0].Message.Media.ServiceAction.StarGift.GiftMsgID != 0 { + t.Fatalf("payer prepay difference leaked owner-only gift_msg_id: events=%+v err=%v", prepaySenderDifference, err) + } + prepayOwnerDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, prepaid.Send.RecipientMessage.Pts-1, 1) + if err != nil || len(prepayOwnerDifference) != 1 || prepayOwnerDifference[0].Message.Media == nil || + prepayOwnerDifference[0].Message.Media.ServiceAction == nil || + prepayOwnerDifference[0].Message.Media.ServiceAction.StarGift == nil || + prepayOwnerDifference[0].Message.Media.ServiceAction.StarGift.GiftMsgID != purchased.Send.RecipientMessage.ID { + t.Fatalf("owner prepay difference lost box-local gift_msg_id: events=%+v err=%v", prepayOwnerDifference, err) + } + var sharedPrepayMediaJSON string + if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p +JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id +WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessage.ID).Scan(&sharedPrepayMediaJSON); err != nil { + t.Fatalf("load shared prepay media: %v", err) + } + sharedPrepayMedia, err := decodeMessageMedia(sharedPrepayMediaJSON) + if err != nil || sharedPrepayMedia == nil || sharedPrepayMedia.ServiceAction == nil || + sharedPrepayMedia.ServiceAction.StarGift == nil || sharedPrepayMedia.ServiceAction.StarGift.GiftMsgID != 0 { + t.Fatalf("shared prepay media retained account-local gift_msg_id: media=%+v err=%v", sharedPrepayMedia, err) + } upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2, @@ -111,14 +144,40 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) { t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique) } upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique + senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) || + senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 || ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID || ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade { t.Fatalf("upgrade message linkage = action %+v source edit %+v", upgradeAction, ownerSourceEdit) } + var sharedUpgradeSourceMediaJSON string + if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p +JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id +WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, purchased.Saved.MsgID).Scan(&sharedUpgradeSourceMediaJSON); err != nil { + t.Fatalf("load shared upgraded source media: %v", err) + } + sharedUpgradeSourceMedia, err := decodeMessageMedia(sharedUpgradeSourceMediaJSON) + if err != nil || sharedUpgradeSourceMedia == nil || sharedUpgradeSourceMedia.ServiceAction == nil || + sharedUpgradeSourceMedia.ServiceAction.StarGift == nil || + sharedUpgradeSourceMedia.ServiceAction.StarGift.UpgradeMsgID != 0 || + sharedUpgradeSourceMedia.ServiceAction.StarGift.GiftMsgID != 0 { + t.Fatalf("shared upgraded source media retained account-local message id: media=%+v err=%v", sharedUpgradeSourceMedia, err) + } + var sharedUpgradeMediaJSON string + if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p +JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id +WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMessage.ID).Scan(&sharedUpgradeMediaJSON); err != nil { + t.Fatalf("load shared upgrade media: %v", err) + } + sharedUpgradeMedia, err := decodeMessageMedia(sharedUpgradeMediaJSON) + if err != nil || sharedUpgradeMedia == nil || sharedUpgradeMedia.ServiceAction == nil || + sharedUpgradeMedia.ServiceAction.StarGiftUnique == nil || sharedUpgradeMedia.ServiceAction.StarGiftUnique.SavedID != 0 { + t.Fatalf("shared upgrade media retained account-local saved_id: media=%+v err=%v", sharedUpgradeMedia, err) + } dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{ UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, ChargeStars: 25, FormID: 11003, CommandKey: "drop-" + suffix, Date: now + 3, @@ -359,6 +418,21 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 { t.Fatalf("burned input message projection = %+v", burnedInputAction) } + for _, edit := range []domain.EditedMessageForUser{craftedInputEdit, burnedInputEdit} { + var sharedCraftInputMediaJSON string + if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p +JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id +WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&sharedCraftInputMediaJSON); err != nil { + t.Fatalf("load shared craft input media for box %d: %v", edit.Message.ID, err) + } + sharedCraftInputMedia, err := decodeMessageMedia(sharedCraftInputMediaJSON) + if err != nil || sharedCraftInputMedia == nil || sharedCraftInputMedia.ServiceAction == nil || + sharedCraftInputMedia.ServiceAction.StarGiftUnique == nil || + sharedCraftInputMedia.ServiceAction.StarGiftUnique.SavedID != 0 { + t.Fatalf("shared craft input retained account-local saved_id for box %d: media=%+v err=%v", + edit.Message.ID, sharedCraftInputMedia, err) + } + } craftReq := domain.StarGiftCraftRequest{UserID: owner.ID, Refs: []domain.SavedStarGiftRef{ {Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index 93f42293..8fe4e5c6 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) { if err != nil { t.Fatalf("migrate star gift lifecycle schema: %v", err) } - if status.Dirty || status.Empty || status.Version != 121 { - t.Fatalf("migration status = %+v, want clean version 121", status) + if status.Dirty || status.Empty || status.Version != 124 { + t.Fatalf("migration status = %+v, want clean version 124", status) } } diff --git a/internal/store/postgres/star_gift_lifecycle_test.go b/internal/store/postgres/star_gift_lifecycle_test.go new file mode 100644 index 00000000..04190537 --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle_test.go @@ -0,0 +1,96 @@ +package postgres + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestTransferUniqueActionSavedIDNamespace(t *testing.T) { + saved := domain.SavedStarGift{SavedID: 42} + unique := domain.UniqueStarGift{ID: 7} + user := domain.Peer{Type: domain.PeerTypeUser, ID: 100} + channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200} + + if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 { + t.Fatalf("user transfer action leaked channel saved_id: %+v", action) + } + if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID { + t.Fatalf("channel transfer action lost channel saved_id: %+v", action) + } +} + +func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) { + ordinary := &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, + StarGift: &domain.MessageStarGiftAction{ + PeerUserID: 9, + SavedID: 10, GiftMsgID: 11, UpgradeMsgID: 12, + }, + }, + } + encoded, err := encodeSharedPrivateStarGiftMedia(ordinary) + if err != nil { + t.Fatalf("encode ordinary shared projection: %v", err) + } + sharedOrdinary, err := decodeMessageMedia(string(encoded)) + if err != nil { + t.Fatalf("decode ordinary shared projection: %v", err) + } + ordinaryAction := sharedOrdinary.ServiceAction.StarGift + if ordinaryAction.SavedID != 0 || ordinaryAction.GiftMsgID != 0 || ordinaryAction.UpgradeMsgID != 0 { + t.Fatalf("ordinary shared projection retained box-local refs: %+v", ordinaryAction) + } + if original := ordinary.ServiceAction.StarGift; original.SavedID != 10 || original.GiftMsgID != 11 || original.UpgradeMsgID != 12 { + t.Fatalf("ordinary source projection was mutated: %+v", original) + } + + unique := &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, + StarGiftUnique: &domain.MessageStarGiftUniqueAction{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 9}, SavedID: 13, + }, + }, + } + encoded, err = encodeSharedPrivateStarGiftMedia(unique) + if err != nil { + t.Fatalf("encode unique shared projection: %v", err) + } + sharedUnique, err := decodeMessageMedia(string(encoded)) + if err != nil { + t.Fatalf("decode unique shared projection: %v", err) + } + if action := sharedUnique.ServiceAction.StarGiftUnique; action.SavedID != 0 { + t.Fatalf("unique shared projection retained user saved_id: %+v", action) + } + if unique.ServiceAction.StarGiftUnique.SavedID != 13 { + t.Fatalf("unique source projection was mutated: %+v", unique.ServiceAction.StarGiftUnique) + } +} + +func TestEncodeSharedPrivateStarGiftMediaPreservesChannelSavedID(t *testing.T) { + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, + StarGiftUnique: &domain.MessageStarGiftUniqueAction{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}, SavedID: 14, + }, + }, + } + encoded, err := encodeSharedPrivateStarGiftMedia(media) + if err != nil { + t.Fatalf("encode channel shared projection: %v", err) + } + shared, err := decodeMessageMedia(string(encoded)) + if err != nil { + t.Fatalf("decode channel shared projection: %v", err) + } + if action := shared.ServiceAction.StarGiftUnique; action.SavedID != 14 { + t.Fatalf("channel shared projection lost saved_id: %+v", action) + } +} diff --git a/internal/store/postgres/star_gift_private_projection.go b/internal/store/postgres/star_gift_private_projection.go new file mode 100644 index 00000000..2fcfc665 --- /dev/null +++ b/internal/store/postgres/star_gift_private_projection.go @@ -0,0 +1,151 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +// projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source +// message identity only in the gift owner's message-box projection. Telegram +// defines gift_msg_id as receiver-only, and TDesktop also treats user unique +// saved_id as an inputSavedStarGiftUser identity. A non-owner counterpart box +// id is therefore not a valid substitute: it could resolve to an unrelated +// gift owned by that viewer. The shared private_messages row omits the local +// reference for the same reason. +func projectPrivateStarGiftSourceRef( + _ context.Context, + _ pgx.Tx, + req *domain.SendPrivateTextRequest, + sourceOwnerUserID int64, + sourceOwnerBoxID int, +) (privateSendMediaProjection, error) { + if req == nil || req.Media == nil || sourceOwnerUserID <= 0 || sourceOwnerBoxID <= 0 || + (sourceOwnerUserID != req.SenderUserID && sourceOwnerUserID != req.RecipientUserID) { + return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: invalid scope") + } + + shared, err := cloneMessageMedia(req.Media) + if err != nil { + return privateSendMediaProjection{}, err + } + sender, err := cloneMessageMedia(req.Media) + if err != nil { + return privateSendMediaProjection{}, err + } + recipient, err := cloneMessageMedia(req.Media) + if err != nil { + return privateSendMediaProjection{}, err + } + + switch { + case privateStarGiftAction(shared) != nil: + sharedAction := privateStarGiftAction(shared) + senderAction := privateStarGiftAction(sender) + recipientAction := privateStarGiftAction(recipient) + if sharedAction.GiftMsgID != sourceOwnerBoxID { + return privateSendMediaProjection{}, fmt.Errorf( + "project private star gift source: gift_msg_id %d does not match owner box %d", + sharedAction.GiftMsgID, sourceOwnerBoxID, + ) + } + sharedAction.GiftMsgID = 0 + senderAction.GiftMsgID = 0 + recipientAction.GiftMsgID = 0 + if req.SenderUserID == sourceOwnerUserID { + senderAction.GiftMsgID = sourceOwnerBoxID + } else { + recipientAction.GiftMsgID = sourceOwnerBoxID + } + case privateStarGiftUniqueAction(shared) != nil: + sharedAction := privateStarGiftUniqueAction(shared) + senderAction := privateStarGiftUniqueAction(sender) + recipientAction := privateStarGiftUniqueAction(recipient) + if sharedAction.Peer.Type != domain.PeerTypeUser || sharedAction.Peer.ID != sourceOwnerUserID || + sharedAction.SavedID != int64(sourceOwnerBoxID) { + return privateSendMediaProjection{}, fmt.Errorf( + "project private unique star gift source: saved_id %d does not match owner box %d", + sharedAction.SavedID, sourceOwnerBoxID, + ) + } + sharedAction.SavedID = 0 + senderAction.SavedID = 0 + recipientAction.SavedID = 0 + if req.SenderUserID == sourceOwnerUserID { + senderAction.SavedID = int64(sourceOwnerBoxID) + } else { + recipientAction.SavedID = int64(sourceOwnerBoxID) + } + default: + return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media") + } + + return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil +} + +func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) { + encoded, err := encodeMessageMedia(media) + if err != nil { + return nil, fmt.Errorf("clone private message media: %w", err) + } + cloned, err := decodeMessageMedia(string(encoded)) + if err != nil { + return nil, fmt.Errorf("clone private message media: %w", err) + } + return cloned, nil +} + +// encodeSharedPrivateStarGiftMedia returns the logical private-message +// envelope for an already viewpoint-projected Star Gift service message. +// Conversation message ids belong to a single owner's message_boxes +// namespace, so the shared row must never retain them. saved_id is likewise +// box-local for user gifts, while channel saved ids remain globally meaningful +// inside the channel gift namespace. +func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error) { + shared, err := cloneMessageMedia(media) + if err != nil { + return nil, err + } + + switch { + case privateStarGiftAction(shared) != nil: + action := privateStarGiftAction(shared) + action.GiftMsgID = 0 + action.UpgradeMsgID = 0 + if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser { + action.SavedID = 0 + } + case privateStarGiftUniqueAction(shared) != nil: + action := privateStarGiftUniqueAction(shared) + if action.Peer.Type == domain.PeerTypeUser { + action.SavedID = 0 + } + default: + return nil, fmt.Errorf("encode shared private star gift media: unsupported media") + } + + encoded, err := encodeMessageMedia(shared) + if err != nil { + return nil, fmt.Errorf("encode shared private star gift media: %w", err) + } + return encoded, nil +} + +func privateStarGiftAction(media *domain.MessageMedia) *domain.MessageStarGiftAction { + if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGift { + return nil + } + return media.ServiceAction.StarGift +} + +func privateStarGiftUniqueAction(media *domain.MessageMedia) *domain.MessageStarGiftUniqueAction { + if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique { + return nil + } + return media.ServiceAction.StarGiftUnique +} diff --git a/internal/store/postgres/star_gift_upgrade.go b/internal/store/postgres/star_gift_upgrade.go index d7cfe383..56c89d93 100644 --- a/internal/store/postgres/star_gift_upgrade.go +++ b/internal/store/postgres/star_gift_upgrade.go @@ -230,6 +230,12 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For } return nil }, + projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) { + if result.Saved.Owner.Type != domain.PeerTypeUser { + return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil + } + return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID) + }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { ownerMessageID := sent.RecipientMessage.ID if saved.FromUserID == req.UserID { @@ -434,7 +440,10 @@ WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxI return nil, fmt.Errorf("enqueue star gift source edit: %w", err) } if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 { - privateMediaJSON = mediaJSON + privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media) + if err != nil { + return nil, err + } } edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) }