fix: sync non-PTS moderation updates

This commit is contained in:
iamxvbaba 2026-07-24 11:57:00 +08:00
parent cc76cd3679
commit 70e57b4d07
15 changed files with 331 additions and 378 deletions

View file

@ -942,21 +942,22 @@ func run(logger *zap.Logger) error {
go readModelListener.Run(ctx) go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router) activeSessions.SetLifecycleObserver(router)
adminService.Configure(adminapp.Dependencies{ adminService.Configure(adminapp.Dependencies{
Auth: authService, Auth: authService,
Revoker: router, Revoker: router,
Users: usersService, Users: usersService,
Stars: starsService, Stars: starsService,
StarsNotifier: router, StarsNotifier: router,
UserNotifier: router, UserNotifier: router,
FreezeNotifier: router, UserModerationNotifier: router,
Channels: channelsService, FreezeNotifier: router,
ChannelNotifier: router, Channels: channelsService,
Messages: messagesService, ChannelNotifier: router,
Gifts: giftsService, Messages: messagesService,
GiftGranter: router, Gifts: giftsService,
Bots: botsService, GiftGranter: router,
Emoji: filesService, Bots: botsService,
Moderation: moderationService, Emoji: filesService,
Moderation: moderationService,
}) })
moderationActionOptions := []moderationapp.ActionExecutorOption{} moderationActionOptions := []moderationapp.ActionExecutorOption{}
if cfg.PublicLinkWebAddr != "" { if cfg.PublicLinkWebAddr != "" {

View file

@ -107,6 +107,10 @@ type UserNotifier interface {
NotifyUserChanged(ctx context.Context, u domain.User) error NotifyUserChanged(ctx context.Context, u domain.User) error
} }
type UserModerationNotifier interface {
NotifyUserModerationFlagsChanged(ctx context.Context, u domain.User) error
}
type AccountFreezeNotifier interface { type AccountFreezeNotifier interface {
NotifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error NotifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error
} }
@ -184,47 +188,49 @@ type GiftGranter interface {
} }
type Dependencies struct { type Dependencies struct {
Commands CommandRepository Commands CommandRepository
Restrictions RestrictionStore Restrictions RestrictionStore
Auth AuthService Auth AuthService
Revoker AuthKeyRevoker Revoker AuthKeyRevoker
Users UsersService Users UsersService
Stars StarsService Stars StarsService
StarsNotifier StarsNotifier StarsNotifier StarsNotifier
UserNotifier UserNotifier UserNotifier UserNotifier
FreezeNotifier AccountFreezeNotifier UserModerationNotifier UserModerationNotifier
Channels ChannelsService FreezeNotifier AccountFreezeNotifier
ChannelNotifier ChannelNotifier Channels ChannelsService
Messages MessagesService ChannelNotifier ChannelNotifier
Gifts GiftsService Messages MessagesService
GiftGranter GiftGranter Gifts GiftsService
OfficialGifts OfficialGiftsSource GiftGranter GiftGranter
Bots BotService OfficialGifts OfficialGiftsSource
Emoji EmojiService Bots BotService
Moderation ModerationService Emoji EmojiService
Now func() time.Time Moderation ModerationService
Now func() time.Time
} }
type Service struct { type Service struct {
commands CommandRepository commands CommandRepository
restrictions RestrictionStore restrictions RestrictionStore
auth AuthService auth AuthService
revoker AuthKeyRevoker revoker AuthKeyRevoker
users UsersService users UsersService
stars StarsService stars StarsService
starsNotifier StarsNotifier starsNotifier StarsNotifier
userNotifier UserNotifier userNotifier UserNotifier
freezeNotifier AccountFreezeNotifier userModerationNotifier UserModerationNotifier
channels ChannelsService freezeNotifier AccountFreezeNotifier
channelNotifier ChannelNotifier channels ChannelsService
messages MessagesService channelNotifier ChannelNotifier
gifts GiftsService messages MessagesService
giftGranter GiftGranter gifts GiftsService
officialGifts OfficialGiftsSource giftGranter GiftGranter
bots BotService officialGifts OfficialGiftsSource
emoji EmojiService bots BotService
moderation ModerationService emoji EmojiService
now func() time.Time moderation ModerationService
now func() time.Time
} }
func NewService(deps Dependencies) *Service { func NewService(deps Dependencies) *Service {
@ -257,6 +263,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.UserNotifier != nil { if deps.UserNotifier != nil {
s.userNotifier = deps.UserNotifier s.userNotifier = deps.UserNotifier
} }
if deps.UserModerationNotifier != nil {
s.userModerationNotifier = deps.UserModerationNotifier
}
if deps.FreezeNotifier != nil { if deps.FreezeNotifier != nil {
s.freezeNotifier = deps.FreezeNotifier s.freezeNotifier = deps.FreezeNotifier
} }
@ -969,7 +978,7 @@ func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (Co
} }
details["updated_scam"] = updated.Scam details["updated_scam"] = updated.Scam
details["updated_fake"] = updated.Fake details["updated_fake"] = updated.Fake
if err := s.notifyUserChanged(ctx, updated); err != nil { if err := s.notifyUserModerationFlagsChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error() details["notify_error"] = err.Error()
} }
return CommandResult{Message: "user flags updated", Details: details}, nil return CommandResult{Message: "user flags updated", Details: details}, nil
@ -2232,6 +2241,13 @@ func (s *Service) notifyUserChanged(ctx context.Context, u domain.User) error {
return s.userNotifier.NotifyUserChanged(ctx, u) return s.userNotifier.NotifyUserChanged(ctx, u)
} }
func (s *Service) notifyUserModerationFlagsChanged(ctx context.Context, u domain.User) error {
if s == nil || s.userModerationNotifier == nil {
return s.notifyUserChanged(ctx, u)
}
return s.userModerationNotifier.NotifyUserModerationFlagsChanged(ctx, u)
}
func (s *Service) notifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error { func (s *Service) notifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error {
if s == nil || s.freezeNotifier == nil { if s == nil || s.freezeNotifier == nil {
return nil return nil

View file

@ -143,6 +143,39 @@ func TestModerationFlagsRejectImpossibleScamFakeState(t *testing.T) {
} }
} }
func TestSetUserFlagsUsesNonPTSModerationNotifier(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Alice"},
}}
ordinaryNotifier := &fakeUserNotifier{}
moderationNotifier := &fakeUserModerationNotifier{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Users: users,
UserNotifier: ordinaryNotifier,
UserModerationNotifier: moderationNotifier,
Now: fixedNow,
})
if _, err := svc.SetUserFlags(ctx, SetUserFlagsRequest{
CommandMeta: CommandMeta{CommandID: "set-user-scam", Actor: "ops", Reason: "confirmed report"},
UserID: 1001,
Scam: true,
}); err != nil {
t.Fatalf("SetUserFlags: %v", err)
}
if got := users.users[1001]; !got.Scam || got.Fake {
t.Fatalf("updated user = %+v", got)
}
if len(moderationNotifier.users) != 1 || moderationNotifier.users[0] != 1001 {
t.Fatalf("moderation notifications = %v", moderationNotifier.users)
}
if len(ordinaryNotifier.users) != 0 {
t.Fatalf("ordinary notifications = %v, want dedicated non-PTS path", ordinaryNotifier.users)
}
}
func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) { func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
now := fixedNow() now := fixedNow()
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{ store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{
@ -863,6 +896,15 @@ func (f *fakeUserNotifier) NotifyUserChanged(_ context.Context, u domain.User) e
return nil return nil
} }
type fakeUserModerationNotifier struct {
users []int64
}
func (f *fakeUserModerationNotifier) NotifyUserModerationFlagsChanged(_ context.Context, u domain.User) error {
f.users = append(f.users, u.ID)
return nil
}
type fakeChannelsService struct { type fakeChannelsService struct {
channels map[int64]domain.Channel channels map[int64]domain.Channel
verifiedCalls int verifiedCalls int

View file

@ -33,6 +33,10 @@ type usernameAvailabilityStore interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error) CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
} }
type moderationFlagAudienceStore interface {
ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error)
}
// Option 调整用户服务可选依赖。 // Option 调整用户服务可选依赖。
type Option func(*Service) type Option func(*Service)
@ -176,27 +180,6 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
return s.projectUsers(ctx, currentUserID, users) return s.projectUsers(ctx, currentUserID, users)
} }
// ByIDsAuthoritative reloads an explicit profile-refresh target from the
// durable store, then replaces the shared base cache before applying the
// viewer projection. It is intentionally reserved for durable update
// delivery; ordinary reads continue to use ByIDs.
func (s *Service) ByIDsAuthoritative(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 {
return nil, ErrNotAuthorized
}
ids := uniqueUserIDs(userIDs, maxBatchUsers)
if len(ids) == 0 {
return nil, nil
}
s.dropCachedUsers(ctx, ids...)
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return nil, err
}
s.putCachedUsers(ctx, users...)
return s.projectUsers(ctx, currentUserID, users)
}
// ByIDsForViewers 跨多个 viewer 批量投影同一组 user(fan-out 模板化):base user 只加载一次, // ByIDsForViewers 跨多个 viewer 批量投影同一组 user(fan-out 模板化):base user 只加载一次,
// 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User, // 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User,
// 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**(ForViewers v1 // 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**(ForViewers v1
@ -421,6 +404,23 @@ func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool
return updated, nil 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 标记(官方客服账号)。写后刷新基础缓存。 // SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) { func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
if userID == 0 { if userID == 0 {

View file

@ -259,49 +259,6 @@ func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) {
} }
} }
func TestServiceAuthoritativeUserReloadReplacesStaleBaseCache(t *testing.T) {
ctx := context.Background()
base := memory.NewUserStore()
viewer, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000011", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000012", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
store := &countingUserStore{UserStore: base}
cache := newMemoryBaseUserCache()
svc := NewService(store, WithBaseUserCache(cache))
primed, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(primed) != 1 || primed[0].Scam {
t.Fatalf("prime ByIDs users=%+v err=%v", primed, err)
}
if _, err := base.SetScamFake(ctx, target.ID, true, false); err != nil {
t.Fatalf("commit moderation flags behind cache: %v", err)
}
stale, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(stale) != 1 || stale[0].Scam {
t.Fatalf("ordinary cached ByIDs users=%+v err=%v, want stale scam=false", stale, err)
}
fresh, err := svc.ByIDsAuthoritative(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(fresh) != 1 || !fresh[0].Scam || fresh[0].Fake {
t.Fatalf("authoritative ByIDs users=%+v err=%v, want scam=true fake=false", fresh, err)
}
if store.byIDsCalls != 2 {
t.Fatalf("store ByIDs calls=%d, want prime + authoritative reload", store.byIDsCalls)
}
cached, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(cached) != 1 || !cached[0].Scam || cached[0].Fake {
t.Fatalf("replaced cache ByIDs users=%+v err=%v, want scam=true fake=false", cached, err)
}
if store.byIDsCalls != 2 {
t.Fatalf("store ByIDs calls after cached read=%d, want unchanged", store.byIDsCalls)
}
}
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) { func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
ctx := context.Background() ctx := context.Background()
base := memory.NewUserStore() base := memory.NewUserStore()

View file

@ -36,12 +36,7 @@ const (
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot. // UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
// It consumes account pts even though updateUserEmojiStatus has no pts. // It consumes account pts even though updateUserEmojiStatus has no pts.
UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status" UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status"
// UpdateEventUserProfile is a durable viewer-scoped refresh for another UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// user's absolute profile flags (currently moderation SCAM/FAKE state).
// The changed user is carried in Peer; hydration attaches the authoritative
// viewer projection to Users before online dispatch or getDifference.
UpdateEventUserProfile UpdateEventType = "user_profile"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消 // UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。 // 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。 // TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
@ -145,7 +140,6 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventPeerStoryBlocked, UpdateEventPeerStoryBlocked,
UpdateEventUserPhone, UpdateEventUserPhone,
UpdateEventUserEmojiStatus, UpdateEventUserEmojiStatus,
UpdateEventUserProfile,
UpdateEventDialogFilter, UpdateEventDialogFilter,
UpdateEventDialogFilterOrder, UpdateEventDialogFilterOrder,
UpdateEventDialogFilters, UpdateEventDialogFilters,

View file

@ -242,11 +242,6 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil return nil
} }
return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)} return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)}
case domain.UpdateEventUserProfile:
if event.Peer.Type != domain.PeerTypeUser || event.Peer.ID == 0 {
return nil
}
return &tg.UpdateUser{UserID: event.Peer.ID}
case domain.UpdateEventChannelState: case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil return nil

View file

@ -249,15 +249,6 @@ type UsersService interface {
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
} }
// UserAuthoritativeProjectionService bypasses viewer-independent base caches
// for an explicit durable profile-refresh event. The event exists precisely
// because a just-committed absolute user fact must replace client and server
// caches; re-reading a stale Redis value would acknowledge the outbox row
// without ever exposing the committed state.
type UserAuthoritativeProjectionService interface {
ByIDsAuthoritative(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
}
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC // TelegramLoginService is the domain-only boundary shared by the MTProto RPC
// edge and the public OIDC provider. PostgreSQL remains authoritative for all // edge and the public OIDC provider. PostgreSQL remains authoritative for all
// consent transitions; the RPC layer only projects domain state to TL. // consent transitions; the RPC layer only projects domain state to TL.

View file

@ -13,16 +13,17 @@ import (
type moderationProjectionUsers struct { type moderationProjectionUsers struct {
UsersService UsersService
freshCalls int audience []int64
viewers []int64
} }
func (s *moderationProjectionUsers) ByIDs(_ context.Context, _ int64, ids []int64) ([]domain.User, error) { func (s *moderationProjectionUsers) ByIDs(_ context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) {
return []domain.User{{ID: ids[0], FirstName: "stale"}}, nil s.viewers = append(s.viewers, viewerUserID)
return []domain.User{{ID: ids[0], FirstName: "Flagged", Scam: true}}, nil
} }
func (s *moderationProjectionUsers) ByIDsAuthoritative(_ context.Context, _ int64, ids []int64) ([]domain.User, error) { func (s *moderationProjectionUsers) ModerationFlagAudience(_ context.Context, _ int64, _ int) ([]int64, error) {
s.freshCalls++ return append([]int64(nil), s.audience...), nil
return []domain.User{{ID: ids[0], FirstName: "fresh", Scam: true}}, nil
} }
type moderationProjectionChannels struct { type moderationProjectionChannels struct {
@ -42,84 +43,90 @@ func (s *moderationProjectionChannels) GetChannelsAuthoritative(_ context.Contex
}}, nil }}, nil
} }
func TestModerationProfileUpdateCarriesStandardFlagsAndPts(t *testing.T) { func (s *moderationProjectionChannels) FilterActiveMemberIDs(_ context.Context, _ int64, userIDs []int64) ([]int64, error) {
const ( return append([]int64(nil), userIDs...), nil
viewerID = int64(1001) }
targetID = int64(2002)
)
event := domain.UpdateEvent{
UserID: viewerID,
Type: domain.UpdateEventUserProfile,
Pts: 7, PtsCount: 1, Date: 1700000000,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: targetID},
Users: []domain.User{{
ID: targetID, AccessHash: 22, FirstName: "Flagged", Scam: true,
}},
}
updates := tgUpdateForOutboxEventForViewer(event, viewerID) func TestUserModerationFlagsPushStandardNonPTSUpdate(t *testing.T) {
if updates == nil || len(updates.Updates) != 2 { const (
t.Fatalf("updates = %+v", updates) targetID = int64(2002)
onlineViewerID = int64(1001)
offlineViewerID = int64(3003)
)
users := &moderationProjectionUsers{
audience: []int64{targetID, onlineViewerID, offlineViewerID},
}
sessions := &captureSessions{onlineUserIDs: []int64{targetID, onlineViewerID}}
r := New(Config{}, Deps{Users: users, Sessions: sessions}, zap.NewNop(), clock.System)
if err := r.NotifyUserModerationFlagsChanged(context.Background(), domain.User{
ID: targetID, FirstName: "Flagged", Scam: true,
}); err != nil {
t.Fatalf("notify moderation flags: %v", err)
}
pushed := sessions.pushedUserIDs()
if len(pushed) != 2 || pushed[0] != targetID || pushed[1] != onlineViewerID {
t.Fatalf("pushed user ids = %v", pushed)
}
if len(users.viewers) != 2 || users.viewers[0] != targetID || users.viewers[1] != onlineViewerID {
t.Fatalf("projected viewers = %v", users.viewers)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
} }
refresh, ok := updates.Updates[0].(*tg.UpdateUser) refresh, ok := updates.Updates[0].(*tg.UpdateUser)
if !ok || refresh.UserID != targetID { if !ok || refresh.UserID != targetID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0]) t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
} }
bookkeeping, ok := updates.Updates[1].(*tg.UpdateDeleteMessages) for _, update := range updates.Updates {
if !ok || bookkeeping.Pts != 7 || bookkeeping.PtsCount != 1 || len(bookkeeping.Messages) != 0 { if _, syntheticDelete := update.(*tg.UpdateDeleteMessages); syntheticDelete {
t.Fatalf("bookkeeping = %T %+v", updates.Updates[1], updates.Updates[1]) t.Fatalf("synthetic delete bookkeeping leaked into moderation update: %+v", update)
}
} }
if len(updates.Users) != 1 { if len(updates.Users) != 1 {
t.Fatalf("users = %+v", updates.Users) t.Fatalf("users = %+v", updates.Users)
} }
user, ok := updates.Users[0].(*tg.User) if user, ok := updates.Users[0].(*tg.User); !ok || user.ID != targetID || !user.Scam || user.Fake {
if !ok || user.ID != targetID || !user.Scam || user.Fake {
t.Fatalf("user = %T %+v", updates.Users[0], updates.Users[0]) t.Fatalf("user = %T %+v", updates.Users[0], updates.Users[0])
} }
difference := tgUpdatesDifference(viewerID, domain.UpdateDifference{
State: domain.UpdateState{Pts: 7, Date: event.Date},
Events: []domain.UpdateEvent{event},
})
full, ok := difference.(*tg.UpdatesDifference)
if !ok || len(full.OtherUpdates) != 1 || len(full.Users) != 1 {
t.Fatalf("difference = %T %+v", difference, difference)
}
if refresh, ok := full.OtherUpdates[0].(*tg.UpdateUser); !ok || refresh.UserID != targetID {
t.Fatalf("difference refresh = %T %+v", full.OtherUpdates[0], full.OtherUpdates[0])
}
if user, ok := full.Users[0].(*tg.User); !ok || !user.Scam || user.Fake {
t.Fatalf("difference user = %T %+v", full.Users[0], full.Users[0])
}
} }
func TestChannelModerationUpdateCarriesStandardFlagsAndPts(t *testing.T) { func TestChannelModerationFlagsPushStandardNonPTSUpdate(t *testing.T) {
const ( const (
viewerID = int64(3003) ownerID = int64(3003)
memberID = int64(3004)
channelID = int64(4004) channelID = int64(4004)
) )
event := domain.UpdateEvent{ channels := &moderationProjectionChannels{}
UserID: viewerID, sessions := &captureSessions{
Type: domain.UpdateEventChannelState, onlineUserIDs: []int64{ownerID, memberID},
Pts: 9, PtsCount: 1, Date: 1700000001, channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, }
Channels: []domain.Channel{{ r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
ID: channelID, AccessHash: 44, CreatorUserID: viewerID, if err := r.NotifyChannelChanged(context.Background(), domain.Channel{
Title: "Flagged channel", Megagroup: true, Scam: true, ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
}}, Title: "Flagged channel", Megagroup: true, Scam: true,
}); err != nil {
t.Fatalf("notify channel flags: %v", err)
} }
updates := tgUpdateForOutboxEventForViewer(event, viewerID) pushed := sessions.pushedUserIDs()
if updates == nil || len(updates.Updates) != 2 { if len(pushed) != 2 {
t.Fatalf("updates = %+v", updates) t.Fatalf("pushed user ids = %v", pushed)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
} }
refresh, ok := updates.Updates[0].(*tg.UpdateChannel) refresh, ok := updates.Updates[0].(*tg.UpdateChannel)
if !ok || refresh.ChannelID != channelID { if !ok || refresh.ChannelID != channelID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0]) t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
} }
bookkeeping, ok := updates.Updates[1].(*tg.UpdateDeleteMessages) for _, update := range updates.Updates {
if !ok || bookkeeping.Pts != 9 || bookkeeping.PtsCount != 1 || len(bookkeeping.Messages) != 0 { if _, syntheticDelete := update.(*tg.UpdateDeleteMessages); syntheticDelete {
t.Fatalf("bookkeeping = %T %+v", updates.Updates[1], updates.Updates[1]) t.Fatalf("synthetic delete bookkeeping leaked into moderation update: %+v", update)
}
} }
if len(updates.Chats) != 1 { if len(updates.Chats) != 1 {
t.Fatalf("chats = %+v", updates.Chats) t.Fatalf("chats = %+v", updates.Chats)
@ -130,27 +137,18 @@ func TestChannelModerationUpdateCarriesStandardFlagsAndPts(t *testing.T) {
} }
} }
func TestModerationRefreshEventsBypassServerProjectionCaches(t *testing.T) { func TestChannelStateRefreshEventBypassesServerProjectionCache(t *testing.T) {
users := &moderationProjectionUsers{}
channels := &moderationProjectionChannels{} channels := &moderationProjectionChannels{}
r := New(Config{}, Deps{Users: users, Channels: channels}, zap.NewNop(), clock.System) r := New(Config{}, Deps{Channels: channels}, zap.NewNop(), clock.System)
const viewerID = int64(5005) const viewerID = int64(5005)
events := r.enrichUpdateEvents(context.Background(), viewerID, []domain.UpdateEvent{ events := r.enrichUpdateEvents(context.Background(), viewerID, []domain.UpdateEvent{
{
UserID: viewerID, Type: domain.UpdateEventUserProfile,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 6006},
},
{ {
UserID: viewerID, Type: domain.UpdateEventChannelState, UserID: viewerID, Type: domain.UpdateEventChannelState,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 7007}, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 7007},
}, },
}) })
if users.freshCalls != 1 || len(events[0].Users) != 1 || if channels.freshCalls != 1 || len(events[0].Channels) != 1 ||
events[0].Users[0].FirstName != "fresh" || !events[0].Users[0].Scam { events[0].Channels[0].Title != "fresh" || !events[0].Channels[0].Scam {
t.Fatalf("authoritative user refresh = calls:%d users:%+v", users.freshCalls, events[0].Users) t.Fatalf("authoritative channel refresh = calls:%d channels:%+v", channels.freshCalls, events[0].Channels)
}
if channels.freshCalls != 1 || len(events[1].Channels) != 1 ||
events[1].Channels[0].Title != "fresh" || !events[1].Channels[0].Scam {
t.Fatalf("authoritative channel refresh = calls:%d channels:%+v", channels.freshCalls, events[1].Channels)
} }
} }

View file

@ -543,17 +543,6 @@ func tgUpdateForOutboxEventForViewer(event domain.UpdateEvent, viewerUserID int6
Date: event.Date, Date: event.Date,
Seq: 0, // 私聊不维护账号级 seq,恒 0 Seq: 0, // 私聊不维护账号级 seq,恒 0
} }
case domain.UpdateEventUserProfile:
update := tgOtherUpdateFromEvent(event)
if update == nil {
return nil
}
return &tg.Updates{
Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, event),
Users: tgUsersForViewer(viewerUserID, event.Users),
Date: event.Date,
Seq: 0,
}
case domain.UpdateEventChannelState: case domain.UpdateEventChannelState:
update := tgOtherUpdateFromEvent(event) update := tgOtherUpdateFromEvent(event)
if update == nil { if update == nil {

View file

@ -78,6 +78,66 @@ func (r *Router) NotifyUserChanged(ctx context.Context, u domain.User) error {
return nil return nil
} }
type moderationUserAudienceService interface {
ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error)
}
// NotifyUserModerationFlagsChanged sends the standard, non-PTS updateUser
// shape to online accounts that already know the peer. Offline accounts
// converge when their next authoritative peer/dialog read carries the updated
// User flags; no synthetic message-box event is created.
func (r *Router) NotifyUserModerationFlagsChanged(ctx context.Context, u domain.User) error {
if r == nil || u.ID == 0 {
return nil
}
r.invalidateRPCProjectionForUser(u.ID)
if r.deps.Users == nil {
return nil
}
audience := []int64{u.ID}
if service, ok := r.deps.Users.(moderationUserAudienceService); ok {
viewers, err := service.ModerationFlagAudience(ctx, u.ID, 4096)
if err != nil {
r.log.Warn("list moderation user update audience",
zap.Int64("target_user_id", u.ID),
zap.Error(err))
} else if len(viewers) != 0 {
audience = viewers
}
}
pushCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
seen := make(map[int64]struct{}, len(audience))
for _, viewerUserID := range audience {
if viewerUserID == 0 {
continue
}
if _, ok := seen[viewerUserID]; ok {
continue
}
seen[viewerUserID] = struct{}{}
if online, ok := r.deps.Sessions.(OnlineUserProvider); ok && !online.IsUserOnline(viewerUserID) {
continue
}
users, err := r.deps.Users.ByIDs(pushCtx, viewerUserID, []int64{u.ID})
if err != nil || len(users) == 0 {
r.log.Warn("project moderation user update",
zap.Int64("viewer_user_id", viewerUserID),
zap.Int64("target_user_id", u.ID),
zap.Error(err))
continue
}
r.pushUserUpdates(pushCtx, viewerUserID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}},
Users: tgUsersForViewer(viewerUserID, users),
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
}
return nil
}
// pushPremiumStatusUpdate 向用户本人的全部在线 session 推送会员状态变化。 // pushPremiumStatusUpdate 向用户本人的全部在线 session 推送会员状态变化。
// 授予、到期与 admin 认证变更共用:updateUser 触发客户端用随附的 self user // 授予、到期与 admin 认证变更共用:updateUser 触发客户端用随附的 self user
// 对象刷新 premium/verified 等基础 flag(TDesktop processUser 按 flag 翻转)。 // 对象刷新 premium/verified 等基础 flag(TDesktop processUser 按 flag 翻转)。

View file

@ -27,20 +27,6 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
allUserIDs := make(map[int64]struct{}) allUserIDs := make(map[int64]struct{})
allChannelIDs := make(map[int64]struct{}) allChannelIDs := make(map[int64]struct{})
for i := range out { for i := range out {
if out[i].Type == domain.UpdateEventUserProfile {
if service, ok := r.deps.Users.(UserAuthoritativeProjectionService); ok {
users, err := service.ByIDsAuthoritative(ctx, viewerUserID, []int64{out[i].Peer.ID})
if err != nil {
r.log.Warn("reload authoritative user profile event",
zap.Int64("viewer_user_id", viewerUserID),
zap.Int64("target_user_id", out[i].Peer.ID),
zap.Error(err))
} else {
out[i].Users = users
cache.primeUsers(viewerUserID, users)
}
}
}
if out[i].Type == domain.UpdateEventChannelState { if out[i].Type == domain.UpdateEventChannelState {
if service, ok := r.deps.Channels.(ChannelAuthoritativeProjectionService); ok { if service, ok := r.deps.Channels.(ChannelAuthoritativeProjectionService); ok {
views, err := service.GetChannelsAuthoritative(ctx, viewerUserID, []int64{out[i].Peer.ID}) views, err := service.GetChannelsAuthoritative(ctx, viewerUserID, []int64{out[i].Peer.ID})

View file

@ -10,7 +10,6 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
) )
func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) {
@ -338,34 +337,6 @@ FOR UPDATE`, channelID).Scan(&currentScam, &currentFake); err != nil {
if _, err := tx.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil { if _, err := tx.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err) return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
} }
audience, err := channelModerationFlagAudience(ctx, tx, channelID, maxModerationFlagAudience)
if err != nil {
return domain.Channel{}, err
}
qtx := sqlcgen.New(tx)
date := nowUnix()
for _, viewerUserID := range audience {
pts, err := reserveUserPts(ctx, tx, viewerUserID, 1)
if err != nil {
return domain.Channel{}, fmt.Errorf("reserve channel moderation flag pts for viewer %d: %w", viewerUserID, err)
}
event := domain.UpdateEvent{
UserID: viewerUserID,
Type: domain.UpdateEventChannelState,
Pts: pts, PtsCount: 1, Date: date,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
}
if err := appendUserUpdateEvent(ctx, tx, qtx, viewerUserID, event); err != nil {
return domain.Channel{}, fmt.Errorf("append channel moderation flag event for viewer %d: %w", viewerUserID, err)
}
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: viewerUserID,
Pts: int32(pts),
EventType: string(event.Type),
}); err != nil {
return domain.Channel{}, fmt.Errorf("enqueue channel moderation flag dispatch for viewer %d: %w", viewerUserID, err)
}
}
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return domain.Channel{}, fmt.Errorf("commit channel scam/fake: %w", err) return domain.Channel{}, fmt.Errorf("commit channel scam/fake: %w", err)
} }
@ -378,37 +349,6 @@ FOR UPDATE`, channelID).Scan(&currentScam, &currentFake); err != nil {
return channel, nil return channel, nil
} }
func channelModerationFlagAudience(ctx context.Context, db sqlcgen.DBTX, channelID int64, limit int) ([]int64, error) {
if channelID <= 0 || limit <= 0 {
return nil, nil
}
rows, err := db.Query(ctx, `
SELECT cm.user_id
FROM channel_members cm
JOIN users u ON u.id = cm.user_id AND u.deleted_at IS NULL
WHERE cm.channel_id = $1 AND cm.status = 'active'
ORDER BY cm.user_id
LIMIT $2`, channelID, limit)
if err != nil {
return nil, fmt.Errorf("list channel moderation flag audience: %w", err)
}
defer rows.Close()
out := make([]int64, 0)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan channel moderation flag audience: %w", err)
}
if userID != 0 {
out = append(out, userID)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate channel moderation flag audience: %w", err)
}
return out, nil
}
// SetChannelAdminSettings applies an admin-direct moderation-settings patch // SetChannelAdminSettings applies an admin-direct moderation-settings patch
// (no membership/permission checks). nil fields are left unchanged. // (no membership/permission checks). nil fields are left unchanged.
func (s *ChannelStore) SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) { func (s *ChannelStore) SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {

View file

@ -49,7 +49,7 @@ func TestModerationFlagsRejectImpossibleStateAtPostgresBoundary(t *testing.T) {
} }
} }
func TestUserModerationFlagsCreateDurableViewerProfileEvents(t *testing.T) { func TestUserModerationFlagsDoNotAdvanceAccountPts(t *testing.T) {
pool := testPool(t) pool := testPool(t)
ctx := context.Background() ctx := context.Background()
suffix := randomSuffix(t) suffix := randomSuffix(t)
@ -72,6 +72,15 @@ func TestUserModerationFlagsCreateDurableViewerProfileEvents(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("save reverse contact: %v", err) t.Fatalf("save reverse contact: %v", err)
} }
viewers := []domain.User{target, savedTargetViewer, savedByTargetViewer, unrelated}
baseline := make(map[int64]int, len(viewers))
for _, viewer := range viewers {
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil {
t.Fatalf("viewer %d baseline pts: %v", viewer.ID, err)
}
baseline[viewer.ID] = pts
}
updated, err := users.SetScamFake(ctx, target.ID, true, false) updated, err := users.SetScamFake(ctx, target.ID, true, false)
if err != nil { if err != nil {
@ -81,20 +90,16 @@ func TestUserModerationFlagsCreateDurableViewerProfileEvents(t *testing.T) {
t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake) t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake)
} }
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} { for _, viewer := range viewers {
got, err := events.ListAfter(ctx, viewer.ID, 0, 10) pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil { if err != nil || pts != baseline[viewer.ID] {
t.Fatalf("list viewer %d events: %v", viewer.ID, err) t.Fatalf("viewer %d pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
} }
if len(got) != 1 || got[0].Type != domain.UpdateEventUserProfile || got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
got[0].Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) || if err != nil || len(got) != 0 {
got[0].Pts != 1 || got[0].PtsCount != 1 { t.Fatalf("viewer %d moderation events=%+v err=%v", viewer.ID, got, err)
t.Fatalf("viewer %d events = %+v", viewer.ID, got)
} }
} }
if got, err := events.ListAfter(ctx, unrelated.ID, 0, 10); err != nil || len(got) != 0 {
t.Fatalf("unrelated events = %+v err=%v", got, err)
}
var outboxCount int var outboxCount int
if err := pool.QueryRow(ctx, ` if err := pool.QueryRow(ctx, `
SELECT count(*) SELECT count(*)
@ -102,33 +107,42 @@ FROM dispatch_outbox
WHERE target_user_id = ANY($1::bigint[]) WHERE target_user_id = ANY($1::bigint[])
AND event_type = 'user_profile'`, AND event_type = 'user_profile'`,
[]int64{target.ID, savedTargetViewer.ID, savedByTargetViewer.ID, unrelated.ID}, []int64{target.ID, savedTargetViewer.ID, savedByTargetViewer.ID, unrelated.ID},
).Scan(&outboxCount); err != nil || outboxCount != 3 { ).Scan(&outboxCount); err != nil || outboxCount != 0 {
t.Fatalf("profile outbox count=%d err=%v", outboxCount, err) t.Fatalf("profile outbox count=%d err=%v", outboxCount, err)
} }
audience, err := users.ModerationFlagAudience(ctx, target.ID, 4096)
if err != nil {
t.Fatalf("moderation audience: %v", err)
}
audienceSet := make(map[int64]struct{}, len(audience))
for _, userID := range audience {
audienceSet[userID] = struct{}{}
}
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} {
if _, ok := audienceSet[viewer.ID]; !ok {
t.Fatalf("viewer %d missing from audience %v", viewer.ID, audience)
}
}
if _, ok := audienceSet[unrelated.ID]; ok {
t.Fatalf("unrelated viewer included in audience %v", audience)
}
if _, err := users.SetScamFake(ctx, target.ID, true, false); err != nil { if _, err := users.SetScamFake(ctx, target.ID, true, false); err != nil {
t.Fatalf("repeat same flags: %v", err) t.Fatalf("repeat same flags: %v", err)
} }
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} {
got, err := events.ListAfter(ctx, viewer.ID, 0, 10)
if err != nil || len(got) != 1 {
t.Fatalf("same-state viewer %d events = %+v err=%v", viewer.ID, got, err)
}
}
if _, err := users.SetScamFake(ctx, target.ID, false, true); err != nil { if _, err := users.SetScamFake(ctx, target.ID, false, true); err != nil {
t.Fatalf("switch to fake: %v", err) t.Fatalf("switch to fake: %v", err)
} }
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} { for _, viewer := range viewers {
got, err := events.ListAfter(ctx, viewer.ID, 1, 10) pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil || len(got) != 1 || got[0].Pts != 2 || if err != nil || pts != baseline[viewer.ID] {
got[0].Type != domain.UpdateEventUserProfile { t.Fatalf("viewer %d final pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
t.Fatalf("second viewer %d events = %+v err=%v", viewer.ID, got, err)
} }
} }
} }
func TestChannelModerationFlagsCreateDurableMemberStateEvents(t *testing.T) { func TestChannelModerationFlagsDoNotAdvanceMemberAccountPts(t *testing.T) {
pool := testPool(t) pool := testPool(t)
ctx := context.Background() ctx := context.Background()
suffix := randomSuffix(t) suffix := randomSuffix(t)
@ -166,17 +180,15 @@ func TestChannelModerationFlagsCreateDurableMemberStateEvents(t *testing.T) {
if !updated.Scam || updated.Fake { if !updated.Scam || updated.Fake {
t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake) t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake)
} }
for _, viewer := range []domain.User{owner, member} { for _, viewer := range []domain.User{owner, member, unrelated} {
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10) pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil || len(got) != 1 || if err != nil || pts != baseline[viewer.ID] {
got[0].Type != domain.UpdateEventChannelState || t.Fatalf("viewer %d pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
got[0].Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}) || }
got[0].Pts != baseline[viewer.ID]+1 || got[0].PtsCount != 1 { got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
t.Fatalf("viewer %d events = %+v err=%v", viewer.ID, got, err) if err != nil || len(got) != 0 {
t.Fatalf("viewer %d moderation events=%+v err=%v", viewer.ID, got, err)
} }
}
if got, err := events.ListAfter(ctx, unrelated.ID, baseline[unrelated.ID], 10); err != nil || len(got) != 0 {
t.Fatalf("unrelated events = %+v err=%v", got, err)
} }
var outboxCount int var outboxCount int
@ -187,28 +199,20 @@ WHERE target_user_id = ANY($1::bigint[])
AND event_type = 'channel_state' AND event_type = 'channel_state'
AND pts > 0`, AND pts > 0`,
[]int64{owner.ID, member.ID, unrelated.ID}, []int64{owner.ID, member.ID, unrelated.ID},
).Scan(&outboxCount); err != nil || outboxCount != 2 { ).Scan(&outboxCount); err != nil || outboxCount != 0 {
t.Fatalf("channel state outbox count=%d err=%v", outboxCount, err) t.Fatalf("channel state outbox count=%d err=%v", outboxCount, err)
} }
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, false); err != nil { if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, false); err != nil {
t.Fatalf("repeat same channel flags: %v", err) t.Fatalf("repeat same channel flags: %v", err)
} }
for _, viewer := range []domain.User{owner, member} {
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
if err != nil || len(got) != 1 {
t.Fatalf("same-state viewer %d events = %+v err=%v", viewer.ID, got, err)
}
}
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, false, true); err != nil { if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, false, true); err != nil {
t.Fatalf("switch channel to fake: %v", err) t.Fatalf("switch channel to fake: %v", err)
} }
for _, viewer := range []domain.User{owner, member} { for _, viewer := range []domain.User{owner, member, unrelated} {
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID]+1, 10) pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil || len(got) != 1 || got[0].Pts != baseline[viewer.ID]+2 || if err != nil || pts != baseline[viewer.ID] {
got[0].Type != domain.UpdateEventChannelState { t.Fatalf("viewer %d final pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
t.Fatalf("second viewer %d events = %+v err=%v", viewer.ID, got, err)
} }
} }
} }

View file

@ -391,33 +391,6 @@ FOR UPDATE`, userID).Scan(&currentScam, &currentFake); err != nil {
} }
return domain.User{}, fmt.Errorf("set user scam/fake: %w", err) return domain.User{}, fmt.Errorf("set user scam/fake: %w", err)
} }
audience, err := moderationFlagAudience(ctx, tx, userID, maxModerationFlagAudience)
if err != nil {
return domain.User{}, err
}
date := int(time.Now().Unix())
for _, viewerUserID := range audience {
pts, err := reserveUserPts(ctx, tx, viewerUserID, 1)
if err != nil {
return domain.User{}, fmt.Errorf("reserve moderation flag pts for viewer %d: %w", viewerUserID, err)
}
event := domain.UpdateEvent{
UserID: viewerUserID,
Type: domain.UpdateEventUserProfile,
Pts: pts, PtsCount: 1, Date: date,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
}
if err := appendUserUpdateEvent(ctx, tx, qtx, viewerUserID, event); err != nil {
return domain.User{}, fmt.Errorf("append moderation flag event for viewer %d: %w", viewerUserID, err)
}
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: viewerUserID,
Pts: int32(pts),
EventType: string(event.Type),
}); err != nil {
return domain.User{}, fmt.Errorf("enqueue moderation flag dispatch for viewer %d: %w", viewerUserID, err)
}
}
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return domain.User{}, fmt.Errorf("commit user scam/fake: %w", err) return domain.User{}, fmt.Errorf("commit user scam/fake: %w", err)
} }
@ -427,10 +400,17 @@ FOR UPDATE`, userID).Scan(&currentScam, &currentFake); err != nil {
const maxModerationFlagAudience = 4096 const maxModerationFlagAudience = 4096
// moderationFlagAudience returns the bounded set of accounts that can already // ModerationFlagAudience returns the bounded set of accounts that can already
// observe the target through a direct contact or private dialog. The final // observe the target through a direct contact or private dialog. It is used
// user_id ordering is deliberate: concurrent moderation changes acquire user // only for best-effort, non-PTS updateUser fanout after the authoritative flag
// pts watermarks in the same global order and cannot deadlock by target order. // mutation commits.
func (s *UserStore) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) {
if limit > maxModerationFlagAudience {
limit = maxModerationFlagAudience
}
return moderationFlagAudience(ctx, s.db, userID, limit)
}
func moderationFlagAudience(ctx context.Context, db sqlcgen.DBTX, userID int64, limit int) ([]int64, error) { func moderationFlagAudience(ctx context.Context, db sqlcgen.DBTX, userID int64, limit int) ([]int64, error) {
if userID <= 0 || limit <= 0 { if userID <= 0 || limit <= 0 {
return nil, nil return nil, nil