feat: sync privacy read-model rules
This commit is contained in:
parent
9f467f4be7
commit
5d5883a3d1
20 changed files with 1011 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
25
deploy/migrations/0146_account_settings_read_model.up.sql
Normal file
25
deploy/migrations/0146_account_settings_read_model.up.sql
Normal 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();
|
||||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
internal/rpc/account_privacy_rpc_test.go
Normal file
122
internal/rpc/account_privacy_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 saga:channel peer 能付款发送,但不生成频道历史消息;
|
||||
// saved gift 用 inputSavedStarGiftChat.saved_id 定位,Recent Actions 用 admin log 快照承载。
|
||||
func TestStarGiftChannelSaga(t *testing.T) {
|
||||
|
|
|
|||
137
internal/rpc/privacy_paid_messages.go
Normal file
137
internal/rpc/privacy_paid_messages.go
Normal 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()
|
||||
}
|
||||
166
internal/rpc/privacy_paid_messages_test.go
Normal file
166
internal/rpc/privacy_paid_messages_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,8 @@ type outgoingSend struct {
|
|||
groupedID int64
|
||||
// effect 是消息特效 id(私聊专属,0 表无特效)。调用方已对 catalog 校验合法性;
|
||||
// 频道侧忽略(官方群/频道不渲染特效)。
|
||||
effect int64
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(否则该缓存形同虚设)。
|
||||
|
|
|
|||
|
|
@ -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 重建(失效被未失效的源自我抵消)。
|
||||
// 不参与重连 flush:Redis 是跨实例共享的,整库清空是错的,漏掉的通知靠其自身 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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue