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,
|
BaseUsers: userCache,
|
||||||
BotProfiles: botsService,
|
BotProfiles: botsService,
|
||||||
StarGifts: giftsService,
|
StarGifts: giftsService,
|
||||||
|
AccountSettings: router,
|
||||||
}, logger.Named("store").Named("read-model-listener"))
|
}, logger.Named("store").Named("read-model-listener"))
|
||||||
go readModelListener.Run(ctx)
|
go readModelListener.Run(ctx)
|
||||||
activeSessions.SetLifecycleObserver(router)
|
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
|
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)
|
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
||||||
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
||||||
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,44 @@ package privacy
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store/memory"
|
"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) {
|
func TestDefaultPrivacyRules(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
|
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 (
|
const (
|
||||||
accountSettingsCacheMaxEntries = 4096
|
accountSettingsCacheMaxEntries = 4096
|
||||||
// accountSettingsCacheTTL 兜底跨实例失效;同实例 Set 即时失效。设置页连续调
|
// accountSettingsCacheTTL is only the lost-notification safety net. Normal
|
||||||
// getGlobalPrivacy/getAccountTTL/getContentSettings/getContactSignUp 时只查一次 PG。
|
// consistency comes from account_settings read-model notifications; local
|
||||||
accountSettingsCacheTTL = 60 * time.Second
|
// writes update the cached value directly.
|
||||||
|
accountSettingsCacheTTL = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// accountSettingsCache 缓存 userID→AccountSettings,避免设置页 4 个 get handler 各查
|
// accountSettingsCache 缓存 userID→AccountSettings,避免设置页 4 个 get handler 各查
|
||||||
|
|
@ -52,6 +53,38 @@ func (c *accountSettingsCache) Store(userID int64, settings domain.AccountSettin
|
||||||
c.cache.Store(userID, settings)
|
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 {
|
type accountSettingsBatchReader interface {
|
||||||
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
|
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
appchannels "telesrv/internal/app/channels"
|
appchannels "telesrv/internal/app/channels"
|
||||||
appdialogs "telesrv/internal/app/dialogs"
|
appdialogs "telesrv/internal/app/dialogs"
|
||||||
|
appprivacy "telesrv/internal/app/privacy"
|
||||||
appusers "telesrv/internal/app/users"
|
appusers "telesrv/internal/app/users"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store/memory"
|
"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) {
|
func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,14 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
absentIDs := make([]int, len(absentIndexes))
|
||||||
absentRandomIDs := make([]int64, len(absentIndexes))
|
absentRandomIDs := make([]int64, len(absentIndexes))
|
||||||
for i, originalIndex := range absentIndexes {
|
for i, originalIndex := range absentIndexes {
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@ func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest)
|
||||||
return mediaInvalidErr()
|
return mediaInvalidErr()
|
||||||
case req.AllowPaidStars < 0:
|
case req.AllowPaidStars < 0:
|
||||||
return starsAmountInvalidErr()
|
return starsAmountInvalidErr()
|
||||||
case req.AllowPaidStars > 0 || req.AllowPaidFloodskip:
|
case req.AllowPaidFloodskip:
|
||||||
return paymentUnsupportedErr()
|
return paymentUnsupportedErr()
|
||||||
case !req.SuggestedPost.Zero():
|
case !req.SuggestedPost.Zero():
|
||||||
return suggestedPostPeerInvalidErr()
|
return suggestedPostPeerInvalidErr()
|
||||||
|
|
|
||||||
|
|
@ -141,10 +141,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
||||||
}
|
}
|
||||||
return updates, nil
|
return updates, nil
|
||||||
}
|
}
|
||||||
if req.AllowPaidStars > 0 {
|
|
||||||
sendErr = paymentUnsupportedErr()
|
|
||||||
return nil, sendErr
|
|
||||||
}
|
|
||||||
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
|
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendErr = err
|
sendErr = err
|
||||||
|
|
@ -227,6 +223,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
||||||
sendAsInput: req.SendAs,
|
sendAsInput: req.SendAs,
|
||||||
clearDraft: req.ClearDraft,
|
clearDraft: req.ClearDraft,
|
||||||
richMessage: richMessage,
|
richMessage: richMessage,
|
||||||
|
allowPaidStars: req.AllowPaidStars,
|
||||||
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
|
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendErr = err
|
sendErr = err
|
||||||
|
|
@ -249,6 +246,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
||||||
replyMarkup: replyMarkup,
|
replyMarkup: replyMarkup,
|
||||||
richMessage: richMessage,
|
richMessage: richMessage,
|
||||||
effect: req.Effect,
|
effect: req.Effect,
|
||||||
|
allowPaidStars: req.AllowPaidStars,
|
||||||
})
|
})
|
||||||
duplicate = dup
|
duplicate = dup
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -392,15 +392,6 @@ func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) {
|
||||||
}(),
|
}(),
|
||||||
want: "STARS_AMOUNT_INVALID",
|
want: "STARS_AMOUNT_INVALID",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "paid stars",
|
|
||||||
req: func() *tg.MessagesSendMessageRequest {
|
|
||||||
req := base()
|
|
||||||
req.SetAllowPaidStars(1)
|
|
||||||
return req
|
|
||||||
}(),
|
|
||||||
want: "PAYMENT_UNSUPPORTED",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "paid floodskip",
|
name: "paid floodskip",
|
||||||
req: func() *tg.MessagesSendMessageRequest {
|
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"
|
"github.com/iamxvbaba/td/tlprofile"
|
||||||
appchannels "telesrv/internal/app/channels"
|
appchannels "telesrv/internal/app/channels"
|
||||||
appmessages "telesrv/internal/app/messages"
|
appmessages "telesrv/internal/app/messages"
|
||||||
|
appprivacy "telesrv/internal/app/privacy"
|
||||||
appstargifts "telesrv/internal/app/stargifts"
|
appstargifts "telesrv/internal/app/stargifts"
|
||||||
appstars "telesrv/internal/app/stars"
|
appstars "telesrv/internal/app/stars"
|
||||||
appusers "telesrv/internal/app/users"
|
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 能付款发送,但不生成频道历史消息;
|
// 频道 star gift saga:channel peer 能付款发送,但不生成频道历史消息;
|
||||||
// saved gift 用 inputSavedStarGiftChat.saved_id 定位,Recent Actions 用 admin log 快照承载。
|
// saved gift 用 inputSavedStarGiftChat.saved_id 定位,Recent Actions 用 admin log 快照承载。
|
||||||
func TestStarGiftChannelSaga(t *testing.T) {
|
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
|
groupedID int64
|
||||||
// effect 是消息特效 id(私聊专属,0 表无特效)。调用方已对 catalog 校验合法性;
|
// effect 是消息特效 id(私聊专属,0 表无特效)。调用方已对 catalog 校验合法性;
|
||||||
// 频道侧忽略(官方群/频道不渲染特效)。
|
// 频道侧忽略(官方群/频道不渲染特效)。
|
||||||
effect int64
|
effect int64
|
||||||
|
allowPaidStars int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。
|
// sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。
|
||||||
|
|
@ -127,6 +128,9 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
|
||||||
if r.deps.Messages == nil {
|
if r.deps.Messages == nil {
|
||||||
return nil, false, peerIDInvalidErr()
|
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 {
|
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
@ -357,7 +361,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
|
||||||
ClearDraft: req.ClearDraft,
|
ClearDraft: req.ClearDraft,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if req.AllowPaidStars > 0 || req.AllowPaidFloodskip {
|
if req.AllowPaidFloodskip {
|
||||||
return nil, paymentUnsupportedErr()
|
return nil, paymentUnsupportedErr()
|
||||||
}
|
}
|
||||||
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
|
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,
|
replyToInput: req.ReplyTo,
|
||||||
sendAsInput: req.SendAs,
|
sendAsInput: req.SendAs,
|
||||||
clearDraft: req.ClearDraft,
|
clearDraft: req.ClearDraft,
|
||||||
|
allowPaidStars: req.AllowPaidStars,
|
||||||
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
|
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
|
||||||
}
|
}
|
||||||
updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
|
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,
|
clearDraft: req.ClearDraft,
|
||||||
replyMarkup: replyMarkup,
|
replyMarkup: replyMarkup,
|
||||||
effect: req.Effect,
|
effect: req.Effect,
|
||||||
|
allowPaidStars: req.AllowPaidStars,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
if req.AllowPaidStars < 0 {
|
||||||
|
return nil, starsAmountInvalidErr()
|
||||||
|
}
|
||||||
|
if req.AllowPaidFloodskip {
|
||||||
|
return nil, paymentUnsupportedErr()
|
||||||
|
}
|
||||||
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
|
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
|
||||||
if !ok || peer.ID == 0 {
|
if !ok || peer.ID == 0 {
|
||||||
return nil, peerIDInvalidErr()
|
return nil, peerIDInvalidErr()
|
||||||
|
|
@ -511,6 +523,14 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
|
||||||
for i, item := range req.MultiMedia {
|
for i, item := range req.MultiMedia {
|
||||||
if !replays[i].found {
|
if !replays[i].found {
|
||||||
|
|
@ -571,6 +591,7 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
|
||||||
sendAsInput: req.SendAs,
|
sendAsInput: req.SendAs,
|
||||||
clearDraft: clearDraftPending,
|
clearDraft: clearDraftPending,
|
||||||
groupedID: groupedID,
|
groupedID: groupedID,
|
||||||
|
allowPaidStars: req.AllowPaidStars,
|
||||||
}
|
}
|
||||||
var result tg.UpdatesClass
|
var result tg.UpdatesClass
|
||||||
duplicate := false
|
duplicate := false
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
|
|
||||||
appmessages "telesrv/internal/app/messages"
|
appmessages "telesrv/internal/app/messages"
|
||||||
apppolls "telesrv/internal/app/polls"
|
apppolls "telesrv/internal/app/polls"
|
||||||
|
appprivacy "telesrv/internal/app/privacy"
|
||||||
appstories "telesrv/internal/app/stories"
|
appstories "telesrv/internal/app/stories"
|
||||||
appusers "telesrv/internal/app/users"
|
appusers "telesrv/internal/app/users"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
|
@ -36,6 +37,8 @@ type fakeFiles struct {
|
||||||
resolveWebPageFn func(string) (domain.MessageWebPage, error)
|
resolveWebPageFn func(string) (domain.MessageWebPage, error)
|
||||||
lookupWebPageFn func(string) (domain.MessageWebPage, bool)
|
lookupWebPageFn func(string) (domain.MessageWebPage, bool)
|
||||||
webPagePreviewOn bool
|
webPagePreviewOn bool
|
||||||
|
getDocumentsCalls int
|
||||||
|
createUploadCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeProfilePhotoKey struct {
|
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
|
return append([]domain.AvailableEffect(nil), f.effects...), hash & 0x7fffffff, nil
|
||||||
}
|
}
|
||||||
func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
|
func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
|
||||||
|
f.getDocumentsCalls++
|
||||||
out := make([]domain.Document, 0, len(ids))
|
out := make([]domain.Document, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if d, ok := f.docs[id]; ok {
|
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) {
|
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
|
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) {
|
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) {
|
func TestSendMultiMediaPartialFailureSubsetRetryKeepsReservedGroupedID(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
r, owner, friend := newMediaTestRouter(t)
|
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 {
|
if err := r.applyBotCanEditToUser(ctx, currentUserID, u, user); err != nil {
|
||||||
return nil, err
|
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)
|
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
|
||||||
loadEpoch := r.userFullProjectionCache.LoadEpoch()
|
loadEpoch := r.userFullProjectionCache.LoadEpoch()
|
||||||
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
|
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.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
|
||||||
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
|
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
|
||||||
|
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
|
||||||
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
|
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
|
||||||
return &tg.UsersUserFull{
|
return &tg.UsersUserFull{
|
||||||
FullUser: full,
|
FullUser: full,
|
||||||
|
|
@ -185,6 +191,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
|
||||||
}
|
}
|
||||||
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
|
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
|
||||||
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
|
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
|
||||||
|
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
|
||||||
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
|
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
|
||||||
return &tg.UsersUserFull{
|
return &tg.UsersUserFull{
|
||||||
FullUser: full,
|
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 事件,
|
// TestReadModelChangeListenerBotFullFlushesChannelFullBots 回归: bot 改资料(bot_full 事件,
|
||||||
// 迁移 0013)须 flush channelFullBotInfoCache(否则群信息页 bot 简介/命令跨实例陈旧至 TTL);
|
// 迁移 0013)须 flush channelFullBotInfoCache(否则群信息页 bot 简介/命令跨实例陈旧至 TTL);
|
||||||
// 普通用户的 user_base 事件不得 flush(否则该缓存形同虚设)。
|
// 普通用户的 user_base 事件不得 flush(否则该缓存形同虚设)。
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ type ReadModelCacheSet struct {
|
||||||
BaseUsers BaseUserCache
|
BaseUsers BaseUserCache
|
||||||
BotProfiles BotProfileReadModelCache
|
BotProfiles BotProfileReadModelCache
|
||||||
StarGifts StarGiftCatalogCache
|
StarGifts StarGiftCatalogCache
|
||||||
|
AccountSettings AccountSettingsReadModelCache
|
||||||
}
|
}
|
||||||
|
|
||||||
type StarGiftCatalogCache interface {
|
type StarGiftCatalogCache interface {
|
||||||
|
|
@ -44,6 +45,15 @@ type StarGiftCatalogCache interface {
|
||||||
FlushStarGiftCatalog()
|
FlushStarGiftCatalog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AccountSettingsReadModelCache interface {
|
||||||
|
InvalidateAccountSettingsReadModel(userID int64)
|
||||||
|
FlushAccountSettingsReadModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountSettingsReadModelWarmer interface {
|
||||||
|
WarmAccountSettingsReadModel(context.Context, int64) error
|
||||||
|
}
|
||||||
|
|
||||||
// BaseUserCache 是跨进程共享的 user:base 缓存(Redis)。user_base read-model 事件必须删除
|
// BaseUserCache 是跨进程共享的 user:base 缓存(Redis)。user_base read-model 事件必须删除
|
||||||
// 对应 user 键,否则 RPC 投影失效后会从陈旧的 user:base 重建(失效被未失效的源自我抵消)。
|
// 对应 user 键,否则 RPC 投影失效后会从陈旧的 user:base 重建(失效被未失效的源自我抵消)。
|
||||||
// 不参与重连 flush:Redis 是跨实例共享的,整库清空是错的,漏掉的通知靠其自身 TTL 兜底。
|
// 不参与重连 flush:Redis 是跨实例共享的,整库清空是错的,漏掉的通知靠其自身 TTL 兜底。
|
||||||
|
|
@ -241,7 +251,8 @@ func (l *ReadModelChangeListener) empty() bool {
|
||||||
l.caches.RPCProjections == nil &&
|
l.caches.RPCProjections == nil &&
|
||||||
l.caches.BaseUsers == nil &&
|
l.caches.BaseUsers == nil &&
|
||||||
l.caches.BotProfiles == nil &&
|
l.caches.BotProfiles == nil &&
|
||||||
l.caches.StarGifts == nil
|
l.caches.StarGifts == nil &&
|
||||||
|
l.caches.AccountSettings == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ReadModelChangeListener) flush(reasons ...string) {
|
func (l *ReadModelChangeListener) flush(reasons ...string) {
|
||||||
|
|
@ -318,6 +329,10 @@ func (l *ReadModelChangeListener) flush(reasons ...string) {
|
||||||
l.caches.StarGifts.FlushStarGiftCatalog()
|
l.caches.StarGifts.FlushStarGiftCatalog()
|
||||||
flushed = append(flushed, "star_gifts")
|
flushed = append(flushed, "star_gifts")
|
||||||
}
|
}
|
||||||
|
if l.caches.AccountSettings != nil {
|
||||||
|
l.caches.AccountSettings.FlushAccountSettingsReadModel()
|
||||||
|
flushed = append(flushed, "account_settings")
|
||||||
|
}
|
||||||
// 注意:BaseUsers(Redis) 刻意不在重连时 flush——它是跨实例共享缓存,整库清空会误伤
|
// 注意:BaseUsers(Redis) 刻意不在重连时 flush——它是跨实例共享缓存,整库清空会误伤
|
||||||
// 其它实例;漏掉的通知由其 5min TTL 兜底。
|
// 其它实例;漏掉的通知由其 5min TTL 兜底。
|
||||||
l.log.Info("read model caches flushed",
|
l.log.Info("read model caches flushed",
|
||||||
|
|
@ -346,6 +361,19 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch evt.Model {
|
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":
|
case "star_gift_catalog":
|
||||||
if l.caches.StarGifts != nil {
|
if l.caches.StarGifts != nil {
|
||||||
l.caches.StarGifts.InvalidateStarGiftCatalog()
|
l.caches.StarGifts.InvalidateStarGiftCatalog()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue