fix(usernames): sync index active collectible aliases

This commit is contained in:
iamxvbaba 2026-07-28 16:22:34 +08:00
parent 2f995f607a
commit 5156b17c1b
29 changed files with 657 additions and 112 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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 {

View file

@ -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))

View file

@ -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

View file

@ -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
}

View file

@ -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{

View file

@ -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.

View file

@ -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 {

View file

@ -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

View file

@ -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) {

View file

@ -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)

View file

@ -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
}

View file

@ -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.

View file

@ -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.

View file

@ -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 '\'

View file

@ -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