Merge remote-tracking branch 'upstream/main' into dev
This commit is contained in:
commit
6b29556ef8
836 changed files with 1598388 additions and 64684 deletions
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
|
@ -30,8 +31,10 @@ func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User
|
|||
}
|
||||
|
||||
type captureMailSender struct {
|
||||
to string
|
||||
code string
|
||||
to string
|
||||
code string
|
||||
requests []otpdelivery.Request
|
||||
err error
|
||||
}
|
||||
|
||||
type blockingCodeCAS struct {
|
||||
|
|
@ -116,10 +119,102 @@ func (s *blockingCodeCAS) CompareAndDelete(ctx context.Context, key, revision st
|
|||
return s.CodeStore.CompareAndDelete(ctx, key, revision)
|
||||
}
|
||||
|
||||
func (s *captureMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
|
||||
s.to = to
|
||||
s.code = code
|
||||
return nil
|
||||
func (s *captureMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
|
||||
s.to = req.Recipient
|
||||
s.code = req.Code
|
||||
s.requests = append(s.requests, req)
|
||||
return otpdelivery.Result{}, s.err
|
||||
}
|
||||
|
||||
func TestLoginEmailDeliveryCarriesPurposeAndStableID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureMailSender{}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
|
||||
u := createUser(t, users, "15550010150")
|
||||
|
||||
pattern, length, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "Alice@Example.Test", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SendLoginEmailCode: %v", err)
|
||||
}
|
||||
if pattern == "" || length != 6 || len(sender.requests) != 1 {
|
||||
t.Fatalf("pattern=%q length=%d requests=%d", pattern, length, len(sender.requests))
|
||||
}
|
||||
req := sender.requests[0]
|
||||
if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginEmailChange || req.Channel != otpdelivery.ChannelEmail ||
|
||||
req.Recipient != "alice@example.test" || len(req.Code) != 6 {
|
||||
t.Fatalf("request = %+v", req)
|
||||
}
|
||||
snapshot, found, err := codes.GetSnapshot(ctx, loginEmailVerifyChangePrefix+fmt.Sprint(u.ID))
|
||||
if err != nil || !found || snapshot.Record.DeliveryID != req.DeliveryID || snapshot.Record.Code != req.Code {
|
||||
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEmailSetupDeliveryUsesSetupPurpose(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureMailSender{}
|
||||
phone := "15550010151"
|
||||
phoneHash := "setup-purpose-hash"
|
||||
if err := codes.Set(ctx, phoneHash, store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: phone,
|
||||
Channel: codeChannelEmailSetupRequired,
|
||||
}, time.Minute); err != nil {
|
||||
t.Fatalf("seed setup code: %v", err)
|
||||
}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(memory.NewUserStore()),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
|
||||
|
||||
if _, _, err := svc.SendLoginEmailCode(ctx, 0, phone, phoneHash, "new@example.test", true); err != nil {
|
||||
t.Fatalf("SendLoginEmailCode setup: %v", err)
|
||||
}
|
||||
if len(sender.requests) != 1 || sender.requests[0].Purpose != otpdelivery.PurposeLoginEmailSetup {
|
||||
t.Fatalf("requests = %+v", sender.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEmailExplicitRejectionDeletesOnlyCurrentAttempt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 400, Code: "RECIPIENT_INVALID"}}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
|
||||
u := createUser(t, users, "15550010152")
|
||||
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
|
||||
|
||||
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "bad@example.test", false); err == nil {
|
||||
t.Fatal("explicit rejection succeeded")
|
||||
}
|
||||
if _, found, err := codes.Get(ctx, key); err != nil || found {
|
||||
t.Fatalf("rejected code found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEmailUnknownOutcomeReturnsSuccessAndKeepsCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureMailSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("ack lost")}}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
|
||||
u := createUser(t, users, "15550010153")
|
||||
|
||||
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "unknown@example.test", false); err != nil {
|
||||
t.Fatalf("unknown outcome: %v", err)
|
||||
}
|
||||
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
|
||||
if rec, found, err := codes.Get(ctx, key); err != nil || !found || rec.Code != sender.code {
|
||||
t.Fatalf("unknown code=%+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -53,27 +55,60 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey
|
|||
if s.emailSignupEnabled {
|
||||
return s.sendChangePhoneCodeByEmail(ctx, userID, authKeyID, sessionID, phone)
|
||||
}
|
||||
if strings.TrimSpace(s.phoneChangeCode) == "" {
|
||||
if s.phoneCodeSender == nil && strings.TrimSpace(s.phoneChangeCode) == "" {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
|
||||
}
|
||||
hash, err := phoneChangeHash()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
code := s.phoneChangeCode
|
||||
channel := store.PhoneCodeChannelPhone
|
||||
deliveryID := ""
|
||||
if s.phoneCodeSender != nil {
|
||||
code, err = randomDigits(s.phoneCodeLength)
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
deliveryID, err = otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
channel = store.PhoneCodeChannelSMS
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: phone,
|
||||
Code: s.phoneChangeCode,
|
||||
Channel: store.PhoneCodeChannelPhone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: channel,
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
UserID: userID,
|
||||
AuthKeyID: authKeyID,
|
||||
SessionID: sessionID,
|
||||
MaxAttempts: s.phoneChangeMaxAttempts,
|
||||
}
|
||||
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
|
||||
}
|
||||
if s.phoneCodeSender != nil {
|
||||
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeChangePhone,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: phone,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
||||
return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change code: %w", cleanupErr))
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
}
|
||||
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +138,15 @@ func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64,
|
|||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
ttl := s.phoneChangeCodeTTL
|
||||
deliveryID, err := otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: store.PhoneCodeChannelEmailLogin,
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
Email: email,
|
||||
|
|
@ -115,11 +155,24 @@ func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64,
|
|||
SessionID: sessionID,
|
||||
MaxAttempts: s.phoneChangeMaxAttempts,
|
||||
}
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
if err := s.codes.Set(ctx, hash, rec, ttl); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
|
||||
}
|
||||
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, ttl); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("send phone change email code: %w", err)
|
||||
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeChangePhone,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: email,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
||||
return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change email code: %w", cleanupErr))
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryEmail, EmailPattern: emailPattern(email), Length: len(code)}, nil
|
||||
}
|
||||
|
|
@ -165,7 +218,9 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, orig
|
|||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
consumed := verified.Record
|
||||
channelOK := consumed.Channel == store.PhoneCodeChannelPhone || consumed.Channel == store.PhoneCodeChannelEmailLogin
|
||||
channelOK := consumed.Channel == store.PhoneCodeChannelPhone ||
|
||||
consumed.Channel == store.PhoneCodeChannelSMS ||
|
||||
consumed.Channel == store.PhoneCodeChannelEmailLogin
|
||||
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || !channelOK {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
|
@ -30,6 +31,16 @@ type recordingPhoneChangeStore struct {
|
|||
last domain.PhoneChangeRequest
|
||||
}
|
||||
|
||||
type trackingPhoneCodeStore struct {
|
||||
store.CodeStore
|
||||
lastHash string
|
||||
}
|
||||
|
||||
func (s *trackingPhoneCodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
|
||||
s.lastHash = hash
|
||||
return s.CodeStore.Set(ctx, hash, code, ttl)
|
||||
}
|
||||
|
||||
func (s *recordingPhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
|
||||
s.mu.Lock()
|
||||
s.last = req
|
||||
|
|
@ -67,6 +78,53 @@ func newPhoneChangeFixture(t *testing.T) phoneChangeFixture {
|
|||
return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID, changes: changes}
|
||||
}
|
||||
|
||||
func TestPhoneChangeWebhookDeliversRandomScopedCode(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
sender := &captureMailSender{}
|
||||
f.service.phoneCodeSender = sender
|
||||
f.service.phoneCodeLength = 6
|
||||
f.service.phoneChangeCode = ""
|
||||
|
||||
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012020")
|
||||
if err != nil {
|
||||
t.Fatalf("SendChangePhoneCode: %v", err)
|
||||
}
|
||||
if hash == "" || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 || len(sender.requests) != 1 {
|
||||
t.Fatalf("hash=%q delivery=%+v requests=%d", hash, delivery, len(sender.requests))
|
||||
}
|
||||
req := sender.requests[0]
|
||||
if req.Purpose != otpdelivery.PurposeChangePhone || req.Channel != otpdelivery.ChannelSMS || req.Recipient != "15550012020" || req.DeliveryID == "" {
|
||||
t.Fatalf("request = %+v", req)
|
||||
}
|
||||
rec, found, err := f.codes.Get(f.ctx, hash)
|
||||
if err != nil || !found || rec.Channel != store.PhoneCodeChannelSMS || rec.DeliveryID != req.DeliveryID || rec.Code != req.Code {
|
||||
t.Fatalf("record=%+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 78, req.Recipient, hash, req.Code, 1700000000); err != nil {
|
||||
t.Fatalf("ChangePhone: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeWebhookRejectionRevokesScopedCode(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
|
||||
tracked := &trackingPhoneCodeStore{CodeStore: f.codes}
|
||||
f.service.codes = tracked
|
||||
f.service.phoneCodeSender = sender
|
||||
f.service.phoneCodeLength = 5
|
||||
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012021")
|
||||
if hash != "" || err == nil || len(sender.requests) != 1 {
|
||||
t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests))
|
||||
}
|
||||
if tracked.lastHash == "" {
|
||||
t.Fatal("code was not stored before delivery")
|
||||
}
|
||||
if rec, found, getErr := f.codes.Get(f.ctx, tracked.lastHash); getErr != nil || found || rec.Code != "" {
|
||||
t.Fatalf("post-rejection code rec=%+v found=%v err=%v", rec, found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "+1 (555) 001-2002")
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/mail"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -47,7 +48,9 @@ type Service struct {
|
|||
phoneChangeCode string
|
||||
phoneChangeCodeTTL time.Duration
|
||||
phoneChangeMaxAttempts int
|
||||
loginEmailSender mail.Sender
|
||||
loginEmailSender otpdelivery.Sender
|
||||
phoneCodeSender otpdelivery.Sender
|
||||
phoneCodeLength int
|
||||
loginEmailCodeTTL time.Duration
|
||||
loginEmailCodeMaxAttempts int
|
||||
loginEmailCodeLength int
|
||||
|
|
@ -144,7 +147,7 @@ func WithPublicBaseURL(baseURL string) ServiceOption {
|
|||
}
|
||||
}
|
||||
|
||||
func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
|
||||
func WithLoginEmailVerification(codes store.CodeStore, sender otpdelivery.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.codes = codes
|
||||
s.loginEmailSender = sender
|
||||
|
|
@ -175,6 +178,17 @@ func WithEmailSignupPhonePrefixes(prefixes []string) ServiceOption {
|
|||
}
|
||||
}
|
||||
|
||||
// WithPhoneCodeDelivery replaces the fixed development code used by the
|
||||
// change-phone flow with an externally delivered SMS code.
|
||||
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.phoneCodeSender = sender
|
||||
if length > 0 {
|
||||
s.phoneCodeLength = length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{
|
||||
|
|
@ -185,6 +199,7 @@ func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
|||
loginEmailCodeLength: 6,
|
||||
phoneChangeCodeTTL: 5 * time.Minute,
|
||||
phoneChangeMaxAttempts: 5,
|
||||
phoneCodeLength: 5,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
|
|
@ -639,18 +654,54 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p
|
|||
return "", 0, err
|
||||
}
|
||||
rec.Code = code
|
||||
deliveryID, err := otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
rec.DeliveryID = deliveryID
|
||||
expiresAt := time.Now().Add(s.loginEmailCodeTTL)
|
||||
if err := s.codes.Set(ctx, key, rec, s.loginEmailCodeTTL); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, s.loginEmailCodeTTL); err != nil {
|
||||
// Set does not expose its generated revision. A blind Del here could
|
||||
// remove a newer concurrent resend; leave the unreachable random code
|
||||
// to expire or be replaced by the retry instead.
|
||||
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if !found || snapshot.Record.DeliveryID != deliveryID {
|
||||
return "", 0, domain.ErrEmailCodeInvalid
|
||||
}
|
||||
purpose := otpdelivery.PurposeLoginEmailChange
|
||||
if setup {
|
||||
purpose = otpdelivery.PurposeLoginEmailSetup
|
||||
}
|
||||
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: purpose,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: email,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
deleted, cleanupErr := s.codes.CompareAndDelete(cleanupCtx, key, snapshot.Revision)
|
||||
if cleanupErr != nil {
|
||||
return "", 0, fmt.Errorf("%w; rollback email code: %v", err, cleanupErr)
|
||||
}
|
||||
_ = deleted // false means a newer concurrent resend owns the key.
|
||||
return "", 0, err
|
||||
}
|
||||
return emailPattern(email), len(code), nil
|
||||
}
|
||||
|
||||
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
|
||||
_, err := sender.Deliver(ctx, req)
|
||||
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error) {
|
||||
if s == nil || s.codes == nil {
|
||||
return "", domain.ErrEmailNotAllowed
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。
|
||||
// 第一阶段用开发固定验证码,2FA 配置由 account 服务持久化查询。
|
||||
//
|
||||
// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。
|
||||
// 输入输出在 RPC 边界使用 iamxvbaba/td/tg 类型,本包内部只用 internal/domain 模型。
|
||||
package auth
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
|
@ -340,7 +341,7 @@ func TestExistingAccountResendDeliveryFailureLeavesNoUsableCode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
|
||||
func TestConfiguredEmailLoginMirrorsSameCodeThroughAppDelivery(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009207"}); err != nil {
|
||||
|
|
@ -360,7 +361,40 @@ func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
|
|||
if mailSender.to != "secure@example.test" || mailSender.code == "" {
|
||||
t.Fatalf("email delivery = %q/%q", mailSender.to, mailSender.code)
|
||||
}
|
||||
if len(delivery.requests) != 0 {
|
||||
t.Fatalf("email code leaked into app delivery: %+v", delivery.requests)
|
||||
if len(delivery.requests) != 1 || delivery.requests[0].Code != mailSender.code || delivery.requests[0].PhoneCodeHash == "" {
|
||||
t.Fatalf("email App-code delivery=%+v, want same code and non-empty hash", delivery.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginProviderFailureKeepsDurableAppCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
user, err := users.Create(ctx, domain.User{Phone: "15550009215"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
emails := &testLoginEmailStore{emails: map[string]string{user.Phone: "fallback@example.test"}}
|
||||
codes := memory.NewCodeStore()
|
||||
mailSender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
var observed []error
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
|
||||
WithLoginEmail(LoginEmailOptions{Enabled: true, CodeLength: 6, Store: emails, Sender: mailSender}),
|
||||
WithLoginCodeDelivery(delivery),
|
||||
WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) {
|
||||
observed = append(observed, err)
|
||||
}),
|
||||
)
|
||||
|
||||
hash, err := svc.SendCode(ctx, user.Phone)
|
||||
if err != nil || hash == "" {
|
||||
t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err)
|
||||
}
|
||||
if len(delivery.requests) != 1 || len(mailSender.requests) != 1 || len(observed) != 1 ||
|
||||
delivery.requests[0].Code != mailSender.requests[0].Code {
|
||||
t.Fatalf("App=%+v provider=%+v observed=%d", delivery.requests, mailSender.requests, len(observed))
|
||||
}
|
||||
if rec, found, getErr := codes.Get(ctx, hash); getErr != nil || !found || rec.Code != delivery.requests[0].Code {
|
||||
t.Fatalf("code=%+v found=%v err=%v", rec, found, getErr)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -28,13 +28,13 @@ type testMailSender struct {
|
|||
code string
|
||||
}
|
||||
|
||||
func (s *testMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
|
||||
s.to = to
|
||||
s.code = code
|
||||
return nil
|
||||
func (s *testMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
|
||||
s.to = req.Recipient
|
||||
s.code = req.Code
|
||||
return otpdelivery.Result{}, nil
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
|
||||
func TestConfiguredEmailLoginSharesAttemptsAcrossOfficialCodeCarriers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
|
|
@ -43,7 +43,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
|
|||
}
|
||||
emails := &testLoginEmailStore{emails: map[string]string{"15550009101": "alice@example.test"}}
|
||||
sender := &testMailSender{}
|
||||
appDelivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginCodeDelivery(appDelivery),
|
||||
WithLoginEmail(LoginEmailOptions{
|
||||
Enabled: true,
|
||||
CodeLength: 6,
|
||||
|
|
@ -59,6 +61,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
|
|||
if sender.to != "alice@example.test" || len(sender.code) != 6 {
|
||||
t.Fatalf("sent email to/code = %q/%q, want alice@example.test/6 digits", sender.to, sender.code)
|
||||
}
|
||||
if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != sender.code {
|
||||
t.Fatalf("App-code delivery=%+v, want same email code/hash", appDelivery.requests)
|
||||
}
|
||||
delivery, found, err := svc.CodeDelivery(ctx, hash)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("CodeDelivery found=%v err=%v", found, err)
|
||||
|
|
@ -71,14 +76,14 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
|
|||
if bad2 == bad1 {
|
||||
bad2 = wrongCode(sender.code, '2')
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad1); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("first bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, "+15550009101", hash, bad1); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("first bad WebK SignIn err = %v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad2); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("second bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
|
||||
t.Fatalf("second bad native SignInWithEmail err = %v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, sender.code); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("SignInWithEmail after max attempts err = %v, want ErrCodeExpired", err)
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, "+15550009101", hash, sender.code); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("WebK SignIn after shared max attempts err = %v, want ErrCodeExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,35 +104,160 @@ func wrongCode(code string, digit byte) string {
|
|||
return string(out)
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) {
|
||||
func TestConfiguredEmailLoginAcceptsOfficialCodeCarriers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
phone string
|
||||
email string
|
||||
webK bool
|
||||
}{
|
||||
{name: "webk_phone_code", phone: "15550009102", email: "webk@example.test", webK: true},
|
||||
{name: "native_email_verification", phone: "15550009103", email: "native@example.test"},
|
||||
}
|
||||
|
||||
for i, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: tc.phone, FirstName: "Email"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
emails := &testLoginEmailStore{emails: map[string]string{tc.phone: tc.email}}
|
||||
sender := &testMailSender{}
|
||||
appDelivery := &captureLoginCodeDelivery{}
|
||||
var key [8]byte
|
||||
key[0] = byte(0x91 + i)
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginCodeDelivery(appDelivery),
|
||||
WithLoginEmail(LoginEmailOptions{
|
||||
Enabled: true,
|
||||
CodeLength: 6,
|
||||
Store: emails,
|
||||
Sender: sender,
|
||||
}))
|
||||
|
||||
hash, err := svc.SendCode(ctx, tc.phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if len(appDelivery.requests) != 1 || appDelivery.requests[0].Code != sender.code {
|
||||
t.Fatalf("App-code delivery=%+v, want same email code", appDelivery.requests)
|
||||
}
|
||||
|
||||
var got domain.User
|
||||
var needSignUp bool
|
||||
if tc.webK {
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, tc.phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("WebK development code err=%v, want ErrCodeInvalid for random email channel", err)
|
||||
}
|
||||
got, _, needSignUp, err = svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, tc.phone, hash, sender.code)
|
||||
} else {
|
||||
got, _, needSignUp, err = svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, tc.phone, hash, sender.code)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("sign in: %v", err)
|
||||
}
|
||||
if needSignUp || got.ID != u.ID {
|
||||
t.Fatalf("sign in got user=%d needSignUp=%v, want %d/false", got.ID, needSignUp, u.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginViaWebKStillHonorsTwoFactor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550009102", FirstName: "Email"})
|
||||
passwords := memory.NewPasswordStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550009104", FirstName: "Email"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
emails := &testLoginEmailStore{emails: map[string]string{"15550009102": "bob@example.test"}}
|
||||
if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil {
|
||||
t.Fatalf("save password settings: %v", err)
|
||||
}
|
||||
sender := &testMailSender{}
|
||||
var key [8]byte
|
||||
key[0] = 0x91
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithPasswords(passwords),
|
||||
WithLoginCodeDelivery(&captureLoginCodeDelivery{}),
|
||||
WithLoginEmail(LoginEmailOptions{
|
||||
Enabled: true,
|
||||
CodeLength: 5,
|
||||
Store: emails,
|
||||
CodeLength: 6,
|
||||
Store: &testLoginEmailStore{emails: map[string]string{u.Phone: "2fa@example.test"}},
|
||||
Sender: sender,
|
||||
}))
|
||||
var key [8]byte
|
||||
key[0] = 0x94
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550009102")
|
||||
hash, err := svc.SendCode(ctx, u.Phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009102", hash, sender.code)
|
||||
if err != nil {
|
||||
t.Fatalf("SignInWithEmail: %v", err)
|
||||
got, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, u.Phone, hash, sender.code)
|
||||
if !errors.Is(err, domain.ErrSessionPasswordNeeded) {
|
||||
t.Fatalf("WebK email SignIn err=%v, want ErrSessionPasswordNeeded", err)
|
||||
}
|
||||
if needSignUp || got.ID != u.ID {
|
||||
t.Fatalf("SignInWithEmail got user=%d needSignUp=%v, want %d/false", got.ID, needSignUp, u.ID)
|
||||
if got.ID != u.ID {
|
||||
t.Fatalf("WebK email SignIn user=%d, want pending 2FA user %d", got.ID, u.ID)
|
||||
}
|
||||
if bound, found, err := svc.UserID(ctx, key); err != nil || found || bound != 0 {
|
||||
t.Fatalf("UserID after WebK email SignIn with 2FA=%d found=%v err=%v, want not-found", bound, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginHasSingleConsumerAcrossOfficialCodeCarriers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550009105", FirstName: "Email"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
sender := &testMailSender{}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginCodeDelivery(&captureLoginCodeDelivery{}),
|
||||
WithLoginEmail(LoginEmailOptions{
|
||||
Enabled: true,
|
||||
CodeLength: 6,
|
||||
Store: &testLoginEmailStore{emails: map[string]string{u.Phone: "race@example.test"}},
|
||||
Sender: sender,
|
||||
}))
|
||||
hash, err := svc.SendCode(ctx, u.Phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var webKKey, nativeKey [8]byte
|
||||
webKKey[0] = 0x95
|
||||
nativeKey[0] = 0x96
|
||||
go func() {
|
||||
<-start
|
||||
_, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: webKKey}, u.Phone, hash, sender.code)
|
||||
results <- err
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: nativeKey}, u.Phone, hash, sender.code)
|
||||
results <- err
|
||||
}()
|
||||
close(start)
|
||||
|
||||
accepted, expired := 0, 0
|
||||
for range 2 {
|
||||
err := <-results
|
||||
switch {
|
||||
case err == nil:
|
||||
accepted++
|
||||
case errors.Is(err, ErrCodeExpired):
|
||||
expired++
|
||||
default:
|
||||
t.Fatalf("concurrent sign in err=%v, want nil or ErrCodeExpired", err)
|
||||
}
|
||||
}
|
||||
if accepted != 1 || expired != 1 {
|
||||
t.Fatalf("concurrent results accepted=%d expired=%d, want 1/1", accepted, expired)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
163
internal/app/auth/otp_delivery_test.go
Normal file
163
internal/app/auth/otp_delivery_test.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type captureOTPSender struct {
|
||||
requests []otpdelivery.Request
|
||||
err error
|
||||
before func()
|
||||
}
|
||||
|
||||
func (s *captureOTPSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
|
||||
if s.before != nil {
|
||||
s.before()
|
||||
}
|
||||
s.requests = append(s.requests, req)
|
||||
return otpdelivery.Result{ProviderMessageID: "capture-message"}, s.err
|
||||
}
|
||||
|
||||
func TestWebhookPhoneLoginUsesRandomSMSCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
user, err := users.Create(ctx, domain.User{Phone: "15550009301", FirstName: "Webhook"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
appDelivery := &captureLoginCodeDelivery{}
|
||||
sender := &captureOTPSender{before: func() {
|
||||
if len(appDelivery.requests) != 1 {
|
||||
t.Fatalf("provider called before durable App-code: requests=%d", len(appDelivery.requests))
|
||||
}
|
||||
}}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "fixed-code-must-not-leak",
|
||||
WithLoginCodeDelivery(appDelivery),
|
||||
WithPhoneCodeDelivery(sender, 6))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+1 555 000 9301")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if hash == "" || len(sender.requests) != 1 {
|
||||
t.Fatalf("hash=%q requests=%d", hash, len(sender.requests))
|
||||
}
|
||||
req := sender.requests[0]
|
||||
if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginSMS || req.Channel != otpdelivery.ChannelSMS ||
|
||||
req.Recipient != "15550009301" || len(req.Code) != 6 || req.Code == "fixed-code-must-not-leak" || time.Until(req.ExpiresAt) < 4*time.Minute {
|
||||
t.Fatalf("request = %+v", req)
|
||||
}
|
||||
if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != req.Code {
|
||||
t.Fatalf("App-code delivery=%+v, want same hash/code as provider", appDelivery.requests)
|
||||
}
|
||||
rec, found, err := codes.Get(ctx, hash)
|
||||
if err != nil || !found || rec.Code != req.Code || rec.DeliveryID != req.DeliveryID || rec.Channel != store.PhoneCodeChannelSMS {
|
||||
t.Fatalf("stored code=%+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
delivery, found, err := svc.CodeDelivery(ctx, hash)
|
||||
if err != nil || !found || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 {
|
||||
t.Fatalf("delivery=%+v found=%v err=%v", delivery, found, err)
|
||||
}
|
||||
got, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{3}}, req.Recipient, hash, req.Code)
|
||||
if err != nil || needSignUp || got.ID != user.ID {
|
||||
t.Fatalf("SignIn user=%+v needSignUp=%v err=%v", got, needSignUp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
user, err := users.Create(ctx, domain.User{Phone: "15550009305", FirstName: "Fallback"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
appDelivery := &captureLoginCodeDelivery{}
|
||||
sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
|
||||
var observed []error
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
|
||||
WithLoginCodeDelivery(appDelivery),
|
||||
WithPhoneCodeDelivery(sender, 6),
|
||||
WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) {
|
||||
observed = append(observed, err)
|
||||
}),
|
||||
)
|
||||
|
||||
hash, err := svc.SendCode(ctx, user.Phone)
|
||||
if err != nil || hash == "" {
|
||||
t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err)
|
||||
}
|
||||
if len(sender.requests) != 1 || len(appDelivery.requests) != 1 || len(observed) != 1 {
|
||||
t.Fatalf("provider=%d App=%d observed=%d, want 1/1/1", len(sender.requests), len(appDelivery.requests), len(observed))
|
||||
}
|
||||
rec, found, err := codes.Get(ctx, hash)
|
||||
if err != nil || !found || rec.Code != appDelivery.requests[0].Code || rec.Code != sender.requests[0].Code {
|
||||
t.Fatalf("code=%+v found=%v err=%v App=%+v provider=%+v", rec, found, err, appDelivery.requests, sender.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookPhoneLoginExplicitRejectionRollsBackCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baseCodes := memory.NewCodeStore()
|
||||
codes := &trackingCodeStore{CodeStore: baseCodes}
|
||||
sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
|
||||
WithPhoneCodeDelivery(sender, 5))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "15550009302")
|
||||
if hash != "" || err == nil || len(sender.requests) != 1 || codes.lastSetHash == "" {
|
||||
t.Fatalf("hash=%q err=%v requests=%d set=%q", hash, err, len(sender.requests), codes.lastSetHash)
|
||||
}
|
||||
if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found {
|
||||
t.Fatalf("rejected code found=%v err=%v", found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookPhoneLoginUnknownOutcomeKeepsUsableCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
sender := &captureOTPSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("response lost")}}
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
|
||||
WithPhoneCodeDelivery(sender, 5))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "15550009303")
|
||||
if err != nil || hash == "" || len(sender.requests) != 1 {
|
||||
t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests))
|
||||
}
|
||||
rec, found, err := codes.Get(ctx, hash)
|
||||
if err != nil || !found || rec.Code != sender.requests[0].Code {
|
||||
t.Fatalf("unknown outcome code=%+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookPhoneResendRotatesCodeAndDeliveryID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sender := &captureOTPSender{}
|
||||
codes := memory.NewCodeStore()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
|
||||
WithPhoneCodeDelivery(sender, 6))
|
||||
firstHash, err := svc.SendCode(ctx, "15550009304")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
secondHash, err := svc.ResendCode(ctx, "15550009304", firstHash)
|
||||
if err != nil {
|
||||
t.Fatalf("ResendCode: %v", err)
|
||||
}
|
||||
if firstHash == secondHash || len(sender.requests) != 2 ||
|
||||
sender.requests[0].DeliveryID == sender.requests[1].DeliveryID {
|
||||
t.Fatalf("hashes=%q/%q requests=%+v", firstHash, secondHash, sender.requests)
|
||||
}
|
||||
if _, found, err := codes.Get(ctx, firstHash); err != nil || found {
|
||||
t.Fatalf("old code found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,11 +14,11 @@ import (
|
|||
"unicode/utf8"
|
||||
|
||||
"github.com/gotd/ige"
|
||||
"github.com/gotd/td/bin"
|
||||
mtcrypto "github.com/gotd/td/crypto"
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
mtcrypto "github.com/iamxvbaba/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mail"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -27,6 +27,10 @@ var (
|
|||
ErrCodeExpired = errors.New("phone code expired or not found")
|
||||
ErrCodeInvalid = errors.New("phone code invalid")
|
||||
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
|
||||
ErrExpiresAtInvalid = errors.New("temporary auth key request expiry invalid")
|
||||
ErrTempAuthKeyEmpty = errors.New("temporary auth key missing or expired")
|
||||
ErrTempAuthKeyAlreadyBound = errors.New("temporary auth key already bound")
|
||||
ErrAuthKeyPermEmpty = errors.New("permanent auth key required")
|
||||
// ErrLoginCodeDeliveryUnavailable 表示已有账号的 app-code 没有可用的
|
||||
// durable message/event/outbox 投递边界。这是服务端配置错误,不能降级成
|
||||
// “继续返回 sentCode,等 signIn 后补发”。
|
||||
|
|
@ -44,9 +48,10 @@ var (
|
|||
)
|
||||
|
||||
const (
|
||||
codeChannelPhone = "phone"
|
||||
codeChannelEmailLogin = "email_login"
|
||||
codeChannelEmailSetupRequired = "email_setup_required"
|
||||
codeChannelPhone = store.PhoneCodeChannelPhone
|
||||
codeChannelSMS = store.PhoneCodeChannelSMS
|
||||
codeChannelEmailLogin = store.PhoneCodeChannelEmailLogin
|
||||
codeChannelEmailSetupRequired = store.PhoneCodeChannelEmailSetupRequired
|
||||
loginCodeRollbackTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
|
|
@ -66,7 +71,9 @@ func systemLoginPhoneForbidden(phone string) bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
|
||||
// Service 实现登录/注册业务。默认保留开发固定码;配置外部 provider
|
||||
// 后生成随机验证码并通过 otpdelivery 投递。已有账号的外部投递是 durable
|
||||
// 777000 App-code 的附加渠道,不能替换或削弱原有消息事实。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
auths store.AuthorizationStore
|
||||
|
|
@ -82,7 +89,10 @@ type Service struct {
|
|||
codeTTL time.Duration
|
||||
codeMaxAttempts int
|
||||
loginEmails loginEmailStore
|
||||
loginEmailSender mail.Sender
|
||||
loginEmailSender otpdelivery.Sender
|
||||
phoneCodeSender otpdelivery.Sender
|
||||
otpDeliveryFailure func(context.Context, otpdelivery.Request, error)
|
||||
phoneCodeLength int
|
||||
loginEmailEnabled bool
|
||||
loginEmailRequireSetup bool
|
||||
loginEmailCodeLength int
|
||||
|
|
@ -109,7 +119,7 @@ type LoginEmailOptions struct {
|
|||
RequireSetup bool
|
||||
CodeLength int
|
||||
Store loginEmailStore
|
||||
Sender mail.Sender
|
||||
Sender otpdelivery.Sender
|
||||
}
|
||||
|
||||
type authorizationRevoker interface {
|
||||
|
|
@ -207,9 +217,34 @@ func WithEmailSignupPhonePrefixes(prefixes []string) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithPhoneCodeDelivery enables an external SMS delivery provider. Existing
|
||||
// accounts keep their durable 777000 App-code and receive the same code through
|
||||
// the provider as an additional channel. A nil sender preserves development
|
||||
// behavior.
|
||||
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) Option {
|
||||
return func(s *Service) {
|
||||
s.phoneCodeSender = sender
|
||||
if length > 0 {
|
||||
s.phoneCodeLength = length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOTPDeliveryFailureObserver observes failures of an additional provider
|
||||
// delivery after an existing account already has a durable 777000 App-code.
|
||||
// Observers must not log the recipient or code.
|
||||
func WithOTPDeliveryFailureObserver(observer func(context.Context, otpdelivery.Request, error)) Option {
|
||||
return func(s *Service) {
|
||||
s.otpDeliveryFailure = observer
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建登录服务。fixedCode 为开发固定验证码。
|
||||
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
|
||||
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
|
||||
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6, phoneCodeLength: 5}
|
||||
if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil {
|
||||
linker.LinkAuthKeyAuthority(authKeys)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
|
@ -219,26 +254,44 @@ func NewService(users store.UserStore, auths store.AuthorizationStore, codes sto
|
|||
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
|
||||
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
|
||||
if s.authKeys != nil {
|
||||
inner, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binding.TempSessionID = inner.TempSessionID
|
||||
// The bind request's expires_at is a signed client assertion. TDesktop
|
||||
// intentionally adds a small grace interval, while Android derives its
|
||||
// value at handshake completion. Retention and edge admission must use the
|
||||
// server's p_q_inner_data_temp lifetime, never the client value.
|
||||
binding.ExpiresAt = protocolExpiresAt
|
||||
}
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
// The edge may admit the frame immediately before the temporary key's
|
||||
// absolute boundary and the encrypted proof may cross it. This is a temp-key
|
||||
// rotation condition, never a destructive permanent-key proof failure.
|
||||
return ErrTempAuthKeyEmpty
|
||||
}
|
||||
if s.tempKeys == nil {
|
||||
return nil
|
||||
}
|
||||
return s.tempKeys.Save(ctx, binding)
|
||||
if err := s.tempKeys.Save(ctx, binding); err != nil {
|
||||
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||
return ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
return s.classifyBindingStoreInvalid(ctx, binding)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
|
||||
//
|
||||
// 过期处理是有意的连续性权衡(见 TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey):
|
||||
// temp 绑定 expires_at 已过时,仅当 perm key 也未授权才拒绝;perm 仍授权则继续解析,
|
||||
// 避免已登录会话因 temp key 过期而被强制踢下线。严格 PFS 要求过期 temp key 一律失效
|
||||
// (不以 perm 授权豁免),但收紧前需先核实目标客户端(TDesktop/DrKLO)会在过期前主动
|
||||
// 轮换 temp key 并优雅处理拒绝,否则会造成在线会话掉线。RetentionWorker 的 DeleteExpired
|
||||
// 已把残留窗口限制在 expires_at + 宽限(约 24h)内。收紧为显式硬化任务,需客户端验证。
|
||||
// temp→perm 是握手/绑定形成的协议身份关系,与 perm 当前是否登录完全无关。即使
|
||||
// auth.logOut 已删除 authorization,只要绑定仍存在,后续登录 RPC 也必须继续落到同一
|
||||
// perm key,绝不能把 raw temp key 当成新的业务身份。协议过期由 mtprotoedge 在解密/RPC
|
||||
// 之前返回 -404 并关闭连接;这里不再用 authorization 状态猜测 key 类型。
|
||||
func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) {
|
||||
if s == nil || s.tempKeys == nil {
|
||||
return [8]byte{}, false, nil
|
||||
|
|
@ -247,19 +300,7 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
|
|||
if err != nil || !found {
|
||||
return [8]byte{}, found, err
|
||||
}
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) && !s.permAuthKeyAuthorized(ctx, permID) {
|
||||
return [8]byte{}, false, nil
|
||||
}
|
||||
return permID, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) permAuthKeyAuthorized(ctx context.Context, authKeyID [8]byte) bool {
|
||||
if s == nil || s.auths == nil {
|
||||
return false
|
||||
}
|
||||
_, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
return err == nil && found
|
||||
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
|
||||
}
|
||||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
|
||||
|
|
@ -430,32 +471,68 @@ func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUse
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{
|
||||
code := s.fixedCode
|
||||
channel := codeChannelPhone
|
||||
deliveryID := ""
|
||||
if s.phoneCodeSender != nil {
|
||||
code, err = randomDigits(s.phoneCodeLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
deliveryID, err = otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
channel = codeChannelSMS
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: existingUserID,
|
||||
Phone: phone,
|
||||
Code: s.fixedCode,
|
||||
Channel: codeChannelPhone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: channel,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}, s.codeTTL); err != nil {
|
||||
}
|
||||
expiresAt := time.Now().Add(s.codeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
rec := store.PhoneCode{Phone: phone, IssuedUserID: existingUserID}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 新手机号还没有 owner/dialog,只能在 SignUp 创建用户后写第一条
|
||||
// 777000 消息。已有账号则必须在 sendCode RPC 返回前把 app-code
|
||||
// 作为普通 incoming message + durable update/outbox 提交;登录成功不再补发。
|
||||
if existingUserID == 0 {
|
||||
// Existing accounts always retain the original durable App-code path. Commit
|
||||
// it before attempting the external mirror so a provider cannot replace the
|
||||
// message fact or leave an externally disclosed code without local state.
|
||||
if existingUserID != 0 {
|
||||
if err := s.deliverLoginCode(ctx, existingUserID, hash, code); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if s.phoneCodeSender != nil {
|
||||
request := otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeLoginSMS,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: phone,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if existingUserID != 0 {
|
||||
s.deliverOTPWithAppFallback(ctx, s.phoneCodeSender, request)
|
||||
} else if err := deliverOTP(ctx, s.phoneCodeSender, request); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login SMS code: %w", err))
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
if err := s.deliverLoginCode(ctx, existingUserID, hash, s.fixedCode); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 新手机号还没有 owner/dialog,不能在签发阶段创建 777000 消息;
|
||||
// 已有账号的 App-code 已在上面的 provider 分支之前 durable 提交。
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
|
|
@ -521,25 +598,60 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
deliveryID, err := otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: issuedUserID,
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: codeChannelEmailLogin,
|
||||
Email: strings.TrimSpace(email),
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}
|
||||
expiresAt := time.Now().Add(s.codeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store email code: %w", err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if issuedUserID != 0 {
|
||||
if err := s.deliverLoginCode(ctx, issuedUserID, hash, code); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if s.loginEmailSender == nil {
|
||||
if issuedUserID != 0 {
|
||||
s.reportOTPDeliveryFailure(ctx, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeLoginEmail,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: rec.Email,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}, fmt.Errorf("login email sender is not configured"))
|
||||
return hash, nil
|
||||
}
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("login email sender is not configured"))
|
||||
}
|
||||
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
|
||||
request := otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeLoginEmail,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: rec.Email,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if issuedUserID != 0 {
|
||||
s.deliverOTPWithAppFallback(ctx, s.loginEmailSender, request)
|
||||
} else if err := deliverOTP(ctx, s.loginEmailSender, request); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login email code: %w", err))
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
|
|
@ -548,6 +660,34 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
|
|||
return hash, nil
|
||||
}
|
||||
|
||||
// deliverOTPWithAppFallback performs an additional provider delivery only
|
||||
// after the same code is durably visible through 777000. A provider failure
|
||||
// must not invalidate that visible code or fail the RPC; it remains observable
|
||||
// through the injected failure observer.
|
||||
func (s *Service) deliverOTPWithAppFallback(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) {
|
||||
if _, err := sender.Deliver(ctx, req); err != nil {
|
||||
s.reportOTPDeliveryFailure(ctx, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) reportOTPDeliveryFailure(ctx context.Context, req otpdelivery.Request, err error) {
|
||||
if s.otpDeliveryFailure != nil && err != nil {
|
||||
s.otpDeliveryFailure(ctx, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverOTP treats a transport-level unknown outcome as a successful issue:
|
||||
// the provider may already have accepted the request, so the code must remain
|
||||
// usable and the client needs the hash in order to verify or explicitly resend
|
||||
// it. Only an explicit provider rejection is safe to roll back.
|
||||
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
|
||||
_, err := sender.Deliver(ctx, req)
|
||||
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error) {
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil || !found {
|
||||
|
|
@ -561,6 +701,8 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
|
|||
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
|
||||
}
|
||||
switch rec.Channel {
|
||||
case codeChannelSMS:
|
||||
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
|
||||
case codeChannelEmailLogin:
|
||||
return domain.AuthCodeDelivery{
|
||||
Kind: domain.AuthCodeDeliveryEmail,
|
||||
|
|
@ -640,7 +782,7 @@ func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phon
|
|||
if rec.Channel == codeChannelEmailSetupRequired {
|
||||
return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID)
|
||||
}
|
||||
if rec.Channel != codeChannelPhone {
|
||||
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS {
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
return s.createPhoneCode(ctx, phone, rec.IssuedUserID)
|
||||
|
|
@ -652,14 +794,44 @@ func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCo
|
|||
return "", err
|
||||
}
|
||||
rec.Code = s.fixedCode
|
||||
rec.DeliveryID = ""
|
||||
rec.Channel = codeChannelPhone
|
||||
if s.phoneCodeSender != nil {
|
||||
rec.Code, err = randomDigits(s.phoneCodeLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec.DeliveryID, err = otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec.Channel = codeChannelSMS
|
||||
}
|
||||
rec.Attempts = 0
|
||||
if rec.MaxAttempts <= 0 {
|
||||
rec.MaxAttempts = s.codeMaxAttempts
|
||||
}
|
||||
expiresAt := time.Now().Add(s.codeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store resent phone change code: %w", err)
|
||||
}
|
||||
if s.phoneCodeSender != nil {
|
||||
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
|
||||
DeliveryID: rec.DeliveryID,
|
||||
Purpose: otpdelivery.PurposeChangePhone,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: rec.Phone,
|
||||
Code: rec.Code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
|
||||
defer cancel()
|
||||
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
||||
return "", errors.Join(err, fmt.Errorf("rollback undelivered phone change code: %w", cleanupErr))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
|
|
@ -794,7 +966,7 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code, false)
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
|
|
@ -804,17 +976,17 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
return s.finishSignIn(ctx, auth, existing)
|
||||
}
|
||||
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
|
||||
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
|
||||
// 随机邮箱码;未开启该特性时仍允许旧客户端把 phone channel 放进
|
||||
// email_verification,但必须精确匹配该 phone code,不能再接受任意非空值。
|
||||
// 两条路径共用 owner 绑定、原子尝试计数与 2FA 门控。
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn。它与 SignIn
|
||||
// 共享同一个登录凭证状态机:TDesktop/Android 把邮箱码放在
|
||||
// email_verification,WebK 把同一邮箱码放在 phone_code;TL 字段只是 proof
|
||||
// carrier,服务端签发记录的 channel 才表示实际投递渠道。所有渠道都必须精确
|
||||
// 匹配签发码,并共用 owner 绑定、原子尝试计数、一次性消费与 2FA 门控。
|
||||
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code), true)
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code))
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
|
|
@ -828,7 +1000,7 @@ func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization
|
|||
// CodeStore verification. The phone owner is read both before and after that
|
||||
// linearization point. A hash issued for an unregistered number therefore can
|
||||
// never authorize whichever account happens to acquire that number later.
|
||||
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string, emailPath bool) (store.PhoneCode, domain.User, bool, error) {
|
||||
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string) (store.PhoneCode, domain.User, bool, error) {
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return store.PhoneCode{}, domain.User{}, false, err
|
||||
|
|
@ -843,11 +1015,7 @@ func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, cod
|
|||
if rec.Phone != phone || rec.Purpose != "" {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
channelAllowed := rec.Channel == codeChannelPhone && !emailPath
|
||||
if emailPath {
|
||||
channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone)
|
||||
}
|
||||
if !channelAllowed {
|
||||
if !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
|
||||
|
|
@ -990,7 +1158,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
|
||||
if !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
// Email-signup accounts (888-encoded phone) already proved ownership of
|
||||
|
|
@ -1017,7 +1185,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||||
}
|
||||
rec = consumed
|
||||
if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) {
|
||||
if rec.IssuedUserID != 0 || !rec.SignUpVerified || !store.LoginCodeChannelVerifiable(rec.Channel) {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
|
||||
|
|
@ -1081,10 +1249,11 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
loginMessage := domain.Message{}
|
||||
// SMTP setup/login codes are secret factors, not 777000 app messages. Only
|
||||
// the normal phone/app-code registration path creates the bootstrap dialog
|
||||
// carrying the actual code; every account additionally gets the
|
||||
// welcome message below regardless of channel.
|
||||
// A new account has no owner/dialog at issuance time. Only the development
|
||||
// phone/App registration path creates its bootstrap 777000 message here;
|
||||
// external SMS, email setup, and email-signup registration retain only
|
||||
// their verified fact — every account additionally gets the welcome
|
||||
// message below regardless of channel.
|
||||
if rec.Channel == codeChannelPhone {
|
||||
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
|
||||
if err != nil {
|
||||
|
|
@ -1204,13 +1373,6 @@ func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.
|
|||
return s.auths.ByAuthKey(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byte, layer int) error {
|
||||
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) || layer <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.auths.UpdateLayer(ctx, authKeyID, layer)
|
||||
}
|
||||
|
||||
func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) {
|
||||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||
return domain.AuthKeyClientInfo{}, false, nil
|
||||
|
|
@ -1220,12 +1382,13 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
|
|||
return domain.AuthKeyClientInfo{}, found, err
|
||||
}
|
||||
info := domain.AuthKeyClientInfo{
|
||||
Layer: key.Layer,
|
||||
DeviceModel: key.DeviceModel,
|
||||
Platform: key.Platform,
|
||||
SystemVersion: key.SystemVersion,
|
||||
APIID: key.APIID,
|
||||
AppVersion: key.AppVersion,
|
||||
Layer: key.Layer,
|
||||
LayerObservationID: key.LayerObservationID,
|
||||
DeviceModel: key.DeviceModel,
|
||||
Platform: key.Platform,
|
||||
SystemVersion: key.SystemVersion,
|
||||
APIID: key.APIID,
|
||||
AppVersion: key.AppVersion,
|
||||
}
|
||||
if info.Layer == 0 && info.DeviceModel == "" && info.Platform == "" &&
|
||||
info.SystemVersion == "" && info.APIID == 0 && info.AppVersion == "" {
|
||||
|
|
@ -1249,7 +1412,15 @@ func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte
|
|||
return err
|
||||
}
|
||||
if s.auths != nil {
|
||||
return s.auths.UpdateClientInfo(ctx, authKeyID, info)
|
||||
// Layer is an ordered protocol fact. Its authorization-table mirror is
|
||||
// advanced atomically by the durable Layer evidence/bind transactions.
|
||||
// A generic metadata update is deliberately two-store and can race such
|
||||
// a transaction, so it must never write an older Layer after the primary
|
||||
// auth_keys row has already advanced.
|
||||
authorizationInfo := info
|
||||
authorizationInfo.Layer = 0
|
||||
authorizationInfo.LayerObservationID = 0
|
||||
return s.auths.UpdateClientInfo(ctx, authKeyID, authorizationInfo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1340,11 +1511,29 @@ func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64,
|
|||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
if s.authKeys != nil {
|
||||
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Defense in depth: Router normally converts a bound temp key to its perm
|
||||
// identity and edge rejects expired temp keys. Never let an unbound/sticky
|
||||
// temp key create authorization even if either outer boundary regresses.
|
||||
if !found || key.ExpiresAt != 0 {
|
||||
return ErrAuthKeyPermEmpty
|
||||
}
|
||||
}
|
||||
auth.UserID = userID
|
||||
// Bind 是授权切换的持久化状态边界:生产 store 会先清同 auth key 的旧用户
|
||||
// update state,再原子建立新用户 baseline。RPC 层不得在 Bind 成功后清整个 key,
|
||||
// 否则会把刚建立的 retained-floor checkpoint 一并删除。
|
||||
return s.auths.Bind(ctx, auth)
|
||||
if err := s.auths.Bind(ctx, auth); err != nil {
|
||||
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
|
||||
return ErrAuthKeyPermEmpty
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error) {
|
||||
|
|
@ -1418,32 +1607,60 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, error) {
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrExpiresAtInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
}
|
||||
// expires_at in auth.bindTempAuthKey is client-supplied and must only attest
|
||||
// to a still-live binding. It may never create or reclassify a protocol key;
|
||||
// the caller normalizes durable retention to this handshake-authoritative
|
||||
// temp.ExpiresAt instead of trusting the client value.
|
||||
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
}
|
||||
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
perm, found, err := s.authKeys.Get(ctx, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
}
|
||||
if !found {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
if !found || perm.ExpiresAt != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if inner.Nonce != binding.Nonce ||
|
||||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
|
||||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
|
||||
inner.TempSessionID != sessionID ||
|
||||
inner.ExpiresAt != binding.ExpiresAt {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
}
|
||||
return inner, nil
|
||||
if temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
}
|
||||
return inner, temp.ExpiresAt, nil
|
||||
}
|
||||
|
||||
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
|
||||
if s == nil || s.authKeys == nil {
|
||||
return ErrEncryptedMessageInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return ErrTempAuthKeyEmpty
|
||||
}
|
||||
return ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
mtcrypto "github.com/gotd/td/crypto"
|
||||
mtcrypto "github.com/iamxvbaba/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -18,11 +18,12 @@ import (
|
|||
func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
|
||||
permKey := testAuthKey(0x11)
|
||||
tempKey := testAuthKey(0x55)
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
saveAuthKey(t, keys, permKey)
|
||||
saveAuthKey(t, keys, tempKey)
|
||||
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
|
||||
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
|
||||
|
|
@ -31,7 +32,6 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
sessionID = int64(0x1020304050)
|
||||
msgID = int64(0x0102030405060708)
|
||||
)
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
encrypted, err := mtcrypto.EncryptBindMessage(
|
||||
bytes.NewReader(bytes.Repeat([]byte{0xCD}, 128)),
|
||||
permKey,
|
||||
|
|
@ -69,9 +69,73 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
if !errors.Is(err, ErrEncryptedMessageInvalid) {
|
||||
t.Fatalf("BindTempAuthKey wrong session err = %v, want ErrEncryptedMessageInvalid", err)
|
||||
}
|
||||
|
||||
// TDesktop intentionally adds a 30-second bind grace to the expiry it
|
||||
// derived from p_q_inner_data_temp. The request is valid, but the durable
|
||||
// binding must be normalized back to the server handshake expiry.
|
||||
extendedExpiry := expiresAt + 30
|
||||
extendedEncrypted, err := mtcrypto.EncryptBindMessage(
|
||||
bytes.NewReader(bytes.Repeat([]byte{0xCE}, 128)),
|
||||
permKey,
|
||||
msgID+4,
|
||||
&mtcrypto.BindAuthKeyInner{
|
||||
Nonce: nonce,
|
||||
TempAuthKeyID: tempKey.IntID(),
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
TempSessionID: sessionID,
|
||||
ExpiresAt: extendedExpiry,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt extended bind message: %v", err)
|
||||
}
|
||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
ExpiresAt: extendedExpiry,
|
||||
EncryptedMessage: extendedEncrypted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BindTempAuthKey TDesktop grace expiry: %v", err)
|
||||
}
|
||||
stored, found, getErr := tempBindings.GetByTemp(ctx, tempKey.ID)
|
||||
if getErr != nil || !found || stored.ExpiresAt != expiresAt {
|
||||
t.Fatalf("stored binding after extension attempt = %+v found=%v err=%v", stored, found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
|
||||
func TestBindTempAuthKeyClassifiesExpiryWithoutDestroyingPermanentKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
|
||||
permKey := testAuthKey(0x31)
|
||||
tempKey := testAuthKey(0x32)
|
||||
saveAuthKey(t, keys, permKey)
|
||||
saveAuthKeyWithExpiry(t, keys, tempKey, int(time.Now().Add(-time.Second).Unix()))
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
|
||||
request := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
}
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
t.Fatalf("expired protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||
}
|
||||
|
||||
request.TempAuthKeyID = testAuthKey(0x33).ID
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
t.Fatalf("missing protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||
}
|
||||
|
||||
request.ExpiresAt = int(time.Now().Add(-time.Second).Unix())
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
|
||||
t.Fatalf("expired request proof err = %v, want ErrExpiresAtInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAuthKeyClientInfoConvergesMemoryAuthorizationToAuthKeyLayerAuthority(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
|
|
@ -80,6 +144,7 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
|
|||
if err := authz.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key.ID,
|
||||
UserID: 1780243200,
|
||||
Layer: 220,
|
||||
Platform: "unknown",
|
||||
}); err != nil {
|
||||
t.Fatalf("bind authorization: %v", err)
|
||||
|
|
@ -107,6 +172,7 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
|
|||
t.Fatalf("get authorization: found=%v err=%v", found, err)
|
||||
}
|
||||
if storedKey.Platform != "ios" || storedAuth.Platform != "ios" ||
|
||||
storedKey.Layer != info.Layer || storedAuth.Layer != info.Layer ||
|
||||
storedKey.DeviceModel != info.DeviceModel || storedAuth.DeviceModel != info.DeviceModel ||
|
||||
storedKey.AppVersion != info.AppVersion || storedAuth.AppVersion != info.AppVersion {
|
||||
t.Fatalf("client metadata did not converge: key=%+v authorization=%+v", storedKey, storedAuth)
|
||||
|
|
@ -115,15 +181,19 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
|
|||
|
||||
func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
|
||||
permKey := testAuthKey(0x11)
|
||||
tempKey := testAuthKey(0x55)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345")
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
saveAuthKey(t, keys, permKey)
|
||||
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
|
||||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
|
@ -139,11 +209,15 @@ func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
|
|||
|
||||
func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
|
||||
authz := memory.NewAuthorizationStore()
|
||||
permKey := testAuthKey(0x21)
|
||||
tempKey := testAuthKey(0x65)
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, tempBindings, "12345")
|
||||
expiresAt := int(time.Now().Add(-time.Minute).Unix())
|
||||
saveAuthKey(t, keys, permKey)
|
||||
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: permKey.ID, UserID: 1000000001}); err != nil {
|
||||
t.Fatalf("bind authorization: %v", err)
|
||||
|
|
@ -151,7 +225,7 @@ func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T
|
|||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(-time.Minute).Unix()),
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
|
@ -165,17 +239,21 @@ func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthKeyRejectsExpiredTempBindingWithoutAuthorizedPermKey(t *testing.T) {
|
||||
func TestResolveAuthKeyKeepsExpiredBindingCanonicalWithoutAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
|
||||
permKey := testAuthKey(0x31)
|
||||
tempKey := testAuthKey(0x75)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345")
|
||||
expiresAt := int(time.Now().Add(-time.Minute).Unix())
|
||||
saveAuthKey(t, keys, permKey)
|
||||
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
|
||||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(-time.Minute).Unix()),
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
|
@ -184,8 +262,87 @@ func TestResolveAuthKeyRejectsExpiredTempBindingWithoutAuthorizedPermKey(t *test
|
|||
if err != nil {
|
||||
t.Fatalf("ResolveAuthKey: %v", err)
|
||||
}
|
||||
if ok || got != ([8]byte{}) {
|
||||
t.Fatalf("resolved = %x ok=%v, want expired unresolved", got, ok)
|
||||
if !ok || got != permKey.ID {
|
||||
t.Fatalf("resolved = %x ok=%v, want canonical perm %x even while logged out", got, ok, permKey.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredTempLogoutReloginNeverAuthorizesRawTempKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
|
||||
permKey := testAuthKey(0x41)
|
||||
tempKey := testAuthKey(0x81)
|
||||
expiresAt := int(time.Now().Add(-time.Minute).Unix())
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: permKey.ID}); err != nil {
|
||||
t.Fatalf("save perm key: %v", err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{
|
||||
ID: tempKey.ID, ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp key: %v", err)
|
||||
}
|
||||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
bob, err := users.Create(ctx, domain.User{Phone: "15550008101", FirstName: "Bob"})
|
||||
if err != nil {
|
||||
t.Fatalf("create Bob: %v", err)
|
||||
}
|
||||
alice, err := users.Create(ctx, domain.User{Phone: "15550008102", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("create Alice: %v", err)
|
||||
}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: permKey.ID, UserID: bob.ID}); err != nil {
|
||||
t.Fatalf("authorize Bob: %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
if err := svc.LogOut(ctx, permKey.ID); err != nil {
|
||||
t.Fatalf("logout Bob: %v", err)
|
||||
}
|
||||
resolved, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID)
|
||||
if err != nil || !ok || resolved != permKey.ID {
|
||||
t.Fatalf("resolve after logout = %x/%v/%v, want perm", resolved, ok, err)
|
||||
}
|
||||
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: resolved}, alice.ID); err != nil {
|
||||
t.Fatalf("relogin Alice on canonical perm: %v", err)
|
||||
}
|
||||
if a, found, err := authz.ByAuthKey(ctx, permKey.ID); err != nil || !found || a.UserID != alice.ID {
|
||||
t.Fatalf("perm authorization = %+v found=%v err=%v, want Alice", a, found, err)
|
||||
}
|
||||
if a, found, err := authz.ByAuthKey(ctx, tempKey.ID); err != nil || found {
|
||||
t.Fatalf("temp authorization = %+v found=%v err=%v, want absent", a, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationBindRejectsTemporaryProtocolKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempKey := testAuthKey(0x82)
|
||||
if err := keys.Save(ctx, store.AuthKeyData{
|
||||
ID: tempKey.ID, ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp key: %v", err)
|
||||
}
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550008201", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), keys, memory.NewTempAuthKeyBindingStore(keys), "12345")
|
||||
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: tempKey.ID}, u.ID); !errors.Is(err, ErrAuthKeyPermEmpty) {
|
||||
t.Fatalf("bind temp authorization err = %v, want ErrAuthKeyPermEmpty", err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, tempKey.ID); err != nil || found {
|
||||
t.Fatalf("temp authorization found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -668,10 +825,14 @@ func testAuthKey(seed byte) mtcrypto.AuthKey {
|
|||
}
|
||||
|
||||
func saveAuthKey(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey) {
|
||||
saveAuthKeyWithExpiry(t, keys, key, 0)
|
||||
}
|
||||
|
||||
func saveAuthKeyWithExpiry(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey, expiresAt int) {
|
||||
t.Helper()
|
||||
var value [256]byte
|
||||
copy(value[:], key.Value[:])
|
||||
if err := keys.Save(context.Background(), store.AuthKeyData{ID: key.ID, Value: value}); err != nil {
|
||||
if err := keys.Save(context.Background(), store.AuthKeyData{ID: key.ID, Value: value, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,6 +342,12 @@ func TestEmailSetupVerificationAuthorizesSignUpWithWelcomeMessageOnlyNoCodeEcho(
|
|||
if _, _, err := authSvc.SignUp(ctx, domain.Authorization{}, phone, hash, "Direct", "Email"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("SignUp before email setup err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, _, err := authSvc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("WebK SignIn with setup-required placeholder err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, _, err := authSvc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("native SignInWithEmail with setup-required placeholder err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, err := accountSvc.SendLoginEmailCode(ctx, 0, phone, hash, "new@example.test", true); err != nil {
|
||||
t.Fatalf("SendLoginEmailCode: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ func TestServiceSendMessageHonorsSendPermissionGate(t *testing.T) {
|
|||
ChannelID: 2001,
|
||||
RandomID: 1,
|
||||
Message: "blocked",
|
||||
}); !errors.Is(err, domain.ErrUserSendRestricted) {
|
||||
t.Fatalf("SendMessage err=%v, want ErrUserSendRestricted", err)
|
||||
}); !errors.Is(err, domain.ErrUserFrozen) {
|
||||
t.Fatalf("SendMessage err=%v, want ErrUserFrozen", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,8 +79,8 @@ func TestServiceSendMonoforumMessageHonorsSendPermissionGate(t *testing.T) {
|
|||
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
|
||||
RandomID: 1,
|
||||
Message: "blocked",
|
||||
}); !errors.Is(err, domain.ErrUserSendRestricted) {
|
||||
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserSendRestricted", err)
|
||||
}); !errors.Is(err, domain.ErrUserFrozen) {
|
||||
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserFrozen", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ func TestServiceMonoforumReplayPrecedesCurrentSendPermissionGate(t *testing.T) {
|
|||
type channelDenySendChecker struct{}
|
||||
|
||||
func (channelDenySendChecker) CanSendMessages(context.Context, int64) error {
|
||||
return domain.ErrUserSendRestricted
|
||||
return domain.ErrUserFrozen
|
||||
}
|
||||
|
||||
func (p testBotProfiles) BotInfo(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"image"
|
||||
"image/color"
|
||||
stddraw "image/draw"
|
||||
_ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
|
|
@ -24,8 +24,8 @@ import (
|
|||
_ "golang.org/x/image/webp" // 注册 webp Decode,用于 custom emoji / sticker 静态缩略图合成
|
||||
)
|
||||
|
||||
// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。
|
||||
// 同一份上传字节在多个 location_key 下建 blob(不做实际缩放,dev 主路径足够)。
|
||||
// 头像使用真实的 's'(≤150)/'a'(≤160)/'c'(原图) rendition;图片消息使用
|
||||
// 'm' 缩略与 'x' 大图。每个头像 location_key 的元数据尺寸必须与实际 blob 一致。
|
||||
|
||||
// UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。
|
||||
func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) {
|
||||
|
|
@ -103,8 +103,8 @@ func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, b
|
|||
return s.media.GetDocument(ctx, id)
|
||||
}
|
||||
|
||||
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation
|
||||
// big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。
|
||||
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('s'/'a'/'c' 尺寸,'a'/'c' 匹配
|
||||
// InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
|
||||
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
|
|
@ -113,7 +113,7 @@ func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.Upload
|
|||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
|
||||
return s.createAvatarPhoto(ctx, data)
|
||||
}
|
||||
|
||||
// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes.
|
||||
|
|
@ -151,7 +151,7 @@ func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.U
|
|||
}
|
||||
s.blobCache.put(blob.LocationKey, blob)
|
||||
stillBytes := s.avatarVideoStill(ctx, body, extraSizes)
|
||||
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
|
||||
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
|
|
@ -192,7 +192,7 @@ func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize)
|
|||
}
|
||||
photoID := randomID()
|
||||
stillBytes := s.generatedAvatarStill(ctx, size)
|
||||
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
|
||||
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
|
|
@ -605,6 +605,26 @@ func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSiz
|
|||
return photo, nil
|
||||
}
|
||||
|
||||
func (s *Service) createAvatarPhoto(ctx context.Context, data []byte) (domain.Photo, error) {
|
||||
photoID := randomID()
|
||||
sizes, err := s.putAvatarStaticSizes(ctx, photoID, data, photoSizeSpecsForAvatar(data))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
photo := domain.Photo{
|
||||
ID: photoID,
|
||||
AccessHash: randomID(),
|
||||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
DCID: s.dc,
|
||||
Sizes: sizes,
|
||||
}
|
||||
if err := s.media.PutPhoto(ctx, photo); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
|
|
@ -630,6 +650,87 @@ func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data [
|
|||
return sizes, nil
|
||||
}
|
||||
|
||||
// putAvatarStaticSizes stores independently rendered avatar sizes. DrKLO uses the
|
||||
// photos.uploadProfilePhoto response's closest 150px size as an immediate local
|
||||
// location, while UserProfilePhoto updates synthesize the canonical 'a' location.
|
||||
// Keeping a real 's' rendition therefore gives those two states distinct keys and,
|
||||
// more importantly, keeps every advertised size backed by matching image bytes.
|
||||
func (s *Service) putAvatarStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
|
||||
src, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil || src.Bounds().Dx() <= 0 || src.Bounds().Dy() <= 0 {
|
||||
return nil, domain.ErrPhotoInvalid
|
||||
}
|
||||
sizes := make([]domain.PhotoSize, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
rendition, err := avatarRendition(data, src, format, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, rendition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob := domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(rendition)),
|
||||
MimeType: imageMimeType(rendition),
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.blobCache.put(blob.LocationKey, blob)
|
||||
s.prewarmSmallBlob(objectKey, rendition)
|
||||
sizes = append(sizes, domain.PhotoSize{
|
||||
Kind: domain.PhotoSizeKindDefault,
|
||||
Type: spec.Type,
|
||||
W: spec.W,
|
||||
H: spec.H,
|
||||
Size: len(rendition),
|
||||
})
|
||||
}
|
||||
return sizes, nil
|
||||
}
|
||||
|
||||
func avatarRendition(original []byte, src image.Image, format string, spec photoSizeSpec) ([]byte, error) {
|
||||
bounds := src.Bounds()
|
||||
if bounds.Dx() == spec.W && bounds.Dy() == spec.H {
|
||||
return append([]byte(nil), original...), nil
|
||||
}
|
||||
if spec.W <= 0 || spec.H <= 0 {
|
||||
return nil, domain.ErrPhotoInvalid
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, spec.W, spec.H))
|
||||
srcRect := centerCropRect(bounds, spec.W, spec.H)
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcRect, xdraw.Src, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if format == "jpeg" {
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 90}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := png.Encode(&buf, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func centerCropRect(bounds image.Rectangle, targetW, targetH int) image.Rectangle {
|
||||
sourceW, sourceH := bounds.Dx(), bounds.Dy()
|
||||
if int64(sourceW)*int64(targetH) > int64(sourceH)*int64(targetW) {
|
||||
cropW := maxInt(1, sourceH*targetW/targetH)
|
||||
x := bounds.Min.X + (sourceW-cropW)/2
|
||||
return image.Rect(x, bounds.Min.Y, x+cropW, bounds.Max.Y)
|
||||
}
|
||||
if int64(sourceW)*int64(targetH) < int64(sourceH)*int64(targetW) {
|
||||
cropH := maxInt(1, sourceW*targetH/targetW)
|
||||
y := bounds.Min.Y + (sourceH-cropH)/2
|
||||
return image.Rect(bounds.Min.X, y, bounds.Max.X, y+cropH)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) {
|
||||
if len(thumbData) == 0 {
|
||||
return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail")
|
||||
|
|
@ -723,12 +824,15 @@ type photoSizeSpec struct {
|
|||
|
||||
func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec {
|
||||
w, h := imageDimensions(data, 640, 640)
|
||||
small := 160
|
||||
if w < small {
|
||||
small = w
|
||||
shortSide := w
|
||||
if h < shortSide {
|
||||
shortSide = h
|
||||
}
|
||||
sSize := minInt(shortSide, 150)
|
||||
aSize := minInt(shortSide, 160)
|
||||
return []photoSizeSpec{
|
||||
{Type: "a", W: small, H: small},
|
||||
{Type: "s", W: sSize, H: sSize},
|
||||
{Type: "a", W: aSize, H: aSize},
|
||||
{Type: "c", W: w, H: h},
|
||||
}
|
||||
}
|
||||
|
|
@ -767,10 +871,14 @@ const (
|
|||
avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。
|
||||
)
|
||||
|
||||
// avatarVideoStill 生成动画头像的静态尺寸字节:优先抽取上传视频首帧——动画头像
|
||||
// (emoji/sticker 构造器或自选视频)的首帧就是用户在客户端看到的真实画面(彩色
|
||||
// emoji、圆角、布局都一致);抽帧不可用时回退到按 markup 服务端合成。
|
||||
// avatarVideoStill 生成动画头像的静态尺寸字节。emoji/sticker markup 能解析到
|
||||
// 服务端缩略图时优先合成:DrKLO 生成的 MP4 第一帧可能只有背景渐变,直接抽第一帧
|
||||
// 会让静态头像永久缺少 emoji。普通视频或 markup 资源不可用时才回退 ffmpeg 首帧。
|
||||
func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte {
|
||||
markup := avatarStillMarkup(extraSizes)
|
||||
if still, ok := s.generatedAvatarMarkupStill(ctx, markup); ok {
|
||||
return still
|
||||
}
|
||||
if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes {
|
||||
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
|
||||
if err == nil && int64(len(data)) == total && total == body.Size {
|
||||
|
|
@ -789,17 +897,30 @@ func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob
|
|||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
return s.generatedAvatarStill(ctx, avatarStillMarkup(extraSizes))
|
||||
return s.generatedAvatarStill(ctx, markup)
|
||||
}
|
||||
|
||||
func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte {
|
||||
img := generatedAvatarBackground(markup.BackgroundColors)
|
||||
if overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup); ok {
|
||||
drawAvatarMarkup(img, overlay, tintWhite)
|
||||
if still, ok := s.generatedAvatarMarkupStillOnBackground(ctx, markup, img); ok {
|
||||
return still
|
||||
}
|
||||
return encodeAvatarPNG(img)
|
||||
}
|
||||
|
||||
func (s *Service) generatedAvatarMarkupStill(ctx context.Context, markup domain.PhotoSize) ([]byte, bool) {
|
||||
return s.generatedAvatarMarkupStillOnBackground(ctx, markup, generatedAvatarBackground(markup.BackgroundColors))
|
||||
}
|
||||
|
||||
func (s *Service) generatedAvatarMarkupStillOnBackground(ctx context.Context, markup domain.PhotoSize, img *image.RGBA) ([]byte, bool) {
|
||||
overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
drawAvatarMarkup(img, overlay, tintWhite)
|
||||
return encodeAvatarPNG(img), true
|
||||
}
|
||||
|
||||
func generatedAvatarBackground(colors []int) *image.RGBA {
|
||||
if len(colors) == 0 {
|
||||
colors = []int{0x5b8def, 0x53c6a4}
|
||||
|
|
@ -865,6 +986,11 @@ func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSi
|
|||
zap.Error(err))
|
||||
return nil, false, false
|
||||
}
|
||||
// Seed 的 1x1 透明图只是“没有可用静态资源”的显式占位,不是可合成
|
||||
// 内容。把它视为 unavailable,交给调用方回退到 ffmpeg 视频首帧。
|
||||
if img.Bounds().Dx() <= 1 || img.Bounds().Dy() <= 1 {
|
||||
return nil, false, false
|
||||
}
|
||||
return img, documentIsTextColorEmoji(doc), true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -223,6 +223,57 @@ func TestCreatePhotoFromBytesStoresDownloadableMessageSizes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateAvatarFromUploadStoresRealSizedRenditions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 2)
|
||||
data := testJPEG(t, 640, 480)
|
||||
if _, err := svc.SaveFilePart(ctx, 10, 301, 0, data); err != nil {
|
||||
t.Fatalf("SaveFilePart: %v", err)
|
||||
}
|
||||
|
||||
photo, err := svc.CreateAvatarFromUpload(ctx, domain.UploadedFileRef{
|
||||
OwnerUserID: 10,
|
||||
FileID: 301,
|
||||
Parts: 1,
|
||||
Name: "avatar.jpg",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAvatarFromUpload: %v", err)
|
||||
}
|
||||
wants := map[string]image.Point{
|
||||
"s": {X: 150, Y: 150},
|
||||
"a": {X: 160, Y: 160},
|
||||
"c": {X: 640, Y: 480},
|
||||
}
|
||||
if len(photo.Sizes) != len(wants) {
|
||||
t.Fatalf("avatar sizes = %+v, want s/a/c", photo.Sizes)
|
||||
}
|
||||
objectKeys := map[string]struct{}{}
|
||||
for _, size := range photo.Sizes {
|
||||
want, ok := wants[size.Type]
|
||||
if !ok || size.W != want.X || size.H != want.Y {
|
||||
t.Fatalf("avatar size = %+v, want one of %v", size, wants)
|
||||
}
|
||||
assertAvatarImageSize(t, svc, photo.ID, size.Type, want.X, want.Y, "image/jpeg")
|
||||
blob, found, err := media.GetFileBlob(ctx, fmt.Sprintf("photo:%d:%s", photo.ID, size.Type))
|
||||
if err != nil || !found {
|
||||
t.Fatalf("avatar %s blob found=%v err=%v", size.Type, found, err)
|
||||
}
|
||||
if blob.Size != int64(size.Size) {
|
||||
t.Fatalf("avatar %s blob size=%d metadata size=%d", size.Type, blob.Size, size.Size)
|
||||
}
|
||||
objectKeys[blob.ObjectKey] = struct{}{}
|
||||
}
|
||||
if len(objectKeys) != 3 {
|
||||
t.Fatalf("avatar object keys = %v, want distinct s/a/c renditions", objectKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
|
|
@ -299,8 +350,9 @@ func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
|
|||
if !domain.PhotoHasVideo(photo.Sizes) {
|
||||
t.Fatalf("avatar markup photo sizes = %+v, want video markup", photo.Sizes)
|
||||
}
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
|
||||
}
|
||||
|
||||
// TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes 守护两个行为:
|
||||
|
|
@ -433,8 +485,9 @@ func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
|
||||
}
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
|
||||
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("photo:%d:u", photo.ID),
|
||||
Offset: 0,
|
||||
|
|
@ -448,9 +501,9 @@ func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame 守护动画头像静态尺寸优先取
|
||||
// 上传视频首帧(客户端真实渲染画面),而不是服务端合成的近似 still。
|
||||
func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
|
||||
// TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame 守护 markup document/thumb
|
||||
// 不可用时仍可从上传视频抽帧,不能让普通动画头像失去静态尺寸。
|
||||
func TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
|
|
@ -479,7 +532,7 @@ func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
|
|||
t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls)
|
||||
}
|
||||
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("photo:%d:a", photo.ID),
|
||||
LocationKey: fmt.Sprintf("photo:%d:c", photo.ID),
|
||||
Offset: 0,
|
||||
Limit: 1 << 20,
|
||||
})
|
||||
|
|
@ -492,9 +545,112 @@ func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
|
|||
if chunk.MimeType != "image/jpeg" {
|
||||
t.Fatalf("avatar still mime = %q, want image/jpeg from extracted frame", chunk.MimeType)
|
||||
}
|
||||
assertAvatarImageSize(t, svc, photo.ID, "s", 150, 150, "image/jpeg")
|
||||
assertAvatarImageSize(t, svc, photo.ID, "a", 160, 160, "image/jpeg")
|
||||
}
|
||||
|
||||
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string) {
|
||||
func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
const emojiID = int64(78)
|
||||
if err := media.PutDocument(ctx, domain.Document{
|
||||
ID: emojiID,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindCached, Type: "m", W: 1, H: 1,
|
||||
Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("PutDocument: %v", err)
|
||||
}
|
||||
frame := testJPEG(t, 640, 640)
|
||||
thumbnailer := &fakeVideoThumbnailer{thumb: frame}
|
||||
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
|
||||
if _, err := svc.SaveFilePart(ctx, 10, 503, 0, []byte("profile-video-without-server-preview")); err != nil {
|
||||
t.Fatalf("SaveFilePart: %v", err)
|
||||
}
|
||||
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
|
||||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 503, Parts: 1, Name: "avatar.mp4"},
|
||||
0,
|
||||
domain.PhotoSize{Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: emojiID, BackgroundColors: []int{0x112233}})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
|
||||
}
|
||||
if thumbnailer.calls != 1 {
|
||||
t.Fatalf("thumbnailer calls = %d, want synthetic preview rejected and video fallback used", thumbnailer.calls)
|
||||
}
|
||||
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("photo:%d:c", photo.ID), Limit: 1 << 20})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("avatar c blob found=%v err=%v", found, err)
|
||||
}
|
||||
if !bytes.Equal(chunk.Bytes, frame) {
|
||||
t.Fatal("avatar still did not use extracted video frame after rejecting synthetic preview")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateAvatarVideoMarkupPrefersComposedStill 守护 DrKLO emoji 构造器边界:
|
||||
// 客户端生成 MP4 的第一帧可能只有渐变背景;只要 markup thumb 可解析,静态头像
|
||||
// 必须用服务端合成结果,确保 emoji 在当前 session 回显和冷启动中都可见。
|
||||
func TestCreateAvatarVideoMarkupPrefersComposedStill(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
const emojiID = int64(501)
|
||||
if err := media.PutDocument(ctx, domain.Document{
|
||||
ID: emojiID,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindCached,
|
||||
Type: "m",
|
||||
W: 64,
|
||||
H: 64,
|
||||
Bytes: testTransparentThumbPNG(t),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("PutDocument: %v", err)
|
||||
}
|
||||
thumbnailer := &fakeVideoThumbnailer{thumb: testJPEG(t, 320, 320)}
|
||||
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
|
||||
if _, err := svc.SaveFilePart(ctx, 10, 502, 0, []byte("background-only-profile-video")); err != nil {
|
||||
t.Fatalf("SaveFilePart: %v", err)
|
||||
}
|
||||
|
||||
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
|
||||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 502, Parts: 1, Name: "avatar.mp4"},
|
||||
0,
|
||||
domain.PhotoSize{
|
||||
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
|
||||
EmojiID: emojiID,
|
||||
BackgroundColors: []int{0x112233, 0x445566},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
|
||||
}
|
||||
if thumbnailer.calls != 0 {
|
||||
t.Fatalf("thumbnailer calls = %d, want 0 when markup still is available", thumbnailer.calls)
|
||||
}
|
||||
r, g, b, _ := avatarStillCenterPixel(t, svc, photo.ID)
|
||||
if r < 200 || g > 90 || b > 90 {
|
||||
t.Fatalf("composed center pixel rgb=(%d,%d,%d), want visible red emoji overlay", r, g, b)
|
||||
}
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
|
||||
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
|
||||
}
|
||||
|
||||
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string, side int) {
|
||||
t.Helper()
|
||||
assertAvatarImageSize(t, svc, photoID, sizeType, side, side, "image/png")
|
||||
}
|
||||
|
||||
func assertAvatarImageSize(t *testing.T, svc *Service, photoID int64, sizeType string, wantW, wantH int, wantMime string) {
|
||||
t.Helper()
|
||||
chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, sizeType),
|
||||
|
|
@ -504,8 +660,15 @@ func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, siz
|
|||
if err != nil || !found {
|
||||
t.Fatalf("avatar %s blob found=%v err=%v", sizeType, found, err)
|
||||
}
|
||||
if len(chunk.Bytes) == 0 || chunk.MimeType != "image/png" {
|
||||
t.Fatalf("avatar %s chunk mime=%q bytes=%d, want image/png bytes", sizeType, chunk.MimeType, len(chunk.Bytes))
|
||||
if len(chunk.Bytes) == 0 || chunk.MimeType != wantMime {
|
||||
t.Fatalf("avatar %s chunk mime=%q bytes=%d, want %s bytes", sizeType, chunk.MimeType, len(chunk.Bytes), wantMime)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(chunk.Bytes))
|
||||
if err != nil {
|
||||
t.Fatalf("decode avatar %s: %v", sizeType, err)
|
||||
}
|
||||
if gotW, gotH := img.Bounds().Dx(), img.Bounds().Dy(); gotW != wantW || gotH != wantH {
|
||||
t.Fatalf("avatar %s pixels=%dx%d, want %dx%d", sizeType, gotW, gotH, wantW, wantH)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
|
|||
// 这样向已部署(非空 store)的 data/sticker-seed 丢新集后重启即可生效,无需清库重 seed。
|
||||
// 仅当检测到旧版缩略图/可渲染预览元数据缺失时 force=true 全量重导修复。
|
||||
forceSticker := false
|
||||
previewState := fmt.Sprintf("%s:dc=%d", seedStickerPreviewStateVersion, s.dc)
|
||||
if n, err := s.media.CountStickerSets(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if n > 0 {
|
||||
|
|
@ -88,11 +89,18 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
|
|||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
forceSticker = stale
|
||||
migrated, err := s.seedStateMatches(ctx, seedStickerPreviewStateKey, previewState)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
forceSticker = stale || !migrated
|
||||
}
|
||||
if err := s.seedStickerSets(ctx, root, maxRegularSets, forceSticker, &stats); err != nil {
|
||||
return stats, fmt.Errorf("seed sticker sets: %w", err)
|
||||
}
|
||||
if err := s.putSeedState(ctx, seedStickerPreviewStateKey, previewState); err != nil {
|
||||
return stats, fmt.Errorf("record sticker preview seed state: %w", err)
|
||||
}
|
||||
s.logSeedPhase("sticker_sets", phaseStarted, phaseBefore, stats)
|
||||
|
||||
phaseStarted = time.Now()
|
||||
|
|
@ -379,6 +387,10 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
|
|||
if dj.ID == 0 {
|
||||
return domain.Document{}, nil
|
||||
}
|
||||
existing, existingFound, err := s.media.GetDocument(ctx, dj.ID)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
ref, _ := hex.DecodeString(dj.FileReference)
|
||||
doc := domain.Document{
|
||||
ID: dj.ID,
|
||||
|
|
@ -436,27 +448,40 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
|
|||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
ps.Size = len(data)
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, ps.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: seedThumbMimeType(data),
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
ps = seedInlineCachedDocumentThumb(ps, data)
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
// A duplicate document can be present in several catalogs. Do not replace a
|
||||
// better already-persisted preview (and its shared location key) with a lower
|
||||
// quality rendition from the catalog imported later.
|
||||
if prior, ok := seedDocumentThumbByType(existing.Thumbs, ps.Type); existingFound && ok && seedPhotoSizeBetter(prior, ps) {
|
||||
ps = prior
|
||||
} else {
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, ps.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: seedThumbMimeType(data),
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
}
|
||||
}
|
||||
thumbs = append(thumbs, ps)
|
||||
}
|
||||
doc.Thumbs = thumbs
|
||||
if existingFound {
|
||||
doc.Thumbs = mergeSeedDocumentThumbs(existing.Thumbs, doc.Thumbs)
|
||||
}
|
||||
if err := s.ensureSeedCachedThumbBlobs(ctx, doc, stats); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
|
||||
if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil {
|
||||
return domain.Document{}, err
|
||||
|
|
@ -700,6 +725,138 @@ func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.Phot
|
|||
return ps
|
||||
}
|
||||
|
||||
// mergeSeedDocumentThumbs makes duplicate seed imports monotonic for preview quality.
|
||||
// PhotoSize.Type is also the blob location suffix, so only one winner per type may be
|
||||
// advertised. Incoming metadata wins ties; a richer existing preview wins downgrades.
|
||||
func mergeSeedDocumentThumbs(existing, incoming []domain.PhotoSize) []domain.PhotoSize {
|
||||
out := append([]domain.PhotoSize(nil), incoming...)
|
||||
byType := make(map[string]int, len(out))
|
||||
for i, thumb := range out {
|
||||
if thumb.Type != "" {
|
||||
byType[thumb.Type] = i
|
||||
}
|
||||
}
|
||||
for _, thumb := range existing {
|
||||
if thumb.Type != "" {
|
||||
if i, ok := byType[thumb.Type]; ok {
|
||||
if seedPhotoSizeBetter(thumb, out[i]) {
|
||||
out[i] = thumb
|
||||
}
|
||||
continue
|
||||
}
|
||||
byType[thumb.Type] = len(out)
|
||||
}
|
||||
out = append(out, thumb)
|
||||
}
|
||||
|
||||
hasRealPreview := false
|
||||
for _, thumb := range out {
|
||||
if !seedSyntheticTGStickerPreviewThumb(thumb) && seedPhotoSizePreviewTier(thumb) > 1 {
|
||||
hasRealPreview = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRealPreview {
|
||||
return out
|
||||
}
|
||||
filtered := out[:0]
|
||||
for _, thumb := range out {
|
||||
if !seedSyntheticTGStickerPreviewThumb(thumb) {
|
||||
filtered = append(filtered, thumb)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func seedDocumentThumbByType(thumbs []domain.PhotoSize, typ string) (domain.PhotoSize, bool) {
|
||||
for _, thumb := range thumbs {
|
||||
if thumb.Type == typ {
|
||||
return thumb, true
|
||||
}
|
||||
}
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
|
||||
func seedPhotoSizeBetter(a, b domain.PhotoSize) bool {
|
||||
aTier, bTier := seedPhotoSizePreviewTier(a), seedPhotoSizePreviewTier(b)
|
||||
if aTier != bTier {
|
||||
return aTier > bTier
|
||||
}
|
||||
aArea, bArea := int64(a.W)*int64(a.H), int64(b.W)*int64(b.H)
|
||||
if aArea != bArea {
|
||||
return aArea > bArea
|
||||
}
|
||||
aPayload, bPayload := len(a.Bytes)+a.Size, len(b.Bytes)+b.Size
|
||||
return aPayload > bPayload
|
||||
}
|
||||
|
||||
func seedPhotoSizePreviewTier(thumb domain.PhotoSize) int {
|
||||
if seedSyntheticTGStickerPreviewThumb(thumb) {
|
||||
return 0
|
||||
}
|
||||
switch thumb.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(thumb.Bytes) > 0 && thumb.W > 0 && thumb.H > 0 {
|
||||
return 4
|
||||
}
|
||||
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
|
||||
if thumb.Size > 0 && thumb.W > 0 && thumb.H > 0 {
|
||||
return 4
|
||||
}
|
||||
case domain.PhotoSizeKindPath, domain.PhotoSizeKindStripped:
|
||||
if len(thumb.Bytes) > 0 {
|
||||
return 3
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func seedSyntheticTGStickerPreviewThumb(thumb domain.PhotoSize) bool {
|
||||
return thumb.Kind == domain.PhotoSizeKindCached &&
|
||||
thumb.Type == seedSyntheticDocumentThumbType &&
|
||||
thumb.W == 1 && thumb.H == 1 &&
|
||||
bytes.Equal(thumb.Bytes, seedSyntheticTGStickerPreviewThumbPNG)
|
||||
}
|
||||
|
||||
// ensureSeedCachedThumbBlobs keeps the RPC conversion invariant: document cached
|
||||
// previews are exposed as downloadable PhotoSize entries, so every advertised type
|
||||
// must have a matching blob even when the source JSON carried the bytes inline.
|
||||
func (s *Service) ensureSeedCachedThumbBlobs(ctx context.Context, doc domain.Document, stats *SeedStats) error {
|
||||
for _, thumb := range doc.Thumbs {
|
||||
if thumb.Kind != domain.PhotoSizeKindCached || thumb.Type == "" || len(thumb.Bytes) == 0 {
|
||||
continue
|
||||
}
|
||||
locationKey := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
|
||||
mimeType := seedThumbMimeType(thumb.Bytes)
|
||||
stored, found, err := s.media.GetFileBlob(ctx, locationKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && stored.Size == int64(len(thumb.Bytes)) && stored.MimeType == mimeType {
|
||||
continue
|
||||
}
|
||||
if s.blobs == nil {
|
||||
return fmt.Errorf("blob backend not configured for cached document thumb %s", locationKey)
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, thumb.Bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: locationKey,
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(thumb.Bytes)),
|
||||
MimeType: mimeType,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.prewarmSmallBlob(objectKey, thumb.Bytes)
|
||||
stats.Blobs++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error {
|
||||
if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) {
|
||||
return nil
|
||||
|
|
@ -826,11 +983,12 @@ func (s *Service) documentsNeedSeedRepair(ctx context.Context, ids []int64) (boo
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
want := seedThumbMimeType(thumb.Bytes)
|
||||
if want != "application/octet-stream" && blob.MimeType != want {
|
||||
return true, nil
|
||||
}
|
||||
if !ok {
|
||||
return true, nil
|
||||
}
|
||||
want := seedThumbMimeType(thumb.Bytes)
|
||||
if blob.Size != int64(len(thumb.Bytes)) || (want != "application/octet-stream" && blob.MimeType != want) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,20 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
seedEffectsStateKey = "files.effects"
|
||||
seedEffectsStateVersion = "effects-v2"
|
||||
seedAppearanceStateKey = "files.appearance"
|
||||
seedAppearanceStateVersion = "appearance-v1"
|
||||
seedStickerPreviewStateKey = "files.sticker_previews"
|
||||
// v2 explicitly rebuilds sticker documents written before duplicate seed imports
|
||||
// became monotonic. Those databases may contain an effects-generated transparent
|
||||
// 1x1 preview where the sticker catalog has a real static thumbnail.
|
||||
seedStickerPreviewStateVersion = "sticker-previews-v2-monotonic"
|
||||
seedAppearanceStateKey = "files.appearance"
|
||||
seedAppearanceStateVersion = "appearance-v1"
|
||||
)
|
||||
|
||||
func (s *Service) seedStateMatches(ctx context.Context, key, want string) (bool, error) {
|
||||
|
|
@ -88,16 +95,17 @@ func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []str
|
|||
}
|
||||
for _, tj := range dj.Thumbs {
|
||||
ps, downloadable := seedPhotoSize(tj)
|
||||
if !downloadable || ps.Type == "" {
|
||||
if ps.Type == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := index.thumb[dj.ID][ps.Type]; ok {
|
||||
if downloadable {
|
||||
if _, ok := index.thumb[dj.ID][ps.Type]; ok {
|
||||
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
|
||||
}
|
||||
} else if ps.Kind == domain.PhotoSizeKindCached && len(ps.Bytes) > 0 && ps.W > 0 && ps.H > 0 {
|
||||
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
|
||||
}
|
||||
}
|
||||
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
|
||||
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, seedSyntheticDocumentThumbType))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +152,28 @@ func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumen
|
|||
if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size {
|
||||
return false, nil
|
||||
}
|
||||
// A catalog without its own thumbnail may share this document with a richer
|
||||
// catalog. Readiness follows the preview that is actually stored instead of
|
||||
// demanding the synthetic "m" key and repeatedly downgrading that richer
|
||||
// document on every import.
|
||||
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
|
||||
if len(doc.Thumbs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
switch thumb.Kind {
|
||||
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive, domain.PhotoSizeKindCached:
|
||||
if thumb.Type == "" {
|
||||
return false, nil
|
||||
}
|
||||
key := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
|
||||
if _, seen := seenLocationKeys[key]; !seen {
|
||||
seenLocationKeys[key] = struct{}{}
|
||||
locationKeys = append(locationKeys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(expected, doc.ID)
|
||||
}
|
||||
if len(expected) > 0 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
|
@ -463,8 +464,8 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("repair seed: %v", err)
|
||||
}
|
||||
if stats.Reactions != 1 || stats.Blobs != 3 || stats.Skipped {
|
||||
t.Fatalf("repair stats = %+v, want repair import", stats)
|
||||
if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
|
||||
t.Fatalf("repair stats = %+v, want two missing/revalidated main blobs without rewriting intact preview", stats)
|
||||
}
|
||||
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
|
||||
t.Fatal("missing reaction blob was not repaired")
|
||||
|
|
@ -609,8 +610,113 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("repair seed: %v", err)
|
||||
}
|
||||
if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 2 {
|
||||
t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired)
|
||||
if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 1 {
|
||||
t.Fatalf("repair stats = %+v, want missing main blob repaired without rewriting intact preview", repaired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
seedDir := t.TempDir()
|
||||
const sourceID int64 = 7777777
|
||||
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 29)
|
||||
writeEffectsSeed(t, seedDir, sourceID)
|
||||
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 2)
|
||||
first, err := svc.SeedMedia(ctx, seedDir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("first seed: %v", err)
|
||||
}
|
||||
if first.StickerSets != 1 || first.Effects != 1 {
|
||||
t.Fatalf("first stats = %+v, want shared sticker and effect catalogs", first)
|
||||
}
|
||||
|
||||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("shared document ok=%v err=%v", ok, err)
|
||||
}
|
||||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||||
if !ok {
|
||||
t.Fatalf("shared document thumbs = %+v, want real cached preview", doc.Thumbs)
|
||||
}
|
||||
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) || seedSyntheticTGStickerPreviewThumb(thumb) {
|
||||
t.Fatalf("shared preview = %+v, want original 128x128 catalog thumbnail", thumb)
|
||||
}
|
||||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", sourceID))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("shared preview blob ok=%v err=%v", ok, err)
|
||||
}
|
||||
if blob.MimeType != "image/jpeg" || blob.Size != int64(len(realThumb)) {
|
||||
t.Fatalf("shared preview blob = %+v, want real JPEG metadata", blob)
|
||||
}
|
||||
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("doc:%d:m", sourceID), Limit: 1024})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get shared preview ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !bytes.Equal(chunk.Bytes, realThumb) {
|
||||
t.Fatalf("downloaded shared preview = %x, want %x", chunk.Bytes, realThumb)
|
||||
}
|
||||
|
||||
second, err := svc.SeedMedia(ctx, seedDir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("second seed: %v", err)
|
||||
}
|
||||
if second.Documents != 0 || second.Blobs != 0 {
|
||||
t.Fatalf("second stats = %+v, want shared rich preview to satisfy effects readiness", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMediaMigratesSyntheticStickerPreviewToExportedThumbnail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
seedDir := t.TempDir()
|
||||
const sourceID int64 = 8888888
|
||||
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 31)
|
||||
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutDocument(ctx, domain.Document{
|
||||
ID: sourceID,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindCached, Type: seedSyntheticDocumentThumbType,
|
||||
W: 1, H: 1, Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("put stale document: %v", err)
|
||||
}
|
||||
if err := media.PutStickerSet(ctx, domain.StickerSet{
|
||||
ID: 773947703670341676, AccessHash: 1, ShortName: "StatusPack", Title: "Status Pack",
|
||||
Hash: 31, Kind: domain.StickerSetKindEmoji, Emojis: true, DocumentIDs: []int64{sourceID},
|
||||
}); err != nil {
|
||||
t.Fatalf("put stale sticker set: %v", err)
|
||||
}
|
||||
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 2)
|
||||
stats, err := svc.SeedMedia(ctx, seedDir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("migration seed: %v", err)
|
||||
}
|
||||
if stats.StickerSets != 1 || stats.Documents != 1 {
|
||||
t.Fatalf("migration stats = %+v, want forced sticker document rebuild", stats)
|
||||
}
|
||||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("migrated document ok=%v err=%v", ok, err)
|
||||
}
|
||||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||||
if !ok || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
|
||||
t.Fatalf("migrated thumbs = %+v, want exported 128x128 preview", doc.Thumbs)
|
||||
}
|
||||
if state, ok, err := media.GetSeedState(ctx, seedStickerPreviewStateKey); err != nil || !ok || state == "" {
|
||||
t.Fatalf("preview migration state = %q ok=%v err=%v", state, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -735,6 +841,28 @@ func writeStatusPackWithoutThumbSeed(t *testing.T, seedDir string, sourceID int6
|
|||
}
|
||||
}
|
||||
|
||||
func writeStatusPackWithThumbSeed(t *testing.T, seedDir string, sourceID int64, setHash int) []byte {
|
||||
t.Helper()
|
||||
setDir := filepath.Join(seedDir, "telegram_emoji_export", "StatusPack_773947703670341676")
|
||||
stickersDir := filepath.Join(setDir, "stickers")
|
||||
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realThumb := []byte{0xff, 0xd8, 0xff, 0xdb, 0, 4, 0xff, 0xd9}
|
||||
raw := fmt.Sprintf(`{"result":{"set":{"id":773947703670341676,"access_hash":1,"title":"Status Pack","short_name":"StatusPack","count":1,"hash":%d,"emojis":true,"packs":[{"emoticon":"👋","documents":[%d]}]},"packs":[{"emoticon":"👋","documents":[%d]}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeCustomEmoji","alt":"👋","text_color":true,"stickerset":{"id":773947703670341676,"access_hash":1}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[{"_":"PhotoPathSize","type":"j","bytes":"01"},{"_":"PhotoSize","type":"m","w":128,"h":128,"size":%d}]}]}}`, setHash, sourceID, sourceID, sourceID, len(realThumb))
|
||||
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("status_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thumbName := fmt.Sprintf("status_%d_thumb1_PhotoSize_typem_128x128.jpg", sourceID)
|
||||
if err := os.WriteFile(filepath.Join(stickersDir, thumbName), realThumb, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return realThumb
|
||||
}
|
||||
|
||||
func writeEffectsSeed(t *testing.T, seedDir string, sourceID int64) {
|
||||
t.Helper()
|
||||
docsDir := filepath.Join(seedDir, "telegram_effects_export", "documents")
|
||||
|
|
@ -992,7 +1120,7 @@ func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
|
|||
if err := media.PutDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/jpeg"}); err != nil {
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/jpeg"}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
svc := NewService(media, nil, 2)
|
||||
|
|
@ -1004,7 +1132,7 @@ func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
|
|||
t.Fatal("expected stale mime to require repair")
|
||||
}
|
||||
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/webp"}); err != nil {
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/webp"}); err != nil {
|
||||
t.Fatalf("put repaired blob: %v", err)
|
||||
}
|
||||
stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Star gift 目录:从已 seed 的 animated_emoji 集按 emoticon 精选贴纸文档合成(复用文档行与
|
||||
// blob,不复制字节),镜像 EnsureDefaultEmojiStatusSet。目录是静态的,不入库。
|
||||
// 礼物 ID 取明显隔离的常量段避免撞键。
|
||||
|
||||
const starGiftIDBase int64 = 8_888_000_000_000_000
|
||||
|
||||
type starGiftSeed struct {
|
||||
id int64
|
||||
emoticon string
|
||||
stars int64
|
||||
title string
|
||||
}
|
||||
|
||||
// starGiftSeeds 是固定礼物目录(emoticon 需在 animated_emoji 集里,否则该礼物被跳过)。
|
||||
// convert_stars = stars(v1 全额转换,视作用新购 Stars 买入)。
|
||||
var starGiftSeeds = []starGiftSeed{
|
||||
{starGiftIDBase + 1, "❤", 15, "Heart"},
|
||||
{starGiftIDBase + 2, "\U0001f382", 50, "Cake"}, // 🎂
|
||||
{starGiftIDBase + 3, "\U0001f389", 100, "Party"}, // 🎉
|
||||
{starGiftIDBase + 4, "\U0001f525", 250, "Fire"}, // 🔥
|
||||
{starGiftIDBase + 5, "\U0001f3c6", 500, "Trophy"}, // 🏆
|
||||
{starGiftIDBase + 6, "\U0001f48e", 1000, "Diamond"}, // 💎
|
||||
{starGiftIDBase + 7, "\U0001f680", 2500, "Rocket"}, // 🚀
|
||||
}
|
||||
|
||||
// BuildStarGiftCatalog 合成可购买礼物目录:解析每个 seed emoticon 的贴纸文档,跳过未 seed 的。
|
||||
// animated_emoji 未 seed 时返回空目录(客户端显示空礼物面板,购买流仍可对已知 gift_id 工作)。
|
||||
func (s *Service) BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error) {
|
||||
source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup animated_emoji set for star gifts: %w", err)
|
||||
}
|
||||
if !found || len(source.Packs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
byEmoticon := make(map[string]int64, len(source.Packs))
|
||||
for _, pack := range source.Packs {
|
||||
key := normalizeStatusEmoticon(pack.Emoticon)
|
||||
if key == "" || len(pack.DocumentIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := byEmoticon[key]; !ok {
|
||||
byEmoticon[key] = pack.DocumentIDs[0]
|
||||
}
|
||||
}
|
||||
// 收集要加载的文档 id(去重)。
|
||||
docIDs := make([]int64, 0, len(starGiftSeeds))
|
||||
chosen := make([]starGiftSeed, 0, len(starGiftSeeds))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, seed := range starGiftSeeds {
|
||||
id, ok := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
|
||||
if !ok || id == 0 {
|
||||
continue
|
||||
}
|
||||
chosen = append(chosen, seed)
|
||||
if _, dup := seen[id]; !dup {
|
||||
seen[id] = struct{}{}
|
||||
docIDs = append(docIDs, id)
|
||||
}
|
||||
}
|
||||
if len(chosen) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, docIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift sticker documents: %w", err)
|
||||
}
|
||||
docByID := make(map[int64]domain.Document, len(docs))
|
||||
for _, d := range docs {
|
||||
docByID[d.ID] = d
|
||||
}
|
||||
catalog := make([]domain.StarGift, 0, len(chosen))
|
||||
for _, seed := range chosen {
|
||||
id := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
|
||||
doc, ok := docByID[id]
|
||||
if !ok || doc.ID == 0 {
|
||||
continue
|
||||
}
|
||||
catalog = append(catalog, domain.StarGift{
|
||||
ID: seed.id,
|
||||
Stars: seed.stars,
|
||||
ConvertStars: seed.stars,
|
||||
Title: seed.title,
|
||||
Sticker: doc,
|
||||
})
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
|
@ -3,7 +3,9 @@ package help
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
|
|
@ -67,6 +69,7 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增
|
|||
type Service struct {
|
||||
appConfigs store.AppConfigStore
|
||||
countries store.CountryStore
|
||||
accountFreeze AccountFreezeProvider
|
||||
mapboxToken string
|
||||
emailSignupEnable bool
|
||||
emailSignupPhonePrefixes []string
|
||||
|
|
@ -80,6 +83,18 @@ type Service struct {
|
|||
// Option 配置 help 服务运行期默认目录。
|
||||
type Option func(*Service)
|
||||
|
||||
// AccountFreezeProvider supplies account-specific read-only state without
|
||||
// exposing protocol types to the help application service.
|
||||
type AccountFreezeProvider interface {
|
||||
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
|
||||
return func(s *Service) {
|
||||
s.accountFreeze = provider
|
||||
}
|
||||
}
|
||||
|
||||
// WithMapboxToken 设置 TDesktop appConfig 与地图缩略图代理共用的 Mapbox token。
|
||||
func WithMapboxToken(token string) Option {
|
||||
return func(s *Service) {
|
||||
|
|
@ -156,12 +171,69 @@ func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool, emailSi
|
|||
return h + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
|
||||
}
|
||||
|
||||
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。首次调用加载一次后缓存。
|
||||
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
|
||||
// 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.
|
||||
func (s *Service) GetAppConfig(ctx context.Context, userID int64, hash int) (domain.AppConfig, bool, error) {
|
||||
cfg := s.loadAppConfig(ctx)
|
||||
var err error
|
||||
cfg, err = s.accountAppConfig(ctx, userID, cfg)
|
||||
if err != nil {
|
||||
return domain.AppConfig{}, false, err
|
||||
}
|
||||
return cfg, hash != 0 && hash == cfg.Hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domain.AppConfig) (domain.AppConfig, error) {
|
||||
values := make(map[string]json.RawMessage)
|
||||
if err := json.Unmarshal(base.JSON, &values); err != nil {
|
||||
return domain.AppConfig{}, fmt.Errorf("decode base app config: %w", err)
|
||||
}
|
||||
changed := false
|
||||
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
|
||||
if _, exists := values[key]; exists {
|
||||
delete(values, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return domain.AppConfig{}, fmt.Errorf("load account freeze: %w", err)
|
||||
}
|
||||
if found && freeze.Frozen {
|
||||
values["freeze_since_date"] = json.RawMessage(strconv.FormatInt(freeze.Since.Unix(), 10))
|
||||
values["freeze_until_date"] = json.RawMessage(strconv.FormatInt(freeze.Until.Unix(), 10))
|
||||
appeal, _ := json.Marshal(freeze.AppealURL)
|
||||
values["freeze_appeal_url"] = appeal
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return base, nil
|
||||
}
|
||||
body, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return domain.AppConfig{}, fmt.Errorf("encode account app config: %w", err)
|
||||
}
|
||||
hashInput := append([]byte(strconv.Itoa(base.Hash)+"\x00"), body...)
|
||||
overlayHash := int(crc32.ChecksumIEEE(hashInput) & 0x7fffffff)
|
||||
if overlayHash == 0 || overlayHash == base.Hash {
|
||||
overlayHash = base.Hash + 1
|
||||
}
|
||||
return domain.AppConfig{Client: base.Client, Hash: overlayHash, JSON: body}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
|
||||
if s == nil {
|
||||
return defaultAppConfig("", false, nil)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
|
||||
disabled := NewService(nil, nil)
|
||||
cfg, _, err := disabled.GetAppConfig(ctx, 0)
|
||||
cfg, _, err := disabled.GetAppConfig(ctx, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAppConfig (disabled): %v", err)
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) {
|
|||
enabled := NewService(nil, nil,
|
||||
WithEmailSignupEnable(true),
|
||||
WithEmailSignupPhonePrefixes([]string{"888", "380", "373"}))
|
||||
cfg2, _, err := enabled.GetAppConfig(ctx, 0)
|
||||
cfg2, _, err := enabled.GetAppConfig(ctx, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAppConfig (enabled): %v", err)
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) {
|
|||
other := NewService(nil, nil,
|
||||
WithEmailSignupEnable(true),
|
||||
WithEmailSignupPhonePrefixes([]string{"888"}))
|
||||
cfg3, _, err := other.GetAppConfig(ctx, 0)
|
||||
cfg3, _, err := other.GetAppConfig(ctx, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAppConfig (different prefixes): %v", err)
|
||||
}
|
||||
|
|
|
|||
133
internal/app/help/service_freeze_test.go
Normal file
133
internal/app/help/service_freeze_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package help
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
|
||||
since := time.Date(2026, 7, 15, 1, 2, 3, 0, time.UTC)
|
||||
until := since.Add(7 * 24 * time.Hour)
|
||||
provider := &fakeAccountFreezeProvider{items: map[int64]domain.AccountFreeze{
|
||||
1001: {UserID: 1001, Frozen: true, Since: since, Until: until, AppealURL: "https://appeals.example.test/1001"},
|
||||
}}
|
||||
svc := NewService(nil, nil, WithAccountFreezeProvider(provider))
|
||||
|
||||
normal, notModified, err := svc.GetAppConfig(context.Background(), 1002, 0)
|
||||
if err != nil || notModified {
|
||||
t.Fatalf("normal GetAppConfig = %+v notModified=%v err=%v", normal, notModified, err)
|
||||
}
|
||||
frozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, normal.Hash)
|
||||
if err != nil || notModified || frozen.Hash == normal.Hash {
|
||||
t.Fatalf("frozen GetAppConfig = hash:%d normal:%d notModified=%v err=%v", frozen.Hash, normal.Hash, notModified, err)
|
||||
}
|
||||
assertFreezeConfig(t, frozen.JSON, since.Unix(), until.Unix(), "https://appeals.example.test/1001")
|
||||
|
||||
if _, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash); err != nil || !notModified {
|
||||
t.Fatalf("frozen hash replay = notModified:%v err:%v", notModified, err)
|
||||
}
|
||||
provider.items[1001] = domain.AccountFreeze{
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
Since: since,
|
||||
Until: until.Add(24 * time.Hour),
|
||||
AppealURL: "https://appeals.example.test/1001/review",
|
||||
}
|
||||
updated, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash)
|
||||
if err != nil || notModified || updated.Hash == frozen.Hash {
|
||||
t.Fatalf("updated freeze config = hash:%d old:%d notModified=%v err=%v", updated.Hash, frozen.Hash, notModified, err)
|
||||
}
|
||||
assertFreezeConfig(t, updated.JSON, since.Unix(), until.Add(24*time.Hour).Unix(), "https://appeals.example.test/1001/review")
|
||||
other, notModified, err := svc.GetAppConfig(context.Background(), 1002, frozen.Hash)
|
||||
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)
|
||||
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoFreezeConfig(t, unauthorized.JSON)
|
||||
|
||||
provider.items[1001] = domain.AccountFreeze{UserID: 1001}
|
||||
unfrozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, updated.Hash)
|
||||
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)
|
||||
}
|
||||
|
||||
func TestAuthenticatedAppConfigClearsPersistedFreezeWithoutProvider(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)
|
||||
}
|
||||
assertClearedFreezeConfig(t, authenticated.JSON)
|
||||
}
|
||||
|
||||
func TestAccountAppConfigStripsGlobalFreezeFields(t *testing.T) {
|
||||
svc := NewService(nil, nil)
|
||||
base := domain.AppConfig{Client: "tdesktop", Hash: 9, JSON: []byte(`{"quote_length_max":1024,"freeze_since_date":1,"freeze_until_date":2,"freeze_appeal_url":"https://wrong.example"}`)}
|
||||
cfg, err := svc.accountAppConfig(context.Background(), 0, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Hash == base.Hash {
|
||||
t.Fatal("stripped config reused base hash")
|
||||
}
|
||||
assertNoFreezeConfig(t, cfg.JSON)
|
||||
}
|
||||
|
||||
func assertFreezeConfig(t *testing.T, body []byte, since, until int64, appealURL string) {
|
||||
t.Helper()
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(body, &values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["freeze_since_date"] != float64(since) || values["freeze_until_date"] != float64(until) || values["freeze_appeal_url"] != appealURL {
|
||||
t.Fatalf("freeze config = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoFreezeConfig(t *testing.T, body []byte) {
|
||||
t.Helper()
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(body, &values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
|
||||
if _, exists := values[key]; exists {
|
||||
t.Fatalf("unexpected %s in config", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (f *fakeAccountFreezeProvider) AccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
freeze, found := f.items[userID]
|
||||
return freeze, found, nil
|
||||
}
|
||||
|
|
@ -11,14 +11,14 @@ import (
|
|||
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
|
||||
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
|
||||
func TestAppConfigPremiumKeys(t *testing.T) {
|
||||
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
|
||||
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
|
||||
if err != nil || notModified {
|
||||
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
|
||||
}
|
||||
if cfg.Hash != defaultAppConfigHash || cfg.Hash < 10 {
|
||||
t.Fatalf("hash = %d, want defaultAppConfigHash(≥10)", cfg.Hash)
|
||||
}
|
||||
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), defaultAppConfigHash-1)
|
||||
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, defaultAppConfigHash-1)
|
||||
if err != nil || oldNotModified || oldCfg.Hash != defaultAppConfigHash {
|
||||
t.Fatalf("GetAppConfig(old hash) = hash %d notModified %v err %v, want refreshed config", oldCfg.Hash, oldNotModified, err)
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
|
||||
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
|
||||
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
|
||||
if err != nil || notModified {
|
||||
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
|
||||
}
|
||||
|
|
@ -108,14 +108,14 @@ func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
|
|||
|
||||
func TestAppConfigUsesConfiguredMapboxTokenAndHash(t *testing.T) {
|
||||
svc := NewService(nil, nil, WithMapboxToken("pk.test-token"))
|
||||
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0)
|
||||
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0, 0)
|
||||
if err != nil || notModified {
|
||||
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
|
||||
}
|
||||
if cfg.Hash == defaultAppConfigHash {
|
||||
t.Fatalf("hash = %d, want token-specific hash", cfg.Hash)
|
||||
}
|
||||
if _, notModified, err := svc.GetAppConfig(context.Background(), cfg.Hash); err != nil || !notModified {
|
||||
if _, notModified, err := svc.GetAppConfig(context.Background(), 0, cfg.Hash); err != nil || !notModified {
|
||||
t.Fatalf("GetAppConfig(hash) = notModified %v err %v, want notModified", notModified, err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
|
|
|
|||
238
internal/app/langpack/cache.go
Normal file
238
internal/app/langpack/cache.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLangPackCacheMaxBytes = int64(128 << 20)
|
||||
defaultLangPackCacheMaxEntries = 256
|
||||
defaultLanguageListCacheMaxEntries = 32
|
||||
langPackStringValueHeaderBytes = int64(144)
|
||||
langPackFixedHeaderBytes = int64(80)
|
||||
)
|
||||
|
||||
type langPackCacheKind uint8
|
||||
|
||||
const (
|
||||
langPackCacheRaw langPackCacheKind = iota
|
||||
langPackCacheEffective
|
||||
)
|
||||
|
||||
type langPackCacheKey struct {
|
||||
pack string
|
||||
code string
|
||||
kind langPackCacheKind
|
||||
}
|
||||
|
||||
func (k langPackCacheKey) singleflightKey() string {
|
||||
return string(rune(k.kind)) + "\x00" + k.pack + "\x00" + k.code
|
||||
}
|
||||
|
||||
type langPackCache struct {
|
||||
mu sync.Mutex
|
||||
maxBytes int64
|
||||
maxEntries int
|
||||
usedBytes int64
|
||||
epoch uint64
|
||||
ll *list.List
|
||||
items map[langPackCacheKey]*list.Element
|
||||
}
|
||||
|
||||
type langPackCacheEntry struct {
|
||||
key langPackCacheKey
|
||||
pack domain.LangPack
|
||||
size int64
|
||||
}
|
||||
|
||||
func newLangPackCache(maxBytes int64, maxEntries int) *langPackCache {
|
||||
if maxBytes <= 0 || maxEntries <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &langPackCache{
|
||||
maxBytes: maxBytes,
|
||||
maxEntries: maxEntries,
|
||||
ll: list.New(),
|
||||
items: make(map[langPackCacheKey]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *langPackCache) get(key langPackCacheKey) (domain.LangPack, bool) {
|
||||
if c == nil {
|
||||
return domain.LangPack{}, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
element, ok := c.items[key]
|
||||
if !ok {
|
||||
return domain.LangPack{}, false
|
||||
}
|
||||
c.ll.MoveToFront(element)
|
||||
return cloneLangPack(element.Value.(*langPackCacheEntry).pack), true
|
||||
}
|
||||
|
||||
func (c *langPackCache) loadEpoch() uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
return epoch
|
||||
}
|
||||
|
||||
// putIfEpoch 返回 false 仅表示 load 期间发生过 flush,调用方必须重载。
|
||||
// 超大单项不进入缓存,但仍可安全返回给当前请求,因此返回 true。
|
||||
func (c *langPackCache) putIfEpoch(key langPackCacheKey, pack domain.LangPack, loadEpoch uint64) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
size := estimateLangPackBytes(pack)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.epoch != loadEpoch {
|
||||
return false
|
||||
}
|
||||
if existing, ok := c.items[key]; ok {
|
||||
c.remove(existing)
|
||||
}
|
||||
if size > c.maxBytes {
|
||||
return true
|
||||
}
|
||||
entry := &langPackCacheEntry{key: key, pack: cloneLangPack(pack), size: size}
|
||||
c.items[key] = c.ll.PushFront(entry)
|
||||
c.usedBytes += size
|
||||
for c.usedBytes > c.maxBytes || c.ll.Len() > c.maxEntries {
|
||||
oldest := c.ll.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
c.remove(oldest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *langPackCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
c.usedBytes = 0
|
||||
c.ll.Init()
|
||||
clear(c.items)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *langPackCache) remove(element *list.Element) {
|
||||
entry := element.Value.(*langPackCacheEntry)
|
||||
delete(c.items, entry.key)
|
||||
c.ll.Remove(element)
|
||||
c.usedBytes -= entry.size
|
||||
}
|
||||
|
||||
func estimateLangPackBytes(pack domain.LangPack) int64 {
|
||||
size := langPackFixedHeaderBytes + int64(len(pack.LangPack)+len(pack.LangCode))
|
||||
size += int64(len(pack.Strings)) * langPackStringValueHeaderBytes
|
||||
for _, item := range pack.Strings {
|
||||
size += int64(len(item.Key) + len(item.Value) + len(item.ZeroValue) + len(item.OneValue) +
|
||||
len(item.TwoValue) + len(item.FewValue) + len(item.ManyValue) + len(item.OtherValue))
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func cloneLangPack(pack domain.LangPack) domain.LangPack {
|
||||
pack.Strings = append([]domain.LangPackString(nil), pack.Strings...)
|
||||
return pack
|
||||
}
|
||||
|
||||
type languageListCache struct {
|
||||
mu sync.Mutex
|
||||
maxEntries int
|
||||
epoch uint64
|
||||
ll *list.List
|
||||
items map[string]*list.Element
|
||||
}
|
||||
|
||||
type languageListCacheEntry struct {
|
||||
pack string
|
||||
languages []domain.LangPackLanguage
|
||||
}
|
||||
|
||||
func newLanguageListCache(maxEntries int) *languageListCache {
|
||||
if maxEntries <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &languageListCache{
|
||||
maxEntries: maxEntries,
|
||||
ll: list.New(),
|
||||
items: make(map[string]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *languageListCache) get(pack string) ([]domain.LangPackLanguage, bool) {
|
||||
if c == nil {
|
||||
return nil, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
element, ok := c.items[pack]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
c.ll.MoveToFront(element)
|
||||
return cloneLanguages(element.Value.(*languageListCacheEntry).languages), true
|
||||
}
|
||||
|
||||
func (c *languageListCache) loadEpoch() uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
return epoch
|
||||
}
|
||||
|
||||
func (c *languageListCache) putIfEpoch(pack string, languages []domain.LangPackLanguage, loadEpoch uint64) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.epoch != loadEpoch {
|
||||
return false
|
||||
}
|
||||
if existing, ok := c.items[pack]; ok {
|
||||
c.ll.Remove(existing)
|
||||
delete(c.items, pack)
|
||||
}
|
||||
entry := &languageListCacheEntry{pack: pack, languages: cloneLanguages(languages)}
|
||||
c.items[pack] = c.ll.PushFront(entry)
|
||||
if c.ll.Len() > c.maxEntries {
|
||||
oldest := c.ll.Back()
|
||||
if oldest != nil {
|
||||
delete(c.items, oldest.Value.(*languageListCacheEntry).pack)
|
||||
c.ll.Remove(oldest)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *languageListCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
c.ll.Init()
|
||||
clear(c.items)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func cloneLanguages(languages []domain.LangPackLanguage) []domain.LangPackLanguage {
|
||||
return append([]domain.LangPackLanguage(nil), languages...)
|
||||
}
|
||||
35
internal/app/langpack/cache_test.go
Normal file
35
internal/app/langpack/cache_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestLangPackCachesRejectLoadsAcrossFlush(t *testing.T) {
|
||||
packCache := newLangPackCache(1<<20, 8)
|
||||
key := langPackCacheKey{pack: "tdesktop", code: "en", kind: langPackCacheRaw}
|
||||
packEpoch := packCache.loadEpoch()
|
||||
packCache.flush()
|
||||
if packCache.putIfEpoch(key, domain.LangPack{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: "en",
|
||||
Version: 1,
|
||||
Strings: []domain.LangPackString{{Key: "key", Value: "stale"}},
|
||||
}, packEpoch) {
|
||||
t.Fatal("pre-flush pack load was accepted")
|
||||
}
|
||||
if _, ok := packCache.get(key); ok {
|
||||
t.Fatal("pre-flush pack load became visible")
|
||||
}
|
||||
|
||||
languageCache := newLanguageListCache(8)
|
||||
languageEpoch := languageCache.loadEpoch()
|
||||
languageCache.flush()
|
||||
if languageCache.putIfEpoch("tdesktop", []domain.LangPackLanguage{{LangCode: "en"}}, languageEpoch) {
|
||||
t.Fatal("pre-flush language-list load was accepted")
|
||||
}
|
||||
if _, ok := languageCache.get("tdesktop"); ok {
|
||||
t.Fatal("pre-flush language-list load became visible")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package langpack
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
|
@ -12,6 +13,8 @@ import (
|
|||
)
|
||||
|
||||
var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`)
|
||||
var langPackNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
|
||||
var langCodeRE = regexp.MustCompile(`^[a-z0-9]{1,16}(?:-[a-z0-9]{1,16})*$`)
|
||||
|
||||
// ParseTDesktopFile 解析客户端 .strings 文件为 domain 语言包。
|
||||
func ParseTDesktopFile(path string) (domain.LangPack, error) {
|
||||
|
|
@ -82,6 +85,17 @@ func packFromFilename(path string) (domain.LangPack, error) {
|
|||
if langPack == "" || langCode == "" {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid langpack filename %q", filepath.Base(path))
|
||||
}
|
||||
langPack = normalizePack(langPack)
|
||||
langCode = normalizeCode(langCode)
|
||||
if !langPackNameRE.MatchString(langPack) {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid langpack name %q in %q", langPack, filepath.Base(path))
|
||||
}
|
||||
if len(langCode) > 64 || !langCodeRE.MatchString(langCode) {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid language code %q in %q", langCode, filepath.Base(path))
|
||||
}
|
||||
if version <= 0 || version > math.MaxInt32 {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid langpack version %d in %q", version, filepath.Base(path))
|
||||
}
|
||||
return domain.LangPack{
|
||||
LangPack: langPack,
|
||||
LangCode: langCode,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/store/memory"
|
||||
|
|
@ -59,6 +60,23 @@ func TestParseClientLangPackFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseClientLangPackFileWithUnderscorePackName(t *testing.T) {
|
||||
packDir := filepath.Join(t.TempDir(), "android_x")
|
||||
if err := os.MkdirAll(packDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir fixture: %v", err)
|
||||
}
|
||||
path := filepath.Join(packDir, "android_x_en_v42.strings")
|
||||
writeLangPackFixture(t, path, `"TranslationMoreText" = "Translation Platform";`)
|
||||
|
||||
pack, err := ParseTDesktopFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if pack.LangPack != "android_x" || pack.LangCode != "en" || pack.Version != 42 {
|
||||
t.Fatalf("pack meta = %+v", pack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDirectoryWalksClientSubdirs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, item := range []struct {
|
||||
|
|
@ -98,13 +116,21 @@ func TestSeedDirectoryWalksClientSubdirs(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBundledAndroidPersianLangPackParses(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "..", "data", "langpack", "android", "android_fa_v59634849.strings")
|
||||
pack, err := ParseTDesktopFile(path)
|
||||
root := filepath.Join("..", "..", "..", "data", "langpack", "android")
|
||||
candidates, _, err := scanSeedCandidates(root)
|
||||
if err != nil {
|
||||
t.Fatalf("scan bundled android packs: %v", err)
|
||||
}
|
||||
candidate, ok := candidates["android\x00fa"]
|
||||
if !ok {
|
||||
t.Fatal("bundled android fa pack not found")
|
||||
}
|
||||
pack, err := ParseTDesktopFile(candidate.path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse bundled android fa pack: %v", err)
|
||||
}
|
||||
if pack.LangPack != "android" || pack.LangCode != "fa" || pack.Version != 59634849 {
|
||||
t.Fatalf("pack meta = %+v, want android/fa v59634849", pack)
|
||||
if pack.LangPack != "android" || pack.LangCode != "fa" || pack.Version <= 0 {
|
||||
t.Fatalf("pack meta = %+v, want versioned android/fa", pack)
|
||||
}
|
||||
if len(pack.Strings) < 10000 {
|
||||
t.Fatalf("strings count = %d, want full android fa pack", len(pack.Strings))
|
||||
|
|
@ -120,3 +146,120 @@ func TestBundledAndroidPersianLangPackParses(t *testing.T) {
|
|||
}
|
||||
t.Fatalf("TranslateLanguageFA not found in bundled android fa pack")
|
||||
}
|
||||
|
||||
func TestSeedDirectoryReconcilesManifest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
packDir := filepath.Join(root, "tdesktop")
|
||||
if err := os.MkdirAll(packDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir pack dir: %v", err)
|
||||
}
|
||||
v1 := filepath.Join(packDir, "tdesktop_pt-BR_v1.strings")
|
||||
writeLangPackFixture(t, v1, `
|
||||
"lng_language_name" = "Português (Brasil)";
|
||||
"lng_old" = "old";
|
||||
`)
|
||||
|
||||
store := memory.NewLangPackStore()
|
||||
service := NewService(store)
|
||||
seeded, err := service.SeedDirectory(ctx, root)
|
||||
if err != nil || seeded != 2 {
|
||||
t.Fatalf("seed v1 = %d, %v", seeded, err)
|
||||
}
|
||||
pack, err := service.GetLangPack(ctx, "TDESKTOP", "pt_BR")
|
||||
if err != nil || pack.LangCode != "pt-br" || pack.Version != 1 || len(pack.Strings) != 2 {
|
||||
t.Fatalf("normalized pack = %+v, err %v", pack, err)
|
||||
}
|
||||
languages, err := service.ListLanguages(ctx, "tdesktop")
|
||||
if err != nil || findLanguage(languages, "pt-br") == nil {
|
||||
t.Fatalf("languages = %+v, err %v", languages, err)
|
||||
}
|
||||
if seeded, err := service.SeedDirectory(ctx, root); err != nil || seeded != 0 {
|
||||
t.Fatalf("unchanged seed = %d, %v", seeded, err)
|
||||
}
|
||||
|
||||
v2 := filepath.Join(packDir, "tdesktop_pt-br_v2.strings")
|
||||
writeLangPackFixture(t, v2, `
|
||||
"lng_language_name" = "Português do Brasil";
|
||||
"lng_new" = "new";
|
||||
`)
|
||||
seeded, err = service.SeedDirectory(ctx, root)
|
||||
if err != nil || seeded != 2 {
|
||||
t.Fatalf("seed v2 = %d, %v", seeded, err)
|
||||
}
|
||||
pack, err = service.GetLangPack(ctx, "tdesktop", "pt-br")
|
||||
if err != nil || pack.Version != 2 || len(pack.Strings) != 2 || stringValue(pack.Strings, "lng_old") != "" || stringValue(pack.Strings, "lng_new") != "new" {
|
||||
t.Fatalf("replaced pack = %+v, err %v", pack, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(v1); err != nil {
|
||||
t.Fatalf("remove v1: %v", err)
|
||||
}
|
||||
if err := os.Remove(v2); err != nil {
|
||||
t.Fatalf("remove v2: %v", err)
|
||||
}
|
||||
if err := os.Remove(packDir); err != nil {
|
||||
t.Fatalf("remove pack dir: %v", err)
|
||||
}
|
||||
if seeded, err := service.SeedDirectory(ctx, root); err != nil || seeded != 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDirectoryRejectsVersionInvariantViolations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
packDir := filepath.Join(root, "tdesktop")
|
||||
if err := os.MkdirAll(packDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir pack dir: %v", err)
|
||||
}
|
||||
v2 := filepath.Join(packDir, "tdesktop_fr_v2.strings")
|
||||
writeLangPackFixture(t, v2, `"lng_language_name" = "Français";`)
|
||||
service := NewService(memory.NewLangPackStore())
|
||||
if _, err := service.SeedDirectory(ctx, root); err != nil {
|
||||
t.Fatalf("seed v2: %v", err)
|
||||
}
|
||||
|
||||
writeLangPackFixture(t, v2, `"lng_language_name" = "Français modifié";`)
|
||||
if _, err := service.SeedDirectory(ctx, root); err == nil || !strings.Contains(err.Error(), "without version bump") {
|
||||
t.Fatalf("same-version mutation error = %v", err)
|
||||
}
|
||||
pack, err := service.GetLangPack(ctx, "tdesktop", "fr")
|
||||
if err != nil || stringValue(pack.Strings, "lng_language_name") != "Français" {
|
||||
t.Fatalf("pack changed after rejected mutation = %+v, err %v", pack, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(v2); err != nil {
|
||||
t.Fatalf("remove v2: %v", err)
|
||||
}
|
||||
writeLangPackFixture(t, filepath.Join(packDir, "tdesktop_fr_v1.strings"), `"lng_language_name" = "Français";`)
|
||||
if _, err := service.SeedDirectory(ctx, root); err == nil || !strings.Contains(err.Error(), "version rollback") {
|
||||
t.Fatalf("version rollback error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundledLangPackDirectoryReconciles(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "data", "langpack")
|
||||
service := NewService(memory.NewLangPackStore())
|
||||
seeded, err := service.SeedDirectory(context.Background(), root)
|
||||
if err != nil {
|
||||
t.Fatalf("seed bundled langpacks: %v", err)
|
||||
}
|
||||
if seeded < 50000 {
|
||||
t.Fatalf("seeded bundled strings = %d, want full catalog", seeded)
|
||||
}
|
||||
if seeded, err := service.SeedDirectory(context.Background(), root); err != nil || seeded != 0 {
|
||||
t.Fatalf("reconcile unchanged bundled langpacks = %d, %v", seeded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeLangPackFixture(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write langpack fixture %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,27 @@ package langpack
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SeedDirectory 将导出的 .strings 文件导入 LangPackStore。
|
||||
type seedCandidate struct {
|
||||
path string
|
||||
meta domain.LangPack
|
||||
}
|
||||
|
||||
// SeedDirectory 将导出的 .strings 文件清单原子对账到 LangPackStore。
|
||||
// root 可直接指向 data/langpack,也可指向包含 .strings 的具体平台目录。
|
||||
func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) {
|
||||
if s == nil || s.packs == nil || root == "" {
|
||||
|
|
@ -23,33 +36,201 @@ func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) {
|
|||
return 0, fmt.Errorf("stat langpack seed dir: %w", err)
|
||||
}
|
||||
|
||||
seeded := 0
|
||||
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
|
||||
candidates, scopes, err := scanSeedCandidates(dir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scan langpack seed dir: %w", err)
|
||||
}
|
||||
keys := make([]string, 0, len(candidates))
|
||||
for key := range candidates {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
seed := domain.LangPackSeed{
|
||||
Catalog: seedCatalogID(dir),
|
||||
Scopes: scopes,
|
||||
Packs: make([]domain.LangPackSeedEntry, 0, len(keys)),
|
||||
}
|
||||
previous, err := s.packs.GetSeedCatalog(ctx, seed.Catalog)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get previous langpack seed catalog: %w", err)
|
||||
}
|
||||
previousByKey := make(map[string]domain.LangPackSeedCatalogEntry, len(previous.Packs))
|
||||
for _, entry := range previous.Packs {
|
||||
previousByKey[entry.LangPack+"\x00"+entry.LangCode] = entry
|
||||
}
|
||||
for _, key := range keys {
|
||||
candidate := candidates[key]
|
||||
sourceHash, err := fileSHA256(candidate.path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("hash langpack source %q: %w", candidate.path, err)
|
||||
}
|
||||
if old, ok := previousByKey[key]; ok &&
|
||||
old.Version == candidate.meta.Version &&
|
||||
old.SourceHash == sourceHash &&
|
||||
old.ContentHash != "" && old.StringsCount > 0 {
|
||||
seed.Packs = append(seed.Packs, domain.LangPackSeedEntry{
|
||||
Pack: candidate.meta,
|
||||
SourceHash: sourceHash,
|
||||
ContentHash: old.ContentHash,
|
||||
StringsCount: old.StringsCount,
|
||||
ContentLoaded: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
pack, err := ParseTDesktopFile(candidate.path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pack, err = prepareSeedPack(pack)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prepare langpack %q: %w", candidate.path, err)
|
||||
}
|
||||
hash, err := langPackContentHash(pack)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("hash langpack %q: %w", candidate.path, err)
|
||||
}
|
||||
seed.Packs = append(seed.Packs, domain.LangPackSeedEntry{
|
||||
Pack: pack,
|
||||
SourceHash: sourceHash,
|
||||
ContentHash: hash,
|
||||
StringsCount: len(pack.Strings),
|
||||
ContentLoaded: true,
|
||||
})
|
||||
}
|
||||
|
||||
seeded, err := s.packs.ReconcileSeed(ctx, seed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reconcile langpack seed: %w", err)
|
||||
}
|
||||
s.flushCaches()
|
||||
return seeded, nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func scanSeedCandidates(root string) (map[string]seedCandidate, []string, error) {
|
||||
candidates := make(map[string]seedCandidate)
|
||||
scopeSet := make(map[string]struct{})
|
||||
hasChildDirs := false
|
||||
hasFiles := false
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if rel != "." && filepath.Dir(rel) == "." {
|
||||
hasChildDirs = true
|
||||
scope := normalizePack(entry.Name())
|
||||
if langPackNameRE.MatchString(scope) {
|
||||
scopeSet[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
pack, err := ParseTDesktopFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.Version >= pack.Version {
|
||||
if !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
|
||||
return nil
|
||||
}
|
||||
if err := s.packs.UpsertPack(ctx, pack); err != nil {
|
||||
hasFiles = true
|
||||
meta, err := packFromFilename(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seeded += len(pack.Strings)
|
||||
if relDir := filepath.Dir(rel); relDir != "." {
|
||||
firstDir := strings.Split(relDir, string(filepath.Separator))[0]
|
||||
scope := normalizePack(firstDir)
|
||||
if !langPackNameRE.MatchString(scope) || scope != meta.LangPack {
|
||||
return fmt.Errorf("langpack file %q is under pack directory %q", path, firstDir)
|
||||
}
|
||||
}
|
||||
scopeSet[meta.LangPack] = struct{}{}
|
||||
key := meta.LangPack + "\x00" + meta.LangCode
|
||||
if previous, ok := candidates[key]; ok {
|
||||
switch {
|
||||
case meta.Version < previous.meta.Version:
|
||||
return nil
|
||||
case meta.Version == previous.meta.Version:
|
||||
return fmt.Errorf("duplicate langpack version %s/%s v%d in %q and %q", meta.LangPack, meta.LangCode, meta.Version, previous.path, path)
|
||||
}
|
||||
}
|
||||
candidates[key] = seedCandidate{path: path, meta: meta}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return seeded, fmt.Errorf("walk langpack seed dir: %w", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
return seeded, nil
|
||||
if !hasChildDirs && !hasFiles {
|
||||
scope := normalizePack(filepath.Base(root))
|
||||
if langPackNameRE.MatchString(scope) {
|
||||
scopeSet[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
scopes := make([]string, 0, len(scopeSet))
|
||||
for scope := range scopeSet {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
sort.Strings(scopes)
|
||||
return candidates, scopes, nil
|
||||
}
|
||||
|
||||
func prepareSeedPack(pack domain.LangPack) (domain.LangPack, error) {
|
||||
pack.LangPack = normalizePack(pack.LangPack)
|
||||
pack.LangCode = normalizeCode(pack.LangCode)
|
||||
pack.FromVersion = 0
|
||||
if len(pack.Strings) == 0 {
|
||||
return domain.LangPack{}, errors.New("language file contains no strings")
|
||||
}
|
||||
deduplicated := make([]domain.LangPackString, 0, len(pack.Strings))
|
||||
indexes := make(map[string]int, len(pack.Strings))
|
||||
for _, item := range pack.Strings {
|
||||
if item.Key == "" || utf8.RuneCountInString(item.Key) > 128 {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid string key %q", item.Key)
|
||||
}
|
||||
if index, exists := indexes[item.Key]; exists {
|
||||
deduplicated[index] = item
|
||||
continue
|
||||
}
|
||||
indexes[item.Key] = len(deduplicated)
|
||||
deduplicated = append(deduplicated, item)
|
||||
}
|
||||
pack.Strings = deduplicated
|
||||
sort.Slice(pack.Strings, func(i, j int) bool {
|
||||
return pack.Strings[i].Key < pack.Strings[j].Key
|
||||
})
|
||||
return pack, nil
|
||||
}
|
||||
|
||||
func langPackContentHash(pack domain.LangPack) (string, error) {
|
||||
encoded, err := json.Marshal(pack)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func seedCatalogID(root string) string {
|
||||
base := normalizePack(filepath.Base(root))
|
||||
if langPackNameRE.MatchString(base) {
|
||||
return base
|
||||
}
|
||||
sum := sha256.Sum256([]byte(filepath.Clean(root)))
|
||||
return "path-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,38 @@ import (
|
|||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
"golang.org/x/text/unicode/bidi"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供客户端语言包查询。
|
||||
type Service struct {
|
||||
packs store.LangPackStore
|
||||
packs store.LangPackStore
|
||||
packCache *langPackCache
|
||||
languageCache *languageListCache
|
||||
packLoads singleflight.Group
|
||||
languageLoads singleflight.Group
|
||||
}
|
||||
|
||||
// NewService 创建 langpack 服务。
|
||||
func NewService(packs store.LangPackStore) *Service {
|
||||
return &Service{packs: packs}
|
||||
return newServiceWithCacheLimits(
|
||||
packs,
|
||||
defaultLangPackCacheMaxBytes,
|
||||
defaultLangPackCacheMaxEntries,
|
||||
defaultLanguageListCacheMaxEntries,
|
||||
)
|
||||
}
|
||||
|
||||
func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEntries, languageEntries int) *Service {
|
||||
return &Service{
|
||||
packs: packs,
|
||||
packCache: newLangPackCache(maxBytes, maxEntries),
|
||||
languageCache: newLanguageListCache(languageEntries),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLangPack 返回完整语言包。
|
||||
|
|
@ -30,11 +50,23 @@ 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
|
||||
}
|
||||
pack, err := s.packs.GetPack(ctx, packName, code, fromVersion)
|
||||
var (
|
||||
pack domain.LangPack
|
||||
err error
|
||||
)
|
||||
if fromVersion == 0 {
|
||||
pack, err = s.effectivePack(ctx, packName, code)
|
||||
} else {
|
||||
pack, err = s.rawPack(ctx, packName, code)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
return s.overlayWebAStrings(ctx, pack, packName, code, fromVersion)
|
||||
pack.FromVersion = fromVersion
|
||||
if pack.Version <= fromVersion {
|
||||
pack.Strings = nil
|
||||
}
|
||||
return pack, nil
|
||||
}
|
||||
|
||||
// GetStrings 返回指定 key 的语言包字符串。
|
||||
|
|
@ -44,29 +76,42 @@ 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
|
||||
}
|
||||
pack, err := s.packs.GetStrings(ctx, packName, code, keys)
|
||||
pack, err := s.effectivePack(ctx, packName, code)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return s.overlayWebAStrings(ctx, pack, packName, code, 0)
|
||||
}
|
||||
missing := missingLangPackKeys(keys, pack.Strings)
|
||||
if len(missing) == 0 || !shouldOverlayWebA(packName) {
|
||||
return pack, nil
|
||||
}
|
||||
overlay, err := s.packs.GetStrings(ctx, "weba", code, missing)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
wanted := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
wanted[key] = struct{}{}
|
||||
}
|
||||
return mergeMissingLangPackStrings(pack, overlay), nil
|
||||
selected := pack
|
||||
selected.Strings = make([]domain.LangPackString, 0, len(keys))
|
||||
for _, item := range pack.Strings {
|
||||
if _, ok := wanted[item.Key]; ok {
|
||||
selected.Strings = append(selected.Strings, item)
|
||||
}
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
// ListLanguages 返回已 seed 的语言包语言列表。
|
||||
func (s *Service) ListLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
|
||||
packName := normalizePack(langPack)
|
||||
if s == nil || s.packs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.cachedLanguages(ctx, packName)
|
||||
}
|
||||
|
||||
func normalizePack(langPack string) string {
|
||||
if langPack == "" {
|
||||
pack := strings.ToLower(strings.TrimSpace(langPack))
|
||||
if pack == "" {
|
||||
return "tdesktop"
|
||||
}
|
||||
return langPack
|
||||
return pack
|
||||
}
|
||||
|
||||
func normalizeCode(langCode string) string {
|
||||
|
|
@ -74,6 +119,7 @@ func normalizeCode(langCode string) string {
|
|||
if code == "" {
|
||||
return "en"
|
||||
}
|
||||
code = strings.ReplaceAll(code, "_", "-")
|
||||
return strings.TrimSuffix(code, "-raw")
|
||||
}
|
||||
|
||||
|
|
@ -86,15 +132,117 @@ func shouldOverlayWebA(langPack string) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Service) overlayWebAStrings(ctx context.Context, pack domain.LangPack, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
|
||||
if fromVersion != 0 || !shouldOverlayWebA(langPack) {
|
||||
return pack, nil
|
||||
func (s *Service) rawPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) {
|
||||
key := langPackCacheKey{pack: langPack, code: langCode, kind: langPackCacheRaw}
|
||||
return s.cachedPack(ctx, key, func() (domain.LangPack, error) {
|
||||
return s.packs.GetPack(ctx, langPack, langCode, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) effectivePack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) {
|
||||
if !shouldOverlayWebA(langPack) {
|
||||
return s.rawPack(ctx, langPack, langCode)
|
||||
}
|
||||
overlay, err := s.packs.GetPack(ctx, "weba", langCode, fromVersion)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
key := langPackCacheKey{pack: langPack, code: langCode, kind: langPackCacheEffective}
|
||||
return s.cachedPack(ctx, key, func() (domain.LangPack, error) {
|
||||
pack, err := s.rawPack(ctx, langPack, langCode)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
overlay, err := s.rawPack(ctx, "weba", langCode)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
return mergeMissingLangPackStrings(pack, overlay), nil
|
||||
})
|
||||
}
|
||||
|
||||
type cachedPackLoadResult struct {
|
||||
pack domain.LangPack
|
||||
stable bool
|
||||
}
|
||||
|
||||
func (s *Service) cachedPack(ctx context.Context, key langPackCacheKey, load func() (domain.LangPack, error)) (domain.LangPack, error) {
|
||||
if s.packCache == nil {
|
||||
return load()
|
||||
}
|
||||
return mergeMissingLangPackStrings(pack, overlay), nil
|
||||
for {
|
||||
if pack, ok := s.packCache.get(key); ok {
|
||||
return pack, nil
|
||||
}
|
||||
value, err, _ := s.packLoads.Do(key.singleflightKey(), func() (any, error) {
|
||||
if pack, ok := s.packCache.get(key); ok {
|
||||
return cachedPackLoadResult{pack: pack, stable: true}, nil
|
||||
}
|
||||
loadEpoch := s.packCache.loadEpoch()
|
||||
pack, err := load()
|
||||
if err != nil {
|
||||
return cachedPackLoadResult{}, err
|
||||
}
|
||||
return cachedPackLoadResult{
|
||||
pack: pack,
|
||||
stable: s.packCache.putIfEpoch(key, pack, loadEpoch),
|
||||
}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
result := value.(cachedPackLoadResult)
|
||||
if result.stable {
|
||||
return cloneLangPack(result.pack), nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type cachedLanguagesLoadResult struct {
|
||||
languages []domain.LangPackLanguage
|
||||
stable bool
|
||||
}
|
||||
|
||||
func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
|
||||
if languages, ok := s.languageCache.get(langPack); ok {
|
||||
return languages, nil
|
||||
}
|
||||
for {
|
||||
value, err, _ := s.languageLoads.Do(langPack, func() (any, error) {
|
||||
if languages, ok := s.languageCache.get(langPack); ok {
|
||||
return cachedLanguagesLoadResult{languages: languages, stable: true}, nil
|
||||
}
|
||||
loadEpoch := s.languageCache.loadEpoch()
|
||||
languages, err := s.packs.ListLanguages(ctx, langPack)
|
||||
if err != nil {
|
||||
return cachedLanguagesLoadResult{}, err
|
||||
}
|
||||
for i := range languages {
|
||||
languages[i] = completeLanguageMetadata(langPack, languages[i])
|
||||
}
|
||||
return cachedLanguagesLoadResult{
|
||||
languages: languages,
|
||||
stable: s.languageCache.putIfEpoch(langPack, languages, loadEpoch),
|
||||
}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := value.(cachedLanguagesLoadResult)
|
||||
if result.stable {
|
||||
return cloneLanguages(result.languages), nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) flushCaches() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.packCache.flush()
|
||||
s.languageCache.flush()
|
||||
}
|
||||
|
||||
func mergeMissingLangPackStrings(pack, overlay domain.LangPack) domain.LangPack {
|
||||
|
|
@ -121,6 +269,57 @@ func mergeMissingLangPackStrings(pack, overlay domain.LangPack) domain.LangPack
|
|||
return pack
|
||||
}
|
||||
|
||||
func completeLanguageMetadata(langPack string, lang domain.LangPackLanguage) domain.LangPackLanguage {
|
||||
if lang.LangPack == "" {
|
||||
lang.LangPack = langPack
|
||||
}
|
||||
lang.LangCode = normalizeCode(lang.LangCode)
|
||||
if lang.PluralCode == "" {
|
||||
lang.PluralCode = pluralCode(lang.LangCode)
|
||||
}
|
||||
if lang.NativeName == "" {
|
||||
lang.NativeName = lang.Name
|
||||
}
|
||||
if lang.Name == "" {
|
||||
lang.Name = lang.NativeName
|
||||
}
|
||||
if lang.Name == "" {
|
||||
lang.Name = lang.LangCode
|
||||
}
|
||||
if lang.NativeName == "" {
|
||||
lang.NativeName = lang.Name
|
||||
}
|
||||
if lang.StringsCount == 0 {
|
||||
lang.StringsCount = lang.TranslatedCount
|
||||
}
|
||||
if lang.TranslatedCount == 0 {
|
||||
lang.TranslatedCount = lang.StringsCount
|
||||
}
|
||||
lang.Official = true
|
||||
lang.Rtl = lang.Rtl || isRTLText(lang.NativeName)
|
||||
return lang
|
||||
}
|
||||
|
||||
func pluralCode(langCode string) string {
|
||||
if idx := strings.IndexAny(langCode, "-_"); idx > 0 {
|
||||
return langCode[:idx]
|
||||
}
|
||||
return langCode
|
||||
}
|
||||
|
||||
func isRTLText(value string) bool {
|
||||
for _, r := range value {
|
||||
properties, _ := bidi.LookupRune(r)
|
||||
switch properties.Class() {
|
||||
case bidi.R, bidi.AL:
|
||||
return true
|
||||
case bidi.L:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func missingLangPackKeys(keys []string, strings []domain.LangPackString) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ package langpack
|
|||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -71,6 +74,258 @@ func TestServiceNormalizesWebARawLangCode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestListLanguagesUsesSeededPacks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
packs := memory.NewLangPackStore()
|
||||
svc := NewService(packs)
|
||||
if err := packs.UpsertPack(ctx, domain.LangPack{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: "fr",
|
||||
Version: 7,
|
||||
Strings: []domain.LangPackString{
|
||||
{Key: "lng_language_name", Value: "Français"},
|
||||
{Key: "lng_test", Value: "Test"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed fr langpack: %v", err)
|
||||
}
|
||||
if err := packs.UpsertPack(ctx, domain.LangPack{
|
||||
LangPack: "android",
|
||||
LangCode: "fa",
|
||||
Version: 8,
|
||||
Strings: []domain.LangPackString{
|
||||
{Key: "LanguageName", Value: "انگلیسی"},
|
||||
{Key: "TranslateLanguageFA", Value: "فارسی"},
|
||||
{Key: "lng_test", Value: "Test"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed fa langpack: %v", err)
|
||||
}
|
||||
if err := packs.UpsertPack(ctx, domain.LangPack{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: "ckb",
|
||||
Version: 9,
|
||||
Strings: []domain.LangPackString{{Key: "lng_language_name", Value: "کوردی"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed ckb langpack: %v", err)
|
||||
}
|
||||
|
||||
tdesktop, err := svc.ListLanguages(ctx, "tdesktop")
|
||||
if err != nil {
|
||||
t.Fatalf("list tdesktop languages: %v", err)
|
||||
}
|
||||
fr := findLanguage(tdesktop, "fr")
|
||||
if fr == nil || fr.Name != "Français" || fr.NativeName != "Français" || fr.PluralCode != "fr" || fr.StringsCount != 2 {
|
||||
t.Fatalf("fr language = %+v", fr)
|
||||
}
|
||||
ckb := findLanguage(tdesktop, "ckb")
|
||||
if ckb == nil || !ckb.Rtl {
|
||||
t.Fatalf("ckb language = %+v, want file-derived rtl", ckb)
|
||||
}
|
||||
|
||||
android, err := svc.ListLanguages(ctx, "android")
|
||||
if err != nil {
|
||||
t.Fatalf("list android languages: %v", err)
|
||||
}
|
||||
fa := findLanguage(android, "fa")
|
||||
if fa == nil || fa.NativeName != "فارسی" || !fa.Rtl || fa.PluralCode != "fa" {
|
||||
t.Fatalf("fa language = %+v", fa)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCachesLanguageResourcesAfterFirstRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewLangPackStore()
|
||||
for _, pack := range []domain.LangPack{
|
||||
{
|
||||
LangPack: "android",
|
||||
LangCode: "en",
|
||||
Version: 7,
|
||||
Strings: []domain.LangPackString{
|
||||
{Key: "LogOutTitle", Value: "Log Out"},
|
||||
{Key: "NewMessageTitle", Value: "New Message"},
|
||||
},
|
||||
},
|
||||
{
|
||||
LangPack: "weba",
|
||||
LangCode: "en",
|
||||
Version: 12,
|
||||
Strings: []domain.LangPackString{
|
||||
{Key: "AccDescrPollVoteDown", Value: "Go to next unread poll vote"},
|
||||
{Key: "NewMessageTitle", Value: "New Message from WebA"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
if err := base.UpsertPack(ctx, pack); err != nil {
|
||||
t.Fatalf("seed %s: %v", pack.LangPack, err)
|
||||
}
|
||||
}
|
||||
counting := &countingLangPackStore{LangPackStore: base}
|
||||
svc := NewService(counting)
|
||||
|
||||
first, err := svc.GetLangPack(ctx, "android", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("first get langpack: %v", err)
|
||||
}
|
||||
first.Strings[0].Value = "caller mutation"
|
||||
second, err := svc.GetLangPack(ctx, "android", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("second get langpack: %v", err)
|
||||
}
|
||||
if got := stringValue(second.Strings, "LogOutTitle"); got != "Log Out" {
|
||||
t.Fatalf("cached pack was mutated through caller alias: %q", got)
|
||||
}
|
||||
selected, err := svc.GetStrings(ctx, "android", "en", []string{"AccDescrPollVoteDown"})
|
||||
if err != nil || stringValue(selected.Strings, "AccDescrPollVoteDown") == "" {
|
||||
t.Fatalf("cached get strings = %+v, %v", selected, err)
|
||||
}
|
||||
if _, err := svc.GetDifference(ctx, "android", "en", 1); err != nil {
|
||||
t.Fatalf("cached get difference: %v", err)
|
||||
}
|
||||
|
||||
languages, err := svc.ListLanguages(ctx, "android")
|
||||
if err != nil || len(languages) != 1 {
|
||||
t.Fatalf("first list languages = %+v, %v", languages, err)
|
||||
}
|
||||
languages[0].Name = "caller mutation"
|
||||
languages, err = svc.ListLanguages(ctx, "android")
|
||||
if err != nil || len(languages) != 1 || languages[0].Name == "caller mutation" {
|
||||
t.Fatalf("cached languages alias = %+v, %v", languages, err)
|
||||
}
|
||||
|
||||
getPack, getStrings, listLanguages := counting.counts()
|
||||
if getPack != 2 || getStrings != 0 || listLanguages != 1 {
|
||||
t.Fatalf("store calls = getPack:%d getStrings:%d list:%d, want 2/0/1", getPack, getStrings, listLanguages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCollapsesConcurrentLanguagePackLoads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewLangPackStore()
|
||||
if err := base.UpsertPack(ctx, domain.LangPack{
|
||||
LangPack: "weba",
|
||||
LangCode: "en",
|
||||
Version: 3,
|
||||
Strings: []domain.LangPackString{{Key: "NewMessageTitle", Value: "New Message"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed weba: %v", err)
|
||||
}
|
||||
counting := &countingLangPackStore{LangPackStore: base, delay: 10 * time.Millisecond}
|
||||
svc := NewService(counting)
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 32)
|
||||
var wg sync.WaitGroup
|
||||
for range 32 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := svc.GetLangPack(ctx, "weba", "en")
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent get langpack: %v", err)
|
||||
}
|
||||
}
|
||||
getPack, _, _ := counting.counts()
|
||||
if getPack != 1 {
|
||||
t.Fatalf("concurrent store getPack calls = %d, want 1", getPack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLanguagePackCacheIsBounded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewLangPackStore()
|
||||
for _, code := range []string{"en", "fr"} {
|
||||
if err := base.UpsertPack(ctx, domain.LangPack{
|
||||
LangPack: "weba",
|
||||
LangCode: code,
|
||||
Version: 1,
|
||||
Strings: []domain.LangPackString{{Key: "key", Value: code}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", code, err)
|
||||
}
|
||||
}
|
||||
counting := &countingLangPackStore{LangPackStore: base}
|
||||
svc := newServiceWithCacheLimits(counting, 1<<20, 1, 1)
|
||||
for _, code := range []string{"en", "fr", "en"} {
|
||||
if _, err := svc.GetLangPack(ctx, "weba", code); err != nil {
|
||||
t.Fatalf("get %s: %v", code, err)
|
||||
}
|
||||
}
|
||||
getPack, _, _ := counting.counts()
|
||||
if getPack != 3 {
|
||||
t.Fatalf("LRU store getPack calls = %d, want 3", getPack)
|
||||
}
|
||||
|
||||
oversized := &countingLangPackStore{LangPackStore: base}
|
||||
svc = newServiceWithCacheLimits(oversized, 1, 8, 1)
|
||||
if _, err := svc.GetLangPack(ctx, "weba", "en"); err != nil {
|
||||
t.Fatalf("first oversized get: %v", err)
|
||||
}
|
||||
if _, err := svc.GetLangPack(ctx, "weba", "en"); err != nil {
|
||||
t.Fatalf("second oversized get: %v", err)
|
||||
}
|
||||
getPack, _, _ = oversized.counts()
|
||||
if getPack != 2 {
|
||||
t.Fatalf("oversized store getPack calls = %d, want 2", getPack)
|
||||
}
|
||||
}
|
||||
|
||||
type countingLangPackStore struct {
|
||||
store.LangPackStore
|
||||
mu sync.Mutex
|
||||
delay time.Duration
|
||||
getPack int
|
||||
getStrings int
|
||||
listLanguages int
|
||||
}
|
||||
|
||||
func (s *countingLangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
|
||||
s.mu.Lock()
|
||||
s.getPack++
|
||||
delay := s.delay
|
||||
s.mu.Unlock()
|
||||
if delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
return s.LangPackStore.GetPack(ctx, langPack, langCode, fromVersion)
|
||||
}
|
||||
|
||||
func (s *countingLangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
|
||||
s.mu.Lock()
|
||||
s.getStrings++
|
||||
s.mu.Unlock()
|
||||
return s.LangPackStore.GetStrings(ctx, langPack, langCode, keys)
|
||||
}
|
||||
|
||||
func (s *countingLangPackStore) ListLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
|
||||
s.mu.Lock()
|
||||
s.listLanguages++
|
||||
s.mu.Unlock()
|
||||
return s.LangPackStore.ListLanguages(ctx, langPack)
|
||||
}
|
||||
|
||||
func (s *countingLangPackStore) counts() (getPack, getStrings, listLanguages int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.getPack, s.getStrings, s.listLanguages
|
||||
}
|
||||
|
||||
func findLanguage(languages []domain.LangPackLanguage, code string) *domain.LangPackLanguage {
|
||||
for i := range languages {
|
||||
if languages[i].LangCode == code {
|
||||
return &languages[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringValue(strings []domain.LangPackString, key string) string {
|
||||
for _, item := range strings {
|
||||
if item.Key == key {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,18 @@ type DispatchOutboxRetentionStore interface {
|
|||
DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error)
|
||||
}
|
||||
|
||||
// TempAuthKeyRetentionStore 回收过期的 PFS temp auth key 绑定。
|
||||
// TempAuthKeyRetentionStore 回收过期的 PFS temp auth key(含未绑定 key)。
|
||||
type TempAuthKeyRetentionStore interface {
|
||||
DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error)
|
||||
}
|
||||
|
||||
// AuthKeySessionLayerRetentionStore reclaims expired short-lived Layer
|
||||
// watermarks. Selector freshness, not retention timing, is the correctness
|
||||
// gate; this worker only bounds durable storage.
|
||||
type AuthKeySessionLayerRetentionStore interface {
|
||||
DeleteExpiredSessionLayers(ctx context.Context, limit int) (int, error)
|
||||
}
|
||||
|
||||
// OrphanAuthKeyRetentionStore 回收从未形成授权/temp binding 的旧握手 key。
|
||||
// protected 是当前连接注册表实际使用的 raw auth_key_id 快照。
|
||||
type OrphanAuthKeyRetentionStore interface {
|
||||
|
|
@ -64,9 +71,9 @@ type LoginCodeDeliveryRetentionStore interface {
|
|||
// getUpdates 读取(fromID 恒 > confirmed),宽限仅防御 offset 回拨调试;回收目标是清堆积。
|
||||
const botAPIConfirmedGrace = 15 * time.Minute
|
||||
|
||||
// tempAuthKeyExpiryGrace 是 temp key 过期后的回收宽限:ResolveAuthKey 对
|
||||
// 「已过期但 perm 已授权」的绑定是容忍的,立即删除会突然断掉这批宽限中的
|
||||
// 连接;回收目标是清堆积,晚一天无妨。
|
||||
// tempAuthKeyExpiryGrace 只是一段数据库物理回收宽限。MTProto edge 在 expires_at
|
||||
// 到点即停止入站 RPC、主动推送和重发,并断开连接;ResolveAuthKey 不容忍过期 key。
|
||||
// 晚一天删除用于吸收客户端轮换/诊断窗口,不会延长协议有效期。
|
||||
const tempAuthKeyExpiryGrace = 24 * time.Hour
|
||||
|
||||
const (
|
||||
|
|
@ -86,7 +93,8 @@ const (
|
|||
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
|
||||
type RetentionWorker struct {
|
||||
outbox DispatchOutboxRetentionStore
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
authKeySessionLayers AuthKeySessionLayerRetentionStore
|
||||
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
||||
userUpdates UserUpdateEventRetentionStore
|
||||
channelUpdates ChannelUpdateEventRetentionStore
|
||||
|
|
@ -176,6 +184,13 @@ func (w *RetentionWorker) WithLoginCodeDeliveryRetention(store LoginCodeDelivery
|
|||
return w
|
||||
}
|
||||
|
||||
// WithAuthKeySessionLayerRetention enables bounded seek cleanup for expired
|
||||
// per-session Layer evidence.
|
||||
func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionLayerRetentionStore) *RetentionWorker {
|
||||
w.authKeySessionLayers = 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 {
|
||||
|
|
@ -243,6 +258,14 @@ func (w *RetentionWorker) runOutboxPoisonOnce(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
|
||||
if w.authKeySessionLayers != nil {
|
||||
deleted, err := w.authKeySessionLayers.DeleteExpiredSessionLayers(ctx, w.batch)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收过期 auth-key session Layer 证据失败", zap.Error(err))
|
||||
} else if deleted > 0 {
|
||||
w.logger.Info("回收过期 auth-key session Layer 证据完成", zap.Int("deleted", deleted))
|
||||
}
|
||||
}
|
||||
if w.loginCodeDeliveries != nil {
|
||||
deleted, err := w.loginCodeDeliveries.DeleteExpiredLoginCodeDeliveries(ctx, time.Now(), w.batch)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
|
|||
req.SenderUserID = userID
|
||||
}
|
||||
if req.SenderUserID != userID {
|
||||
return domain.SendPrivateTextResult{}, domain.ErrUserSendRestricted
|
||||
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
|
||||
}
|
||||
if req.RandomID != 0 && !req.IdempotencyPreflighted {
|
||||
fingerprint, err := store.PrivateSendFingerprint(req)
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ func TestServiceSendPrivateTextHonorsSendPermissionGate(t *testing.T) {
|
|||
RecipientUserID: 1002,
|
||||
RandomID: 1,
|
||||
Message: "blocked",
|
||||
}); !errors.Is(err, domain.ErrUserSendRestricted) {
|
||||
t.Fatalf("SendPrivateText err=%v, want ErrUserSendRestricted", err)
|
||||
}); !errors.Is(err, domain.ErrUserFrozen) {
|
||||
t.Fatalf("SendPrivateText err=%v, want ErrUserFrozen", err)
|
||||
}
|
||||
if store.sends != 0 {
|
||||
t.Fatalf("store sends=%d, want 0", store.sends)
|
||||
|
|
@ -71,8 +71,8 @@ func TestServiceForwardPrivateMessagesHonorsSendPermissionGate(t *testing.T) {
|
|||
ToUserID: 1003,
|
||||
MessageIDs: []int{1},
|
||||
RandomIDs: []int64{2},
|
||||
}); !errors.Is(err, domain.ErrUserSendRestricted) {
|
||||
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserSendRestricted", err)
|
||||
}); !errors.Is(err, domain.ErrUserFrozen) {
|
||||
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserFrozen", err)
|
||||
}
|
||||
if store.forwards != 0 {
|
||||
t.Fatalf("store forwards=%d, want 0", store.forwards)
|
||||
|
|
@ -502,7 +502,7 @@ type projectionMessageStore struct {
|
|||
type denySendChecker struct{}
|
||||
|
||||
func (denySendChecker) CanSendMessages(context.Context, int64) error {
|
||||
return domain.ErrUserSendRestricted
|
||||
return domain.ErrUserFrozen
|
||||
}
|
||||
|
||||
type gateMessageStore struct {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
|
|||
190
internal/app/stargifts/animation.go
Normal file
190
internal/app/stargifts/animation.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PrepareAnimation normalizes a .tgs or plain Lottie JSON (.json/.lottie) into the
|
||||
// single canonical pair used by both the Telegram download path and admin preview.
|
||||
func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return prepareAnimation(fileName, data)
|
||||
}
|
||||
|
||||
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
fileName = strings.TrimSpace(filepath.Base(fileName))
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
format := domain.StarGiftAnimationLottie
|
||||
var rawJSON []byte
|
||||
if ext == ".tgs" || isGzip(data) {
|
||||
format = domain.StarGiftAnimationTGS
|
||||
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftTGSBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
var err error
|
||||
rawJSON, err = decompressSingleTGS(data)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
} else {
|
||||
if ext != ".json" && ext != ".lottie" {
|
||||
return domain.StarGiftAnimation{}, fmt.Errorf("%w: expected .tgs, .json or plain .lottie", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
rawJSON = data
|
||||
}
|
||||
|
||||
normalized, meta, err := normalizeAndValidateLottie(rawJSON)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
tgs, err := gzipLottie(normalized)
|
||||
if err != nil || int64(len(tgs)) > domain.MaxStarGiftTGSBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
sum := sha256.Sum256(tgs)
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: fileName,
|
||||
SourceFormat: format,
|
||||
JSON: normalized,
|
||||
TGS: tgs,
|
||||
SHA256: append([]byte(nil), sum[:]...),
|
||||
Width: meta.W,
|
||||
Height: meta.H,
|
||||
FrameRate: meta.FrameRate,
|
||||
InPoint: meta.InPoint,
|
||||
OutPoint: meta.OutPoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type lottieMetadata struct {
|
||||
Version string `json:"v"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
FrameRate float64 `json:"fr"`
|
||||
InPoint float64 `json:"ip"`
|
||||
OutPoint float64 `json:"op"`
|
||||
Layers []json.RawMessage `json:"layers"`
|
||||
Assets []json.RawMessage `json:"assets"`
|
||||
}
|
||||
|
||||
func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
|
||||
data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
|
||||
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
var root any
|
||||
if err := dec.Decode(&root); err != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
if _, ok := root.(map[string]any); !ok {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
if containsLottieExpression(root) {
|
||||
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
var meta lottieMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
frameSpan := meta.OutPoint - meta.InPoint
|
||||
if meta.Version == "" || meta.W != 512 || meta.H != 512 ||
|
||||
math.IsNaN(meta.FrameRate) || math.IsInf(meta.FrameRate, 0) || meta.FrameRate <= 0 || meta.FrameRate > domain.MaxStarGiftAnimationFrameRate ||
|
||||
math.IsNaN(meta.InPoint) || math.IsInf(meta.InPoint, 0) || meta.InPoint < 0 ||
|
||||
math.IsNaN(meta.OutPoint) || math.IsInf(meta.OutPoint, 0) || meta.OutPoint <= meta.InPoint ||
|
||||
frameSpan > meta.FrameRate*domain.MaxStarGiftAnimationSeconds || len(meta.Layers) == 0 {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
// Telegram animated stickers are self-contained. Reject remote or embedded image assets;
|
||||
// pre-composition assets with only an id/layers payload remain valid.
|
||||
for _, raw := range meta.Assets {
|
||||
var asset map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &asset) != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
for _, key := range []string{"p", "u"} {
|
||||
if value := asset[key]; len(value) > 0 && string(value) != `""` && string(value) != "null" {
|
||||
return nil, lottieMetadata{}, fmt.Errorf("%w: external assets are not allowed", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, data); err != nil || int64(compact.Len()) > domain.MaxStarGiftLottieBytes {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
return compact.Bytes(), meta, nil
|
||||
}
|
||||
|
||||
func containsLottieExpression(value any) bool {
|
||||
switch node := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range node {
|
||||
if key == "x" {
|
||||
if expression, ok := child.(string); ok && strings.TrimSpace(expression) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if containsLottieExpression(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range node {
|
||||
if containsLottieExpression(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGzip(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
|
||||
}
|
||||
|
||||
func decompressSingleTGS(data []byte) ([]byte, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
gz, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return nil, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
gz.Multistream(false)
|
||||
raw, readErr := io.ReadAll(io.LimitReader(gz, domain.MaxStarGiftLottieBytes+1))
|
||||
closeErr := gz.Close()
|
||||
if readErr != nil || closeErr != nil || int64(len(raw)) > domain.MaxStarGiftLottieBytes || reader.Len() != 0 {
|
||||
return nil, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func gzipLottie(data []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
gz, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gz.Header.ModTime = time.Unix(0, 0)
|
||||
gz.Header.OS = 255
|
||||
if _, err := gz.Write(data); err != nil {
|
||||
_ = gz.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
93
internal/app/stargifts/animation_test.go
Normal file
93
internal/app/stargifts/animation_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
const validGiftLottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4,"nm":"gift"}],"assets":[]}`
|
||||
|
||||
func TestPrepareAnimationNormalizesLottieAndTGS(t *testing.T) {
|
||||
fromJSON, err := prepareAnimation("gift.lottie", []byte(" \n"+validGiftLottie+"\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("prepare lottie: %v", err)
|
||||
}
|
||||
if fromJSON.SourceFormat != domain.StarGiftAnimationLottie || len(fromJSON.TGS) == 0 || fromJSON.Width != 512 || fromJSON.Height != 512 {
|
||||
t.Fatalf("prepared lottie = %+v", fromJSON)
|
||||
}
|
||||
fromTGS, err := prepareAnimation("gift.tgs", fromJSON.TGS)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare tgs: %v", err)
|
||||
}
|
||||
if fromTGS.SourceFormat != domain.StarGiftAnimationTGS || string(fromTGS.JSON) != string(fromJSON.JSON) || hex.EncodeToString(fromTGS.SHA256) != hex.EncodeToString(fromJSON.SHA256) {
|
||||
t.Fatalf("tgs round trip differs: json=%v hash=%x/%x", string(fromTGS.JSON) == string(fromJSON.JSON), fromTGS.SHA256, fromJSON.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAnimationRejectsExternalAssetAndExpression(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"external": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{}],"assets":[{"p":"https://example.test/x.png"}]}`,
|
||||
"expression": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{"ks":{"o":{"x":"time*10"}}}]}`,
|
||||
"wrong-size": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":256,"h":256,"layers":[{}]}`,
|
||||
"frame-rate": `{"v":"5.7","fr":121,"ip":0,"op":30,"w":512,"h":512,"layers":[{}]}`,
|
||||
"duration": `{"v":"5.7","fr":30,"ip":0,"op":901,"w":512,"h":512,"layers":[{}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := prepareAnimation("gift.json", []byte(raw)); !errors.Is(err, domain.ErrStarGiftFileInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftFileInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *testGiftBlob) Name() string { return "localfs" }
|
||||
func (b *testGiftBlob) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
b.data[key] = append([]byte(nil), data...)
|
||||
return key, nil
|
||||
}
|
||||
func (b *testGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
|
||||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStarGiftStore()
|
||||
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
|
||||
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "First", Animation: animation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
second, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
GiftID: first.Gift.ID, Stars: 80, ConvertStars: 40, Enabled: true, SortOrder: 1, Title: "Second", Animation: animation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
current, found, _ := svc.GiftByID(ctx, first.Gift.ID)
|
||||
if !found || current.RevisionID != second.Gift.RevisionID || current.Stars != 80 {
|
||||
t.Fatalf("current=%+v found=%v", current, found)
|
||||
}
|
||||
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
|
||||
if !found || historical.Stars != 50 || historical.Title != "First" {
|
||||
t.Fatalf("historical=%+v found=%v", historical, found)
|
||||
}
|
||||
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +1,413 @@
|
|||
// Package stargifts 实现 Star 礼物应用服务:礼物目录(从 seed 合成、懒加载缓存)+ peer 收到的
|
||||
// 礼物实例 CRUD。扣费/退款/服务消息投递由 rpc 层编排(复用 Stars 账本 + SendPrivateText),
|
||||
// 本层只管目录与持久化。
|
||||
// Package stargifts implements the durable Star Gift catalog and received-gift state.
|
||||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// CatalogProvider 合成礼物目录(app/files 实现)。
|
||||
type CatalogProvider interface {
|
||||
BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error)
|
||||
// BlobBackend is the content-addressed media boundary used by the catalog importer.
|
||||
type BlobBackend interface {
|
||||
Name() string
|
||||
Put(ctx context.Context, data []byte) (string, error)
|
||||
Get(ctx context.Context, objectKey string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Service 是 Star 礼物应用服务。
|
||||
type Service struct {
|
||||
store store.StarGiftStore
|
||||
catalog CatalogProvider
|
||||
store store.StarGiftStore
|
||||
upgrades store.StarGiftUpgradeStore
|
||||
blobs BlobBackend
|
||||
dc int
|
||||
|
||||
mu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
built bool
|
||||
gifts []domain.StarGift
|
||||
byID map[int64]domain.StarGift
|
||||
hash int
|
||||
}
|
||||
|
||||
// NewService 创建 Star 礼物服务。
|
||||
func NewService(st store.StarGiftStore, catalog CatalogProvider) *Service {
|
||||
return &Service{store: st, catalog: catalog}
|
||||
type Option func(*Service)
|
||||
|
||||
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
|
||||
return func(service *Service) { service.upgrades = upgrades }
|
||||
}
|
||||
|
||||
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
|
||||
service := &Service{store: st, blobs: blobs, dc: dc}
|
||||
for _, opt := range opts {
|
||||
opt(service)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// ensureCatalog 懒加载并缓存目录(静态数据,构建一次)。
|
||||
func (s *Service) ensureCatalog(ctx context.Context) error {
|
||||
s.mu.RLock()
|
||||
built := s.built
|
||||
s.mu.RUnlock()
|
||||
if built {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.built {
|
||||
return nil
|
||||
}
|
||||
gifts, err := s.catalog.BuildStarGiftCatalog(ctx)
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("star gift store is not configured")
|
||||
}
|
||||
gifts, err := s.store.Catalog(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gifts = gifts
|
||||
s.byID = make(map[int64]domain.StarGift, len(gifts))
|
||||
for _, g := range gifts {
|
||||
s.byID[g.ID] = g
|
||||
for _, gift := range gifts {
|
||||
s.byID[gift.ID] = gift
|
||||
}
|
||||
s.hash = domain.StarGiftCatalogHash(gifts)
|
||||
s.built = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Catalog 返回礼物目录。
|
||||
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.StarGift, len(s.gifts))
|
||||
copy(out, s.gifts)
|
||||
return out, nil
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]domain.StarGift(nil), s.gifts...), nil
|
||||
}
|
||||
|
||||
// CatalogHash 返回目录 hash(getStarGifts NotModified 判定)。
|
||||
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.hash, nil
|
||||
}
|
||||
|
||||
// GiftByID 返回目录中指定礼物,不存在返回 ok=false。
|
||||
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return domain.StarGift{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
g, ok := s.byID[id]
|
||||
return g, ok, nil
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
gift, ok := s.byID[id]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
return s.store.CatalogRevision(ctx, revisionID)
|
||||
}
|
||||
|
||||
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
|
||||
func (s *Service) InvalidateStarGiftCatalog() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.built = false
|
||||
s.gifts = nil
|
||||
s.byID = nil
|
||||
s.hash = 0
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
|
||||
|
||||
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
if s == nil || s.store == nil || s.blobs == nil {
|
||||
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
|
||||
}
|
||||
write.Title = strings.TrimSpace(write.Title)
|
||||
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
|
||||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
|
||||
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
|
||||
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err)
|
||||
}
|
||||
documentID, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
fileReference := make([]byte, 16)
|
||||
if _, err := rand.Read(fileReference); err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err)
|
||||
}
|
||||
write.Document = domain.Document{
|
||||
ID: documentID,
|
||||
AccessHash: accessHash,
|
||||
FileReference: fileReference,
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: "application/x-tgsticker",
|
||||
Size: int64(len(write.Animation.TGS)),
|
||||
DCID: s.dc,
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
||||
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
|
||||
},
|
||||
}
|
||||
write.Blob = domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", documentID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(write.Animation.TGS)),
|
||||
SHA256: append([]byte(nil), write.Animation.SHA256...),
|
||||
MimeType: "application/x-tgsticker",
|
||||
}
|
||||
entry, err := s.store.CreateCatalogRevision(ctx, write)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
s.InvalidateStarGiftCatalog()
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
|
||||
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
|
||||
return s.store.AnimationJSON(ctx, giftID)
|
||||
}
|
||||
|
||||
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
|
||||
}
|
||||
revision, err := s.store.PublishCollectibleRevision(ctx, write)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return revision, err
|
||||
}
|
||||
|
||||
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
|
||||
// atomically publishes the complete immutable attribute pool. Callers must pass animations
|
||||
// produced by PrepareAnimation; partial revisions are never exposed to clients.
|
||||
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if s == nil || s.store == nil || s.blobs == nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
|
||||
}
|
||||
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
|
||||
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
materialize := func(attributes []domain.StarGiftCollectibleAttribute) error {
|
||||
for i := range attributes {
|
||||
animation := attributes[i].Animation
|
||||
if animation == nil {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, animation.TGS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
|
||||
}
|
||||
documentID, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileReference := make([]byte, 16)
|
||||
if _, err := rand.Read(fileReference); err != nil {
|
||||
return fmt.Errorf("generate collectible file reference: %w", err)
|
||||
}
|
||||
attributes[i].Document = &domain.Document{
|
||||
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
|
||||
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
|
||||
Size: int64(len(animation.TGS)), DCID: s.dc,
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
||||
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
|
||||
},
|
||||
}
|
||||
attributes[i].Blob = &domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
|
||||
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := materialize(write.Models); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if err := materialize(write.Patterns); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
return s.PublishCollectibleRevision(ctx, write)
|
||||
}
|
||||
|
||||
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (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)
|
||||
if err != nil || !ok || !revision.Published {
|
||||
return domain.StarGiftUpgradePreview{}, false, err
|
||||
}
|
||||
return domain.StarGiftUpgradePreview{
|
||||
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
|
||||
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
|
||||
SlugPrefix: revision.SlugPrefix,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
if s == nil || s.store == nil || len(giftIDs) == 0 {
|
||||
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
|
||||
}
|
||||
return s.store.CollectibleAvailability(ctx, giftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
return s.store.UniqueBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
return s.store.UniqueByID(ctx, uniqueGiftID)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
|
||||
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
|
||||
return map[int64]domain.UniqueStarGift{}, nil
|
||||
}
|
||||
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.UpgradeStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
|
||||
return s.store.ListCollections(ctx, owner)
|
||||
}
|
||||
|
||||
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
|
||||
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
|
||||
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
|
||||
return s.store.DeleteCollection(ctx, owner, collectionID)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
|
||||
return s.store.ReorderCollections(ctx, owner, collectionIDs)
|
||||
}
|
||||
|
||||
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
return s.store.SetPinned(ctx, owner, savedGiftIDs)
|
||||
}
|
||||
|
||||
// RecordSavedGift 持久化一条收到的礼物实例,返回行 id。
|
||||
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
return s.store.Create(ctx, gift)
|
||||
}
|
||||
|
||||
// ListSaved 分页返回某 owner 收到的礼物。
|
||||
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
if len(offset) > domain.MaxStarGiftsOffsetBytes {
|
||||
offset = ""
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
|
||||
limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
return s.store.ListByOwner(ctx, owner, excludeUnsaved, offset, limit)
|
||||
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
|
||||
offset := filter.Offset
|
||||
if len(offset) > domain.MaxStarGiftsOffsetBytes {
|
||||
filter.Offset = ""
|
||||
}
|
||||
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
|
||||
filter.Limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
return s.store.ListByOwnerFiltered(ctx, filter)
|
||||
}
|
||||
|
||||
// GetSaved 按协议引用取礼物实例。
|
||||
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
return s.store.GetByRef(ctx, ref)
|
||||
}
|
||||
|
||||
// CountSaved 返回某 owner 展示在资料的礼物数(full.stargifts_count)。
|
||||
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
|
||||
return s.store.ResolveSavedIDs(ctx, owner, refs)
|
||||
}
|
||||
|
||||
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
|
||||
return s.store.CountByOwner(ctx, owner)
|
||||
}
|
||||
|
||||
// ToggleSaved 切换礼物在资料的展示(saveStarGift)。
|
||||
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
|
||||
return s.store.SetUnsaved(ctx, ref, unsaved)
|
||||
}
|
||||
|
||||
// Convert 把礼物标记为已转换(convertStarGift),返回该行供调用方据 ConvertStars 入账。
|
||||
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
return s.store.MarkConverted(ctx, ref)
|
||||
}
|
||||
|
||||
func randomPositiveInt64() (int64, error) {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, fmt.Errorf("generate star gift id: %w", err)
|
||||
}
|
||||
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,40 +9,28 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeCatalog struct {
|
||||
gifts []domain.StarGift
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) {
|
||||
f.calls++
|
||||
return f.gifts, nil
|
||||
}
|
||||
|
||||
func newTestService(gifts []domain.StarGift) (*Service, *fakeCatalog) {
|
||||
cat := &fakeCatalog{gifts: gifts}
|
||||
return NewService(memory.NewStarGiftStore(), cat), cat
|
||||
func newTestService(gifts []domain.StarGift) (*Service, *memory.StarGiftStore) {
|
||||
st := memory.NewStarGiftStore()
|
||||
st.SeedCatalog(gifts)
|
||||
return NewService(st, nil, 2), st
|
||||
}
|
||||
|
||||
func TestCatalogCachedAndHash(t *testing.T) {
|
||||
gifts := []domain.StarGift{
|
||||
{ID: 1, Stars: 15, ConvertStars: 15, Title: "Heart"},
|
||||
{ID: 2, Stars: 50, ConvertStars: 50, Title: "Cake"},
|
||||
{ID: 1, RevisionID: 11, Stars: 15, ConvertStars: 15, Title: "Heart"},
|
||||
{ID: 2, RevisionID: 12, Stars: 50, ConvertStars: 50, Title: "Cake"},
|
||||
}
|
||||
svc, cat := newTestService(gifts)
|
||||
svc, _ := newTestService(gifts)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := svc.Catalog(ctx)
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
|
||||
}
|
||||
// 再取一次不重新构建(缓存)。
|
||||
// 再取一次命中进程内目录缓存。
|
||||
if _, err := svc.Catalog(ctx); err != nil {
|
||||
t.Fatalf("catalog#2: %v", err)
|
||||
}
|
||||
if cat.calls != 1 {
|
||||
t.Fatalf("BuildStarGiftCatalog called %d times, want 1 (cached)", cat.calls)
|
||||
}
|
||||
hash, err := svc.CatalogHash(ctx)
|
||||
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
|
||||
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
|
||||
|
|
@ -61,11 +49,15 @@ func TestSavedGiftLifecycle(t *testing.T) {
|
|||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
|
||||
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 50, Date: 1700000000, ConvertStars: 15,
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 50, Date: 1700000000, ConvertStars: 15,
|
||||
})
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("RecordSavedGift = %d err %v", id, err)
|
||||
}
|
||||
collection, err := svc.CreateCollection(ctx, owner, "Inbox", []int64{id})
|
||||
if err != nil || len(collection.GiftIDs) != 1 {
|
||||
t.Fatalf("CreateCollection = %+v err %v", collection, err)
|
||||
}
|
||||
|
||||
page, err := svc.ListSaved(ctx, owner, false, "", 100)
|
||||
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
|
||||
|
|
@ -99,6 +91,11 @@ func TestSavedGiftLifecycle(t *testing.T) {
|
|||
if len(after.Gifts) != 0 {
|
||||
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
|
||||
}
|
||||
collections, err := svc.ListCollections(ctx, owner)
|
||||
if err != nil || len(collections) != 1 || len(collections[0].GiftIDs) != 0 ||
|
||||
collections[0].Hash != domain.StarGiftCollectionHash("Inbox", nil) {
|
||||
t.Fatalf("collection after convert = %+v err %v, want empty membership and refreshed hash", collections, err)
|
||||
}
|
||||
// 重复转换被拒。
|
||||
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
|
||||
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
|
||||
|
|
@ -111,7 +108,7 @@ func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
|
|||
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
|
||||
|
||||
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 1001, GiftID: 1, MsgID: 0, SavedID: 0,
|
||||
Owner: owner, FromUserID: 1001, GiftID: 1, RevisionID: 11, MsgID: 0, SavedID: 0,
|
||||
Date: 1700000000, ConvertStars: 15,
|
||||
})
|
||||
if err != nil || savedID == 0 {
|
||||
|
|
@ -133,7 +130,7 @@ func TestSavedGiftPagination(t *testing.T) {
|
|||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
|
||||
}); err != nil {
|
||||
t.Fatalf("record#%d: %v", i, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,14 @@ func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetState after difference: %v", err)
|
||||
}
|
||||
if st.Pts != 2 {
|
||||
t.Fatalf("GetState after difference pts=%d, want confirmed pts=2", st.Pts)
|
||||
if st.Pts != 1 {
|
||||
t.Fatalf("GetState before delivery commit pts=%d, want confirmed pts=1", st.Pts)
|
||||
}
|
||||
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, diff.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
|
||||
t.Fatalf("CommitDeliveredState: %v", err)
|
||||
}
|
||||
st, err = svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil || st.Pts != 2 {
|
||||
t.Fatalf("GetState after delivery commit = %+v err=%v, want pts=2", st, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,27 +135,34 @@ func (s *Service) ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID in
|
|||
return s.saveConfirmedState(ctx, authKeyID, userID, domain.UpdateState{Pts: event.Pts, Date: date, Seq: 0})
|
||||
}
|
||||
|
||||
// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。
|
||||
//
|
||||
// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的
|
||||
// 离线数据由 getDialogs 快照承载(TDesktop 不持久化 pts,每次启动都走此路径)。
|
||||
// 若改为返回设备旧确认水位,客户端会在 getDialogs 最新快照之上再重放历史差分,
|
||||
// 造成未读重复累计、dialog 预览被旧消息抢占。持久化 pts 的客户端(Android)
|
||||
// 启动时直接带本地 pts 调 getDifference,不经过 getState,不受影响。
|
||||
func (s *Service) AcknowledgeCurrentState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
|
||||
st, err := s.currentState(ctx, userID)
|
||||
// ObserveDifferenceRequest records only the cursor a client carried into this
|
||||
// request. It is deliberately independent from response delivery: even when
|
||||
// encoding or the socket write later fails, the request still proves the client
|
||||
// already owned this (clamped) cursor before contacting us.
|
||||
func (s *Service) ObserveDifferenceRequest(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateState, error) {
|
||||
current, err := s.currentState(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
|
||||
from = clampDifferenceState(from, current)
|
||||
if err := s.observeClientState(ctx, authKeyID, userID, from); err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
// getState 明确建立“从当前快照开始同步”的 baseline;即使响应丢失,客户端也会
|
||||
// 重试 getState/重新拉 snapshot,而不会依赖 baseline 之前的 durable event。
|
||||
if err := s.observeClientState(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
return from, nil
|
||||
}
|
||||
|
||||
// CommitDeliveredState persists the exact cursor justified by a physically
|
||||
// delivered RPC result. The store owns the atomic/monotonic invariant because
|
||||
// delivery callbacks from different responses may complete out of order.
|
||||
func (s *Service) CommitDeliveredState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState, mode domain.UpdateStateCommitMode) error {
|
||||
if s.states == nil {
|
||||
return nil
|
||||
}
|
||||
return st, nil
|
||||
if mode != domain.UpdateStateCommitDeliveredOnly && mode != domain.UpdateStateCommitDeliveredAndObservedBaseline {
|
||||
return fmt.Errorf("invalid delivered update state commit mode %d", mode)
|
||||
}
|
||||
st.Seq = 0
|
||||
return s.states.CommitDeliveredState(ctx, authKeyID, userID, st, mode)
|
||||
}
|
||||
|
||||
// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。
|
||||
|
|
@ -171,19 +178,9 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
|
|||
if err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
// 只把客户端在本次请求中实际带回的 cursor 记为 observed。绝不能把本次将要
|
||||
// 返回的 State 当确认:响应可能在 socket/进程故障中丢失。恶意/损坏客户端带来的
|
||||
// 超前 pts 钳到账号当前连续水位,避免把 retention 安全边界推过 durable truth。
|
||||
observed := from
|
||||
if observed.Pts < 0 {
|
||||
observed.Pts = 0
|
||||
}
|
||||
if observed.Pts > st.Pts {
|
||||
observed.Pts = st.Pts
|
||||
}
|
||||
if err := s.observeClientState(ctx, authKeyID, userID, observed); err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
// Computation is pure with respect to device confirmed/observed state. The
|
||||
// request observer and physical-delivery commit are explicit caller phases.
|
||||
from = clampDifferenceState(from, st)
|
||||
// TDesktop 不支持账号级 updates.differenceTooLong。retention 只能删除所有授权
|
||||
// 设备都已确认的共同前缀;当前设备若仍带更旧 pts,用一个空的普通
|
||||
// differenceSlice 把 IntermediateState 推进到已确认 checkpoint,再从 live tail 续拉。
|
||||
|
|
@ -196,9 +193,6 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
|
|||
if from.Date != 0 {
|
||||
st.Date = from.Date
|
||||
}
|
||||
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
return domain.UpdateDifference{State: st}, nil
|
||||
}
|
||||
events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit)
|
||||
|
|
@ -246,9 +240,6 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
|
|||
if len(contiguous) > 0 {
|
||||
out.Date = contiguous[len(contiguous)-1].Date
|
||||
}
|
||||
if err := s.saveConfirmedState(ctx, authKeyID, userID, out); err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
return domain.UpdateDifference{
|
||||
State: out,
|
||||
Events: contiguous,
|
||||
|
|
@ -276,12 +267,20 @@ func (s *Service) retainedPrefixCheckpoint(ctx context.Context, authKeyID [8]byt
|
|||
} else if checkpoint.Date == 0 {
|
||||
checkpoint.Date = current.Date
|
||||
}
|
||||
if err := s.saveConfirmedState(ctx, authKeyID, userID, checkpoint); err != nil {
|
||||
return domain.UpdateDifference{}, false, err
|
||||
}
|
||||
return domain.UpdateDifference{State: checkpoint, Partial: true}, true, nil
|
||||
}
|
||||
|
||||
func clampDifferenceState(from, current domain.UpdateState) domain.UpdateState {
|
||||
if from.Pts < 0 {
|
||||
from.Pts = 0
|
||||
}
|
||||
if from.Pts > current.Pts {
|
||||
from.Pts = current.Pts
|
||||
}
|
||||
from.Seq = 0
|
||||
return from
|
||||
}
|
||||
|
||||
func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -490,11 +490,10 @@ func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestAcknowledgeCurrentStateAdvancesConfirmedWatermark 验证 updates.getState
|
||||
// 的语义:返回账号当前最新连续 pts(而非设备旧确认水位),并把确认水位推进
|
||||
// 到此——TDesktop 不持久化 pts,启动靠 getState+getDialogs 快照对齐,返回旧
|
||||
// 水位会诱导其重放快照前差分(未读重复累计、dialog 预览被旧消息抢占)。
|
||||
func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
|
||||
// TestCurrentStateCommitsAuditedBaselineOnlyAfterDelivery verifies that
|
||||
// computing a getState result is side-effect free and that its physically
|
||||
// delivered baseline advances confirmed+observed atomically.
|
||||
func TestCurrentStateCommitsAuditedBaselineOnlyAfterDelivery(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 11
|
||||
|
|
@ -509,8 +508,8 @@ func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
|
|||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
// 设备确认水位停在 pts=1 后账号又推进两格。
|
||||
if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 1}); err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil {
|
||||
t.Fatalf("seed confirmed state: %v", err)
|
||||
}
|
||||
for pts := 2; pts <= 3; pts++ {
|
||||
if err := events.Append(ctx, userID, domain.UpdateEvent{
|
||||
|
|
@ -521,23 +520,33 @@ func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
st, err := svc.AcknowledgeCurrentState(ctx, authKeyID, userID)
|
||||
st, err := svc.CurrentState(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("AcknowledgeCurrentState: %v", err)
|
||||
t.Fatalf("CurrentState: %v", err)
|
||||
}
|
||||
if st.Pts != 3 {
|
||||
t.Fatalf("acknowledged state pts = %d, want account current 3", st.Pts)
|
||||
t.Fatalf("current state pts = %d, want account current 3", st.Pts)
|
||||
}
|
||||
confirmed, err := svc.GetState(ctx, authKeyID, userID)
|
||||
confirmed, _, err := svc.ConfirmedState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState after acknowledge: %v", err)
|
||||
t.Fatalf("ConfirmedState before delivery: %v", err)
|
||||
}
|
||||
if confirmed.Pts != 3 {
|
||||
t.Fatalf("confirmed watermark = %d, want advanced to 3", confirmed.Pts)
|
||||
if confirmed.Pts != 1 {
|
||||
t.Fatalf("confirmed before delivery = %d, want 1", confirmed.Pts)
|
||||
}
|
||||
if _, ok := states.ObservedClientState(authKeyID, userID); ok {
|
||||
t.Fatal("computed getState unexpectedly advanced observed")
|
||||
}
|
||||
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, st, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
|
||||
t.Fatalf("CommitDeliveredState: %v", err)
|
||||
}
|
||||
confirmed, _, err = svc.ConfirmedState(ctx, authKeyID, userID)
|
||||
if err != nil || confirmed.Pts != 3 {
|
||||
t.Fatalf("confirmed after delivery = %+v err=%v, want pts=3", confirmed, err)
|
||||
}
|
||||
observed, ok := states.ObservedClientState(authKeyID, userID)
|
||||
if !ok || observed.Pts != 3 {
|
||||
t.Fatalf("getState observed watermark = %+v/%v, want pts=3", observed, ok)
|
||||
t.Fatalf("observed after delivered baseline = %+v/%v, want pts=3", observed, ok)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -559,7 +568,11 @@ func TestGetDifferenceRetainsOnlyClientObservedInputCursor(t *testing.T) {
|
|||
|
||||
// 服务端把 pts=1..2 放进 response,并不证明客户端收到了 response;observed 只能
|
||||
// 保持在本次 request 实际携带的 pts=0。
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 0, Date: 1700000100})
|
||||
from, err := svc.ObserveDifferenceRequest(ctx, authKeyID, userID, domain.UpdateState{Pts: 0, Date: 1700000100})
|
||||
if err != nil {
|
||||
t.Fatalf("observe first request: %v", err)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, from)
|
||||
if err != nil {
|
||||
t.Fatalf("first difference: %v", err)
|
||||
}
|
||||
|
|
@ -570,10 +583,23 @@ func TestGetDifferenceRetainsOnlyClientObservedInputCursor(t *testing.T) {
|
|||
if !ok || observed.Pts != 0 {
|
||||
t.Fatalf("observed after merely sending response = %+v/%v, want pts=0", observed, ok)
|
||||
}
|
||||
if _, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || found {
|
||||
t.Fatalf("computed response advanced confirmed: found=%v err=%v", found, err)
|
||||
}
|
||||
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, diff.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
|
||||
t.Fatalf("commit delivered difference: %v", err)
|
||||
}
|
||||
if confirmed, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || !found || confirmed.Pts != 2 {
|
||||
t.Fatalf("confirmed after delivery = %+v/%v err=%v, want pts=2", confirmed, found, err)
|
||||
}
|
||||
observed, _ = states.ObservedClientState(authKeyID, userID)
|
||||
if observed.Pts != 0 {
|
||||
t.Fatalf("delivered difference advanced observed to %d, want 0", observed.Pts)
|
||||
}
|
||||
|
||||
// 客户端下一次明确带回 pts=2 后,才允许 retention 把共同安全水位推进到 2。
|
||||
if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 1700000102}); err != nil {
|
||||
t.Fatalf("confirming difference: %v", err)
|
||||
if _, err := svc.ObserveDifferenceRequest(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 1700000102}); err != nil {
|
||||
t.Fatalf("observing next request: %v", err)
|
||||
}
|
||||
observed, ok = states.ObservedClientState(authKeyID, userID)
|
||||
if !ok || observed.Pts != 2 {
|
||||
|
|
@ -624,6 +650,15 @@ func TestGetDifferenceBelowRetainedFloorUsesEmptySliceCheckpoint(t *testing.T) {
|
|||
if !checkpoint.Partial || len(checkpoint.Events) != 0 || checkpoint.State.Pts != 2 || checkpoint.State.Date != 1700000202 {
|
||||
t.Fatalf("checkpoint difference = %+v, want empty differenceSlice at pts/date 2/1700000202", checkpoint)
|
||||
}
|
||||
if _, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || found {
|
||||
t.Fatalf("computed checkpoint advanced confirmed: found=%v err=%v", found, err)
|
||||
}
|
||||
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, checkpoint.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
|
||||
t.Fatalf("commit delivered checkpoint: %v", err)
|
||||
}
|
||||
if confirmed, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || !found || confirmed.Pts != 2 {
|
||||
t.Fatalf("confirmed checkpoint = %+v/%v err=%v, want pts=2", confirmed, found, err)
|
||||
}
|
||||
|
||||
tail, err := svc.GetDifference(ctx, authKeyID, userID, checkpoint.State)
|
||||
if err != nil {
|
||||
|
|
@ -722,6 +757,10 @@ func (s *captureStateStore) Save(_ context.Context, authKeyID [8]byte, userID in
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *captureStateStore) CommitDeliveredState(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState, _ domain.UpdateStateCommitMode) error {
|
||||
return s.Save(ctx, authKeyID, userID, state)
|
||||
}
|
||||
|
||||
func (s *captureStateStore) ObserveClientState(_ context.Context, _ [8]byte, _ int64, _ domain.UpdateState) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue