feat: sync privacy read-model rules

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent 9f467f4be7
commit 5d5883a3d1
20 changed files with 1011 additions and 20 deletions

View file

@ -937,6 +937,7 @@ func run(logger *zap.Logger) error {
BaseUsers: userCache,
BotProfiles: botsService,
StarGifts: giftsService,
AccountSettings: router,
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)

View file

@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS account_settings_read_model_changed ON account_settings;
DROP FUNCTION IF EXISTS telesrv_notify_account_settings_read_model();

View file

@ -0,0 +1,25 @@
CREATE OR REPLACE FUNCTION telesrv_notify_account_settings_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
owner_id bigint;
BEGIN
owner_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
IF owner_id IS NOT NULL AND owner_id > 0 THEN
PERFORM telesrv_bump_read_model_version(
'account_settings',
owner_id,
'user',
owner_id
);
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$;
DROP TRIGGER IF EXISTS account_settings_read_model_changed ON account_settings;
CREATE TRIGGER account_settings_read_model_changed
AFTER INSERT OR UPDATE OR DELETE ON account_settings
FOR EACH ROW
EXECUTE FUNCTION telesrv_notify_account_settings_read_model();

View file

@ -363,6 +363,21 @@ func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int
return out, nil
}
// ViewerIsPremium reads the same bounded viewer-facts read model used by
// AllowPremium privacy rules. Contact permission checks must not bypass that
// cache with a per-send users-table query.
func (s *Service) ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool, error) {
if viewerUserID == 0 {
return false, nil
}
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return false, err
}
fact := facts[viewerUserID]
return fact.Found && !fact.Bot && fact.PremiumUntil > s.now().Unix(), nil
}
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
// 调 CanSee但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。

View file

@ -3,11 +3,44 @@ package privacy
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type countingBaseUsers struct {
calls int
users map[int64]domain.User
}
func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64) ([]domain.User, error) {
p.calls++
out := make([]domain.User, 0, len(userIDs))
for _, userID := range userIDs {
if user, ok := p.users[userID]; ok {
out = append(out, user)
}
}
return out, nil
}
type countingMemberships struct {
calls int
active map[int64]map[int64]bool
}
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
p.calls++
out := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if p.active[channelID][userID] {
out = append(out, userID)
}
}
return out, nil
}
func TestDefaultPrivacyRules(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
@ -192,3 +225,111 @@ func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
}
}
}
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
users := &countingBaseUsers{users: map[int64]domain.User{
2001: {ID: 2001, PremiumUntil: 2000},
2002: {ID: 2002, Bot: true},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(users, nil)
svc.now = func() time.Time { return time.Unix(1000, 0) }
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowPremium},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set premium rules: %v", err)
}
if _, err := svc.SetRules(ctx, 1002, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowBots},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set bot rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001, 1002},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyNoPaidMessages] ||
got[1001][2002][domain.PrivacyKeyNoPaidMessages] ||
got[1002][2001][domain.PrivacyKeyNoPaidMessages] ||
!got[1002][2002][domain.PrivacyKeyNoPaidMessages] {
t.Fatalf("unexpected premium/bot visibility matrix: %+v", got)
}
if users.calls != 1 {
t.Fatalf("base user cold loads = %d, want one batched load", users.calls)
}
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || !premium {
t.Fatalf("warm ViewerIsPremium = %v, err=%v; want true", premium, err)
}
if users.calls != 1 {
t.Fatalf("warm viewer facts hit called backend: calls=%d", users.calls)
}
users.users[2001] = domain.User{ID: 2001}
svc.InvalidateViewerFacts(2001)
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || premium {
t.Fatalf("invalidated ViewerIsPremium = %v, err=%v; want false", premium, err)
}
if users.calls != 2 {
t.Fatalf("invalidated viewer facts cold loads = %d, want 2", users.calls)
}
}
func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
memberships := &countingMemberships{active: map[int64]map[int64]bool{
9001: {2001: true},
9002: {},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{9001, 9002}},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set participant rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyChatInvite},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyChatInvite] ||
got[1001][2002][domain.PrivacyKeyChatInvite] {
t.Fatalf("unexpected membership visibility matrix: %+v", got)
}
if memberships.calls != 2 {
t.Fatalf("membership cold loads = %d, want one batch per referenced chat", memberships.calls)
}
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
}
if memberships.calls != 2 {
t.Fatalf("negative cache missed: calls=%d", memberships.calls)
}
memberships.active[9002][2002] = true
svc.InvalidateMembership(9002, 2002)
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
}
if memberships.calls != 3 {
t.Fatalf("pair invalidation reloads = %d, want 3", memberships.calls)
}
}

View file

@ -0,0 +1,122 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appprivacy "telesrv/internal/app/privacy"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountPrivacyAllKeysRoundTripAndRecordDifferenceEvents(t *testing.T) {
ctx := context.Background()
const userID int64 = 8101
authKeyID := [8]byte{8, 1}
sessionID := int64(81)
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
events := memory.NewUpdateEventStore()
updates := appupdates.NewService(memory.NewUpdateStateStore(), events)
router := New(Config{}, Deps{
Privacy: privacy,
Updates: updates,
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
requestCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, userID), authKeyID), sessionID)
keys := []struct {
name string
input tg.InputPrivacyKeyClass
domain domain.PrivacyKey
wire func(tg.PrivacyKeyClass) bool
}{
{"status_timestamp", &tg.InputPrivacyKeyStatusTimestamp{}, domain.PrivacyKeyStatusTimestamp, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyStatusTimestamp); return ok }},
{"chat_invite", &tg.InputPrivacyKeyChatInvite{}, domain.PrivacyKeyChatInvite, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyChatInvite); return ok }},
{"phone_call", &tg.InputPrivacyKeyPhoneCall{}, domain.PrivacyKeyPhoneCall, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneCall); return ok }},
{"phone_p2p", &tg.InputPrivacyKeyPhoneP2P{}, domain.PrivacyKeyPhoneP2P, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneP2P); return ok }},
{"forwards", &tg.InputPrivacyKeyForwards{}, domain.PrivacyKeyForwards, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyForwards); return ok }},
{"profile_photo", &tg.InputPrivacyKeyProfilePhoto{}, domain.PrivacyKeyProfilePhoto, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyProfilePhoto); return ok }},
{"phone_number", &tg.InputPrivacyKeyPhoneNumber{}, domain.PrivacyKeyPhoneNumber, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneNumber); return ok }},
{"added_by_phone", &tg.InputPrivacyKeyAddedByPhone{}, domain.PrivacyKeyAddedByPhone, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyAddedByPhone); return ok }},
{"voice_messages", &tg.InputPrivacyKeyVoiceMessages{}, domain.PrivacyKeyVoiceMessages, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyVoiceMessages); return ok }},
{"about", &tg.InputPrivacyKeyAbout{}, domain.PrivacyKeyAbout, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyAbout); return ok }},
{"birthday", &tg.InputPrivacyKeyBirthday{}, domain.PrivacyKeyBirthday, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyBirthday); return ok }},
{"star_gifts_auto_save", &tg.InputPrivacyKeyStarGiftsAutoSave{}, domain.PrivacyKeyStarGiftsAutoSave, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyStarGiftsAutoSave); return ok }},
{"no_paid_messages", &tg.InputPrivacyKeyNoPaidMessages{}, domain.PrivacyKeyNoPaidMessages, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyNoPaidMessages); return ok }},
{"saved_music", &tg.InputPrivacyKeySavedMusic{}, domain.PrivacyKeySavedMusic, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeySavedMusic); return ok }},
}
for _, test := range keys {
t.Run(test.name, func(t *testing.T) {
gotKey, ok := domainPrivacyKeyFromInput(test.input)
if !ok || gotKey != test.domain {
t.Fatalf("input key maps to %q/%v, want %q/true", gotKey, ok, test.domain)
}
if !test.wire(tgPrivacyKey(test.domain)) {
t.Fatalf("domain key %q projected as %T", test.domain, tgPrivacyKey(test.domain))
}
set, err := router.onAccountSetPrivacy(requestCtx, &tg.AccountSetPrivacyRequest{
Key: test.input,
Rules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueDisallowAll{}},
})
if err != nil {
t.Fatalf("setPrivacy: %v", err)
}
if len(set.Rules) != 1 {
t.Fatalf("setPrivacy rules=%d, want 1", len(set.Rules))
}
if _, ok := set.Rules[0].(*tg.PrivacyValueDisallowAll); !ok {
t.Fatalf("setPrivacy rule=%T, want disallowAll", set.Rules[0])
}
get, err := router.onAccountGetPrivacy(requestCtx, test.input)
if err != nil {
t.Fatalf("getPrivacy: %v", err)
}
if len(get.Rules) != 1 {
t.Fatalf("getPrivacy rules=%d, want 1", len(get.Rules))
}
if _, ok := get.Rules[0].(*tg.PrivacyValueDisallowAll); !ok {
t.Fatalf("getPrivacy rule=%T, want disallowAll", get.Rules[0])
}
})
}
recorded, err := events.ListAfter(ctx, userID, 0, 100)
if err != nil {
t.Fatalf("list privacy events: %v", err)
}
if len(recorded) != len(keys) {
t.Fatalf("privacy events=%d, want %d", len(recorded), len(keys))
}
for i, event := range recorded {
if event.Type != domain.UpdateEventPrivacy ||
event.Privacy.OwnerUserID != userID ||
event.Privacy.Key != keys[i].domain ||
event.PtsCount != 1 {
t.Fatalf("event[%d]=%+v, want durable privacy snapshot for %q", i, event, keys[i].domain)
}
}
difference, err := updates.GetDifference(ctx, [8]byte{8, 2}, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("getDifference: %v", err)
}
wireDifference, ok := tgUpdatesDifference(userID, difference).(*tg.UpdatesDifference)
if !ok || len(wireDifference.OtherUpdates) != len(keys) {
t.Fatalf("wire difference=%T updates=%d, want %d privacy updates", wireDifference, len(wireDifference.OtherUpdates), len(keys))
}
for i, update := range wireDifference.OtherUpdates {
privacyUpdate, ok := update.(*tg.UpdatePrivacy)
if !ok {
t.Fatalf("difference update[%d]=%T, want updatePrivacy", i, update)
}
if !keys[i].wire(privacyUpdate.Key) {
t.Fatalf("difference update[%d] key=%T, want %q", i, privacyUpdate.Key, keys[i].domain)
}
}
}

View file

@ -10,9 +10,10 @@ import (
const (
accountSettingsCacheMaxEntries = 4096
// accountSettingsCacheTTL 兜底跨实例失效;同实例 Set 即时失效。设置页连续调
// getGlobalPrivacy/getAccountTTL/getContentSettings/getContactSignUp 时只查一次 PG。
accountSettingsCacheTTL = 60 * time.Second
// accountSettingsCacheTTL is only the lost-notification safety net. Normal
// consistency comes from account_settings read-model notifications; local
// writes update the cached value directly.
accountSettingsCacheTTL = 24 * time.Hour
)
// accountSettingsCache 缓存 userID→AccountSettings避免设置页 4 个 get handler 各查
@ -52,6 +53,38 @@ func (c *accountSettingsCache) Store(userID int64, settings domain.AccountSettin
c.cache.Store(userID, settings)
}
func (c *accountSettingsCache) Flush() {
if c == nil {
return
}
c.cache.Flush()
}
// InvalidateAccountSettingsReadModel is called by the shared PostgreSQL
// read-model listener. Router owns this cache, so exposing the invalidation on
// Router keeps store/postgres independent from the RPC package.
func (r *Router) InvalidateAccountSettingsReadModel(userID int64) {
if r == nil || r.accountSettings == nil {
return
}
r.accountSettings.Delete(userID)
}
func (r *Router) FlushAccountSettingsReadModel() {
if r == nil || r.accountSettings == nil {
return
}
r.accountSettings.Flush()
}
func (r *Router) WarmAccountSettingsReadModel(ctx context.Context, userID int64) error {
if r == nil || userID == 0 {
return nil
}
_, err := r.cachedAccountSettings(ctx, userID)
return err
}
type accountSettingsBatchReader interface {
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
}

View file

@ -8,6 +8,7 @@ import (
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appprivacy "telesrv/internal/app/privacy"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
@ -119,6 +120,99 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
}
}
func TestMessagesCreateChatFiltersPrivacyBeforeMembershipWritesRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
allowed, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Allowed"})
if err != nil {
t.Fatalf("create allowed user: %v", err)
}
denied, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550001033", FirstName: "Denied"})
if err != nil {
t.Fatalf("create denied user: %v", err)
}
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, denied.ID, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set denied invite privacy: %v", err)
}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Privacy: privacy,
}, zaptest.NewLogger(t), clock.System)
invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: allowed.ID, AccessHash: allowed.AccessHash},
&tg.InputUser{UserID: denied.ID, AccessHash: denied.AccessHash},
},
Title: "Privacy Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
if len(invited.MissingInvitees) != 1 || invited.MissingInvitees[0].UserID != denied.ID {
t.Fatalf("missing invitees = %+v, want denied user %d", invited.MissingInvitees, denied.ID)
}
updates, ok := invited.Updates.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", invited.Updates)
}
var channel *tg.Channel
for _, chat := range updates.Chats {
if candidate, ok := chat.(*tg.Channel); ok {
channel = candidate
break
}
}
if channel == nil || channel.ParticipantsCount != 2 {
t.Fatalf("channel = %#v, want owner + allowed only", channel)
}
for _, update := range updates.Updates {
newMessage, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
service, ok := newMessage.Message.(*tg.MessageService)
if !ok {
continue
}
add, ok := service.Action.(*tg.MessageActionChatAddUser)
if !ok {
continue
}
if len(add.Users) != 1 || add.Users[0] != allowed.ID {
t.Fatalf("invite action users = %v, want allowed user %d only", add.Users, allowed.ID)
}
}
participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 10,
})
if err != nil {
t.Fatalf("get participants: %v", err)
}
list := participants.(*tg.ChannelsChannelParticipants)
if list.Count != 2 {
t.Fatalf("participant count = %d, want 2", list.Count)
}
for _, user := range list.Users {
if got, ok := user.(*tg.User); ok && got.ID == denied.ID {
t.Fatalf("denied user %d was written as a member", denied.ID)
}
}
}
func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
tests := []struct {
name string

View file

@ -110,6 +110,14 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
if err != nil {
return nil, err
}
if toPeer.Type == domain.PeerTypeUser {
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
if err := r.ensurePrivateContactAllowed(ctx, userID, toPeer.ID, req.AllowPaidStars, len(absentIndexes)); err != nil {
return nil, err
}
}
absentIDs := make([]int, len(absentIndexes))
absentRandomIDs := make([]int64, len(absentIndexes))
for i, originalIndex := range absentIndexes {

View file

@ -412,7 +412,7 @@ func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest)
return mediaInvalidErr()
case req.AllowPaidStars < 0:
return starsAmountInvalidErr()
case req.AllowPaidStars > 0 || req.AllowPaidFloodskip:
case req.AllowPaidFloodskip:
return paymentUnsupportedErr()
case !req.SuggestedPost.Zero():
return suggestedPostPeerInvalidErr()

View file

@ -141,10 +141,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
}
return updates, nil
}
if req.AllowPaidStars > 0 {
sendErr = paymentUnsupportedErr()
return nil, sendErr
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil {
sendErr = err
@ -227,6 +223,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
richMessage: richMessage,
allowPaidStars: req.AllowPaidStars,
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
if err != nil {
sendErr = err
@ -249,6 +246,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
replyMarkup: replyMarkup,
richMessage: richMessage,
effect: req.Effect,
allowPaidStars: req.AllowPaidStars,
})
duplicate = dup
if err != nil {

View file

@ -392,15 +392,6 @@ func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) {
}(),
want: "STARS_AMOUNT_INVALID",
},
{
name: "paid stars",
req: func() *tg.MessagesSendMessageRequest {
req := base()
req.SetAllowPaidStars(1)
return req
}(),
want: "PAYMENT_UNSUPPORTED",
},
{
name: "paid floodskip",
req: func() *tg.MessagesSendMessageRequest {
@ -428,3 +419,27 @@ func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) {
})
}
}
func TestMessagesSendMessageAllowsUnusedPaidAuthorizationForFreeRecipient(t *testing.T) {
const (
senderID = int64(1000000001)
recipientID = int64(1000000002)
)
messages := &captureMessages{}
r := New(Config{}, Deps{
Messages: messages,
Users: mapUsersService{users: map[int64]domain.User{
senderID: {ID: senderID, FirstName: "Sender"},
recipientID: {ID: recipientID, FirstName: "Recipient"},
}},
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: recipientID},
Message: "free",
RandomID: 457,
}
req.SetAllowPaidStars(10)
if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), senderID), req); err != nil {
t.Fatalf("free recipient with unused authorization: %v", err)
}
}

View file

@ -13,6 +13,7 @@ import (
"github.com/iamxvbaba/td/tlprofile"
appchannels "telesrv/internal/app/channels"
appmessages "telesrv/internal/app/messages"
appprivacy "telesrv/internal/app/privacy"
appstargifts "telesrv/internal/app/stargifts"
appstars "telesrv/internal/app/stars"
appusers "telesrv/internal/app/users"
@ -983,6 +984,72 @@ func TestStarGiftSaga(t *testing.T) {
}
}
func TestStarGiftAutoSavePrivacyCreatesPendingGiftUntilRecipientApproves(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := context.Background()
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, recipient.ID, domain.PrivacyKeyStarGiftsAutoSave, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set gift auto-save privacy: %v", err)
}
r.deps.Privacy = privacy
senderCtx := WithUserID(ctx, sender.ID)
recipientCtx := WithUserID(ctx, recipient.ID)
invoice := &tg.InputInvoiceStarGift{
Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
GiftID: gift.ID,
}
formClass, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
t.Fatalf("get gift form: %v", err)
}
form := formClass.(*tg.PaymentsPaymentFormStarGift)
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{
FormID: form.FormID,
Invoice: invoice,
}); err != nil {
t.Fatalf("send gift: %v", err)
}
all, err := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{},
Limit: 10,
})
if err != nil || all.Count != 1 || len(all.Gifts) != 1 {
t.Fatalf("all saved gifts count=%d len=%d err=%v, want pending gift", all.Count, len(all.Gifts), err)
}
if !all.Gifts[0].Unsaved {
t.Fatal("privacy-denied incoming gift must be pending (unsaved=true), not rejected")
}
msgID, ok := all.Gifts[0].GetMsgID()
if !ok || msgID == 0 {
t.Fatalf("pending gift msg_id=%d present=%v", msgID, ok)
}
visible, err := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{},
ExcludeUnsaved: true,
Limit: 10,
})
if err != nil || visible.Count != 0 || len(visible.Gifts) != 0 {
t.Fatalf("exclude-unsaved count=%d len=%d err=%v, want hidden pending gift", visible.Count, len(visible.Gifts), err)
}
if ok, err := r.onPaymentsSaveStarGift(recipientCtx, &tg.PaymentsSaveStarGiftRequest{
Stargift: &tg.InputSavedStarGiftUser{MsgID: msgID},
}); err != nil || !ok {
t.Fatalf("recipient approve gift = %v err=%v", ok, err)
}
visible, err = r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{},
ExcludeUnsaved: true,
Limit: 10,
})
if err != nil || visible.Count != 1 || len(visible.Gifts) != 1 || visible.Gifts[0].Unsaved {
t.Fatalf("approved visible gifts=%+v err=%v, want one saved gift", visible, err)
}
}
// 频道 star gift sagachannel peer 能付款发送,但不生成频道历史消息;
// saved gift 用 inputSavedStarGiftChat.saved_id 定位Recent Actions 用 admin log 快照承载。
func TestStarGiftChannelSaga(t *testing.T) {

View file

@ -0,0 +1,137 @@
package rpc
import (
"context"
"math"
"github.com/iamxvbaba/td/tg"
)
type privateContactRequirement struct {
paidStars int64
requirePremium bool
}
type privacyContactFreeEvaluator interface {
CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error)
}
type privacyViewerPremiumEvaluator interface {
ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool, error)
}
func applyPrivateContactRestrictionToUser(user *tg.User, restriction privateContactRequirement) {
if user == nil {
return
}
user.SetContactRequirePremium(restriction.requirePremium)
if restriction.paidStars > 0 {
user.SetSendPaidMessagesStars(restriction.paidStars)
} else {
user.Flags2.Unset(15)
user.SendPaidMessagesStars = 0
}
}
func applyPrivateContactRestrictionToUserFull(full *tg.UserFull, restriction privateContactRequirement) {
if full == nil {
return
}
full.SetContactRequirePremium(restriction.requirePremium)
if restriction.paidStars > 0 {
full.SetSendPaidMessagesStars(restriction.paidStars)
} else {
full.Flags2.Unset(14)
full.SendPaidMessagesStars = 0
}
}
// privateContactRequirementFor returns the recipient's current restriction
// from two in-memory read models:
// - account settings: base premium/paid requirement;
// - privacy/contact facts: contacts and PrivacyKeyNoPaidMessages exceptions.
//
// PostgreSQL is only a bounded cache-miss loader. Local writes are
// write-through and cross-instance changes invalidate + prewarm both models.
func (r *Router) privateContactRestrictionFor(
ctx context.Context,
senderUserID, recipientUserID int64,
) (privateContactRequirement, error) {
if r == nil || senderUserID == 0 || recipientUserID == 0 || senderUserID == recipientUserID {
return privateContactRequirement{}, nil
}
if evaluator, ok := r.deps.Privacy.(privacyContactFreeEvaluator); ok {
free, err := evaluator.CanContactForFreeBatch(ctx, []int64{recipientUserID}, senderUserID)
if err != nil {
return privateContactRequirement{}, internalErr()
}
if free[recipientUserID] {
return privateContactRequirement{}, nil
}
}
settings, err := r.cachedAccountSettings(ctx, recipientUserID)
if err != nil {
return privateContactRequirement{}, internalErr()
}
global := settings.GlobalPrivacy
if global.NoncontactPeersPaidStars > 0 {
return privateContactRequirement{paidStars: global.NoncontactPeersPaidStars}, nil
}
return privateContactRequirement{requirePremium: global.NewNoncontactPeersRequirePremium}, nil
}
func (r *Router) viewerIsPremiumForPrivacy(ctx context.Context, viewerUserID int64) (bool, error) {
var err error
premium := false
if evaluator, ok := r.deps.Privacy.(privacyViewerPremiumEvaluator); ok {
premium, err = evaluator.ViewerIsPremium(ctx, viewerUserID)
if err != nil {
return false, internalErr()
}
} else if r.deps.Users != nil {
user, found, loadErr := r.deps.Users.ByID(ctx, viewerUserID, viewerUserID)
if loadErr != nil {
return false, internalErr()
}
premium = found && user.PremiumActiveAt(r.clock.Now().Unix())
}
return premium, nil
}
func (r *Router) ensurePrivateContactAllowed(
ctx context.Context,
senderUserID, recipientUserID, allowPaidStars int64,
messageCount int,
) error {
if allowPaidStars < 0 || messageCount < 1 {
return starsAmountInvalidErr()
}
requirement, err := r.privateContactRestrictionFor(ctx, senderUserID, recipientUserID)
if err != nil {
return err
}
if requirement.requirePremium {
premium, err := r.viewerIsPremiumForPrivacy(ctx, senderUserID)
if err != nil {
return err
}
if !premium {
return premiumAccountRequiredErr()
}
return nil
}
if requirement.paidStars <= 0 {
return nil
}
if requirement.paidStars > math.MaxInt64/int64(messageCount) {
return starsAmountInvalidErr()
}
required := requirement.paidStars * int64(messageCount)
if allowPaidStars < required {
return allowPaymentRequiredErr(required)
}
// The privacy gate and no-paid exception are complete here. The separate
// private paid-message ledger is not part of the current message store yet;
// never accept an authorization without an atomic debit.
return paymentUnsupportedErr()
}

View file

@ -0,0 +1,166 @@
package rpc
import (
"context"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
appprivacy "telesrv/internal/app/privacy"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newPrivateRequirementRouter(t *testing.T, senderPremium bool) (*Router, *appaccount.Service, *appprivacy.Service, *memory.ContactStore, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
usersStore := memory.NewUserStore()
aliceInput := domain.User{AccessHash: 11, Phone: "15550008101", FirstName: "Alice"}
if senderPremium {
aliceInput.PremiumUntil = int(time.Now().Add(time.Hour).Unix())
}
alice, err := usersStore.Create(ctx, aliceInput)
if err != nil {
t.Fatalf("create Alice: %v", err)
}
bob, err := usersStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550008102", FirstName: "Bob"})
if err != nil {
t.Fatalf("create Bob: %v", err)
}
users := appusers.NewService(usersStore)
contacts := memory.NewContactStore()
privacy := appprivacy.NewService(memory.NewPrivacyStore(), contacts).ConfigureReadModels(users, nil)
settingsStore := memory.NewPasswordStore()
account := appaccount.NewService(settingsStore, appaccount.WithAccountSettings(settingsStore))
router := New(Config{}, Deps{
Account: account,
Privacy: privacy,
Users: users,
Messages: &captureMessages{},
}, zaptest.NewLogger(t), clock.System)
return router, account, privacy, contacts, alice, bob
}
func TestPrivateContactRequirementUsesNoPaidMessagesReadModel(t *testing.T) {
ctx := context.Background()
r, account, privacy, contacts, alice, bob := newPrivateRequirementRouter(t, false)
if _, err := account.SetGlobalPrivacy(ctx, bob.ID, domain.GlobalPrivacy{NoncontactPeersPaidStars: 7}); err != nil {
t.Fatalf("set Bob paid requirement: %v", err)
}
full, err := r.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{
UserID: bob.ID, AccessHash: bob.AccessHash,
})
if err != nil {
t.Fatalf("get Bob full user: %v", err)
}
if stars, ok := full.FullUser.GetSendPaidMessagesStars(); !ok || stars != 7 {
t.Fatalf("full user paid stars = %d, %v; want 7, true", stars, ok)
}
projected, ok := full.Users[0].(*tg.User)
if !ok {
t.Fatalf("projected user = %T, want *tg.User", full.Users[0])
}
if stars, ok := projected.GetSendPaidMessagesStars(); !ok || stars != 7 {
t.Fatalf("user paid stars = %d, %v; want 7, true", stars, ok)
}
requirements, err := r.onUsersGetRequirementsToContact(WithUserID(ctx, alice.ID), []tg.InputUserClass{
&tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash},
})
if err != nil {
t.Fatalf("get requirements: %v", err)
}
if paid, ok := requirements[0].(*tg.RequirementToContactPaidMessages); !ok || paid.StarsAmount != 7 {
t.Fatalf("requirement = %#v, want paid 7", requirements[0])
}
send := func(randomID int64, allow int64) error {
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Message: "hello",
RandomID: randomID,
}
if allow != 0 {
req.SetAllowPaidStars(allow)
}
_, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), req)
return err
}
if err := send(81001, 0); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(7)") {
t.Fatalf("non-exempt send err = %v, want ALLOW_PAYMENT_REQUIRED 7", err)
}
if err := send(81002, 7); err == nil || !strings.Contains(err.Error(), "PAYMENT_UNSUPPORTED") {
t.Fatalf("authorized paid send err = %v, want explicit unsupported ledger", err)
}
if _, err := privacy.SetRules(ctx, bob.ID, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{{
Kind: domain.PrivacyRuleAllowUsers,
UserIDs: []int64{alice.ID},
}, {
Kind: domain.PrivacyRuleDisallowAll,
}}); err != nil {
t.Fatalf("allow Alice in NoPaidMessages: %v", err)
}
if err := send(81003, 0); err != nil {
t.Fatalf("explicit no-paid exception send: %v", err)
}
full, err = r.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{
UserID: bob.ID, AccessHash: bob.AccessHash,
})
if err != nil {
t.Fatalf("get exempt Bob full user: %v", err)
}
if _, ok := full.FullUser.GetSendPaidMessagesStars(); ok || full.FullUser.ContactRequirePremium {
t.Fatalf("exempt full user still carries contact restriction: %+v", full.FullUser)
}
if _, err := privacy.SetRules(ctx, bob.ID, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("clear explicit exception: %v", err)
}
if _, err := contacts.Upsert(ctx, bob.ID, domain.ContactInput{
ContactUserID: alice.ID,
FirstName: "Alice",
}); err != nil {
t.Fatalf("Bob add Alice contact: %v", err)
}
if err := send(81004, 0); err != nil {
t.Fatalf("recipient contact must be free: %v", err)
}
}
func TestPrivateContactRequirementPremiumGateUsesViewerFactsReadModel(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
senderPremium bool
wantErr bool
}{
{name: "non-premium blocked", wantErr: true},
{name: "premium allowed", senderPremium: true},
} {
t.Run(tc.name, func(t *testing.T) {
r, account, _, _, alice, bob := newPrivateRequirementRouter(t, tc.senderPremium)
if _, err := account.SetGlobalPrivacy(ctx, bob.ID, domain.GlobalPrivacy{NewNoncontactPeersRequirePremium: true}); err != nil {
t.Fatalf("set premium requirement: %v", err)
}
_, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Message: "hello",
RandomID: 82001,
})
if tc.wantErr {
if err == nil || !strings.Contains(err.Error(), "PREMIUM_ACCOUNT_REQUIRED") {
t.Fatalf("send err = %v, want PREMIUM_ACCOUNT_REQUIRED", err)
}
} else if err != nil {
t.Fatalf("premium sender should pass: %v", err)
}
})
}
}

View file

@ -48,6 +48,7 @@ type outgoingSend struct {
// effect 是消息特效 id私聊专属0 表无特效)。调用方已对 catalog 校验合法性;
// 频道侧忽略(官方群/频道不渲染特效)。
effect int64
allowPaidStars int64
}
// sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。
@ -127,6 +128,9 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
if r.deps.Messages == nil {
return nil, false, peerIDInvalidErr()
}
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, p.allowPaidStars, 1); err != nil {
return nil, false, err
}
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil {
return nil, false, err
}
@ -357,7 +361,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
ClearDraft: req.ClearDraft,
})
}
if req.AllowPaidStars > 0 || req.AllowPaidFloodskip {
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
@ -417,6 +421,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
allowPaidStars: req.AllowPaidStars,
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
}
updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
@ -433,6 +438,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
clearDraft: req.ClearDraft,
replyMarkup: replyMarkup,
effect: req.Effect,
allowPaidStars: req.AllowPaidStars,
})
if err != nil {
return nil, err
@ -452,6 +458,12 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems {
return nil, limitInvalidErr()
}
if req.AllowPaidStars < 0 {
return nil, starsAmountInvalidErr()
}
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.ID == 0 {
return nil, peerIDInvalidErr()
@ -511,6 +523,14 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if err != nil {
return nil, err
}
if peer.Type == domain.PeerTypeUser {
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, req.AllowPaidStars, absentCount); err != nil {
return nil, err
}
}
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
for i, item := range req.MultiMedia {
if !replays[i].found {
@ -571,6 +591,7 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
sendAsInput: req.SendAs,
clearDraft: clearDraftPending,
groupedID: groupedID,
allowPaidStars: req.AllowPaidStars,
}
var result tg.UpdatesClass
duplicate := false

View file

@ -14,6 +14,7 @@ import (
appmessages "telesrv/internal/app/messages"
apppolls "telesrv/internal/app/polls"
appprivacy "telesrv/internal/app/privacy"
appstories "telesrv/internal/app/stories"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
@ -36,6 +37,8 @@ type fakeFiles struct {
resolveWebPageFn func(string) (domain.MessageWebPage, error)
lookupWebPageFn func(string) (domain.MessageWebPage, bool)
webPagePreviewOn bool
getDocumentsCalls int
createUploadCalls int
}
type fakeProfilePhotoKey struct {
@ -78,6 +81,7 @@ func (f *fakeFiles) AvailableEffects(context.Context) ([]domain.AvailableEffect,
return append([]domain.AvailableEffect(nil), f.effects...), hash & 0x7fffffff, nil
}
func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
f.getDocumentsCalls++
out := make([]domain.Document, 0, len(ids))
for _, id := range ids {
if d, ok := f.docs[id]; ok {
@ -564,6 +568,7 @@ func fakeAvatarStaticSizes() []domain.PhotoSize {
}
}
func (f *fakeFiles) CreateDocumentFromUpload(_ context.Context, _ domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) {
f.createUploadCalls++
return domain.Document{ID: 888, AccessHash: 8, DCID: 2, MimeType: spec.MimeType, Attributes: spec.Attributes}, nil
}
func (f *fakeFiles) CreateDocumentFromBytes(_ context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) {
@ -837,6 +842,73 @@ func TestSendMediaPrivateSticker(t *testing.T) {
}
}
func TestSendMediaVoicePrivacyPreflightsBeforeUploadMaterialization(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
files := r.deps.Files.(*fakeFiles)
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, friend.ID, domain.PrivacyKeyVoiceMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set voice privacy: %v", err)
}
r.deps.Privacy = privacy
_, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaUploadedDocument{
File: &tg.InputFile{ID: 7001, Parts: 1, Name: "voice.ogg"},
MimeType: "audio/ogg",
Attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeAudio{Voice: true, Duration: 1}},
},
RandomID: 7001,
})
if err == nil || !tgerr.Is(err, "CHAT_SEND_VOICES_FORBIDDEN") {
t.Fatalf("send uploaded voice err=%v, want CHAT_SEND_VOICES_FORBIDDEN", err)
}
if files.createUploadCalls != 0 {
t.Fatalf("uploaded voice materialized %d documents before privacy rejection", files.createUploadCalls)
}
if files.getDocumentsCalls != 0 {
t.Fatalf("uploaded voice unexpectedly loaded documents: calls=%d", files.getDocumentsCalls)
}
}
func TestSendMediaVoicePrivacyBatchesReferencedDocumentPreflight(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
files := r.deps.Files.(*fakeFiles)
files.docs[7011] = domain.Document{
ID: 7011,
AccessHash: 71,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, RoundMessage: true}},
}
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, friend.ID, domain.PrivacyKeyVoiceMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set voice privacy: %v", err)
}
r.deps.Privacy = privacy
_, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{
ID: 7011, AccessHash: 71,
}},
RandomID: 7011,
})
if err == nil || !tgerr.Is(err, "CHAT_SEND_VOICES_FORBIDDEN") {
t.Fatalf("send referenced round video err=%v, want CHAT_SEND_VOICES_FORBIDDEN", err)
}
if files.getDocumentsCalls != 1 {
t.Fatalf("referenced document preflight loads=%d, want one bounded batch", files.getDocumentsCalls)
}
if files.createUploadCalls != 0 {
t.Fatalf("referenced document path unexpectedly materialized upload: calls=%d", files.createUploadCalls)
}
}
func TestSendMultiMediaPartialFailureSubsetRetryKeepsReservedGroupedID(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)

View file

@ -154,6 +154,11 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
if err := r.applyBotCanEditToUser(ctx, currentUserID, u, user); err != nil {
return nil, err
}
contactRestriction, err := r.privateContactRestrictionFor(ctx, currentUserID, u.ID)
if err != nil {
return nil, err
}
applyPrivateContactRestrictionToUser(user, contactRestriction)
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
loadEpoch := r.userFullProjectionCache.LoadEpoch()
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
@ -165,6 +170,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
FullUser: full,
@ -185,6 +191,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
FullUser: full,

View file

@ -207,6 +207,45 @@ func TestReadModelChangeListenerInvalidatesPrivateMediaCountCache(t *testing.T)
}
}
func TestReadModelChangeListenerInvalidatesAccountSettingsCache(t *testing.T) {
settings := &fakeAccountSettingsReadModelCache{}
listener := NewReadModelChangeListener("", ReadModelCacheSet{
AccountSettings: settings,
}, nil)
listener.handlePayload(`{"model":"account_settings","owner_user_id":100,"peer_type":"user","peer_id":100,"version":2}`)
if len(settings.invalidated) != 1 || settings.invalidated[0] != 100 {
t.Fatalf("account_settings invalidations = %v, want [100]", settings.invalidated)
}
if len(settings.warmed) != 1 || settings.warmed[0] != 100 {
t.Fatalf("account_settings warmups = %v, want [100]", settings.warmed)
}
listener.flush("test")
if settings.flushes != 1 {
t.Fatalf("account_settings flushes = %d, want 1", settings.flushes)
}
}
type fakeAccountSettingsReadModelCache struct {
invalidated []int64
warmed []int64
flushes int
}
func (f *fakeAccountSettingsReadModelCache) InvalidateAccountSettingsReadModel(userID int64) {
f.invalidated = append(f.invalidated, userID)
}
func (f *fakeAccountSettingsReadModelCache) FlushAccountSettingsReadModel() {
f.flushes++
}
func (f *fakeAccountSettingsReadModelCache) WarmAccountSettingsReadModel(_ context.Context, userID int64) error {
f.warmed = append(f.warmed, userID)
return nil
}
// TestReadModelChangeListenerBotFullFlushesChannelFullBots 回归: bot 改资料(bot_full 事件,
// 迁移 0013)须 flush channelFullBotInfoCache(否则群信息页 bot 简介/命令跨实例陈旧至 TTL);
// 普通用户的 user_base 事件不得 flush(否则该缓存形同虚设)。

View file

@ -37,6 +37,7 @@ type ReadModelCacheSet struct {
BaseUsers BaseUserCache
BotProfiles BotProfileReadModelCache
StarGifts StarGiftCatalogCache
AccountSettings AccountSettingsReadModelCache
}
type StarGiftCatalogCache interface {
@ -44,6 +45,15 @@ type StarGiftCatalogCache interface {
FlushStarGiftCatalog()
}
type AccountSettingsReadModelCache interface {
InvalidateAccountSettingsReadModel(userID int64)
FlushAccountSettingsReadModel()
}
type AccountSettingsReadModelWarmer interface {
WarmAccountSettingsReadModel(context.Context, int64) error
}
// BaseUserCache 是跨进程共享的 user:base 缓存(Redis)。user_base read-model 事件必须删除
// 对应 user 键,否则 RPC 投影失效后会从陈旧的 user:base 重建(失效被未失效的源自我抵消)。
// 不参与重连 flushRedis 是跨实例共享的,整库清空是错的,漏掉的通知靠其自身 TTL 兜底。
@ -241,7 +251,8 @@ func (l *ReadModelChangeListener) empty() bool {
l.caches.RPCProjections == nil &&
l.caches.BaseUsers == nil &&
l.caches.BotProfiles == nil &&
l.caches.StarGifts == nil
l.caches.StarGifts == nil &&
l.caches.AccountSettings == nil
}
func (l *ReadModelChangeListener) flush(reasons ...string) {
@ -318,6 +329,10 @@ func (l *ReadModelChangeListener) flush(reasons ...string) {
l.caches.StarGifts.FlushStarGiftCatalog()
flushed = append(flushed, "star_gifts")
}
if l.caches.AccountSettings != nil {
l.caches.AccountSettings.FlushAccountSettingsReadModel()
flushed = append(flushed, "account_settings")
}
// 注意BaseUsers(Redis) 刻意不在重连时 flush——它是跨实例共享缓存整库清空会误伤
// 其它实例;漏掉的通知由其 5min TTL 兜底。
l.log.Info("read model caches flushed",
@ -346,6 +361,19 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
}
}
switch evt.Model {
case "account_settings":
if evt.OwnerUserID != 0 && l.caches.AccountSettings != nil {
l.caches.AccountSettings.InvalidateAccountSettingsReadModel(evt.OwnerUserID)
if warmer, ok := l.caches.AccountSettings.(AccountSettingsReadModelWarmer); ok {
ctx, cancel := context.WithTimeout(context.Background(), privacyReadModelWarmTimeout)
err := warmer.WarmAccountSettingsReadModel(ctx, evt.OwnerUserID)
cancel()
if err != nil {
l.log.Warn("warm account settings read model after change",
zap.Int64("owner_user_id", evt.OwnerUserID), zap.Error(err))
}
}
}
case "star_gift_catalog":
if l.caches.StarGifts != nil {
l.caches.StarGifts.InvalidateStarGiftCatalog()