Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
|
|
@ -3,7 +3,9 @@ package account
|
|||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -26,6 +28,10 @@ const (
|
|||
codeChannelEmailChange = "email_change"
|
||||
codeChannelEmailLogin = "email_login"
|
||||
codeChannelEmailSetupRequired = "email_setup_required"
|
||||
codeChannelPasswordRecovery = "password_recovery"
|
||||
passwordRecoveryCodePrefix = "password-recovery:"
|
||||
passwordRecoveryCodeTTL = 15 * time.Minute
|
||||
passwordRecoveryCASRetries = 32
|
||||
)
|
||||
|
||||
// Service 提供账号安全配置查询。
|
||||
|
|
@ -411,11 +417,9 @@ func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (st
|
|||
if !settings.HasPassword || settings.RecoveryEmail == "" {
|
||||
return "", domain.ErrPasswordRecoveryNA
|
||||
}
|
||||
// A fixed recovery code was used here regardless of whether it was
|
||||
// actually emailed anywhere, which let anyone reset 2FA on any account
|
||||
// with a recovery email set. Recovery now requires a real sender and a
|
||||
// freshly generated code delivered to it -- no sender, no recovery.
|
||||
if s.loginEmailSender == nil {
|
||||
// Recovery has no development-code fallback. If the server cannot both
|
||||
// persist and deliver a fresh code, it must report the flow unavailable.
|
||||
if s == nil || s.codes == nil || s.loginEmailSender == nil {
|
||||
return "", domain.ErrPasswordRecoveryNA
|
||||
}
|
||||
code, err := randomDigits(s.loginEmailCodeLength)
|
||||
|
|
@ -426,14 +430,20 @@ func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (st
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expiresAtUnix := time.Now().Unix() + recoveryCodeTTL
|
||||
expiresAt := time.Unix(expiresAtUnix, 0)
|
||||
settings.RecoveryCode = code
|
||||
settings.RecoveryCodeExpiresAt = expiresAtUnix
|
||||
if s.passwords != nil {
|
||||
if err := s.passwords.Save(ctx, userID, settings); err != nil {
|
||||
return "", err
|
||||
}
|
||||
expiresAt := time.Now().Add(passwordRecoveryCodeTTL)
|
||||
key := passwordRecoveryCodeKey(userID)
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
UserID: userID,
|
||||
Email: normalizeLoginEmail(settings.RecoveryEmail),
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: codeChannelPasswordRecovery,
|
||||
MaxAttempts: s.loginEmailCodeMaxAttempts,
|
||||
RecoveryBinding: passwordRecoveryBinding(settings),
|
||||
}
|
||||
if err := s.codes.Set(ctx, key, rec, passwordRecoveryCodeTTL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
|
|
@ -443,14 +453,12 @@ func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (st
|
|||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
if s.passwords != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
settings.RecoveryCode = ""
|
||||
settings.RecoveryCodeExpiresAt = 0
|
||||
_ = s.passwords.Save(cleanupCtx, userID, settings)
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
if cleanupErr := s.deletePasswordRecoveryCode(cleanupCtx, key, deliveryID); cleanupErr != nil {
|
||||
return "", fmt.Errorf("deliver password recovery code: %w (cleanup failed: %v)", err, cleanupErr)
|
||||
}
|
||||
return "", err
|
||||
return "", fmt.Errorf("deliver password recovery code: %w", err)
|
||||
}
|
||||
return emailPattern(settings.RecoveryEmail), nil
|
||||
}
|
||||
|
|
@ -460,23 +468,35 @@ func (s *Service) CheckRecoveryPassword(ctx context.Context, userID int64, code
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkRecoveryCode(settings, code)
|
||||
if !settings.HasPassword || settings.RecoveryEmail == "" {
|
||||
return domain.ErrPasswordRecoveryExpired
|
||||
}
|
||||
return s.verifyPasswordRecoveryCode(ctx, userID, passwordRecoveryBinding(settings), code, false)
|
||||
}
|
||||
|
||||
func (s *Service) RecoverPassword(ctx context.Context, userID int64, code string, input *domain.PasswordInputSettings) error {
|
||||
if s == nil || s.passwords == nil || userID == 0 {
|
||||
return nil
|
||||
return domain.ErrPasswordRecoveryExpired
|
||||
}
|
||||
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkRecoveryCode(settings, code); err != nil {
|
||||
return err
|
||||
if !settings.HasPassword || settings.RecoveryEmail == "" {
|
||||
return domain.ErrPasswordRecoveryExpired
|
||||
}
|
||||
binding := passwordRecoveryBinding(settings)
|
||||
if input == nil || len(input.NewPasswordHash) == 0 {
|
||||
settings = defaultPasswordSettings()
|
||||
return s.passwords.Save(ctx, userID, settings)
|
||||
if err := s.verifyPasswordRecoveryCode(ctx, userID, binding, code, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.passwords.Save(ctx, userID, defaultPasswordSettings())
|
||||
}
|
||||
// Reject invalid proofs before doing the comparatively expensive SRP
|
||||
// verifier/challenge work. The final consuming check below still decides
|
||||
// the single winner if the code changes concurrently.
|
||||
if err := s.verifyPasswordRecoveryCode(ctx, userID, binding, code, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNewPasswordSettings(*input); err != nil {
|
||||
return err
|
||||
|
|
@ -495,8 +515,9 @@ func (s *Service) RecoverPassword(ctx context.Context, userID int64, code string
|
|||
if input.HasHint {
|
||||
settings.Hint = input.Hint
|
||||
}
|
||||
settings.RecoveryCode = ""
|
||||
settings.RecoveryCodeExpiresAt = 0
|
||||
if err := s.verifyPasswordRecoveryCode(ctx, userID, binding, code, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.passwords.Save(ctx, userID, normalizePasswordSettings(settings))
|
||||
}
|
||||
|
||||
|
|
@ -555,32 +576,123 @@ func (s *Service) ResendPasswordEmail(ctx context.Context, userID int64) error {
|
|||
}
|
||||
|
||||
func (s *Service) CancelPasswordEmail(ctx context.Context, userID int64) error {
|
||||
if s != nil && s.codes != nil && userID != 0 {
|
||||
if err := s.codes.Del(ctx, passwordRecoveryCodeKey(userID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.EmailUnconfirmedPattern = ""
|
||||
settings.RecoveryCode = ""
|
||||
settings.RecoveryCodeExpiresAt = 0
|
||||
if s.passwords != nil {
|
||||
return s.passwords.Save(ctx, userID, settings)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRecoveryCode(settings domain.PasswordSettings, code string) error {
|
||||
// No standing fixed-code fallback: an unrequested (or already consumed)
|
||||
// recovery must not be satisfiable by any code at all.
|
||||
if settings.RecoveryCode == "" {
|
||||
return domain.ErrPasswordRecoveryNA
|
||||
func passwordRecoveryCodeKey(userID int64) string {
|
||||
return passwordRecoveryCodePrefix + fmt.Sprint(userID)
|
||||
}
|
||||
|
||||
func passwordRecoveryBinding(settings domain.PasswordSettings) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s", settings.SRPID, normalizeLoginEmail(settings.RecoveryEmail))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Service) deletePasswordRecoveryCode(ctx context.Context, key, deliveryID string) error {
|
||||
for attempt := 0; attempt < passwordRecoveryCASRetries; attempt++ {
|
||||
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
|
||||
if err != nil || !found {
|
||||
return err
|
||||
}
|
||||
if snapshot.Record.Channel != codeChannelPasswordRecovery || snapshot.Record.DeliveryID != deliveryID {
|
||||
return nil
|
||||
}
|
||||
deleted, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if settings.RecoveryCodeExpiresAt > 0 && time.Now().Unix() > settings.RecoveryCodeExpiresAt {
|
||||
return domain.ErrEmailCodeInvalid
|
||||
return fmt.Errorf("delete password recovery code: concurrent state did not settle")
|
||||
}
|
||||
|
||||
// verifyPasswordRecoveryCode keeps check non-consuming while making the final
|
||||
// recovery a single-winner CAS. Wrong attempts are counted atomically and the
|
||||
// code is removed at the configured threshold.
|
||||
func (s *Service) verifyPasswordRecoveryCode(ctx context.Context, userID int64, binding, code string, consume bool) error {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return domain.ErrRecoveryCodeEmpty
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(settings.RecoveryCode), []byte(code)) != 1 {
|
||||
return domain.ErrEmailCodeInvalid
|
||||
if s == nil || s.codes == nil || userID == 0 {
|
||||
return domain.ErrPasswordRecoveryExpired
|
||||
}
|
||||
return nil
|
||||
key := passwordRecoveryCodeKey(userID)
|
||||
for attempt := 0; attempt < passwordRecoveryCASRetries; attempt++ {
|
||||
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return domain.ErrPasswordRecoveryExpired
|
||||
}
|
||||
rec := snapshot.Record
|
||||
if rec.Channel != codeChannelPasswordRecovery || rec.UserID != userID || rec.RecoveryBinding == "" || rec.RecoveryBinding != binding {
|
||||
deleted, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted {
|
||||
return domain.ErrPasswordRecoveryExpired
|
||||
}
|
||||
continue
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(rec.Code), []byte(code)) == 1 {
|
||||
if !consume {
|
||||
return nil
|
||||
}
|
||||
deleted, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
maxAttempts := rec.MaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = s.loginEmailCodeMaxAttempts
|
||||
}
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
rec.Attempts++
|
||||
var applied bool
|
||||
if rec.Attempts >= maxAttempts {
|
||||
applied, err = s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
|
||||
} else {
|
||||
applied, err = s.codes.CompareAndUpdate(ctx, key, snapshot.Revision, rec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
return domain.ErrRecoveryCodeInvalid
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("verify password recovery code: concurrent state did not settle")
|
||||
}
|
||||
|
||||
func randomBytesOrDefault(n int, fallback []byte) []byte {
|
||||
|
|
@ -613,14 +725,24 @@ func randomDigits(n int) (string, error) {
|
|||
if n <= 0 {
|
||||
n = 6
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(n)
|
||||
for _, v := range b {
|
||||
out.WriteByte(byte('0') + v%10)
|
||||
var buf [32]byte
|
||||
for out.Len() < n {
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, v := range buf {
|
||||
// Reject the top six values so every digit has exactly 25 source
|
||||
// byte values instead of inheriting modulo bias from 256 %% 10.
|
||||
if v >= 250 {
|
||||
continue
|
||||
}
|
||||
out.WriteByte(byte('0') + v%10)
|
||||
if out.Len() == n {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
|
@ -1072,6 +1194,41 @@ func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.
|
|||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAccountSettingsBatch is the bounded cold loader behind the RPC read
|
||||
// model. Missing rows are returned as explicit defaults so they are negative
|
||||
// cached instead of being queried again.
|
||||
func (s *Service) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
|
||||
out := make(map[int64]domain.AccountSettings, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID > 0 {
|
||||
out[userID] = domain.DefaultAccountSettings()
|
||||
}
|
||||
}
|
||||
if s == nil || s.settings == nil || len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if batch, ok := s.settings.(store.AccountSettingsBatchStore); ok {
|
||||
loaded, err := batch.GetAccountSettingsBatch(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for userID, settings := range loaded {
|
||||
out[userID] = settings
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
for userID := range out {
|
||||
settings, found, err := s.settings.GetAccountSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
out[userID] = settings
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
|
||||
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
|
||||
settings, err := s.GetAccountSettings(ctx, userID)
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ import (
|
|||
"crypto/sha512"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -95,8 +97,11 @@ func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
|
|||
if pattern != "b***b@example.com" {
|
||||
t.Fatalf("recovery pattern = %q, want masked email", pattern)
|
||||
}
|
||||
if sender.to != "bob@example.com" || sender.code == "" {
|
||||
t.Fatalf("sender = %+v, want delivered code to bob@example.com", sender)
|
||||
if sender.to != "bob@example.com" || sender.code == "" || len(sender.requests) != 1 || sender.requests[0].Purpose != otpdelivery.PurposePasswordRecovery {
|
||||
t.Fatalf("recovery delivery = %+v, want one password-recovery email", sender)
|
||||
}
|
||||
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); err != nil {
|
||||
t.Fatalf("CheckRecoveryPassword: %v", err)
|
||||
}
|
||||
if err := svc.RecoverPassword(ctx, userID, sender.code, nil); err != nil {
|
||||
t.Fatalf("RecoverPassword clear: %v", err)
|
||||
|
|
@ -110,6 +115,155 @@ func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPasswordRecoveryFailsClosedWithoutSenderOrIssuedCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1012
|
||||
passwords := memory.NewPasswordStore()
|
||||
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
|
||||
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
|
||||
SRPID: 7, SRPVerifier: []byte{1, 2, 3},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(passwords)
|
||||
if _, err := svc.RequestPasswordRecovery(ctx, userID); !errors.Is(err, domain.ErrPasswordRecoveryNA) {
|
||||
t.Fatalf("RequestPasswordRecovery err=%v, want unavailable", err)
|
||||
}
|
||||
if err := svc.RecoverPassword(ctx, userID, "12345", nil); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
|
||||
t.Fatalf("standing fixed code err=%v, want expired", err)
|
||||
}
|
||||
if err := NewService(nil).RecoverPassword(ctx, userID, "12345", nil); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
|
||||
t.Fatalf("missing password store err=%v, want fail-closed expiry", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordRecoveryAttemptLimitAndStateBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1013
|
||||
passwords := memory.NewPasswordStore()
|
||||
settings := domain.PasswordSettings{
|
||||
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
|
||||
SRPID: 8, SRPVerifier: []byte{4, 5, 6},
|
||||
}
|
||||
if err := passwords.Save(ctx, userID, settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(passwords,
|
||||
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 3, 6))
|
||||
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
if err := svc.CheckRecoveryPassword(ctx, userID, "000000"); !errors.Is(err, domain.ErrRecoveryCodeInvalid) {
|
||||
t.Fatalf("wrong attempt %d err=%v, want invalid", attempt, err)
|
||||
}
|
||||
}
|
||||
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
|
||||
t.Fatalf("code after attempt limit err=%v, want expired", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued := sender.code
|
||||
settings.SRPID++
|
||||
if err := passwords.Save(ctx, userID, settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.CheckRecoveryPassword(ctx, userID, issued); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
|
||||
t.Fatalf("code after 2FA state change err=%v, want expired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentPasswordRecoveryHasSingleConsumer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1014
|
||||
passwords := memory.NewPasswordStore()
|
||||
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
|
||||
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
|
||||
SRPID: 9, SRPVerifier: []byte{7, 8, 9},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(passwords,
|
||||
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 5, 6))
|
||||
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const workers = 24
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs <- svc.RecoverPassword(ctx, userID, sender.code, nil)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
successes := 0
|
||||
for err := range errs {
|
||||
if err == nil {
|
||||
successes++
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
|
||||
t.Fatalf("concurrent recovery err=%v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("successful recoveries=%d, want 1", successes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordRecoveryDeliveryFailureRemovesIssuedCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1015
|
||||
passwords := memory.NewPasswordStore()
|
||||
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
|
||||
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
|
||||
SRPID: 10, SRPVerifier: []byte{10},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sender := &captureMailSender{err: errors.New("provider rejected request")}
|
||||
svc := NewService(passwords,
|
||||
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 5, 6))
|
||||
if _, err := svc.RequestPasswordRecovery(ctx, userID); err == nil {
|
||||
t.Fatal("RequestPasswordRecovery succeeded after known delivery failure")
|
||||
}
|
||||
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
|
||||
t.Fatalf("undelivered code err=%v, want expired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordRecoveryUnknownDeliveryOutcomeKeepsIssuedCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1016
|
||||
passwords := memory.NewPasswordStore()
|
||||
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
|
||||
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
|
||||
SRPID: 11, SRPVerifier: []byte{11},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sender := &captureMailSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("provider ACK lost")}}
|
||||
svc := NewService(passwords,
|
||||
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 5, 6))
|
||||
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
|
||||
t.Fatalf("RequestPasswordRecovery outcome-unknown err=%v", err)
|
||||
}
|
||||
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); err != nil {
|
||||
t.Fatalf("outcome-unknown code was discarded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordWaitAndDecline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1003
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
|
||||
const (
|
||||
passwordHashSize = 256
|
||||
recoveryCodeTTL = 15 * 60
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -870,27 +870,24 @@ func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, p
|
|||
// never offers a "Can't access this email?" escape hatch that can only ever
|
||||
// fail (or, before this was locked down, silently succeed with the
|
||||
// well-known fixed dev code).
|
||||
//
|
||||
// emailSignupEnabled accounts fail this even with a real phoneCodeSender:
|
||||
// their "phone" is a synthetic 888-prefixed display number
|
||||
// (domain.NewEmailSignupDisplayPhone), never a real number anyone can
|
||||
// receive SMS on -- email is the actual identity there, regardless of what
|
||||
// SMS infra exists for other (real-phone) accounts on this server.
|
||||
func (s *Service) LoginEmailResetAvailable() bool {
|
||||
return s.phoneCodeSender != nil && !s.emailSignupEnabled
|
||||
return s != nil && s.phoneCodeSender != nil && s.codes != nil && s.users != nil && !s.emailSignupEnabled
|
||||
}
|
||||
|
||||
// ConsumeLoginEmailReset authorizes auth.resetLoginEmail with the exact
|
||||
// email-login hash previously issued for this phone owner. Possession of only
|
||||
// a phone number is never sufficient to remove an authentication factor.
|
||||
func (s *Service) ConsumeLoginEmailReset(ctx context.Context, phone, phoneCodeHash string) (int64, error) {
|
||||
// This flow exists to fall back to an SMS code when the login email is
|
||||
// unreachable. Two independent reasons it must refuse outright, before
|
||||
// ClearLoginEmail runs so nothing is ever mutated on a doomed request:
|
||||
// - no real phoneCodeSender: the "SMS code" is always the well-known
|
||||
// TELESRV_DEV_AUTH_CODE (see createPhoneCode), so anyone who can call
|
||||
// sendCode for a phone (no email access required) could strip the
|
||||
// login-email requirement with a publicly known code.
|
||||
// - emailSignupEnabled: this account's "phone" is a synthetic 888-
|
||||
// prefixed display number (domain.NewEmailSignupDisplayPhone), never
|
||||
// a real number anyone can receive SMS on. Email is the actual
|
||||
// identity here regardless of whether a real SMS sender happens to
|
||||
// be configured for other (real-phone) accounts on this server.
|
||||
if s.phoneCodeSender == nil || s.emailSignupEnabled {
|
||||
// Refuse before consuming the email proof or clearing any account state. If
|
||||
// there is no real SMS sender, the successor code would be the public
|
||||
// development code and could strip the login-email factor.
|
||||
if !s.LoginEmailResetAvailable() || s.codes == nil {
|
||||
return 0, ErrCodeInvalid
|
||||
}
|
||||
phone = normalizePhone(phone)
|
||||
|
|
@ -1488,18 +1485,7 @@ func (s *Service) ResetAuthorization(ctx context.Context, userID, hash int64) (d
|
|||
if revoker, ok := s.auths.(authorizationRevoker); ok {
|
||||
return revoker.RevokeByHash(ctx, userID, hash)
|
||||
}
|
||||
target, found, err := s.authorizationByHash(ctx, userID, hash)
|
||||
if err != nil || !found {
|
||||
return target, found, err
|
||||
}
|
||||
if err := s.deleteAuthKey(ctx, target.AuthKeyID); err != nil {
|
||||
return target, true, err
|
||||
}
|
||||
deleted, found, err := s.auths.DeleteByHash(ctx, userID, hash)
|
||||
if err != nil || !found {
|
||||
return deleted, found, err
|
||||
}
|
||||
return deleted, true, nil
|
||||
return s.auths.DeleteByHash(ctx, userID, hash)
|
||||
}
|
||||
|
||||
func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
|
|
@ -1509,54 +1495,7 @@ func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAut
|
|||
if revoker, ok := s.auths.(authorizationRevoker); ok {
|
||||
return revoker.RevokeByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
}
|
||||
targets, err := s.authorizationsByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range targets {
|
||||
if err := s.deleteAuthKey(ctx, a.AuthKeyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
deleted, err := s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteAuthKey(ctx context.Context, authKeyID [8]byte) error {
|
||||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||
return nil
|
||||
}
|
||||
return s.authKeys.Delete(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) authorizationByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
items, err := s.auths.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
for _, a := range items {
|
||||
if a.Hash == hash {
|
||||
return a, true, nil
|
||||
}
|
||||
}
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
items, err := s.auths.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.Authorization, 0, len(items))
|
||||
for _, a := range items {
|
||||
if a.AuthKeyID != keepAuthKeyID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
|
||||
func TestResetAuthorizationKeepsProtocolAuthKeyForRPCLogout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
|
|
@ -562,15 +562,15 @@ func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
|
|||
if err != nil || !found || deleted.AuthKeyID != key {
|
||||
t.Fatalf("ResetAuthorization deleted=%x found=%v err=%v, want key %x", deleted.AuthKeyID, found, err, key)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, key); err != nil || found {
|
||||
t.Fatalf("auth key after reset found=%v err=%v, want missing", found, err)
|
||||
if _, found, err := keys.Get(ctx, key); err != nil || !found {
|
||||
t.Fatalf("auth key after reset found=%v err=%v, want present for RPC 401", found, err)
|
||||
}
|
||||
if _, found, err := svc.UserID(ctx, key); err != nil || found {
|
||||
t.Fatalf("user after reset found=%v err=%v, want missing", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
|
||||
func TestResetAuthorizationsKeepsRevokedProtocolAuthKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
|
|
@ -600,12 +600,18 @@ func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
|
|||
if err != nil || len(deleted) != 1 || deleted[0].AuthKeyID != revoked {
|
||||
t.Fatalf("ResetAuthorizations deleted=%v err=%v, want revoked key", deleted, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, revoked); err != nil || found {
|
||||
t.Fatalf("revoked auth key found=%v err=%v, want missing", found, err)
|
||||
if _, found, err := keys.Get(ctx, revoked); err != nil || !found {
|
||||
t.Fatalf("revoked auth key found=%v err=%v, want present for RPC 401", found, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, keep); err != nil || !found {
|
||||
t.Fatalf("kept auth key found=%v err=%v, want present", found, err)
|
||||
}
|
||||
if _, found, err := svc.UserID(ctx, revoked); err != nil || found {
|
||||
t.Fatalf("revoked user found=%v err=%v, want missing", found, err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, keep); err != nil || !found || got != u.ID {
|
||||
t.Fatalf("kept user=%d found=%v err=%v, want %d", got, found, err, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -443,8 +443,12 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
|
|||
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
|
||||
codes := memory.NewCodeStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
otp := &captureOTPSender{}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
|
||||
WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(&captureOTPSender{}, 5))
|
||||
WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(otp, 5))
|
||||
if !svc.LoginEmailResetAvailable() {
|
||||
t.Fatal("LoginEmailResetAvailable=false with real SMS sender")
|
||||
}
|
||||
seed := func(hash, channel string) {
|
||||
t.Helper()
|
||||
if err := codes.Set(ctx, hash, store.PhoneCode{
|
||||
|
|
@ -501,6 +505,33 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoginEmailResetUnavailableWithoutRealSMSSender(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "15550009339", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
const hash = "unavailable-email-reset"
|
||||
if err := codes.Set(ctx, hash, store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent, IssuedUserID: owner.ID,
|
||||
Phone: owner.Phone, Code: "654321", Channel: codeChannelEmailLogin,
|
||||
}, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
|
||||
if svc.LoginEmailResetAvailable() {
|
||||
t.Fatal("LoginEmailResetAvailable=true without real SMS sender")
|
||||
}
|
||||
if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("ConsumeLoginEmailReset err=%v, want invalid", err)
|
||||
}
|
||||
if _, found, err := codes.Get(ctx, hash); err != nil || !found {
|
||||
t.Fatalf("unavailable reset consumed proof found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentLoginEmailResetHasSingleConsumer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
|
|||
53
internal/app/authdiagnostics/service.go
Normal file
53
internal/app/authdiagnostics/service.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package authdiagnostics
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
codes store.CodeStore
|
||||
reports store.AuthDeliveryReportStore
|
||||
}
|
||||
|
||||
func NewService(codes store.CodeStore, reports store.AuthDeliveryReportStore) *Service {
|
||||
return &Service{codes: codes, reports: reports}
|
||||
}
|
||||
|
||||
func (s *Service) ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error) {
|
||||
phone := domain.NormalizePhone(req.Phone)
|
||||
if s == nil || s.codes == nil || s.reports == nil ||
|
||||
!domain.ValidPhone(phone) || req.PhoneCodeHash == "" {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
record, found, err := s.codes.Get(ctx, req.PhoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeExpired
|
||||
}
|
||||
if record.Version != store.PhoneCodeVersionCurrent || record.Purpose != "" ||
|
||||
record.Phone != phone || !store.LoginCodeChannelVerifiable(record.Channel) {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
var channel domain.AuthCodeDeliveryKind
|
||||
switch record.Channel {
|
||||
case store.PhoneCodeChannelPhone:
|
||||
channel = domain.AuthCodeDeliveryPhone
|
||||
case store.PhoneCodeChannelSMS:
|
||||
channel = domain.AuthCodeDeliverySMS
|
||||
default:
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
report, err := domain.NewAuthDeliveryReport(
|
||||
req.AuthKeyID, req.SessionID, req.ClientType, phone, req.PhoneCodeHash,
|
||||
record.IssuedUserID, record.DeliveryID, channel, req.MNC, req.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, err
|
||||
}
|
||||
return s.reports.CreateAuthDeliveryReport(ctx, report)
|
||||
}
|
||||
81
internal/app/authdiagnostics/service_test.go
Normal file
81
internal/app/authdiagnostics/service_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package authdiagnostics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestReportMissingCodeValidatesLiveDeliveryAndStoresOnlyHashes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
reports := memory.NewAuthDeliveryReportStore()
|
||||
const (
|
||||
phone = "15550001234"
|
||||
codeHash = "login-code-hash"
|
||||
)
|
||||
if err := codes.Set(ctx, codeHash, store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent, Phone: phone, Code: "12345",
|
||||
DeliveryID: "delivery-1", Channel: store.PhoneCodeChannelSMS,
|
||||
IssuedUserID: 42,
|
||||
}, time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(codes, reports)
|
||||
now := time.Now().UTC()
|
||||
req := domain.AuthMissingCodeReportRequest{
|
||||
AuthKeyID: [8]byte{1, 2, 3}, SessionID: 99, ClientType: "tdesktop",
|
||||
Phone: "+1 (555) 000-1234", PhoneCodeHash: codeHash, MNC: "46000",
|
||||
CreatedAt: now,
|
||||
}
|
||||
first, created, err := service.ReportMissingCode(ctx, req)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first report created=%v err=%v", created, err)
|
||||
}
|
||||
second, created, err := service.ReportMissingCode(ctx, req)
|
||||
if err != nil || created || second.ID != first.ID {
|
||||
t.Fatalf("retry report=%+v created=%v err=%v", second, created, err)
|
||||
}
|
||||
stored := reports.Reports()
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("stored reports=%d, want 1", len(stored))
|
||||
}
|
||||
if stored[0].PhoneHash != sha256.Sum256([]byte(phone)) ||
|
||||
stored[0].CodeHash != sha256.Sum256([]byte(codeHash)) {
|
||||
t.Fatalf("stored hashes do not match normalized delivery identity: %+v", stored[0])
|
||||
}
|
||||
if stored[0].DeliveryID != "delivery-1" || stored[0].IssuedUserID != 42 ||
|
||||
stored[0].Channel != domain.AuthCodeDeliverySMS {
|
||||
t.Fatalf("stored delivery metadata=%+v", stored[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportMissingCodeRejectsUnknownOrMismatchedLoginState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
service := NewService(codes, memory.NewAuthDeliveryReportStore())
|
||||
now := time.Now().UTC()
|
||||
base := domain.AuthMissingCodeReportRequest{
|
||||
AuthKeyID: [8]byte{1}, SessionID: 10, Phone: "15550002222",
|
||||
PhoneCodeHash: "missing", CreatedAt: now,
|
||||
}
|
||||
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||
t.Fatalf("missing hash err=%v, want phone-code expired", err)
|
||||
}
|
||||
if err := codes.Set(ctx, "current", store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent, Phone: "15550003333",
|
||||
Channel: store.PhoneCodeChannelPhone,
|
||||
}, time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.PhoneCodeHash = "current"
|
||||
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
|
||||
t.Fatalf("mismatched phone err=%v, want phone-code invalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,15 +74,26 @@ You can control me by sending these commands:
|
|||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
|
||||
// botReply 是 BotFather 的一条回复。
|
||||
// botReply 是内置 service bot 的一条回复。ReplyMarkup 为可选 inline keyboard
|
||||
// 快照(@verifybot 的按钮式对话使用);落库前经 domain.ValidateReplyMarkup 校验。
|
||||
type botReply struct {
|
||||
Text string
|
||||
Entities []domain.MessageEntity
|
||||
Text string
|
||||
Entities []domain.MessageEntity
|
||||
ReplyMarkup *domain.MessageReplyMarkup
|
||||
}
|
||||
|
||||
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
||||
func (s *Service) HandlesBot(botUserID int64) bool {
|
||||
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID || botUserID == domain.ChatBotUserID)
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
switch botUserID {
|
||||
case domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID,
|
||||
domain.VerifyBotUserID, domain.VerifierBotUserID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
|
||||
|
|
@ -103,6 +114,10 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
|
|||
go s.respondAsStickers(userID, msg)
|
||||
case domain.ChatBotUserID:
|
||||
go s.respondAsChatBot(userID, msg)
|
||||
case domain.VerifyBotUserID:
|
||||
go s.respondAsVerify(userID, msg)
|
||||
case domain.VerifierBotUserID:
|
||||
go s.respondAsVerifier(userID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,12 +160,24 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
|
|||
if s == nil || s.messages == nil || reply.Text == "" {
|
||||
return domain.SendPrivateTextResult{}, false
|
||||
}
|
||||
markup := reply.ReplyMarkup
|
||||
if err := domain.ValidateReplyMarkup(markup); err != nil {
|
||||
// 键盘校验必须先于落库(I9):结构非法的 markup 绝不写库,但正文仍然发出
|
||||
// ——用户至少收到提示文本,不会因为一颗坏按钮而完全失联。
|
||||
s.log.Error("service bot: invalid reply markup",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
markup = nil
|
||||
}
|
||||
if markup.IsZero() {
|
||||
markup = nil
|
||||
}
|
||||
res, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: botUserID,
|
||||
RecipientUserID: userID,
|
||||
RandomID: s.botReplyRandomID(),
|
||||
Message: reply.Text,
|
||||
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
|
||||
ReplyMarkup: markup,
|
||||
Date: int(s.now().Unix()),
|
||||
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,30 @@ type aiChatGenerator interface {
|
|||
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
|
||||
}
|
||||
|
||||
// verificationApplications is the applicant-side surface of official platform
|
||||
// verification used by the built-in @verifybot (app/verification.Service
|
||||
// satisfies it as-is).
|
||||
//
|
||||
// It is declared as a narrow port rather than taken as a concrete service for the
|
||||
// usual reason plus one specific to this feature: every verification rule --
|
||||
// ownership, public username, restrictions, already-verified, cooldown, rate
|
||||
// limit, the status machine -- belongs to that service, and the bot must not be
|
||||
// able to reach past it. Nothing here can write a peer's verified flag.
|
||||
type verificationApplications interface {
|
||||
EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error)
|
||||
StartDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error)
|
||||
SaveDraft(ctx context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error)
|
||||
Submit(ctx context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error)
|
||||
Cancel(ctx context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error)
|
||||
Draft(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error)
|
||||
ApplicantApplications(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error)
|
||||
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
}
|
||||
|
||||
// The third-party verification ports live in verifierbot.go
|
||||
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
|
||||
// only way to reach the feature, and are kept next to the dialog that uses them.
|
||||
|
||||
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
|
||||
// router↔bots 的构造循环;这些能力都依赖 TL/连接层边界,不能在 app 层实现):
|
||||
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
|
||||
|
|
@ -83,6 +107,9 @@ type Service struct {
|
|||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
verification verificationApplications
|
||||
customVerification customVerifications
|
||||
verifierTargets verifierBotTargets
|
||||
telegramLogin *telegramloginapp.Service
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
|
|
@ -92,6 +119,13 @@ type Service struct {
|
|||
now func() time.Time
|
||||
chatBotStreamThrottle time.Duration
|
||||
publicBaseURL string
|
||||
// dialogLimiter bounds how often one applicant can drive a service-bot dialog.
|
||||
// The verification service already rate-limits application creation; this is the
|
||||
// separate bound on dialog traffic itself, so a script cannot spin the state
|
||||
// machine (and its writes) even without ever submitting anything.
|
||||
dialogLimiter store.RateLimiter
|
||||
dialogRateLimit int
|
||||
dialogRateWindow time.Duration
|
||||
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
|
||||
replySeq atomic.Int64
|
||||
replyLocks [replyLockStripes]sync.Mutex
|
||||
|
|
@ -177,6 +211,56 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithVerification injects the official verification service used by the
|
||||
// built-in @verifybot. Without it the bot still answers, but every command
|
||||
// reports that verification is unavailable rather than half-running the dialog.
|
||||
func WithVerification(v verificationApplications) Option {
|
||||
return func(s *Service) {
|
||||
if v != nil {
|
||||
s.verification = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomVerification injects the third-party verification service used by the
|
||||
// built-in @verifierbot. Without it the bot still answers, but every command
|
||||
// reports that third-party verification is unavailable rather than half-running the
|
||||
// dialog.
|
||||
func WithCustomVerification(v customVerifications) Option {
|
||||
return func(s *Service) {
|
||||
if v != nil {
|
||||
s.customVerification = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithVerifierTargets injects the directory of an applicant's own peers used by
|
||||
// @verifierbot's subject picker. It is optional: with nothing injected the bot
|
||||
// falls back to the official verification service's EligibleTargets, which
|
||||
// enumerates exactly the same peers (only its eligibility verdicts, which answer a
|
||||
// different question, are ignored).
|
||||
func WithVerifierTargets(t verifierBotTargets) Option {
|
||||
return func(s *Service) {
|
||||
if t != nil {
|
||||
s.verifierTargets = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithDialogRateLimiter bounds service-bot dialog traffic per user. A zero limit
|
||||
// or a nil limiter disables the bound, which is what a deployment without Redis
|
||||
// gets.
|
||||
func WithDialogRateLimiter(limiter store.RateLimiter, limit int, window time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if limiter == nil || limit <= 0 || window <= 0 {
|
||||
return
|
||||
}
|
||||
s.dialogLimiter = limiter
|
||||
s.dialogRateLimit = limit
|
||||
s.dialogRateWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// WithTelegramLogin injects the OIDC application service used by BotFather.
|
||||
// BotFather never writes the login tables directly.
|
||||
func WithTelegramLogin(login *telegramloginapp.Service) Option {
|
||||
|
|
@ -262,6 +346,33 @@ func (s *Service) SetAIChatGenerator(g aiChatGenerator) {
|
|||
}
|
||||
}
|
||||
|
||||
// SetVerification injects the official verification service after construction.
|
||||
// The bots service is built before the peer directories that service depends on,
|
||||
// so in the shipped process this is the wiring order that actually exists (same
|
||||
// deferred-injection pattern as SetRouterHooks).
|
||||
func (s *Service) SetVerification(v verificationApplications) {
|
||||
if s != nil && v != nil {
|
||||
s.verification = v
|
||||
}
|
||||
}
|
||||
|
||||
// SetCustomVerification injects the third-party verification service after
|
||||
// construction. The bots service is built before the stores and directories that
|
||||
// service depends on, so in the shipped process this is the wiring order that
|
||||
// actually exists (same deferred-injection pattern as SetVerification).
|
||||
func (s *Service) SetCustomVerification(v customVerifications) {
|
||||
if s != nil && v != nil {
|
||||
s.customVerification = v
|
||||
}
|
||||
}
|
||||
|
||||
// SetVerifierTargets injects @verifierbot's subject directory after construction.
|
||||
func (s *Service) SetVerifierTargets(t verifierBotTargets) {
|
||||
if s != nil && t != nil {
|
||||
s.verifierTargets = t
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 bots 服务。
|
||||
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
|
|
|
|||
1626
internal/app/bots/verifierbot.go
Normal file
1626
internal/app/bots/verifierbot.go
Normal file
File diff suppressed because it is too large
Load diff
1012
internal/app/bots/verifierbot_test.go
Normal file
1012
internal/app/bots/verifierbot_test.go
Normal file
File diff suppressed because it is too large
Load diff
1493
internal/app/bots/verifybot.go
Normal file
1493
internal/app/bots/verifybot.go
Normal file
File diff suppressed because it is too large
Load diff
984
internal/app/bots/verifybot_test.go
Normal file
984
internal/app/bots/verifybot_test.go
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake verification service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeVerification is an in-memory stand-in for app/verification.Service. It
|
||||
// keeps the properties the bot dialog actually leans on: one draft per applicant,
|
||||
// StartDraft resuming instead of duplicating, optimistic-locking versions, and
|
||||
// the domain validation of the payload.
|
||||
type fakeVerification struct {
|
||||
targets []domain.VerificationTarget
|
||||
apps map[int64]domain.VerificationApplication
|
||||
nextID int64
|
||||
starts int
|
||||
submits int
|
||||
targetsErr error
|
||||
startErr error
|
||||
}
|
||||
|
||||
func newFakeVerification(targets ...domain.VerificationTarget) *fakeVerification {
|
||||
return &fakeVerification{
|
||||
targets: targets,
|
||||
apps: make(map[int64]domain.VerificationApplication),
|
||||
nextID: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeVerification) EligibleTargets(_ context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
|
||||
if f.targetsErr != nil {
|
||||
return nil, f.targetsErr
|
||||
}
|
||||
if applicantUserID <= 0 {
|
||||
return nil, domain.ErrVerificationApplicationInvalid
|
||||
}
|
||||
return append([]domain.VerificationTarget(nil), f.targets...), nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) draftFor(applicantUserID int64) (domain.VerificationApplication, bool) {
|
||||
for _, app := range f.apps {
|
||||
if app.ApplicantUserID == applicantUserID && app.Status == domain.VerificationStatusDraft {
|
||||
return app, true
|
||||
}
|
||||
}
|
||||
return domain.VerificationApplication{}, false
|
||||
}
|
||||
|
||||
func (f *fakeVerification) StartDraft(_ context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
|
||||
f.starts++
|
||||
if app, found := f.draftFor(req.ApplicantUserID); found {
|
||||
return app, false, nil
|
||||
}
|
||||
if f.startErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.startErr
|
||||
}
|
||||
var target domain.VerificationTarget
|
||||
for _, candidate := range f.targets {
|
||||
if candidate.Type == req.TargetType && candidate.ID == req.TargetID {
|
||||
target = candidate
|
||||
}
|
||||
}
|
||||
if target.ID == 0 {
|
||||
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetInvalid
|
||||
}
|
||||
if !target.Eligible {
|
||||
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetAlreadyVerified
|
||||
}
|
||||
f.nextID++
|
||||
app := domain.VerificationApplication{
|
||||
ID: f.nextID,
|
||||
ApplicantUserID: req.ApplicantUserID,
|
||||
TargetType: target.Type,
|
||||
TargetID: target.ID,
|
||||
TargetTitle: target.Title,
|
||||
TargetUsername: target.Username,
|
||||
Status: domain.VerificationStatusDraft,
|
||||
CreatedAt: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC),
|
||||
Version: 1,
|
||||
}
|
||||
f.apps[app.ID] = app
|
||||
return app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) SaveDraft(_ context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
|
||||
app, found := f.apps[applicationID]
|
||||
if !found || app.ApplicantUserID != applicantUserID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
if app.Version != version {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
|
||||
}
|
||||
if app.Status != domain.VerificationStatusDraft {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
||||
}
|
||||
if err := draft.ValidateDraft(); err != nil {
|
||||
return domain.VerificationApplication{}, err
|
||||
}
|
||||
draft = draft.Normalize()
|
||||
app.Category = draft.Category
|
||||
app.Description = draft.Description
|
||||
app.OfficialWebsite = draft.OfficialWebsite
|
||||
app.SocialLinks = draft.SocialLinks
|
||||
app.PressLinks = draft.PressLinks
|
||||
app.AdditionalNote = draft.AdditionalNote
|
||||
app.Version++
|
||||
f.apps[applicationID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Submit(_ context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
|
||||
app, found := f.apps[applicationID]
|
||||
if !found || app.ApplicantUserID != applicantUserID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
if app.Version != version {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
|
||||
}
|
||||
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusSubmitted) {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
||||
}
|
||||
if err := (domain.VerificationDraftInput{
|
||||
Category: app.Category,
|
||||
Description: app.Description,
|
||||
OfficialWebsite: app.OfficialWebsite,
|
||||
SocialLinks: app.SocialLinks,
|
||||
PressLinks: app.PressLinks,
|
||||
AdditionalNote: app.AdditionalNote,
|
||||
}).ValidateForSubmission(); err != nil {
|
||||
return domain.VerificationApplication{}, err
|
||||
}
|
||||
f.submits++
|
||||
app.Status = domain.VerificationStatusSubmitted
|
||||
app.SubmittedAt = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
app.Version++
|
||||
f.apps[applicationID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Cancel(_ context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
|
||||
app, found := f.apps[applicationID]
|
||||
if !found || app.ApplicantUserID != applicantUserID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
if app.Version != version {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
|
||||
}
|
||||
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusCancelled) {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
||||
}
|
||||
app.Status = domain.VerificationStatusCancelled
|
||||
app.DecisionReason = reason
|
||||
app.Version++
|
||||
f.apps[applicationID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Draft(_ context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
|
||||
if app, found := f.draftFor(applicantUserID); found {
|
||||
return app, nil
|
||||
}
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
|
||||
func (f *fakeVerification) ApplicantApplications(_ context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
|
||||
out := make([]domain.VerificationApplication, 0, len(f.apps))
|
||||
for _, app := range f.apps {
|
||||
if app.ApplicantUserID == applicantUserID {
|
||||
out = append(out, app)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if app, found := f.apps[applicationID]; found {
|
||||
return app, nil
|
||||
}
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
|
||||
var _ verificationApplications = (*fakeVerification)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func verifyChannelTarget() domain.VerificationTarget {
|
||||
return domain.VerificationTarget{
|
||||
Type: domain.VerificationTargetChannel, ID: 7001,
|
||||
Title: "Example News", Username: "examplenews", AccessHash: 42, Eligible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func verifyBotTarget() domain.VerificationTarget {
|
||||
return domain.VerificationTarget{
|
||||
Type: domain.VerificationTargetBot, ID: 8002,
|
||||
Title: "Example Bot", Username: "examplebot", Eligible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newVerifyBotTestService(t *testing.T, verification verificationApplications, opts ...Option) (*Service, *memory.UserStore, *memory.MessageStore) {
|
||||
t.Helper()
|
||||
users := memory.NewUserStore()
|
||||
bots := memory.NewBotStore(users)
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
all := append([]Option{WithVerification(verification)}, opts...)
|
||||
return NewService(users, bots, messages, all...), users, messages
|
||||
}
|
||||
|
||||
// verifyBotReplies returns every @verifybot message in the user's box, oldest
|
||||
// first.
|
||||
func verifyBotReplies(t *testing.T, messages *memory.MessageStore, userID int64) []domain.Message {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list @verifybot history: %v", err)
|
||||
}
|
||||
out := make([]domain.Message, 0, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
if msg.From.ID == domain.VerifyBotUserID {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func latestVerifyReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
|
||||
t.Helper()
|
||||
replies := verifyBotReplies(t, messages, userID)
|
||||
if len(replies) == 0 {
|
||||
t.Fatal("no @verifybot reply")
|
||||
}
|
||||
latest := replies[len(replies)-1]
|
||||
// Every keyboard the bot renders must be a valid, persistable markup: the send
|
||||
// path validates before storing, so an invalid one would silently vanish.
|
||||
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
|
||||
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
|
||||
}
|
||||
for _, row := range verifyInlineRows(latest) {
|
||||
for _, button := range row {
|
||||
if len(button.Data) > domain.MaxCallbackDataLen {
|
||||
t.Fatalf("callback data %q is %d bytes, limit is %d", button.Data, len(button.Data), domain.MaxCallbackDataLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func verifyInlineRows(msg domain.Message) [][]domain.MarkupButton {
|
||||
if msg.ReplyMarkup == nil {
|
||||
return nil
|
||||
}
|
||||
return msg.ReplyMarkup.Inline
|
||||
}
|
||||
|
||||
// sendToVerifyBot drives the responder synchronously, bypassing the
|
||||
// OnPrivateMessage goroutine dispatch for determinism (the same shortcut the
|
||||
// BotFather and @Stickers tests take).
|
||||
func sendToVerifyBot(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, text string) domain.Message {
|
||||
t.Helper()
|
||||
svc.respondAsVerify(userID, domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
|
||||
Body: text,
|
||||
})
|
||||
return latestVerifyReply(t, messages, userID)
|
||||
}
|
||||
|
||||
func verifyButtonData(msg domain.Message, label string) ([]byte, bool) {
|
||||
for _, row := range verifyInlineRows(msg) {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
|
||||
return append([]byte(nil), button.Data...), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// pressVerifyCallbackData drives the internal callback path with raw data, the
|
||||
// way rpc.Router does once it has validated the click.
|
||||
func pressVerifyCallbackData(t *testing.T, svc *Service, userID int64, msg domain.Message, data []byte) domain.BotCallbackAnswer {
|
||||
t.Helper()
|
||||
if len(data) > domain.MaxCallbackDataLen {
|
||||
t.Fatalf("callback data too long: %d bytes", len(data))
|
||||
}
|
||||
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
ID: 1,
|
||||
BotUserID: domain.VerifyBotUserID,
|
||||
UserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
MessageID: msg.ID,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("callback query: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("callback query reported unhandled for @verifybot")
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
func pressVerifyButton(t *testing.T, svc *Service, userID int64, msg domain.Message, label string) domain.BotCallbackAnswer {
|
||||
t.Helper()
|
||||
data, found := verifyButtonData(msg, label)
|
||||
if !found {
|
||||
t.Fatalf("button %q is not in the keyboard of message %d: %+v", label, msg.ID, msg.ReplyMarkup)
|
||||
}
|
||||
return pressVerifyCallbackData(t, svc, userID, msg, data)
|
||||
}
|
||||
|
||||
const (
|
||||
verifyTestDescription = "Example News is the daily newsroom of the Example Foundation, publishing since 2015."
|
||||
verifyTestWebsite = "https://news.example.com"
|
||||
verifyTestPressLinks = "https://press.example.org/story-one\nhttps://media.example.net/story-two"
|
||||
)
|
||||
|
||||
// runVerifyApplication walks the whole dialog up to (but not including) Submit and
|
||||
// returns the summary message.
|
||||
func runVerifyApplication(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64) domain.Message {
|
||||
t.Helper()
|
||||
intro := sendToVerifyBot(t, svc, messages, userID, "/start")
|
||||
pressVerifyButton(t, svc, userID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, userID)
|
||||
|
||||
pressVerifyButton(t, svc, userID, picker, "@examplenews")
|
||||
categories := latestVerifyReply(t, messages, userID)
|
||||
|
||||
pressVerifyButton(t, svc, userID, categories, "Media outlet")
|
||||
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "describe the subject") {
|
||||
t.Fatalf("after category, reply = %q", got.Body)
|
||||
}
|
||||
|
||||
sendToVerifyBot(t, svc, messages, userID, verifyTestDescription)
|
||||
social := sendToVerifyBot(t, svc, messages, userID, verifyTestWebsite)
|
||||
if !strings.Contains(social.Body, "social media") {
|
||||
t.Fatalf("after website, reply = %q", social.Body)
|
||||
}
|
||||
|
||||
pressVerifyButton(t, svc, userID, social, verifySkipButtonText)
|
||||
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "press coverage") {
|
||||
t.Fatalf("after skipping social links, reply = %q", got.Body)
|
||||
}
|
||||
|
||||
note := sendToVerifyBot(t, svc, messages, userID, verifyTestPressLinks)
|
||||
pressVerifyButton(t, svc, userID, note, verifySkipButtonText)
|
||||
return latestVerifyReply(t, messages, userID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestVerifyBotStartExplainsAndOffersApplyButton(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7100")
|
||||
|
||||
if !svc.HandlesBot(domain.VerifyBotUserID) {
|
||||
t.Fatal("service should handle @verifybot")
|
||||
}
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
for _, want := range []string{"official", "public @username", "/new", "/help"} {
|
||||
if !strings.Contains(reply.Body, want) {
|
||||
t.Fatalf("/start reply missing %q: %q", want, reply.Body)
|
||||
}
|
||||
}
|
||||
data, found := verifyButtonData(reply, verifyApplyButtonText)
|
||||
if !found {
|
||||
t.Fatalf("/start reply has no apply button: %+v", reply.ReplyMarkup)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), verifyCallbackDataPrefix) {
|
||||
t.Fatalf("callback data %q is not a @verifybot token", data)
|
||||
}
|
||||
if fake.starts != 0 {
|
||||
t.Fatalf("StartDraft called %d times on /start, want 0", fake.starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotFullApplicationFlowFilesExactlyOneApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget(), verifyBotTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7101")
|
||||
|
||||
summary := runVerifyApplication(t, svc, messages, owner.ID)
|
||||
for _, want := range []string{"Example News", "Media outlet", verifyTestWebsite, "press.example.org/story-one", verifySubmitButtonText} {
|
||||
if !strings.Contains(summary.Body, want) {
|
||||
t.Fatalf("summary missing %q: %q", want, summary.Body)
|
||||
}
|
||||
}
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
filed := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(filed.Body, "#101") || !strings.Contains(filed.Body, "/status") {
|
||||
t.Fatalf("submitted reply = %q", filed.Body)
|
||||
}
|
||||
if fake.submits != 1 || len(fake.apps) != 1 {
|
||||
t.Fatalf("submits=%d applications=%d, want exactly one of each", fake.submits, len(fake.apps))
|
||||
}
|
||||
app := fake.apps[101]
|
||||
if app.Status != domain.VerificationStatusSubmitted {
|
||||
t.Fatalf("application status = %q", app.Status)
|
||||
}
|
||||
if app.Category != "media" || app.OfficialWebsite != verifyTestWebsite || len(app.PressLinks) != 2 {
|
||||
t.Fatalf("stored application = %+v", app)
|
||||
}
|
||||
if app.TargetID != 7001 || app.TargetType != domain.VerificationTargetChannel {
|
||||
t.Fatalf("stored target = %s/%d", app.TargetType, app.TargetID)
|
||||
}
|
||||
}
|
||||
|
||||
// The target buttons must not leak the peer they stand for: the whole point of the
|
||||
// token table is that a click cannot name a peer at all.
|
||||
func TestVerifyBotCallbackDataCarriesNoTargetIdentity(t *testing.T) {
|
||||
target := verifyChannelTarget()
|
||||
fake := newFakeVerification(target)
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7102")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
buttons := 0
|
||||
for _, row := range verifyInlineRows(picker) {
|
||||
for _, button := range row {
|
||||
buttons++
|
||||
data := string(button.Data)
|
||||
// Structural assertion rather than a substring hunt: the data is the
|
||||
// prefix plus an opaque hex token and nothing else, so it is incapable of
|
||||
// encoding a peer id, an access hash, a username or a peer type.
|
||||
token, ok := strings.CutPrefix(data, verifyCallbackDataPrefix)
|
||||
if !ok || len(token) != 2*verifyOptionTokenBytes {
|
||||
t.Fatalf("callback data %q is not <prefix><token>", data)
|
||||
}
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune("0123456789abcdef", c) {
|
||||
t.Fatalf("callback data %q carries non-token bytes", data)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{target.Username, string(target.Type)} {
|
||||
if strings.Contains(data, forbidden) {
|
||||
t.Fatalf("callback data %q leaks %q", data, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if buttons == 0 {
|
||||
t.Fatal("target picker has no buttons")
|
||||
}
|
||||
|
||||
// The token is minted per render, so the same target never has a stable,
|
||||
// guessable identifier on the wire.
|
||||
firstData, _ := verifyButtonData(picker, "@"+target.Username)
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
secondData, found := verifyButtonData(latestVerifyReply(t, messages, owner.ID), "@"+target.Username)
|
||||
if !found {
|
||||
t.Fatal("re-rendered picker has no target button")
|
||||
}
|
||||
if string(firstData) == string(secondData) {
|
||||
t.Fatalf("token %q is stable across renders", firstData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotRepeatedButtonPressIsIdempotent(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7103")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
|
||||
first := latestVerifyReply(t, messages, owner.ID)
|
||||
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
|
||||
second := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
if first.Body != second.Body {
|
||||
t.Fatalf("repeat target press changed the answer:\nfirst = %q\nsecond = %q", first.Body, second.Body)
|
||||
}
|
||||
if len(fake.apps) != 1 {
|
||||
t.Fatalf("applications = %d after pressing the same target twice, want 1", len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
// The same must hold for the terminal action: a double-tapped Submit files one
|
||||
// application and repeats the same confirmation.
|
||||
func TestVerifyBotRepeatedSubmitFilesOneApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7121")
|
||||
|
||||
summary := runVerifyApplication(t, svc, messages, owner.ID)
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
firstFiled := latestVerifyReply(t, messages, owner.ID)
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
secondFiled := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
if firstFiled.Body != secondFiled.Body {
|
||||
t.Fatalf("repeat submit changed the answer:\nfirst = %q\nsecond = %q", firstFiled.Body, secondFiled.Body)
|
||||
}
|
||||
if fake.submits != 1 || len(fake.apps) != 1 {
|
||||
t.Fatalf("submits=%d applications=%d after double submit, want 1/1", fake.submits, len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotForgedCallbackTokenIsRefused(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7104")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
before := len(verifyBotReplies(t, messages, owner.ID))
|
||||
|
||||
// A token that was never minted for this user, and a plausible-looking
|
||||
// hand-written one: both resolve only through the user's own state, so both are
|
||||
// refused without any side effect.
|
||||
for _, data := range [][]byte{
|
||||
[]byte(verifyCallbackDataPrefix + "deadbeefcafe"),
|
||||
[]byte("tgt:channel:7001"),
|
||||
[]byte(verifyCallbackDataPrefix),
|
||||
} {
|
||||
answer := pressVerifyCallbackData(t, svc, owner.ID, intro, data)
|
||||
if !answer.Alert || !strings.Contains(answer.Message, "no longer active") {
|
||||
t.Fatalf("forged data %q answered %+v, want an explaining alert", data, answer)
|
||||
}
|
||||
}
|
||||
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
|
||||
t.Fatalf("forged callbacks produced %d new messages", got-before)
|
||||
}
|
||||
if fake.starts != 0 || len(fake.apps) != 0 {
|
||||
t.Fatalf("forged callbacks touched the service: starts=%d apps=%d", fake.starts, len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
// A token minted for one applicant must be meaningless for another: resolution
|
||||
// goes through the clicking user's own chat state only.
|
||||
func TestVerifyBotTokenFromAnotherUserIsRefused(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
victim := newOwner(t, users, "+7105")
|
||||
attacker := newOwner(t, users, "+7106")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, victim.ID, "/start")
|
||||
pressVerifyButton(t, svc, victim.ID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, victim.ID)
|
||||
stolen, found := verifyButtonData(picker, "@examplenews")
|
||||
if !found {
|
||||
t.Fatal("victim picker has no target button")
|
||||
}
|
||||
|
||||
sendToVerifyBot(t, svc, messages, attacker.ID, "/start")
|
||||
attackerIntro := latestVerifyReply(t, messages, attacker.ID)
|
||||
answer := pressVerifyCallbackData(t, svc, attacker.ID, attackerIntro, stolen)
|
||||
if !answer.Alert {
|
||||
t.Fatalf("stolen token answered %+v, want an alert", answer)
|
||||
}
|
||||
for _, app := range fake.apps {
|
||||
if app.ApplicantUserID == attacker.ID {
|
||||
t.Fatalf("stolen token created an application for the attacker: %+v", app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotPressLinkMinimumIsEnforced(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7107")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
social := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
|
||||
pressVerifyButton(t, svc, owner.ID, social, verifySkipButtonText)
|
||||
|
||||
tooFew := sendToVerifyBot(t, svc, messages, owner.ID, "https://press.example.org/story-one")
|
||||
if !strings.Contains(tooFew.Body, strconv.Itoa(domain.MinVerificationPressLinks)) {
|
||||
t.Fatalf("single press link accepted or unexplained: %q", tooFew.Body)
|
||||
}
|
||||
if len(fake.apps[101].PressLinks) != 0 {
|
||||
t.Fatalf("press links stored despite refusal: %+v", fake.apps[101].PressLinks)
|
||||
}
|
||||
|
||||
accepted := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestPressLinks)
|
||||
if !strings.Contains(accepted.Body, "reviewers should know") {
|
||||
t.Fatalf("two press links did not advance the dialog: %q", accepted.Body)
|
||||
}
|
||||
if len(fake.apps[101].PressLinks) != 2 {
|
||||
t.Fatalf("press links = %+v, want two stored", fake.apps[101].PressLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotRejectsInvalidLinksWithAReason(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7108")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
|
||||
// Not a URL, a non-web scheme, and an address the domain refuses as
|
||||
// non-public (which is also what keeps a submitted link from becoming an SSRF
|
||||
// probe).
|
||||
for _, bad := range []string{"my site", "ftp://example.com", "http://127.0.0.1/admin", "https://localhost/x"} {
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, bad)
|
||||
if !strings.Contains(reply.Body, "http:// or https://") {
|
||||
t.Fatalf("website %q answered %q, want the link rules", bad, reply.Body)
|
||||
}
|
||||
if fake.apps[101].OfficialWebsite != "" {
|
||||
t.Fatalf("website %q was stored", bad)
|
||||
}
|
||||
}
|
||||
// A short description is refused with the actual bar, not a generic error.
|
||||
shortDesc := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
|
||||
if !strings.Contains(shortDesc.Body, "social media") {
|
||||
t.Fatalf("valid website not accepted: %q", shortDesc.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotDescriptionMinimumIsExplained(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7109")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, "a newsroom")
|
||||
if !strings.Contains(reply.Body, strconv.Itoa(domain.MinVerificationDescriptionLength)) {
|
||||
t.Fatalf("short description answered %q, want the minimum length", reply.Body)
|
||||
}
|
||||
if fake.apps[101].Description != "" {
|
||||
t.Fatalf("short description was stored: %q", fake.apps[101].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotGlobalCommandsWorkMidStep(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7110")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
|
||||
// /help in the middle of the description step answers help and keeps the step.
|
||||
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
|
||||
if help.Body != verifyBotHelpText {
|
||||
t.Fatalf("/help mid-step = %q", help.Body)
|
||||
}
|
||||
status := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
|
||||
if !strings.Contains(status.Body, "#101") {
|
||||
t.Fatalf("/status mid-step = %q", status.Body)
|
||||
}
|
||||
resumed := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
if !strings.Contains(resumed.Body, "official website") {
|
||||
t.Fatalf("description not accepted after global commands: %q", resumed.Body)
|
||||
}
|
||||
if fake.apps[101].Description != verifyTestDescription {
|
||||
t.Fatalf("description = %q, want the step to have survived", fake.apps[101].Description)
|
||||
}
|
||||
// An unknown command is never swallowed as a field value.
|
||||
unknown := sendToVerifyBot(t, svc, messages, owner.ID, "/nope")
|
||||
if !strings.Contains(unknown.Body, "do not know that command") {
|
||||
t.Fatalf("unknown command = %q", unknown.Body)
|
||||
}
|
||||
if fake.apps[101].OfficialWebsite != "" {
|
||||
t.Fatalf("unknown command stored as a website: %q", fake.apps[101].OfficialWebsite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotStatusListsApplicationsWithoutInternalNotes(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7111")
|
||||
|
||||
if empty := sendToVerifyBot(t, svc, messages, owner.ID, "/status"); empty.Body != verifyNoApplicationsText {
|
||||
t.Fatalf("/status without applications = %q", empty.Body)
|
||||
}
|
||||
|
||||
fake.apps[500] = domain.VerificationApplication{
|
||||
ID: 500, ApplicantUserID: owner.ID,
|
||||
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
|
||||
TargetTitle: "Example News", TargetUsername: "examplenews",
|
||||
Status: domain.VerificationStatusRejected,
|
||||
DecisionReason: "the linked coverage does not mention the channel",
|
||||
InternalNote: "applicant argued with the reviewer",
|
||||
ReviewedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
|
||||
Version: 4,
|
||||
}
|
||||
fake.apps[501] = domain.VerificationApplication{
|
||||
ID: 501, ApplicantUserID: owner.ID,
|
||||
TargetType: domain.VerificationTargetBot, TargetID: 8002, TargetUsername: "examplebot",
|
||||
Status: domain.VerificationStatusSubmitted,
|
||||
SubmittedAt: time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC),
|
||||
Version: 2,
|
||||
}
|
||||
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
|
||||
for _, want := range []string{"#500", "#501", "@examplebot", "does not mention the channel", "2026-07-20"} {
|
||||
if !strings.Contains(reply.Body, want) {
|
||||
t.Fatalf("/status missing %q: %q", want, reply.Body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reply.Body, "argued with the reviewer") {
|
||||
t.Fatalf("/status leaked the internal note: %q", reply.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotCancelWithdrawsTheOpenApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7112")
|
||||
|
||||
if nothing := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel"); nothing.Body != verifyNothingToCancelText {
|
||||
t.Fatalf("/cancel with nothing open = %q", nothing.Body)
|
||||
}
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
|
||||
cancelled := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel")
|
||||
if !strings.Contains(cancelled.Body, "#101") || !strings.Contains(cancelled.Body, "withdrawn") {
|
||||
t.Fatalf("/cancel = %q", cancelled.Body)
|
||||
}
|
||||
if fake.apps[101].Status != domain.VerificationStatusCancelled {
|
||||
t.Fatalf("application status = %q after /cancel", fake.apps[101].Status)
|
||||
}
|
||||
// The dialog is gone with it, so a stale button cannot revive it.
|
||||
idle := sendToVerifyBot(t, svc, messages, owner.ID, "still here?")
|
||||
if idle.Body != verifyBotIdleText {
|
||||
t.Fatalf("after /cancel, plain text = %q", idle.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotCancelButtonWithdrawsFromInsideTheForm(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7113")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
categories := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, categories, verifyCancelButtonText)
|
||||
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "withdrawn") {
|
||||
t.Fatalf("cancel button = %q", reply.Body)
|
||||
}
|
||||
if fake.apps[101].Status != domain.VerificationStatusCancelled {
|
||||
t.Fatalf("application status = %q after the cancel button", fake.apps[101].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotHelpAndIdleText(t *testing.T) {
|
||||
fake := newFakeVerification()
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7114")
|
||||
|
||||
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
|
||||
for _, want := range []string{"/new", "/status", "/cancel", "/help"} {
|
||||
if !strings.Contains(help.Body, want) {
|
||||
t.Fatalf("/help missing %q: %q", want, help.Body)
|
||||
}
|
||||
}
|
||||
assertReplyEntityText(t, help, domain.MessageEntityBotCommand, "/new")
|
||||
|
||||
// Nothing to verify: the requirement is stated instead of an empty picker.
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyNoTargetsText {
|
||||
t.Fatalf("/new with no candidates = %q", reply.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotShowsIneligibleTargetsWithTheirReason(t *testing.T) {
|
||||
verified := verifyChannelTarget()
|
||||
verified.Eligible = false
|
||||
verified.Verified = true
|
||||
verified.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
fake := newFakeVerification(verified)
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7115")
|
||||
|
||||
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
if !strings.Contains(picker.Body, "cannot be filed") && !strings.Contains(picker.Body, verifyNoEligibleText) {
|
||||
t.Fatalf("picker with only ineligible candidates = %q", picker.Body)
|
||||
}
|
||||
answer := pressVerifyButton(t, svc, owner.ID, picker, "unavailable")
|
||||
if !answer.Alert || !strings.Contains(answer.Message, "already verified") {
|
||||
t.Fatalf("ineligible button answered %+v, want the reason", answer)
|
||||
}
|
||||
if fake.starts != 0 || len(fake.apps) != 0 {
|
||||
t.Fatalf("ineligible button reached the service: starts=%d apps=%d", fake.starts, len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotNewResumesTheOpenDraft(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7116")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
|
||||
resumed := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
if !strings.Contains(resumed.Body, "#101") || !strings.Contains(resumed.Body, "official website") {
|
||||
t.Fatalf("/new mid-draft = %q, want a resume at the website step", resumed.Body)
|
||||
}
|
||||
if len(fake.apps) != 1 {
|
||||
t.Fatalf("applications = %d after /new mid-draft, want 1", len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotWithoutServiceReportsUnavailable(t *testing.T) {
|
||||
svc, users, messages := newVerifyBotTestService(t, nil)
|
||||
owner := newOwner(t, users, "+7117")
|
||||
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
|
||||
t.Fatalf("/new without a verification service = %q", reply.Body)
|
||||
}
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText {
|
||||
t.Fatalf("/help without a verification service = %q", reply.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, _, _ := newVerifyBotTestService(t, fake)
|
||||
|
||||
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
BotUserID: 555111, UserID: 900, Data: []byte("vb:whatever"),
|
||||
}); handled || err != nil {
|
||||
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
|
||||
}
|
||||
// A built-in bot with no keyboards is claimed but answered empty, so the click
|
||||
// cannot hang for the whole callback timeout.
|
||||
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
|
||||
})
|
||||
if !handled || err != nil || answer.Message != "" {
|
||||
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotSendVerificationNoticeNeverLeaksInternalNote(t *testing.T) {
|
||||
fake := newFakeVerification()
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7118")
|
||||
ctx := context.Background()
|
||||
|
||||
app := domain.VerificationApplication{
|
||||
ID: 4242, ApplicantUserID: owner.ID,
|
||||
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
|
||||
TargetTitle: "Example News", TargetUsername: "examplenews",
|
||||
DecisionReason: "the coverage you linked does not mention the channel",
|
||||
InternalNote: "reviewer note: applicant is a repeat filer, escalate next time",
|
||||
}
|
||||
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindApproved); err != nil {
|
||||
t.Fatalf("approved notice: %v", err)
|
||||
}
|
||||
approved := latestVerifyReply(t, messages, owner.ID)
|
||||
for _, want := range []string{"#4242", "Example News", "@examplenews", "approved"} {
|
||||
if !strings.Contains(approved.Body, want) {
|
||||
t.Fatalf("approved notice missing %q: %q", want, approved.Body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(approved.Body, "repeat filer") {
|
||||
t.Fatalf("approved notice leaked the internal note: %q", approved.Body)
|
||||
}
|
||||
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRejected); err != nil {
|
||||
t.Fatalf("rejected notice: %v", err)
|
||||
}
|
||||
rejected := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(rejected.Body, "#4242") || !strings.Contains(rejected.Body, "does not mention the channel") {
|
||||
t.Fatalf("rejected notice = %q", rejected.Body)
|
||||
}
|
||||
if strings.Contains(rejected.Body, "repeat filer") || strings.Contains(rejected.Body, "escalate") {
|
||||
t.Fatalf("rejected notice leaked the internal note: %q", rejected.Body)
|
||||
}
|
||||
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRevoked); err != nil {
|
||||
t.Fatalf("revoked notice: %v", err)
|
||||
}
|
||||
revoked := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(revoked.Body, "revoked") || strings.Contains(revoked.Body, "repeat filer") {
|
||||
t.Fatalf("revoked notice = %q", revoked.Body)
|
||||
}
|
||||
|
||||
// An unknown kind is reported rather than delivered as an empty message: the
|
||||
// outbox row must stay pending instead of being marked delivered.
|
||||
before := len(verifyBotReplies(t, messages, owner.ID))
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, "teleported"); err == nil {
|
||||
t.Fatal("unknown notice kind reported success")
|
||||
}
|
||||
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
|
||||
t.Fatalf("unknown notice kind sent %d messages", got-before)
|
||||
}
|
||||
if err := svc.SendVerificationNotice(ctx, 0, app, verificationapp.NoticeKindApproved); err == nil {
|
||||
t.Fatal("empty recipient reported success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotSubmitBouncesAnIncompleteApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7119")
|
||||
|
||||
summary := runVerifyApplication(t, svc, messages, owner.ID)
|
||||
// Simulate a payload that lost a required field between rendering the summary
|
||||
// and the press: Submit must send the applicant back, not file a broken record.
|
||||
app := fake.apps[101]
|
||||
app.PressLinks = nil
|
||||
app.Version++
|
||||
fake.apps[101] = app
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
bounced := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(bounced.Body, "press coverage") {
|
||||
t.Fatalf("incomplete submit = %q, want the press step", bounced.Body)
|
||||
}
|
||||
if fake.submits != 0 {
|
||||
t.Fatalf("submits = %d for an incomplete application", fake.submits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotPolicyRefusalsAreExplained(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
fake.startErr = domain.ErrVerificationRateLimited
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7120")
|
||||
|
||||
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
|
||||
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "limit on open applications") {
|
||||
t.Fatalf("rate-limited StartDraft = %q", reply.Body)
|
||||
}
|
||||
|
||||
fake.targetsErr = verificationapp.ErrDisabled
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
|
||||
t.Fatalf("disabled verification = %q", reply.Body)
|
||||
}
|
||||
if !errors.Is(fake.targetsErr, verificationapp.ErrDisabled) {
|
||||
t.Fatal("test setup lost the sentinel")
|
||||
}
|
||||
}
|
||||
1527
internal/app/botverification/service.go
Normal file
1527
internal/app/botverification/service.go
Normal file
File diff suppressed because it is too large
Load diff
1539
internal/app/botverification/service_test.go
Normal file
1539
internal/app/botverification/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -216,6 +216,21 @@ func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []in
|
|||
return s.channels.GetChannels(ctx, userID, ids)
|
||||
}
|
||||
|
||||
// GetChannelsAuthoritative bypasses the app-level versioned read model for a
|
||||
// durable channel_state refresh. PostgreSQL GetChannels is a bounded direct
|
||||
// projection query, so the returned flag snapshot cannot be the pre-commit
|
||||
// value that the event is intended to invalidate.
|
||||
func (s *Service) GetChannelsAuthoritative(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
ids := uniqueNonZero(channelIDs)
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.channels.GetChannels(ctx, userID, ids)
|
||||
}
|
||||
|
||||
// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
|
||||
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
|
|
@ -582,7 +597,10 @@ func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, usern
|
|||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
username = normalizeChannelUsername(username)
|
||||
if !validChannelUsername(username) {
|
||||
// Public resolution also covers Fragment-style collectible usernames,
|
||||
// whose protocol minimum is four characters. Channel username mutation
|
||||
// remains on the ordinary 5..32 validation path above.
|
||||
if !domain.ValidCollectibleUsername(username) {
|
||||
return domain.Channel{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
return s.channels.ResolvePublicChannelUsername(ctx, userID, username)
|
||||
|
|
@ -1031,6 +1049,29 @@ func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req do
|
|||
return s.channels.ListChannelMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
type messageReactionLookupStore interface {
|
||||
FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
|
||||
}
|
||||
|
||||
func (s *Service) FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 ||
|
||||
req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID ||
|
||||
req.ReactorUserID == 0 {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.ViewerUserID == 0 {
|
||||
req.ViewerUserID = userID
|
||||
}
|
||||
if req.ViewerUserID != userID {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
lookup, ok := s.channels.(messageReactionLookupStore)
|
||||
if !ok {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
return lookup.FindChannelMessageReaction(ctx, req)
|
||||
}
|
||||
|
||||
type messageReactionUsageStore interface {
|
||||
RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error
|
||||
}
|
||||
|
|
@ -1127,34 +1168,6 @@ func (s *Service) ClearRecentReactions(ctx context.Context, userID int64) error
|
|||
return s.channels.ClearRecentMessageReactions(ctx, userID)
|
||||
}
|
||||
|
||||
// SavedReactionTags returns account-level saved-message reaction tag titles.
|
||||
func (s *Service) SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 {
|
||||
return []domain.SavedReactionTag{}, nil
|
||||
}
|
||||
if limit > domain.MaxSavedReactionTags {
|
||||
limit = domain.MaxSavedReactionTags
|
||||
}
|
||||
return s.channels.ListSavedReactionTags(ctx, userID, limit)
|
||||
}
|
||||
|
||||
// UpdateSavedReactionTag stores the account-level custom title for one saved-message reaction tag.
|
||||
func (s *Service) UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if tag.UserID == 0 {
|
||||
tag.UserID = userID
|
||||
}
|
||||
if tag.UserID != userID || tag.Reaction.Type != domain.MessageReactionEmoji || tag.Reaction.Emoticon == "" {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.UpsertSavedReactionTag(ctx, tag)
|
||||
}
|
||||
|
||||
// ReadMessageContents returns visible channel messages whose content-read state can be synced.
|
||||
func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -1445,6 +1458,30 @@ func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.D
|
|||
return s.channels.DeleteChannelMessages(ctx, req)
|
||||
}
|
||||
|
||||
type moderationChannelMessageStore interface {
|
||||
ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
|
||||
}
|
||||
|
||||
// ModerationDeleteMessages is the explicit server-authority deletion path used
|
||||
// only by the durable moderation action worker. It never accepts a client
|
||||
// identity and therefore cannot be reached by ordinary RPC permission checks.
|
||||
func (s *Service) ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
|
||||
if s == nil || s.channels == nil || channelID <= 0 ||
|
||||
len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
}
|
||||
store, ok := s.channels.(moderationChannelMessageStore)
|
||||
if !ok {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return store.ModerationDeleteChannelMessages(ctx, channelID, append([]int(nil), ids...), date)
|
||||
}
|
||||
|
||||
// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone.
|
||||
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -2233,6 +2270,21 @@ func (s *Service) FilterActiveMemberIDs(ctx context.Context, channelID int64, us
|
|||
return s.channels.FilterActiveChannelMemberIDs(ctx, channelID, candidates)
|
||||
}
|
||||
|
||||
// FilterMessageAudienceIDs keeps active members and currently authorized
|
||||
// public-preview viewers from a bounded online candidate set. The store performs
|
||||
// one batched authoritative check per bounded chunk so runtime session indexes
|
||||
// never become an access-control source of truth.
|
||||
func (s *Service) FilterMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
candidates := uniqueNonZero(userIDs)
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.channels.FilterChannelMessageAudienceIDs(ctx, channelID, candidates)
|
||||
}
|
||||
|
||||
// GetDifference returns channel-scoped pts difference.
|
||||
func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 {
|
||||
|
|
@ -2249,7 +2301,8 @@ func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.Ch
|
|||
if err != nil {
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
return s.filterBotChannelDifference(ctx, userID, diff), nil
|
||||
diff = s.filterBotChannelDifference(ctx, userID, diff)
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// ClearDanglingPinnedMessage 清除指向已删除消息的悬挂置顶值(unpinAll 自愈)。
|
||||
|
|
|
|||
|
|
@ -2904,7 +2904,10 @@ func TestListSendAsChannelsFiltersPostMessageRights(t *testing.T) {
|
|||
|
||||
func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
channelStore := memory.NewChannelStore()
|
||||
registry := memory.NewCollectibleUsernameStore()
|
||||
channelStore.AttachUsernameRegistry(registry)
|
||||
service := NewService(channelStore)
|
||||
created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "CU Public Lab",
|
||||
MemberUserIDs: []int64{1002},
|
||||
|
|
@ -2945,6 +2948,53 @@ func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
|
|||
if err != nil || !found || resolved.ID != public.ID {
|
||||
t.Fatalf("ResolvePublicUsername = %+v found %v err %v, want public channel", resolved, found, err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: public.ID}
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "nfc4",
|
||||
Owner: peer,
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 1,
|
||||
Actor: "test",
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("mint channel collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "@NFC4")
|
||||
if err != nil || !found || resolved.ID != public.ID {
|
||||
t.Fatalf("ResolvePublicUsername collectible = %+v found %v err %v, want public channel", resolved, found, err)
|
||||
}
|
||||
collectibleSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc", 10)
|
||||
if err != nil || len(collectibleSearch.Results) != 1 || collectibleSearch.Results[0].ID != public.ID {
|
||||
t.Fatalf("collectible channel search = %+v err=%v, want public channel", collectibleSearch, err)
|
||||
}
|
||||
if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: public.ID,
|
||||
Username: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("clear editable username: %v", err)
|
||||
}
|
||||
resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "nfc4")
|
||||
if err != nil || !found || resolved.ID != public.ID {
|
||||
t.Fatalf("NFT-only ResolvePublicUsername = %+v found=%v err=%v", resolved, found, err)
|
||||
}
|
||||
if view, err := service.GetChannel(ctx, 1003, public.ID); err != nil || view.Channel.ID != public.ID {
|
||||
t.Fatalf("NFT-only public preview = %+v err=%v", view, err)
|
||||
}
|
||||
if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: public.ID,
|
||||
Username: "cu_public_lab",
|
||||
}); err != nil {
|
||||
t.Fatalf("restore editable username: %v", err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, peer, "nfc4", false); err != nil || !changed {
|
||||
t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if _, found, err := service.ResolvePublicUsername(ctx, 1003, "nfc4"); err != nil || found {
|
||||
t.Fatalf("inactive collectible resolve found=%v err=%v, want hidden", found, err)
|
||||
}
|
||||
hiddenSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc4", 10)
|
||||
if err != nil || len(hiddenSearch.Results) != 0 {
|
||||
t.Fatalf("inactive collectible search = %+v err=%v, want empty", hiddenSearch, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) {
|
||||
|
|
@ -3014,12 +3064,37 @@ func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("non-member GetDifference public preview: %v", err)
|
||||
}
|
||||
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 0 || len(diff.NewMessages) != 0 || len(diff.OtherUpdates) != 0 {
|
||||
t.Fatalf("preview diff = %+v, want empty public preview difference at current pts", diff)
|
||||
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 1 || len(diff.NewMessages) != 1 || len(diff.OtherUpdates) != 0 {
|
||||
t.Fatalf("preview diff = %+v, want one public preview message at current pts", diff)
|
||||
}
|
||||
if diff.NewMessages[0].ID != sent.Message.ID || diff.NewMessages[0].Body != sent.Message.Body {
|
||||
t.Fatalf("preview diff message = %+v, want sent public post %+v", diff.NewMessages[0], sent.Message)
|
||||
}
|
||||
if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID {
|
||||
t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog)
|
||||
}
|
||||
audience, err := service.FilterMessageAudienceIDs(ctx, public.ID, []int64{viewerID, ownerID, viewerID})
|
||||
if err != nil || len(audience) != 2 {
|
||||
t.Fatalf("public message audience = %v err %v, want owner and preview viewer", audience, err)
|
||||
}
|
||||
if _, err := service.JoinChannel(ctx, viewerID, public.ID, 21); err != nil {
|
||||
t.Fatalf("JoinChannel public preview viewer: %v", err)
|
||||
}
|
||||
if _, err := service.LeaveChannel(ctx, viewerID, public.ID, 22); err != nil {
|
||||
t.Fatalf("LeaveChannel public preview viewer: %v", err)
|
||||
}
|
||||
filtered, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{
|
||||
ChannelID: public.ID,
|
||||
Pts: sent.Event.Pts,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("preview difference across participant events: %v", err)
|
||||
}
|
||||
if !filtered.Final || filtered.Pts != sent.Event.Pts || len(filtered.Events) != 0 ||
|
||||
len(filtered.NewMessages) != 0 || len(filtered.OtherUpdates) != 0 {
|
||||
t.Fatalf("difference after transient participant changes = %+v, want unchanged PTS", filtered)
|
||||
}
|
||||
|
||||
private, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Private Preview",
|
||||
|
|
@ -3047,6 +3122,9 @@ func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) {
|
|||
if _, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{ChannelID: public.ID, Pts: created.Event.Pts, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) {
|
||||
t.Fatalf("banned public preview GetDifference err = %v, want ErrChannelUserBanned", err)
|
||||
}
|
||||
if audience, err := service.FilterMessageAudienceIDs(ctx, public.ID, []int64{viewerID}); err != nil || len(audience) != 0 {
|
||||
t.Fatalf("banned public message audience = %v err %v, want empty", audience, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDifferenceStartsAtMemberAvailableMinPts(t *testing.T) {
|
||||
|
|
|
|||
31
internal/app/clienttelemetry/service.go
Normal file
31
internal/app/clienttelemetry/service.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package clienttelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store store.ClientTelemetryStore
|
||||
}
|
||||
|
||||
func NewService(telemetryStore store.ClientTelemetryStore) *Service {
|
||||
return &Service{store: telemetryStore}
|
||||
}
|
||||
|
||||
func (s *Service) Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
|
||||
}
|
||||
event, err := domain.NewClientTelemetryEvent(
|
||||
userID, kind, peer, subjectIDs, payload, createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ClientTelemetryEvent{}, false, err
|
||||
}
|
||||
return s.store.CreateClientTelemetry(ctx, event)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ const maxCloseFriendsCount = 5000
|
|||
|
||||
type phonePrivacyService interface {
|
||||
userprojection.PrivacyEvaluator
|
||||
userprojection.BatchPrivacyEvaluator
|
||||
AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error)
|
||||
}
|
||||
|
||||
|
|
@ -123,19 +124,17 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
|
|||
return domain.Contact{}, ErrContactNameEmpty
|
||||
}
|
||||
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空),
|
||||
// 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。
|
||||
// 归一成纯数字。空串表示客户端只按 user id 添加联系人,必须原样保留;
|
||||
// TL 明确允许省略号码,服务端不得从 target 全局资料反向补出隐私号码。
|
||||
input.Phone = digitsOnly(input.Phone)
|
||||
if s.users != nil {
|
||||
target, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
_, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.Phone == "" {
|
||||
input.Phone = target.Phone
|
||||
}
|
||||
}
|
||||
contact, err := s.contacts.Upsert(ctx, userID, input)
|
||||
if err != nil {
|
||||
|
|
@ -150,7 +149,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
|
|||
return s.projectContact(ctx, userID, contact)
|
||||
}
|
||||
|
||||
// AcceptContact shares the current user's phone/profile with an existing one-way contact.
|
||||
// AcceptContact creates the reciprocal contact for an existing one-way contact.
|
||||
// Phone visibility remains governed exclusively by account privacy rules; this
|
||||
// RPC has no protocol flag authorizing a hidden phone-number exception.
|
||||
func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
|
|
@ -189,11 +190,6 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
|
|||
return domain.Contact{}, err
|
||||
}
|
||||
s.InvalidateViewers(userID, contactUserID)
|
||||
if s.privacy != nil {
|
||||
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
}
|
||||
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
|
|
@ -235,6 +231,30 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
|
|||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
if s.privacy != nil && len(targets) > 0 {
|
||||
targetIDs := make([]int64, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.ID != 0 && target.ID != userID {
|
||||
targetIDs = append(targetIDs, target.ID)
|
||||
}
|
||||
}
|
||||
visibility, err := s.privacy.CanSeeBatch(
|
||||
ctx,
|
||||
targetIDs,
|
||||
userID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
allowed := targets[:0]
|
||||
for _, target := range targets {
|
||||
if visibility[target.ID][domain.PrivacyKeyAddedByPhone] {
|
||||
allowed = append(allowed, target)
|
||||
}
|
||||
}
|
||||
targets = allowed
|
||||
}
|
||||
byPhone := make(map[string]domain.User, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.Phone != "" {
|
||||
|
|
@ -310,10 +330,52 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit
|
|||
if limit <= 0 || limit > maxSearchLimit {
|
||||
limit = maxSearchLimit
|
||||
}
|
||||
res, err := s.users.Search(ctx, userID, query, normalizePhone(query), limit)
|
||||
phoneQuery := ""
|
||||
if isPhoneSearchQuery(query) {
|
||||
phoneQuery = normalizePhone(query)
|
||||
}
|
||||
res, err := s.users.Search(ctx, userID, query, phoneQuery, limit)
|
||||
if err != nil {
|
||||
return domain.UserSearchResult{}, err
|
||||
}
|
||||
if s.privacy != nil && phoneQuery != "" && len(res.MyResults)+len(res.Results) > 0 {
|
||||
targetIDs := make([]int64, 0, len(res.MyResults)+len(res.Results))
|
||||
for _, target := range res.MyResults {
|
||||
if target.ID != 0 && target.ID != userID {
|
||||
targetIDs = append(targetIDs, target.ID)
|
||||
}
|
||||
}
|
||||
for _, target := range res.Results {
|
||||
if target.ID != 0 && target.ID != userID {
|
||||
targetIDs = append(targetIDs, target.ID)
|
||||
}
|
||||
}
|
||||
visibility, err := s.privacy.CanSeeBatch(
|
||||
ctx,
|
||||
targetIDs,
|
||||
userID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
|
||||
)
|
||||
if err != nil {
|
||||
return domain.UserSearchResult{}, err
|
||||
}
|
||||
knownContacts := map[int64]domain.Contact{}
|
||||
if s.contacts != nil && len(targetIDs) > 0 {
|
||||
knownContacts, err = s.contacts.GetMany(ctx, userID, targetIDs)
|
||||
if err != nil {
|
||||
return domain.UserSearchResult{}, err
|
||||
}
|
||||
}
|
||||
allowed := func(target domain.User) bool {
|
||||
if visibility[target.ID][domain.PrivacyKeyAddedByPhone] {
|
||||
return true
|
||||
}
|
||||
contact, found := knownContacts[target.ID]
|
||||
return found && contact.Phone != "" && strings.HasPrefix(contact.Phone, phoneQuery)
|
||||
}
|
||||
res.MyResults = filterSearchUsers(res.MyResults, allowed)
|
||||
res.Results = filterSearchUsers(res.Results, allowed)
|
||||
}
|
||||
return s.projectSearchResult(ctx, userID, res)
|
||||
}
|
||||
|
||||
|
|
@ -452,11 +514,14 @@ func (s *Service) peerCanSeeCurrentUserPhone(ctx context.Context, ownerUserID, v
|
|||
if s.contacts == nil {
|
||||
return false, nil
|
||||
}
|
||||
_, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
|
||||
contact, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found, nil
|
||||
// Merely adding the owner by user id does not mean the viewer knows the
|
||||
// owner's phone. Only a non-empty owner-scoped contact phone can suppress the
|
||||
// "share my phone" prompt when PhoneNumber privacy itself denies visibility.
|
||||
return found && contact.Phone != "", nil
|
||||
}
|
||||
|
||||
// BlockContact adds peer to the current user's blocklist.
|
||||
|
|
@ -506,7 +571,22 @@ func (s *Service) GetBlocked(ctx context.Context, userID int64, offset, limit in
|
|||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
return s.contacts.ListBlocked(ctx, userID, offset, limit)
|
||||
list, err := s.contacts.ListBlocked(ctx, userID, offset, limit)
|
||||
if err != nil || len(list.Blocked) == 0 || s.projector == nil {
|
||||
return list, err
|
||||
}
|
||||
users := make([]domain.User, len(list.Blocked))
|
||||
for i := range list.Blocked {
|
||||
users[i] = list.Blocked[i].User
|
||||
}
|
||||
projected, err := s.projector.ForViewer(ctx, userID, users)
|
||||
if err != nil {
|
||||
return domain.BlockedContactList{}, err
|
||||
}
|
||||
for i := range list.Blocked {
|
||||
list.Blocked[i].User = projected[i]
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
|
||||
|
|
@ -590,6 +670,34 @@ func normalizePhone(phone string) string {
|
|||
return phone
|
||||
}
|
||||
|
||||
func isPhoneSearchQuery(query string) bool {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return false
|
||||
}
|
||||
hasDigit := false
|
||||
for _, r := range query {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '+', r == ' ', r == '-', r == '(', r == ')':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit
|
||||
}
|
||||
|
||||
func filterSearchUsers(users []domain.User, keep func(domain.User) bool) []domain.User {
|
||||
out := users[:0]
|
||||
for _, user := range users {
|
||||
if keep(user) {
|
||||
out = append(out, user)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
|
|
|
|||
|
|
@ -168,6 +168,151 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAddContactWithoutPhoneDoesNotBackfillTargetPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
|
||||
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
|
||||
|
||||
contact, err := svc.AddContact(ctx, owner.ID, domain.ContactInput{
|
||||
ContactUserID: target.ID,
|
||||
FirstName: "Saved",
|
||||
Phone: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddContact: %v", err)
|
||||
}
|
||||
if contact.Phone != "" || contact.User.Phone != "" {
|
||||
t.Fatalf("projected contact phone = local %q user %q, want both empty", contact.Phone, contact.User.Phone)
|
||||
}
|
||||
stored, found, err := contactsStore.Get(ctx, owner.ID, target.ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("stored contact found=%v err=%v", found, err)
|
||||
}
|
||||
if stored.Phone != "" || stored.User.Phone != "" {
|
||||
t.Fatalf("stored contact phone = local %q user %q, want both empty", stored.Phone, stored.User.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportContactsHonorsAddedByPhoneInOneBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
|
||||
if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set AddedByPhone: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
|
||||
input := []domain.ContactInput{{ClientID: 1, Phone: target.Phone, FirstName: "Saved"}}
|
||||
|
||||
hidden, err := svc.ImportContacts(ctx, owner.ID, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts hidden: %v", err)
|
||||
}
|
||||
if len(hidden.Imported) != 0 || len(hidden.Contacts) != 0 {
|
||||
t.Fatalf("hidden import = %+v, want no resolved target", hidden)
|
||||
}
|
||||
|
||||
if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{
|
||||
ContactUserID: owner.ID,
|
||||
FirstName: owner.FirstName,
|
||||
}); err != nil {
|
||||
t.Fatalf("target add owner: %v", err)
|
||||
}
|
||||
visible, err := svc.ImportContacts(ctx, owner.ID, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts visible: %v", err)
|
||||
}
|
||||
if len(visible.Imported) != 1 || visible.Imported[0].UserID != target.ID || len(visible.Contacts) != 1 {
|
||||
t.Fatalf("visible import = %+v, want target %d", visible, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneSearchHonorsAddedByPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
|
||||
if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set AddedByPhone: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
|
||||
|
||||
hidden, err := svc.Search(ctx, owner.ID, "+1 (555) 123-4567", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search hidden: %v", err)
|
||||
}
|
||||
if len(hidden.Results) != 0 {
|
||||
t.Fatalf("hidden phone search results = %+v, want empty", hidden.Results)
|
||||
}
|
||||
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{
|
||||
ContactUserID: target.ID,
|
||||
FirstName: target.FirstName,
|
||||
Phone: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("owner add target without phone: %v", err)
|
||||
}
|
||||
stillHidden, err := svc.Search(ctx, owner.ID, target.Phone, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search owner-only contact: %v", err)
|
||||
}
|
||||
if len(stillHidden.MyResults)+len(stillHidden.Results) != 0 {
|
||||
t.Fatalf("owner-only empty-phone contact search = %+v, want hidden", stillHidden)
|
||||
}
|
||||
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{
|
||||
ContactUserID: target.ID,
|
||||
FirstName: target.FirstName,
|
||||
Phone: target.Phone,
|
||||
}); err != nil {
|
||||
t.Fatalf("owner save target phone: %v", err)
|
||||
}
|
||||
knownLocally, err := svc.Search(ctx, owner.ID, target.Phone, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search locally known phone: %v", err)
|
||||
}
|
||||
if len(knownLocally.MyResults)+len(knownLocally.Results) != 1 {
|
||||
t.Fatalf("locally known phone search = %+v, want one local contact", knownLocally)
|
||||
}
|
||||
if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{
|
||||
ContactUserID: owner.ID,
|
||||
FirstName: owner.FirstName,
|
||||
}); err != nil {
|
||||
t.Fatalf("target add owner: %v", err)
|
||||
}
|
||||
visible, err := svc.Search(ctx, owner.ID, target.Phone, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search visible: %v", err)
|
||||
}
|
||||
if len(visible.Results) != 1 || visible.Results[0].ID != target.ID {
|
||||
t.Fatalf("visible phone search = %+v, want target %d", visible.Results, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -387,8 +532,8 @@ func TestAddContactNormalizesPhoneToDigits(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("AddContact digitless phone: %v", err)
|
||||
}
|
||||
if emptied.Phone != bob.Phone {
|
||||
t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone)
|
||||
if emptied.Phone != "" || emptied.User.Phone != "" {
|
||||
t.Fatalf("digitless phone contact = local %q user %q, want empty without account-phone fallback", emptied.Phone, emptied.User.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -499,6 +644,46 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSearchFindsOnlyActiveCollectibleUsernames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
registry := memory.NewCollectibleUsernameStore()
|
||||
users.AttachUsernameRegistry(registry)
|
||||
viewer, err := users.Create(ctx, domain.User{Phone: "15550000101", FirstName: "Viewer"})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15550000102", FirstName: "Unrelated", Username: "target_slot"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}
|
||||
if _, err := registry.SetEditableUsername(ctx, peer, target.Username); err != nil {
|
||||
t.Fatalf("seed editable username: %v", err)
|
||||
}
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "nft4",
|
||||
Owner: peer,
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 1,
|
||||
Actor: "test",
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("mint collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
svc := NewService(memory.NewContactStore(), users)
|
||||
found, err := svc.Search(ctx, viewer.ID, "@NFT4", 10)
|
||||
if err != nil || len(found.Results) != 1 || found.Results[0].ID != target.ID {
|
||||
t.Fatalf("search active collectible = %+v err=%v, want target", found, err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed {
|
||||
t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
hidden, err := svc.Search(ctx, viewer.ID, "nft4", 10)
|
||||
if err != nil || len(hidden.Results) != 0 || len(hidden.MyResults) != 0 {
|
||||
t.Fatalf("search inactive collectible = %+v err=%v, want empty", hidden, err)
|
||||
}
|
||||
}
|
||||
|
||||
func contactByID(t *testing.T, list domain.ContactList, id int64) domain.Contact {
|
||||
t.Helper()
|
||||
for _, contact := range list.Contacts {
|
||||
|
|
|
|||
|
|
@ -342,11 +342,24 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
|
|||
if !ok || view.Forbidden {
|
||||
continue
|
||||
}
|
||||
if view.Self.Status == domain.ChannelMemberLeft && !view.Self.Guest {
|
||||
// A visible public preview still needs one transient dialog so a
|
||||
// client can finish bootstrapping the requested peer. Keep the top
|
||||
// message and read state at zero: the response is an admission
|
||||
// token, not a persisted/chat-list dialog snapshot. In particular,
|
||||
// clients that persist non-zero top dialogs will instead continue
|
||||
// with messages.getHistory, which is the authoritative preview
|
||||
// history path.
|
||||
out.Dialogs = append(out.Dialogs, publicChannelPreviewBootstrapDialog(view))
|
||||
out.Channels = append(out.Channels, view.Channel)
|
||||
out.Count++
|
||||
present[channelID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
// Linked discussion guests need a transient peer-dialog snapshot so
|
||||
// TDesktop can finish materializing the comments History after
|
||||
// requestSelf. ChannelLeft keeps the snapshot out of the main chat list,
|
||||
// and Guest guarantees this path never turns an ordinary public preview
|
||||
// into a dialog.
|
||||
// while Guest authorizes the target's real top-message snapshot.
|
||||
if view.Self.Status != domain.ChannelMemberActive && !view.Self.Guest {
|
||||
continue
|
||||
}
|
||||
|
|
@ -378,6 +391,14 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func publicChannelPreviewBootstrapDialog(view domain.ChannelView) domain.Dialog {
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID},
|
||||
ChannelLeft: true,
|
||||
Pts: view.Channel.Pts,
|
||||
}
|
||||
}
|
||||
|
||||
func isChannelPreviewAccessError(err error) bool {
|
||||
return errors.Is(err, domain.ErrChannelPrivate) ||
|
||||
errors.Is(err, domain.ErrChannelUserBanned) ||
|
||||
|
|
@ -387,22 +408,24 @@ func isChannelPreviewAccessError(err error) bool {
|
|||
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
|
||||
dialog := view.Dialog
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
UnreadReactions: dialog.UnreadReactions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
Pts: view.Channel.Pts,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
|
||||
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
UnreadReactions: dialog.UnreadReactions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
Pts: view.Channel.Pts,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ type countingDialogChannelStore struct {
|
|||
getChannelCalls int
|
||||
getChannelsCalls int
|
||||
getChannelDialogsCalls int
|
||||
listHistoryCalls int
|
||||
}
|
||||
|
||||
func (s *countingDialogChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
|
||||
|
|
@ -77,6 +78,11 @@ func (s *countingDialogChannelStore) GetChannelDialogs(ctx context.Context, view
|
|||
return s.ChannelStore.GetChannelDialogs(ctx, viewerUserID, channelIDs)
|
||||
}
|
||||
|
||||
func (s *countingDialogChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) {
|
||||
s.listHistoryCalls++
|
||||
return s.ChannelStore.ListChannelHistory(ctx, viewerUserID, filter)
|
||||
}
|
||||
|
||||
func TestGetDialogsHashUsesWarmStableHashCacheAndInvalidatesOnWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
|
|
@ -695,7 +701,7 @@ func TestGetPeerDialogsRejectsHugeVector(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsSkipsPublicChannelPreviewForNonMember(t *testing.T) {
|
||||
func TestGetPeerDialogsReturnsZeroTopBootstrapForPublicChannelPreview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
|
|
@ -716,12 +722,13 @@ func TestGetPeerDialogsSkipsPublicChannelPreviewForNonMember(t *testing.T) {
|
|||
}); err != nil {
|
||||
t.Fatalf("UpdateUsername public: %v", err)
|
||||
}
|
||||
if _, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: public.Channel.ID,
|
||||
RandomID: 99,
|
||||
Message: "public peer dialog top",
|
||||
Date: 1700002010,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage public: %v", err)
|
||||
}
|
||||
private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
|
|
@ -740,8 +747,19 @@ func TestGetPeerDialogsSkipsPublicChannelPreviewForNonMember(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs public preview: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 0 || len(list.ChannelMessages) != 0 || len(list.Channels) != 0 || list.Count != 0 {
|
||||
t.Fatalf("peer dialogs = %+v, want no materialized public preview dialog", list)
|
||||
if len(list.Dialogs) != 1 || len(list.ChannelMessages) != 0 || len(list.Channels) != 1 || list.Count != 1 {
|
||||
t.Fatalf("peer dialogs = %+v, want one zero-top public preview bootstrap", list)
|
||||
}
|
||||
dialog := list.Dialogs[0]
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID != public.Channel.ID ||
|
||||
!dialog.ChannelLeft || dialog.TopMessage != 0 || dialog.TopMessageDate != 0 ||
|
||||
dialog.ReadInboxMaxID != 0 || dialog.ReadOutboxMaxID != 0 ||
|
||||
dialog.UnreadCount != 0 || dialog.UnreadMentions != 0 ||
|
||||
dialog.UnreadReactions != 0 || dialog.Pts != sent.Event.Pts {
|
||||
t.Fatalf("public preview bootstrap dialog = %+v", dialog)
|
||||
}
|
||||
if list.Channels[0].ID != public.Channel.ID {
|
||||
t.Fatalf("public preview channels = %+v, want channel %d", list.Channels, public.Channel.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -810,6 +828,7 @@ func TestGetPeerDialogsBatchesMissingChannelVisibilityChecks(t *testing.T) {
|
|||
|
||||
channelStore.getChannelCalls = 0
|
||||
channelStore.getChannelsCalls = 0
|
||||
channelStore.listHistoryCalls = 0
|
||||
list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: first.Channel.ID},
|
||||
{Type: domain.PeerTypeChannel, ID: private.Channel.ID},
|
||||
|
|
@ -822,8 +841,16 @@ func TestGetPeerDialogsBatchesMissingChannelVisibilityChecks(t *testing.T) {
|
|||
if channelStore.getChannelsCalls != 1 || channelStore.getChannelCalls != 0 {
|
||||
t.Fatalf("visibility channel calls: GetChannels=%d GetChannel=%d, want one batch call only", channelStore.getChannelsCalls, channelStore.getChannelCalls)
|
||||
}
|
||||
if len(list.Dialogs) != 0 || len(list.ChannelMessages) != 0 || len(list.Channels) != 0 || list.Count != 0 {
|
||||
t.Fatalf("peer dialogs = %+v, want no public preview dialogs", list)
|
||||
if channelStore.listHistoryCalls != 0 {
|
||||
t.Fatalf("public preview history calls = %d, want zero", channelStore.listHistoryCalls)
|
||||
}
|
||||
if len(list.Dialogs) != 2 || len(list.ChannelMessages) != 0 || len(list.Channels) != 2 || list.Count != 2 {
|
||||
t.Fatalf("peer dialogs = %+v, want two deduplicated zero-top public previews", list)
|
||||
}
|
||||
for _, dialog := range list.Dialogs {
|
||||
if !dialog.ChannelLeft || dialog.TopMessage != 0 || dialog.ReadInboxMaxID != 0 || dialog.ReadOutboxMaxID != 0 {
|
||||
t.Fatalf("public preview bootstrap dialog = %+v", dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
575
internal/app/files/premium_promo_seed.go
Normal file
575
internal/app/files/premium_promo_seed.go
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
premiumPromoSeedStateKey = "files.premium_promo"
|
||||
premiumPromoSeedStateVersion = "premium-promo-v1"
|
||||
|
||||
premiumPromoManifestName = "premium_promo.json"
|
||||
premiumPromoMaxVideos = 128
|
||||
premiumPromoMaxVideoSize = int64(64 << 20)
|
||||
premiumPromoMaxThumbSize = int64(4 << 20)
|
||||
premiumPromoMaxTotalSize = int64(512 << 20)
|
||||
)
|
||||
|
||||
// PremiumPromoSeedStats reports the startup import outcome. Videos is the
|
||||
// number of usable catalog entries; Blobs counts main/thumbnail blobs written
|
||||
// during this run.
|
||||
type PremiumPromoSeedStats struct {
|
||||
Videos int
|
||||
Blobs int
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
type premiumPromoSeedJSON struct {
|
||||
APICall string `json:"api_call"`
|
||||
StatusText string `json:"status_text"`
|
||||
VideoSections []string `json:"video_sections"`
|
||||
Videos []seedDocumentJSON `json:"videos"`
|
||||
PeriodOptions []json.RawMessage `json:"period_options"`
|
||||
}
|
||||
|
||||
type premiumPromoSeedVideo struct {
|
||||
section string
|
||||
document domain.Document
|
||||
mainPath string
|
||||
thumbPath string
|
||||
thumbType string
|
||||
}
|
||||
|
||||
// SeedPremiumPromo imports the exported promo videos into the ordinary
|
||||
// document/file_blob storage. A missing root is an optional-resource fallback;
|
||||
// once the directory exists, malformed or incomplete data is a startup error.
|
||||
func (s *Service) SeedPremiumPromo(ctx context.Context, root string) (PremiumPromoSeedStats, error) {
|
||||
var stats PremiumPromoSeedStats
|
||||
if root == "" {
|
||||
s.clearPremiumPromo()
|
||||
stats.Skipped = true
|
||||
s.warnPremiumPromoMissing(root, errors.New("seed dir is empty"))
|
||||
return stats, nil
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.clearPremiumPromo()
|
||||
stats.Skipped = true
|
||||
s.warnPremiumPromoMissing(root, err)
|
||||
return stats, nil
|
||||
}
|
||||
return stats, fmt.Errorf("stat premium promo seed dir %q: %w", root, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return stats, fmt.Errorf("premium promo seed path %q is not a directory", root)
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(root, premiumPromoManifestName)
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("read premium promo manifest %q: %w", manifestPath, err)
|
||||
}
|
||||
videos, err := parsePremiumPromoSeed(root, raw)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("validate premium promo seed: %w", err)
|
||||
}
|
||||
for i := range videos {
|
||||
videos[i].document.DCID = s.dc
|
||||
}
|
||||
stats.Videos = len(videos)
|
||||
|
||||
stateHash, err := premiumPromoSeedHash(raw, videos, s.dc)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("hash premium promo seed: %w", err)
|
||||
}
|
||||
stateMatches, err := s.seedStateMatches(ctx, premiumPromoSeedStateKey, stateHash)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("read premium promo seed state: %w", err)
|
||||
}
|
||||
if stateMatches {
|
||||
if catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos); err != nil {
|
||||
return stats, fmt.Errorf("verify premium promo catalog: %w", err)
|
||||
} else if ready {
|
||||
s.setPremiumPromoCatalog(catalog)
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, video := range videos {
|
||||
existing, found, err := s.media.GetDocument(ctx, video.document.ID)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("read premium promo document %d: %w", video.document.ID, err)
|
||||
}
|
||||
if found && existing.AccessHash != video.document.AccessHash {
|
||||
return stats, fmt.Errorf(
|
||||
"premium promo document %d collides with access_hash %d (seed has %d)",
|
||||
video.document.ID,
|
||||
existing.AccessHash,
|
||||
video.document.AccessHash,
|
||||
)
|
||||
}
|
||||
|
||||
forceBlobWrite := !stateMatches
|
||||
if wrote, err := s.putPremiumPromoBlob(
|
||||
ctx,
|
||||
fmt.Sprintf("doc:%d", video.document.ID),
|
||||
video.mainPath,
|
||||
video.document.MimeType,
|
||||
video.document.Size,
|
||||
forceBlobWrite,
|
||||
); err != nil {
|
||||
return stats, fmt.Errorf("import premium promo video %d: %w", video.document.ID, err)
|
||||
} else if wrote {
|
||||
stats.Blobs++
|
||||
}
|
||||
thumb := video.document.Thumbs[0]
|
||||
if wrote, err := s.putPremiumPromoBlob(
|
||||
ctx,
|
||||
fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType),
|
||||
video.thumbPath,
|
||||
"image/jpeg",
|
||||
int64(thumb.Size),
|
||||
forceBlobWrite,
|
||||
); err != nil {
|
||||
return stats, fmt.Errorf("import premium promo thumbnail %d: %w", video.document.ID, err)
|
||||
} else if wrote {
|
||||
stats.Blobs++
|
||||
}
|
||||
if err := s.media.PutDocument(ctx, video.document); err != nil {
|
||||
return stats, fmt.Errorf("store premium promo document %d: %w", video.document.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("verify imported premium promo catalog: %w", err)
|
||||
}
|
||||
if !ready {
|
||||
return stats, errors.New("premium promo catalog is incomplete after import")
|
||||
}
|
||||
if err := s.putSeedState(ctx, premiumPromoSeedStateKey, stateHash); err != nil {
|
||||
return stats, fmt.Errorf("record premium promo seed state: %w", err)
|
||||
}
|
||||
s.setPremiumPromoCatalog(catalog)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// PremiumPromo returns a deep copy so callers cannot mutate the startup
|
||||
// catalog or another request's response.
|
||||
func (s *Service) PremiumPromo(_ context.Context) (domain.PremiumPromoCatalog, bool, error) {
|
||||
s.premiumPromoMu.RLock()
|
||||
defer s.premiumPromoMu.RUnlock()
|
||||
if !s.premiumPromoReady {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
return domain.PremiumPromoCatalog{
|
||||
VideoSections: append([]string(nil), s.premiumPromo.VideoSections...),
|
||||
Videos: copyDocuments(s.premiumPromo.Videos),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) setPremiumPromoCatalog(catalog domain.PremiumPromoCatalog) {
|
||||
s.premiumPromoMu.Lock()
|
||||
defer s.premiumPromoMu.Unlock()
|
||||
s.premiumPromo = domain.PremiumPromoCatalog{
|
||||
VideoSections: append([]string(nil), catalog.VideoSections...),
|
||||
Videos: copyDocuments(catalog.Videos),
|
||||
}
|
||||
s.premiumPromoReady = true
|
||||
}
|
||||
|
||||
func (s *Service) clearPremiumPromo() {
|
||||
s.premiumPromoMu.Lock()
|
||||
defer s.premiumPromoMu.Unlock()
|
||||
s.premiumPromo = domain.PremiumPromoCatalog{}
|
||||
s.premiumPromoReady = false
|
||||
}
|
||||
|
||||
func (s *Service) warnPremiumPromoMissing(root string, err error) {
|
||||
if s.log == nil {
|
||||
return
|
||||
}
|
||||
s.log.Warn(
|
||||
"Premium promo seed 目录不存在,help.getPremiumPromo 将返回无视频兼容响应",
|
||||
zap.String("dir", root),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
func parsePremiumPromoSeed(root string, raw []byte) ([]premiumPromoSeedVideo, error) {
|
||||
var parsed premiumPromoSeedJSON
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", premiumPromoManifestName, err)
|
||||
}
|
||||
if parsed.APICall != "help.getPremiumPromo" {
|
||||
return nil, fmt.Errorf("api_call = %q, want help.getPremiumPromo", parsed.APICall)
|
||||
}
|
||||
if len(parsed.VideoSections) == 0 || len(parsed.VideoSections) > premiumPromoMaxVideos {
|
||||
return nil, fmt.Errorf("video_sections count %d is outside 1..%d", len(parsed.VideoSections), premiumPromoMaxVideos)
|
||||
}
|
||||
if len(parsed.VideoSections) != len(parsed.Videos) {
|
||||
return nil, fmt.Errorf("video_sections count %d does not match videos count %d", len(parsed.VideoSections), len(parsed.Videos))
|
||||
}
|
||||
|
||||
seenSections := make(map[string]struct{}, len(parsed.VideoSections))
|
||||
seenDocuments := make(map[int64]struct{}, len(parsed.Videos))
|
||||
out := make([]premiumPromoSeedVideo, 0, len(parsed.Videos))
|
||||
var totalSize int64
|
||||
for i, dj := range parsed.Videos {
|
||||
section := parsed.VideoSections[i]
|
||||
if !validPremiumPromoSection(section) {
|
||||
return nil, fmt.Errorf("video_sections[%d] %q is invalid", i, section)
|
||||
}
|
||||
if _, exists := seenSections[section]; exists {
|
||||
return nil, fmt.Errorf("duplicate video section %q", section)
|
||||
}
|
||||
seenSections[section] = struct{}{}
|
||||
|
||||
if dj.ID <= 0 {
|
||||
return nil, fmt.Errorf("videos[%d].id must be positive", i)
|
||||
}
|
||||
if _, exists := seenDocuments[dj.ID]; exists {
|
||||
return nil, fmt.Errorf("duplicate video document id %d", dj.ID)
|
||||
}
|
||||
seenDocuments[dj.ID] = struct{}{}
|
||||
if dj.AccessHash == 0 {
|
||||
return nil, fmt.Errorf("videos[%d].access_hash must be non-zero", i)
|
||||
}
|
||||
fileReference, err := hex.DecodeString(dj.FileReference)
|
||||
if err != nil || len(fileReference) == 0 {
|
||||
return nil, fmt.Errorf("videos[%d].file_reference is not non-empty hex", i)
|
||||
}
|
||||
date, err := time.Parse(time.RFC3339, dj.Date)
|
||||
if err != nil || date.Unix() < 0 || date.Unix() > 1<<31-1 {
|
||||
return nil, fmt.Errorf("videos[%d].date %q is outside TL int date range", i, dj.Date)
|
||||
}
|
||||
if dj.MimeType != "video/mp4" {
|
||||
return nil, fmt.Errorf("videos[%d].mime_type = %q, want video/mp4", i, dj.MimeType)
|
||||
}
|
||||
if dj.Size <= 0 || dj.Size > premiumPromoMaxVideoSize {
|
||||
return nil, fmt.Errorf("videos[%d].size %d is outside 1..%d", i, dj.Size, premiumPromoMaxVideoSize)
|
||||
}
|
||||
if err := validatePremiumPromoAttributes(i, dj.Attributes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mainPath := filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", dj.ID))
|
||||
mainInfo, err := regularFileInfo(mainPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] main file: %w", i, err)
|
||||
}
|
||||
if mainInfo.Size() != dj.Size {
|
||||
return nil, fmt.Errorf("videos[%d] main file size %d does not match manifest %d", i, mainInfo.Size(), dj.Size)
|
||||
}
|
||||
if err := validateMP4Header(mainPath); err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] main file: %w", i, err)
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", dj.ID))
|
||||
thumbInfo, err := regularFileInfo(thumbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err)
|
||||
}
|
||||
if thumbInfo.Size() <= 0 || thumbInfo.Size() > premiumPromoMaxThumbSize {
|
||||
return nil, fmt.Errorf("videos[%d] thumbnail size %d is outside 1..%d", i, thumbInfo.Size(), premiumPromoMaxThumbSize)
|
||||
}
|
||||
w, h, err := jpegDimensions(thumbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err)
|
||||
}
|
||||
thumbType := premiumPromoThumbType(w, h)
|
||||
attributes := seedDocumentAttributes(dj.Attributes)
|
||||
document := domain.Document{
|
||||
ID: dj.ID,
|
||||
AccessHash: dj.AccessHash,
|
||||
FileReference: fileReference,
|
||||
Date: int(date.Unix()),
|
||||
MimeType: dj.MimeType,
|
||||
Size: dj.Size,
|
||||
DCID: 0, // overwritten with the canonical server DC by the caller
|
||||
Attributes: attributes,
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault,
|
||||
Type: thumbType,
|
||||
W: w,
|
||||
H: h,
|
||||
Size: int(thumbInfo.Size()),
|
||||
}},
|
||||
}
|
||||
out = append(out, premiumPromoSeedVideo{
|
||||
section: section,
|
||||
document: document,
|
||||
mainPath: mainPath,
|
||||
thumbPath: thumbPath,
|
||||
thumbType: thumbType,
|
||||
})
|
||||
totalSize += mainInfo.Size() + thumbInfo.Size()
|
||||
if totalSize > premiumPromoMaxTotalSize {
|
||||
return nil, fmt.Errorf("premium promo source bytes %d exceed limit %d", totalSize, premiumPromoMaxTotalSize)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validatePremiumPromoAttributes(index int, attrs []seedAttrJSON) error {
|
||||
var filename, video, animated int
|
||||
for j, attr := range attrs {
|
||||
switch attr.Type {
|
||||
case "DocumentAttributeFilename":
|
||||
filename++
|
||||
if strings.TrimSpace(attr.FileName) == "" {
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has empty file_name", index, j)
|
||||
}
|
||||
case "DocumentAttributeVideo":
|
||||
video++
|
||||
if attr.W <= 0 || attr.W > 16384 || attr.H <= 0 || attr.H > 16384 {
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has invalid video dimensions %dx%d", index, j, attr.W, attr.H)
|
||||
}
|
||||
if attr.Duration <= 0 || attr.Duration > 3600 {
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has invalid duration %v", index, j, attr.Duration)
|
||||
}
|
||||
case "DocumentAttributeAnimated":
|
||||
animated++
|
||||
default:
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has unsupported type %q", index, j, attr.Type)
|
||||
}
|
||||
}
|
||||
if filename != 1 || video != 1 || animated > 1 {
|
||||
return fmt.Errorf("videos[%d] must contain exactly one filename/video and at most one animated attribute", index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPremiumPromoSection(section string) bool {
|
||||
if section == "" || len(section) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range section {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func regularFileInfo(path string) (os.FileInfo, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%q is not a regular file", path)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func validateMP4Header(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
header := make([]byte, 12)
|
||||
if _, err := io.ReadFull(f, header); err != nil {
|
||||
return fmt.Errorf("read MP4 header: %w", err)
|
||||
}
|
||||
if string(header[4:8]) != "ftyp" {
|
||||
return errors.New("missing ISO BMFF ftyp header")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jpegDimensions(path string) (int, int, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, err := jpeg.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("decode JPEG config: %w", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Width > 16384 || cfg.Height <= 0 || cfg.Height > 16384 {
|
||||
return 0, 0, fmt.Errorf("invalid JPEG dimensions %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
func premiumPromoThumbType(w, h int) string {
|
||||
maxDimension := w
|
||||
if h > maxDimension {
|
||||
maxDimension = h
|
||||
}
|
||||
switch {
|
||||
case maxDimension <= 100:
|
||||
return "s"
|
||||
case maxDimension <= 320:
|
||||
return "m"
|
||||
case maxDimension <= 800:
|
||||
return "x"
|
||||
case maxDimension <= 1280:
|
||||
return "y"
|
||||
default:
|
||||
return "w"
|
||||
}
|
||||
}
|
||||
|
||||
func premiumPromoSeedHash(raw []byte, videos []premiumPromoSeedVideo, dc int) (string, error) {
|
||||
return seedStateHash(func(h hash.Hash) error {
|
||||
writeSeedStateHeader(h, premiumPromoSeedStateVersion, dc)
|
||||
if _, err := h.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
paths := make([]string, 0, len(videos)*2)
|
||||
for _, video := range videos {
|
||||
paths = append(paths, video.mainPath, video.thumbPath)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
info, err := regularFileInfo(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel := filepath.Join(filepath.Base(filepath.Dir(path)), filepath.Base(path))
|
||||
_, _ = fmt.Fprintf(h, "\nfile=%s\x00size=%d\x00mtime=%d", filepath.ToSlash(rel), info.Size(), info.ModTime().UnixNano())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) loadPremiumPromoCatalog(ctx context.Context, videos []premiumPromoSeedVideo) (domain.PremiumPromoCatalog, bool, error) {
|
||||
ids := make([]int64, 0, len(videos))
|
||||
locationKeys := make([]string, 0, len(videos)*2)
|
||||
for i := range videos {
|
||||
videos[i].document.DCID = s.dc
|
||||
ids = append(ids, videos[i].document.ID)
|
||||
locationKeys = append(
|
||||
locationKeys,
|
||||
fmt.Sprintf("doc:%d", videos[i].document.ID),
|
||||
fmt.Sprintf("doc:%d:%s", videos[i].document.ID, videos[i].thumbType),
|
||||
)
|
||||
}
|
||||
stored, err := s.media.GetDocuments(ctx, ids)
|
||||
if err != nil {
|
||||
return domain.PremiumPromoCatalog{}, false, err
|
||||
}
|
||||
if len(stored) != len(videos) {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
byID := make(map[int64]domain.Document, len(stored))
|
||||
for _, doc := range stored {
|
||||
byID[doc.ID] = doc
|
||||
}
|
||||
blobs, err := s.media.GetFileBlobs(ctx, locationKeys)
|
||||
if err != nil {
|
||||
return domain.PremiumPromoCatalog{}, false, err
|
||||
}
|
||||
|
||||
catalog := domain.PremiumPromoCatalog{
|
||||
VideoSections: make([]string, 0, len(videos)),
|
||||
Videos: make([]domain.Document, 0, len(videos)),
|
||||
}
|
||||
for _, video := range videos {
|
||||
storedDoc, ok := byID[video.document.ID]
|
||||
if !ok || !premiumPromoDocumentEqual(storedDoc, video.document) {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
mainKey := fmt.Sprintf("doc:%d", video.document.ID)
|
||||
thumbKey := fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType)
|
||||
if !s.premiumPromoBlobReady(ctx, blobs[mainKey], mainKey, video.document.Size, video.document.MimeType) {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
if !s.premiumPromoBlobReady(ctx, blobs[thumbKey], thumbKey, int64(video.document.Thumbs[0].Size), "image/jpeg") {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
catalog.VideoSections = append(catalog.VideoSections, video.section)
|
||||
catalog.Videos = append(catalog.Videos, storedDoc)
|
||||
}
|
||||
return catalog, true, nil
|
||||
}
|
||||
|
||||
func premiumPromoDocumentEqual(got, want domain.Document) bool {
|
||||
return got.ID == want.ID &&
|
||||
got.AccessHash == want.AccessHash &&
|
||||
bytes.Equal(got.FileReference, want.FileReference) &&
|
||||
got.Date == want.Date &&
|
||||
got.MimeType == want.MimeType &&
|
||||
got.Size == want.Size &&
|
||||
got.DCID == want.DCID &&
|
||||
reflect.DeepEqual(got.Attributes, want.Attributes) &&
|
||||
reflect.DeepEqual(got.Thumbs, want.Thumbs)
|
||||
}
|
||||
|
||||
func (s *Service) premiumPromoBlobReady(ctx context.Context, blob domain.FileBlob, locationKey string, size int64, mimeType string) bool {
|
||||
if blob.LocationKey != locationKey ||
|
||||
blob.Backend != domain.MediaBackend(s.blobs.Name()) ||
|
||||
blob.ObjectKey == "" ||
|
||||
blob.Size != size ||
|
||||
blob.MimeType != mimeType {
|
||||
return false
|
||||
}
|
||||
_, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, 1)
|
||||
return err == nil && total == size
|
||||
}
|
||||
|
||||
func (s *Service) putPremiumPromoBlob(
|
||||
ctx context.Context,
|
||||
locationKey string,
|
||||
path string,
|
||||
mimeType string,
|
||||
wantSize int64,
|
||||
force bool,
|
||||
) (bool, error) {
|
||||
if !force {
|
||||
if blob, found, err := s.media.GetFileBlob(ctx, locationKey); err != nil {
|
||||
return false, err
|
||||
} else if found && s.premiumPromoBlobReady(ctx, blob, locationKey, wantSize, mimeType) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
objectKey, size, sum, err := s.blobs.PutReader(ctx, f)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if size != wantSize {
|
||||
return false, fmt.Errorf("streamed size %d does not match validated size %d", size, wantSize)
|
||||
}
|
||||
blob := domain.FileBlob{
|
||||
LocationKey: locationKey,
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: size,
|
||||
SHA256: append([]byte(nil), sum...),
|
||||
MimeType: mimeType,
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, blob); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.blobCache.put(locationKey, blob)
|
||||
return true, nil
|
||||
}
|
||||
292
internal/app/files/premium_promo_seed_test.go
Normal file
292
internal/app/files/premium_promo_seed_test.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestSeedPremiumPromoImportsDownloadsSkipsAndRepairs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root, videoBytes, thumbBytes := writePremiumPromoFixture(t)
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 7, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
|
||||
|
||||
first, err := svc.SeedPremiumPromo(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo first: %v", err)
|
||||
}
|
||||
if first.Skipped || first.Videos != 1 || first.Blobs != 2 {
|
||||
t.Fatalf("first stats = %+v, want one video and two blobs", first)
|
||||
}
|
||||
catalog, found, err := svc.PremiumPromo(ctx)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("PremiumPromo found=%v err=%v", found, err)
|
||||
}
|
||||
if len(catalog.VideoSections) != 1 || catalog.VideoSections[0] != "no_ads" || len(catalog.Videos) != 1 {
|
||||
t.Fatalf("catalog = %+v", catalog)
|
||||
}
|
||||
doc := catalog.Videos[0]
|
||||
if doc.DCID != 7 || doc.MimeType != "video/mp4" || len(doc.Thumbs) != 1 || doc.Thumbs[0].Type != "m" {
|
||||
t.Fatalf("document = %+v", doc)
|
||||
}
|
||||
|
||||
main, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("doc:%d", doc.ID),
|
||||
Limit: len(videoBytes) + 1,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("download main ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !bytes.Equal(main.Bytes, videoBytes) {
|
||||
t.Fatalf("downloaded main = %x, want %x", main.Bytes, videoBytes)
|
||||
}
|
||||
thumb, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, doc.Thumbs[0].Type),
|
||||
Limit: len(thumbBytes) + 1,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("download thumb ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !bytes.Equal(thumb.Bytes, thumbBytes) {
|
||||
t.Fatalf("downloaded thumb differs: got %d bytes, want %d", len(thumb.Bytes), len(thumbBytes))
|
||||
}
|
||||
|
||||
// Returned values are request-owned: mutating one response must not corrupt
|
||||
// the immutable catalog seen by later/concurrent requests.
|
||||
catalog.VideoSections[0] = "mutated"
|
||||
catalog.Videos[0].FileReference[0] ^= 0xff
|
||||
catalog.Videos[0].Thumbs[0].Type = "z"
|
||||
again, found, err := svc.PremiumPromo(ctx)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("PremiumPromo again found=%v err=%v", found, err)
|
||||
}
|
||||
if again.VideoSections[0] != "no_ads" || again.Videos[0].Thumbs[0].Type != "m" || again.Videos[0].FileReference[0] != 0 {
|
||||
t.Fatalf("catalog was mutated through returned value: %+v", again)
|
||||
}
|
||||
|
||||
var readers sync.WaitGroup
|
||||
readerErrors := make(chan error, 32)
|
||||
for i := 0; i < 32; i++ {
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
got, found, err := svc.PremiumPromo(ctx)
|
||||
if err != nil || !found || len(got.Videos) != 1 {
|
||||
readerErrors <- fmt.Errorf("found=%v videos=%d err=%v", found, len(got.Videos), err)
|
||||
return
|
||||
}
|
||||
got.VideoSections[0] = "request-owned"
|
||||
got.Videos[0].FileReference[0] = 0x7f
|
||||
}()
|
||||
}
|
||||
readers.Wait()
|
||||
close(readerErrors)
|
||||
for err := range readerErrors {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
second, err := svc.SeedPremiumPromo(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo unchanged: %v", err)
|
||||
}
|
||||
if !second.Skipped || second.Videos != 1 || second.Blobs != 0 {
|
||||
t.Fatalf("unchanged stats = %+v, want skipped catalog", second)
|
||||
}
|
||||
|
||||
mainKey := fmt.Sprintf("doc:%d", doc.ID)
|
||||
media.mu.Lock()
|
||||
delete(media.blobs, mainKey)
|
||||
media.mu.Unlock()
|
||||
repaired, err := svc.SeedPremiumPromo(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo repair: %v", err)
|
||||
}
|
||||
if repaired.Skipped || repaired.Videos != 1 || repaired.Blobs != 1 {
|
||||
t.Fatalf("repair stats = %+v, want one repaired blob", repaired)
|
||||
}
|
||||
if _, ok, err := media.GetFileBlob(ctx, mainKey); err != nil || !ok {
|
||||
t.Fatalf("repaired main blob ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedPremiumPromoMissingAndInvalidSources(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
newService := func(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
return NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
|
||||
}
|
||||
|
||||
t.Run("missing directory falls back", func(t *testing.T) {
|
||||
svc := newService(t)
|
||||
stats, err := svc.SeedPremiumPromo(ctx, filepath.Join(t.TempDir(), "missing"))
|
||||
if err != nil || !stats.Skipped {
|
||||
t.Fatalf("stats=%+v err=%v, want optional-resource fallback", stats, err)
|
||||
}
|
||||
if _, found, err := svc.PremiumPromo(ctx); err != nil || found {
|
||||
t.Fatalf("PremiumPromo found=%v err=%v, want unavailable", found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing directory without manifest fails", func(t *testing.T) {
|
||||
svc := newService(t)
|
||||
if _, err := svc.SeedPremiumPromo(ctx, t.TempDir()); err == nil {
|
||||
t.Fatal("existing incomplete seed directory was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("positional vectors must match", func(t *testing.T) {
|
||||
root, _, _ := writePremiumPromoFixture(t)
|
||||
rewritePremiumPromoManifest(t, root, func(m map[string]any) {
|
||||
m["video_sections"] = []string{"no_ads", "extra"}
|
||||
})
|
||||
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
|
||||
t.Fatal("mismatched video_sections/videos was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manifest size must match file", func(t *testing.T) {
|
||||
root, _, _ := writePremiumPromoFixture(t)
|
||||
rewritePremiumPromoManifest(t, root, func(m map[string]any) {
|
||||
videos := m["videos"].([]any)
|
||||
videos[0].(map[string]any)["size"] = float64(999)
|
||||
})
|
||||
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
|
||||
t.Fatal("wrong video size was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing thumbnail fails", func(t *testing.T) {
|
||||
root, _, _ := writePremiumPromoFixture(t)
|
||||
thumbPath := filepath.Join(root, "thumbs", "1000000000000001.jpg")
|
||||
if err := os.Remove(thumbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
|
||||
t.Fatal("missing thumbnail was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSeedPremiumPromoFromRealExport(t *testing.T) {
|
||||
root := os.Getenv("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR")
|
||||
if root == "" {
|
||||
t.Skip("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR not set")
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skipf("seed dir %s not present: %v", root, err)
|
||||
}
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
svc := NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
|
||||
stats, err := svc.SeedPremiumPromo(context.Background(), root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo: %v", err)
|
||||
}
|
||||
catalog, found, err := svc.PremiumPromo(context.Background())
|
||||
if err != nil || !found {
|
||||
t.Fatalf("PremiumPromo found=%v err=%v", found, err)
|
||||
}
|
||||
if stats.Videos != 31 || len(catalog.VideoSections) != 31 || len(catalog.Videos) != 31 {
|
||||
t.Fatalf("stats=%+v sections=%d videos=%d, want 31", stats, len(catalog.VideoSections), len(catalog.Videos))
|
||||
}
|
||||
t.Logf("real premium promo seed: videos=%d blobs=%d", stats.Videos, stats.Blobs)
|
||||
}
|
||||
|
||||
func writePremiumPromoFixture(t *testing.T) (string, []byte, []byte) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "thumbs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const documentID int64 = 1000000000000001
|
||||
videoBytes := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm', 0, 0, 0, 0}
|
||||
if err := os.WriteFile(filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", documentID)), videoBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
|
||||
for y := 0; y < 240; y++ {
|
||||
for x := 0; x < 160; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x88, A: 0xff})
|
||||
}
|
||||
}
|
||||
var thumb bytes.Buffer
|
||||
if err := jpeg.Encode(&thumb, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thumbBytes := thumb.Bytes()
|
||||
if err := os.WriteFile(filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", documentID)), thumbBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := premiumPromoSeedJSON{
|
||||
APICall: "help.getPremiumPromo",
|
||||
StatusText: "ignored source status",
|
||||
VideoSections: []string{"no_ads"},
|
||||
Videos: []seedDocumentJSON{{
|
||||
ID: documentID,
|
||||
AccessHash: -7,
|
||||
FileReference: "00112233445566778899aabbccddeeff",
|
||||
Date: "2026-01-02T03:04:05Z",
|
||||
MimeType: "video/mp4",
|
||||
Size: int64(len(videoBytes)),
|
||||
DCID: 4,
|
||||
Attributes: []seedAttrJSON{
|
||||
{Type: "DocumentAttributeFilename", FileName: "promo.mp4"},
|
||||
{Type: "DocumentAttributeVideo", W: 720, H: 1070, Duration: 5, SupportsStreaming: true},
|
||||
{Type: "DocumentAttributeAnimated"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
raw, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, premiumPromoManifestName), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, append([]byte(nil), videoBytes...), append([]byte(nil), thumbBytes...)
|
||||
}
|
||||
|
||||
func rewritePremiumPromoManifest(t *testing.T, root string, mutate func(map[string]any)) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, premiumPromoManifestName)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mutate(manifest)
|
||||
raw, err = json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -71,6 +72,13 @@ type Service struct {
|
|||
// effectsHash 在 seed 时算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算。
|
||||
effects []domain.AvailableEffect
|
||||
effectsHash int
|
||||
|
||||
// premiumPromo is populated during startup seed and then read by RPC
|
||||
// handlers. Keep a lock so the ownership boundary remains race-safe even
|
||||
// when exercised concurrently in tests.
|
||||
premiumPromoMu sync.RWMutex
|
||||
premiumPromo domain.PremiumPromoCatalog
|
||||
premiumPromoReady bool
|
||||
}
|
||||
|
||||
// Option 配置 files 服务的可选能力。
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
compatandroid "telesrv/internal/compat/android"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/seed/catalog"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -56,9 +57,10 @@ const tdesktopClient = "tdesktop"
|
|||
//
|
||||
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
|
||||
// so this compatibility key must always remain an array, even when it is empty.
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
const tdesktopNoForwardsAppConfig = `,"no_forwards_request_expire_period":86400`
|
||||
|
||||
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
const defaultAppConfigHash = 27 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
@ -137,7 +139,8 @@ func defaultAppConfig(mapboxToken string, emailSignupEnable bool, emailSignupPho
|
|||
}
|
||||
|
||||
func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) []byte {
|
||||
base := tdesktopDefaultAppConfigBase
|
||||
androidInvoiceBilling := `,"premium_playmarket_direct_currency_list":` + compatandroid.DirectInvoiceCurrenciesJSON()
|
||||
base := tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling
|
||||
if emailSignupEnable {
|
||||
base += `,"email_signup_enabled":true`
|
||||
if len(emailSignupPhonePrefixes) > 0 {
|
||||
|
|
@ -172,8 +175,10 @@ func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool, emailSi
|
|||
}
|
||||
|
||||
// GetAppConfig returns the cached global app config plus an authenticated,
|
||||
// per-account freeze overlay. The overlay owns its own deterministic hash so a
|
||||
// FROZEN_METHOD_INVALID-triggered refresh can never be answered notModified.
|
||||
// per-account freeze overlay. Only active freezes add account fields; an
|
||||
// inactive account receives the field-free base config. The overlay owns its
|
||||
// own deterministic hash so a FROZEN_METHOD_INVALID-triggered refresh and a
|
||||
// later unfreeze can never be answered notModified against the other state.
|
||||
func (s *Service) GetAppConfig(ctx context.Context, userID int64, hash int) (domain.AppConfig, bool, error) {
|
||||
cfg := s.loadAppConfig(ctx)
|
||||
var err error
|
||||
|
|
@ -197,15 +202,6 @@ func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domai
|
|||
}
|
||||
}
|
||||
if userID > 0 {
|
||||
// DrKLO applies only keys present in the new JSON object and retains old
|
||||
// SharedPreferences values for missing keys. Authenticated non-frozen
|
||||
// accounts therefore need an explicit zero/empty triplet to converge after
|
||||
// an unfreeze; merely omitting the overlay works in TDesktop but leaves
|
||||
// Android frozen indefinitely. Unauthenticated config remains unscoped.
|
||||
values["freeze_since_date"] = json.RawMessage("0")
|
||||
values["freeze_until_date"] = json.RawMessage("0")
|
||||
values["freeze_appeal_url"] = json.RawMessage(`""`)
|
||||
changed = true
|
||||
if s != nil && s.accountFreeze != nil {
|
||||
freeze, found, err := s.accountFreeze.AccountFreeze(ctx, userID)
|
||||
if err != nil {
|
||||
|
|
@ -216,6 +212,7 @@ func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domai
|
|||
values["freeze_until_date"] = json.RawMessage(strconv.FormatInt(freeze.Until.Unix(), 10))
|
||||
appeal, _ := json.Marshal(freeze.AppealURL)
|
||||
values["freeze_appeal_url"] = appeal
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
|
|||
if err != nil || notModified || other.Hash != normal.Hash {
|
||||
t.Fatalf("other user = hash:%d notModified:%v err:%v", other.Hash, notModified, err)
|
||||
}
|
||||
assertClearedFreezeConfig(t, other.JSON)
|
||||
assertNoFreezeConfig(t, other.JSON)
|
||||
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -58,21 +58,24 @@ func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
|
|||
if err != nil || notModified || unfrozen.Hash != normal.Hash {
|
||||
t.Fatalf("unfreeze refresh = hash:%d notModified:%v err:%v", unfrozen.Hash, notModified, err)
|
||||
}
|
||||
assertClearedFreezeConfig(t, unfrozen.JSON)
|
||||
assertNoFreezeConfig(t, unfrozen.JSON)
|
||||
}
|
||||
|
||||
func TestAuthenticatedAppConfigClearsPersistedFreezeWithoutProvider(t *testing.T) {
|
||||
func TestAuthenticatedNonFrozenAppConfigOmitsFreezeFieldsAndReusesBaseHash(t *testing.T) {
|
||||
svc := NewService(nil, nil)
|
||||
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoFreezeConfig(t, unauthorized.JSON)
|
||||
authenticated, notModified, err := svc.GetAppConfig(context.Background(), 1001, unauthorized.Hash)
|
||||
if err != nil || notModified || authenticated.Hash == unauthorized.Hash {
|
||||
t.Fatalf("authenticated clear config = hash:%d base:%d notModified:%v err:%v", authenticated.Hash, unauthorized.Hash, notModified, err)
|
||||
authenticated, notModified, err := svc.GetAppConfig(context.Background(), 1001, 0)
|
||||
if err != nil || notModified || authenticated.Hash != unauthorized.Hash {
|
||||
t.Fatalf("authenticated config = hash:%d base:%d notModified:%v err:%v", authenticated.Hash, unauthorized.Hash, notModified, err)
|
||||
}
|
||||
assertNoFreezeConfig(t, authenticated.JSON)
|
||||
if _, notModified, err := svc.GetAppConfig(context.Background(), 1001, authenticated.Hash); err != nil || !notModified {
|
||||
t.Fatalf("authenticated hash replay = notModified:%v err:%v", notModified, err)
|
||||
}
|
||||
assertClearedFreezeConfig(t, authenticated.JSON)
|
||||
}
|
||||
|
||||
func TestAccountAppConfigStripsGlobalFreezeFields(t *testing.T) {
|
||||
|
|
@ -112,17 +115,6 @@ func assertNoFreezeConfig(t *testing.T, body []byte) {
|
|||
}
|
||||
}
|
||||
|
||||
func assertClearedFreezeConfig(t *testing.T, body []byte) {
|
||||
t.Helper()
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(body, &values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["freeze_since_date"] != float64(0) || values["freeze_until_date"] != float64(0) || values["freeze_appeal_url"] != "" {
|
||||
t.Fatalf("freeze clear config = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAccountFreezeProvider struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestAppConfigPremiumKeys 断言 premium / Stars 相关 key 完整下发且 hash 已递增:
|
||||
|
|
@ -26,6 +28,9 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
|
||||
t.Fatalf("app config json invalid: %v", err)
|
||||
}
|
||||
if period, ok := decoded["no_forwards_request_expire_period"].(float64); !ok || int(period) != domain.PrivateNoForwardsRequestExpirePeriod {
|
||||
t.Fatalf("no_forwards_request_expire_period = %v, want %d", decoded["no_forwards_request_expire_period"], domain.PrivateNoForwardsRequestExpirePeriod)
|
||||
}
|
||||
if blocked, ok := decoded["premium_purchase_blocked"].(bool); !ok || blocked {
|
||||
t.Fatalf("premium_purchase_blocked = %v, want false (star gift 送礼入口耦合此 flag)", decoded["premium_purchase_blocked"])
|
||||
}
|
||||
|
|
@ -37,6 +42,13 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
|
||||
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
|
||||
}
|
||||
if available, ok := decoded["giveaway_gifts_purchase_available"].(bool); !ok || !available {
|
||||
t.Fatalf("giveaway_gifts_purchase_available = %v, want true", decoded["giveaway_gifts_purchase_available"])
|
||||
}
|
||||
directCurrencies, ok := decoded["premium_playmarket_direct_currency_list"].([]any)
|
||||
if !ok || len(directCurrencies) == 0 || !containsJSONCurrency(directCurrencies, "USD") {
|
||||
t.Fatalf("premium_playmarket_direct_currency_list = %#v, want non-empty list containing USD", decoded["premium_playmarket_direct_currency_list"])
|
||||
}
|
||||
if posting, ok := decoded["rich_message_posting"].(string); !ok || posting != "enabled" {
|
||||
t.Fatalf("rich_message_posting = %v, want enabled (TDesktop 富文本编辑入口默认打开)", decoded["rich_message_posting"])
|
||||
}
|
||||
|
|
@ -45,39 +57,44 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
t.Fatalf("fragment_prefixes = %#v, want [\"888\"]", decoded["fragment_prefixes"])
|
||||
}
|
||||
wantNumbers := map[string]float64{
|
||||
"reactions_user_max_default": 1,
|
||||
"reactions_user_max_premium": 3,
|
||||
"boosts_channel_level_max": 100,
|
||||
"stargifts_pinned_to_top_limit": 6,
|
||||
"about_length_limit_default": 70,
|
||||
"about_length_limit_premium": 140,
|
||||
"dialogs_pinned_limit_default": 5,
|
||||
"dialogs_pinned_limit_premium": 10,
|
||||
"dialogs_folder_pinned_limit_default": 100,
|
||||
"dialogs_folder_pinned_limit_premium": 200,
|
||||
"saved_dialogs_pinned_limit_default": 5,
|
||||
"saved_dialogs_pinned_limit_premium": 100,
|
||||
"caption_length_limit_default": 1024,
|
||||
"caption_length_limit_premium": 4096,
|
||||
"channels_limit_default": 500,
|
||||
"channels_limit_premium": 1000,
|
||||
"dialog_filters_limit_default": 10,
|
||||
"dialog_filters_limit_premium": 20,
|
||||
"chatlist_update_period": 3600,
|
||||
"chatlist_invites_limit_default": 3,
|
||||
"chatlist_invites_limit_premium": 20,
|
||||
"chatlists_joined_limit_default": 2,
|
||||
"chatlists_joined_limit_premium": 20,
|
||||
"upload_max_fileparts_default": 4000,
|
||||
"upload_max_fileparts_premium": 8000,
|
||||
"aicompose_tone_examples_num": 3,
|
||||
"aicompose_tone_title_length_max": 12,
|
||||
"aicompose_tone_prompt_length_max": 1024,
|
||||
"aicompose_tone_saved_limit_default": 5,
|
||||
"aicompose_tone_saved_limit_premium": 20,
|
||||
"stories_stealth_future_period": 1500,
|
||||
"stories_stealth_past_period": 300,
|
||||
"stories_stealth_cooldown_period": 10800,
|
||||
"giveaway_boosts_per_premium": 4,
|
||||
"giveaway_countries_max": 10,
|
||||
"giveaway_add_peers_max": 10,
|
||||
"giveaway_period_max": 604800,
|
||||
"reactions_user_max_default": 1,
|
||||
"reactions_user_max_premium": 3,
|
||||
"boosts_channel_level_max": 100,
|
||||
"stargifts_pinned_to_top_limit": 6,
|
||||
"about_length_limit_default": 70,
|
||||
"about_length_limit_premium": 140,
|
||||
"bot_verification_description_length_limit": 70,
|
||||
"dialogs_pinned_limit_default": 5,
|
||||
"dialogs_pinned_limit_premium": 10,
|
||||
"dialogs_folder_pinned_limit_default": 100,
|
||||
"dialogs_folder_pinned_limit_premium": 200,
|
||||
"saved_dialogs_pinned_limit_default": 5,
|
||||
"saved_dialogs_pinned_limit_premium": 100,
|
||||
"caption_length_limit_default": 1024,
|
||||
"caption_length_limit_premium": 4096,
|
||||
"channels_limit_default": 500,
|
||||
"channels_limit_premium": 1000,
|
||||
"dialog_filters_limit_default": 10,
|
||||
"dialog_filters_limit_premium": 20,
|
||||
"chatlist_update_period": 3600,
|
||||
"chatlist_invites_limit_default": 3,
|
||||
"chatlist_invites_limit_premium": 20,
|
||||
"chatlists_joined_limit_default": 2,
|
||||
"chatlists_joined_limit_premium": 20,
|
||||
"upload_max_fileparts_default": 4000,
|
||||
"upload_max_fileparts_premium": 8000,
|
||||
"aicompose_tone_examples_num": 3,
|
||||
"aicompose_tone_title_length_max": 12,
|
||||
"aicompose_tone_prompt_length_max": 1024,
|
||||
"aicompose_tone_saved_limit_default": 5,
|
||||
"aicompose_tone_saved_limit_premium": 20,
|
||||
"stories_stealth_future_period": 1500,
|
||||
"stories_stealth_past_period": 300,
|
||||
"stories_stealth_cooldown_period": 10800,
|
||||
}
|
||||
for key, want := range wantNumbers {
|
||||
got, ok := decoded[key].(float64)
|
||||
|
|
@ -93,6 +110,15 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func containsJSONCurrency(values []any, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
|
||||
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
|
||||
if err != nil || notModified {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package langpack
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -205,8 +207,8 @@ func TestSeedDirectoryReconcilesManifest(t *testing.T) {
|
|||
t.Fatalf("reconcile removed file = %d, %v", seeded, err)
|
||||
}
|
||||
languages, err = service.ListLanguages(ctx, "tdesktop")
|
||||
if err != nil || len(languages) != 0 {
|
||||
t.Fatalf("languages after removal = %+v, err %v", languages, err)
|
||||
if !errors.Is(err, domain.ErrLangPackInvalid) || len(languages) != 0 {
|
||||
t.Fatalf("languages after removal = %+v, err %v, want ErrLangPackInvalid", languages, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ func (s *Service) GetDifference(ctx context.Context, langPack, langCode string,
|
|||
if s == nil || s.packs == nil {
|
||||
return domain.LangPack{LangPack: packName, LangCode: code, FromVersion: fromVersion}, nil
|
||||
}
|
||||
if err := s.validateLanguage(ctx, packName, code); err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
var (
|
||||
pack domain.LangPack
|
||||
err error
|
||||
|
|
@ -96,6 +99,9 @@ func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, key
|
|||
if s == nil || s.packs == nil {
|
||||
return domain.LangPack{LangPack: packName, LangCode: code}, nil
|
||||
}
|
||||
if err := s.validateLanguage(ctx, packName, code); err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
pack, err := s.effectivePack(ctx, packName, code)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
|
|
@ -123,7 +129,14 @@ func (s *Service) ListLanguages(ctx context.Context, langPack string) ([]domain.
|
|||
if s == nil || s.packs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.cachedLanguages(ctx, packName)
|
||||
languages, err := s.cachedLanguages(ctx, packName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(languages) == 0 {
|
||||
return nil, domain.ErrLangPackInvalid
|
||||
}
|
||||
return languages, nil
|
||||
}
|
||||
|
||||
func normalizePack(langPack string) string {
|
||||
|
|
@ -131,6 +144,9 @@ func normalizePack(langPack string) string {
|
|||
if pack == "" {
|
||||
return "tdesktop"
|
||||
}
|
||||
if pack == "web" {
|
||||
return "webk"
|
||||
}
|
||||
return pack
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +278,22 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Service) validateLanguage(ctx context.Context, langPack, langCode string) error {
|
||||
languages, err := s.cachedLanguages(ctx, langPack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(languages) == 0 {
|
||||
return domain.ErrLangPackInvalid
|
||||
}
|
||||
for _, language := range languages {
|
||||
if normalizeCode(language.LangCode) == langCode {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return domain.ErrLangCodeNotSupported
|
||||
}
|
||||
|
||||
func (s *Service) brandPack(pack domain.LangPack) domain.LangPack {
|
||||
for i := range pack.Strings {
|
||||
item := &pack.Strings[i]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package langpack
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -11,6 +12,106 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceNormalizesWebAliasAndRejectsUnknownCatalogEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
packs := memory.NewLangPackStore()
|
||||
svc := NewService(packs)
|
||||
for _, pack := range []domain.LangPack{
|
||||
{
|
||||
LangPack: "webk",
|
||||
LangCode: "en",
|
||||
Version: 7,
|
||||
Strings: []domain.LangPackString{{Key: "lng_settings_language", Value: "Language"}},
|
||||
},
|
||||
{
|
||||
LangPack: "webk",
|
||||
LangCode: "zh-hans",
|
||||
Version: 9,
|
||||
Strings: []domain.LangPackString{{Key: "lng_settings_language", Value: "语言"}},
|
||||
},
|
||||
} {
|
||||
if err := packs.UpsertPack(ctx, pack); err != nil {
|
||||
t.Fatalf("seed %s/%s: %v", pack.LangPack, pack.LangCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
languages, err := svc.ListLanguages(ctx, " WEB ")
|
||||
if err != nil {
|
||||
t.Fatalf("list web languages: %v", err)
|
||||
}
|
||||
if len(languages) != 2 || findLanguage(languages, "zh-hans") == nil {
|
||||
t.Fatalf("web languages = %+v, want canonical webk catalog", languages)
|
||||
}
|
||||
|
||||
full, err := svc.GetLangPack(ctx, "web", "ZH_HANS")
|
||||
if err != nil {
|
||||
t.Fatalf("get web langpack: %v", err)
|
||||
}
|
||||
if full.LangPack != "webk" || full.LangCode != "zh-hans" || full.Version != 9 || stringValue(full.Strings, "lng_settings_language") != "语言" {
|
||||
t.Fatalf("web langpack = %+v, want canonical webk/zh-hans", full)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, "web", "zh-hans", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("get web difference: %v", err)
|
||||
}
|
||||
if diff.LangPack != "webk" || diff.FromVersion != 1 || len(diff.Strings) != 1 {
|
||||
t.Fatalf("web difference = %+v, want canonical webk delta", diff)
|
||||
}
|
||||
|
||||
selected, err := svc.GetStrings(ctx, "web", "zh-hans", []string{"lng_settings_language"})
|
||||
if err != nil {
|
||||
t.Fatalf("get web strings: %v", err)
|
||||
}
|
||||
if selected.LangPack != "webk" || stringValue(selected.Strings, "lng_settings_language") != "语言" {
|
||||
t.Fatalf("web strings = %+v, want selected webk string", selected)
|
||||
}
|
||||
|
||||
invalidPackCalls := map[string]func() error{
|
||||
"list": func() error {
|
||||
_, err := svc.ListLanguages(ctx, "web-invalid")
|
||||
return err
|
||||
},
|
||||
"full": func() error {
|
||||
_, err := svc.GetLangPack(ctx, "web-invalid", "en")
|
||||
return err
|
||||
},
|
||||
"difference": func() error {
|
||||
_, err := svc.GetDifference(ctx, "web-invalid", "en", 1)
|
||||
return err
|
||||
},
|
||||
"strings": func() error {
|
||||
_, err := svc.GetStrings(ctx, "web-invalid", "en", []string{"key"})
|
||||
return err
|
||||
},
|
||||
}
|
||||
for name, call := range invalidPackCalls {
|
||||
if err := call(); !errors.Is(err, domain.ErrLangPackInvalid) {
|
||||
t.Fatalf("%s invalid pack error = %v, want ErrLangPackInvalid", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
unsupportedCodeCalls := map[string]func() error{
|
||||
"full": func() error {
|
||||
_, err := svc.GetLangPack(ctx, "web", "fr")
|
||||
return err
|
||||
},
|
||||
"difference": func() error {
|
||||
_, err := svc.GetDifference(ctx, "web", "fr", 1)
|
||||
return err
|
||||
},
|
||||
"strings": func() error {
|
||||
_, err := svc.GetStrings(ctx, "web", "fr", []string{"key"})
|
||||
return err
|
||||
},
|
||||
}
|
||||
for name, call := range unsupportedCodeCalls {
|
||||
if err := call(); !errors.Is(err, domain.ErrLangCodeNotSupported) {
|
||||
t.Fatalf("%s unsupported code error = %v, want ErrLangCodeNotSupported", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNormalizesWebARawLangCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
packs := memory.NewLangPackStore()
|
||||
|
|
|
|||
|
|
@ -67,6 +67,19 @@ type LoginCodeDeliveryRetentionStore interface {
|
|||
DeleteExpiredLoginCodeDeliveries(ctx context.Context, expiredBefore time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
type ClientTelemetryRetentionStore interface {
|
||||
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
type AuthDeliveryReportRetentionStore interface {
|
||||
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
type ModerationRetentionStore interface {
|
||||
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
// botAPIConfirmedGrace 是已确认 Bot API update 行的删除宽限:确认水位之下的行不会再被
|
||||
// getUpdates 读取(fromID 恒 > confirmed),宽限仅防御 offset 回拨调试;回收目标是清堆积。
|
||||
const botAPIConfirmedGrace = 15 * time.Minute
|
||||
|
|
@ -92,24 +105,29 @@ const (
|
|||
// 前缀;落后或缺 state 的任一设备都会把 floor 压回 0。客户端偶然带回已确认前的旧 pts 时,
|
||||
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
|
||||
type RetentionWorker struct {
|
||||
outbox DispatchOutboxRetentionStore
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
authKeySessionLayers AuthKeySessionLayerRetentionStore
|
||||
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
||||
userUpdates UserUpdateEventRetentionStore
|
||||
channelUpdates ChannelUpdateEventRetentionStore
|
||||
loginCodeDeliveries LoginCodeDeliveryRetentionStore
|
||||
orphanAuthKeys OrphanAuthKeyRetentionStore
|
||||
activeAuthKeys ActiveRawAuthKeyProvider
|
||||
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
botAPIRetention time.Duration
|
||||
orphanRetention time.Duration
|
||||
outboxPoisonRetention time.Duration
|
||||
outboxPoisonInterval time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
outbox DispatchOutboxRetentionStore
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
authKeySessionLayers AuthKeySessionLayerRetentionStore
|
||||
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
||||
userUpdates UserUpdateEventRetentionStore
|
||||
channelUpdates ChannelUpdateEventRetentionStore
|
||||
loginCodeDeliveries LoginCodeDeliveryRetentionStore
|
||||
clientTelemetry ClientTelemetryRetentionStore
|
||||
authDeliveryReports AuthDeliveryReportRetentionStore
|
||||
moderation ModerationRetentionStore
|
||||
orphanAuthKeys OrphanAuthKeyRetentionStore
|
||||
activeAuthKeys ActiveRawAuthKeyProvider
|
||||
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
botAPIRetention time.Duration
|
||||
orphanRetention time.Duration
|
||||
clientTelemetryRetention time.Duration
|
||||
authDeliveryReportRetention time.Duration
|
||||
outboxPoisonRetention time.Duration
|
||||
outboxPoisonInterval time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
|
||||
|
|
@ -191,6 +209,29 @@ func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionL
|
|||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) WithClientTelemetryRetention(store ClientTelemetryRetentionStore, retention time.Duration) *RetentionWorker {
|
||||
if retention <= 0 {
|
||||
retention = 30 * 24 * time.Hour
|
||||
}
|
||||
w.clientTelemetry = store
|
||||
w.clientTelemetryRetention = retention
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) WithAuthDeliveryReportRetention(store AuthDeliveryReportRetentionStore, retention time.Duration) *RetentionWorker {
|
||||
if retention <= 0 {
|
||||
retention = 30 * 24 * time.Hour
|
||||
}
|
||||
w.authDeliveryReports = store
|
||||
w.authDeliveryReportRetention = retention
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) WithModerationRetention(store ModerationRetentionStore) *RetentionWorker {
|
||||
w.moderation = store
|
||||
return w
|
||||
}
|
||||
|
||||
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key,
|
||||
// 不能提供 temp→perm business key;否则未登录或 PFS 连接会被误判为 orphan。
|
||||
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
|
||||
|
|
@ -275,6 +316,45 @@ func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
|
|||
w.logger.Info("expired login-code delivery receipt cleanup complete", zap.Int("deleted", deleted))
|
||||
}
|
||||
}
|
||||
if w.clientTelemetry != nil {
|
||||
deleted, err := w.clientTelemetry.DeleteExpiredClientTelemetry(
|
||||
ctx, time.Now().Add(-w.clientTelemetryRetention), w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期客户端 telemetry 失败", zap.Error(err))
|
||||
} else if deleted > 0 {
|
||||
w.logger.Info("回收过期客户端 telemetry 完成", zap.Int("deleted", deleted))
|
||||
}
|
||||
}
|
||||
if w.authDeliveryReports != nil {
|
||||
deleted, err := w.authDeliveryReports.DeleteExpiredAuthDeliveryReports(
|
||||
ctx, time.Now().Add(-w.authDeliveryReportRetention), w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期验证码投递诊断失败", zap.Error(err))
|
||||
} else if deleted > 0 {
|
||||
w.logger.Info("回收过期验证码投递诊断完成", zap.Int("deleted", deleted))
|
||||
}
|
||||
}
|
||||
if w.moderation != nil {
|
||||
now := time.Now()
|
||||
impressions, err := w.moderation.DeleteExpiredSponsoredMessageImpressions(
|
||||
ctx, now, w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期 sponsored impression 失败", zap.Error(err))
|
||||
} else if impressions > 0 {
|
||||
w.logger.Info("回收过期 sponsored impression 完成", zap.Int("deleted", impressions))
|
||||
}
|
||||
links, err := w.moderation.DeleteExpiredModerationAppealLinks(
|
||||
ctx, now, w.batch,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期审核申诉链接失败", zap.Error(err))
|
||||
} else if links > 0 {
|
||||
w.logger.Info("回收过期审核申诉链接完成", zap.Int("deleted", links))
|
||||
}
|
||||
}
|
||||
if w.tempKeys != nil {
|
||||
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
|
||||
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ func TestRetentionWorkerUsesIndependentOutboxPoisonPolicyAndSignalsRelease(t *te
|
|||
if outbox.calls != 1 || outbox.olderThan != 2*time.Minute || outbox.limit != 73 {
|
||||
t.Fatalf("outbox poison calls/args = %d/%v/%d, want 1/2m/73", outbox.calls, outbox.olderThan, outbox.limit)
|
||||
}
|
||||
entries := logs.FilterMessage("terminal failed dispatch_outbox 已结束隔离并释放用户 lane").All()
|
||||
entries := logs.FilterMessage("terminal-failed dispatch_outbox rows released from quarantine and unfroze their user lane").All()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("poison release error signals = %d, want 1", len(entries))
|
||||
}
|
||||
|
|
@ -131,6 +131,86 @@ func TestRetentionWorkerReclaimsExpiredLoginCodeDeliveryReceipts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
type fakeReportRetention struct {
|
||||
telemetryBefore time.Time
|
||||
authBefore time.Time
|
||||
sponsoredBefore time.Time
|
||||
appealBefore time.Time
|
||||
telemetryCalls int
|
||||
authCalls int
|
||||
sponsoredCalls int
|
||||
appealCalls int
|
||||
limit int
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredClientTelemetry(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.telemetryCalls++
|
||||
f.telemetryBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredAuthDeliveryReports(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.authCalls++
|
||||
f.authBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredSponsoredMessageImpressions(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.sponsoredCalls++
|
||||
f.sponsoredBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeReportRetention) DeleteExpiredModerationAppealLinks(_ context.Context, before time.Time, limit int) (int, error) {
|
||||
f.appealCalls++
|
||||
f.appealBefore = before
|
||||
f.limit = limit
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func TestRetentionWorkerSeparatesTelemetryDiagnosticsAndModerationCapabilities(t *testing.T) {
|
||||
const (
|
||||
telemetryTTL = 7 * 24 * time.Hour
|
||||
authTTL = 14 * 24 * time.Hour
|
||||
batch = 47
|
||||
)
|
||||
store := &fakeReportRetention{}
|
||||
w := NewRetentionWorker(
|
||||
&fakeOutboxRetention{}, nil, zap.NewNop(),
|
||||
168*time.Hour, time.Hour, batch,
|
||||
).WithClientTelemetryRetention(store, telemetryTTL).
|
||||
WithAuthDeliveryReportRetention(store, authTTL).
|
||||
WithModerationRetention(store)
|
||||
before := time.Now()
|
||||
w.runRetentionOnce(context.Background())
|
||||
after := time.Now()
|
||||
if store.telemetryCalls != 1 || store.authCalls != 1 ||
|
||||
store.sponsoredCalls != 1 || store.appealCalls != 1 ||
|
||||
store.limit != batch {
|
||||
t.Fatalf("calls telemetry/auth/sponsored/appeal=%d/%d/%d/%d limit=%d",
|
||||
store.telemetryCalls, store.authCalls,
|
||||
store.sponsoredCalls, store.appealCalls, store.limit)
|
||||
}
|
||||
if store.telemetryBefore.Before(before.Add(-telemetryTTL)) ||
|
||||
store.telemetryBefore.After(after.Add(-telemetryTTL)) {
|
||||
t.Fatalf("telemetry boundary=%v", store.telemetryBefore)
|
||||
}
|
||||
if store.authBefore.Before(before.Add(-authTTL)) ||
|
||||
store.authBefore.After(after.Add(-authTTL)) {
|
||||
t.Fatalf("auth boundary=%v", store.authBefore)
|
||||
}
|
||||
if store.sponsoredBefore.Before(before) ||
|
||||
store.sponsoredBefore.After(after) ||
|
||||
store.appealBefore.Before(before) ||
|
||||
store.appealBefore.After(after) {
|
||||
t.Fatalf("moderation capability boundaries sponsored=%v appeal=%v",
|
||||
store.sponsoredBefore, store.appealBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
|
||||
f.calls++
|
||||
f.confirmedGrace = confirmedGrace
|
||||
|
|
@ -308,7 +388,7 @@ func TestRetentionWorkerSkipsOrphanDeleteWhenHeartbeatFails(t *testing.T) {
|
|||
if store.heartbeatCalls != 1 || store.calls != 0 {
|
||||
t.Fatalf("heartbeat/delete calls = %d/%d, want 1/0", store.heartbeatCalls, store.calls)
|
||||
}
|
||||
entries := logs.FilterMessage("刷新 active raw auth key heartbeat 失败,本轮跳过 orphan GC").All()
|
||||
entries := logs.FilterMessage("refreshing active raw auth key heartbeat failed, skipping orphan GC this round").All()
|
||||
if len(entries) != 1 || entries[0].ContextMap()["signal"] != "auth_key_heartbeat_failed" {
|
||||
t.Fatalf("heartbeat failure signals = %+v", entries)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,42 @@ func (s *Service) SetChatTheme(ctx context.Context, userID int64, req domain.Set
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// GetPrivateNoForwards returns the canonical content-protection state for one
|
||||
// ordinary private chat.
|
||||
func (s *Service) GetPrivateNoForwards(ctx context.Context, userID, peerUserID int64) (domain.PrivateNoForwardsState, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 || peerUserID == 0 || userID == peerUserID {
|
||||
return domain.PrivateNoForwardsState{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
backend, ok := s.messages.(store.PrivateNoForwardsStore)
|
||||
if !ok {
|
||||
return domain.PrivateNoForwardsState{}, nil
|
||||
}
|
||||
return backend.GetPrivateNoForwards(ctx, userID, peerUserID)
|
||||
}
|
||||
|
||||
// TogglePrivateNoForwards atomically mutates the pair state and appends the
|
||||
// corresponding service message when the official state machine requires one.
|
||||
func (s *Service) TogglePrivateNoForwards(ctx context.Context, userID int64, req domain.TogglePrivateNoForwardsRequest) (domain.TogglePrivateNoForwardsResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.ActorUserID == 0 {
|
||||
req.ActorUserID = userID
|
||||
}
|
||||
if req.ActorUserID != userID || req.PeerUserID == 0 || req.PeerUserID == userID ||
|
||||
req.RequestMsgID < 0 || req.RequestMsgID > domain.MaxMessageBoxID {
|
||||
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, userID); err != nil {
|
||||
return domain.TogglePrivateNoForwardsResult{}, err
|
||||
}
|
||||
backend, ok := s.messages.(store.PrivateNoForwardsStore)
|
||||
if !ok {
|
||||
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return backend.TogglePrivateNoForwards(ctx, req)
|
||||
}
|
||||
|
||||
func chatThemeServiceMedia(emoticon string) *domain.MessageMedia {
|
||||
return &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
|
|
@ -433,6 +469,31 @@ func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req dom
|
|||
return s.messages.GetMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// SavedReactionTags returns the global or one-sub-dialog Saved Messages tag list.
|
||||
func (s *Service) SavedReactionTags(ctx context.Context, userID int64, savedPeer domain.Peer, limit int) ([]domain.SavedReactionTag, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSavedReactionTags {
|
||||
limit = domain.MaxSavedReactionTags
|
||||
}
|
||||
return s.messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
|
||||
UserID: userID,
|
||||
SavedPeer: savedPeer,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSavedReactionTag stores or removes the optional global title for one
|
||||
// tag that is currently assigned to at least one visible Saved Message.
|
||||
func (s *Service) UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error {
|
||||
if s == nil || s.messages == nil || userID == 0 || !tag.Reaction.Valid() {
|
||||
return domain.ErrReactionInvalid
|
||||
}
|
||||
tag.UserID = userID
|
||||
return s.messages.UpsertSavedReactionTag(ctx, tag)
|
||||
}
|
||||
|
||||
// EditMessage 编辑当前账号发出的私聊文本消息。
|
||||
func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
|
|
|
|||
|
|
@ -682,6 +682,14 @@ func (s projectionMessageStore) GetMessageReactions(context.Context, domain.Priv
|
|||
return domain.PrivateMessageReactionsResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) ListSavedReactionTags(context.Context, domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) UpsertSavedReactionTag(context.Context, domain.SavedReactionTag) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) VoteMessagePoll(context.Context, domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
|
||||
return domain.PrivateMessagePollResult{}, nil
|
||||
}
|
||||
|
|
|
|||
512
internal/app/moderation/actions.go
Normal file
512
internal/app/moderation/actions.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type moderationAdminActions interface {
|
||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
}
|
||||
|
||||
type moderationChannelDeleter interface {
|
||||
ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
|
||||
}
|
||||
|
||||
type moderationChannelDeleteNotifier interface {
|
||||
NotifyModerationChannelDeletion(ctx context.Context, result domain.DeleteChannelMessagesResult)
|
||||
}
|
||||
|
||||
type moderationAccountDeleter interface {
|
||||
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||
}
|
||||
|
||||
type moderationAppealLinkIssuer interface {
|
||||
IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error)
|
||||
}
|
||||
|
||||
type ActionExecutor struct {
|
||||
admin moderationAdminActions
|
||||
channels moderationChannelDeleter
|
||||
channelNotifier moderationChannelDeleteNotifier
|
||||
accounts moderationAccountDeleter
|
||||
appealLinks moderationAppealLinkIssuer
|
||||
publicBaseURL string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ActionExecutorOption func(*ActionExecutor)
|
||||
|
||||
func WithAppealLinks(issuer moderationAppealLinkIssuer, publicBaseURL string) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
executor.appealLinks = issuer
|
||||
executor.publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
|
||||
}
|
||||
}
|
||||
|
||||
func WithActionClock(now func() time.Time) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
if now != nil {
|
||||
executor.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewActionExecutor(adminActions moderationAdminActions, channels moderationChannelDeleter, channelNotifier moderationChannelDeleteNotifier, accounts moderationAccountDeleter, opts ...ActionExecutorOption) *ActionExecutor {
|
||||
executor := &ActionExecutor{
|
||||
admin: adminActions, channels: channels,
|
||||
channelNotifier: channelNotifier, accounts: accounts,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(executor)
|
||||
}
|
||||
}
|
||||
return executor
|
||||
}
|
||||
|
||||
type freezeAccountActionPayload struct {
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
AppealURL string `json:"appeal_url,omitempty"`
|
||||
}
|
||||
|
||||
type deletePrivateMessageActionPayload struct {
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
IDs []int `json:"ids"`
|
||||
Revoke bool `json:"revoke"`
|
||||
}
|
||||
|
||||
type deleteChannelMessageActionPayload struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCaseDetail, action domain.ModerationAction) error {
|
||||
if e == nil || action.CaseID != detail.Case.ID {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
actor, _, ok := decisionAuditContext(detail.Decisions, action.DecisionID)
|
||||
if !ok {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
meta := admin.CommandMeta{
|
||||
CommandID: action.CommandID, Actor: actor,
|
||||
Reason: fmt.Sprintf("moderation case %d decision %d", detail.Case.ID, action.DecisionID),
|
||||
}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, true, false, meta)
|
||||
case domain.ModerationActionMarkFake:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, false, true, meta)
|
||||
case domain.ModerationActionClearPeerFlags:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, false, false, meta)
|
||||
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload freezeAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
frozen := action.Kind == domain.ModerationActionFreezeAccount
|
||||
if !frozen && (!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if frozen {
|
||||
now := e.now().UTC()
|
||||
if payload.Until.IsZero() {
|
||||
payload.Until = now.Add(30 * 24 * time.Hour)
|
||||
}
|
||||
if !payload.Until.After(now) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if payload.AppealURL == "" {
|
||||
if e.appealLinks == nil || e.publicBaseURL == "" {
|
||||
return fmt.Errorf("moderation appeal link issuer is not configured")
|
||||
}
|
||||
linkExpiresAt := payload.Until
|
||||
maxLinkExpiry := now.Add(domain.MaxModerationAppealLinkLifetime)
|
||||
if linkExpiresAt.After(maxLinkExpiry) {
|
||||
linkExpiresAt = maxLinkExpiry
|
||||
}
|
||||
token, err := e.appealLinks.IssueAppealLink(
|
||||
ctx, detail.Case.ID, detail.Case.Target.ID,
|
||||
linkExpiresAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.AppealURL = e.publicBaseURL + "/appeal/" + token
|
||||
}
|
||||
}
|
||||
_, err := e.admin.SetAccountFrozen(ctx, admin.SetAccountFrozenRequest{
|
||||
CommandMeta: meta, UserID: detail.Case.Target.ID, Frozen: frozen,
|
||||
Until: payload.Until, AppealURL: payload.AppealURL,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload deletePrivateMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if payload.OwnerUserID <= 0 || len(payload.IDs) == 0 ||
|
||||
len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
_, err := e.admin.DeletePrivateMessages(ctx, admin.DeletePrivateMessagesRequest{
|
||||
CommandMeta: meta, OwnerUserID: payload.OwnerUserID,
|
||||
Peer: detail.Case.Target, IDs: payload.IDs, Revoke: payload.Revoke,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionDeleteChannelMessage:
|
||||
if e.channels == nil || detail.Case.Target.Type != domain.PeerTypeChannel {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload deleteChannelMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload.IDs) == 0 || len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
result, err := e.channels.ModerationDeleteMessages(
|
||||
ctx, detail.Case.Target.ID, payload.IDs, int(e.now().Unix()),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.channelNotifier != nil {
|
||||
e.channelNotifier.NotifyModerationChannelDeletion(ctx, result)
|
||||
}
|
||||
return nil
|
||||
case domain.ModerationActionDeleteAccount:
|
||||
if e.accounts == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := e.accounts.ExecuteAccountDeletion(
|
||||
ctx, detail.Case.Target.ID, domain.AccountDeletionManual,
|
||||
fmt.Sprintf("moderation case %d", detail.Case.ID), e.now().UTC(),
|
||||
)
|
||||
return err
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) validateDecisionActions(ctx context.Context, detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
|
||||
flagActions := 0
|
||||
freezeActions := 0
|
||||
hasDeleteAccount := false
|
||||
for _, action := range actions {
|
||||
if _, duplicate := seen[action.Kind]; duplicate {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
seen[action.Kind] = struct{}{}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake,
|
||||
domain.ModerationActionClearPeerFlags:
|
||||
flagActions++
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||
freezeActions++
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload freezeAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if action.Kind == domain.ModerationActionUnfreezeAccount &&
|
||||
(!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
var payload deletePrivateMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser ||
|
||||
payload.OwnerUserID <= 0 ||
|
||||
!validModerationMessageIDs(payload.IDs) ||
|
||||
!s.privateDeletionCoveredByEvidence(ctx, detail, payload) {
|
||||
return domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
case domain.ModerationActionDeleteChannelMessage:
|
||||
var payload deleteChannelMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail.Case.Target.Type != domain.PeerTypeChannel ||
|
||||
!validModerationMessageIDs(payload.IDs) ||
|
||||
!s.channelDeletionCoveredByEvidence(ctx, detail, payload.IDs) {
|
||||
return domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
case domain.ModerationActionDeleteAccount:
|
||||
hasDeleteAccount = true
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
if flagActions > 1 || freezeActions > 1 ||
|
||||
(hasDeleteAccount && len(actions) != 1) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) privateDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, payload deletePrivateMessageActionPayload) bool {
|
||||
needed := make(map[int]struct{}, len(payload.IDs))
|
||||
for _, id := range payload.IDs {
|
||||
needed[id] = struct{}{}
|
||||
}
|
||||
for _, reportID := range detail.ReportIDs {
|
||||
report, found, err := s.Report(ctx, reportID)
|
||||
if err != nil || !found || report.ReporterUserID != payload.OwnerUserID {
|
||||
continue
|
||||
}
|
||||
for _, item := range report.Items {
|
||||
if item.Kind == domain.ModerationItemMessage &&
|
||||
item.Peer == detail.Case.Target {
|
||||
delete(needed, int(item.ItemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(needed) == 0
|
||||
}
|
||||
|
||||
func (s *Service) channelDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, ids []int) bool {
|
||||
needed := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
needed[id] = struct{}{}
|
||||
}
|
||||
for _, reportID := range detail.ReportIDs {
|
||||
report, found, err := s.Report(ctx, reportID)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
for _, item := range report.Items {
|
||||
if item.Kind == domain.ModerationItemMessage &&
|
||||
item.Peer == detail.Case.Target {
|
||||
delete(needed, int(item.ItemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(needed) == 0
|
||||
}
|
||||
|
||||
func validModerationMessageIDs(ids []int) bool {
|
||||
if len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *ActionExecutor) setPeerFlags(ctx context.Context, target domain.Peer, scam, fake bool, meta admin.CommandMeta) error {
|
||||
if e.admin == nil {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
switch target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
_, err := e.admin.SetUserFlags(ctx, admin.SetUserFlagsRequest{
|
||||
CommandMeta: meta, UserID: target.ID, Scam: scam, Fake: fake,
|
||||
})
|
||||
return err
|
||||
case domain.PeerTypeChannel:
|
||||
_, err := e.admin.SetChannelFlags(ctx, admin.SetChannelFlagsRequest{
|
||||
CommandMeta: meta, ChannelID: target.ID, Scam: scam, Fake: fake,
|
||||
})
|
||||
return err
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func decisionAuditContext(decisions []domain.ModerationDecision, decisionID int64) (string, string, bool) {
|
||||
for _, decision := range decisions {
|
||||
if decision.ID == decisionID {
|
||||
return decision.Actor, decision.Reason, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func decodeStrictActionPayload(raw json.RawMessage, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ActionWorker struct {
|
||||
store store.ModerationCaseStore
|
||||
executor *ActionExecutor
|
||||
interval time.Duration
|
||||
lease time.Duration
|
||||
batch int
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewActionWorker(caseStore store.ModerationCaseStore, executor *ActionExecutor, log *zap.Logger) *ActionWorker {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &ActionWorker{
|
||||
store: caseStore, executor: executor,
|
||||
interval: time.Second, lease: 30 * time.Second, batch: 20, log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ActionWorker) Run(ctx context.Context) {
|
||||
if w == nil || w.store == nil || w.executor == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := w.runOnce(ctx); err != nil && ctx.Err() == nil {
|
||||
w.log.Warn("审核处置任务执行失败", zap.Error(err))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ActionWorker) runOnce(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
actions, err := w.store.ClaimModerationActions(ctx, now, w.batch, w.lease)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, action := range actions {
|
||||
current, currentErr := w.store.IsModerationActionCurrent(ctx, action)
|
||||
if currentErr == nil && !current {
|
||||
if err := w.store.SupersedeModerationAction(
|
||||
ctx, action.ID, action.Attempts, time.Now().UTC(),
|
||||
); err != nil {
|
||||
w.log.Warn("提交已被新案件取代的审核处置失败",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
w.log.Info("审核处置已被同目标的更新处置取代",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
detail, found, getErr := w.store.GetModerationCase(ctx, action.CaseID)
|
||||
execErr := currentErr
|
||||
if execErr == nil {
|
||||
execErr = getErr
|
||||
}
|
||||
if execErr == nil && !found {
|
||||
execErr = domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if execErr == nil {
|
||||
execErr = w.executor.Execute(ctx, detail, action)
|
||||
}
|
||||
finishedAt := time.Now().UTC()
|
||||
retryAt := finishedAt
|
||||
errorText := ""
|
||||
if execErr != nil {
|
||||
errorText = execErr.Error()
|
||||
retryAt = finishedAt.Add(moderationActionRetryDelay(action.Attempts))
|
||||
}
|
||||
if err := w.store.CompleteModerationAction(
|
||||
ctx, action.ID, action.Attempts, execErr == nil,
|
||||
errorText, retryAt, finishedAt,
|
||||
); err != nil {
|
||||
w.log.Warn("提交审核处置结果失败",
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.Int("attempts", action.Attempts),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if execErr != nil {
|
||||
w.log.Warn("审核处置等待重试",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)),
|
||||
zap.Int("attempts", action.Attempts),
|
||||
zap.Error(execErr))
|
||||
} else {
|
||||
w.log.Info("审核处置完成",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func moderationActionRetryDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
delay := time.Second << min(attempt-1, 10)
|
||||
if delay > time.Hour {
|
||||
return time.Hour
|
||||
}
|
||||
return delay
|
||||
}
|
||||
325
internal/app/moderation/actions_test.go
Normal file
325
internal/app/moderation/actions_test.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type captureModerationAdmin struct {
|
||||
userFlags []admin.SetUserFlagsRequest
|
||||
frozen []admin.SetAccountFrozenRequest
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
a.frozen = append(a.frozen, req)
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||
a.userFlags = append(a.userFlags, req)
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (*captureModerationAdmin) SetChannelFlags(context.Context, admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (*captureModerationAdmin) DeletePrivateMessages(context.Context, admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func TestActionWorkerAppliesFakeFlagAndResolvesCase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Add(-10 * time.Second)
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision, created, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "impersonation confirmed",
|
||||
CommandID: "mod-fake-1", Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(2 * time.Second),
|
||||
})
|
||||
if err != nil || !created || decision.Case.Status != domain.ModerationCaseActionPending {
|
||||
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||
}
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(
|
||||
reports,
|
||||
NewActionExecutor(adminActions, nil, nil, nil),
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.userFlags) != 1 {
|
||||
t.Fatalf("flag actions=%d, want 1", len(adminActions.userFlags))
|
||||
}
|
||||
flag := adminActions.userFlags[0]
|
||||
if flag.UserID != target.ID || flag.Scam || !flag.Fake ||
|
||||
flag.CommandID != "mod-fake-1:000" || flag.Actor != "reviewer" {
|
||||
t.Fatalf("flag request=%+v", flag)
|
||||
}
|
||||
resolved, found, err := service.Case(ctx, claimed.ID)
|
||||
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved ||
|
||||
resolved.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWorkerSupersedesOlderTargetSanction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
createDecision := func(reporter int64, command string, kind domain.ModerationActionKind, at time.Time) int64 {
|
||||
t.Helper()
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: command,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||
Target: target, Limit: 10,
|
||||
})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("open cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", at.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{Kind: kind, Payload: []byte(`{}`)}},
|
||||
CreatedAt: at.Add(2 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return claimed.ID
|
||||
}
|
||||
oldCaseID := createDecision(101, "old-scam", domain.ModerationActionMarkScam, now)
|
||||
newCaseID := createDecision(102, "new-fake", domain.ModerationActionMarkFake, now.Add(3*time.Second))
|
||||
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(
|
||||
reports,
|
||||
NewActionExecutor(adminActions, nil, nil, nil),
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.userFlags) != 1 || adminActions.userFlags[0].Scam ||
|
||||
!adminActions.userFlags[0].Fake {
|
||||
t.Fatalf("flag actions=%+v", adminActions.userFlags)
|
||||
}
|
||||
oldDetail, _, err := service.Case(ctx, oldCaseID)
|
||||
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||
len(oldDetail.Actions) != 1 ||
|
||||
oldDetail.Actions[0].Status != domain.ModerationActionSuperseded {
|
||||
t.Fatalf("old detail=%+v err=%v", oldDetail, err)
|
||||
}
|
||||
newDetail, _, err := service.Case(ctx, newCaseID)
|
||||
if err != nil || newDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||
len(newDetail.Actions) != 1 ||
|
||||
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||
t.Fatalf("new detail=%+v err=%v", newDetail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppealCannotClearSanctionOwnedByNewerCase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
createCase := func(reporter int64, option, command string, at time.Time) domain.ModerationCase {
|
||||
t.Helper()
|
||||
if _, _, err := service.AcceptReport(ctx, 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,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||
Target: target, Limit: 10,
|
||||
})
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("open cases=%+v err=%v", items, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, items[0].ID, items[0].Version, "reviewer", at.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: at.Add(2 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, _, err := service.Case(ctx, claimed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return detail.Case
|
||||
}
|
||||
oldCase := createCase(101, "old", "old", now)
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(reports, NewActionExecutor(adminActions, nil, nil, nil), zap.NewNop())
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appeal, _, err := service.SubmitAppeal(ctx, oldCase.ID, target.ID, "mistake", now.Add(3*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCase := createCase(102, "new", "new", now.Add(4*time.Second))
|
||||
if newCase.ID == oldCase.ID {
|
||||
t.Fatal("new report reused decided case")
|
||||
}
|
||||
oldDetail, _, err := service.Case(ctx, oldCase.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, oldCase.ID, oldDetail.Case.Version, "reviewer", now.Add(8*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: oldCase.ID, AppealID: appeal.ID,
|
||||
ExpectedVersion: claimed.Version, Actor: "reviewer",
|
||||
Reason: "grant", CommandID: "stale-appeal",
|
||||
Kind: domain.ModerationDecisionAppealGrant,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(9 * time.Second),
|
||||
})
|
||||
if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
t.Fatalf("ReviewAppeal error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type captureAppealLinkIssuer struct {
|
||||
caseID int64
|
||||
appellantID int64
|
||||
expiresAt time.Time
|
||||
issuedAt time.Time
|
||||
returnedToken string
|
||||
}
|
||||
|
||||
func (i *captureAppealLinkIssuer) IssueAppealLink(_ context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||
i.caseID = caseID
|
||||
i.appellantID = appellantUserID
|
||||
i.expiresAt = expiresAt
|
||||
i.issuedAt = now
|
||||
return i.returnedToken, nil
|
||||
}
|
||||
|
||||
func TestActionExecutorFreezeDefaultsAndBoundsAppealLink(t *testing.T) {
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
adminActions := &captureModerationAdmin{}
|
||||
issuer := &captureAppealLinkIssuer{returnedToken: "token"}
|
||||
executor := NewActionExecutor(
|
||||
adminActions, nil, nil, nil,
|
||||
WithActionClock(func() time.Time { return now }),
|
||||
WithAppealLinks(issuer, "https://example.test/"),
|
||||
)
|
||||
detail := domain.ModerationCaseDetail{
|
||||
Case: domain.ModerationCase{
|
||||
ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20},
|
||||
},
|
||||
Decisions: []domain.ModerationDecision{{
|
||||
ID: 30, Actor: "reviewer",
|
||||
}},
|
||||
}
|
||||
action := domain.ModerationAction{
|
||||
CaseID: 10, DecisionID: 30,
|
||||
Kind: domain.ModerationActionFreezeAccount,
|
||||
Payload: []byte(`{}`), CommandID: "freeze:000",
|
||||
}
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.frozen) != 1 {
|
||||
t.Fatalf("freeze calls=%d", len(adminActions.frozen))
|
||||
}
|
||||
req := adminActions.frozen[0]
|
||||
wantUntil := now.Add(30 * 24 * time.Hour)
|
||||
if !req.Frozen || req.UserID != 20 || !req.Until.Equal(wantUntil) ||
|
||||
req.AppealURL != "https://example.test/appeal/token" {
|
||||
t.Fatalf("freeze request=%+v", req)
|
||||
}
|
||||
if issuer.caseID != 10 || issuer.appellantID != 20 ||
|
||||
!issuer.expiresAt.Equal(wantUntil) || !issuer.issuedAt.Equal(now) {
|
||||
t.Fatalf("appeal issue=%+v", issuer)
|
||||
}
|
||||
|
||||
adminActions.frozen = nil
|
||||
longUntil := now.Add(365 * 24 * time.Hour)
|
||||
action.Payload = []byte(`{"until":"` + longUntil.Format(time.RFC3339Nano) + `"}`)
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !adminActions.frozen[0].Until.Equal(longUntil) {
|
||||
t.Fatalf("long freeze until=%v", adminActions.frozen[0].Until)
|
||||
}
|
||||
if want := now.Add(domain.MaxModerationAppealLinkLifetime); !issuer.expiresAt.Equal(want) {
|
||||
t.Fatalf("link expiry=%v want=%v", issuer.expiresAt, want)
|
||||
}
|
||||
}
|
||||
83
internal/app/moderation/appeal_links.go
Normal file
83
internal/app/moderation/appeal_links.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const moderationAppealTokenBytes = 32
|
||||
|
||||
// IssueAppealLink creates a hash-only, time-bounded bearer capability. The raw
|
||||
// token is returned once and must only be embedded in the affected user's
|
||||
// client-visible appeal URL.
|
||||
func (s *Service) IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return "", fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
raw := make([]byte, moderationAppealTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate moderation appeal token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
link := domain.ModerationAppealLink{
|
||||
CaseID: caseID, AppellantUserID: appellantUserID,
|
||||
TokenHash: sha256.Sum256(raw), ExpiresAt: expiresAt.UTC(),
|
||||
CreatedAt: now.UTC(),
|
||||
}
|
||||
if _, err := s.cases.IssueModerationAppealLink(ctx, link); err == nil {
|
||||
return token, nil
|
||||
} else if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", domain.ErrModerationActionConflict
|
||||
}
|
||||
|
||||
func (s *Service) ResolveAppealLink(ctx context.Context, token string, now time.Time) (domain.ModerationAppealLink, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppealLink{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
hash, err := moderationAppealTokenHash(token)
|
||||
if err != nil {
|
||||
return domain.ModerationAppealLink{}, false, err
|
||||
}
|
||||
return s.cases.GetModerationAppealLink(ctx, hash, now.UTC())
|
||||
}
|
||||
|
||||
func (s *Service) Appeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.GetModerationAppeal(ctx, appealID)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitAppealLink(ctx context.Context, token, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
hash, err := moderationAppealTokenHash(token)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
return s.cases.SubmitModerationAppealByLink(ctx, hash, text, now.UTC())
|
||||
}
|
||||
|
||||
func moderationAppealTokenHash(token string) ([sha256.Size]byte, error) {
|
||||
if len(token) != base64.RawURLEncoding.EncodedLen(moderationAppealTokenBytes) {
|
||||
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil || len(raw) != moderationAppealTokenBytes ||
|
||||
base64.RawURLEncoding.EncodeToString(raw) != token {
|
||||
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
178
internal/app/moderation/appeal_links_test.go
Normal file
178
internal/app/moderation/appeal_links_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAppealLinkSubmissionIsHashOnlyIdempotentAndExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(
|
||||
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: "appeal-link-decision",
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(2 * time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(24*time.Hour), now.Add(3*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) != 43 {
|
||||
t.Fatalf("token length=%d", len(token))
|
||||
}
|
||||
link, found, err := service.ResolveAppealLink(ctx, token, now.Add(4*time.Second))
|
||||
if err != nil || !found || link.TokenHash == ([32]byte{}) {
|
||||
t.Fatalf("link=%+v found=%v err=%v", link, found, err)
|
||||
}
|
||||
expiredToken, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(10*time.Second), now.Add(4*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < domain.MaxModerationAppealLinksPerCase-2; i++ {
|
||||
issuedAt := now.Add(time.Duration(20+i) * time.Second)
|
||||
if _, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(time.Hour), issuedAt,
|
||||
); err != nil {
|
||||
t.Fatalf("issue bounded link %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(time.Hour), now.Add(time.Minute),
|
||||
); !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
t.Fatalf("appeal link overflow err=%v", err)
|
||||
}
|
||||
if actions, err := store.ClaimModerationActions(
|
||||
ctx, now.Add(5*time.Second), 10, time.Minute,
|
||||
); err != nil || len(actions) != 1 {
|
||||
t.Fatalf("actions=%+v err=%v", actions, err)
|
||||
} else if err := store.CompleteModerationAction(
|
||||
ctx, actions[0].ID, actions[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(6*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appeal, created, err := service.SubmitAppealLink(
|
||||
ctx, token, "The account was impersonated.", now.Add(7*time.Second),
|
||||
)
|
||||
if err != nil || !created || appeal.CaseID != claimed.ID ||
|
||||
appeal.AppellantUserID != target.ID {
|
||||
t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err)
|
||||
}
|
||||
retry, created, err := service.SubmitAppealLink(
|
||||
ctx, token, "A different retry body must not create another appeal.",
|
||||
now.Add(8*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != appeal.ID || retry.Text != appeal.Text {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
appealed, found, err := service.Case(ctx, detail.Case.ID)
|
||||
if err != nil || !found ||
|
||||
appealed.Case.Status != domain.ModerationCaseAppealReview ||
|
||||
len(appealed.Appeals) != 1 {
|
||||
t.Fatalf("appealed=%+v found=%v err=%v", appealed, found, err)
|
||||
}
|
||||
appealClaim, err := service.ClaimCase(
|
||||
ctx, appealed.Case.ID, appealed.Case.Version,
|
||||
"appeal-reviewer", now.Add(8*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := domain.ModerationDecisionRequest{
|
||||
CaseID: appealClaim.ID, AppealID: appeal.ID,
|
||||
ExpectedVersion: appealClaim.Version, Actor: "appeal-reviewer",
|
||||
Reason: "original evidence was insufficient",
|
||||
CommandID: "appeal-grant-without-remedy",
|
||||
Kind: domain.ModerationDecisionAppealGrant,
|
||||
CreatedAt: now.Add(9 * time.Second),
|
||||
}
|
||||
if _, _, err := service.ReviewAppeal(ctx, grant); !errors.Is(
|
||||
err, domain.ErrModerationActionInvalid,
|
||||
) {
|
||||
t.Fatalf("grant without required flag remedy err=%v", err)
|
||||
}
|
||||
grant.CommandID = "appeal-grant-with-remedy"
|
||||
grant.Actions = []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||
}}
|
||||
granted, created, err := service.ReviewAppeal(ctx, grant)
|
||||
if err != nil || !created ||
|
||||
granted.Case.Status != domain.ModerationCaseActionPending {
|
||||
t.Fatalf("granted=%+v created=%v err=%v", granted, created, err)
|
||||
}
|
||||
remedies, err := store.ClaimModerationActions(
|
||||
ctx, now.Add(10*time.Second), 10, time.Minute,
|
||||
)
|
||||
if err != nil || len(remedies) != 1 ||
|
||||
remedies[0].Kind != domain.ModerationActionClearPeerFlags {
|
||||
t.Fatalf("remedies=%+v err=%v", remedies, err)
|
||||
}
|
||||
if err := store.CompleteModerationAction(
|
||||
ctx, remedies[0].ID, remedies[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(11*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dismissed, found, err := service.Case(ctx, appealed.Case.ID)
|
||||
if err != nil || !found ||
|
||||
dismissed.Case.Status != domain.ModerationCaseDismissed {
|
||||
t.Fatalf("dismissed=%+v found=%v err=%v", dismissed, found, err)
|
||||
}
|
||||
|
||||
if _, found, err := service.ResolveAppealLink(
|
||||
ctx, expiredToken, now.Add(10*time.Second),
|
||||
); err != nil || found {
|
||||
t.Fatalf("expired resolve found=%v err=%v", found, err)
|
||||
}
|
||||
if _, _, err := service.SubmitAppealLink(
|
||||
ctx, expiredToken, "too late", now.Add(10*time.Second),
|
||||
); !errors.Is(err, domain.ErrModerationAppealLinkInvalid) {
|
||||
t.Fatalf("expired submit err=%v", err)
|
||||
}
|
||||
if _, _, err := service.ResolveAppealLink(ctx, "not-a-token", now); !errors.Is(
|
||||
err, domain.ErrModerationAppealLinkInvalid,
|
||||
) {
|
||||
t.Fatalf("invalid token err=%v", err)
|
||||
}
|
||||
}
|
||||
180
internal/app/moderation/cases.go
Normal file
180
internal/app/moderation/cases.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Service) ListCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return nil, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.ListModerationCases(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) Case(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.GetModerationCase(ctx, caseID)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCase{}, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.ClaimModerationCase(ctx, caseID, expectedVersion, actor, now)
|
||||
}
|
||||
|
||||
func (s *Service) DecideCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
return s.cases.DecideModerationCase(ctx, prepared)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitAppeal(ctx context.Context, caseID, appellantUserID int64, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, caseID)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
switch detail.Case.Target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if detail.Case.Target.ID != appellantUserID {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
view, err := s.channels.ResolveChannel(ctx, appellantUserID, detail.Case.Target.ID)
|
||||
if err != nil || view.Forbidden ||
|
||||
(view.Self.Role != domain.ChannelRoleCreator &&
|
||||
view.Self.Role != domain.ChannelRoleAdmin) {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
default:
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
|
||||
}
|
||||
appeal, err := domain.NewModerationAppeal(
|
||||
caseID, appellantUserID, detail.Case.Status, text, now,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
return s.cases.CreateModerationAppeal(ctx, appeal)
|
||||
}
|
||||
|
||||
func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if prepared.AppealID <= 0 ||
|
||||
(prepared.Kind != domain.ModerationDecisionAppealGrant &&
|
||||
prepared.Kind != domain.ModerationDecisionAppealDeny) {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
appealFound := false
|
||||
for _, appeal := range detail.Appeals {
|
||||
if appeal.ID == prepared.AppealID &&
|
||||
appeal.Status == domain.ModerationAppealPending {
|
||||
appealFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !appealFound {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if prepared.Kind == domain.ModerationDecisionAppealGrant {
|
||||
if err := validateAppealRemedyActions(detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
}
|
||||
return s.cases.ReviewModerationAppeal(ctx, prepared)
|
||||
}
|
||||
|
||||
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||
history := append([]domain.ModerationAction(nil), detail.Actions...)
|
||||
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
|
||||
var flagsActive, freezeActive, irreversible bool
|
||||
for _, action := range history {
|
||||
if action.Status != domain.ModerationActionSucceeded {
|
||||
continue
|
||||
}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake:
|
||||
flagsActive = true
|
||||
case domain.ModerationActionClearPeerFlags:
|
||||
flagsActive = false
|
||||
case domain.ModerationActionFreezeAccount:
|
||||
freezeActive = true
|
||||
case domain.ModerationActionUnfreezeAccount:
|
||||
freezeActive = false
|
||||
case domain.ModerationActionDeletePrivateMessage,
|
||||
domain.ModerationActionDeleteChannelMessage,
|
||||
domain.ModerationActionDeleteAccount:
|
||||
irreversible = true
|
||||
}
|
||||
}
|
||||
if irreversible {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
expected := make(map[domain.ModerationActionKind]bool, 2)
|
||||
if flagsActive {
|
||||
expected[domain.ModerationActionClearPeerFlags] = true
|
||||
}
|
||||
if freezeActive {
|
||||
expected[domain.ModerationActionUnfreezeAccount] = true
|
||||
}
|
||||
if len(actions) != len(expected) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !expected[action.Kind] {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
delete(expected, action.Kind)
|
||||
}
|
||||
if len(expected) != 0 {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
43
internal/app/moderation/cases_test.go
Normal file
43
internal/app/moderation/cases_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestValidateAppealRemedyActionsMatchesOnlyAppliedReversibleState(t *testing.T) {
|
||||
detail := domain.ModerationCaseDetail{Actions: []domain.ModerationAction{
|
||||
{ID: 2, Kind: domain.ModerationActionFreezeAccount, Status: domain.ModerationActionSucceeded},
|
||||
{ID: 1, Kind: domain.ModerationActionMarkScam, Status: domain.ModerationActionSucceeded},
|
||||
{ID: 3, Kind: domain.ModerationActionDeletePrivateMessage, Status: domain.ModerationActionFailed},
|
||||
}}
|
||||
remedies := []domain.ModerationActionDraft{
|
||||
{Kind: domain.ModerationActionClearPeerFlags},
|
||||
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||
}
|
||||
if err := validateAppealRemedyActions(detail, remedies); err != nil {
|
||||
t.Fatalf("valid remedies err=%v", err)
|
||||
}
|
||||
if err := validateAppealRemedyActions(
|
||||
detail, remedies[:1],
|
||||
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("missing unfreeze err=%v", err)
|
||||
}
|
||||
if err := validateAppealRemedyActions(detail, []domain.ModerationActionDraft{
|
||||
{Kind: domain.ModerationActionMarkFake},
|
||||
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||
}); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("new punishment in appeal err=%v", err)
|
||||
}
|
||||
detail.Actions = append(detail.Actions, domain.ModerationAction{
|
||||
ID: 4, Kind: domain.ModerationActionDeletePrivateMessage,
|
||||
Status: domain.ModerationActionSucceeded,
|
||||
})
|
||||
if err := validateAppealRemedyActions(
|
||||
detail, remedies,
|
||||
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("irreversible grant err=%v", err)
|
||||
}
|
||||
}
|
||||
739
internal/app/moderation/evidence.go
Normal file
739
internal/app/moderation/evidence.go
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type privateMessageReader interface {
|
||||
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
|
||||
GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
|
||||
}
|
||||
|
||||
type channelMessageReader interface {
|
||||
GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
|
||||
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
|
||||
}
|
||||
|
||||
type storyReader interface {
|
||||
GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error)
|
||||
}
|
||||
|
||||
type userReader interface {
|
||||
ByID(ctx context.Context, viewerUserID, userID int64) (domain.User, bool, error)
|
||||
}
|
||||
|
||||
type channelPeerReader interface {
|
||||
ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
|
||||
}
|
||||
|
||||
type profilePhotoReader interface {
|
||||
GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error)
|
||||
}
|
||||
|
||||
func (s *Service) ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error) {
|
||||
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
|
||||
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||
holds := make([]domain.ModerationMediaHold, 0)
|
||||
switch req.Target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if s == nil || s.privateMessages == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation private message reader is not configured")
|
||||
}
|
||||
list, err := s.privateMessages.GetMessages(ctx, req.ReporterUserID, ids)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, message := range list.Messages {
|
||||
if message.Peer == req.Target {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
message, found := byID[id]
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := json.Marshal(privateMessageEvidence(message))
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal private message evidence: %w", err)
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemMessage, Peer: req.Target,
|
||||
ItemID: int64(message.ID), AuthorUserID: message.From.ID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if s == nil || s.channelMessages == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel message reader is not configured")
|
||||
}
|
||||
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.Target.ID, ids)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||
for _, message := range history.Messages {
|
||||
if message.ChannelID == req.Target.ID && !message.Deleted {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
message, found := byID[id]
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := marshalChannelMessageEvidence(message)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal channel message evidence: %w", err)
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemMessage, Peer: req.Target,
|
||||
ItemID: int64(message.ID), AuthorUserID: message.SenderUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||
}
|
||||
default:
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID,
|
||||
Source: domain.ModerationSourceMessages,
|
||||
Target: req.Target,
|
||||
Reason: req.Reason,
|
||||
Option: req.Option,
|
||||
Comment: req.Comment,
|
||||
Items: items,
|
||||
MediaHolds: dedupeMediaHolds(holds),
|
||||
CreatedAt: req.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||
if source != domain.ModerationSourceAccountPeer && source != domain.ModerationSourceMessagesSpam {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
snapshot := peerEvidenceV1{SchemaVersion: 1, Target: target}
|
||||
switch target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if s == nil || s.users == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation user reader is not configured")
|
||||
}
|
||||
user, found, err := s.users.ByID(ctx, reporterUserID, target.ID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found || user.Deleted {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
snapshot.User = &peerUserEvidenceV1{
|
||||
ID: user.ID, FirstName: user.FirstName, LastName: user.LastName,
|
||||
Username: user.Username, About: user.About, Bot: user.Bot,
|
||||
Verified: user.Verified, Scam: user.Scam, Fake: user.Fake,
|
||||
PhotoID: user.PhotoID,
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if s == nil || s.channels == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel reader is not configured")
|
||||
}
|
||||
view, err := s.channels.ResolveChannel(ctx, reporterUserID, target.ID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
channel := view.Channel
|
||||
snapshot.Channel = &peerChannelEvidenceV1{
|
||||
ID: channel.ID, Title: channel.Title, About: channel.About,
|
||||
Username: channel.Username, Broadcast: channel.Broadcast,
|
||||
Megagroup: channel.Megagroup, Verified: channel.Verified,
|
||||
Scam: channel.Scam, Fake: channel.Fake, PhotoID: channel.PhotoID,
|
||||
}
|
||||
default:
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
evidence, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal peer evidence: %w", err)
|
||||
}
|
||||
authorUserID := int64(0)
|
||||
if target.Type == domain.PeerTypeUser {
|
||||
authorUserID = target.ID
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID, Source: source, Target: target,
|
||||
Reason: reason, Option: option, Comment: comment,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
|
||||
Evidence: evidence,
|
||||
}},
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error) {
|
||||
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.PhotoID <= 0 ||
|
||||
!req.Reason.Valid() || s == nil || s.photos == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
photos, _, err := s.photos.GetProfilePhotos(ctx, req.Target.Type, req.Target.ID, -1, 1, req.PhotoID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if len(photos) != 1 || photos[0].ID != req.PhotoID ||
|
||||
photos[0].AccessHash != req.AccessHash {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
photo := photos[0]
|
||||
if len(req.FileReference) > 0 && len(photo.FileReference) > 0 &&
|
||||
!bytes.Equal(req.FileReference, photo.FileReference) {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := json.Marshal(profilePhotoEvidenceV1{
|
||||
SchemaVersion: 1, Owner: req.Target, Photo: photo,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal profile photo evidence: %w", err)
|
||||
}
|
||||
authorUserID := int64(0)
|
||||
if req.Target.Type == domain.PeerTypeUser {
|
||||
authorUserID = req.Target.ID
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceProfilePhoto,
|
||||
Target: req.Target, Reason: req.Reason, Option: string(req.Reason),
|
||||
Comment: req.Comment, CreatedAt: req.CreatedAt,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemProfilePhoto, Peer: req.Target,
|
||||
ItemID: req.PhotoID, AuthorUserID: authorUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
}},
|
||||
MediaHolds: photoHolds(0, photo),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error) {
|
||||
if req.ReporterUserID <= 0 || req.ChannelID <= 0 || req.ParticipantUserID <= 0 ||
|
||||
s == nil || s.channelMessages == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.ChannelID, ids)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||
for _, message := range history.Messages {
|
||||
if message.ChannelID == req.ChannelID && !message.Deleted {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||
holds := make([]domain.ModerationMediaHold, 0)
|
||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||
for _, id := range ids {
|
||||
message, found := byID[id]
|
||||
if !found || message.SenderUserID != req.ParticipantUserID {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := marshalChannelMessageEvidence(message)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemMessage, Peer: target,
|
||||
ItemID: int64(message.ID), AuthorUserID: req.ParticipantUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceChannelSpam,
|
||||
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||
Items: items, MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error) {
|
||||
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
var evidence []byte
|
||||
var err error
|
||||
switch req.Target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if s == nil || s.privateMessages == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
result, err := s.privateMessages.GetMessageReactions(ctx, req.ReporterUserID, domain.PrivateMessageReactionsRequest{
|
||||
OwnerUserID: req.ReporterUserID, Peer: req.Target, IDs: []int{req.MessageID},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if len(result.Messages) != 1 || result.Messages[0].ID != req.MessageID ||
|
||||
result.Messages[0].Peer != req.Target {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
reactions := reactionRowsForUser(result.Messages[0].Reactions, req.ReactorUserID)
|
||||
if len(reactions) == 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err = json.Marshal(privateReactionEvidenceV1{
|
||||
SchemaVersion: 1, Message: privateMessageEvidence(result.Messages[0]),
|
||||
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(reactions),
|
||||
})
|
||||
case domain.PeerTypeChannel:
|
||||
if s == nil || s.channelMessages == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
lookup, found, lookupErr := s.channelMessages.FindMessageReaction(ctx, req.ReporterUserID, domain.ChannelMessageReactionLookupRequest{
|
||||
ViewerUserID: req.ReporterUserID, ChannelID: req.Target.ID,
|
||||
MessageID: req.MessageID, ReactorUserID: req.ReactorUserID,
|
||||
})
|
||||
if lookupErr != nil {
|
||||
return domain.ModerationReport{}, false, lookupErr
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err = json.Marshal(channelReactionEvidenceV1{
|
||||
SchemaVersion: 1, Message: channelMessageEvidence(lookup.Message),
|
||||
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(lookup.Reactions),
|
||||
})
|
||||
default:
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal reaction evidence: %w", err)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceReaction,
|
||||
Target: req.Target, Reason: domain.ModerationReasonOther,
|
||||
Option: "reaction", CreatedAt: req.CreatedAt,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemReaction, Peer: req.Target,
|
||||
ItemID: int64(req.MessageID), SecondaryID: req.ReactorUserID,
|
||||
AuthorUserID: req.ReactorUserID, EvidenceSchemaVersion: 1,
|
||||
Evidence: evidence,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||
if reporterUserID <= 0 || !chat.HasParticipant(reporterUserID) || chat.ID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
offenderUserID := chat.PeerOf(reporterUserID)
|
||||
if offenderUserID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: offenderUserID}
|
||||
evidence, err := json.Marshal(encryptedChatEvidenceV1{
|
||||
SchemaVersion: 1, ChatID: chat.ID, State: chat.State,
|
||||
AdminUserID: chat.AdminUserID, ParticipantUserID: chat.ParticipantUserID,
|
||||
Date: chat.Date,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal encrypted chat evidence: %w", err)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEncryptedSpam,
|
||||
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||
CreatedAt: createdAt,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemEncryptedChat, Peer: target,
|
||||
ItemID: int64(chat.ID), AuthorUserID: offenderUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error) {
|
||||
ids, err := canonicalPositiveIDs(req.StoryIDs, domain.MaxStoryID)
|
||||
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation story reader is not configured")
|
||||
}
|
||||
list, err := s.stories.GetStoriesByID(ctx, req.ReporterUserID, req.Target, ids, int(req.CreatedAt.Unix()))
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
byID := make(map[int]domain.Story, len(list.Stories))
|
||||
for _, story := range list.Stories {
|
||||
if story.Owner == req.Target && !story.Deleted {
|
||||
byID[story.ID] = story
|
||||
}
|
||||
}
|
||||
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||
holds := make([]domain.ModerationMediaHold, 0)
|
||||
for _, id := range ids {
|
||||
story, found := byID[id]
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
evidence, err := json.Marshal(storyEvidenceV1{
|
||||
SchemaVersion: 1, Owner: story.Owner, StoryID: story.ID,
|
||||
Date: story.Date, ExpireDate: story.ExpireDate, Pinned: story.Pinned,
|
||||
Public: story.Public, CloseFriends: story.CloseFriends,
|
||||
Contacts: story.Contacts, SelectedContacts: story.SelectedContacts,
|
||||
NoForwards: story.NoForwards, Edited: story.Edited,
|
||||
Caption: story.Caption, Entities: story.Entities, Media: story.Media,
|
||||
MediaAreas: story.MediaAreas, Forward: story.Forward,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal story evidence: %w", err)
|
||||
}
|
||||
authorUserID := int64(0)
|
||||
if story.Owner.Type == domain.PeerTypeUser {
|
||||
authorUserID = story.Owner.ID
|
||||
}
|
||||
items = append(items, domain.ModerationReportItem{
|
||||
Kind: domain.ModerationItemStory, Peer: story.Owner,
|
||||
ItemID: int64(story.ID), AuthorUserID: authorUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
})
|
||||
holds = append(holds, mediaHolds(len(items)-1, story.Media)...)
|
||||
}
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceStory,
|
||||
Target: req.Target, Reason: req.Reason, Option: req.Option,
|
||||
Comment: req.Comment, Items: items,
|
||||
MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||
legacy := domain.NewEphemeralAbuseReport(reporterUserID, option, comment, target, createdAt)
|
||||
if err := legacy.Validate(); err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
evidence, err := json.Marshal(struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Evidence domain.EphemeralReportEvidence `json:"evidence"`
|
||||
}{SchemaVersion: 1, Evidence: legacy.Evidence})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
|
||||
}
|
||||
holds := mediaHolds(0, target.Content.Media)
|
||||
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEphemeral,
|
||||
Target: target.Peer, Reason: reason, Option: option, Comment: comment,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemEphemeral, Peer: target.Peer,
|
||||
ItemID: int64(target.ID), AuthorUserID: target.SenderUserID,
|
||||
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||
}},
|
||||
MediaHolds: holds, CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
type peerEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Target domain.Peer `json:"target"`
|
||||
User *peerUserEvidenceV1 `json:"user,omitempty"`
|
||||
Channel *peerChannelEvidenceV1 `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
type peerUserEvidenceV1 struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
About string `json:"about"`
|
||||
Bot bool `json:"bot,omitempty"`
|
||||
Verified bool `json:"verified,omitempty"`
|
||||
Scam bool `json:"scam,omitempty"`
|
||||
Fake bool `json:"fake,omitempty"`
|
||||
PhotoID int64 `json:"photo_id,omitempty"`
|
||||
}
|
||||
|
||||
type peerChannelEvidenceV1 struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
About string `json:"about"`
|
||||
Username string `json:"username"`
|
||||
Broadcast bool `json:"broadcast,omitempty"`
|
||||
Megagroup bool `json:"megagroup,omitempty"`
|
||||
Verified bool `json:"verified,omitempty"`
|
||||
Scam bool `json:"scam,omitempty"`
|
||||
Fake bool `json:"fake,omitempty"`
|
||||
PhotoID int64 `json:"photo_id,omitempty"`
|
||||
}
|
||||
|
||||
type profilePhotoEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Owner domain.Peer `json:"owner"`
|
||||
Photo domain.Photo `json:"photo"`
|
||||
}
|
||||
|
||||
type privateReactionEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Message privateMessageEvidenceV1 `json:"message"`
|
||||
ReactorUserID int64 `json:"reactor_user_id"`
|
||||
Reactions []messageReactionEvidenceV1 `json:"reactions"`
|
||||
}
|
||||
|
||||
type channelReactionEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Message channelMessageEvidenceV1 `json:"message"`
|
||||
ReactorUserID int64 `json:"reactor_user_id"`
|
||||
Reactions []messageReactionEvidenceV1 `json:"reactions"`
|
||||
}
|
||||
|
||||
type messageReactionEvidenceV1 struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Type domain.MessageReactionType `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Big bool `json:"big,omitempty"`
|
||||
Unread bool `json:"unread,omitempty"`
|
||||
ChosenOrder int `json:"chosen_order,omitempty"`
|
||||
Date int `json:"date"`
|
||||
}
|
||||
|
||||
type encryptedChatEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChatID int `json:"chat_id"`
|
||||
State domain.SecretChatState `json:"state"`
|
||||
AdminUserID int64 `json:"admin_user_id"`
|
||||
ParticipantUserID int64 `json:"participant_user_id"`
|
||||
Date int `json:"date"`
|
||||
}
|
||||
|
||||
type privateMessageEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
MessageID int `json:"message_id"`
|
||||
UID int64 `json:"uid"`
|
||||
Peer domain.Peer `json:"peer"`
|
||||
From domain.Peer `json:"from"`
|
||||
Date int `json:"date"`
|
||||
EditDate int `json:"edit_date,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
|
||||
Forward *domain.MessageForward `json:"forward,omitempty"`
|
||||
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
|
||||
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
|
||||
GroupedID int64 `json:"grouped_id,omitempty"`
|
||||
}
|
||||
|
||||
type channelMessageEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
From domain.Peer `json:"from"`
|
||||
SendAs *domain.Peer `json:"send_as,omitempty"`
|
||||
Date int `json:"date"`
|
||||
EditDate int `json:"edit_date,omitempty"`
|
||||
Post bool `json:"post,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
|
||||
Forward *domain.MessageForward `json:"forward,omitempty"`
|
||||
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
|
||||
Action *domain.ChannelMessageAction `json:"action,omitempty"`
|
||||
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
|
||||
GroupedID int64 `json:"grouped_id,omitempty"`
|
||||
}
|
||||
|
||||
type storyEvidenceV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Owner domain.Peer `json:"owner"`
|
||||
StoryID int `json:"story_id"`
|
||||
Date int `json:"date"`
|
||||
ExpireDate int `json:"expire_date"`
|
||||
Pinned bool `json:"pinned,omitempty"`
|
||||
Public bool `json:"public,omitempty"`
|
||||
CloseFriends bool `json:"close_friends,omitempty"`
|
||||
Contacts bool `json:"contacts,omitempty"`
|
||||
SelectedContacts bool `json:"selected_contacts,omitempty"`
|
||||
NoForwards bool `json:"no_forwards,omitempty"`
|
||||
Edited bool `json:"edited,omitempty"`
|
||||
Caption string `json:"caption"`
|
||||
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||
MediaAreas []domain.StoryMediaArea `json:"media_areas,omitempty"`
|
||||
Forward *domain.StoryForward `json:"forward,omitempty"`
|
||||
}
|
||||
|
||||
func privateMessageEvidence(message domain.Message) privateMessageEvidenceV1 {
|
||||
return privateMessageEvidenceV1{
|
||||
SchemaVersion: 1, MessageID: message.ID, UID: message.UID,
|
||||
Peer: message.Peer, From: message.From, Date: message.Date,
|
||||
EditDate: message.EditDate, Body: message.Body,
|
||||
Entities: message.Entities, ReplyTo: message.ReplyTo,
|
||||
Forward: message.Forward, Reactions: message.Reactions,
|
||||
Media: message.Media, RichMessage: message.RichMessage,
|
||||
GroupedID: message.GroupedID,
|
||||
}
|
||||
}
|
||||
|
||||
func channelMessageEvidence(message domain.ChannelMessage) channelMessageEvidenceV1 {
|
||||
return channelMessageEvidenceV1{
|
||||
SchemaVersion: 1, ChannelID: message.ChannelID,
|
||||
MessageID: message.ID, SenderUserID: message.SenderUserID,
|
||||
From: message.From, SendAs: message.SendAs, Date: message.Date,
|
||||
EditDate: message.EditDate, Post: message.Post, Body: message.Body,
|
||||
Entities: message.Entities, ReplyTo: message.ReplyTo,
|
||||
Forward: message.Forward, Reactions: message.Reactions,
|
||||
Action: message.Action, Media: message.Media,
|
||||
RichMessage: message.RichMessage, GroupedID: message.GroupedID,
|
||||
}
|
||||
}
|
||||
|
||||
func marshalChannelMessageEvidence(message domain.ChannelMessage) ([]byte, error) {
|
||||
evidence, err := json.Marshal(channelMessageEvidence(message))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal channel message evidence: %w", err)
|
||||
}
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func reactionRowsForUser(reactions *domain.ChannelMessageReactions, userID int64) []domain.ChannelMessagePeerReaction {
|
||||
if reactions == nil || userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]domain.ChannelMessagePeerReaction, 0, len(reactions.Recent))
|
||||
for _, reaction := range reactions.Recent {
|
||||
if reaction.UserID == userID {
|
||||
rows = append(rows, reaction)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func reactionEvidenceRows(rows []domain.ChannelMessagePeerReaction) []messageReactionEvidenceV1 {
|
||||
out := make([]messageReactionEvidenceV1, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, messageReactionEvidenceV1{
|
||||
UserID: row.UserID, Type: row.Reaction.Type,
|
||||
Value: row.Reaction.Value(), Big: row.Big, Unread: row.Unread,
|
||||
ChosenOrder: row.ChosenOrder, Date: row.Date,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].ChosenOrder != out[j].ChosenOrder {
|
||||
return out[i].ChosenOrder < out[j].ChosenOrder
|
||||
}
|
||||
if out[i].Type != out[j].Type {
|
||||
return out[i].Type < out[j].Type
|
||||
}
|
||||
return out[i].Value < out[j].Value
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func canonicalPositiveIDs(ids []int, max int) ([]int, error) {
|
||||
if len(ids) == 0 || len(ids) > domain.MaxModerationReportItems {
|
||||
return nil, domain.ErrModerationReportInvalid
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
out := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > max {
|
||||
return nil, domain.ErrModerationReportInvalid
|
||||
}
|
||||
if _, duplicate := seen[id]; !duplicate {
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mediaHolds(itemIndex int, media *domain.MessageMedia) []domain.ModerationMediaHold {
|
||||
if media == nil {
|
||||
return nil
|
||||
}
|
||||
holds := make([]domain.ModerationMediaHold, 0, 8)
|
||||
addPhoto := func(photo *domain.Photo) {
|
||||
if photo == nil || photo.ID <= 0 {
|
||||
return
|
||||
}
|
||||
for _, size := range photo.Sizes {
|
||||
if size.Type != "" {
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
|
||||
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
addDocument := func(document *domain.Document) {
|
||||
if document == nil || document.ID <= 0 {
|
||||
return
|
||||
}
|
||||
prefix := "doc:" + strconv.FormatInt(document.ID, 10)
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
|
||||
StorageKey: prefix,
|
||||
})
|
||||
for _, thumb := range document.Thumbs {
|
||||
if thumb.Type != "" {
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
|
||||
StorageKey: prefix + ":" + thumb.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
addPhoto(media.Photo)
|
||||
addDocument(media.Document)
|
||||
addDocument(media.LivePhotoVideo)
|
||||
return dedupeMediaHolds(holds)
|
||||
}
|
||||
|
||||
func photoHolds(itemIndex int, photo domain.Photo) []domain.ModerationMediaHold {
|
||||
if photo.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
holds := make([]domain.ModerationMediaHold, 0, len(photo.Sizes))
|
||||
for _, size := range photo.Sizes {
|
||||
if size.Type == "" {
|
||||
continue
|
||||
}
|
||||
holds = append(holds, domain.ModerationMediaHold{
|
||||
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
|
||||
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
|
||||
})
|
||||
}
|
||||
return dedupeMediaHolds(holds)
|
||||
}
|
||||
|
||||
func dedupeMediaHolds(holds []domain.ModerationMediaHold) []domain.ModerationMediaHold {
|
||||
if len(holds) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[domain.ModerationMediaHold]struct{}, len(holds))
|
||||
out := make([]domain.ModerationMediaHold, 0, len(holds))
|
||||
for _, hold := range holds {
|
||||
if _, duplicate := seen[hold]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[hold] = struct{}{}
|
||||
out = append(out, hold)
|
||||
}
|
||||
return out
|
||||
}
|
||||
106
internal/app/moderation/legacy.go
Normal file
106
internal/app/moderation/legacy.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// MigrateLegacyEphemeralReports converts every pre-unified durable report into
|
||||
// the canonical moderation shape. The store commits the new report and its
|
||||
// legacy provenance mapping atomically; rerunning after a crash is safe.
|
||||
func (s *Service) MigrateLegacyEphemeralReports(ctx context.Context, source store.LegacyEphemeralReportReader, batchSize int) (int, error) {
|
||||
if s == nil || s.reports == nil || source == nil {
|
||||
return 0, fmt.Errorf("legacy ephemeral report migration is not configured")
|
||||
}
|
||||
if batchSize <= 0 || batchSize > 1000 {
|
||||
return 0, fmt.Errorf("legacy ephemeral report batch limit out of range")
|
||||
}
|
||||
importer, ok := s.reports.(store.LegacyEphemeralReportImporter)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("moderation report store does not support legacy imports")
|
||||
}
|
||||
migrated := 0
|
||||
for {
|
||||
rows, err := source.ListUnmigratedEphemeralReports(ctx, batchSize)
|
||||
if err != nil {
|
||||
return migrated, err
|
||||
}
|
||||
for _, legacy := range rows {
|
||||
report, err := legacyEphemeralModerationReport(legacy.Report)
|
||||
if err != nil {
|
||||
return migrated, fmt.Errorf("convert legacy ephemeral report %d: %w", legacy.ID, err)
|
||||
}
|
||||
if _, _, err := importer.ImportLegacyEphemeralReport(ctx, legacy.ID, report); err != nil {
|
||||
return migrated, fmt.Errorf("import legacy ephemeral report %d: %w", legacy.ID, err)
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
if len(rows) < batchSize {
|
||||
return migrated, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func legacyEphemeralModerationReport(legacy domain.EphemeralAbuseReport) (domain.ModerationReport, error) {
|
||||
if err := legacy.Validate(); err != nil {
|
||||
return domain.ModerationReport{}, err
|
||||
}
|
||||
reason, ok := legacyEphemeralModerationReason(legacy.Option)
|
||||
if !ok {
|
||||
return domain.ModerationReport{}, fmt.Errorf("%w: unsupported legacy option %q", domain.ErrModerationReportInvalid, legacy.Option)
|
||||
}
|
||||
evidence, err := json.Marshal(struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Evidence domain.EphemeralReportEvidence `json:"evidence"`
|
||||
}{SchemaVersion: 1, Evidence: legacy.Evidence})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, fmt.Errorf("marshal legacy ephemeral evidence: %w", err)
|
||||
}
|
||||
return domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: legacy.ReporterUserID,
|
||||
Source: domain.ModerationSourceEphemeral,
|
||||
Target: legacy.Evidence.Peer,
|
||||
Reason: reason,
|
||||
Option: legacy.Option,
|
||||
Comment: legacy.Comment,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemEphemeral,
|
||||
Peer: legacy.Evidence.Peer,
|
||||
ItemID: int64(legacy.Evidence.MessageID),
|
||||
AuthorUserID: legacy.Evidence.SenderUserID,
|
||||
EvidenceSchemaVersion: 1,
|
||||
Evidence: evidence,
|
||||
}},
|
||||
MediaHolds: mediaHolds(0, legacy.Evidence.Content.Media),
|
||||
CreatedAt: legacy.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func legacyEphemeralModerationReason(option string) (domain.ModerationReason, bool) {
|
||||
switch option {
|
||||
case "spam":
|
||||
return domain.ModerationReasonSpam, true
|
||||
case "violence":
|
||||
return domain.ModerationReasonViolence, true
|
||||
case "pornography":
|
||||
return domain.ModerationReasonPornography, true
|
||||
case "child_abuse":
|
||||
return domain.ModerationReasonChildAbuse, true
|
||||
case "illegal_drugs":
|
||||
return domain.ModerationReasonIllegalDrugs, true
|
||||
case "personal_details":
|
||||
return domain.ModerationReasonPersonalDetails, true
|
||||
case "copyright":
|
||||
return domain.ModerationReasonCopyright, true
|
||||
case "fake":
|
||||
return domain.ModerationReasonFake, true
|
||||
case "other", "other:comment":
|
||||
return domain.ModerationReasonOther, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
88
internal/app/moderation/legacy_test.go
Normal file
88
internal/app/moderation/legacy_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type legacyEphemeralReader struct {
|
||||
rows []store.LegacyEphemeralReport
|
||||
}
|
||||
|
||||
func (r *legacyEphemeralReader) ListUnmigratedEphemeralReports(_ context.Context, limit int) ([]store.LegacyEphemeralReport, error) {
|
||||
if len(r.rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > len(r.rows) {
|
||||
limit = len(r.rows)
|
||||
}
|
||||
out := append([]store.LegacyEphemeralReport(nil), r.rows[:limit]...)
|
||||
r.rows = r.rows[limit:]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type legacyModerationImporter struct {
|
||||
*memory.ModerationReportStore
|
||||
mappings map[int64]int64
|
||||
}
|
||||
|
||||
func (s *legacyModerationImporter) ImportLegacyEphemeralReport(ctx context.Context, legacyID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
|
||||
if reportID, ok := s.mappings[legacyID]; ok {
|
||||
existing, _, err := s.GetModerationReport(ctx, reportID)
|
||||
return existing, false, err
|
||||
}
|
||||
stored, created, err := s.CreateModerationReport(ctx, report)
|
||||
if err == nil {
|
||||
s.mappings[legacyID] = stored.ID
|
||||
}
|
||||
return stored, created, err
|
||||
}
|
||||
|
||||
func TestMigrateLegacyEphemeralReportsPreservesEvidenceAndMediaHolds(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
reporter := int64(101)
|
||||
message := domain.EphemeralMessage{
|
||||
ID: 44, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 303},
|
||||
SenderUserID: 202, ReceiverUserID: reporter, Date: int(now.Unix()),
|
||||
Content: domain.EphemeralContent{
|
||||
Message: "evidence",
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindDocument,
|
||||
Document: &domain.Document{
|
||||
ID: 909, AccessHash: 1, MimeType: "text/plain", Size: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
legacy := domain.NewEphemeralAbuseReport(reporter, "spam", "review", message, now)
|
||||
source := &legacyEphemeralReader{rows: []store.LegacyEphemeralReport{{ID: 7, Report: legacy}}}
|
||||
target := &legacyModerationImporter{
|
||||
ModerationReportStore: memory.NewModerationReportStore(),
|
||||
mappings: make(map[int64]int64),
|
||||
}
|
||||
service := NewService(target)
|
||||
count, err := service.MigrateLegacyEphemeralReports(context.Background(), source, 10)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("migrate count=%d err=%v", count, err)
|
||||
}
|
||||
reports := target.Reports()
|
||||
if len(reports) != 1 {
|
||||
t.Fatalf("reports=%d, want 1", len(reports))
|
||||
}
|
||||
got := reports[0]
|
||||
if got.Source != domain.ModerationSourceEphemeral ||
|
||||
got.Target != message.Peer || len(got.Items) != 1 ||
|
||||
got.Items[0].AuthorUserID != message.SenderUserID {
|
||||
t.Fatalf("migrated report=%+v", got)
|
||||
}
|
||||
if len(got.MediaHolds) != 1 ||
|
||||
got.MediaHolds[0].StorageKey != "doc:909" {
|
||||
t.Fatalf("media holds=%+v", got.MediaHolds)
|
||||
}
|
||||
}
|
||||
102
internal/app/moderation/registry_reports.go
Normal file
102
internal/app/moderation/registry_reports.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Service) SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error) {
|
||||
if s == nil || s.registry == nil || len(randomID) == 0 {
|
||||
return domain.SponsoredMessageImpression{}, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
impression, found, err := s.registry.GetSponsoredMessageImpression(
|
||||
ctx, userID, sha256.Sum256(randomID), now,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SponsoredMessageImpression{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.SponsoredMessageImpression{}, domain.ErrModerationImpressionExpired
|
||||
}
|
||||
return impression, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error) {
|
||||
impression, err := s.SponsoredImpression(ctx, userID, randomID, now)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if impression.ReportID > 0 {
|
||||
report, found, err := s.Report(ctx, impression.ReportID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||
}
|
||||
return report, false, nil
|
||||
}
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: userID, Source: domain.ModerationSourceSponsored,
|
||||
Target: impression.Target, Reason: reason, Option: option,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemSponsored, Peer: impression.Target,
|
||||
ItemID: impression.ID, AuthorUserID: impression.AuthorUserID,
|
||||
EvidenceSchemaVersion: impression.EvidenceSchemaVersion,
|
||||
Evidence: impression.Evidence,
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.registry.CreateSponsoredModerationReport(ctx, impression.ID, report)
|
||||
}
|
||||
|
||||
func (s *Service) ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.registry == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
decision, found, err := s.registry.GetChannelAntiSpamDecision(
|
||||
ctx, channelID, messageID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
if decision.ReportID > 0 {
|
||||
report, found, err := s.Report(ctx, decision.ReportID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||
}
|
||||
return report, false, nil
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID,
|
||||
Source: domain.ModerationSourceAntiSpamFalsePositive,
|
||||
Target: target,
|
||||
Reason: domain.ModerationReasonOther,
|
||||
Option: "false_positive",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemAntiSpamDecision, Peer: target,
|
||||
ItemID: decision.ID, SecondaryID: int64(messageID),
|
||||
AuthorUserID: decision.AuthorUserID,
|
||||
EvidenceSchemaVersion: decision.EvidenceSchemaVersion,
|
||||
Evidence: decision.Evidence,
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.registry.CreateAntiSpamFalsePositiveReport(ctx, decision.ID, report)
|
||||
}
|
||||
98
internal/app/moderation/registry_reports_test.go
Normal file
98
internal/app/moderation/registry_reports_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestSponsoredReportRequiresIssuedImpressionAndLinksAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
randomID := []byte("server-issued-random-id")
|
||||
if _, _, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now,
|
||||
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||
t.Fatalf("unseen impression err=%v", err)
|
||||
}
|
||||
impression, err := domain.NewSponsoredMessageImpression(
|
||||
11, randomID, domain.Peer{Type: domain.PeerTypeChannel, ID: 22},
|
||||
33, []byte(`{"author_id":33,"creative_id":"creative-1"}`),
|
||||
now, now.Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
impression, created, err := store.CreateSponsoredMessageImpression(ctx, impression)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("impression=%+v created=%v err=%v", impression, created, err)
|
||||
}
|
||||
report, created, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now.Add(time.Second),
|
||||
)
|
||||
if err != nil || !created || report.ID <= 0 {
|
||||
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||
}
|
||||
retry, created, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonFake, "fake", now.Add(2*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != report.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
if reports := store.Reports(); len(reports) != 1 ||
|
||||
reports[0].Items[0].EvidenceHash != impression.EvidenceHash {
|
||||
t.Fatalf("reports=%+v", reports)
|
||||
}
|
||||
if _, _, err := service.ReportSponsored(
|
||||
ctx, 11, []byte("expired"),
|
||||
domain.ModerationReasonSpam, "spam", now.Add(2*time.Hour),
|
||||
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||
t.Fatalf("expired/unseen err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntiSpamFalsePositiveRequiresNativeDecisionAndIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
if _, _, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now,
|
||||
); !errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||
t.Fatalf("missing decision err=%v", err)
|
||||
}
|
||||
decision, err := domain.NewChannelAntiSpamDecision(
|
||||
22, 33, 44,
|
||||
[]byte(`{"engine":"native-v1","score":0.99}`), now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision, created, err := store.CreateChannelAntiSpamDecision(ctx, decision)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||
}
|
||||
report, created, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now.Add(time.Second),
|
||||
)
|
||||
if err != nil || !created || report.ID <= 0 {
|
||||
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||
}
|
||||
retry, created, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now.Add(2*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != report.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
if reports := store.Reports(); len(reports) != 1 ||
|
||||
reports[0].Items[0].EvidenceHash != decision.EvidenceHash ||
|
||||
reports[0].Items[0].SecondaryID != 33 {
|
||||
t.Fatalf("reports=%+v", reports)
|
||||
}
|
||||
}
|
||||
89
internal/app/moderation/service.go
Normal file
89
internal/app/moderation/service.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service owns moderation submission invariants. RPC handlers provide
|
||||
// domain-only snapshots; the service canonicalizes and persists them before a
|
||||
// client may observe a successful report response.
|
||||
type Service struct {
|
||||
reports store.ModerationReportStore
|
||||
cases store.ModerationCaseStore
|
||||
registry store.ModerationEvidenceRegistryStore
|
||||
privateMessages privateMessageReader
|
||||
channelMessages channelMessageReader
|
||||
stories storyReader
|
||||
users userReader
|
||||
channels channelPeerReader
|
||||
photos profilePhotoReader
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithMessageReaders(private privateMessageReader, channels channelMessageReader) Option {
|
||||
return func(service *Service) {
|
||||
service.privateMessages = private
|
||||
service.channelMessages = channels
|
||||
}
|
||||
}
|
||||
|
||||
func WithStoryReader(stories storyReader) Option {
|
||||
return func(service *Service) {
|
||||
service.stories = stories
|
||||
}
|
||||
}
|
||||
|
||||
func WithPeerReaders(users userReader, channels channelPeerReader) Option {
|
||||
return func(service *Service) {
|
||||
service.users = users
|
||||
service.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
func WithProfilePhotoReader(photos profilePhotoReader) Option {
|
||||
return func(service *Service) {
|
||||
service.photos = photos
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(reports store.ModerationReportStore, opts ...Option) *Service {
|
||||
service := &Service{reports: reports}
|
||||
if cases, ok := reports.(store.ModerationCaseStore); ok {
|
||||
service.cases = cases
|
||||
}
|
||||
if registry, ok := reports.(store.ModerationEvidenceRegistryStore); ok {
|
||||
service.registry = registry
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(service)
|
||||
}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) AcceptReport(ctx context.Context, draft domain.ModerationReportDraft) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.reports == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||
}
|
||||
report, err := domain.NewModerationReport(draft)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.reports.CreateModerationReport(ctx, report)
|
||||
}
|
||||
|
||||
func (s *Service) Report(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.reports == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||
}
|
||||
if reportID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
return s.reports.GetModerationReport(ctx, reportID)
|
||||
}
|
||||
36
internal/app/moderation/service_test.go
Normal file
36
internal/app/moderation/service_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAcceptReportReturnsDurableRetry(t *testing.T) {
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
draft := domain.ModerationReportDraft{
|
||||
ReporterUserID: 100, Source: domain.ModerationSourceMessagesSpam,
|
||||
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||
ItemID: 200, AuthorUserID: 200, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"snapshot":"peer"}`),
|
||||
}},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
first, created, err := service.AcceptReport(context.Background(), draft)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first created=%v err=%v", created, err)
|
||||
}
|
||||
draft.CreatedAt = draft.CreatedAt.Add(time.Minute)
|
||||
retry, created, err := service.AcceptReport(context.Background(), draft)
|
||||
if err != nil || created || retry.ID != first.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -46,28 +46,27 @@ func newRegistry() *registry {
|
|||
}
|
||||
}
|
||||
|
||||
// sweepLocked 是 P1 的纯年龄 GC(调用方持有 r.mu):
|
||||
// - 终态 tombstone 超过 tombstoneTTL → 回收(密钥材料随之销毁);
|
||||
// - 非终态超过 2×ringTimeout → 直接回收(双端同时崩溃的兜底,防僵尸通话
|
||||
// 吃满并发上限;不推送、不落历史,正常超时由客户端定时器与 P2 dispatcher 处理)。
|
||||
func (r *registry) sweepLocked(nowUnix int64, ringTimeoutSec, tombstoneTTLSec int64) {
|
||||
// sweepTombstonesLocked 只回收超过保留期的终态 tombstone(调用方持有 r.mu)。
|
||||
//
|
||||
// 非终态绝不能在 GC 中按 Date 直接删除:Requested/Ringing/Accepted 的超时必须由
|
||||
// Service.ExpireDue 完成状态迁移、双端推送和历史落库;Confirmed 没有服务端时长
|
||||
// 上限,必须一直可供 signaling/discard 寻址,直到显式挂断或进程重启。
|
||||
func (r *registry) sweepTombstonesLocked(nowUnix, tombstoneTTLSec int64) {
|
||||
for id, e := range r.byID {
|
||||
switch {
|
||||
case e.call.Terminal():
|
||||
if nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
|
||||
r.removeLocked(id, e, false)
|
||||
}
|
||||
default:
|
||||
if nowUnix-int64(e.call.Date) > 2*ringTimeoutSec {
|
||||
r.removeLocked(id, e, true)
|
||||
}
|
||||
if e.call.Terminal() && nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
|
||||
r.removeLocked(id, e, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *registry) removeLocked(id int64, e *entry, wasActive bool) {
|
||||
delete(r.byID, id)
|
||||
delete(r.byRandom, randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID})
|
||||
key := randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID}
|
||||
// 终态后允许客户端复用 random_id 创建新通话;旧 tombstone 到期时不能
|
||||
// 把已指向新 call 的幂等索引一并删掉。
|
||||
if indexedID, ok := r.byRandom[key]; ok && indexedID == id {
|
||||
delete(r.byRandom, key)
|
||||
}
|
||||
if wasActive {
|
||||
r.decActiveLocked(e.call.AdminID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ type Config struct {
|
|||
TombstoneTTL time.Duration
|
||||
// MaxActivePerUser 是单用户并发非终态通话上限(防呼叫轰炸自锁)。
|
||||
MaxActivePerUser int
|
||||
// MaxRegistryEntries 是进程内 registry 的硬上限。达到上限时拒绝新通话,
|
||||
// 不驱逐可能仍在进行的 Confirmed 通话。
|
||||
MaxRegistryEntries int
|
||||
// SignalingRatePerSecond 是单通话每秒信令转发上限;超限静默丢弃(不破坏客户端状态机)。
|
||||
SignalingRatePerSecond int
|
||||
}
|
||||
|
|
@ -59,6 +62,9 @@ func (c Config) withDefaults() Config {
|
|||
if c.MaxActivePerUser <= 0 {
|
||||
c.MaxActivePerUser = 4
|
||||
}
|
||||
if c.MaxRegistryEntries <= 0 {
|
||||
c.MaxRegistryEntries = 10_000
|
||||
}
|
||||
if c.SignalingRatePerSecond <= 0 {
|
||||
c.SignalingRatePerSecond = 50
|
||||
}
|
||||
|
|
@ -103,7 +109,7 @@ func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.Pho
|
|||
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
s.reg.sweepLocked(nowUnix, int64(s.cfg.RingTimeout/time.Second), int64(s.cfg.TombstoneTTL/time.Second))
|
||||
s.reg.sweepTombstonesLocked(nowUnix, int64(s.cfg.TombstoneTTL/time.Second))
|
||||
|
||||
// 幂等:同一 (callerID, randomID) 的未终结通话直接返回快照,吸收客户端重试。
|
||||
key := randomKey{callerID: callerID, randomID: in.RandomID}
|
||||
|
|
@ -112,6 +118,9 @@ func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.Pho
|
|||
return e.call, nil
|
||||
}
|
||||
}
|
||||
if len(s.reg.byID) >= s.cfg.MaxRegistryEntries {
|
||||
return domain.PhoneCall{}, ErrOccupyFailed
|
||||
}
|
||||
if s.reg.active[callerID] >= s.cfg.MaxActivePerUser {
|
||||
return domain.PhoneCall{}, ErrOccupyFailed
|
||||
}
|
||||
|
|
@ -306,7 +315,7 @@ func (s *Service) ExpireDue(ctx context.Context, now time.Time) []domain.PhoneCa
|
|||
s.reg.markDiscardedLocked(e, reason, 0, int(nowUnix))
|
||||
expired = append(expired, e.call)
|
||||
}
|
||||
s.reg.sweepLocked(nowUnix, ringSec, int64(s.cfg.TombstoneTTL/time.Second))
|
||||
s.reg.sweepTombstonesLocked(nowUnix, int64(s.cfg.TombstoneTTL/time.Second))
|
||||
return expired
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ func newTestService(clk clock.Clock, mutate ...func(*Config)) *Service {
|
|||
RingTimeout: 90 * time.Second,
|
||||
TombstoneTTL: 60 * time.Second,
|
||||
MaxActivePerUser: 4,
|
||||
MaxRegistryEntries: 10_000,
|
||||
SignalingRatePerSecond: 50,
|
||||
}
|
||||
for _, fn := range mutate {
|
||||
|
|
@ -265,9 +266,15 @@ func TestPhoneCallRandomIDIdempotent(t *testing.T) {
|
|||
if err != nil || third.ID == first.ID {
|
||||
t.Fatalf("post-discard request id = %d err=%v, want fresh call", third.ID, err)
|
||||
}
|
||||
// 旧 tombstone 到期回收时,不得误删已改指向新 call 的 random_id 索引。
|
||||
clk.Advance(61 * time.Second)
|
||||
retry, err := s.RequestCall(ctx, 1, req)
|
||||
if err != nil || retry.ID != third.ID {
|
||||
t.Fatalf("retry after old tombstone GC id = %d err=%v, want %d", retry.ID, err, third.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallQuotaAndSweep(t *testing.T) {
|
||||
func TestPhoneCallQuotaAndExpiry(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk, func(c *Config) { c.MaxActivePerUser = 2 })
|
||||
ctx := context.Background()
|
||||
|
|
@ -281,10 +288,53 @@ func TestPhoneCallQuotaAndSweep(t *testing.T) {
|
|||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); !errors.Is(err, ErrOccupyFailed) {
|
||||
t.Fatalf("over quota err = %v, want ErrOccupyFailed", err)
|
||||
}
|
||||
// 双端崩溃兜底:超过 2×RingTimeout 的僵尸通话被纯年龄 GC 回收,配额释放。
|
||||
clk.Advance(181 * time.Second)
|
||||
// 未建立通话只能由 ExpireDue 迁入终态,确保 dispatcher 能推送并落历史;
|
||||
// registry GC 不得静默删除 active call。
|
||||
clk.Advance(91 * time.Second)
|
||||
expired := s.ExpireDue(ctx, clk.Now())
|
||||
if len(expired) != 2 {
|
||||
t.Fatalf("expired = %d, want 2", len(expired))
|
||||
}
|
||||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
|
||||
t.Fatalf("request after sweep: %v", err)
|
||||
t.Fatalf("request after expiry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallRegistryCapacityDoesNotEvictConfirmedCall(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk, func(c *Config) { c.MaxRegistryEntries = 1 })
|
||||
ctx := context.Background()
|
||||
ga, gaHash := testGA()
|
||||
|
||||
confirmed := mustRequest(t, s, 1, 2, gaHash)
|
||||
if _, err := s.AcceptCall(ctx, 2, confirmed.ID, confirmed.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if _, _, err := s.ConfirmCall(ctx, 1, confirmed.ID, confirmed.AccessHash, ga, 1, testProtocol()); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
|
||||
clk.Advance(365 * 24 * time.Hour)
|
||||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||||
t.Fatalf("confirmed call expired after one year: %+v", got)
|
||||
}
|
||||
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
|
||||
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
|
||||
}); !errors.Is(err, ErrOccupyFailed) {
|
||||
t.Fatalf("request at registry capacity err = %v, want ErrOccupyFailed", err)
|
||||
}
|
||||
if snap, ok := s.Lookup(ctx, confirmed.ID, confirmed.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed {
|
||||
t.Fatalf("confirmed call = %+v ok=%v, want preserved", snap, ok)
|
||||
}
|
||||
|
||||
if _, _, err := s.DiscardCall(ctx, 1, confirmed.ID, confirmed.AccessHash, domain.PhoneCallDiscardReasonHangup, 1); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
clk.Advance(61 * time.Second)
|
||||
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
|
||||
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
|
||||
}); err != nil {
|
||||
t.Fatalf("request after tombstone GC: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -476,4 +526,13 @@ func TestPhoneCallExpireDue(t *testing.T) {
|
|||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||||
t.Fatalf("second ExpireDue = %d, want 0", len(got))
|
||||
}
|
||||
// 回归:旧 registry GC 会在 2×RingTimeout 后静默删除 Confirmed,导致后续
|
||||
// sendSignalingData/discardCall 返回 CALL_PEER_INVALID。
|
||||
clk.Advance(91 * time.Second)
|
||||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||||
t.Fatalf("confirmed call expired after 2×RingTimeout: %+v", got)
|
||||
}
|
||||
if _, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok {
|
||||
t.Fatal("confirmed call must remain addressable after 2×RingTimeout")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,31 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
|
|||
return err
|
||||
}
|
||||
c.InvalidateOwners(rules.OwnerUserID)
|
||||
// 数据写入已提交,预热失败不能伪装成写失败;LISTEN/NOTIFY 也会在每个
|
||||
// 实例上再次失效并预热,覆盖本实例通知晚于这里到达的时序。
|
||||
_ = c.WarmOwners(ctx, rules.OwnerUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
|
||||
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
|
||||
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
|
||||
owners := dedupPrivacyOwnerIDs(ownerUserIDs)
|
||||
if len(owners) == 0 || c == nil || c.cache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 隐私规则变更很少、读取极热。必须重建全部 key,
|
||||
// 不能只塞本次 key,否则会把 owner 的其它持久规则误当成默认规则。
|
||||
loadEpoch := c.cache.LoadEpoch()
|
||||
list, err := c.inner.ListPrivacyRules(ctx, owners, allPrivacyRuleKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots := buildPrivacyRulesByOwner(list, owners)
|
||||
for _, ownerUserID := range owners {
|
||||
c.cache.StoreIfEpoch(ownerUserID, snapshots[ownerUserID], loadEpoch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +185,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 {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
|
||||
func TestCachedPrivacyStoreWarmsCompleteOwnerSnapshotOnSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
counting := &countingPrivacyStore{PrivacyStore: base}
|
||||
|
|
@ -121,24 +121,34 @@ func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
|
|||
t.Fatalf("set first: %v", err)
|
||||
}
|
||||
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
|
||||
t.Fatalf("prime get ok=%v err=%v", ok, err)
|
||||
t.Fatalf("first memory get ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
|
||||
Key: domain.PrivacyKeyProfilePhoto,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("set second: %v", err)
|
||||
}
|
||||
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("after invalidation get ok=%v err=%v", ok, err)
|
||||
t.Fatalf("phone after second set ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules)
|
||||
if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("phone rules after second set = %+v, want disallow all", got.Rules)
|
||||
}
|
||||
photo, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyProfilePhoto)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("photo after second set ok=%v err=%v", ok, err)
|
||||
}
|
||||
if photo.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("photo rules after second set = %+v, want disallow all", photo.Rules)
|
||||
}
|
||||
if counting.setCalls != 2 {
|
||||
t.Fatalf("SetPrivacyRules calls = %d, want 2", counting.setCalls)
|
||||
}
|
||||
if counting.listCalls != 2 {
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want 2 after invalidation", counting.listCalls)
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want exactly one write-path warm per set and no read-path query", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
245
internal/app/privacy/facts.go
Normal file
245
internal/app/privacy/facts.go
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,19 @@ 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
|
||||
}
|
||||
|
||||
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||
if !ValidKey(key) {
|
||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
|
|
@ -52,13 +93,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 +131,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 +234,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 +254,97 @@ 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
|
||||
}
|
||||
|
||||
// ViewerIsPremium reads the same bounded viewer-facts read model used by
|
||||
// AllowPremium privacy rules. Contact permission checks must not bypass that
|
||||
// cache with a per-send users-table query.
|
||||
func (s *Service) ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool, error) {
|
||||
if viewerUserID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
fact := facts[viewerUserID]
|
||||
return fact.Found && !fact.Bot && fact.PremiumUntil > s.now().Unix(), nil
|
||||
}
|
||||
|
||||
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
||||
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
||||
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
||||
|
|
@ -249,6 +382,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 +429,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 +534,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 +552,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,44 @@ package privacy
|
|||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type countingBaseUsers struct {
|
||||
calls int
|
||||
users map[int64]domain.User
|
||||
}
|
||||
|
||||
func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
p.calls++
|
||||
out := make([]domain.User, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if user, ok := p.users[userID]; ok {
|
||||
out = append(out, user)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type countingMemberships struct {
|
||||
calls int
|
||||
active map[int64]map[int64]bool
|
||||
}
|
||||
|
||||
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
p.calls++
|
||||
out := make([]int64, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if p.active[channelID][userID] {
|
||||
out = append(out, userID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestDefaultPrivacyRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
|
||||
|
|
@ -192,3 +225,111 @@ func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
users := &countingBaseUsers{users: map[int64]domain.User{
|
||||
2001: {ID: 2001, PremiumUntil: 2000},
|
||||
2002: {ID: 2002, Bot: true},
|
||||
}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(users, nil)
|
||||
svc.now = func() time.Time { return time.Unix(1000, 0) }
|
||||
|
||||
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowPremium},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set premium rules: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, 1002, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowBots},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set bot rules: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CanSeeMatrix(
|
||||
ctx,
|
||||
[]int64{1001, 1002},
|
||||
[]int64{2001, 2002},
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeMatrix: %v", err)
|
||||
}
|
||||
if !got[1001][2001][domain.PrivacyKeyNoPaidMessages] ||
|
||||
got[1001][2002][domain.PrivacyKeyNoPaidMessages] ||
|
||||
got[1002][2001][domain.PrivacyKeyNoPaidMessages] ||
|
||||
!got[1002][2002][domain.PrivacyKeyNoPaidMessages] {
|
||||
t.Fatalf("unexpected premium/bot visibility matrix: %+v", got)
|
||||
}
|
||||
if users.calls != 1 {
|
||||
t.Fatalf("base user cold loads = %d, want one batched load", users.calls)
|
||||
}
|
||||
|
||||
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || !premium {
|
||||
t.Fatalf("warm ViewerIsPremium = %v, err=%v; want true", premium, err)
|
||||
}
|
||||
if users.calls != 1 {
|
||||
t.Fatalf("warm viewer facts hit called backend: calls=%d", users.calls)
|
||||
}
|
||||
|
||||
users.users[2001] = domain.User{ID: 2001}
|
||||
svc.InvalidateViewerFacts(2001)
|
||||
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || premium {
|
||||
t.Fatalf("invalidated ViewerIsPremium = %v, err=%v; want false", premium, err)
|
||||
}
|
||||
if users.calls != 2 {
|
||||
t.Fatalf("invalidated viewer facts cold loads = %d, want 2", users.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{
|
||||
9001: {2001: true},
|
||||
9002: {},
|
||||
}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{9001, 9002}},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set participant rules: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CanSeeMatrix(
|
||||
ctx,
|
||||
[]int64{1001},
|
||||
[]int64{2001, 2002},
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyChatInvite},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeMatrix: %v", err)
|
||||
}
|
||||
if !got[1001][2001][domain.PrivacyKeyChatInvite] ||
|
||||
got[1001][2002][domain.PrivacyKeyChatInvite] {
|
||||
t.Fatalf("unexpected membership visibility matrix: %+v", got)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("membership cold loads = %d, want one batch per referenced chat", memberships.calls)
|
||||
}
|
||||
|
||||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
|
||||
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("negative cache missed: calls=%d", memberships.calls)
|
||||
}
|
||||
|
||||
memberships.active[9002][2002] = true
|
||||
svc.InvalidateMembership(9002, 2002)
|
||||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
|
||||
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
|
||||
}
|
||||
if memberships.calls != 3 {
|
||||
t.Fatalf("pair invalidation reloads = %d, want 3", memberships.calls)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
454
internal/app/rating/service.go
Normal file
454
internal/app/rating/service.go
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
// Package rating implements the composite account rating use cases: reading the
|
||||
// stored projection, recomputing it from the raw contribution signals, and
|
||||
// applying operator adjustments through the contribution ledger.
|
||||
//
|
||||
// This is gramsrv's local rating model, not a 1:1 reproduction of Telegram's
|
||||
// private algorithm. The service gathers signals, applies the configured
|
||||
// weights and pending-delay policy, and persists the result under optimistic
|
||||
// concurrency for both admin and read-only client projection.
|
||||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultPendingDelay parks a rating increase for a day, matching the
|
||||
// shipped TELESRV_RATING_PENDING_DELAY default.
|
||||
defaultPendingDelay = 24 * time.Hour
|
||||
// defaultStaleAfter is the recompute horizon used when none is configured.
|
||||
defaultStaleAfter = 6 * time.Hour
|
||||
// defaultListLimit / maxListLimit bound one leaderboard page.
|
||||
defaultListLimit = 50
|
||||
maxListLimit = 200
|
||||
// defaultEventLimit / maxEventLimit bound one ledger page.
|
||||
defaultEventLimit = 50
|
||||
maxEventLimit = 200
|
||||
// defaultRecomputeBatch / maxRecomputeBatch bound one worker cycle.
|
||||
defaultRecomputeBatch = 500
|
||||
maxRecomputeBatch = 10000
|
||||
)
|
||||
|
||||
// ErrDisabled reports that the local composite rating feature is switched off.
|
||||
// Reads degrade to an empty admin projection; writes are refused so an operator
|
||||
// never believes an adjustment was recorded when it was not.
|
||||
var ErrDisabled = errors.New("account rating is disabled")
|
||||
|
||||
// Service is the composite account rating use-case layer.
|
||||
type Service struct {
|
||||
store store.AccountRatingStore
|
||||
weights domain.AccountRatingWeights
|
||||
pendingDelay time.Duration
|
||||
staleAfter time.Duration
|
||||
enabled bool
|
||||
|
||||
now func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Option adjusts optional service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithStore injects the rating read model and ledger store.
|
||||
func WithStore(st store.AccountRatingStore) Option {
|
||||
return func(s *Service) { s.store = st }
|
||||
}
|
||||
|
||||
// WithWeights installs the composite formula. An invalid set is rejected in
|
||||
// favour of the shipped defaults, so a misconfigured deployment produces a
|
||||
// conservative rating instead of an inconsistent one.
|
||||
func WithWeights(weights domain.AccountRatingWeights) Option {
|
||||
return func(s *Service) {
|
||||
if err := weights.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
s.weights = weights
|
||||
}
|
||||
}
|
||||
|
||||
// WithPendingDelay configures how long a rating increase stays parked as
|
||||
// pending. Zero applies every change immediately.
|
||||
func WithPendingDelay(delay time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if delay >= 0 {
|
||||
s.pendingDelay = delay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStaleAfter configures the projection age after which the background
|
||||
// worker recomputes a user.
|
||||
func WithStaleAfter(staleAfter time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if staleAfter > 0 {
|
||||
s.staleAfter = staleAfter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithEnabled toggles the feature.
|
||||
func WithEnabled(enabled bool) Option {
|
||||
return func(s *Service) { s.enabled = enabled }
|
||||
}
|
||||
|
||||
// WithClock injects the clock (tests).
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger injects the service logger.
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return func(s *Service) {
|
||||
if log != nil {
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates the rating service. It is enabled by default so that the
|
||||
// only switch is the configuration flag, and it stays safe without a store:
|
||||
// reads answer empty and writes report a configuration error.
|
||||
func NewService(opts ...Option) *Service {
|
||||
s := &Service{
|
||||
weights: domain.DefaultAccountRatingWeights(),
|
||||
pendingDelay: defaultPendingDelay,
|
||||
staleAfter: defaultStaleAfter,
|
||||
enabled: true,
|
||||
now: time.Now,
|
||||
log: zap.NewNop(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
if s.pendingDelay < 0 {
|
||||
s.pendingDelay = 0
|
||||
}
|
||||
if s.staleAfter <= 0 {
|
||||
s.staleAfter = defaultStaleAfter
|
||||
}
|
||||
if err := s.weights.Validate(); err != nil {
|
||||
s.weights = domain.DefaultAccountRatingWeights()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Enabled reports whether the feature is switched on.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.enabled }
|
||||
|
||||
// Ready reports whether the feature is on and backed by a store.
|
||||
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
|
||||
|
||||
// Weights returns the configured composite formula, so the admin panel can
|
||||
// explain a level with the same numbers that produced it.
|
||||
func (s *Service) Weights() domain.AccountRatingWeights {
|
||||
if s == nil {
|
||||
return domain.DefaultAccountRatingWeights()
|
||||
}
|
||||
return s.weights
|
||||
}
|
||||
|
||||
func (s *Service) ratingStore() (store.AccountRatingStore, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
return s.store, nil
|
||||
}
|
||||
|
||||
// Rating returns the stored projection.
|
||||
//
|
||||
// domain.ErrAccountRatingNotFound is propagated rather than flattened to a zero
|
||||
// value so the admin API can distinguish "not computed" from a computed zero.
|
||||
// A missing store reports a configuration error an operator can diagnose.
|
||||
func (s *Service) Rating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return st.AccountRating(ctx, userID)
|
||||
}
|
||||
|
||||
// RatingBatch resolves several users in one round trip. Users without a stored
|
||||
// projection are absent from the map, so a disabled feature and an unconfigured
|
||||
// store both read as "nobody has a rating" -- the batch shape already encodes
|
||||
// absence and needs no error to express it.
|
||||
func (s *Service) RatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || s.store == nil {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
unique := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
unique = append(unique, userID)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
batch, err := s.store.AccountRatingBatch(ctx, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if batch == nil {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// Recompute gathers the contribution signals, applies the configured weights
|
||||
// and the pending-delay policy relative to the stored value, and persists the
|
||||
// result.
|
||||
//
|
||||
// The save is guarded by the stored version. A concurrent writer (another
|
||||
// recompute, an adjustment, the worker) only invalidates the base the pending
|
||||
// policy was resolved against, so exactly one retry against the freshly
|
||||
// returned row is both sufficient and terminating.
|
||||
func (s *Service) Recompute(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return domain.AccountRating{}, ErrDisabled
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
// The service accounts are infrastructure, not participants. Refusing here as
|
||||
// well as in the seeding query means an operator cannot create a rating for one
|
||||
// by hand either -- the platform account is not flagged is_bot, so nothing else
|
||||
// would stop it.
|
||||
if !domain.RatableAccount(userID, false) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
signals, err := st.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
signals.UserID = userID
|
||||
prev, err := s.previous(ctx, st, userID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
computed := domain.ComputeAccountRating(signals, s.weights, now)
|
||||
stored, changed, err := st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(prev, computed, s.pendingDelay, now))
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if changed {
|
||||
return stored, nil
|
||||
}
|
||||
// One retry: `stored` is the row that won the race, so resolving the pending
|
||||
// policy against it produces the correct next version.
|
||||
stored, changed, err = st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(stored, computed, s.pendingDelay, now))
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if !changed {
|
||||
return stored, fmt.Errorf("recompute account rating %d: concurrent version conflict", userID)
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// Adjust records an operator adjustment in the contribution ledger and
|
||||
// immediately recomputes the projection, so the manual component is visible
|
||||
// without waiting for the background worker. Replaying the same CommandKey
|
||||
// records nothing and reports applied=false; the current rating is still
|
||||
// returned so a retried admin command stays idempotent.
|
||||
func (s *Service) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return domain.AccountRating{}, false, ErrDisabled
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
_, applied, err := st.AdjustAccountRating(ctx, req)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
rating, err := s.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
// List is the admin leaderboard query with a bounded page size.
|
||||
func (s *Service) List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.MinLevel < 0 {
|
||||
filter.MinLevel = 0
|
||||
}
|
||||
if filter.MinLevel > domain.MaxAccountRatingLevel {
|
||||
filter.MinLevel = domain.MaxAccountRatingLevel
|
||||
}
|
||||
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
||||
return st.ListAccountRatings(ctx, filter)
|
||||
}
|
||||
|
||||
// Events returns one user's contribution ledger, newest first.
|
||||
func (s *Service) Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
return st.AccountRatingEvents(ctx, userID, clampLimit(limit, defaultEventLimit, maxEventLimit))
|
||||
}
|
||||
|
||||
// RunRecomputeCycle advances the read model by one bounded batch and returns how
|
||||
// many users it wrote. A single user's failure is logged and skipped: one poisoned
|
||||
// row must not stall the whole cycle.
|
||||
//
|
||||
// The cycle does two things, and the order matters. It first refreshes projections
|
||||
// that have gone stale, because those are rows somebody is already looking at.
|
||||
// Whatever batch budget is left it spends seeding accounts that have no projection
|
||||
// at all -- without that pass the read model can never populate itself, since
|
||||
// StaleAccountRatings walks account_rating and cannot return a user who is not in
|
||||
// it. Staleness keeps existing ratings honest; seeding is what makes them exist at
|
||||
// all, which is what makes the admin leaderboard populate without an operator
|
||||
// opening every account first.
|
||||
func (s *Service) RunRecomputeCycle(ctx context.Context, limit int) (int, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return 0, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
limit = clampLimit(limit, defaultRecomputeBatch, maxRecomputeBatch)
|
||||
olderThan := s.now().UTC().Add(-s.staleAfter).Unix()
|
||||
userIDs, err := st.StaleAccountRatings(ctx, olderThan, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed, err := s.recomputeEach(ctx, userIDs, "recompute account rating failed")
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
// The bound belongs to the cycle, not to each pass, so a backlog of stale rows
|
||||
// can never turn one cycle into an unbounded amount of work.
|
||||
remaining := limit - len(userIDs)
|
||||
if remaining <= 0 {
|
||||
return processed, nil
|
||||
}
|
||||
unrated, err := st.UnratedAccounts(ctx, remaining)
|
||||
if err != nil {
|
||||
// Seeding extends the cycle rather than being its purpose: a store that
|
||||
// cannot enumerate accounts must not turn a successful stale pass into a
|
||||
// failed cycle.
|
||||
s.log.Warn("list unrated accounts failed", zap.Error(err))
|
||||
return processed, nil
|
||||
}
|
||||
seeded, err := s.recomputeEach(ctx, unrated, "seed account rating failed")
|
||||
return processed + seeded, err
|
||||
}
|
||||
|
||||
// recomputeEach recomputes a list of users, skipping the ones that fail, and
|
||||
// giving up early only when the context is done.
|
||||
func (s *Service) recomputeEach(ctx context.Context, userIDs []int64, failureMessage string) (int, error) {
|
||||
processed := 0
|
||||
for _, userID := range userIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Recompute(ctx, userID); err != nil {
|
||||
s.log.Warn(failureMessage,
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// EnsureRating returns the stored local-admin projection, computing and storing
|
||||
// it first when an administrative caller needs an immediate value.
|
||||
//
|
||||
// The background cycle reaches every account eventually; callers that require a
|
||||
// local rating immediately use this bounded materialization path instead.
|
||||
func (s *Service) EnsureRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
rating, err := s.Rating(ctx, userID)
|
||||
if err == nil {
|
||||
return rating, nil
|
||||
}
|
||||
if !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return s.Recompute(ctx, userID)
|
||||
}
|
||||
|
||||
// previous reads the stored projection the pending policy is resolved against.
|
||||
// A never-computed user yields the zero value, which domain.ResolveAccountRating
|
||||
// Pending treats as "apply immediately" -- a first rating is never parked.
|
||||
func (s *Service) previous(ctx context.Context, st store.AccountRatingStore, userID int64) (domain.AccountRating, error) {
|
||||
prev, err := st.AccountRating(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
return domain.AccountRating{}, nil
|
||||
}
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return prev, nil
|
||||
}
|
||||
|
||||
func clampLimit(limit, fallback, maximum int) int {
|
||||
if limit <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if limit > maximum {
|
||||
return maximum
|
||||
}
|
||||
return limit
|
||||
}
|
||||
719
internal/app/rating/service_test.go
Normal file
719
internal/app/rating/service_test.go
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
|
||||
// concurrency contract as PostgreSQL: a save whose version does not follow the
|
||||
// stored one reports changed=false and returns the row that won.
|
||||
type fakeRatingStore struct {
|
||||
signals map[int64]domain.AccountRatingSignals
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
events map[int64][]domain.AccountRatingEvent
|
||||
keys map[string]domain.AccountRatingEvent
|
||||
|
||||
stale []int64
|
||||
staleOlderThan int64
|
||||
staleLimit int
|
||||
|
||||
unrated []int64
|
||||
unratedLimit int
|
||||
unratedCalls int
|
||||
unratedErr error
|
||||
|
||||
saves []domain.AccountRating
|
||||
forceConflicts int
|
||||
signalsErr error
|
||||
}
|
||||
|
||||
func newFakeRatingStore() *fakeRatingStore {
|
||||
return &fakeRatingStore{
|
||||
signals: map[int64]domain.AccountRatingSignals{},
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
events: map[int64][]domain.AccountRatingEvent{},
|
||||
keys: map[string]domain.AccountRatingEvent{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if rating, ok := f.ratings[userID]; ok {
|
||||
out[userID] = rating
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
f.saves = append(f.saves, rating)
|
||||
current := f.ratings[rating.UserID]
|
||||
if f.forceConflicts > 0 {
|
||||
f.forceConflicts--
|
||||
return current, false, nil
|
||||
}
|
||||
if rating.Version != current.Version+1 {
|
||||
return current, false, nil
|
||||
}
|
||||
f.ratings[rating.UserID] = rating
|
||||
return rating, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if f.signalsErr != nil {
|
||||
return domain.AccountRatingSignals{}, f.signalsErr
|
||||
}
|
||||
signals := f.signals[userID]
|
||||
signals.UserID = userID
|
||||
signals.Manual = f.manual[userID]
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if req.CommandKey != "" {
|
||||
if event, ok := f.keys[req.CommandKey]; ok {
|
||||
return event, false, nil
|
||||
}
|
||||
}
|
||||
event := domain.AccountRatingEvent{
|
||||
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
|
||||
}
|
||||
f.events[req.UserID] = append(f.events[req.UserID], event)
|
||||
f.manual[req.UserID] += req.Amount
|
||||
if req.CommandKey != "" {
|
||||
f.keys[req.CommandKey] = event
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level >= filter.MinLevel {
|
||||
out = append(out, rating)
|
||||
}
|
||||
if len(out) >= filter.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
events := f.events[userID]
|
||||
if len(events) > limit {
|
||||
events = events[:limit]
|
||||
}
|
||||
return append([]domain.AccountRatingEvent(nil), events...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
f.staleOlderThan = olderThanUnix
|
||||
f.staleLimit = limit
|
||||
if len(f.stale) > limit {
|
||||
return append([]int64(nil), f.stale[:limit]...), nil
|
||||
}
|
||||
return append([]int64(nil), f.stale...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
|
||||
f.unratedCalls++
|
||||
f.unratedLimit = limit
|
||||
if f.unratedErr != nil {
|
||||
return nil, f.unratedErr
|
||||
}
|
||||
out := make([]int64, 0, len(f.unrated))
|
||||
for _, userID := range f.unrated {
|
||||
if _, rated := f.ratings[userID]; rated {
|
||||
continue
|
||||
}
|
||||
out = append(out, userID)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
|
||||
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
|
||||
return NewService(append(base, opts...)...)
|
||||
}
|
||||
|
||||
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{
|
||||
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
weights := domain.DefaultAccountRatingWeights()
|
||||
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}, weights, testNow)
|
||||
if rating.Stars != want.Stars || rating.Level != want.Level ||
|
||||
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
|
||||
rating.PenaltyComponent != want.PenaltyComponent {
|
||||
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
|
||||
}
|
||||
if rating.Version != 1 {
|
||||
t.Fatalf("first stored version = %d, want 1", rating.Version)
|
||||
}
|
||||
if !rating.ComputedAt.Equal(testNow) {
|
||||
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputePendingPolicy(t *testing.T) {
|
||||
t.Run("increase is parked", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 100 {
|
||||
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
|
||||
}
|
||||
if rating.PendingStars != 400 {
|
||||
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
|
||||
}
|
||||
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
|
||||
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
|
||||
}
|
||||
if rating.Version != 5 {
|
||||
t.Fatalf("version = %d, want 5", rating.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decrease applies immediately", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 0 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
|
||||
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{
|
||||
UserID: 7, Stars: 100, Level: 1, Version: 2,
|
||||
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
|
||||
}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
|
||||
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero delay never parks", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 1
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
|
||||
}
|
||||
if rating.Version != 4 || rating.Stars != 200 {
|
||||
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 2
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute reported success while every save lost the version race")
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
|
||||
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
|
||||
})
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("Adjust = %v, %v", applied, err)
|
||||
}
|
||||
if rating.ManualComponent != 300 || rating.Stars != 400 {
|
||||
t.Fatalf("rating = %#v, want the manual component folded in", rating)
|
||||
}
|
||||
if len(st.events[7]) != 1 {
|
||||
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
|
||||
|
||||
first, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("first Adjust = %v, %v", applied, err)
|
||||
}
|
||||
second, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("replayed Adjust: %v", err)
|
||||
}
|
||||
if applied {
|
||||
t.Fatal("replayed Adjust reported applied=true")
|
||||
}
|
||||
if len(st.events[7]) != 1 || st.manual[7] != 300 {
|
||||
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
|
||||
}
|
||||
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
|
||||
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustValidatesRequest(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st)
|
||||
tests := []domain.AdjustAccountRatingRequest{
|
||||
{UserID: 0, Amount: 10},
|
||||
{UserID: 7, Amount: 0},
|
||||
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
|
||||
}
|
||||
for _, req := range tests {
|
||||
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
|
||||
}
|
||||
}
|
||||
if len(st.events) != 0 || len(st.saves) != 0 {
|
||||
t.Fatal("store was touched by an invalid adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2, 3}
|
||||
for _, userID := range st.stale {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st, WithStaleAfter(6*time.Hour))
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want 3", processed)
|
||||
}
|
||||
if st.staleLimit != 10 {
|
||||
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
|
||||
}
|
||||
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
|
||||
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
|
||||
}
|
||||
for _, userID := range st.stale {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("user %d was not recomputed", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 0, 2}
|
||||
st.forceConflicts = 2 // both saves of the first user lose the race
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the surviving user only", processed)
|
||||
}
|
||||
if st.staleLimit != defaultRecomputeBatch {
|
||||
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("disabled service reported enabled/ready")
|
||||
}
|
||||
// The userFull projection omits both TL flags on this error, which is exactly
|
||||
// the pre-rating wire shape.
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
batch, err := service.RatingBatch(context.Background(), []int64{7})
|
||||
if err != nil || len(batch) != 0 {
|
||||
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
|
||||
}
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil || processed != 0 {
|
||||
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
|
||||
service := NewService()
|
||||
if service.Ready() {
|
||||
t.Fatal("Ready = true without a store")
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); err == nil {
|
||||
t.Fatal("Rating accepted a missing store")
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute accepted a missing store")
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
|
||||
t.Fatal("Adjust accepted a missing store")
|
||||
}
|
||||
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
|
||||
t.Fatal("RunRecomputeCycle accepted a missing store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilServiceIsSafe(t *testing.T) {
|
||||
var service *Service
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("nil service reported enabled/ready")
|
||||
}
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("nil service weights = %#v, want the defaults", got)
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
|
||||
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndEventsBoundThePage(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
|
||||
for i := range maxEventLimit + 10 {
|
||||
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List = %d rows, want 1", len(list))
|
||||
}
|
||||
events, err := service.Events(context.Background(), 7, 100000)
|
||||
if err != nil {
|
||||
t.Fatalf("Events: %v", err)
|
||||
}
|
||||
if len(events) != maxEventLimit {
|
||||
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
|
||||
}
|
||||
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Events accepted a zero user id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
|
||||
}()
|
||||
// The first cycle runs before the ticker, so cancelling immediately still
|
||||
// leaves exactly one recompute behind.
|
||||
<-time.After(20 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker did not stop on context cancellation")
|
||||
}
|
||||
if _, ok := st.ratings[1]; !ok {
|
||||
t.Fatal("worker did not recompute the stale user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
|
||||
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
worker.Run(context.Background())
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("disabled worker kept running")
|
||||
}
|
||||
if worker.batch != defaultRecomputeBatch {
|
||||
t.Fatalf("batch = %d, want the default fallback", worker.batch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
|
||||
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
|
||||
// so it can only ever refresh rows that already exist; without a seeding pass the
|
||||
// very first row for a user has to come from an operator recomputing that user by
|
||||
// hand, and the read model stays permanently empty.
|
||||
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.unrated = []int64{11, 12, 13}
|
||||
for _, userID := range st.unrated {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the three seeded accounts", processed)
|
||||
}
|
||||
for _, userID := range st.unrated {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("account %d was not seeded", userID)
|
||||
}
|
||||
}
|
||||
// A second cycle has nothing left to seed, so seeding converges instead of
|
||||
// rewriting the same rows every interval.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
|
||||
// must not let one cycle do an unbounded amount of work.
|
||||
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2}
|
||||
st.unrated = []int64{11, 12, 13, 14}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the batch bound of 3", processed)
|
||||
}
|
||||
if st.unratedLimit != 1 {
|
||||
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
|
||||
}
|
||||
|
||||
// A cycle whose stale pass already fills the batch does not query for seeds at
|
||||
// all: refreshing rows somebody is looking at comes first.
|
||||
full := newFakeRatingStore()
|
||||
full.stale = []int64{1, 2, 3}
|
||||
full.unrated = []int64{11}
|
||||
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if full.unratedCalls != 0 {
|
||||
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
|
||||
// accounts must not turn a successful stale pass into a failed cycle.
|
||||
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.unratedErr = errors.New("no users table")
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the one stale row", processed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
|
||||
// when the worker has not reached an account yet, the first read materializes the
|
||||
// local projection and the second read must not write again.
|
||||
func TestEnsureRatingMaterializesOnce(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
rating, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRating: %v", err)
|
||||
}
|
||||
if rating.UserID != 7 || rating.Stars == 0 {
|
||||
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
|
||||
}
|
||||
writes := len(st.saves)
|
||||
again, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureRating: %v", err)
|
||||
}
|
||||
if again.Version != rating.Version {
|
||||
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
|
||||
}
|
||||
if len(st.saves) != writes {
|
||||
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled feature materialises nothing. Telegram wire fields remain unset
|
||||
// independently of this local feature flag.
|
||||
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if len(st.saves) != 0 {
|
||||
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
|
||||
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
|
||||
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
|
||||
// rating in the first place -- and an operator must not be able to create one by
|
||||
// hand either.
|
||||
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
|
||||
for _, userID := range domain.SystemUserIDs() {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
st.unrated = []int64{userID}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
|
||||
}
|
||||
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
|
||||
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
|
||||
}
|
||||
if len(st.ratings) != 0 {
|
||||
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
|
||||
}
|
||||
// A seeding pass that is somehow handed one skips it rather than failing the
|
||||
// whole cycle.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary account is unaffected.
|
||||
st := newFakeRatingStore()
|
||||
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
|
||||
t.Fatalf("Recompute of an ordinary account: %v", err)
|
||||
}
|
||||
}
|
||||
91
internal/app/rating/worker.go
Normal file
91
internal/app/rating/worker.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultRecomputeInterval matches the shipped
|
||||
// TELESRV_RATING_RECOMPUTE_INTERVAL default.
|
||||
defaultRecomputeInterval = 15 * time.Minute
|
||||
)
|
||||
|
||||
// RecomputeWorker keeps the rating read model fresh.
|
||||
//
|
||||
// The projection is derived from signals that change outside the rating write
|
||||
// path (Stars flow, message activity, moderation decisions, account age), so no
|
||||
// single writer can keep it current. This worker walks the stale projections in
|
||||
// bounded batches; it never recomputes the whole table in one pass, and a
|
||||
// cancelled context stops it between users rather than mid-write.
|
||||
type RecomputeWorker struct {
|
||||
service *Service
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// NewRecomputeWorker creates the periodic recompute worker. Non-positive
|
||||
// interval/batch fall back to the shipped defaults, matching the retention
|
||||
// worker's contract.
|
||||
func NewRecomputeWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *RecomputeWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = defaultRecomputeInterval
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = defaultRecomputeBatch
|
||||
}
|
||||
return &RecomputeWorker{service: service, logger: logger, interval: interval, batch: batch}
|
||||
}
|
||||
|
||||
// Run recomputes one batch immediately and then on every tick until ctx is
|
||||
// done. A disabled or store-less service exits immediately with one explicit
|
||||
// log line instead of ticking forever over a no-op.
|
||||
func (w *RecomputeWorker) Run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !w.service.Ready() {
|
||||
w.logger.Info("account rating recompute worker disabled",
|
||||
zap.Bool("enabled", w.service.Enabled()))
|
||||
return
|
||||
}
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RecomputeWorker) runOnce(ctx context.Context) {
|
||||
if w == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
processed, err := w.service.RunRecomputeCycle(ctx, w.batch)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("account rating recompute cycle failed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if processed > 0 {
|
||||
w.logger.Info("account rating recompute cycle completed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch))
|
||||
}
|
||||
}
|
||||
|
|
@ -166,4 +166,10 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
|
|||
if !pattern.Attributes[1].TextColor {
|
||||
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
|
||||
}
|
||||
preview, found, err := svc.CollectiblePreviewSample(ctx, result.Catalog.Gift.ID)
|
||||
if err != nil || !found || len(preview.Models) != 2 || len(preview.Patterns) != 2 || len(preview.Backdrops) != 2 ||
|
||||
preview.Models[0].Animation == nil || len(preview.Models[0].Animation.JSON) != 0 ||
|
||||
preview.Patterns[0].Animation == nil || len(preview.Patterns[0].Animation.JSON) != 0 {
|
||||
t.Fatalf("collectible preview sample = found:%v err:%v value:%+v", found, err, preview)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -442,10 +442,22 @@ func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind)
|
|||
}
|
||||
|
||||
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return s.collectiblePreview(ctx, giftID, 0)
|
||||
}
|
||||
|
||||
// CollectiblePreviewSample returns the small randomized working set consumed by official-client
|
||||
// upgrade rollers. The complete published pool remains available through CollectiblePreview for
|
||||
// payments.getStarGiftUpgradeAttributes and the admin editor.
|
||||
func (s *Service) CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
const attributesPerKind = 3
|
||||
return s.collectiblePreview(ctx, giftID, attributesPerKind)
|
||||
}
|
||||
|
||||
func (s *Service) collectiblePreview(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
if s == nil || s.store == nil || giftID <= 0 {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
revision, ok, err := s.store.ActiveCollectibleRevision(ctx, giftID)
|
||||
revision, ok, err := s.store.ActiveCollectibleProjection(ctx, giftID, samplePerKind)
|
||||
if err != nil || !ok || !revision.Published {
|
||||
return domain.StarGiftUpgradePreview{}, false, err
|
||||
}
|
||||
|
|
@ -740,6 +752,25 @@ func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64,
|
|||
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) NotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
|
||||
if s == nil {
|
||||
return false, domain.ErrStarGiftUnavailable
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
// Isolated memory/RPC adapters have no settings table; production's
|
||||
// persisted default is enabled, so preserve that wire behavior.
|
||||
return true, nil
|
||||
}
|
||||
return s.lifecycle.StarGiftNotificationsEnabled(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
return s.store.ResolveUserMessageRef(ctx, viewerUserID, msgID)
|
||||
}
|
||||
|
||||
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
|
||||
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
|
||||
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
|
||||
|
|
@ -794,17 +825,15 @@ func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) {
|
|||
return s.lifecycle.TonBalance(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) {
|
||||
func (s *Service) TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.TonTransactionPage{}, nil
|
||||
}
|
||||
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
|
||||
offset = ""
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.TonTransactionPage{}, err
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
|
||||
limit = domain.MaxStarsTransactionsLimit
|
||||
}
|
||||
return s.lifecycle.TonTransactions(ctx, userID, offset, limit)
|
||||
return s.lifecycle.TonTransactions(ctx, userID, query)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) {
|
||||
|
|
@ -814,17 +843,15 @@ func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int
|
|||
return s.lifecycle.ChannelStarsBalance(ctx, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
|
||||
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarsTransactionPage{}, nil
|
||||
}
|
||||
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
|
||||
offset = ""
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
|
||||
limit = domain.MaxStarsTransactionsLimit
|
||||
}
|
||||
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, offset, limit)
|
||||
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, query)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) {
|
||||
|
|
@ -834,17 +861,15 @@ func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64
|
|||
return s.lifecycle.ChannelTonBalance(ctx, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) {
|
||||
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.TonTransactionPage{}, nil
|
||||
}
|
||||
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
|
||||
offset = ""
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.TonTransactionPage{}, err
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
|
||||
limit = domain.MaxStarsTransactionsLimit
|
||||
}
|
||||
return s.lifecycle.ChannelTonTransactions(ctx, channelID, offset, limit)
|
||||
return s.lifecycle.ChannelTonTransactions(ctx, channelID, query)
|
||||
}
|
||||
|
||||
func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error {
|
||||
|
|
@ -880,9 +905,11 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs
|
|||
|
||||
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
|
||||
if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil {
|
||||
availability, err := s.store.CollectibleAvailability(ctx, []int64{gift.GiftID})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
} else if ok && revision.Published && revision.Issued < revision.SupplyTotal {
|
||||
}
|
||||
if current, ok := availability[gift.GiftID]; ok && current.Issued < current.SupplyTotal {
|
||||
var token [32]byte
|
||||
if _, err := rand.Read(token[:]); err != nil {
|
||||
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ import (
|
|||
|
||||
// Service 是 Stars 账本应用服务。
|
||||
type Service struct {
|
||||
store store.StarsStore
|
||||
grantAmount int64
|
||||
now func() time.Time
|
||||
store store.StarsStore
|
||||
purchaseStore store.StarsPurchaseStore
|
||||
grantAmount int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Option 配置 Service。
|
||||
|
|
@ -26,6 +27,11 @@ func WithStartingGrant(amount int64) Option {
|
|||
return func(s *Service) { s.grantAmount = amount }
|
||||
}
|
||||
|
||||
// WithPurchaseStore enables the atomic fiat Stars checkout aggregate.
|
||||
func WithPurchaseStore(st store.StarsPurchaseStore) Option {
|
||||
return func(s *Service) { s.purchaseStore = st }
|
||||
}
|
||||
|
||||
// WithClock 注入时钟(测试用)。
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
|
|
@ -78,16 +84,67 @@ func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain
|
|||
return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
|
||||
}
|
||||
|
||||
// ListTransactions 按 keyset 分页返回流水 + 当前余额,首读时惰性授予。
|
||||
func (s *Service) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
|
||||
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
|
||||
offset = ""
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
|
||||
limit = domain.MaxStarsTransactionsLimit
|
||||
// ListTransactions 按方向与顺序做 keyset 分页,首读时惰性授予。
|
||||
func (s *Service) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
if _, err := s.ensureGranted(ctx, userID); err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
return s.store.ListTransactions(ctx, userID, offset, limit)
|
||||
return s.store.ListTransactions(ctx, userID, query)
|
||||
}
|
||||
|
||||
// IssuePurchaseForm persists a short-lived, exact checkout intent.
|
||||
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
if s.purchaseStore == nil || !validPurchaseForm(form) {
|
||||
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return s.purchaseStore.IssueStarsPurchaseForm(ctx, form)
|
||||
}
|
||||
|
||||
// Purchase settles one exact persisted form. Package validation remains at
|
||||
// the RPC boundary as well, while the store revalidates the persisted tuple
|
||||
// under lock before performing any write.
|
||||
func (s *Service) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return s.purchaseStore.PurchaseStars(ctx, req)
|
||||
}
|
||||
|
||||
// GetGiveawayInfo resolves one launch card from the same aggregate that
|
||||
// persisted it. date is supplied by the RPC clock for deterministic tests.
|
||||
func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
|
||||
reader, ok := s.purchaseStore.(store.StarsGiveawayStore)
|
||||
if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
|
||||
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date)
|
||||
}
|
||||
|
||||
func validPurchaseForm(form domain.StarsPurchaseForm) bool {
|
||||
return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600
|
||||
}
|
||||
|
||||
func validPurchaseCommand(form domain.StarsPurchaseForm) bool {
|
||||
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" {
|
||||
return false
|
||||
}
|
||||
switch form.Kind {
|
||||
case domain.StarsPurchaseTopup:
|
||||
return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) ||
|
||||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0))
|
||||
case domain.StarsPurchaseGift:
|
||||
return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{})
|
||||
case domain.StarsPurchaseGiveaway:
|
||||
g := form.Giveaway
|
||||
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil &&
|
||||
g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 &&
|
||||
g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 &&
|
||||
int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ func TestStartingGrantOnce(t *testing.T) {
|
|||
t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance)
|
||||
}
|
||||
// 流水里应恰有一条 grant。
|
||||
page, err := svc.ListTransactions(ctx, 7, "", 100)
|
||||
page, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTransactions: %v", err)
|
||||
}
|
||||
|
|
@ -106,7 +106,7 @@ func TestListTransactionsPagination(t *testing.T) {
|
|||
t.Fatalf("Credit#%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
page1, err := svc.ListTransactions(ctx, 7, "", 2)
|
||||
page1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page1: %v", err)
|
||||
}
|
||||
|
|
@ -117,14 +117,14 @@ func TestListTransactionsPagination(t *testing.T) {
|
|||
if page1.Transactions[0].Amount != 14 {
|
||||
t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount)
|
||||
}
|
||||
page2, err := svc.ListTransactions(ctx, 7, page1.NextOffset, 2)
|
||||
page2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page1.NextOffset, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page2: %v", err)
|
||||
}
|
||||
if len(page2.Transactions) != 2 {
|
||||
t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions))
|
||||
}
|
||||
page3, err := svc.ListTransactions(ctx, 7, page2.NextOffset, 2)
|
||||
page3, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page2.NextOffset, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page3: %v", err)
|
||||
}
|
||||
|
|
@ -135,3 +135,75 @@ func TestListTransactionsPagination(t *testing.T) {
|
|||
t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTransactionsDirectionAndAscending(t *testing.T) {
|
||||
svc := newTestService(0)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.Credit(ctx, 7, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 100: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, 7, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 40: %v", err)
|
||||
}
|
||||
if _, err := svc.Credit(ctx, 7, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 20: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, 7, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 10: %v", err)
|
||||
}
|
||||
|
||||
all, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("all transactions: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, all.Transactions, []int64{-10, 20, -40, 100})
|
||||
if all.Balance != 70 {
|
||||
t.Fatalf("all balance = %d, want 70", all.Balance)
|
||||
}
|
||||
|
||||
incoming1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("incoming page1: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, incoming1.Transactions, []int64{20})
|
||||
if incoming1.NextOffset == "" {
|
||||
t.Fatal("incoming page1 missing next offset")
|
||||
}
|
||||
incoming2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("incoming page2: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, incoming2.Transactions, []int64{100})
|
||||
if incoming2.NextOffset != "" {
|
||||
t.Fatalf("terminal incoming next offset = %q", incoming2.NextOffset)
|
||||
}
|
||||
|
||||
outgoing, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ascending outgoing: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, outgoing.Transactions, []int64{-40, -10})
|
||||
|
||||
_, err = svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Direction: 99})
|
||||
if !errors.Is(err, domain.ErrStarsTransactionQueryInvalid) {
|
||||
t.Fatalf("invalid direction error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
|
||||
t.Helper()
|
||||
if len(transactions) != len(want) {
|
||||
t.Fatalf("transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
|
||||
}
|
||||
for i, amount := range want {
|
||||
if transactions[i].Amount != amount {
|
||||
t.Fatalf("transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,14 @@ func (s *Service) Create(ctx context.Context, spec domain.ThemeSpec) (domain.The
|
|||
|
||||
func (s *Service) resolve(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) {
|
||||
if ref.ID != 0 {
|
||||
return s.store.GetThemeByID(ctx, ref.ID)
|
||||
t, ok, err := s.store.GetThemeByID(ctx, ref.ID)
|
||||
if err != nil || !ok {
|
||||
return domain.Theme{}, ok, err
|
||||
}
|
||||
if t.AccessHash != ref.AccessHash {
|
||||
return domain.Theme{}, false, nil
|
||||
}
|
||||
return t, true, nil
|
||||
}
|
||||
if ref.Slug != "" {
|
||||
return s.store.GetThemeBySlug(ctx, ref.Slug)
|
||||
|
|
|
|||
|
|
@ -35,14 +35,14 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
|
|||
}
|
||||
|
||||
// 非创建者不能改。
|
||||
if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("non-creator update err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
|
||||
// 创建者可改 title + document。
|
||||
newTitle := "A2"
|
||||
newDoc := int64(99)
|
||||
updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc})
|
||||
updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc})
|
||||
if err != nil {
|
||||
t.Fatalf("creator update: %v", err)
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
|
|||
}
|
||||
|
||||
// install 计数 + 列表。
|
||||
if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID}, true); err != nil {
|
||||
if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, true); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
got, ok, _ := svc.Get(ctx, domain.ThemeRef{Slug: a.Slug})
|
||||
|
|
@ -67,6 +67,20 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
|
|||
if err := svc.Install(ctx, owner, domain.ThemeRef{Slug: "nope"}, false); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("install unknown err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
|
||||
forged := domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash + 1}
|
||||
if _, ok, err := svc.Get(ctx, forged); err != nil || ok {
|
||||
t.Fatalf("get forged access hash = ok %v err %v, want false/nil", ok, err)
|
||||
}
|
||||
if err := svc.Save(ctx, owner, forged); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("save forged access hash err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
if err := svc.Install(ctx, owner, forged, true); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("install forged access hash err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
if _, err := svc.Update(ctx, owner, forged, domain.ThemeUpdate{Title: strptr("forged")}); !errors.Is(err, domain.ErrThemeNotFound) {
|
||||
t.Fatalf("update forged access hash err = %v, want ErrThemeNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
|
|
|||
|
|
@ -400,31 +400,9 @@ func (s *Service) PublishNewMessage(ctx context.Context, userID int64, msg domai
|
|||
}, true, 0, false)
|
||||
}
|
||||
|
||||
// RecordMessageReactions records a durable marker for message reaction changes.
|
||||
//
|
||||
// updateMessageReactions has no pts fields in Layer 225, but TDesktop still
|
||||
// needs getDifference to advance account pts and carry the latest reaction
|
||||
// aggregate for offline devices.
|
||||
func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = msg.OwnerUserID
|
||||
}
|
||||
date := msg.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventMessageReactions,
|
||||
Date: date,
|
||||
Message: msg,
|
||||
Peer: msg.Peer,
|
||||
PtsCount: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordMessagePoll records a durable marker for message poll state changes
|
||||
// (vote / close). updateMessagePoll has no pts fields in Layer 225 — same
|
||||
// bookkeeping shape as RecordMessageReactions.
|
||||
// historical bookkeeping shape pending its own audit.
|
||||
func (s *Service) RecordMessagePoll(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = msg.OwnerUserID
|
||||
|
|
@ -753,16 +731,6 @@ func (s *Service) RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte,
|
|||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordChannelAvailableMessages records a local channel history clear for multi-device sync.
|
||||
func (s *Service) RecordChannelAvailableMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventChannelAvailable,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
MaxID: availableMinID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
func (s *Service) recordEvent(ctx context.Context, stateAuthKeyID, excludeAuthKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEventCore(ctx, stateAuthKeyID, excludeAuthKeyID, userID, event, dispatch, excludeSessionID, true)
|
||||
}
|
||||
|
|
|
|||
552
internal/app/usernames/service.go
Normal file
552
internal/app/usernames/service.go
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
// Package usernames implements the collectible (Fragment-style) username
|
||||
// registry use cases: reading a peer's username vector, toggling and reordering
|
||||
// the collectible rows a client owns, and the operator lifecycle that mints,
|
||||
// transfers, revokes and burns the assets behind those rows.
|
||||
//
|
||||
// The service owns normalisation and validation. Every entry point normalises
|
||||
// the name through domain.NormalizeUsername and runs the domain Validate()
|
||||
// checks before the store is touched, so an RPC handler, the admin API and a
|
||||
// unit test all reject the same shapes with the same errors.
|
||||
package usernames
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultListLimit is the admin listing page size used when the caller does
|
||||
// not bound the query itself.
|
||||
defaultListLimit = 50
|
||||
// maxListLimit bounds one listing page regardless of the requested limit.
|
||||
maxListLimit = 200
|
||||
// defaultTransferLimit / maxTransferLimit bound the provenance log page.
|
||||
defaultTransferLimit = 50
|
||||
maxTransferLimit = 200
|
||||
// usernamePlaceholder is the substitution supported by the operator URL
|
||||
// template, e.g. https://example.org/nft/{username}.
|
||||
usernamePlaceholder = "{username}"
|
||||
// defaultCollectibleURLPath is the public-link route used when no operator
|
||||
// template is configured.
|
||||
defaultCollectibleURLPath = "nft/username"
|
||||
)
|
||||
|
||||
// ErrPeerInvalid rejects a registry mutation for a peer that cannot hold
|
||||
// usernames. Only users and channels have a username registry; anything else is
|
||||
// a caller bug rather than a client-visible protocol state.
|
||||
var ErrPeerInvalid = errors.New("username peer invalid")
|
||||
|
||||
// PeerUsernameNotifier is the domain-only edge hook invoked after a username
|
||||
// registry mutation. The RPC router implements it: it invalidates the cached
|
||||
// peer projections and pushes the username change to online clients, exactly
|
||||
// like the account.updateUsername path does for the editable slot. Keeping it an
|
||||
// injected port means this package never depends on the protocol edge.
|
||||
type PeerUsernameNotifier interface {
|
||||
NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error
|
||||
}
|
||||
|
||||
// Service is the collectible username use-case layer.
|
||||
type Service struct {
|
||||
registry store.UsernameRegistryStore
|
||||
collectibles store.CollectibleUsernameStore
|
||||
notifier PeerUsernameNotifier
|
||||
|
||||
// urlTemplate is the operator-provided collectible landing URL template;
|
||||
// publicBaseURL is the fallback root the default route is built from.
|
||||
urlTemplate string
|
||||
publicBaseURL string
|
||||
|
||||
now func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Option adjusts optional service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithRegistryStore injects the peer username registry reader/writer.
|
||||
func WithRegistryStore(registry store.UsernameRegistryStore) Option {
|
||||
return func(s *Service) { s.registry = registry }
|
||||
}
|
||||
|
||||
// WithCollectibleStore injects the collectible asset lifecycle store.
|
||||
func WithCollectibleStore(collectibles store.CollectibleUsernameStore) Option {
|
||||
return func(s *Service) { s.collectibles = collectibles }
|
||||
}
|
||||
|
||||
// WithNotifier injects the edge invalidation/update hook.
|
||||
func WithNotifier(notifier PeerUsernameNotifier) Option {
|
||||
return func(s *Service) { s.notifier = notifier }
|
||||
}
|
||||
|
||||
// WithURLTemplate configures the collectible asset landing URL template. An
|
||||
// empty template keeps the public-link default route.
|
||||
func WithURLTemplate(template string) Option {
|
||||
return func(s *Service) { s.urlTemplate = strings.TrimSpace(template) }
|
||||
}
|
||||
|
||||
// WithPublicBaseURL configures the public-link root the default collectible URL
|
||||
// route is derived from.
|
||||
func WithPublicBaseURL(baseURL string) Option {
|
||||
return func(s *Service) { s.publicBaseURL = strings.TrimSpace(baseURL) }
|
||||
}
|
||||
|
||||
// WithClock injects the clock (tests).
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger injects the service logger.
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return func(s *Service) {
|
||||
if log != nil {
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates the collectible username service. Every dependency is
|
||||
// optional: a service without stores answers with a configuration error instead
|
||||
// of panicking, which keeps partial deployments diagnosable.
|
||||
func NewService(opts ...Option) *Service {
|
||||
s := &Service{now: time.Now, log: zap.NewNop()}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetPeerUsernameNotifier injects the edge hook after construction. The RPC
|
||||
// router is built after the app services, so the notification port is bound
|
||||
// here rather than through NewService.
|
||||
func (s *Service) SetPeerUsernameNotifier(notifier PeerUsernameNotifier) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.notifier = notifier
|
||||
}
|
||||
|
||||
// Configured reports whether both registries are installed.
|
||||
func (s *Service) Configured() bool {
|
||||
return s != nil && s.registry != nil && s.collectibles != nil
|
||||
}
|
||||
|
||||
func (s *Service) registryStore() (store.UsernameRegistryStore, error) {
|
||||
if s == nil || s.registry == nil {
|
||||
return nil, fmt.Errorf("username registry store is not configured")
|
||||
}
|
||||
return s.registry, nil
|
||||
}
|
||||
|
||||
func (s *Service) collectibleStore() (store.CollectibleUsernameStore, error) {
|
||||
if s == nil || s.collectibles == nil {
|
||||
return nil, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
return s.collectibles, nil
|
||||
}
|
||||
|
||||
// PeerUsernames returns the peer's username vector in projection order.
|
||||
func (s *Service) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return nil, nil
|
||||
}
|
||||
list, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return domain.SortUsernames(list), nil
|
||||
}
|
||||
|
||||
// UsernamesBatch resolves several peers in one round trip. Peers holding no
|
||||
// usernames are absent from the result.
|
||||
func (s *Service) UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unique := make([]domain.Peer, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if !validPeer(peer) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
unique = append(unique, peer)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return map[domain.Peer][]domain.Username{}, nil
|
||||
}
|
||||
batch, err := registry.PeerUsernamesBatch(ctx, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[domain.Peer][]domain.Username, len(batch))
|
||||
for peer, list := range batch {
|
||||
if len(list) == 0 {
|
||||
continue
|
||||
}
|
||||
out[peer] = domain.SortUsernames(list)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ToggleUsername activates or deactivates one collectible row. The editable
|
||||
// slot is never touched: it is owned by account/channels.updateUsername.
|
||||
func (s *Service) ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if username == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
current, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := registry.SetUsernameActive(ctx, peer, username, active)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// ReorderUsernames rewrites the collectible order. order must be a permutation
|
||||
// of the peer's collectible usernames; the editable slot always projects first.
|
||||
func (s *Service) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
normalized := make([]string, 0, len(order))
|
||||
for _, name := range order {
|
||||
normalized = append(normalized, domain.NormalizeUsername(name))
|
||||
}
|
||||
current, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := domain.ValidateUsernameReorder(current, normalized); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := registry.ReorderUsernames(ctx, peer, normalized)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// DeactivateAllUsernames clears the active flag on every collectible row.
|
||||
func (s *Service) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
changed, err := registry.DeactivateAllUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// CollectibleInfo returns the fragment.collectibleInfo projection for a name.
|
||||
func (s *Service) CollectibleInfo(ctx context.Context, username string) (domain.CollectibleInfo, error) {
|
||||
asset, err := s.Collectible(ctx, username)
|
||||
if err != nil {
|
||||
return domain.CollectibleInfo{}, err
|
||||
}
|
||||
return asset.Info(), nil
|
||||
}
|
||||
|
||||
// Collectible looks up the asset behind a collectible username.
|
||||
func (s *Service) Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, err
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if !domain.ValidCollectibleUsername(username) {
|
||||
return domain.CollectibleUsername{}, domain.ErrUsernameInvalid
|
||||
}
|
||||
return collectibles.CollectibleUsername(ctx, username)
|
||||
}
|
||||
|
||||
// Mint creates a collectible asset, optionally assigning it in the same
|
||||
// command. An empty URL is rendered from the configured template and an unset
|
||||
// purchase date is stamped with the service clock, so the stored provenance is
|
||||
// always complete and reproducible.
|
||||
func (s *Service) Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if strings.TrimSpace(req.URL) == "" {
|
||||
req.URL = s.CollectibleURL(req.Username)
|
||||
}
|
||||
if req.PurchaseDate.IsZero() {
|
||||
req.PurchaseDate = s.now().UTC()
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
asset, created, err := collectibles.MintCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if created {
|
||||
s.notifyPeers(ctx, req.Owner, asset.Owner)
|
||||
}
|
||||
return asset, created, nil
|
||||
}
|
||||
|
||||
// Transfer moves the asset to req.To, either out of the vault or from the
|
||||
// current holder. Both the previous and the new holder are invalidated.
|
||||
func (s *Service) Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
asset, changed, err := collectibles.TransferCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, previousOwner, req.To, asset.Owner)
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// Revoke returns the asset to the vault, or burns it when req.Burn is set.
|
||||
func (s *Service) Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
asset, changed, err := collectibles.RevokeCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, previousOwner, asset.Owner)
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// Delete removes an asset outright, releasing its name and discarding its
|
||||
// provenance. Revoke with Burn retires an asset but keeps the history; this is
|
||||
// the operator's escape hatch for an asset issued by mistake.
|
||||
//
|
||||
// The previous owner is notified exactly like a revoke: the peer's projection
|
||||
// still carries the username until it is invalidated.
|
||||
func (s *Service) Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
deleted, err := collectibles.DeleteCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if deleted {
|
||||
s.notifyPeers(ctx, previousOwner, domain.Peer{})
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// List is the admin listing query. The limit is always bounded, so an
|
||||
// unfiltered operator request can never ask the store for an unbounded scan.
|
||||
func (s *Service) List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Status != "" && !filter.Status.Valid() {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if filter.Owner.Type != "" && !validPeer(filter.Owner) {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
filter.Query = domain.NormalizeUsername(filter.Query)
|
||||
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
||||
return collectibles.ListCollectibleUsernames(ctx, filter)
|
||||
}
|
||||
|
||||
// Transfers returns the provenance log of one asset, newest first.
|
||||
func (s *Service) Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if collectibleID <= 0 {
|
||||
return nil, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return collectibles.CollectibleUsernameTransfers(ctx, collectibleID, clampLimit(limit, defaultTransferLimit, maxTransferLimit))
|
||||
}
|
||||
|
||||
// CollectibleURL renders the asset landing URL for a name. The operator
|
||||
// template wins; {username} is substituted when present and appended as a path
|
||||
// segment when it is not. Without a template the public-link default route is
|
||||
// used, and without any configured root the URL stays empty rather than
|
||||
// pointing at an unrelated host.
|
||||
func (s *Service) CollectibleURL(username string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
template := strings.TrimSpace(s.urlTemplate)
|
||||
if template != "" {
|
||||
if strings.Contains(template, usernamePlaceholder) {
|
||||
return strings.ReplaceAll(template, usernamePlaceholder, username)
|
||||
}
|
||||
return strings.TrimRight(template, "/") + "/" + username
|
||||
}
|
||||
if strings.TrimSpace(s.publicBaseURL) == "" {
|
||||
return ""
|
||||
}
|
||||
return links.Build(s.publicBaseURL, defaultCollectibleURLPath+"/"+username, nil)
|
||||
}
|
||||
|
||||
// currentOwner reads the holder before a lifecycle mutation so the previous
|
||||
// peer's projection is invalidated too. It is best effort: a missing or
|
||||
// unreadable asset only means there is no extra peer to notify, and the
|
||||
// mutation itself remains the authority.
|
||||
func (s *Service) currentOwner(ctx context.Context, collectibles store.CollectibleUsernameStore, username string) domain.Peer {
|
||||
asset, err := collectibles.CollectibleUsername(ctx, username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
s.log.Debug("read collectible username owner before mutation",
|
||||
zap.String("username", username),
|
||||
zap.Error(err))
|
||||
}
|
||||
return domain.Peer{}
|
||||
}
|
||||
if !asset.Owned() {
|
||||
return domain.Peer{}
|
||||
}
|
||||
return asset.Owner
|
||||
}
|
||||
|
||||
// notifyPeers invalidates projections and pushes updates for every distinct
|
||||
// affected peer. Notification is best effort: the registry mutation already
|
||||
// committed, and a failed push converges through the client's next
|
||||
// authoritative peer read.
|
||||
func (s *Service) notifyPeers(ctx context.Context, peers ...domain.Peer) {
|
||||
if s == nil || s.notifier == nil {
|
||||
return
|
||||
}
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if !validPeer(peer) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
if err := s.notifier.NotifyPeerUsernamesChanged(ctx, peer); err != nil {
|
||||
s.log.Warn("notify collectible username change failed",
|
||||
zap.String("peer_type", string(peer.Type)),
|
||||
zap.Int64("peer_id", peer.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validPeer(peer domain.Peer) bool {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clampLimit(limit, fallback, maximum int) int {
|
||||
if limit <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if limit > maximum {
|
||||
return maximum
|
||||
}
|
||||
return limit
|
||||
}
|
||||
734
internal/app/usernames/service_test.go
Normal file
734
internal/app/usernames/service_test.go
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
package usernames
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
testUser = domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
testChannel = domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
|
||||
testClock = time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
type toggleCall struct {
|
||||
peer domain.Peer
|
||||
username string
|
||||
active bool
|
||||
}
|
||||
|
||||
// fakeRegistry is an in-memory domain.Username registry recording exactly what
|
||||
// the service asked it to do, so the tests can assert normalisation reached the
|
||||
// store and validation did not.
|
||||
type fakeRegistry struct {
|
||||
lists map[domain.Peer][]domain.Username
|
||||
toggles []toggleCall
|
||||
orders [][]string
|
||||
clears []domain.Peer
|
||||
changed bool
|
||||
batchErr error
|
||||
}
|
||||
|
||||
func newFakeRegistry() *fakeRegistry {
|
||||
return &fakeRegistry{lists: map[domain.Peer][]domain.Username{}, changed: true}
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
|
||||
if f.batchErr != nil {
|
||||
return nil, f.batchErr
|
||||
}
|
||||
return append([]domain.Username(nil), f.lists[peer]...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
if f.batchErr != nil {
|
||||
return nil, f.batchErr
|
||||
}
|
||||
out := make(map[domain.Peer][]domain.Username, len(peers))
|
||||
for _, peer := range peers {
|
||||
if list, ok := f.lists[peer]; ok {
|
||||
out[peer] = append([]domain.Username(nil), list...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
||||
f.toggles = append(f.toggles, toggleCall{peer: peer, username: username, active: active})
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) ReorderUsernames(_ context.Context, _ domain.Peer, order []string) (bool, error) {
|
||||
f.orders = append(f.orders, append([]string(nil), order...))
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
|
||||
f.clears = append(f.clears, peer)
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
// fakeCollectibles records the lifecycle commands and serves stored assets.
|
||||
type fakeCollectibles struct {
|
||||
assets map[string]domain.CollectibleUsername
|
||||
mints []domain.MintCollectibleUsernameRequest
|
||||
transfers []domain.TransferCollectibleUsernameRequest
|
||||
revokes []domain.RevokeCollectibleUsernameRequest
|
||||
deletes []domain.DeleteCollectibleUsernameRequest
|
||||
filters []domain.CollectibleUsernameFilter
|
||||
logLimits []int
|
||||
created bool
|
||||
changed bool
|
||||
}
|
||||
|
||||
func newFakeCollectibles() *fakeCollectibles {
|
||||
return &fakeCollectibles{assets: map[string]domain.CollectibleUsername{}, created: true, changed: true}
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.mints = append(f.mints, req)
|
||||
asset := domain.CollectibleUsername{
|
||||
ID: int64(len(f.mints)), Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
|
||||
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount, URL: req.URL,
|
||||
}
|
||||
if req.Owner.Type != "" {
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.Owner
|
||||
asset.OriginalOwner = req.Owner
|
||||
}
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.created, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.transfers = append(f.transfers, req)
|
||||
asset := f.assets[strings.ToLower(req.Username)]
|
||||
asset.Username = req.Username
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.To
|
||||
asset.TransferCount++
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.revokes = append(f.revokes, req)
|
||||
asset := f.assets[strings.ToLower(req.Username)]
|
||||
asset.Username = req.Username
|
||||
asset.Owner = domain.Peer{}
|
||||
asset.Status = domain.CollectibleUsernameStatusVault
|
||||
if req.Burn {
|
||||
asset.Status = domain.CollectibleUsernameStatusBurned
|
||||
}
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
f.deletes = append(f.deletes, req)
|
||||
key := strings.ToLower(req.Username)
|
||||
if _, ok := f.assets[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.assets, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
asset, ok := f.assets[strings.ToLower(username)]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
|
||||
for _, asset := range f.assets {
|
||||
if asset.ID == id {
|
||||
return asset, nil
|
||||
}
|
||||
}
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
f.filters = append(f.filters, filter)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsernameTransfers(_ context.Context, _ int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
f.logLimits = append(f.logLimits, limit)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// recordingNotifier captures the peers whose projections were invalidated.
|
||||
type recordingNotifier struct {
|
||||
peers []domain.Peer
|
||||
err error
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) NotifyPeerUsernamesChanged(_ context.Context, peer domain.Peer) error {
|
||||
n.peers = append(n.peers, peer)
|
||||
return n.err
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, registry *fakeRegistry, collectibles *fakeCollectibles, opts ...Option) (*Service, *recordingNotifier) {
|
||||
t.Helper()
|
||||
notifier := &recordingNotifier{}
|
||||
base := []Option{
|
||||
WithRegistryStore(registry),
|
||||
WithCollectibleStore(collectibles),
|
||||
WithNotifier(notifier),
|
||||
WithClock(func() time.Time { return testClock }),
|
||||
}
|
||||
return NewService(append(base, opts...)...), notifier
|
||||
}
|
||||
|
||||
func TestPeerUsernamesProjectsStoredOrder(t *testing.T) {
|
||||
// Legacy numbering: the editable slot and the first collectible both carry
|
||||
// sort_order 0, and the editable slot wins that tie, so a peer that never
|
||||
// reordered anything projects its own username first.
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "editable", Active: true, Editable: true, SortOrder: 0},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if got := projectedNames(t, service); got != "editable,alpha,zeta" {
|
||||
t.Fatalf("projection order = %v, want editable,alpha,zeta", got)
|
||||
}
|
||||
|
||||
// After a reorder that made a collectible primary, stored order decides and
|
||||
// the editable slot is no longer first: clients show usernames[0] as primary.
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "zeta", Active: true, SortOrder: 2, CollectibleID: 2},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "editable", Active: true, Editable: true, SortOrder: 1},
|
||||
}
|
||||
if got := projectedNames(t, service); got != "alpha,editable,zeta" {
|
||||
t.Fatalf("reordered projection = %v, want alpha,editable,zeta", got)
|
||||
}
|
||||
}
|
||||
|
||||
func projectedNames(t *testing.T, service *Service) string {
|
||||
t.Helper()
|
||||
list, err := service.PeerUsernames(context.Background(), testUser)
|
||||
if err != nil {
|
||||
t.Fatalf("PeerUsernames: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
got = append(got, item.Username)
|
||||
}
|
||||
return strings.Join(got, ",")
|
||||
}
|
||||
|
||||
func TestUsernamesBatchSkipsInvalidAndEmptyPeers(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}}
|
||||
registry.lists[testChannel] = nil
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
batch, err := service.UsernamesBatch(context.Background(), []domain.Peer{testUser, testUser, testChannel, {}, {Type: domain.PeerTypeUser}})
|
||||
if err != nil {
|
||||
t.Fatalf("UsernamesBatch: %v", err)
|
||||
}
|
||||
if len(batch) != 1 || len(batch[testUser]) != 1 {
|
||||
t.Fatalf("batch = %#v, want only the peer holding usernames", batch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleUsernameNormalizesBeforeStore(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "Nft_One", Active: false, CollectibleID: 1},
|
||||
}
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.ToggleUsername(context.Background(), testUser, " @Nft_One ", true)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ToggleUsername = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.toggles) != 1 || registry.toggles[0].username != "Nft_One" || !registry.toggles[0].active {
|
||||
t.Fatalf("store toggles = %#v, want normalized Nft_One", registry.toggles)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the toggled peer", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleUsernameValidatesBeforeStore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
list []domain.Username
|
||||
username string
|
||||
active bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "editable slot is not collectible",
|
||||
list: []domain.Username{{Username: "editable", Active: true, Editable: true}},
|
||||
username: "editable",
|
||||
wantErr: domain.ErrUsernameNotCollectible,
|
||||
},
|
||||
{
|
||||
name: "unknown username",
|
||||
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
|
||||
username: "missing",
|
||||
wantErr: domain.ErrUsernameNotOccupied,
|
||||
},
|
||||
{
|
||||
name: "empty username",
|
||||
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
|
||||
username: "@",
|
||||
wantErr: domain.ErrUsernameInvalid,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = test.list
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
_, err := service.ToggleUsername(context.Background(), testUser, test.username, test.active)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("ToggleUsername error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if len(registry.toggles) != 0 {
|
||||
t.Fatalf("store was called with invalid input: %#v", registry.toggles)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("notifier ran for a rejected toggle: %#v", notifier.peers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderUsernamesNormalizesPermutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
|
||||
}
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.ReorderUsernames(context.Background(), testUser, []string{"@zeta", " alpha ", "editable"})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ReorderUsernames = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "zeta,alpha,editable" {
|
||||
t.Fatalf("store order = %#v, want normalized zeta,alpha,editable", registry.orders)
|
||||
}
|
||||
if len(notifier.peers) != 1 {
|
||||
t.Fatalf("notified peers = %#v, want one", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderUsernamesRejectsIncompletePermutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "alpha", Active: true, CollectibleID: 1},
|
||||
{Username: "zeta", Active: true, CollectibleID: 2},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if _, err := service.ReorderUsernames(context.Background(), testUser, []string{"alpha"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("ReorderUsernames error = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
if len(registry.orders) != 0 {
|
||||
t.Fatalf("store was called with a non-permutation: %#v", registry.orders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReorderUsernamesAcceptsTheEditableSlot is the report "channels.reorderUsernames
|
||||
// answers USERNAME_INVALID": Telegram Desktop sends the whole visible list, and
|
||||
// core.telegram.org/api/fragment requires exactly that ("all currently active
|
||||
// usernames must be specified"), so the editable slot is a legitimate member of
|
||||
// the order -- including as its first entry, and including when it is the only
|
||||
// username the peer has.
|
||||
func TestReorderUsernamesAcceptsTheEditableSlot(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testChannel] = []domain.Username{
|
||||
{Username: "chan_slot", Active: true, Editable: true},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
|
||||
t.Fatalf("editable-only reorder: %v", err)
|
||||
}
|
||||
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "chan_slot" {
|
||||
t.Fatalf("store order = %#v, want chan_slot", registry.orders)
|
||||
}
|
||||
|
||||
// An inactive collectible does not have to be listed, and listing an unknown
|
||||
// name is still rejected.
|
||||
registry.lists[testChannel] = []domain.Username{
|
||||
{Username: "chan_slot", Active: true, Editable: true},
|
||||
{Username: "hidden", Active: false, CollectibleID: 7},
|
||||
}
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
|
||||
t.Fatalf("reorder omitting an inactive collectible: %v", err)
|
||||
}
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot", "nothere"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("reorder with an unknown name = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateAllUsernamesNotifiesPeer(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.DeactivateAllUsernames(context.Background(), testChannel)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("DeactivateAllUsernames = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.clears) != 1 || registry.clears[0] != testChannel {
|
||||
t.Fatalf("store clears = %#v", registry.clears)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testChannel {
|
||||
t.Fatalf("notified peers = %#v", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintRendersCollectibleURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
url string
|
||||
wantURL string
|
||||
username string
|
||||
}{
|
||||
{
|
||||
name: "public-link default route",
|
||||
opts: []Option{WithPublicBaseURL("https://example.test")},
|
||||
username: "alpha",
|
||||
wantURL: "https://example.test/nft/username/alpha",
|
||||
},
|
||||
{
|
||||
name: "template placeholder",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/{username}?ref=1"), WithPublicBaseURL("https://example.test")},
|
||||
username: "alpha",
|
||||
wantURL: "https://frag.example/u/alpha?ref=1",
|
||||
},
|
||||
{
|
||||
name: "template without placeholder appends the name",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/")},
|
||||
username: "alpha",
|
||||
wantURL: "https://frag.example/u/alpha",
|
||||
},
|
||||
{
|
||||
name: "explicit request URL wins",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/{username}")},
|
||||
username: "alpha",
|
||||
url: "https://operator.example/custom",
|
||||
wantURL: "https://operator.example/custom",
|
||||
},
|
||||
{
|
||||
name: "no template and no base URL keeps the URL empty",
|
||||
username: "alpha",
|
||||
wantURL: "",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles, test.opts...)
|
||||
|
||||
asset, created, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "@" + test.username, Currency: domain.CollectibleCurrencyStars, Amount: 1000, URL: test.url,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("Mint = %v, %v", created, err)
|
||||
}
|
||||
if asset.URL != test.wantURL {
|
||||
t.Fatalf("asset URL = %q, want %q", asset.URL, test.wantURL)
|
||||
}
|
||||
if len(collectibles.mints) != 1 {
|
||||
t.Fatalf("mints = %d, want 1", len(collectibles.mints))
|
||||
}
|
||||
if got := collectibles.mints[0].Username; got != test.username {
|
||||
t.Fatalf("stored username = %q, want normalized %q", got, test.username)
|
||||
}
|
||||
if !collectibles.mints[0].PurchaseDate.Equal(testClock) {
|
||||
t.Fatalf("purchase date = %v, want the service clock %v", collectibles.mints[0].PurchaseDate, testClock)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintValidatesBeforeStore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req domain.MintCollectibleUsernameRequest
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "username too short",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "ab", Currency: domain.CollectibleCurrencyStars},
|
||||
wantErr: domain.ErrUsernameInvalid,
|
||||
},
|
||||
{
|
||||
name: "unsupported currency",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: "EUR"},
|
||||
wantErr: domain.ErrCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "crypto amount without currency",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: domain.CollectibleCurrencyStars, CryptoAmount: 5},
|
||||
wantErr: domain.ErrCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "owner peer is not a username holder",
|
||||
req: domain.MintCollectibleUsernameRequest{
|
||||
Username: "alpha", Currency: domain.CollectibleCurrencyStars,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
|
||||
},
|
||||
wantErr: domain.ErrCollectibleUsernameStateInvalid,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), test.req); !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Mint error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if len(collectibles.mints) != 0 {
|
||||
t.Fatalf("store was called with invalid input: %#v", collectibles.mints)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("notifier ran for a rejected mint: %#v", notifier.peers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintNotifiesOwnerOnly(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles, WithPublicBaseURL("https://example.test"))
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "vaulted", Currency: domain.CollectibleCurrencyStars,
|
||||
}); err != nil {
|
||||
t.Fatalf("Mint vault: %v", err)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("vault mint notified %#v, want nothing", notifier.peers)
|
||||
}
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "assigned", Currency: domain.CollectibleCurrencyStars, Owner: testUser,
|
||||
}); err != nil {
|
||||
t.Fatalf("Mint assigned: %v", err)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the assigned owner", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferNotifiesPreviousAndNewOwner(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
}
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
_, changed, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{
|
||||
Username: "@Alpha", To: testChannel, Actor: "admin", CommandKey: "cmd-1",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("Transfer = %v, %v", changed, err)
|
||||
}
|
||||
if len(collectibles.transfers) != 1 || collectibles.transfers[0].Username != "Alpha" {
|
||||
t.Fatalf("stored transfer = %#v, want normalized username", collectibles.transfers)
|
||||
}
|
||||
if len(notifier.peers) != 2 {
|
||||
t.Fatalf("notified peers = %#v, want previous and new owner", notifier.peers)
|
||||
}
|
||||
seen := map[domain.Peer]bool{notifier.peers[0]: true, notifier.peers[1]: true}
|
||||
if !seen[testUser] || !seen[testChannel] {
|
||||
t.Fatalf("notified peers = %#v, want %v and %v", notifier.peers, testUser, testChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeNotifiesPreviousOwner(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
}
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
asset, changed, err := service.Revoke(context.Background(), domain.RevokeCollectibleUsernameRequest{
|
||||
Username: "alpha", Burn: true, Actor: "admin",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("Revoke = %v, %v", changed, err)
|
||||
}
|
||||
if asset.Status != domain.CollectibleUsernameStatusBurned {
|
||||
t.Fatalf("asset status = %q, want burned", asset.Status)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the previous owner", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleInfoProjectsPurchaseRecord(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
PurchaseDate: testClock, Currency: domain.CollectibleCurrencyStars, Amount: 2500,
|
||||
URL: "https://example.test/nft/username/alpha",
|
||||
}
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
info, err := service.CollectibleInfo(context.Background(), "@ALPHA")
|
||||
if err != nil {
|
||||
t.Fatalf("CollectibleInfo: %v", err)
|
||||
}
|
||||
if info.PurchaseDate != int(testClock.Unix()) || info.Amount != 2500 || info.Currency != domain.CollectibleCurrencyStars {
|
||||
t.Fatalf("collectible info = %#v", info)
|
||||
}
|
||||
if _, err := service.CollectibleInfo(context.Background(), "ab"); !errors.Is(err, domain.ErrUsernameInvalid) {
|
||||
t.Fatalf("CollectibleInfo short name error = %v, want ErrUsernameInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndTransfersBoundThePage(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Query: " @Alpha ", Limit: 0}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Limit: 100000}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(collectibles.filters) != 2 ||
|
||||
collectibles.filters[0].Limit != defaultListLimit || collectibles.filters[0].Query != "Alpha" ||
|
||||
collectibles.filters[1].Limit != maxListLimit {
|
||||
t.Fatalf("filters = %#v", collectibles.filters)
|
||||
}
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Status: "sold"}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
|
||||
t.Fatalf("List accepted an unmodelled status")
|
||||
}
|
||||
|
||||
if _, err := service.Transfers(context.Background(), 7, 0); err != nil {
|
||||
t.Fatalf("Transfers: %v", err)
|
||||
}
|
||||
if len(collectibles.logLimits) != 1 || collectibles.logLimits[0] != defaultTransferLimit {
|
||||
t.Fatalf("transfer log limits = %#v", collectibles.logLimits)
|
||||
}
|
||||
if _, err := service.Transfers(context.Background(), 0, 10); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("Transfers accepted a zero collectible id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWithoutStoresReportsConfiguration(t *testing.T) {
|
||||
service := NewService()
|
||||
if service.Configured() {
|
||||
t.Fatal("Configured = true without stores")
|
||||
}
|
||||
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
|
||||
t.Fatal("PeerUsernames accepted a missing registry store")
|
||||
}
|
||||
if _, err := service.ToggleUsername(context.Background(), testUser, "alpha", true); err == nil {
|
||||
t.Fatal("ToggleUsername accepted a missing registry store")
|
||||
}
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{Username: "alpha"}); err == nil {
|
||||
t.Fatal("Mint accepted a missing collectible store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilServiceIsSafe(t *testing.T) {
|
||||
var service *Service
|
||||
service.SetPeerUsernameNotifier(&recordingNotifier{})
|
||||
if service.Configured() {
|
||||
t.Fatal("nil service reported configured")
|
||||
}
|
||||
if url := service.CollectibleURL("alpha"); url != "" {
|
||||
t.Fatalf("nil service URL = %q", url)
|
||||
}
|
||||
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
|
||||
t.Fatal("nil service PeerUsernames returned no error")
|
||||
}
|
||||
if _, _, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{Username: "alpha", To: testUser}); err == nil {
|
||||
t.Fatal("nil service Transfer returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierFailureDoesNotFailTheMutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "alpha", Active: true, CollectibleID: 1},
|
||||
}
|
||||
notifier := &recordingNotifier{err: errors.New("push failed")}
|
||||
service := NewService(
|
||||
WithRegistryStore(registry),
|
||||
WithCollectibleStore(newFakeCollectibles()),
|
||||
WithNotifier(notifier),
|
||||
)
|
||||
|
||||
changed, err := service.ToggleUsername(context.Background(), testUser, "alpha", false)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ToggleUsername = %v, %v; committed mutation must survive a failed push", changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceDeleteNotifiesPreviousOwner covers the hard delete: the request is
|
||||
// normalised and validated before the store is touched, and the peer that held
|
||||
// the asset is invalidated so its projection stops advertising the username.
|
||||
func TestServiceDeleteNotifiesPreviousOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
registry := newFakeRegistry()
|
||||
collectibles := newFakeCollectibles()
|
||||
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 501}
|
||||
collectibles.assets["gone"] = domain.CollectibleUsername{
|
||||
ID: 9, Username: "Gone", Status: domain.CollectibleUsernameStatusOwned, Owner: holder,
|
||||
}
|
||||
svc, notifier := newTestService(t, registry, collectibles)
|
||||
|
||||
deleted, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: " @Gone ", Actor: "admin", Reason: "issued by mistake",
|
||||
})
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
if len(collectibles.deletes) != 1 || collectibles.deletes[0].Username != "Gone" {
|
||||
t.Fatalf("store received %+v, want the normalised name", collectibles.deletes)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != holder {
|
||||
t.Fatalf("notified peers = %#v, want the previous owner %+v", notifier.peers, holder)
|
||||
}
|
||||
|
||||
// An invalid name never reaches the store.
|
||||
before := len(collectibles.deletes)
|
||||
if _, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{Username: "no"}); err == nil {
|
||||
t.Fatalf("delete of a too-short name = nil error, want rejection")
|
||||
}
|
||||
if len(collectibles.deletes) != before {
|
||||
t.Fatalf("store was called with an invalid request: %+v", collectibles.deletes)
|
||||
}
|
||||
|
||||
// Nothing live left is not an error, and nothing is notified.
|
||||
notifier.peers = nil
|
||||
deleted, err = svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: "absentname", Actor: "admin", Reason: "again",
|
||||
})
|
||||
if err != nil || deleted {
|
||||
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("no-op delete notified %+v", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -18,7 +20,7 @@ const (
|
|||
DefaultContactProjectionCacheTTL = 24 * time.Hour
|
||||
|
||||
contactSnapshotMaxViewers = 4096
|
||||
contactReverseSnapshotOwnerCap = 16
|
||||
contactReversePairMaxEntries = 262144
|
||||
contactPersonalPhotoSnapshotCap = 4096
|
||||
)
|
||||
|
||||
|
|
@ -34,11 +36,32 @@ type personalPhotoSnapshot struct {
|
|||
expireAt time.Time
|
||||
}
|
||||
|
||||
type reverseContactKey struct {
|
||||
ownerUserID int64
|
||||
contactUserID int64
|
||||
}
|
||||
|
||||
type reverseContactSnapshot struct {
|
||||
contact domain.Contact
|
||||
found bool
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
type reverseContactEntry struct {
|
||||
key reverseContactKey
|
||||
snapshot reverseContactSnapshot
|
||||
}
|
||||
|
||||
type contactSnapshotLoadResult struct {
|
||||
snap contactAccountSnapshot
|
||||
stored bool
|
||||
}
|
||||
|
||||
type reverseContactLoadResult struct {
|
||||
contacts map[int64]domain.Contact
|
||||
stored bool
|
||||
}
|
||||
|
||||
type personalPhotoSnapshotLoadResult struct {
|
||||
snap personalPhotoSnapshot
|
||||
stored bool
|
||||
|
|
@ -59,6 +82,10 @@ type CachedContactStore struct {
|
|||
mu sync.RWMutex
|
||||
contacts map[int64]contactAccountSnapshot
|
||||
personalPhotos map[int64]personalPhotoSnapshot
|
||||
reverse map[reverseContactKey]*list.Element
|
||||
reverseLRU *list.List
|
||||
reverseByOwner map[int64]map[int64]struct{}
|
||||
reverseCap int
|
||||
epoch uint64
|
||||
sf singleflight.Group
|
||||
}
|
||||
|
|
@ -76,6 +103,10 @@ func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedC
|
|||
now: time.Now,
|
||||
contacts: make(map[int64]contactAccountSnapshot, 1024),
|
||||
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
|
||||
reverse: make(map[reverseContactKey]*list.Element, 4096),
|
||||
reverseLRU: list.New(),
|
||||
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
|
||||
reverseCap: contactReversePairMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,24 +162,90 @@ func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int6
|
|||
if len(owners) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if len(owners) > contactReverseSnapshotOwnerCap {
|
||||
// Large fan-out should keep using the store's set query until a dedicated
|
||||
// reverse-contact read model exists; loading hundreds of full contact
|
||||
// lists would be worse than one batched SQL.
|
||||
return c.inner.GetReverseContacts(ctx, userID, owners)
|
||||
}
|
||||
missing := make([]int64, 0, len(owners))
|
||||
now := c.now()
|
||||
for _, ownerID := range owners {
|
||||
snap, err := c.contactSnapshot(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Reuse a full owner snapshot when another hot path already loaded it.
|
||||
// Do not cold-load one full list per owner: a large projection would turn
|
||||
// into N SQL queries.
|
||||
if snap, ok := c.lookupContactSnapshot(ownerID, now); ok {
|
||||
if contact, found := snap.contacts[userID]; found {
|
||||
out[ownerID] = cloneCachedContact(contact)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if contact, ok := snap.contacts[userID]; ok {
|
||||
if contact, found, cached := c.lookupReverseContact(ownerID, userID, now); cached {
|
||||
if found {
|
||||
out[ownerID] = contact
|
||||
}
|
||||
continue
|
||||
}
|
||||
missing = append(missing, ownerID)
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
loaded, err := c.loadReverseContacts(ctx, userID, missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ownerID, contact := range loaded {
|
||||
if contact.User.ID != 0 {
|
||||
out[ownerID] = cloneCachedContact(contact)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadReverseContacts performs at most one batched cold-store read for all
|
||||
// missing owner→viewer pairs, then caches both hits and misses. Privacy
|
||||
// projection therefore stays memory-only after warm-up instead of repeating a
|
||||
// reverse-contact SQL query on every large user vector.
|
||||
func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
owners := append([]int64(nil), ownerUserIDs...)
|
||||
sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] })
|
||||
sfKey := fmt.Sprintf("contact-reverse:%d:%v", userID, owners)
|
||||
for {
|
||||
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
|
||||
loadEpoch := c.cacheEpoch()
|
||||
contacts, err := c.inner.GetReverseContacts(ctx, userID, owners)
|
||||
if err != nil {
|
||||
return reverseContactLoadResult{}, err
|
||||
}
|
||||
now := c.now()
|
||||
expireAt := now.Add(c.ttl)
|
||||
c.mu.Lock()
|
||||
stored := c.epoch == loadEpoch
|
||||
if stored {
|
||||
for _, ownerID := range owners {
|
||||
key := reverseContactKey{ownerUserID: ownerID, contactUserID: userID}
|
||||
contact, found := contacts[ownerID]
|
||||
c.storeReverseContactLocked(key, reverseContactSnapshot{
|
||||
contact: cloneCachedContact(contact),
|
||||
found: found,
|
||||
expireAt: expireAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return reverseContactLoadResult{
|
||||
contacts: cloneCachedContactMap(contacts),
|
||||
stored: stored,
|
||||
}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := v.(reverseContactLoadResult)
|
||||
if result.stored {
|
||||
return result.contacts, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
contact, err := c.inner.Upsert(ctx, userID, input)
|
||||
if err == nil {
|
||||
|
|
@ -367,6 +464,59 @@ func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.
|
|||
return snap, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int64, now time.Time) (domain.Contact, bool, bool) {
|
||||
key := reverseContactKey{ownerUserID: ownerUserID, contactUserID: contactUserID}
|
||||
c.mu.Lock()
|
||||
element, ok := c.reverse[key]
|
||||
if !ok {
|
||||
c.mu.Unlock()
|
||||
return domain.Contact{}, false, false
|
||||
}
|
||||
entry := element.Value.(*reverseContactEntry)
|
||||
snap := entry.snapshot
|
||||
if !snap.expireAt.After(now) {
|
||||
c.removeReverseElementLocked(element)
|
||||
c.mu.Unlock()
|
||||
return domain.Contact{}, false, false
|
||||
}
|
||||
c.reverseLRU.MoveToFront(element)
|
||||
c.mu.Unlock()
|
||||
return cloneCachedContact(snap.contact), snap.found, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, snapshot reverseContactSnapshot) {
|
||||
if element, ok := c.reverse[key]; ok {
|
||||
entry := element.Value.(*reverseContactEntry)
|
||||
entry.snapshot = snapshot
|
||||
c.reverseLRU.MoveToFront(element)
|
||||
return
|
||||
}
|
||||
element := c.reverseLRU.PushFront(&reverseContactEntry{key: key, snapshot: snapshot})
|
||||
c.reverse[key] = element
|
||||
if c.reverseByOwner[key.ownerUserID] == nil {
|
||||
c.reverseByOwner[key.ownerUserID] = make(map[int64]struct{})
|
||||
}
|
||||
c.reverseByOwner[key.ownerUserID][key.contactUserID] = struct{}{}
|
||||
for c.reverseLRU.Len() > c.reverseCap {
|
||||
c.removeReverseElementLocked(c.reverseLRU.Back())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) removeReverseElementLocked(element *list.Element) {
|
||||
if element == nil {
|
||||
return
|
||||
}
|
||||
entry := element.Value.(*reverseContactEntry)
|
||||
delete(c.reverse, entry.key)
|
||||
if viewers := c.reverseByOwner[entry.key.ownerUserID]; viewers != nil {
|
||||
delete(viewers, entry.key.contactUserID)
|
||||
if len(viewers) == 0 {
|
||||
delete(c.reverseByOwner, entry.key.ownerUserID)
|
||||
}
|
||||
}
|
||||
c.reverseLRU.Remove(element)
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
||||
if c == nil || len(ids) == 0 {
|
||||
return
|
||||
|
|
@ -379,6 +529,11 @@ func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
|||
}
|
||||
delete(c.contacts, id)
|
||||
delete(c.personalPhotos, id)
|
||||
for contactUserID := range c.reverseByOwner[id] {
|
||||
if element, ok := c.reverse[reverseContactKey{ownerUserID: id, contactUserID: contactUserID}]; ok {
|
||||
c.removeReverseElementLocked(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
|
@ -391,6 +546,9 @@ func (c *CachedContactStore) FlushReadModelCache() {
|
|||
c.epoch++
|
||||
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
|
||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
|
||||
c.reverse = make(map[reverseContactKey]*list.Element, 4096)
|
||||
c.reverseLRU.Init()
|
||||
c.reverseByOwner = make(map[int64]map[int64]struct{}, 1024)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
@ -415,6 +573,14 @@ func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) co
|
|||
return contactAccountSnapshot{contacts: contacts, ordered: ordered, hash: list.Hash, expireAt: expireAt}
|
||||
}
|
||||
|
||||
func cloneCachedContactMap(in map[int64]domain.Contact) map[int64]domain.Contact {
|
||||
out := make(map[int64]domain.Contact, len(in))
|
||||
for id, contact := range in {
|
||||
out[id] = cloneCachedContact(contact)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupContactIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
|
|
|
|||
|
|
@ -162,6 +162,81 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreCachesLargeReverseContactBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
owners := make([]int64, 32)
|
||||
for i := range owners {
|
||||
owners[i] = int64(i + 1)
|
||||
if i%2 == 0 {
|
||||
if _, err := base.Upsert(ctx, owners[i], domain.ContactInput{
|
||||
ContactUserID: 9001,
|
||||
FirstName: "Viewer",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner %d: %v", owners[i], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
cached := NewCachedContactStore(counting, 0)
|
||||
|
||||
first, err := cached.GetReverseContacts(ctx, 9001, owners)
|
||||
if err != nil {
|
||||
t.Fatalf("first reverse lookup: %v", err)
|
||||
}
|
||||
if len(first) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
|
||||
t.Fatalf("first reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(first), counting.reverseCalls, counting.listCalls)
|
||||
}
|
||||
second, err := cached.GetReverseContacts(ctx, 9001, owners)
|
||||
if err != nil {
|
||||
t.Fatalf("second reverse lookup: %v", err)
|
||||
}
|
||||
if len(second) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
|
||||
t.Fatalf("cached reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(second), counting.reverseCalls, counting.listCalls)
|
||||
}
|
||||
|
||||
cached.InvalidateViewers(owners[0])
|
||||
third, err := cached.GetReverseContacts(ctx, 9001, owners)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse lookup after owner invalidation: %v", err)
|
||||
}
|
||||
if len(third) != 16 || counting.reverseCalls != 2 {
|
||||
t.Fatalf("invalidated reverse hits=%d reverseCalls=%d, want 16/2", len(third), counting.reverseCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreReversePairsUsePerEntryLRU(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
for ownerID := int64(1); ownerID <= 3; ownerID++ {
|
||||
if _, err := base.Upsert(ctx, ownerID, domain.ContactInput{
|
||||
ContactUserID: 9001,
|
||||
FirstName: "Viewer",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner %d: %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
cached := NewCachedContactStore(counting, 0)
|
||||
cached.reverseCap = 2
|
||||
|
||||
for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} {
|
||||
got, err := cached.GetReverseContacts(ctx, 9001, []int64{ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("reverse owner %d: %v", ownerID, err)
|
||||
}
|
||||
if _, ok := got[ownerID]; !ok {
|
||||
t.Fatalf("reverse owner %d missing", ownerID)
|
||||
}
|
||||
}
|
||||
if counting.reverseCalls != 4 {
|
||||
t.Fatalf("reverse calls = %d, want 4 (owner 1 touched, owner 2 evicted only)", counting.reverseCalls)
|
||||
}
|
||||
if len(cached.reverse) != 2 || cached.reverseLRU.Len() != 2 {
|
||||
t.Fatalf("reverse cache map/list = %d/%d, want 2/2", len(cached.reverse), cached.reverseLRU.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package userprojection
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
|
|
@ -209,7 +210,8 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
vis = matrix[u.ID][viewer]
|
||||
}
|
||||
var perr error
|
||||
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, found, vis, profileRefs, fallbackRefs, nil)
|
||||
hasKnownContactPhone := found && contact.Phone != ""
|
||||
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, nil)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
|
|
@ -341,8 +343,9 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
|
|||
}
|
||||
|
||||
// ForViewer applies the owner-specific user view that Telegram clients expect.
|
||||
// In particular, phone is visible for self and contacts; non-contacts should not
|
||||
// receive a phone field because TDesktop will prefer it over the public name.
|
||||
// A contact relationship alone never grants phone visibility. A viewer may retain
|
||||
// an owner-scoped phone it explicitly supplied, while the target account phone is
|
||||
// governed by PhoneNumber privacy.
|
||||
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
||||
|
|
@ -489,8 +492,9 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID && !u.Bot {
|
||||
contact, found := contactsByID[u.ID]
|
||||
projected = applyContactProjection(projected, contact, found)
|
||||
hasKnownContactPhone := found && contact.Phone != ""
|
||||
var err error
|
||||
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, visibility[u.ID], profileRefs, fallbackRefs, personalRefs)
|
||||
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, hasKnownContactPhone, visibility[u.ID], profileRefs, fallbackRefs, personalRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -608,9 +612,11 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
user.ContactNote = contact.Note
|
||||
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
if contact.User.Phone != "" {
|
||||
user.Phone = contact.User.Phone
|
||||
} else {
|
||||
// contact.Phone is an owner-local fact supplied by this viewer. It may differ
|
||||
// from the target's current account phone and is safe to preserve because the
|
||||
// viewer already knew it. An empty contact.Phone must not replace or authorize
|
||||
// the target account phone carried by user.Phone.
|
||||
if contact.Phone != "" {
|
||||
user.Phone = contact.Phone
|
||||
}
|
||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
|
|
@ -623,11 +629,16 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
return user
|
||||
}
|
||||
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, hasKnownContactPhone bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone(), nil
|
||||
}
|
||||
if privacy == nil {
|
||||
// Missing privacy wiring must fail closed for an account phone. The only
|
||||
// safe exception is an owner-scoped phone the viewer explicitly supplied.
|
||||
if !hasKnownContactPhone {
|
||||
user.Phone = ""
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
// vis 为批量预取结果(projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得);
|
||||
|
|
@ -642,7 +653,7 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
|
|||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !phoneAllowed {
|
||||
if !phoneAllowed && !hasKnownContactPhone {
|
||||
user.Phone = ""
|
||||
}
|
||||
statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp)
|
||||
|
|
@ -650,10 +661,8 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
|
|||
return domain.User{}, err
|
||||
}
|
||||
if !statusAllowed {
|
||||
user.Status = domain.ApproximateUserStatus(user.LastSeenAt, int(time.Now().Unix()))
|
||||
user.LastSeenAt = 0
|
||||
if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline {
|
||||
user.Status = domain.UserStatus{Kind: domain.UserStatusRecently}
|
||||
}
|
||||
}
|
||||
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
ref.Personal = true
|
||||
|
|
|
|||
|
|
@ -119,6 +119,51 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProjectorContactWithoutKnownPhoneCannotBypassPhonePrivacy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
viewerID = int64(3101)
|
||||
ownerID = int64(3102)
|
||||
)
|
||||
contacts := memory.NewContactStore()
|
||||
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{
|
||||
ContactUserID: ownerID,
|
||||
FirstName: "Saved",
|
||||
Phone: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
|
||||
projector := New(
|
||||
WithContactStore(contacts),
|
||||
WithPrivacyEvaluator(privacy),
|
||||
)
|
||||
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{
|
||||
ID: ownerID,
|
||||
Phone: "15550003102",
|
||||
FirstName: "Owner",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer: %v", err)
|
||||
}
|
||||
owner := projectionUser(t, users, ownerID)
|
||||
if !owner.Contact || owner.Phone != "" {
|
||||
t.Fatalf("owner projection = %+v, want contact=true with hidden phone", owner)
|
||||
}
|
||||
batch, err := projector.ForViewers(ctx, []int64{viewerID}, []domain.User{{
|
||||
ID: ownerID,
|
||||
Phone: "15550003102",
|
||||
FirstName: "Owner",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewers: %v", err)
|
||||
}
|
||||
batchOwner := projectionUser(t, batch[viewerID], ownerID)
|
||||
if !batchOwner.Contact || batchOwner.Phone != "" {
|
||||
t.Fatalf("batch owner projection = %+v, want contact=true with hidden phone", batchOwner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ type usernameAvailabilityStore interface {
|
|||
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
|
||||
}
|
||||
|
||||
type moderationFlagAudienceStore interface {
|
||||
ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error)
|
||||
}
|
||||
|
||||
// Option 调整用户服务可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
|
|
@ -138,6 +142,14 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
|
|||
return s.loadBaseUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
|
||||
// shared base-user read model. Privacy uses this as a batched cold loader behind
|
||||
// its bounded process cache; no viewer projection is performed, avoiding a
|
||||
// privacy -> users -> privacy recursion.
|
||||
func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
return s.loadBaseUsersByIDs(ctx, userIDs)
|
||||
}
|
||||
|
||||
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
||||
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||
if currentUserID == 0 {
|
||||
|
|
@ -392,6 +404,23 @@ func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool
|
|||
return updated, nil
|
||||
}
|
||||
|
||||
// ModerationFlagAudience returns the bounded set of existing viewers that may
|
||||
// need an immediate updateUser after SCAM/FAKE changes. This is an online
|
||||
// accelerator only: it does not allocate PTS or create durable update events.
|
||||
func (s *Service) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) {
|
||||
if userID == 0 {
|
||||
return nil, ErrNotAuthorized
|
||||
}
|
||||
if limit <= 0 || limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
audience, ok := s.users.(moderationFlagAudienceStore)
|
||||
if !ok {
|
||||
return []int64{userID}, nil
|
||||
}
|
||||
return audience.ModerationFlagAudience(ctx, userID, limit)
|
||||
}
|
||||
|
||||
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
|
||||
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
|
|
@ -548,7 +577,11 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
return domain.User{}, false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if !validUsername(username) {
|
||||
// Resolution covers both the editable username slot (5..32) and
|
||||
// Fragment-style collectible usernames (4..32). Keep the stricter
|
||||
// validUsername check on create/update paths; only lookup accepts the
|
||||
// collectible lower bound.
|
||||
if !domain.ValidCollectibleUsername(username) {
|
||||
return domain.User{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
|
|
@ -563,7 +596,9 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
return u, true, nil
|
||||
}
|
||||
|
||||
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
|
||||
// ResolvePhone resolves a phone number only when the target's AddedByPhone
|
||||
// privacy allows the current viewer. The evaluator is backed by owner-level
|
||||
// privacy/contact snapshots in production, so this adds no per-rule SQL query.
|
||||
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
|
||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
|
|
@ -577,6 +612,28 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
|||
return u, found, err
|
||||
}
|
||||
s.putCachedUsers(ctx, u)
|
||||
if s.privacy != nil && u.ID != currentUserID {
|
||||
allowed := false
|
||||
var err error
|
||||
if batch, ok := s.privacy.(userprojection.BatchPrivacyEvaluator); ok {
|
||||
visibility, batchErr := batch.CanSeeBatch(
|
||||
ctx,
|
||||
[]int64{u.ID},
|
||||
currentUserID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
|
||||
)
|
||||
err = batchErr
|
||||
allowed = visibility[u.ID][domain.PrivacyKeyAddedByPhone]
|
||||
} else {
|
||||
allowed, err = s.privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAddedByPhone)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
if !allowed {
|
||||
return domain.User{}, false, domain.ErrPhoneNotOccupied
|
||||
}
|
||||
}
|
||||
u, err = s.projectOne(ctx, currentUserID, u)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
|
@ -44,6 +45,34 @@ func TestServiceUsernameLifecycle(t *testing.T) {
|
|||
if err != nil || !found || resolved.ID != owner.ID {
|
||||
t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err)
|
||||
}
|
||||
registry := memory.NewCollectibleUsernameStore()
|
||||
store.AttachUsernameRegistry(registry)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
if _, err := registry.SetEditableUsername(ctx, peer, updated.Username); err != nil {
|
||||
t.Fatalf("seed editable username registry: %v", err)
|
||||
}
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "nft4",
|
||||
Owner: peer,
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 1,
|
||||
Actor: "test",
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("mint four-character collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
resolved, found, err = svc.ResolveUsername(ctx, other.ID, "@NFT4")
|
||||
if err != nil || !found || resolved.ID != owner.ID {
|
||||
t.Fatalf("ResolveUsername collectible = user %+v found %v err %v, want owner", resolved, found, err)
|
||||
}
|
||||
if _, err := svc.UpdateUsername(ctx, owner.ID, "nft4"); !errors.Is(err, domain.ErrUsernameInvalid) {
|
||||
t.Fatalf("four-character editable username err = %v, want username invalid", err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed {
|
||||
t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if _, found, err := svc.ResolveUsername(ctx, other.ID, "nft4"); err != nil || found {
|
||||
t.Fatalf("inactive collectible found=%v err=%v, want hidden", found, err)
|
||||
}
|
||||
if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err)
|
||||
}
|
||||
|
|
@ -60,6 +89,42 @@ func TestServiceUsernameLifecycle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolvePhoneHonorsAddedByPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contacts := memory.NewContactStore()
|
||||
viewer, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001001", FirstName: "Viewer"})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550001002", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
|
||||
if _, err := privacy.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set AddedByPhone: %v", err)
|
||||
}
|
||||
svc := NewService(users,
|
||||
WithContactStore(contacts),
|
||||
WithPrivacyEvaluator(privacy),
|
||||
)
|
||||
|
||||
if _, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone); !errors.Is(err, domain.ErrPhoneNotOccupied) || found {
|
||||
t.Fatalf("ResolvePhone stranger found=%v err=%v, want phone not occupied", found, err)
|
||||
}
|
||||
if _, err := contacts.Upsert(ctx, target.ID, domain.ContactInput{
|
||||
ContactUserID: viewer.ID,
|
||||
FirstName: viewer.FirstName,
|
||||
}); err != nil {
|
||||
t.Fatalf("target add viewer: %v", err)
|
||||
}
|
||||
got, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone)
|
||||
if err != nil || !found || got.ID != target.ID {
|
||||
t.Fatalf("ResolvePhone contact = %+v found=%v err=%v, want target", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateProfile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewUserStore()
|
||||
|
|
|
|||
1422
internal/app/verification/service.go
Normal file
1422
internal/app/verification/service.go
Normal file
File diff suppressed because it is too large
Load diff
1416
internal/app/verification/service_test.go
Normal file
1416
internal/app/verification/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
88
internal/app/verification/worker.go
Normal file
88
internal/app/verification/worker.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// defaultNotifyInterval matches the shipped
|
||||
// TELESRV_VERIFICATION_NOTIFY_INTERVAL default.
|
||||
const defaultNotifyInterval = 15 * time.Second
|
||||
|
||||
// NotificationWorker drains the applicant-notification outbox.
|
||||
//
|
||||
// A decision is committed together with its outbox row, never with a message
|
||||
// send: @verifybot may be blocked, the applicant may be deleted, and the panel
|
||||
// must not wait on either. Delivery is therefore a separate, retrying cycle over
|
||||
// durable rows, and this worker is only its cadence.
|
||||
type NotificationWorker struct {
|
||||
service *Service
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// NewNotificationWorker creates the periodic delivery worker. Non-positive
|
||||
// interval/batch fall back to the shipped defaults, matching the rating
|
||||
// recompute worker's contract.
|
||||
func NewNotificationWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *NotificationWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = defaultNotifyInterval
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = defaultNotifyBatch
|
||||
}
|
||||
return &NotificationWorker{service: service, logger: logger, interval: interval, batch: batch}
|
||||
}
|
||||
|
||||
// Run delivers one batch immediately and then on every tick until ctx is done. A
|
||||
// disabled or store-less service exits immediately with one explicit log line
|
||||
// instead of ticking forever over a no-op.
|
||||
func (w *NotificationWorker) Run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !w.service.Ready() {
|
||||
w.logger.Info("verification notification worker disabled",
|
||||
zap.Bool("enabled", w.service.Enabled()))
|
||||
return
|
||||
}
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *NotificationWorker) runOnce(ctx context.Context) {
|
||||
if w == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
delivered, err := w.service.RunNotificationCycle(ctx, w.batch)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("verification notification cycle failed",
|
||||
zap.Int("delivered", delivered),
|
||||
zap.Int("batch", w.batch),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if delivered > 0 {
|
||||
w.logger.Info("verification notification cycle completed",
|
||||
zap.Int("delivered", delivered),
|
||||
zap.Int("batch", w.batch))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue