feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
83
internal/store/memory/auth_delivery_report.go
Normal file
83
internal/store/memory/auth_delivery_report.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type AuthDeliveryReportStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
byFingerprint map[[32]byte]domain.AuthDeliveryReport
|
||||
}
|
||||
|
||||
func NewAuthDeliveryReportStore() *AuthDeliveryReportStore {
|
||||
return &AuthDeliveryReportStore{
|
||||
nextID: 1, byFingerprint: make(map[[32]byte]domain.AuthDeliveryReport),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(_ context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
|
||||
if err := report.Validate(); err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if existing, ok := s.byFingerprint[report.Fingerprint]; ok {
|
||||
return existing, false, nil
|
||||
}
|
||||
var hourly, phoneDaily int
|
||||
hourAgo := report.CreatedAt.Add(-time.Hour)
|
||||
dayAgo := report.CreatedAt.Add(-24 * time.Hour)
|
||||
for _, existing := range s.byFingerprint {
|
||||
if existing.CreatedAt.After(report.CreatedAt) {
|
||||
continue
|
||||
}
|
||||
if existing.AuthKeyID == report.AuthKeyID && !existing.CreatedAt.Before(hourAgo) {
|
||||
hourly++
|
||||
}
|
||||
if existing.PhoneHash == report.PhoneHash && !existing.CreatedAt.Before(dayAgo) {
|
||||
phoneDaily++
|
||||
}
|
||||
}
|
||||
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
|
||||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
|
||||
}
|
||||
report.ID = s.nextID
|
||||
s.nextID++
|
||||
s.byFingerprint[report.Fingerprint] = report
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthDeliveryReportStore) Reports() []domain.AuthDeliveryReport {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.AuthDeliveryReport, 0, len(s.byFingerprint))
|
||||
for _, report := range s.byFingerprint {
|
||||
out = append(out, report)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(_ context.Context, olderThan time.Time, limit int) (int, error) {
|
||||
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
|
||||
return 0, domain.ErrAuthDeliveryReportInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
deleted := 0
|
||||
for fingerprint, report := range s.byFingerprint {
|
||||
if deleted >= limit {
|
||||
break
|
||||
}
|
||||
if report.CreatedAt.Before(olderThan) {
|
||||
delete(s.byFingerprint, fingerprint)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
|
@ -479,6 +479,39 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FindChannelMessageReaction(_ context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
|
||||
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(req.ViewerUserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionLookup{}, false, err
|
||||
}
|
||||
message, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
|
||||
if !ok || message.Deleted || message.ID <= member.AvailableMinID {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
|
||||
}
|
||||
rows := cloneChannelPeerReactions(s.reactions[req.ChannelID][req.MessageID][req.ReactorUserID])
|
||||
if len(rows) == 0 {
|
||||
return domain.ChannelMessageReactionLookup{
|
||||
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
|
||||
}, false, nil
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].ChosenOrder != rows[j].ChosenOrder {
|
||||
return rows[i].ChosenOrder < rows[j].ChosenOrder
|
||||
}
|
||||
return messageReactionKey(rows[i].Reaction) < messageReactionKey(rows[j].Reaction)
|
||||
})
|
||||
return domain.ChannelMessageReactionLookup{
|
||||
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
|
||||
Reactions: rows,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error {
|
||||
if userID == 0 || len(reactions) == 0 {
|
||||
return nil
|
||||
|
|
|
|||
99
internal/store/memory/client_telemetry.go
Normal file
99
internal/store/memory/client_telemetry.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type ClientTelemetryStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
byID map[int64]domain.ClientTelemetryEvent
|
||||
byFingerprint map[[32]byte]int64
|
||||
}
|
||||
|
||||
func NewClientTelemetryStore() *ClientTelemetryStore {
|
||||
return &ClientTelemetryStore{
|
||||
nextID: 1, byID: make(map[int64]domain.ClientTelemetryEvent),
|
||||
byFingerprint: make(map[[32]byte]int64),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClientTelemetryStore) CreateClientTelemetry(_ context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
|
||||
if err := event.Validate(); err != nil || event.ID != 0 {
|
||||
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id, ok := s.byFingerprint[event.Fingerprint]; ok {
|
||||
return cloneClientTelemetry(s.byID[id]), false, nil
|
||||
}
|
||||
var hourly, daily int
|
||||
for _, existing := range s.byID {
|
||||
if existing.UserID != event.UserID ||
|
||||
existing.CreatedAt.After(event.CreatedAt) {
|
||||
continue
|
||||
}
|
||||
if !existing.CreatedAt.Before(event.CreatedAt.Add(-24 * time.Hour)) {
|
||||
daily++
|
||||
}
|
||||
if !existing.CreatedAt.Before(event.CreatedAt.Add(-time.Hour)) {
|
||||
hourly++
|
||||
}
|
||||
}
|
||||
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
|
||||
daily >= domain.MaxClientTelemetryEventsPerDay {
|
||||
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
|
||||
}
|
||||
event.ID = s.nextID
|
||||
s.nextID++
|
||||
event = cloneClientTelemetry(event)
|
||||
s.byID[event.ID] = event
|
||||
s.byFingerprint[event.Fingerprint] = event.ID
|
||||
return cloneClientTelemetry(event), true, nil
|
||||
}
|
||||
|
||||
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(_ context.Context, olderThan time.Time, limit int) (int, error) {
|
||||
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
|
||||
return 0, domain.ErrClientTelemetryInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ids := make([]int64, 0)
|
||||
for id, event := range s.byID {
|
||||
if event.CreatedAt.Before(olderThan) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
if len(ids) > limit {
|
||||
ids = ids[:limit]
|
||||
}
|
||||
for _, id := range ids {
|
||||
event := s.byID[id]
|
||||
delete(s.byFingerprint, event.Fingerprint)
|
||||
delete(s.byID, id)
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
|
||||
func (s *ClientTelemetryStore) Events() []domain.ClientTelemetryEvent {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.ClientTelemetryEvent, 0, len(s.byID))
|
||||
for _, event := range s.byID {
|
||||
out = append(out, cloneClientTelemetry(event))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneClientTelemetry(event domain.ClientTelemetryEvent) domain.ClientTelemetryEvent {
|
||||
event.SubjectIDs = append([]int64(nil), event.SubjectIDs...)
|
||||
event.Payload = append([]byte(nil), event.Payload...)
|
||||
return event
|
||||
}
|
||||
62
internal/store/memory/client_telemetry_test.go
Normal file
62
internal/store/memory/client_telemetry_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestClientTelemetryStoreIdempotencyRateLimitAndRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewClientTelemetryStore()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
newEvent := func(subject int64, at time.Time) domain.ClientTelemetryEvent {
|
||||
event, err := domain.NewClientTelemetryEvent(
|
||||
71, domain.ClientTelemetryMessageDelivery,
|
||||
domain.Peer{Type: domain.PeerTypeUser, ID: 72},
|
||||
[]int64{subject}, map[string]any{"push": true}, at,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
first := newEvent(1, now)
|
||||
stored, created, err := store.CreateClientTelemetry(ctx, first)
|
||||
if err != nil || !created || stored.ID <= 0 {
|
||||
t.Fatalf("first=%+v created=%v err=%v", stored, created, err)
|
||||
}
|
||||
retry, created, err := store.CreateClientTelemetry(ctx, first)
|
||||
if err != nil || created || retry.ID != stored.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
for i := 1; i < domain.MaxClientTelemetryEventsPerHour; i++ {
|
||||
if _, created, err := store.CreateClientTelemetry(
|
||||
ctx, newEvent(int64(i+1), now),
|
||||
); err != nil || !created {
|
||||
t.Fatalf("create %d created=%v err=%v", i, created, err)
|
||||
}
|
||||
}
|
||||
if got, created, err := store.CreateClientTelemetry(ctx, first); err != nil ||
|
||||
created || got.ID != stored.ID {
|
||||
t.Fatalf("retry at limit got=%+v created=%v err=%v", got, created, err)
|
||||
}
|
||||
if _, _, err := store.CreateClientTelemetry(
|
||||
ctx, newEvent(domain.MaxClientTelemetryEventsPerHour+1, now),
|
||||
); !errors.Is(err, domain.ErrClientTelemetryRateLimited) {
|
||||
t.Fatalf("overflow err=%v", err)
|
||||
}
|
||||
deleted, err := store.DeleteExpiredClientTelemetry(
|
||||
ctx, now.Add(time.Second), domain.MaxClientTelemetryEventsPerHour+1,
|
||||
)
|
||||
if err != nil || deleted != domain.MaxClientTelemetryEventsPerHour {
|
||||
t.Fatalf("deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
recreated, created, err := store.CreateClientTelemetry(ctx, first)
|
||||
if err != nil || !created || recreated.ID == stored.ID {
|
||||
t.Fatalf("recreated=%+v created=%v err=%v", recreated, created, err)
|
||||
}
|
||||
}
|
||||
1010
internal/store/memory/moderation.go
Normal file
1010
internal/store/memory/moderation.go
Normal file
File diff suppressed because it is too large
Load diff
221
internal/store/memory/moderation_case_test.go
Normal file
221
internal/store/memory/moderation_case_test.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestModerationCaseLifecycleAndNewReportsDuringAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 900}
|
||||
store := NewModerationReportStore()
|
||||
create := func(reporter int64, option string, at time.Time) domain.ModerationReport {
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake,
|
||||
Option: option,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: at,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, created, err := store.CreateModerationReport(ctx, report)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("create report created=%v err=%v", created, err)
|
||||
}
|
||||
return stored
|
||||
}
|
||||
create(101, "fake", now)
|
||||
create(102, "fake:impersonation", now.Add(time.Second))
|
||||
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
item := cases[0]
|
||||
if item.ReportCount != 2 || item.DistinctReporterCount != 2 ||
|
||||
item.Version != 2 || item.Severity != domain.ModerationSeverityMedium {
|
||||
t.Fatalf("case aggregate=%+v", item)
|
||||
}
|
||||
claimed, err := store.ClaimModerationCase(ctx, item.ID, item.Version, "reviewer", now.Add(2*time.Second))
|
||||
if err != nil || claimed.Status != domain.ModerationCaseInReview {
|
||||
t.Fatalf("claim=%+v err=%v", claimed, err)
|
||||
}
|
||||
decision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
|
||||
CaseID: item.ID, ExpectedVersion: claimed.Version, Actor: "reviewer",
|
||||
Reason: "confirmed impersonation", CommandID: "decision-1",
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(3 * time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, created, err := store.DecideModerationCase(ctx, decision)
|
||||
if err != nil || !created ||
|
||||
detail.Case.Status != domain.ModerationCaseActionPending ||
|
||||
len(detail.Actions) != 1 {
|
||||
t.Fatalf("decision detail=%+v created=%v err=%v", detail, created, err)
|
||||
}
|
||||
if _, created, err := store.DecideModerationCase(ctx, decision); err != nil || created {
|
||||
t.Fatalf("decision retry created=%v err=%v", created, err)
|
||||
}
|
||||
|
||||
// Once a decision is durable, later reports open a new case instead of
|
||||
// mutating the evidence set under the pending action.
|
||||
create(103, "fake:new-evidence", now.Add(4*time.Second))
|
||||
cases, err = store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 2 {
|
||||
t.Fatalf("cases after new evidence=%+v err=%v", cases, err)
|
||||
}
|
||||
actions, err := store.ClaimModerationActions(ctx, now.Add(5*time.Second), 10, time.Minute)
|
||||
if err != nil || len(actions) != 1 {
|
||||
t.Fatalf("claimed actions=%+v err=%v", actions, err)
|
||||
}
|
||||
if err := store.CompleteModerationAction(
|
||||
ctx, actions[0].ID, actions[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(6*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, found, err := store.GetModerationCase(ctx, item.ID)
|
||||
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
|
||||
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
|
||||
}
|
||||
appeal, err := domain.NewModerationAppeal(
|
||||
item.ID, target.ID, domain.ModerationCaseResolved,
|
||||
"This is a mistake.", now.Add(7*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, created, err := store.CreateModerationAppeal(ctx, appeal); err != nil || !created {
|
||||
t.Fatalf("appeal created=%v err=%v", created, err)
|
||||
}
|
||||
appealed, _, _ := store.GetModerationCase(ctx, item.ID)
|
||||
if appealed.Case.Status != domain.ModerationCaseAppealReview ||
|
||||
len(appealed.Appeals) != 1 {
|
||||
t.Fatalf("appealed detail=%+v", appealed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationActionFailedCanBeRedrivenByNewDecision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 902}
|
||||
store := NewModerationReportStore()
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: 901, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
|
||||
t.Fatalf("create report created=%v err=%v", created, err)
|
||||
}
|
||||
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := store.ClaimModerationCase(
|
||||
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstDecision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "first command kept failing",
|
||||
CommandID: "redrive-first", Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(2 * time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, created, err := store.DecideModerationCase(ctx, firstDecision); err != nil || !created {
|
||||
t.Fatalf("first decision created=%v err=%v", created, err)
|
||||
}
|
||||
for attempt := 1; attempt <= domain.MaxModerationActionAttempts; attempt++ {
|
||||
at := now.Add(time.Duration(attempt+2) * time.Second)
|
||||
actions, err := store.ClaimModerationActions(ctx, at, 10, time.Second)
|
||||
if err != nil || len(actions) != 1 {
|
||||
t.Fatalf("attempt %d actions=%+v err=%v", attempt, actions, err)
|
||||
}
|
||||
if err := store.CompleteModerationAction(
|
||||
ctx, actions[0].ID, actions[0].Attempts, false, "downstream unavailable",
|
||||
at.Add(time.Millisecond), at,
|
||||
); err != nil {
|
||||
t.Fatalf("attempt %d: %v", attempt, err)
|
||||
}
|
||||
}
|
||||
failed, found, err := store.GetModerationCase(ctx, claimed.ID)
|
||||
if err != nil || !found || failed.Case.Status != domain.ModerationCaseActionFailed ||
|
||||
len(failed.Actions) != 1 ||
|
||||
failed.Actions[0].Status != domain.ModerationActionFailed {
|
||||
t.Fatalf("failed=%+v found=%v err=%v", failed, found, err)
|
||||
}
|
||||
redrive, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: failed.Case.Version,
|
||||
Actor: "reviewer", Reason: "redrive after dependency recovery",
|
||||
CommandID: "redrive-second", Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, created, err := store.DecideModerationCase(ctx, redrive)
|
||||
if err != nil || !created ||
|
||||
pending.Case.Status != domain.ModerationCaseActionPending ||
|
||||
len(pending.Actions) != 2 {
|
||||
t.Fatalf("pending=%+v created=%v err=%v", pending, created, err)
|
||||
}
|
||||
actions, err := store.ClaimModerationActions(ctx, now.Add(2*time.Minute), 10, time.Second)
|
||||
if err != nil || len(actions) != 1 || actions[0].DecisionID == failed.Actions[0].DecisionID {
|
||||
t.Fatalf("redrive actions=%+v err=%v", actions, err)
|
||||
}
|
||||
if err := store.CompleteModerationAction(
|
||||
ctx, actions[0].ID, actions[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(2*time.Minute+time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, found, err := store.GetModerationCase(ctx, claimed.ID)
|
||||
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
|
||||
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
|
||||
}
|
||||
var failedCount, succeededCount int
|
||||
for _, action := range resolved.Actions {
|
||||
switch action.Status {
|
||||
case domain.ModerationActionFailed:
|
||||
failedCount++
|
||||
case domain.ModerationActionSucceeded:
|
||||
succeededCount++
|
||||
}
|
||||
}
|
||||
if failedCount != 1 || succeededCount != 1 {
|
||||
t.Fatalf("action history failed=%d succeeded=%d", failedCount, succeededCount)
|
||||
}
|
||||
}
|
||||
92
internal/store/memory/moderation_test.go
Normal file
92
internal/store/memory/moderation_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestModerationReportStoreIdempotencyAndCopyIsolation(t *testing.T) {
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: 11, Source: domain.ModerationSourceMessages,
|
||||
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
|
||||
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemMessage,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
|
||||
ItemID: 5, AuthorUserID: 22, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"message":"spam"}`),
|
||||
}},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewModerationReportStore()
|
||||
first, created, err := store.CreateModerationReport(context.Background(), report)
|
||||
if err != nil || !created || first.ID <= 0 {
|
||||
t.Fatalf("first = %+v created=%v err=%v", first, created, err)
|
||||
}
|
||||
first.Items[0].Evidence[0] = '['
|
||||
retry, created, err := store.CreateModerationReport(context.Background(), report)
|
||||
if err != nil || created || retry.ID != first.ID {
|
||||
t.Fatalf("retry = %+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
if retry.Items[0].Evidence[0] != '{' {
|
||||
t.Fatalf("caller mutation changed stored evidence: %s", retry.Items[0].Evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationReportStoreRateLimitDoesNotChargeIdempotentRetry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewModerationReportStore()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
var first domain.ModerationReport
|
||||
for i := 0; i < domain.MaxModerationReportsPerHour; i++ {
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
|
||||
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
|
||||
Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemMessage,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
|
||||
ItemID: int64(i + 1), AuthorUserID: 72,
|
||||
EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"message":"spam"}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
|
||||
t.Fatalf("create %d: created=%v err=%v", i, created, err)
|
||||
}
|
||||
if i == 0 {
|
||||
first = report
|
||||
}
|
||||
}
|
||||
if got, created, err := store.CreateModerationReport(ctx, first); err != nil || created || got.ID == 0 {
|
||||
t.Fatalf("retry after limit: got=%+v created=%v err=%v", got, created, err)
|
||||
}
|
||||
overflow, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
|
||||
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
|
||||
Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemMessage,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
|
||||
ItemID: 999, AuthorUserID: 72, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"message":"overflow"}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := store.CreateModerationReport(ctx, overflow); err != domain.ErrModerationRateLimited {
|
||||
t.Fatalf("overflow err=%v, want ErrModerationRateLimited", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -160,6 +160,18 @@ func (s *PasswordStore) GetAccountSettings(_ context.Context, userID int64) (dom
|
|||
return settings, ok, nil // AccountSettings 全是值类型,无需深拷贝
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetAccountSettingsBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
|
||||
out := make(map[int64]domain.AccountSettings, len(userIDs))
|
||||
s.mu.RLock()
|
||||
for _, userID := range userIDs {
|
||||
if settings, ok := s.accountSettings[userID]; ok {
|
||||
out[userID] = settings
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveAccountSettings(_ context.Context, userID int64, settings domain.AccountSettings) error {
|
||||
s.mu.Lock()
|
||||
s.accountSettings[userID] = settings
|
||||
|
|
|
|||
|
|
@ -105,9 +105,20 @@ func cloneUpdateEvent(event domain.UpdateEvent) domain.UpdateEvent {
|
|||
event.Reaction = cloneUpdateReaction(event.Reaction)
|
||||
event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies)
|
||||
event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage)
|
||||
event.Privacy.Rules = clonePrivacyRuleSlice(event.Privacy.Rules)
|
||||
return event
|
||||
}
|
||||
|
||||
func clonePrivacyRuleSlice(in []domain.PrivacyRule) []domain.PrivacyRule {
|
||||
out := make([]domain.PrivacyRule, len(in))
|
||||
for i := range in {
|
||||
out[i] = in[i]
|
||||
out[i].UserIDs = append([]int64(nil), in[i].UserIDs...)
|
||||
out[i].ChatIDs = append([]int64(nil), in[i].ChatIDs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneUpdateStory(story domain.Story) domain.Story {
|
||||
story.Entities = append([]domain.MessageEntity(nil), story.Entities...)
|
||||
story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue