Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
|
|
@ -33,6 +33,10 @@ type usernameAvailabilityStore interface {
|
|||
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
|
||||
}
|
||||
|
||||
type moderationFlagAudienceStore interface {
|
||||
ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error)
|
||||
}
|
||||
|
||||
// Option 调整用户服务可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
|
|
@ -138,6 +142,14 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
|
|||
return s.loadBaseUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
|
||||
// shared base-user read model. Privacy uses this as a batched cold loader behind
|
||||
// its bounded process cache; no viewer projection is performed, avoiding a
|
||||
// privacy -> users -> privacy recursion.
|
||||
func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
return s.loadBaseUsersByIDs(ctx, userIDs)
|
||||
}
|
||||
|
||||
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
||||
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||
if currentUserID == 0 {
|
||||
|
|
@ -392,6 +404,23 @@ func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool
|
|||
return updated, nil
|
||||
}
|
||||
|
||||
// ModerationFlagAudience returns the bounded set of existing viewers that may
|
||||
// need an immediate updateUser after SCAM/FAKE changes. This is an online
|
||||
// accelerator only: it does not allocate PTS or create durable update events.
|
||||
func (s *Service) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) {
|
||||
if userID == 0 {
|
||||
return nil, ErrNotAuthorized
|
||||
}
|
||||
if limit <= 0 || limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
audience, ok := s.users.(moderationFlagAudienceStore)
|
||||
if !ok {
|
||||
return []int64{userID}, nil
|
||||
}
|
||||
return audience.ModerationFlagAudience(ctx, userID, limit)
|
||||
}
|
||||
|
||||
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
|
||||
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
|
|
@ -548,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)
|
||||
|
|
@ -563,7 +596,9 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
return u, true, nil
|
||||
}
|
||||
|
||||
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
|
||||
// ResolvePhone resolves a phone number only when the target's AddedByPhone
|
||||
// privacy allows the current viewer. The evaluator is backed by owner-level
|
||||
// privacy/contact snapshots in production, so this adds no per-rule SQL query.
|
||||
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
|
||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
|
|
@ -577,6 +612,28 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
|||
return u, found, err
|
||||
}
|
||||
s.putCachedUsers(ctx, u)
|
||||
if s.privacy != nil && u.ID != currentUserID {
|
||||
allowed := false
|
||||
var err error
|
||||
if batch, ok := s.privacy.(userprojection.BatchPrivacyEvaluator); ok {
|
||||
visibility, batchErr := batch.CanSeeBatch(
|
||||
ctx,
|
||||
[]int64{u.ID},
|
||||
currentUserID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
|
||||
)
|
||||
err = batchErr
|
||||
allowed = visibility[u.ID][domain.PrivacyKeyAddedByPhone]
|
||||
} else {
|
||||
allowed, err = s.privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAddedByPhone)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
if !allowed {
|
||||
return domain.User{}, false, domain.ErrPhoneNotOccupied
|
||||
}
|
||||
}
|
||||
u, err = s.projectOne(ctx, currentUserID, u)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
|
@ -44,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)
|
||||
}
|
||||
|
|
@ -60,6 +89,42 @@ func TestServiceUsernameLifecycle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolvePhoneHonorsAddedByPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contacts := memory.NewContactStore()
|
||||
viewer, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001001", FirstName: "Viewer"})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550001002", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
|
||||
if _, err := privacy.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set AddedByPhone: %v", err)
|
||||
}
|
||||
svc := NewService(users,
|
||||
WithContactStore(contacts),
|
||||
WithPrivacyEvaluator(privacy),
|
||||
)
|
||||
|
||||
if _, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone); !errors.Is(err, domain.ErrPhoneNotOccupied) || found {
|
||||
t.Fatalf("ResolvePhone stranger found=%v err=%v, want phone not occupied", found, err)
|
||||
}
|
||||
if _, err := contacts.Upsert(ctx, target.ID, domain.ContactInput{
|
||||
ContactUserID: viewer.ID,
|
||||
FirstName: viewer.FirstName,
|
||||
}); err != nil {
|
||||
t.Fatalf("target add viewer: %v", err)
|
||||
}
|
||||
got, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone)
|
||||
if err != nil || !found || got.ID != target.ID {
|
||||
t.Fatalf("ResolvePhone contact = %+v found=%v err=%v, want target", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateProfile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewUserStore()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue