feat: sync durable moderation and appeals

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent e1a95c7318
commit 9f467f4be7
140 changed files with 13730 additions and 316 deletions

View file

@ -87,6 +87,34 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
return nil
}
func (c *CachedPrivacyStore) SetPrivacyRulesWithUpdate(
ctx context.Context,
rules domain.PrivacyRules,
event domain.UpdateEvent,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.UpdateEvent, error) {
writer, ok := c.inner.(store.PrivacyUpdateStore)
if !ok {
return domain.UpdateEvent{}, domain.ErrPrivacyRuleInvalid
}
recorded, err := writer.SetPrivacyRulesWithUpdate(ctx, rules, event, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.UpdateEvent{}, err
}
c.InvalidateOwners(rules.OwnerUserID)
_ = c.WarmOwners(ctx, rules.OwnerUserID)
return recorded, nil
}
func (c *CachedPrivacyStore) SupportsDurablePrivacyUpdates() bool {
if c == nil {
return false
}
capability, ok := c.inner.(interface{ SupportsDurablePrivacyUpdates() bool })
return ok && capability.SupportsDurablePrivacyUpdates()
}
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
@ -185,6 +213,40 @@ func (c *CachedPrivacyStore) FlushReadModelCache() {
c.cache.Flush()
}
// InvalidateOwners lets Service be registered as the single privacy read-model
// cache group: rule snapshots and relationship facts then share one invalidation
// lifecycle.
func (s *Service) InvalidateOwners(ids ...int64) {
if s == nil || s.rules == nil {
return
}
if cache, ok := s.rules.(interface{ InvalidateOwners(...int64) }); ok {
cache.InvalidateOwners(ids...)
}
}
func (s *Service) WarmOwners(ctx context.Context, ids ...int64) error {
if s == nil || s.rules == nil {
return nil
}
if cache, ok := s.rules.(interface {
WarmOwners(context.Context, ...int64) error
}); ok {
return cache.WarmOwners(ctx, ids...)
}
return nil
}
func (s *Service) FlushReadModelCache() {
if s == nil {
return
}
if cache, ok := s.rules.(interface{ FlushReadModelCache() }); ok {
cache.FlushReadModelCache()
}
s.flushFactCaches()
}
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {

View file

@ -0,0 +1,245 @@
package privacy
import (
"context"
"strconv"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
defaultPrivacyViewerFactsTTL = 10 * time.Minute
defaultPrivacyMembershipTTL = 24 * time.Hour
privacyViewerFactsMaxEntries = 8192
privacyMembershipMaxEntries = 65536
)
// baseUserProvider returns viewer-independent user facts through the users read
// model. Implementations must batch cold misses rather than issue one query per
// user.
type baseUserProvider interface {
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
}
// channelMembershipProvider is the cold loader behind the bounded membership
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
type channelMembershipProvider interface {
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
type viewerFacts struct {
Found bool
Bot bool
PremiumUntil int64
}
type membershipKey struct {
ChatID int64
UserID int64
}
type evaluationNeeds struct {
viewerBase bool
chatIDs []int64
}
func newViewerFactsCache() *readmodelcache.Cache[int64, viewerFacts] {
return readmodelcache.New[int64, viewerFacts](readmodelcache.Config[int64, viewerFacts]{
MaxEntries: privacyViewerFactsMaxEntries,
TTL: defaultPrivacyViewerFactsTTL,
})
}
func newMembershipCache() *readmodelcache.Cache[membershipKey, bool] {
return readmodelcache.New[membershipKey, bool](readmodelcache.Config[membershipKey, bool]{
MaxEntries: privacyMembershipMaxEntries,
TTL: defaultPrivacyMembershipTTL,
KeyString: func(key membershipKey) string {
return strconv.FormatInt(key.ChatID, 10) + ":" + strconv.FormatInt(key.UserID, 10)
},
})
}
func needsForRules(rules domain.PrivacyRules) evaluationNeeds {
var needs evaluationNeeds
seenChats := make(map[int64]struct{})
for _, rule := range rules.Rules {
switch rule.Kind {
case domain.PrivacyRuleAllowPremium,
domain.PrivacyRuleAllowBots,
domain.PrivacyRuleDisallowBots:
needs.viewerBase = true
case domain.PrivacyRuleAllowChatParticipants,
domain.PrivacyRuleDisallowChatParticipants:
for _, chatID := range rule.ChatIDs {
if chatID <= 0 {
continue
}
if _, ok := seenChats[chatID]; ok {
continue
}
seenChats[chatID] = struct{}{}
needs.chatIDs = append(needs.chatIDs, chatID)
}
}
}
return needs
}
func mergeNeeds(dst *evaluationNeeds, src evaluationNeeds) {
if src.viewerBase {
dst.viewerBase = true
}
if len(src.chatIDs) == 0 {
return
}
seen := make(map[int64]struct{}, len(dst.chatIDs)+len(src.chatIDs))
for _, id := range dst.chatIDs {
seen[id] = struct{}{}
}
for _, id := range src.chatIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
dst.chatIDs = append(dst.chatIDs, id)
}
}
func (s *Service) loadViewerFacts(ctx context.Context, viewerUserIDs []int64) (map[int64]viewerFacts, error) {
ids := dedupNonZero(viewerUserIDs)
if len(ids) == 0 {
return map[int64]viewerFacts{}, nil
}
loadMissing := func(ctx context.Context, missing []int64) (map[int64]viewerFacts, error) {
out := make(map[int64]viewerFacts, len(missing))
for _, id := range missing {
out[id] = viewerFacts{} // negative cache: user was not found.
}
if s == nil || s.baseUsers == nil {
return out, nil
}
users, err := s.baseUsers.PrivacyBaseUsers(ctx, missing)
if err != nil {
return nil, err
}
for _, user := range users {
if user.ID == 0 {
continue
}
out[user.ID] = viewerFacts{
Found: true,
Bot: user.Bot,
PremiumUntil: int64(user.PremiumUntil),
}
}
return out, nil
}
if s == nil || s.viewerFacts == nil {
return loadMissing(ctx, ids)
}
return s.viewerFacts.GetOrLoadBatch(ctx, ids,
func(int64) (int64, bool) { return 0, true },
loadMissing,
)
}
func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserIDs []int64) (map[membershipKey]bool, error) {
chats := dedupNonZero(chatIDs)
viewers := dedupNonZero(viewerUserIDs)
if len(chats) == 0 || len(viewers) == 0 {
return map[membershipKey]bool{}, nil
}
keys := make([]membershipKey, 0, len(chats)*len(viewers))
for _, chatID := range chats {
for _, viewerID := range viewers {
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
}
}
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
out := make(map[membershipKey]bool, len(missing))
byChat := make(map[int64][]int64)
for _, key := range missing {
out[key] = false // negative cache: not an active member.
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
}
if s == nil || s.memberships == nil {
return out, nil
}
for chatID, userIDs := range byChat {
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
if err != nil {
return nil, err
}
for _, userID := range active {
out[membershipKey{ChatID: chatID, UserID: userID}] = true
}
}
return out, nil
}
if s == nil || s.membershipFacts == nil {
return loadMissing(ctx, keys)
}
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
func(membershipKey) (int64, bool) { return 0, true },
loadMissing,
)
}
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
if ctx == nil || !facts.Found {
return
}
ctx.ViewerIsBot = facts.Bot
ctx.ViewerIsPremium = !facts.Bot && facts.PremiumUntil > now
}
func applyMembershipFacts(ctx *domain.PrivacyContext, chatIDs []int64, facts map[membershipKey]bool) {
if ctx == nil || len(chatIDs) == 0 {
return
}
for _, chatID := range chatIDs {
if facts[membershipKey{ChatID: chatID, UserID: ctx.ViewerUserID}] {
ctx.SharedChatIDs = append(ctx.SharedChatIDs, chatID)
}
}
}
// InvalidateViewerFacts invalidates bot/premium facts after a user-base change.
func (s *Service) InvalidateViewerFacts(userIDs ...int64) {
if s == nil || s.viewerFacts == nil {
return
}
s.viewerFacts.Invalidate(dedupNonZero(userIDs)...)
}
// InvalidateMembership invalidates one membership pair after a channel-member change.
func (s *Service) InvalidateMembership(channelID, userID int64) {
if s == nil || s.membershipFacts == nil || channelID == 0 || userID == 0 {
return
}
s.membershipFacts.Invalidate(membershipKey{ChatID: channelID, UserID: userID})
}
// InvalidateChannelMemberships invalidates all cached pairs for a changed/deleted channel.
func (s *Service) InvalidateChannelMemberships(channelID int64) {
if s == nil || s.membershipFacts == nil || channelID == 0 {
return
}
s.membershipFacts.InvalidateWhere(func(key membershipKey) bool { return key.ChatID == channelID })
}
func (s *Service) flushFactCaches() {
if s == nil {
return
}
if s.viewerFacts != nil {
s.viewerFacts.Flush()
}
if s.membershipFacts != nil {
s.membershipFacts.Flush()
}
}

View file

@ -3,21 +3,49 @@ package privacy
import (
"context"
"slices"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const maxPrivacyRules = 100
const (
maxPrivacyRules = 100
maxPrivacyRuleIDs = 5000
)
// Service owns account privacy rules and viewer-specific evaluation.
type Service struct {
rules store.PrivacyStore
contacts store.ContactStore
rules store.PrivacyStore
contacts store.ContactStore
baseUsers baseUserProvider
memberships channelMembershipProvider
viewerFacts *readmodelcache.Cache[int64, viewerFacts]
membershipFacts *readmodelcache.Cache[membershipKey, bool]
now func() time.Time
}
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
return &Service{rules: rules, contacts: contacts}
return &Service{
rules: rules,
contacts: contacts,
viewerFacts: newViewerFactsCache(),
membershipFacts: newMembershipCache(),
now: time.Now,
}
}
// ConfigureReadModels wires the cold loaders behind the bounded in-memory
// privacy fact caches. It is called after users/channels services are built to
// avoid a package dependency cycle.
func (s *Service) ConfigureReadModels(users baseUserProvider, memberships channelMembershipProvider) *Service {
if s == nil {
return s
}
s.baseUsers = users
s.memberships = memberships
return s
}
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
@ -43,6 +71,52 @@ func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.Pr
}
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
out, err := normalizedRules(ownerUserID, key, rules)
if err != nil {
return domain.PrivacyRules{}, err
}
if s != nil && s.rules != nil {
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
return domain.PrivacyRules{}, err
}
}
return out, nil
}
// SetRulesWithUpdate uses the production atomic write boundary when available.
// durable=false means no write was attempted; the RPC layer may then use the
// ordinary SetRules + Updates.RecordPrivacy fallback used by memory tests.
func (s *Service) SetRulesWithUpdate(
ctx context.Context,
ownerUserID int64,
key domain.PrivacyKey,
rules []domain.PrivacyRule,
date int,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.PrivacyRules, domain.UpdateEvent, bool, error) {
out, err := normalizedRules(ownerUserID, key, rules)
if err != nil {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
}
capability, ok := s.rules.(interface{ SupportsDurablePrivacyUpdates() bool })
if !ok || !capability.SupportsDurablePrivacyUpdates() {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, nil
}
writer := s.rules.(store.PrivacyUpdateStore)
event, err := writer.SetPrivacyRulesWithUpdate(ctx, out, domain.UpdateEvent{
Type: domain.UpdateEventPrivacy,
Date: date,
Privacy: cloneRules(out),
PtsCount: 1,
}, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
}
return out, event, true, nil
}
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
if !ValidKey(key) {
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
}
@ -52,13 +126,7 @@ func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.Pr
if err := validateRules(rules); err != nil {
return domain.PrivacyRules{}, err
}
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
if s != nil && s.rules != nil {
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
return domain.PrivacyRules{}, err
}
}
return out, nil
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}, nil
}
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
@ -96,17 +164,33 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
if err != nil {
return false, err
}
needs := needsForRules(rules)
evalCtx := domain.PrivacyContext{
OwnerUserID: ownerUserID,
ViewerUserID: viewerUserID,
}
if s != nil && s.contacts != nil {
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
if contact, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
return false, err
} else if found {
evalCtx.ViewerIsContact = true
evalCtx.ViewerCloseFriend = contact.CloseFriend
}
}
if needs.viewerBase {
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return false, err
}
applyViewerFacts(&evalCtx, facts[viewerUserID], s.now().Unix())
}
if len(needs.chatIDs) > 0 {
facts, err := s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
if err != nil {
return false, err
}
applyMembershipFacts(&evalCtx, needs.chatIDs, facts)
}
return Evaluate(rules, evalCtx), nil
}
@ -183,6 +267,16 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
var needs evaluationNeeds
for _, owner := range owners {
for _, key := range keys {
rules, ok := rulesByOwner[owner][key]
if !ok {
rules = defaultRules(owner, key)
}
mergeNeeds(&needs, needsForRules(rules))
}
}
// 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的
// contacts.Get(owner, viewer))。
var reverse map[int64]domain.Contact
@ -193,25 +287,82 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
return nil, err
}
}
var baseFacts map[int64]viewerFacts
if needs.viewerBase {
var err error
baseFacts, err = s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return nil, err
}
}
var membershipFacts map[membershipKey]bool
if len(needs.chatIDs) > 0 {
var err error
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
if err != nil {
return nil, err
}
}
now := s.now().Unix()
for _, owner := range owners {
_, isContact := reverse[owner]
contact, isContact := reverse[owner]
m := make(map[domain.PrivacyKey]bool, len(keys))
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewerUserID], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
out[owner] = m
}
return out, nil
}
// CanContactForFreeBatch evaluates the complete exception predicate for
// per-user contact requirements. Contacts are always free because the global
// setting is explicitly "noncontact peers"; privacyKeyNoPaidMessages adds
// exceptions beyond that relationship. Both facts come from the in-memory
// privacy/contact read models after their bounded cold loads.
func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error) {
owners := dedupNonZero(ownerUserIDs)
out := make(map[int64]bool, len(owners))
if viewerUserID == 0 || len(owners) == 0 {
return out, nil
}
visibility, err := s.CanSeeBatch(
ctx,
owners,
viewerUserID,
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
)
if err != nil {
return nil, err
}
var contacts map[int64]domain.Contact
if s != nil && s.contacts != nil {
contacts, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
if err != nil {
return nil, err
}
}
for _, ownerUserID := range owners {
_, isContact := contacts[ownerUserID]
out[ownerUserID] = ownerUserID == viewerUserID ||
isContact ||
visibility[ownerUserID][domain.PrivacyKeyNoPaidMessages]
}
return out, 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。
@ -249,6 +400,33 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
var needs evaluationNeeds
for _, owner := range owners {
for _, key := range keys {
rules, ok := rulesByOwner[owner][key]
if !ok {
rules = defaultRules(owner, key)
}
mergeNeeds(&needs, needsForRules(rules))
}
}
var baseFacts map[int64]viewerFacts
if needs.viewerBase {
var err error
baseFacts, err = s.loadViewerFacts(ctx, viewers)
if err != nil {
return nil, err
}
}
var membershipFacts map[membershipKey]bool
if len(needs.chatIDs) > 0 {
var err error
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, viewers)
if err != nil {
return nil, err
}
}
now := s.now().Unix()
for _, owner := range owners {
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
var ownerContacts map[int64]domain.Contact
@ -269,17 +447,21 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
perViewer[viewer] = m
continue
}
_, isContact := ownerContacts[viewer]
contact, isContact := ownerContacts[viewer]
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewer], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
perViewer[viewer] = m
}
@ -370,6 +552,7 @@ func validateRules(rules []domain.PrivacyRule) error {
if len(rules) > maxPrivacyRules {
return domain.ErrPrivacyRuleInvalid
}
totalIDs := 0
for _, rule := range rules {
switch rule.Kind {
case domain.PrivacyRuleAllowContacts,
@ -387,6 +570,20 @@ func validateRules(rules []domain.PrivacyRule) error {
default:
return domain.ErrPrivacyRuleInvalid
}
totalIDs += len(rule.UserIDs) + len(rule.ChatIDs)
if totalIDs > maxPrivacyRuleIDs {
return domain.ErrPrivacyRuleInvalid
}
for _, id := range rule.UserIDs {
if id <= 0 {
return domain.ErrPrivacyRuleInvalid
}
}
for _, id := range rule.ChatIDs {
if id <= 0 {
return domain.ErrPrivacyRuleInvalid
}
}
}
return nil
}