From 5156b17c1b3dfd7256cc79972cb78191781147e4 Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:22:34 +0800 Subject: [PATCH] fix(usernames): sync index active collectible aliases --- .../0160_peer_username_search.down.sql | 1 + .../0160_peer_username_search.up.sql | 6 + internal/app/channels/service.go | 5 +- internal/app/channels/service_test.go | 52 +++++- internal/app/contacts/service_test.go | 40 +++++ internal/app/users/service.go | 6 +- internal/app/users/service_test.go | 28 +++ .../rpc/collectible_usernames_rpc_test.go | 39 +++-- internal/rpc/contacts_users_rpc_test.go | 30 +++- internal/rpc/fragment.go | 20 ++- internal/rpc/username_notify_test.go | 20 +-- internal/store/collectible_username.go | 3 +- internal/store/memory/channel_core.go | 11 +- internal/store/memory/channel_helpers.go | 17 +- internal/store/memory/channel_members.go | 2 +- .../store/memory/channel_message_history.go | 2 +- internal/store/memory/channel_settings.go | 22 ++- internal/store/memory/channel_store.go | 11 +- internal/store/memory/collectible_username.go | 55 ++++++ internal/store/memory/community.go | 2 +- internal/store/memory/users.go | 43 ++++- internal/store/postgres/channel_core.go | 11 +- internal/store/postgres/channel_helpers.go | 33 +++- internal/store/postgres/channel_settings.go | 8 +- .../collectible_username_integration_test.go | 159 ++++++++++++++++++ internal/store/postgres/peer_username.go | 33 ++++ internal/store/postgres/queries/user.sql | 17 +- internal/store/postgres/sqlcgen/user.sql.go | 53 +++--- internal/web/server.go | 40 ++--- 29 files changed, 657 insertions(+), 112 deletions(-) create mode 100644 deploy/migrations/0160_peer_username_search.down.sql create mode 100644 deploy/migrations/0160_peer_username_search.up.sql diff --git a/deploy/migrations/0160_peer_username_search.down.sql b/deploy/migrations/0160_peer_username_search.down.sql new file mode 100644 index 00000000..8f49984d --- /dev/null +++ b/deploy/migrations/0160_peer_username_search.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS public.peer_usernames_active_search_idx; diff --git a/deploy/migrations/0160_peer_username_search.up.sql b/deploy/migrations/0160_peer_username_search.up.sql new file mode 100644 index 00000000..b48b3ff5 --- /dev/null +++ b/deploy/migrations/0160_peer_username_search.up.sql @@ -0,0 +1,6 @@ +-- Active usernames are searched by peer type and a case-normalized prefix. +-- text_pattern_ops keeps the lookup indexable even when the database collation +-- cannot use a regular btree index for LIKE 'prefix%'. +CREATE INDEX peer_usernames_active_search_idx + ON public.peer_usernames (peer_type, username_lower text_pattern_ops, peer_id) + WHERE active; diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index 48d86681..f79d1ccf 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -597,7 +597,10 @@ func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, usern return domain.Channel{}, false, domain.ErrChannelInvalid } username = normalizeChannelUsername(username) - if !validChannelUsername(username) { + // Public resolution also covers Fragment-style collectible usernames, + // whose protocol minimum is four characters. Channel username mutation + // remains on the ordinary 5..32 validation path above. + if !domain.ValidCollectibleUsername(username) { return domain.Channel{}, false, domain.ErrUsernameInvalid } return s.channels.ResolvePublicChannelUsername(ctx, userID, username) diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go index 427462ca..a8e7f908 100644 --- a/internal/app/channels/service_test.go +++ b/internal/app/channels/service_test.go @@ -2904,7 +2904,10 @@ func TestListSendAsChannelsFiltersPostMessageRights(t *testing.T) { func TestPublicChannelSearchAndResolveUsername(t *testing.T) { ctx := context.Background() - service := NewService(memory.NewChannelStore()) + channelStore := memory.NewChannelStore() + registry := memory.NewCollectibleUsernameStore() + channelStore.AttachUsernameRegistry(registry) + service := NewService(channelStore) created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ Title: "CU Public Lab", MemberUserIDs: []int64{1002}, @@ -2945,6 +2948,53 @@ func TestPublicChannelSearchAndResolveUsername(t *testing.T) { if err != nil || !found || resolved.ID != public.ID { t.Fatalf("ResolvePublicUsername = %+v found %v err %v, want public channel", resolved, found, err) } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: public.ID} + if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{ + Username: "nfc4", + Owner: peer, + Currency: domain.CollectibleCurrencyStars, + Amount: 1, + Actor: "test", + }); err != nil || !created { + t.Fatalf("mint channel collectible: created=%v err=%v", created, err) + } + resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "@NFC4") + if err != nil || !found || resolved.ID != public.ID { + t.Fatalf("ResolvePublicUsername collectible = %+v found %v err %v, want public channel", resolved, found, err) + } + collectibleSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc", 10) + if err != nil || len(collectibleSearch.Results) != 1 || collectibleSearch.Results[0].ID != public.ID { + t.Fatalf("collectible channel search = %+v err=%v, want public channel", collectibleSearch, err) + } + if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + ChannelID: public.ID, + Username: "", + }); err != nil { + t.Fatalf("clear editable username: %v", err) + } + resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "nfc4") + if err != nil || !found || resolved.ID != public.ID { + t.Fatalf("NFT-only ResolvePublicUsername = %+v found=%v err=%v", resolved, found, err) + } + if view, err := service.GetChannel(ctx, 1003, public.ID); err != nil || view.Channel.ID != public.ID { + t.Fatalf("NFT-only public preview = %+v err=%v", view, err) + } + if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + ChannelID: public.ID, + Username: "cu_public_lab", + }); err != nil { + t.Fatalf("restore editable username: %v", err) + } + if changed, err := registry.SetUsernameActive(ctx, peer, "nfc4", false); err != nil || !changed { + t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err) + } + if _, found, err := service.ResolvePublicUsername(ctx, 1003, "nfc4"); err != nil || found { + t.Fatalf("inactive collectible resolve found=%v err=%v, want hidden", found, err) + } + hiddenSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc4", 10) + if err != nil || len(hiddenSearch.Results) != 0 { + t.Fatalf("inactive collectible search = %+v err=%v, want empty", hiddenSearch, err) + } } func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) { diff --git a/internal/app/contacts/service_test.go b/internal/app/contacts/service_test.go index 17c9d705..c168edbc 100644 --- a/internal/app/contacts/service_test.go +++ b/internal/app/contacts/service_test.go @@ -644,6 +644,46 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) { } } +func TestSearchFindsOnlyActiveCollectibleUsernames(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + registry := memory.NewCollectibleUsernameStore() + users.AttachUsernameRegistry(registry) + viewer, err := users.Create(ctx, domain.User{Phone: "15550000101", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + target, err := users.Create(ctx, domain.User{Phone: "15550000102", FirstName: "Unrelated", Username: "target_slot"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: target.ID} + if _, err := registry.SetEditableUsername(ctx, peer, target.Username); err != nil { + t.Fatalf("seed editable username: %v", err) + } + if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{ + Username: "nft4", + Owner: peer, + Currency: domain.CollectibleCurrencyStars, + Amount: 1, + Actor: "test", + }); err != nil || !created { + t.Fatalf("mint collectible: created=%v err=%v", created, err) + } + svc := NewService(memory.NewContactStore(), users) + found, err := svc.Search(ctx, viewer.ID, "@NFT4", 10) + if err != nil || len(found.Results) != 1 || found.Results[0].ID != target.ID { + t.Fatalf("search active collectible = %+v err=%v, want target", found, err) + } + if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed { + t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err) + } + hidden, err := svc.Search(ctx, viewer.ID, "nft4", 10) + if err != nil || len(hidden.Results) != 0 || len(hidden.MyResults) != 0 { + t.Fatalf("search inactive collectible = %+v err=%v, want empty", hidden, err) + } +} + func contactByID(t *testing.T, list domain.ContactList, id int64) domain.Contact { t.Helper() for _, contact := range list.Contacts { diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 97703e97..516476df 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -577,7 +577,11 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user return domain.User{}, false, err } username = normalizeUsername(username) - if !validUsername(username) { + // Resolution covers both the editable username slot (5..32) and + // Fragment-style collectible usernames (4..32). Keep the stricter + // validUsername check on create/update paths; only lookup accepts the + // collectible lower bound. + if !domain.ValidCollectibleUsername(username) { return domain.User{}, false, domain.ErrUsernameInvalid } u, found, err := s.users.ByUsername(ctx, username) diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go index f515a195..61499da5 100644 --- a/internal/app/users/service_test.go +++ b/internal/app/users/service_test.go @@ -45,6 +45,34 @@ func TestServiceUsernameLifecycle(t *testing.T) { if err != nil || !found || resolved.ID != owner.ID { t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err) } + registry := memory.NewCollectibleUsernameStore() + store.AttachUsernameRegistry(registry) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := registry.SetEditableUsername(ctx, peer, updated.Username); err != nil { + t.Fatalf("seed editable username registry: %v", err) + } + if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{ + Username: "nft4", + Owner: peer, + Currency: domain.CollectibleCurrencyStars, + Amount: 1, + Actor: "test", + }); err != nil || !created { + t.Fatalf("mint four-character collectible: created=%v err=%v", created, err) + } + resolved, found, err = svc.ResolveUsername(ctx, other.ID, "@NFT4") + if err != nil || !found || resolved.ID != owner.ID { + t.Fatalf("ResolveUsername collectible = user %+v found %v err %v, want owner", resolved, found, err) + } + if _, err := svc.UpdateUsername(ctx, owner.ID, "nft4"); !errors.Is(err, domain.ErrUsernameInvalid) { + t.Fatalf("four-character editable username err = %v, want username invalid", err) + } + if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed { + t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err) + } + if _, found, err := svc.ResolveUsername(ctx, other.ID, "nft4"); err != nil || found { + t.Fatalf("inactive collectible found=%v err=%v, want hidden", found, err) + } if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) { t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err) } diff --git a/internal/rpc/collectible_usernames_rpc_test.go b/internal/rpc/collectible_usernames_rpc_test.go index 9a3979a5..7cc54245 100644 --- a/internal/rpc/collectible_usernames_rpc_test.go +++ b/internal/rpc/collectible_usernames_rpc_test.go @@ -234,12 +234,12 @@ func TestUsersGetUsersProjectsCollectibleUsernamesInOneBatch(t *testing.T) { registry := newFakeUsernameRegistry() f := newUsernameProjectionFixture(t, registry) registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}] = []domain.Username{ - {Username: "owner_slot", Editable: true, Active: true}, - {Username: "nft", Active: true, SortOrder: 0, CollectibleID: 7}, + {Username: "owner_slot", Editable: true, Active: true, SortOrder: 1}, + {Username: "nft4", Active: true, SortOrder: 0, CollectibleID: 7}, } registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.friend.ID}] = []domain.Username{ {Username: "friend_slot", Editable: true, Active: true}, - {Username: "gem", Active: false, SortOrder: 0, CollectibleID: 8}, + {Username: "gem4", Active: false, SortOrder: 1, CollectibleID: 8}, } ctx := WithUserID(context.Background(), f.owner.ID) @@ -254,26 +254,29 @@ func TestUsersGetUsersProjectsCollectibleUsernamesInOneBatch(t *testing.T) { t.Fatalf("users = %d, want 2", len(out)) } self := out[0].(*tg.User) - if _, ok := self.GetUsername(); ok { - t.Fatalf("self scalar username is set together with collectible vector") + if scalar, ok := self.GetUsername(); !ok || scalar != "nft4" { + t.Fatalf("self scalar username = %q (set %v), want primary collectible nft4", scalar, ok) } vector, ok := self.GetUsernames() if !ok { t.Fatalf("self usernames unset, want registry vector") } - if got := usernameStrings(vector); len(got) != 2 || got[0] != "owner_slot" || got[1] != "nft" { - t.Fatalf("self usernames = %v, want [owner_slot nft]", got) + if got := usernameStrings(vector); len(got) != 2 || got[0] != "nft4" || got[1] != "owner_slot" { + t.Fatalf("self usernames = %v, want [nft4 owner_slot]", got) } - if !vector[0].Editable || !vector[0].Active { - t.Fatalf("editable slot flags = %+v, want editable+active", vector[0]) + if vector[0].Editable || !vector[0].Active { + t.Fatalf("primary collectible flags = %+v, want non-editable+active", vector[0]) } - if vector[1].Editable || !vector[1].Active { - t.Fatalf("collectible flags = %+v, want non-editable+active", vector[1]) + if !vector[1].Editable || !vector[1].Active { + t.Fatalf("editable slot flags = %+v, want editable+active", vector[1]) } friend := out[1].(*tg.User) + if scalar, ok := friend.GetUsername(); !ok || scalar != "friend_slot" { + t.Fatalf("friend scalar username = %q (set %v), want friend_slot", scalar, ok) + } friendVector, _ := friend.GetUsernames() - if got := usernameStrings(friendVector); len(got) != 2 || got[1] != "gem" { - t.Fatalf("friend usernames = %v, want [friend_slot gem]", got) + if got := usernameStrings(friendVector); len(got) != 2 || got[1] != "gem4" { + t.Fatalf("friend usernames = %v, want [friend_slot gem4]", got) } if friendVector[1].Active { t.Fatalf("inactive collectible projected active: %+v", friendVector[1]) @@ -663,7 +666,7 @@ func TestChannelsGetChannelsProjectsCollectibleUsernames(t *testing.T) { r, owner, channel := newCollectibleChannelFixture(t, registry) ctx := WithUserID(context.Background(), owner.ID) registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}] = []domain.Username{ - {Username: "chan_slot", Editable: true, Active: true}, + {Username: "chan_slot", Editable: true, Active: true, SortOrder: 1}, {Username: "chan_nft", Active: true, SortOrder: 0, CollectibleID: 44}, } @@ -681,11 +684,11 @@ func TestChannelsGetChannelsProjectsCollectibleUsernames(t *testing.T) { if !ok { t.Fatalf("channel usernames unset, want registry vector") } - if got := usernameStrings(vector); len(got) != 2 || got[0] != "chan_slot" || got[1] != "chan_nft" { - t.Fatalf("channel usernames = %v, want [chan_slot chan_nft]", got) + if got := usernameStrings(vector); len(got) != 2 || got[0] != "chan_nft" || got[1] != "chan_slot" { + t.Fatalf("channel usernames = %v, want [chan_nft chan_slot]", got) } - if scalar, ok := out[0].(*tg.Channel).GetUsername(); ok || scalar != "" { - t.Fatalf("scalar channel username = %q (set %v), want absent with collectible vector", scalar, ok) + if scalar, ok := out[0].(*tg.Channel).GetUsername(); !ok || scalar != "chan_nft" { + t.Fatalf("scalar channel username = %q (set %v), want primary collectible chan_nft", scalar, ok) } } diff --git a/internal/rpc/contacts_users_rpc_test.go b/internal/rpc/contacts_users_rpc_test.go index 36134a28..0c198c7e 100644 --- a/internal/rpc/contacts_users_rpc_test.go +++ b/internal/rpc/contacts_users_rpc_test.go @@ -13,6 +13,7 @@ import ( appprivacy "telesrv/internal/app/privacy" appstories "telesrv/internal/app/stories" appupdates "telesrv/internal/app/updates" + usernamesapp "telesrv/internal/app/usernames" "telesrv/internal/app/userprojection" appusers "telesrv/internal/app/users" "telesrv/internal/domain" @@ -24,6 +25,8 @@ import ( func TestContactsSearchFindsUsers(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() + registry := memory.NewCollectibleUsernameStore() + users.AttachUsernameRegistry(registry) owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) @@ -32,12 +35,29 @@ func TestContactsSearchFindsUsers(t *testing.T) { if err != nil { t.Fatalf("create friend: %v", err) } + friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID} + if _, err := registry.SetEditableUsername(ctx, friendPeer, friend.Username); err != nil { + t.Fatalf("seed editable username: %v", err) + } + if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{ + Username: "nft4", + Owner: friendPeer, + Currency: domain.CollectibleCurrencyStars, + Amount: 1, + Actor: "test", + }); err != nil || !created { + t.Fatalf("mint collectible: created=%v err=%v", created, err) + } r := New(Config{}, Deps{ Contacts: appcontacts.NewService(memory.NewContactStore(), users), + Usernames: usernamesapp.NewService( + usernamesapp.WithRegistryStore(registry), + usernamesapp.WithCollectibleStore(registry), + ), }, zaptest.NewLogger(t), clock.System) var in bin.Buffer - if err := (&tg.ContactsSearchRequest{Q: "@search", Limit: 20}).Encode(&in); err != nil { + if err := (&tg.ContactsSearchRequest{Q: "@NFT4", Limit: 20}).Encode(&in); err != nil { t.Fatalf("encode request: %v", err) } enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) @@ -55,6 +75,14 @@ func TestContactsSearchFindsUsers(t *testing.T) { if !ok || peer.UserID != friend.ID { t.Fatalf("peer = %T %+v, want friend", box.Results[0], box.Results[0]) } + user := box.Users[0].(*tg.User) + if scalar, ok := user.GetUsername(); !ok || scalar != "search_friend" { + t.Fatalf("search result scalar username = %q (set %v), want search_friend", scalar, ok) + } + vector, ok := user.GetUsernames() + if !ok || len(vector) != 2 || vector[1].Username != "nft4" || !vector[1].Active { + t.Fatalf("search result username vector = %+v (set %v), want active nft4 alias", vector, ok) + } } func TestContactsEditCloseFriendsProjectsUserFlag(t *testing.T) { diff --git a/internal/rpc/fragment.go b/internal/rpc/fragment.go index 997d55cc..d0c16966 100644 --- a/internal/rpc/fragment.go +++ b/internal/rpc/fragment.go @@ -302,8 +302,16 @@ func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPe continue } if vector := tgUsernamesFromRegistry(list, u.Username); len(vector) > 0 { - u.Flags.Unset(3) - u.Username = "" + // Layer 228 defines username as the main active username, not as a + // legacy alternative to usernames. TDesktop seeds its local search + // index from this scalar before consuming the complete vector, so + // both fields must be projected together. + if primary := domain.ActiveUsername(list); primary != "" { + u.SetUsername(primary) + } else { + u.Flags.Unset(3) + u.Username = "" + } u.SetUsernames(vector) } } @@ -320,8 +328,12 @@ func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPe // when unset, which is exactly the fallback tgUsernamesFromRegistry wants. scalar, _ := ch.GetUsername() if vector := tgUsernamesFromRegistry(list, scalar); len(vector) > 0 { - ch.Flags.Unset(6) - ch.Username = "" + if primary := domain.ActiveUsername(list); primary != "" { + ch.SetUsername(primary) + } else { + ch.Flags.Unset(6) + ch.Username = "" + } ch.SetUsernames(vector) } } diff --git a/internal/rpc/username_notify_test.go b/internal/rpc/username_notify_test.go index 51a998fc..0fdb5919 100644 --- a/internal/rpc/username_notify_test.go +++ b/internal/rpc/username_notify_test.go @@ -26,8 +26,8 @@ func TestNotifyPeerUsernamesChangedUserPushesPreloadedVector(t *testing.T) { } registry := newFakeUsernameRegistry() registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: targetID}] = []domain.Username{ - {Username: "owner_slot", Editable: true, Active: true}, - {Username: "nft", Active: true, CollectibleID: 7}, + {Username: "owner_slot", Editable: true, Active: true, SortOrder: 1}, + {Username: "nft4", Active: true, SortOrder: 0, CollectibleID: 7}, } sessions := &captureSessions{onlineUserIDs: []int64{targetID, viewerID}} r := New(Config{}, Deps{Users: users, Usernames: registry, Sessions: sessions}, zap.NewNop(), clock.System) @@ -42,11 +42,11 @@ func TestNotifyPeerUsernamesChangedUserPushesPreloadedVector(t *testing.T) { } updates := sessions.lastUserPush().(*tg.Updates) user := updates.Users[0].(*tg.User) - if scalar, ok := user.GetUsername(); ok || scalar != "" { - t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok) + if scalar, ok := user.GetUsername(); !ok || scalar != "nft4" { + t.Fatalf("pushed scalar username = %q (set %v), want primary collectible nft4", scalar, ok) } vector, ok := user.GetUsernames() - if !ok || len(vector) != 2 || vector[1].Username != "nft" { + if !ok || len(vector) != 2 || vector[0].Username != "nft4" { t.Fatalf("pushed username vector = %+v (set %v)", vector, ok) } } @@ -63,8 +63,8 @@ func TestNotifyPeerUsernamesChangedChannelPushesPreloadedVector(t *testing.T) { }} registry := newFakeUsernameRegistry() registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = []domain.Username{ - {Username: "channel_slot", Editable: true, Active: true}, - {Username: "collectible", Active: true, CollectibleID: 9}, + {Username: "channel_slot", Editable: true, Active: true, SortOrder: 1}, + {Username: "collectible", Active: true, SortOrder: 0, CollectibleID: 9}, } sessions := &captureSessions{ onlineUserIDs: []int64{ownerID, memberID}, @@ -84,11 +84,11 @@ func TestNotifyPeerUsernamesChangedChannelPushesPreloadedVector(t *testing.T) { } updates := sessions.lastUserPush().(*tg.Updates) channel := updates.Chats[0].(*tg.Channel) - if scalar, ok := channel.GetUsername(); ok || scalar != "" { - t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok) + if scalar, ok := channel.GetUsername(); !ok || scalar != "collectible" { + t.Fatalf("pushed scalar username = %q (set %v), want primary collectible", scalar, ok) } vector, ok := channel.GetUsernames() - if !ok || len(vector) != 2 || vector[1].Username != "collectible" { + if !ok || len(vector) != 2 || vector[0].Username != "collectible" { t.Fatalf("pushed username vector = %+v (set %v)", vector, ok) } } diff --git a/internal/store/collectible_username.go b/internal/store/collectible_username.go index d453d47d..fe58d6a0 100644 --- a/internal/store/collectible_username.go +++ b/internal/store/collectible_username.go @@ -8,7 +8,8 @@ import ( // UsernameRegistryStore reads a peer's full username list. The list is the // projection source for the TL usernames vector, so every caller sees the same -// order the client will render: editable slot first, then collectibles. +// stored order the client will render. Reorder may promote a collectible ahead +// of the editable slot; the first active row is also the Layer 228 main scalar. type UsernameRegistryStore interface { // PeerUsernames returns the peer's registry rows in projection order. PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) diff --git a/internal/store/memory/channel_core.go b/internal/store/memory/channel_core.go index 99f52406..b0214ff5 100644 --- a/internal/store/memory/channel_core.go +++ b/internal/store/memory/channel_core.go @@ -229,8 +229,15 @@ func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domai return cloneChannel(channel), nil } -func publicPreviewableChannel(channel domain.Channel) bool { - return publicSearchableChannel(channel) +func (s *ChannelStore) publicPreviewableChannelLocked(channel domain.Channel) bool { + hasActiveUsername := strings.TrimSpace(channel.Username) != "" + if !hasActiveUsername && s.usernameRegistry != nil { + hasActiveUsername = s.usernameRegistry.peerHasActiveCollectibleUsername(domain.Peer{ + Type: domain.PeerTypeChannel, + ID: channel.ID, + }) + } + return publicSearchableChannel(channel) && hasActiveUsername } func minInt(a, b int) int { diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index 0d79ef0b..13eb2cd3 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -83,6 +83,13 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6 return domain.PublicChannelSearchResult{}, nil } s.mu.RLock() + registry := s.usernameRegistry + s.mu.RUnlock() + var usernameMatches map[int64]int + if registry != nil { + usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeChannel) + } + s.mu.RLock() defer s.mu.RUnlock() type item struct { @@ -92,6 +99,11 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6 items := make([]item, 0, limit) for channelID, channel := range s.channels { rank, ok := publicChannelSearchRank(channel, query) + if usernameRank, matched := usernameMatches[channelID]; matched && + !channel.Deleted && (channel.Broadcast || channel.Megagroup) && + (!ok || usernameRank < rank) { + rank, ok = usernameRank, true + } if !ok { continue } @@ -426,7 +438,7 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C return channel, syntheticMonoforumUserMember(channel, userID), true, nil } } - if !publicPreviewableChannel(channel) { + if !s.publicPreviewableChannelLocked(channel) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate } return channel, publicPreviewMember(channel, userID, existing, found), true, nil @@ -556,8 +568,7 @@ func recommendableChannel(channel domain.Channel) bool { func publicSearchableChannel(channel domain.Channel) bool { return !channel.Deleted && - (channel.Broadcast || channel.Megagroup) && - strings.TrimSpace(channel.Username) != "" + (channel.Broadcast || channel.Megagroup) } func channelRoleOrder(role domain.ChannelMemberRole) int { diff --git a/internal/store/memory/channel_members.go b/internal/store/memory/channel_members.go index 14e63cc0..96be9cc3 100644 --- a/internal/store/memory/channel_members.go +++ b/internal/store/memory/channel_members.go @@ -902,7 +902,7 @@ func (s *ChannelStore) FilterChannelMessageAudienceIDs(_ context.Context, channe if !ok || channel.Deleted { return nil, nil } - public := publicPreviewableChannel(channel) + public := s.publicPreviewableChannelLocked(channel) members := s.members[channelID] out := make([]int64, 0, len(userIDs)) seen := make(map[int64]struct{}, len(userIDs)) diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go index 5a29e800..c7993dd1 100644 --- a/internal/store/memory/channel_message_history.go +++ b/internal/store/memory/channel_message_history.go @@ -224,7 +224,7 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6 } member, ok := s.members[channelID][viewerUserID] joined := ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages - publicPreview := req.AllowPublicPreview && publicPreviewableChannel(channel) && + publicPreview := req.AllowPublicPreview && s.publicPreviewableChannelLocked(channel) && (!ok || member.Status != domain.ChannelMemberKicked && !member.BannedRights.ViewMessages) if !joined && !publicPreview { continue diff --git a/internal/store/memory/channel_settings.go b/internal/store/memory/channel_settings.go index 89fb99a7..0122fc77 100644 --- a/internal/store/memory/channel_settings.go +++ b/internal/store/memory/channel_settings.go @@ -139,7 +139,7 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64, return true, nil } -func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { +func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } @@ -168,6 +168,11 @@ func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChanne } } } + if s.usernameRegistry != nil { + if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, username); err != nil { + return domain.Channel{}, err + } + } prevUsername := channel.Username channel.Username = username s.channels[req.ChannelID] = channel @@ -311,16 +316,27 @@ func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUse return domain.Channel{}, false, nil } s.mu.RLock() - defer s.mu.RUnlock() - + registry := s.usernameRegistry for _, channel := range s.channels { if !publicSearchableChannel(channel) { continue } if strings.ToLower(channel.Username) == username { + s.mu.RUnlock() return cloneChannel(channel), true, nil } } + s.mu.RUnlock() + if registry != nil { + if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeChannel); ok { + s.mu.RLock() + channel, found := s.channels[peer.ID] + s.mu.RUnlock() + if found && !channel.Deleted && (channel.Broadcast || channel.Megagroup) { + return cloneChannel(channel), true, nil + } + } + } return domain.Channel{}, false, nil } diff --git a/internal/store/memory/channel_store.go b/internal/store/memory/channel_store.go index c9a38cf5..5978f3c3 100644 --- a/internal/store/memory/channel_store.go +++ b/internal/store/memory/channel_store.go @@ -101,7 +101,8 @@ type ChannelStore struct { // topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。 topicReads map[int64]map[int64]map[int]memoryTopicRead // polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。 - polls *PollStore + polls *PollStore + usernameRegistry *CollectibleUsernameStore } // AttachPollStore 注入共享 poll 权威。 @@ -109,6 +110,14 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) { s.polls = polls } +// AttachUsernameRegistry gives the memory backend the same global username +// index the PostgreSQL stores share through peer_usernames. +func (s *ChannelStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) { + s.mu.Lock() + s.usernameRegistry = registry + s.mu.Unlock() +} + // NewChannelStore creates an in-memory ChannelStore. func NewChannelStore() *ChannelStore { return &ChannelStore{ diff --git a/internal/store/memory/collectible_username.go b/internal/store/memory/collectible_username.go index 219a42b9..6f0052c8 100644 --- a/internal/store/memory/collectible_username.go +++ b/internal/store/memory/collectible_username.go @@ -161,6 +161,61 @@ func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers [ return out, nil } +// activeUsernamePeer resolves an active registry name for the memory user and +// channel stores. Keeping lookup on the same registry that owns toggle/reorder +// state prevents the test backend from silently falling back to scalar-only +// behavior. +func (s *CollectibleUsernameStore) activeUsernamePeer(username string, peerType domain.PeerType) (domain.Peer, bool) { + key := strings.ToLower(domain.NormalizeUsername(username)) + if key == "" { + return domain.Peer{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.registry[key] + if !ok || !entry.row.Active || entry.peer.Type != peerType { + return domain.Peer{}, false + } + return entry.peer, true +} + +// activeUsernameMatches returns the best username rank for each peer: exact +// matches precede prefix matches. Inactive rows stay occupied in the registry +// but are deliberately absent from client search. +func (s *CollectibleUsernameStore) activeUsernameMatches(query string, peerType domain.PeerType) map[int64]int { + query = strings.ToLower(domain.NormalizeUsername(query)) + if query == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[int64]int) + for username, entry := range s.registry { + if !entry.row.Active || entry.peer.Type != peerType || !strings.HasPrefix(username, query) { + continue + } + rank := 1 + if username == query { + rank = 0 + } + if current, ok := out[entry.peer.ID]; !ok || rank < current { + out[entry.peer.ID] = rank + } + } + return out +} + +func (s *CollectibleUsernameStore) peerHasActiveCollectibleUsername(peer domain.Peer) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, entry := range s.registry { + if entry.peer == peer && entry.row.Active && !entry.row.Editable { + return true + } + } + return false +} + // SetUsernameActive toggles one collectible row. The domain validator owns the // rules: the editable slot is off limits and a peer that holds usernames must // keep at least one active. diff --git a/internal/store/memory/community.go b/internal/store/memory/community.go index a6635416..1d49abf8 100644 --- a/internal/store/memory/community.go +++ b/internal/store/memory/community.go @@ -112,7 +112,7 @@ func (s *CommunityStore) viewLocked(userID, id int64) (domain.CommunityView, err cm, ok := s.channels.members[l.Peer.ID][userID] joined = ok && cm.Status == domain.ChannelMemberActive if channel, ok := s.channels.channels[l.Peer.ID]; ok { - inherentlyViewable = publicPreviewableChannel(channel) + inherentlyViewable = s.channels.publicPreviewableChannelLocked(channel) } s.channels.mu.RUnlock() } else if l.Peer.Type == domain.PeerTypeUser && s.dialogs != nil { diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go index cfa5f8b7..8caff7e3 100644 --- a/internal/store/memory/users.go +++ b/internal/store/memory/users.go @@ -11,9 +11,10 @@ import ( // UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。 type UserStore struct { - mu sync.RWMutex - byID map[int64]domain.User - nextID int64 + mu sync.RWMutex + byID map[int64]domain.User + nextID int64 + usernameRegistry *CollectibleUsernameStore } // NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers / ChatBot) @@ -28,6 +29,14 @@ func NewUserStore() *UserStore { return s } +// AttachUsernameRegistry gives the memory backend the same global username +// index the PostgreSQL stores share through peer_usernames. +func (s *UserStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) { + s.mu.Lock() + s.usernameRegistry = registry + s.mu.Unlock() +} + func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) { s.mu.RLock() u, ok := s.byID[id] @@ -104,18 +113,25 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, return out, nil } -func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) { +func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) { username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) if username == "" { return domain.User{}, false, nil } s.mu.RLock() - defer s.mu.RUnlock() + registry := s.usernameRegistry for _, u := range s.byID { if !u.Deleted && strings.ToLower(u.Username) == username { + s.mu.RUnlock() return u, true, nil } } + s.mu.RUnlock() + if registry != nil { + if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeUser); ok { + return s.ByID(ctx, peer.ID) + } + } return domain.User{}, false, nil } @@ -144,13 +160,21 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ return domain.UserSearchResult{}, nil } s.mu.RLock() + registry := s.usernameRegistry + s.mu.RUnlock() + var usernameMatches map[int64]int + if registry != nil { + usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeUser) + } + s.mu.RLock() defer s.mu.RUnlock() users := make([]domain.User, 0) for _, u := range s.byID { if u.ID == currentUserID || u.Deleted { continue } - if userMatchesSearch(u, query, phoneQuery) { + _, usernameMatch := usernameMatches[u.ID] + if usernameMatch || userMatchesSearch(u, query, phoneQuery) { users = append(users, u) } } @@ -163,7 +187,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ return domain.UserSearchResult{Results: users}, nil } -func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) { +func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { username = strings.TrimSpace(strings.TrimPrefix(username, "@")) usernameLower := strings.ToLower(username) s.mu.Lock() @@ -179,6 +203,11 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str } } } + if s.usernameRegistry != nil { + if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, username); err != nil { + return domain.User{}, err + } + } u.Username = username s.byID[userID] = u return u, nil diff --git a/internal/store/postgres/channel_core.go b/internal/store/postgres/channel_core.go index 85e1cae6..5458e3e9 100644 --- a/internal/store/postgres/channel_core.go +++ b/internal/store/postgres/channel_core.go @@ -395,6 +395,10 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids) if err != nil { return nil, err } + publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, s.db, peerUsernameTypeChannel, remaining) + if err != nil { + return nil, err + } for _, channel := range channels { if member, ok := linkedGuests[channel.ID]; ok { views[channel.ID] = domain.ChannelView{ @@ -427,7 +431,8 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids) continue } } - if !publicPreviewableChannel(channel) { + _, hasActiveUsername := publicUsernameIDs[channel.ID] + if !publicPreviewableChannel(channel, hasActiveUsername) { continue } existing, found := previewMembers[channel.ID] @@ -514,10 +519,10 @@ func finishChannelScan(ch *domain.Channel, rights, reactionPolicy string, wallpa } } -func publicPreviewableChannel(channel domain.Channel) bool { +func publicPreviewableChannel(channel domain.Channel, hasActiveUsername bool) bool { return !channel.Deleted && (channel.Broadcast || channel.Megagroup) && - strings.TrimSpace(channel.Username) != "" + (strings.TrimSpace(channel.Username) != "" || hasActiveUsername) } func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) { diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index 87c03e93..f70f6ea6 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -150,11 +150,28 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in queryPrefix := escapeLike(queryLower) + "%" queryLike := "%" + escapeLike(queryLower) + "%" rows, err := s.db.Query(ctx, ` +WITH username_matches AS ( + SELECT + peer_id, + MIN(CASE + WHEN username_lower = $2 THEN 0 + ELSE 1 + END) AS rank + FROM peer_usernames + WHERE peer_type = 'channel' + AND active + AND collectible_id IS NOT NULL + AND ( + username_lower = $2 + OR username_lower LIKE $3 ESCAPE '\' + ) + GROUP BY peer_id +) SELECT `+channelColumns+` FROM channels c +LEFT JOIN username_matches um ON um.peer_id = c.id WHERE NOT c.deleted AND (c.broadcast OR c.megagroup) - AND COALESCE(c.username, '') <> '' AND NOT EXISTS ( SELECT 1 FROM channel_members m @@ -163,15 +180,16 @@ WHERE NOT c.deleted AND m.status = 'active' ) AND ( - lower(c.username) = $2 + um.peer_id IS NOT NULL + OR lower(c.username) = $2 OR lower(c.username) LIKE $3 ESCAPE '\' OR lower(c.title) LIKE $3 ESCAPE '\' OR lower(c.username) LIKE $4 ESCAPE '\' OR lower(c.title) LIKE $4 ESCAPE '\' ) ORDER BY CASE - WHEN lower(c.username) = $2 THEN 0 - WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1 + WHEN um.rank = 0 OR lower(c.username) = $2 THEN 0 + WHEN um.rank = 1 OR lower(c.username) LIKE $3 ESCAPE '\' THEN 1 WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2 WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3 ELSE 4 @@ -421,7 +439,12 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil } } - if !publicPreviewableChannel(ch) { + publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, db, peerUsernameTypeChannel, []int64{ch.ID}) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + _, hasActiveUsername := publicUsernameIDs[ch.ID] + if !publicPreviewableChannel(ch, hasActiveUsername) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate } member, err = s.getPublicPreviewMember(ctx, db, viewerUserID, ch) diff --git a/internal/store/postgres/channel_settings.go b/internal/store/postgres/channel_settings.go index ba55ceba..548837ab 100644 --- a/internal/store/postgres/channel_settings.go +++ b/internal/store/postgres/channel_settings.go @@ -510,6 +510,9 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU if !found || owner.peerType != peerUsernameTypeChannel { return domain.Channel{}, false, nil } + if !owner.active { + return domain.Channel{}, false, nil + } ch, err := getChannelByID(ctx, s.db, owner.peerID) if err != nil { if errors.Is(err, domain.ErrChannelInvalid) { @@ -517,7 +520,7 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU } return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err) } - if !publicPreviewableChannel(ch) { + if !publicPreviewableChannel(ch, true) { return domain.Channel{}, false, nil } // A collectible row is authoritative for its own name: the channel's scalar @@ -527,9 +530,6 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) { return domain.Channel{}, false, nil } - if owner.collectible && !owner.active { - return domain.Channel{}, false, nil - } return ch, true, nil } diff --git a/internal/store/postgres/collectible_username_integration_test.go b/internal/store/postgres/collectible_username_integration_test.go index 4c2663cb..de8c3fc8 100644 --- a/internal/store/postgres/collectible_username_integration_test.go +++ b/internal/store/postgres/collectible_username_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" @@ -90,6 +91,164 @@ func registryRows(t *testing.T, pool *pgxpool.Pool, peer domain.Peer) []domain.U return list } +func TestCollectibleUsernameResolveAndSearchUseActiveRegistry(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + seed := time.Now().UnixNano() % 1_000_000 + viewer := collectibleTestUser(t, pool, 3_100_000_000+seed, "") + userPeer := collectibleTestUser(t, pool, 3_200_000_000+seed, "") + userEditable := fmt.Sprintf("uedit%d", seed) + userCollectible := fmt.Sprintf("unft%d", seed) + setEditableUsername(t, pool, userPeer, userEditable) + cleanupCollectible(t, pool, lowerASCII(userCollectible)) + + registry := NewCollectibleUsernameStore(pool) + if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(userCollectible, userPeer, "")); err != nil || !created { + t.Fatalf("mint user collectible: created=%v err=%v", created, err) + } + users := NewUserStore(pool) + resolvedUser, found, err := users.ByUsername(ctx, userCollectible) + if err != nil || !found || resolvedUser.ID != userPeer.ID { + t.Fatalf("resolve user collectible = %+v found=%v err=%v", resolvedUser, found, err) + } + userSearch, err := users.Search(ctx, viewer.ID, userCollectible, "", 10) + if err != nil || len(userSearch.Results) != 1 || userSearch.Results[0].ID != userPeer.ID { + t.Fatalf("search user collectible = %+v err=%v", userSearch, err) + } + if changed, err := registry.SetUsernameActive(ctx, userPeer, userCollectible, false); err != nil || !changed { + t.Fatalf("deactivate user collectible: changed=%v err=%v", changed, err) + } + if _, found, err := users.ByUsername(ctx, userCollectible); err != nil || found { + t.Fatalf("resolve inactive user collectible found=%v err=%v", found, err) + } + if hidden, err := users.Search(ctx, viewer.ID, userCollectible, "", 10); err != nil || len(hidden.Results)+len(hidden.MyResults) != 0 { + t.Fatalf("search inactive user collectible = %+v err=%v", hidden, err) + } + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: userPeer.ID, + Title: "Unrelated collectible channel", + Broadcast: true, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM channels WHERE id = $1`, channelPeer.ID) + }) + channelEditable := fmt.Sprintf("cedit%d", seed) + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: userPeer.ID, + ChannelID: channelPeer.ID, + Username: channelEditable, + }); err != nil { + t.Fatalf("set channel editable username: %v", err) + } + channelCollectible := fmt.Sprintf("cnft%d", seed) + cleanupCollectible(t, pool, lowerASCII(channelCollectible)) + if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(channelCollectible, channelPeer, "")); err != nil || !created { + t.Fatalf("mint channel collectible: created=%v err=%v", created, err) + } + resolvedChannel, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible) + if err != nil || !found || resolvedChannel.ID != channelPeer.ID { + t.Fatalf("resolve channel collectible = %+v found=%v err=%v", resolvedChannel, found, err) + } + channelSearch, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10) + if err != nil || len(channelSearch.Results) != 1 || channelSearch.Results[0].ID != channelPeer.ID { + t.Fatalf("search channel collectible = %+v err=%v", channelSearch, err) + } + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: userPeer.ID, + ChannelID: channelPeer.ID, + Username: "", + }); err != nil { + t.Fatalf("clear channel editable username: %v", err) + } + if nftOnly, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || !found || nftOnly.ID != channelPeer.ID { + t.Fatalf("resolve NFT-only channel = %+v found=%v err=%v", nftOnly, found, err) + } + if view, err := channels.GetChannel(ctx, viewer.ID, channelPeer.ID); err != nil || view.Channel.ID != channelPeer.ID { + t.Fatalf("preview NFT-only channel = %+v err=%v", view, err) + } + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: userPeer.ID, + ChannelID: channelPeer.ID, + Username: channelEditable, + }); err != nil { + t.Fatalf("restore channel editable username: %v", err) + } + if changed, err := registry.SetUsernameActive(ctx, channelPeer, channelCollectible, false); err != nil || !changed { + t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err) + } + if _, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || found { + t.Fatalf("resolve inactive channel collectible found=%v err=%v", found, err) + } + if hidden, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10); err != nil || len(hidden.Results) != 0 { + t.Fatalf("search inactive channel collectible = %+v err=%v", hidden, err) + } +} + +func TestCollectibleUsernameSearchPrefixUsesActiveRegistryIndex(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, ` +WITH names AS ( + SELECT + CASE WHEN n = 1 THEN 'nftplanfixture' ELSE 'otherplanfixture' || n::text END AS username, + 9100000000 + n AS owner_peer_id + FROM generate_series(1, 5000) AS n +), +assets AS ( + INSERT INTO collectible_usernames ( + username, username_lower, status, owner_peer_type, owner_peer_id, + purchase_date, currency, amount, + original_owner_peer_type, original_owner_peer_id, + created_at, updated_at + ) + SELECT + username, username, 'owned', 'user', owner_peer_id, + now(), 'XTR', 1, + 'user', owner_peer_id, + now(), now() + FROM names + RETURNING id, username, username_lower, owner_peer_id +) +INSERT INTO peer_usernames ( + username_lower, username, peer_type, peer_id, + active, editable, sort_order, collectible_id +) +SELECT + username_lower, username, 'user', owner_peer_id, + true, false, 0, id +FROM assets`); err != nil { + t.Fatalf("seed username plan fixture: %v", err) + } + if _, err := tx.Exec(ctx, "ANALYZE peer_usernames"); err != nil { + t.Fatalf("analyze username plan fixture: %v", err) + } + if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil { + t.Fatalf("disable seqscan: %v", err) + } + plan := explainText(t, ctx, tx, ` +SELECT peer_id +FROM peer_usernames +WHERE peer_type = 'user' + AND active + AND collectible_id IS NOT NULL + AND username_lower LIKE $1 || '%' ESCAPE '\'`, "nft") + if !strings.Contains(plan, "peer_usernames_active_search_idx") { + t.Fatalf("active username prefix plan = %s, want peer_usernames_active_search_idx", plan) + } +} + // TestCollectibleUsernameMintIntoVault covers a vault mint: the asset exists, the // name is not projected into any peer's registry, and the provenance log records // the mint. diff --git a/internal/store/postgres/peer_username.go b/internal/store/postgres/peer_username.go index 1f311a4c..5a00a838 100644 --- a/internal/store/postgres/peer_username.go +++ b/internal/store/postgres/peer_username.go @@ -69,6 +69,39 @@ func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, return owner.matches(peerType, peerID), nil } +// activeCollectibleUsernamePeerIDs returns the requested peers that own at +// least one active collectible username. Editable registry rows are excluded: +// their scalar users.username/channels.username value is the cross-check that +// prevents a stale registry row from making a private peer public. +func activeCollectibleUsernamePeerIDs(ctx context.Context, db sqlcgen.DBTX, peerType string, peerIDs []int64) (map[int64]struct{}, error) { + out := make(map[int64]struct{}) + if len(peerIDs) == 0 { + return out, nil + } + rows, err := db.Query(ctx, ` +SELECT DISTINCT peer_id +FROM peer_usernames +WHERE peer_type = $1 + AND active + AND collectible_id IS NOT NULL + AND peer_id = ANY($2::bigint[])`, peerType, peerIDs) + if err != nil { + return nil, fmt.Errorf("list peers with active usernames: %w", err) + } + defer rows.Close() + for rows.Next() { + var peerID int64 + if err := rows.Scan(&peerID); err != nil { + return nil, fmt.Errorf("scan peer with active username: %w", err) + } + out[peerID] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list peers with active usernames: %w", err) + } + return out, nil +} + // replacePeerUsernameTx rewrites the peer's editable username slot. username is // the display form (original case) and usernameLower its registry key; an empty // pair clears the slot. diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index c0c170d5..72c6af8e 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -20,7 +20,18 @@ ORDER BY id; SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL; -- name: SearchUsers :many -WITH matched AS ( +WITH username_matches AS ( + SELECT + peer_id, + bool_or(username_lower = sqlc.arg(query_lower)::text) AS exact + FROM peer_usernames + WHERE peer_type = 'user' + AND active + AND collectible_id IS NOT NULL + AND username_lower LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\' + GROUP BY peer_id +), +matched AS ( SELECT u.id, u.access_hash, @@ -51,7 +62,7 @@ WITH matched AS ( COALESCE(c.mutual, false)::boolean AS mutual, CASE WHEN sqlc.arg(phone_query)::text <> '' AND u.phone = sqlc.arg(phone_query)::text THEN 0 - WHEN lower(u.username) = sqlc.arg(query_lower)::text THEN 1 + WHEN COALESCE(um.exact, false) OR lower(u.username) = sqlc.arg(query_lower)::text THEN 1 WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = sqlc.arg(query_lower)::text THEN 2 WHEN lower(u.first_name) = sqlc.arg(query_lower)::text THEN 3 WHEN c.contact_user_id IS NOT NULL THEN 4 @@ -59,11 +70,13 @@ WITH matched AS ( END AS rank FROM users u LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id + LEFT JOIN username_matches um ON um.peer_id = u.id WHERE u.id <> sqlc.arg(current_user_id)::bigint AND u.deleted_at IS NULL AND sqlc.arg(query_lower)::text <> '' AND ( (sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%') + OR um.peer_id IS NOT NULL OR lower(u.username) LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\' OR lower(u.first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' OR lower(u.last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index 52411e35..2d03c6dd 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -364,7 +364,18 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User } const searchUsers = `-- name: SearchUsers :many -WITH matched AS ( +WITH username_matches AS ( + SELECT + peer_id, + bool_or(username_lower = $2::text) AS exact + FROM peer_usernames + WHERE peer_type = 'user' + AND active + AND collectible_id IS NOT NULL + AND username_lower LIKE $3::text || '%' ESCAPE '\' + GROUP BY peer_id +), +matched AS ( SELECT u.id, u.access_hash, @@ -394,27 +405,29 @@ WITH matched AS ( (c.contact_user_id IS NOT NULL)::boolean AS contact, COALESCE(c.mutual, false)::boolean AS mutual, CASE - WHEN $2::text <> '' AND u.phone = $2::text THEN 0 - WHEN lower(u.username) = $3::text THEN 1 - WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $3::text THEN 2 - WHEN lower(u.first_name) = $3::text THEN 3 + WHEN $4::text <> '' AND u.phone = $4::text THEN 0 + WHEN COALESCE(um.exact, false) OR lower(u.username) = $2::text THEN 1 + WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $2::text THEN 2 + WHEN lower(u.first_name) = $2::text THEN 3 WHEN c.contact_user_id IS NOT NULL THEN 4 ELSE 5 END AS rank FROM users u - LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id - WHERE u.id <> $4::bigint + LEFT JOIN contacts c ON c.user_id = $5::bigint AND c.contact_user_id = u.id + LEFT JOIN username_matches um ON um.peer_id = u.id + WHERE u.id <> $5::bigint AND u.deleted_at IS NULL - AND $3::text <> '' + AND $2::text <> '' AND ( - ($2::text <> '' AND u.phone LIKE $2::text || '%') - OR lower(u.username) LIKE $5::text || '%' ESCAPE '\' - OR lower(u.first_name) LIKE '%' || $5::text || '%' ESCAPE '\' - OR lower(u.last_name) LIKE '%' || $5::text || '%' ESCAPE '\' - OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $5::text || '%' ESCAPE '\' - OR lower(c.contact_first_name) LIKE '%' || $5::text || '%' ESCAPE '\' - OR lower(c.contact_last_name) LIKE '%' || $5::text || '%' ESCAPE '\' - OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $5::text || '%' ESCAPE '\' + ($4::text <> '' AND u.phone LIKE $4::text || '%') + OR um.peer_id IS NOT NULL + OR lower(u.username) LIKE $3::text || '%' ESCAPE '\' + OR lower(u.first_name) LIKE '%' || $3::text || '%' ESCAPE '\' + OR lower(u.last_name) LIKE '%' || $3::text || '%' ESCAPE '\' + OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $3::text || '%' ESCAPE '\' + OR lower(c.contact_first_name) LIKE '%' || $3::text || '%' ESCAPE '\' + OR lower(c.contact_last_name) LIKE '%' || $3::text || '%' ESCAPE '\' + OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $3::text || '%' ESCAPE '\' ) ) SELECT @@ -452,10 +465,10 @@ LIMIT $1 type SearchUsersParams struct { LimitCount int32 - PhoneQuery string QueryLower string - CurrentUserID int64 QueryLike string + PhoneQuery string + CurrentUserID int64 } type SearchUsersRow struct { @@ -491,10 +504,10 @@ type SearchUsersRow struct { func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) { rows, err := q.db.Query(ctx, searchUsers, arg.LimitCount, - arg.PhoneQuery, arg.QueryLower, - arg.CurrentUserID, arg.QueryLike, + arg.PhoneQuery, + arg.CurrentUserID, ) if err != nil { return nil, err diff --git a/internal/web/server.go b/internal/web/server.go index 2b379c05..f3d19d22 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -703,15 +703,20 @@ func (h *handler) resolvePublicPeer(ctx context.Context, username string) (publi } func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain.User) (publicPeer, bool, error) { - if u.ID == 0 || !strings.EqualFold(strings.TrimSpace(u.Username), requested) || !validUsernamePath(u.Username) { + requested = domain.NormalizeUsername(requested) + if u.ID == 0 || !validUsernamePath(requested) { return publicPeer{}, false, fmt.Errorf("user username lookup returned invalid owner for %q", requested) } + canonicalUsername := requested + if strings.EqualFold(strings.TrimSpace(u.Username), requested) && validUsernamePath(u.Username) { + canonicalUsername = strings.TrimSpace(u.Username) + } title := strings.TrimSpace(u.FirstName + " " + u.LastName) if title == "" { - title = u.Username + title = canonicalUsername } if err := validatePublicPeerText(title, u.About); err != nil { - return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", u.Username, err) + return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", requested, err) } about := strings.TrimSpace(u.About) photoKind := domain.ProfilePhotoKindProfile @@ -733,7 +738,7 @@ func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain } peer := publicPeer{ kind: publicPeerUser, - username: u.Username, + username: canonicalUsername, title: title, about: about, verified: u.Verified, @@ -756,15 +761,20 @@ func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain } func (h *handler) publicChannelPeer(ctx context.Context, requested string, ch domain.Channel) (publicPeer, bool, error) { - if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !strings.EqualFold(strings.TrimSpace(ch.Username), requested) || !validUsernamePath(ch.Username) { + requested = domain.NormalizeUsername(requested) + if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !validUsernamePath(requested) { return publicPeer{}, false, fmt.Errorf("channel username lookup returned invalid owner for %q", requested) } + canonicalUsername := requested + if strings.EqualFold(strings.TrimSpace(ch.Username), requested) && validUsernamePath(ch.Username) { + canonicalUsername = strings.TrimSpace(ch.Username) + } if err := validatePublicPeerText(ch.Title, ch.About); err != nil { - return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err) + return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", requested, err) } peer := publicPeer{ kind: publicPeerChannel, - username: ch.Username, + username: canonicalUsername, title: strings.TrimSpace(ch.Title), about: strings.TrimSpace(ch.About), verified: ch.Verified, @@ -1044,21 +1054,7 @@ func validStarGiftSlugPath(slug string) bool { } func validUsernamePath(username string) bool { - if username == "" || len(username) < 5 || len(username) > 32 { - return false - } - for i, r := range username { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9', r == '_': - if i == 0 { - return false - } - default: - return false - } - } - return true + return domain.ValidCollectibleUsername(domain.NormalizeUsername(username)) } func linkKind(set domain.StickerSet) string {