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

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