Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
2
internal/app/account/doc.go
Normal file
2
internal/app/account/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package account 是账号安全与设置应用服务。
|
||||
package account
|
||||
158
internal/app/account/service.go
Normal file
158
internal/app/account/service.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand")
|
||||
|
||||
// Service 提供账号安全配置查询。
|
||||
type Service struct {
|
||||
passwords store.PasswordStore
|
||||
reactions store.AccountReactionSettingsStore
|
||||
}
|
||||
|
||||
// ServiceOption 调整 account 服务依赖。
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithReactionSettings 注入账号级 reaction 设置持久化。
|
||||
func WithReactionSettings(reactions store.AccountReactionSettingsStore) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.reactions = reactions
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{passwords: passwords}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetPassword 返回当前账号 2FA 配置。未登录或无记录时返回持久化策略的默认 no-password 配置。
|
||||
func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) {
|
||||
if s == nil || s.passwords == nil || userID == 0 {
|
||||
return defaultPasswordSettings(), nil
|
||||
}
|
||||
settings, found, err := s.passwords.GetByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.PasswordSettings{}, err
|
||||
}
|
||||
if !found {
|
||||
return defaultPasswordSettings(), nil
|
||||
}
|
||||
if len(settings.SecureRandom) == 0 {
|
||||
settings.SecureRandom = append([]byte(nil), defaultSecureRandom...)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func defaultPasswordSettings() domain.PasswordSettings {
|
||||
return domain.PasswordSettings{SecureRandom: append([]byte(nil), defaultSecureRandom...)}
|
||||
}
|
||||
|
||||
// GetReactionSettings returns account-level reaction preferences.
|
||||
func (s *Service) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) {
|
||||
if s == nil || s.reactions == nil || userID == 0 {
|
||||
return domain.DefaultAccountReactionSettings(), nil
|
||||
}
|
||||
settings, found, err := s.reactions.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.DefaultAccountReactionSettings(), nil
|
||||
}
|
||||
return normalizeReactionSettings(settings), nil
|
||||
}
|
||||
|
||||
// SetReactionsNotifySettings stores reaction notification preferences.
|
||||
func (s *Service) SetReactionsNotifySettings(ctx context.Context, userID int64, notify domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) {
|
||||
settings, err := s.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
settings.Notify = normalizeNotifySettings(notify)
|
||||
return s.saveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
// SetDefaultReaction stores the account default quick reaction.
|
||||
func (s *Service) SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) {
|
||||
settings, err := s.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
if reaction.Type == "" || reaction.Emoticon == "" {
|
||||
reaction = domain.DefaultAccountReactionSettings().DefaultReaction
|
||||
}
|
||||
settings.DefaultReaction = reaction
|
||||
return s.saveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
// SetPaidReactionPrivacy stores the account default paid reaction privacy.
|
||||
func (s *Service) SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error) {
|
||||
settings, err := s.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
settings.PaidPrivacy = normalizePaidPrivacy(privacy)
|
||||
return s.saveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
func (s *Service) saveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) (domain.AccountReactionSettings, error) {
|
||||
settings = normalizeReactionSettings(settings)
|
||||
if s == nil || s.reactions == nil || userID == 0 {
|
||||
return settings, nil
|
||||
}
|
||||
return settings, s.reactions.SaveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings {
|
||||
defaults := domain.DefaultAccountReactionSettings()
|
||||
settings.Notify = normalizeNotifySettings(settings.Notify)
|
||||
if settings.DefaultReaction.Type == "" || settings.DefaultReaction.Emoticon == "" {
|
||||
settings.DefaultReaction = defaults.DefaultReaction
|
||||
}
|
||||
settings.PaidPrivacy = normalizePaidPrivacy(settings.PaidPrivacy)
|
||||
return settings
|
||||
}
|
||||
|
||||
func normalizeNotifySettings(settings domain.ReactionsNotifySettings) domain.ReactionsNotifySettings {
|
||||
if !validNotifyFrom(settings.MessagesFrom) {
|
||||
settings.MessagesFrom = domain.ReactionNotifyFromContacts
|
||||
}
|
||||
if !validNotifyFrom(settings.StoriesFrom) {
|
||||
settings.StoriesFrom = domain.ReactionNotifyFromContacts
|
||||
}
|
||||
if !validNotifyFrom(settings.PollVotesFrom) {
|
||||
settings.PollVotesFrom = domain.ReactionNotifyFromContacts
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func validNotifyFrom(value domain.ReactionNotifyFrom) bool {
|
||||
switch value {
|
||||
case domain.ReactionNotifyFromNone, domain.ReactionNotifyFromContacts, domain.ReactionNotifyFromAll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePaidPrivacy(privacy domain.PaidReactionPrivacy) domain.PaidReactionPrivacy {
|
||||
switch privacy.Kind {
|
||||
case domain.PaidReactionPrivacyAnonymous:
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}
|
||||
case domain.PaidReactionPrivacyPeer:
|
||||
if privacy.Peer != nil && privacy.Peer.ID != 0 {
|
||||
peer := *privacy.Peer
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}
|
||||
}
|
||||
}
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}
|
||||
}
|
||||
5
internal/app/auth/doc.go
Normal file
5
internal/app/auth/doc.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。
|
||||
// 第一阶段用开发固定验证码,2FA 配置由 account 服务持久化查询。
|
||||
//
|
||||
// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。
|
||||
package auth
|
||||
362
internal/app/auth/service.go
Normal file
362
internal/app/auth/service.go
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gotd/ige"
|
||||
"github.com/gotd/td/bin"
|
||||
mtcrypto "github.com/gotd/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// 登录错误。
|
||||
var (
|
||||
ErrCodeExpired = errors.New("phone code expired or not found")
|
||||
ErrCodeInvalid = errors.New("phone code invalid")
|
||||
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
|
||||
)
|
||||
|
||||
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
auths store.AuthorizationStore
|
||||
codes store.CodeStore
|
||||
authKeys store.AuthKeyStore
|
||||
tempKeys store.TempAuthKeyBindingStore
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
fixedCode string
|
||||
codeTTL time.Duration
|
||||
}
|
||||
|
||||
// Option 调整登录服务的可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithLoginMessages 在登录成功后写入官方系统账号的登录消息与会话摘要。
|
||||
func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) Option {
|
||||
return func(s *Service) {
|
||||
s.messages = messages
|
||||
s.dialogs = dialogs
|
||||
}
|
||||
}
|
||||
|
||||
// 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}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binding.TempSessionID = inner.TempSessionID
|
||||
}
|
||||
if s.tempKeys == nil {
|
||||
return nil
|
||||
}
|
||||
return s.tempKeys.Save(ctx, binding)
|
||||
}
|
||||
|
||||
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_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
|
||||
}
|
||||
binding, found, err := s.tempKeys.GetByTemp(ctx, authKeyID)
|
||||
if err != nil || !found {
|
||||
return [8]byte{}, found, err
|
||||
}
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return [8]byte{}, false, nil
|
||||
}
|
||||
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
|
||||
}
|
||||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录时 found=false。
|
||||
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
if err != nil || !found {
|
||||
return 0, found, err
|
||||
}
|
||||
return a.UserID, true, nil
|
||||
}
|
||||
|
||||
// SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。
|
||||
func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: normalizePhone(phone), Code: s.fixedCode}, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// SignIn 校验验证码并尝试登录。
|
||||
// needSignUp=true 表示验证码正确但用户不存在,调用方应引导注册(此时不删验证码,留给 SignUp)。
|
||||
func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (u domain.User, loginMessage domain.Message, needSignUp bool, err error) {
|
||||
phone = normalizePhone(phone)
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone || rec.Code != code {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
|
||||
existing, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册
|
||||
}
|
||||
if err := s.bind(ctx, auth, existing.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return existing, loginMessage, false, nil
|
||||
}
|
||||
|
||||
// SignUp 在 SignIn 判定需注册后创建用户并绑定授权。
|
||||
// signUp 的 TL 请求不带验证码,这里校验 phone_code_hash 仍有效且手机号匹配。
|
||||
func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) {
|
||||
phone = normalizePhone(phone)
|
||||
firstName = strings.TrimSpace(firstName)
|
||||
lastName = strings.TrimSpace(lastName)
|
||||
if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 {
|
||||
return domain.User{}, domain.Message{}, domain.ErrFirstNameInvalid
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
|
||||
accessHash, err := randomInt64()
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
u, err := s.users.Create(ctx, domain.User{
|
||||
AccessHash: accessHash,
|
||||
Phone: phone,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
if err := s.bind(ctx, auth, u.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
loginMessage, err := s.recordLoginMessage(ctx, u.ID, rec.Code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return u, loginMessage, nil
|
||||
}
|
||||
|
||||
// LogOut 解绑当前 auth_key 的授权。
|
||||
func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error {
|
||||
return s.auths.Delete(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
auth.UserID = userID
|
||||
return s.auths.Bind(ctx, auth)
|
||||
}
|
||||
|
||||
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram!
|
||||
|
||||
This code can be used to log in to your Telegram account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
|
||||
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||||
if s.messages == nil || s.dialogs == nil {
|
||||
return domain.Message{}, nil
|
||||
}
|
||||
body := fmt.Sprintf(loginMessageTpl, code)
|
||||
codeOffset := len("Login code: ")
|
||||
msg, err := s.messages.Create(ctx, domain.Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: int(time.Now().Unix()),
|
||||
Body: body,
|
||||
Entities: []domain.MessageEntity{
|
||||
{Type: domain.MessageEntityBold, Offset: 0, Length: len("Login code:")},
|
||||
{Type: domain.MessageEntityBold, Offset: codeOffset, Length: len(code)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if err := s.dialogs.Upsert(ctx, userID, domain.Dialog{
|
||||
Peer: msg.Peer,
|
||||
TopMessage: msg.ID,
|
||||
TopMessageDate: msg.Date,
|
||||
UnreadCount: 1,
|
||||
}); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
perm, found, err := s.authKeys.Get(ctx, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if !found {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 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 inner, nil
|
||||
}
|
||||
|
||||
func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) {
|
||||
var msg mtcrypto.EncryptedMessage
|
||||
if err := msg.Decode(&bin.Buffer{Buf: encrypted}); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if msg.AuthKeyID != perm.ID || len(msg.EncryptedData) == 0 || len(msg.EncryptedData)%aes.BlockSize != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
key, iv := mtcrypto.KeysV1(mtcrypto.Key(perm.Value), msg.MsgKey)
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
plaintext := make([]byte, len(msg.EncryptedData))
|
||||
ige.DecryptBlocks(block, iv[:], plaintext, msg.EncryptedData)
|
||||
|
||||
const headerLen = 16 + 8 + 4 + 4
|
||||
if len(plaintext) < headerLen {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
b := &bin.Buffer{Buf: plaintext}
|
||||
randomPrefix := make([]byte, 16)
|
||||
if err := b.ConsumeN(randomPrefix, len(randomPrefix)); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if _, err := b.Long(); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if _, err := b.Int32(); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
msgLen, err := b.Int32()
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if msgLen <= 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
bodyEnd := headerLen + int(msgLen)
|
||||
if bodyEnd > len(plaintext) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if msg.MsgKey != mtcrypto.MessageKeyV1(plaintext[:bodyEnd]) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
body := plaintext[headerLen:bodyEnd]
|
||||
var inner mtcrypto.BindAuthKeyInner
|
||||
if err := inner.Decode(&bin.Buffer{Buf: body}); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
return inner, nil
|
||||
}
|
||||
|
||||
func authKeyIDFromInt64(v int64) [8]byte {
|
||||
var id [8]byte
|
||||
binary.LittleEndian.PutUint64(id[:], uint64(v))
|
||||
return id
|
||||
}
|
||||
|
||||
func authKeyIDInt64(id [8]byte) int64 {
|
||||
return int64(binary.LittleEndian.Uint64(id[:]))
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func randomInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return int64(binary.LittleEndian.Uint64(b[:])), nil
|
||||
}
|
||||
259
internal/app/auth/service_test.go
Normal file
259
internal/app/auth/service_test.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mtcrypto "github.com/gotd/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
permKey := testAuthKey(0x11)
|
||||
tempKey := testAuthKey(0x55)
|
||||
saveAuthKey(t, keys, permKey)
|
||||
saveAuthKey(t, keys, tempKey)
|
||||
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
|
||||
const (
|
||||
nonce = int64(0x12345678)
|
||||
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,
|
||||
msgID,
|
||||
&mtcrypto.BindAuthKeyInner{
|
||||
Nonce: nonce,
|
||||
TempAuthKeyID: tempKey.IntID(),
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
TempSessionID: sessionID,
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt bind message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
ExpiresAt: expiresAt,
|
||||
EncryptedMessage: encrypted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BindTempAuthKey valid message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
ExpiresAt: expiresAt,
|
||||
EncryptedMessage: encrypted,
|
||||
})
|
||||
if !errors.Is(err, ErrEncryptedMessageInvalid) {
|
||||
t.Fatalf("BindTempAuthKey wrong session err = %v, want ErrEncryptedMessageInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
permKey := testAuthKey(0x11)
|
||||
tempKey := testAuthKey(0x55)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345")
|
||||
|
||||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAuthKey: %v", err)
|
||||
}
|
||||
if !ok || got != permKey.ID {
|
||||
t.Fatalf("resolved = %x ok=%v, want perm %x", got, ok, permKey.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550004310")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
|
||||
_, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, "15550004310", hash, "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("SignIn with digits-only phone: %v", err)
|
||||
}
|
||||
if !needSignUp {
|
||||
t.Fatal("SignIn needSignUp = false, want true")
|
||||
}
|
||||
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+1 555 000 4310", hash, "Test", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp with formatted phone: %v", err)
|
||||
}
|
||||
if u.Phone != "15550004310" {
|
||||
t.Fatalf("created phone = %q, want normalized digits", u.Phone)
|
||||
}
|
||||
if u.ID != domain.UserIDSequenceBase {
|
||||
t.Fatalf("created user id = %d, want base %d", u.ID, domain.UserIDSequenceBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
var key1, key2 [8]byte
|
||||
key1[0] = 1
|
||||
key2[0] = 2
|
||||
|
||||
hash1, err := svc.SendCode(ctx, "+15550005001")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user1: %v", err)
|
||||
}
|
||||
user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key1}, "+15550005001", hash1, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user1: %v", err)
|
||||
}
|
||||
hash2, err := svc.SendCode(ctx, "+15550005002")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user2: %v", err)
|
||||
}
|
||||
user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key2}, "+15550005002", hash2, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user2: %v", err)
|
||||
}
|
||||
|
||||
got1, found, err := svc.UserID(ctx, key1)
|
||||
if err != nil || !found || got1 != user1.ID {
|
||||
t.Fatalf("key1 user = %d found=%v err=%v, want %d", got1, found, err, user1.ID)
|
||||
}
|
||||
got2, found, err := svc.UserID(ctx, key2)
|
||||
if err != nil || !found || got2 != user2.ID {
|
||||
t.Fatalf("key2 user = %d found=%v err=%v, want %d", got2, found, err, user2.ID)
|
||||
}
|
||||
if got1 == got2 {
|
||||
t.Fatalf("auth keys mapped to same user id %d", got1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
var key [8]byte
|
||||
key[0] = 9
|
||||
|
||||
hash1, err := svc.SendCode(ctx, "+15550006001")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user1: %v", err)
|
||||
}
|
||||
user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006001", hash1, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user1: %v", err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user1.ID {
|
||||
t.Fatalf("initial auth user = %d found=%v err=%v, want %d", got, found, err, user1.ID)
|
||||
}
|
||||
if err := svc.LogOut(ctx, key); err != nil {
|
||||
t.Fatalf("LogOut: %v", err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, key); err != nil || found || got != 0 {
|
||||
t.Fatalf("after logout user = %d found=%v err=%v, want none", got, found, err)
|
||||
}
|
||||
|
||||
hash2, err := svc.SendCode(ctx, "+15550006002")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user2: %v", err)
|
||||
}
|
||||
user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006002", hash2, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user2: %v", err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user2.ID {
|
||||
t.Fatalf("after switch user = %d found=%v err=%v, want %d", got, found, err, user2.ID)
|
||||
}
|
||||
if user1.ID == user2.ID {
|
||||
t.Fatalf("user ids did not change after switch: %d", user1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginMessages(messages, dialogs))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550004311")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
u, msg, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004311", hash, "Test", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID {
|
||||
t.Fatalf("dialogs = %+v, want official system dialog", list.Dialogs)
|
||||
}
|
||||
if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support {
|
||||
t.Fatalf("users = %+v, want verified support system user", list.Users)
|
||||
}
|
||||
if msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") {
|
||||
t.Fatalf("login message = %+v, want returned official login code message", msg)
|
||||
}
|
||||
if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Login code: 12345") {
|
||||
t.Fatalf("messages = %+v, want login code message", list.Messages)
|
||||
}
|
||||
if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 1 {
|
||||
t.Fatalf("dialog top/unread = %+v, message = %+v", list.Dialogs[0], list.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func testAuthKey(seed byte) mtcrypto.AuthKey {
|
||||
var raw mtcrypto.Key
|
||||
for i := range raw {
|
||||
raw[i] = seed + byte(i)
|
||||
}
|
||||
return raw.WithID()
|
||||
}
|
||||
|
||||
func saveAuthKey(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey) {
|
||||
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 {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
}
|
||||
2
internal/app/channels/doc.go
Normal file
2
internal/app/channels/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package channels contains domain service orchestration for Telegram channels and supergroups.
|
||||
package channels
|
||||
1514
internal/app/channels/service.go
Normal file
1514
internal/app/channels/service.go
Normal file
File diff suppressed because it is too large
Load diff
1784
internal/app/channels/service_test.go
Normal file
1784
internal/app/channels/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
3
internal/app/contacts/doc.go
Normal file
3
internal/app/contacts/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package contacts 是联系人应用服务:联系人、拉黑、搜索。
|
||||
// 第一阶段实现 PG-backed 空账号通讯录查询;联系人导入留后续业务迭代。
|
||||
package contacts
|
||||
266
internal/app/contacts/service.go
Normal file
266
internal/app/contacts/service.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrContactIDInvalid = errors.New("contact id invalid")
|
||||
ErrContactNameEmpty = errors.New("contact name empty")
|
||||
)
|
||||
|
||||
const maxSearchLimit = 50
|
||||
|
||||
// Service 提供通讯录查询。
|
||||
type Service struct {
|
||||
contacts store.ContactStore
|
||||
users store.UserStore
|
||||
}
|
||||
|
||||
// NewService 创建 contacts 服务。
|
||||
func NewService(contacts store.ContactStore, users ...store.UserStore) *Service {
|
||||
s := &Service{contacts: contacts}
|
||||
if len(users) > 0 {
|
||||
s.users = users[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。
|
||||
func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 {
|
||||
return domain.ContactList{}, false, nil
|
||||
}
|
||||
list, err := s.contacts.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.ContactList{}, false, err
|
||||
}
|
||||
if s.users != nil && len(list.Contacts) > 0 {
|
||||
if err := s.attachCurrentLastSeen(ctx, &list); err != nil {
|
||||
return domain.ContactList{}, false, err
|
||||
}
|
||||
}
|
||||
if hash != 0 && hash == list.Hash {
|
||||
return list, true, nil
|
||||
}
|
||||
return list, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) attachCurrentLastSeen(ctx context.Context, list *domain.ContactList) error {
|
||||
ids := make([]int64, 0, len(list.Contacts))
|
||||
seen := make(map[int64]struct{}, len(list.Contacts))
|
||||
for _, contact := range list.Contacts {
|
||||
id := contact.User.ID
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
users, err := s.users.ByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := make(map[int64]domain.User, len(users))
|
||||
for _, u := range users {
|
||||
current[u.ID] = u
|
||||
}
|
||||
for i := range list.Contacts {
|
||||
if u, ok := current[list.Contacts[i].User.ID]; ok {
|
||||
list.Contacts[i].User.LastSeenAt = u.LastSeenAt
|
||||
list.Contacts[i].User.Status = u.Status
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.FirstName == "" && input.LastName == "" {
|
||||
return domain.Contact{}, ErrContactNameEmpty
|
||||
}
|
||||
if s.users != nil {
|
||||
target, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.Phone == "" {
|
||||
input.Phone = target.Phone
|
||||
}
|
||||
}
|
||||
contact, err := s.contacts.Upsert(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) {
|
||||
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || len(inputs) == 0 {
|
||||
return domain.ImportContactsResult{}, nil
|
||||
}
|
||||
out := domain.ImportContactsResult{
|
||||
Imported: make([]domain.ImportedContact, 0, len(inputs)),
|
||||
Contacts: make([]domain.Contact, 0, len(inputs)),
|
||||
}
|
||||
normalized := make([]domain.ContactInput, 0, len(inputs))
|
||||
phones := make([]string, 0, len(inputs))
|
||||
seenPhones := make(map[string]struct{}, len(inputs))
|
||||
for _, input := range inputs {
|
||||
phone := normalizePhone(input.Phone)
|
||||
if phone == "" {
|
||||
continue
|
||||
}
|
||||
input.Phone = phone
|
||||
normalized = append(normalized, input)
|
||||
if _, ok := seenPhones[phone]; ok {
|
||||
continue
|
||||
}
|
||||
seenPhones[phone] = struct{}{}
|
||||
phones = append(phones, phone)
|
||||
}
|
||||
if len(phones) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
targets, err := s.users.ByPhones(ctx, phones)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
byPhone := make(map[string]domain.User, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.Phone != "" {
|
||||
byPhone[target.Phone] = target
|
||||
}
|
||||
}
|
||||
upsertsByTarget := make(map[int64]domain.ContactInput, len(targets))
|
||||
order := make([]int64, 0, len(targets))
|
||||
seenTargets := map[int64]struct{}{}
|
||||
for _, input := range normalized {
|
||||
target, found := byPhone[input.Phone]
|
||||
if !found || target.ID == userID || target.ID == 0 {
|
||||
continue
|
||||
}
|
||||
input.ContactUserID = target.ID
|
||||
if input.FirstName == "" && input.LastName == "" {
|
||||
input.FirstName = target.FirstName
|
||||
input.LastName = target.LastName
|
||||
}
|
||||
if _, ok := seenTargets[target.ID]; !ok {
|
||||
seenTargets[target.ID] = struct{}{}
|
||||
order = append(order, target.ID)
|
||||
}
|
||||
out.Imported = append(out.Imported, domain.ImportedContact{UserID: target.ID, ClientID: input.ClientID})
|
||||
upsertsByTarget[target.ID] = input
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
upserts := make([]domain.ContactInput, 0, len(order))
|
||||
for _, targetID := range order {
|
||||
upserts = append(upserts, upsertsByTarget[targetID])
|
||||
}
|
||||
contacts, err := s.contacts.UpsertMany(ctx, userID, upserts)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
out.Contacts = append(out.Contacts, contacts...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) {
|
||||
if s == nil || s.users == nil || userID == 0 {
|
||||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
query = strings.TrimPrefix(query, "@")
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > maxSearchLimit {
|
||||
limit = maxSearchLimit
|
||||
}
|
||||
return s.users.Search(ctx, userID, query, normalizePhone(query), limit)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.contacts.Delete(ctx, userID, contactUserIDs)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
contact, found, err := s.contacts.UpdateNote(ctx, userID, contactUserID, note, entities)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
|
||||
return domain.PeerSettings{}, nil
|
||||
}
|
||||
_, found, err := s.contacts.Get(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.PeerSettings{}, err
|
||||
}
|
||||
return domain.PeerSettings{
|
||||
AddContact: !found,
|
||||
BlockContact: !found,
|
||||
ShareContact: found,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
|
||||
list, notModified, err := s.GetContacts(ctx, userID, hash)
|
||||
if err != nil || notModified {
|
||||
return nil, notModified, err
|
||||
}
|
||||
ids := make([]int, 0, len(list.Contacts))
|
||||
for _, contact := range list.Contacts {
|
||||
ids = append(ids, int(contact.User.ID))
|
||||
}
|
||||
return ids, false, nil
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
if !utf8.ValidString(phone) {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
44
internal/app/contacts/service_test.go
Normal file
44
internal/app/contacts/service_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users)
|
||||
|
||||
res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{
|
||||
{ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"},
|
||||
{ClientID: 12, Phone: "15551234567", FirstName: "Alice Final"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts: %v", err)
|
||||
}
|
||||
if len(res.Imported) != 2 {
|
||||
t.Fatalf("imported = %d, want 2", len(res.Imported))
|
||||
}
|
||||
if res.Imported[0].UserID != target.ID || res.Imported[1].UserID != target.ID {
|
||||
t.Fatalf("imported user ids = %+v, want target %d", res.Imported, target.ID)
|
||||
}
|
||||
if len(res.Contacts) != 1 {
|
||||
t.Fatalf("contacts = %d, want 1 deduped upsert", len(res.Contacts))
|
||||
}
|
||||
if res.Contacts[0].FirstName != "Alice Final" {
|
||||
t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName)
|
||||
}
|
||||
}
|
||||
3
internal/app/dialogs/doc.go
Normal file
3
internal/app/dialogs/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package dialogs 是会话应用服务:会话列表、未读数、置顶、草稿。
|
||||
// 第一阶段实现 PG-backed 空账号会话摘要查询;真实消息闭环留第二阶段。
|
||||
package dialogs
|
||||
593
internal/app/dialogs/service.go
Normal file
593
internal/app/dialogs/service.go
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供会话列表查询。
|
||||
type Service struct {
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
}
|
||||
|
||||
// NewService 创建 dialogs 服务。
|
||||
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
|
||||
s := &Service{dialogs: dialogs}
|
||||
if len(channels) > 0 {
|
||||
s.channels = channels[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
|
||||
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
if filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID && filter.Folder == nil {
|
||||
if s.dialogs == nil {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, filter.FolderID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
filter.Folder = &folder
|
||||
}
|
||||
var out domain.DialogList
|
||||
if s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByUser(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if s.channels != nil {
|
||||
list, err := s.channels.ListChannelDialogs(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, list)
|
||||
}
|
||||
sortDialogList(out.Dialogs)
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if len(out.Dialogs) > limit {
|
||||
keep := make(map[domain.Peer]struct{}, limit)
|
||||
for _, d := range out.Dialogs[:limit] {
|
||||
keep[d.Peer] = struct{}{}
|
||||
}
|
||||
out.Dialogs = out.Dialogs[:limit]
|
||||
out.Messages = filterPrivateMessagesByPeer(out.Messages, keep)
|
||||
out.ChannelMessages = filterChannelMessagesByPeer(out.ChannelMessages, keep)
|
||||
out.Channels = filterChannelsByPeer(out.Channels, keep)
|
||||
}
|
||||
if out.Count == 0 {
|
||||
out.Count = len(out.Dialogs)
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。
|
||||
func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
if len(peers) > domain.MaxDialogFolderPeers {
|
||||
return domain.DialogList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
userPeers := make([]domain.Peer, 0, len(peers))
|
||||
channelIDs := make([]int64, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userPeers = append(userPeers, peer)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, peer.ID)
|
||||
}
|
||||
}
|
||||
var out domain.DialogList
|
||||
if len(userPeers) > 0 && s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByPeers(ctx, userID, userPeers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if len(channelIDs) > 0 && s.channels != nil {
|
||||
list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, list)
|
||||
out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID int64, channelIDs []int64, out domain.DialogList) (domain.DialogList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
present := make(map[int64]struct{}, len(out.Dialogs))
|
||||
for _, dialog := range out.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 {
|
||||
present[dialog.Peer.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[channelID] = struct{}{}
|
||||
if _, ok := present[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
view, err := s.channels.GetChannel(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
if isChannelPreviewAccessError(err) {
|
||||
continue
|
||||
}
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
if isChannelPreviewAccessError(err) {
|
||||
continue
|
||||
}
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
|
||||
dialog := dialogFromChannelView(view)
|
||||
if len(history.Messages) > 0 {
|
||||
top := history.Messages[0]
|
||||
dialog.TopMessage = top.ID
|
||||
dialog.TopMessageDate = top.Date
|
||||
out.ChannelMessages = append(out.ChannelMessages, top)
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, dialog)
|
||||
out.Channels = append(out.Channels, view.Channel)
|
||||
out.Channels = append(out.Channels, history.Channels...)
|
||||
out.Users = append(out.Users, history.Users...)
|
||||
out.Count++
|
||||
present[channelID] = struct{}{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isChannelPreviewAccessError(err error) bool {
|
||||
return errors.Is(err, domain.ErrChannelPrivate) ||
|
||||
errors.Is(err, domain.ErrChannelUserBanned) ||
|
||||
errors.Is(err, domain.ErrChannelInvalid)
|
||||
}
|
||||
|
||||
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
|
||||
dialog := view.Dialog
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
}
|
||||
}
|
||||
|
||||
// SaveDraft stores or clears a cloud draft for one peer/topic.
|
||||
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateDraft(draft); err != nil {
|
||||
return err
|
||||
}
|
||||
if draft.Empty() {
|
||||
_, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
|
||||
return err
|
||||
}
|
||||
return s.dialogs.SaveDraft(ctx, userID, draft)
|
||||
}
|
||||
|
||||
// DeleteDraft clears one cloud draft.
|
||||
func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := validateDraftKey(peer, topMessageID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
|
||||
}
|
||||
|
||||
// ListDrafts returns bounded cloud drafts for messages.getAllDrafts.
|
||||
func (s *Service) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.dialogs.ListDrafts(ctx, userID, clampDraftLimit(limit))
|
||||
}
|
||||
|
||||
// ClearDrafts deletes bounded cloud drafts for messages.clearAllDrafts.
|
||||
func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
|
||||
}
|
||||
|
||||
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
|
||||
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
|
||||
default:
|
||||
if s.dialogs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetPinned(ctx, userID, peer, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
|
||||
if s == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.channels != nil {
|
||||
if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
|
||||
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
|
||||
default:
|
||||
if s.dialogs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var out []domain.Peer
|
||||
if s.dialogs != nil {
|
||||
peers, err := s.dialogs.ListUnreadMarked(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, peers...)
|
||||
}
|
||||
if s.channels != nil {
|
||||
peers, err := s.channels.ListChannelUnreadMarked(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, peers...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.PeerSettingsBarHidden(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return domain.DialogFolderList{}, nil
|
||||
}
|
||||
return s.dialogs.ListFolders(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.UpsertFolder(ctx, userID, folder)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.DeleteFolder(ctx, userID, folderID)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderDialogFolders(ctx context.Context, userID int64, order []int) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.ReorderFolders(ctx, userID, order)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.SetFolderTagsEnabled(ctx, userID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
privatePeers := make([]domain.FolderPeerUpdate, 0, len(peers))
|
||||
channelPeers := make([]domain.FolderPeerUpdate, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Peer.Type == domain.PeerTypeChannel {
|
||||
channelPeers = append(channelPeers, peer)
|
||||
} else {
|
||||
privatePeers = append(privatePeers, peer)
|
||||
}
|
||||
}
|
||||
if len(privatePeers) > 0 && s.dialogs != nil {
|
||||
if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(channelPeers) > 0 && s.channels != nil {
|
||||
if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.DialogList) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(drafts) == 0 {
|
||||
return nil
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID != 0 {
|
||||
continue
|
||||
}
|
||||
byPeer[draft.Peer] = cloneDraft(draft)
|
||||
}
|
||||
if len(byPeer) == 0 {
|
||||
return nil
|
||||
}
|
||||
attached := false
|
||||
for i := range list.Dialogs {
|
||||
draft, ok := byPeer[list.Dialogs[i].Peer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := cloneDraft(draft)
|
||||
list.Dialogs[i].Draft = &d
|
||||
attached = true
|
||||
}
|
||||
if attached {
|
||||
list.Hash = dialogHashWithDrafts(list.Hash, list.Dialogs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDraft(draft domain.DialogDraft) error {
|
||||
if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(draft.Entities) > domain.MaxMessageEntityCount {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(draft.Message) > domain.MaxMessageTextLength {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if domain.ValidateMessageReplyBounds(draft.ReplyTo) != nil {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDraftKey(peer domain.Peer, topMessageID int) error {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampDraftLimit(limit int) int {
|
||||
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
|
||||
return domain.MaxDialogDraftsPerUser
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func cloneDraft(draft domain.DialogDraft) domain.DialogDraft {
|
||||
draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...)
|
||||
if draft.ReplyTo != nil {
|
||||
reply := *draft.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), draft.ReplyTo.QuoteEntities...)
|
||||
draft.ReplyTo = &reply
|
||||
}
|
||||
if draft.WebPage != nil {
|
||||
webpage := *draft.WebPage
|
||||
draft.WebPage = &webpage
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
|
||||
if len(dialogs) == 0 {
|
||||
return base
|
||||
}
|
||||
h := fnv.New64a()
|
||||
var buf [48]byte
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(base))
|
||||
_, _ = h.Write(buf[:8])
|
||||
for _, d := range dialogs {
|
||||
if d.Draft == nil {
|
||||
continue
|
||||
}
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
|
||||
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.Draft.TopMessageID))
|
||||
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.Draft.Date))
|
||||
binary.LittleEndian.PutUint64(buf[16:24], uint64(len(d.Draft.Message)))
|
||||
if d.Draft.NoWebpage {
|
||||
buf[24] = 1
|
||||
} else {
|
||||
buf[24] = 0
|
||||
}
|
||||
if d.Draft.InvertMedia {
|
||||
buf[25] = 1
|
||||
} else {
|
||||
buf[25] = 0
|
||||
}
|
||||
binary.LittleEndian.PutUint64(buf[26:34], uint64(len(d.Draft.Entities)))
|
||||
binary.LittleEndian.PutUint64(buf[34:42], uint64(d.Draft.Effect))
|
||||
_, _ = h.Write(buf[:])
|
||||
_, _ = h.Write([]byte(d.Draft.Message))
|
||||
if d.Draft.WebPage != nil {
|
||||
_, _ = h.Write([]byte(d.Draft.WebPage.URL))
|
||||
}
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
|
||||
out.Dialogs = append(out.Dialogs, in.Dialogs...)
|
||||
out.Messages = append(out.Messages, in.Messages...)
|
||||
out.Users = append(out.Users, in.Users...)
|
||||
out.Count += in.Count
|
||||
out.Hash ^= in.Hash
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeChannelDialogs(out domain.DialogList, in domain.ChannelDialogList) domain.DialogList {
|
||||
out.Dialogs = append(out.Dialogs, in.Dialogs...)
|
||||
out.ChannelMessages = append(out.ChannelMessages, in.Messages...)
|
||||
out.Channels = append(out.Channels, in.Channels...)
|
||||
out.Users = append(out.Users, in.Users...)
|
||||
out.Count += in.Count
|
||||
out.Hash ^= in.Hash
|
||||
return out
|
||||
}
|
||||
|
||||
func sortDialogList(dialogs []domain.Dialog) {
|
||||
sort.SliceStable(dialogs, func(i, j int) bool {
|
||||
if dialogs[i].Pinned != dialogs[j].Pinned {
|
||||
return dialogs[i].Pinned
|
||||
}
|
||||
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
|
||||
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
|
||||
}
|
||||
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
|
||||
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
|
||||
}
|
||||
if dialogs[i].TopMessage != dialogs[j].TopMessage {
|
||||
return dialogs[i].TopMessage > dialogs[j].TopMessage
|
||||
}
|
||||
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
|
||||
})
|
||||
}
|
||||
|
||||
func filterPrivateMessagesByPeer(messages []domain.Message, keep map[domain.Peer]struct{}) []domain.Message {
|
||||
out := messages[:0]
|
||||
for _, msg := range messages {
|
||||
if _, ok := keep[msg.Peer]; ok {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterChannelMessagesByPeer(messages []domain.ChannelMessage, keep map[domain.Peer]struct{}) []domain.ChannelMessage {
|
||||
out := messages[:0]
|
||||
for _, msg := range messages {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID}
|
||||
if _, ok := keep[peer]; ok {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterChannelsByPeer(channels []domain.Channel, keep map[domain.Peer]struct{}) []domain.Channel {
|
||||
out := channels[:0]
|
||||
for _, ch := range channels {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}
|
||||
if _, ok := keep[peer]; ok {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
282
internal/app/dialogs/service_test.go
Normal file
282
internal/app/dialogs/service_test.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
created, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Offline Read",
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 42,
|
||||
Message: "restore read outbox",
|
||||
Date: 11,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if _, err := channels.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
MaxID: sent.Message.ID,
|
||||
Date: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReadHistory: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
dialog := findChannelDialog(t, list, created.Channel.ID)
|
||||
if dialog.ReadOutboxMaxID != sent.Message.ID {
|
||||
t.Fatalf("getDialogs read_outbox = %d, want %d", dialog.ReadOutboxMaxID, sent.Message.ID)
|
||||
}
|
||||
|
||||
peerList, err := dialogs.GetPeerDialogs(ctx, 1001, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs: %v", err)
|
||||
}
|
||||
peerDialog := findChannelDialog(t, peerList, created.Channel.ID)
|
||||
if peerDialog.ReadOutboxMaxID != sent.Message.ID {
|
||||
t.Fatalf("getPeerDialogs read_outbox = %d, want %d", peerDialog.ReadOutboxMaxID, sent.Message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
first, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Pinned One",
|
||||
Date: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat first: %v", err)
|
||||
}
|
||||
second, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Pinned Two",
|
||||
Date: 21,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat second: %v", err)
|
||||
}
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}
|
||||
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned first = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned second = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if err := dialogs.ReorderPinned(ctx, 1001, []domain.Peer{secondPeer, firstPeer}, true); err != nil {
|
||||
t.Fatalf("ReorderPinned: %v", err)
|
||||
}
|
||||
if changed, err := dialogs.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if err := dialogs.EditPeerFolders(ctx, 1001, []domain.FolderPeerUpdate{
|
||||
{Peer: firstPeer, FolderID: domain.DialogArchiveFolderID},
|
||||
}); err != nil {
|
||||
t.Fatalf("EditPeerFolders: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
firstDialog := findChannelDialog(t, list, first.Channel.ID)
|
||||
if !firstDialog.Pinned || firstDialog.PinnedOrder != 1 || !firstDialog.UnreadMark || firstDialog.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("first dialog = %+v, want pinned order 1, unread mark, archived", firstDialog)
|
||||
}
|
||||
secondDialog := findChannelDialog(t, list, second.Channel.ID)
|
||||
if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 {
|
||||
t.Fatalf("second dialog = %+v, want pinned order 2", secondDialog)
|
||||
}
|
||||
marks, err := dialogs.UnreadMarks(ctx, 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("UnreadMarks: %v", err)
|
||||
}
|
||||
if len(marks) != 1 || marks[0] != firstPeer {
|
||||
t.Fatalf("unread marks = %+v, want first channel", marks)
|
||||
}
|
||||
archived, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogArchiveFolderID,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs archive: %v", err)
|
||||
}
|
||||
if got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("archived dialog = %+v, want archive folder", got)
|
||||
}
|
||||
|
||||
custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogCustomFolderMinID,
|
||||
Folder: &domain.DialogFolder{ID: domain.DialogCustomFolderMinID, Groups: true},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs custom groups: %v", err)
|
||||
}
|
||||
if got := findChannelDialog(t, custom, first.Channel.ID); got.Peer.ID != first.Channel.ID {
|
||||
t.Fatalf("custom group dialog = %+v, want first channel", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
old, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Older Channel",
|
||||
Date: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat old: %v", err)
|
||||
}
|
||||
newer, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Newer Channel",
|
||||
Date: 30,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat newer: %v", err)
|
||||
}
|
||||
|
||||
first, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs first: %v", err)
|
||||
}
|
||||
if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID {
|
||||
t.Fatalf("first page dialogs = %+v, want newer channel", first.Dialogs)
|
||||
}
|
||||
|
||||
next, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
OffsetDate: first.Dialogs[0].TopMessageDate,
|
||||
OffsetID: first.Dialogs[0].TopMessage,
|
||||
HasOffsetPeer: true,
|
||||
OffsetPeer: first.Dialogs[0].Peer,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs next: %v", err)
|
||||
}
|
||||
if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != old.Channel.ID {
|
||||
t.Fatalf("next page dialogs = %+v, want only older channel", next.Dialogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsRejectsHugeVector(t *testing.T) {
|
||||
dialogs := NewService(nil, memory.NewChannelStore())
|
||||
peers := make([]domain.Peer, domain.MaxDialogFolderPeers+1)
|
||||
for i := range peers {
|
||||
peers[i] = domain.Peer{Type: domain.PeerTypeChannel, ID: int64(i + 1)}
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(context.Background(), 1001, peers); !errors.Is(err, domain.ErrChannelInvalid) {
|
||||
t.Fatalf("GetPeerDialogs huge vector err = %v, want ErrChannelInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
public, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Public Peer Dialog",
|
||||
Broadcast: true,
|
||||
Date: 1700002000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel public: %v", err)
|
||||
}
|
||||
if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
UserID: 1001,
|
||||
ChannelID: public.Channel.ID,
|
||||
Username: "public_peer_dialog",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateUsername public: %v", err)
|
||||
}
|
||||
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: public.Channel.ID,
|
||||
RandomID: 99,
|
||||
Message: "public peer dialog top",
|
||||
Date: 1700002010,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage public: %v", err)
|
||||
}
|
||||
private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Private Peer Dialog",
|
||||
Broadcast: true,
|
||||
Date: 1700002020,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel private: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: public.Channel.ID},
|
||||
{Type: domain.PeerTypeChannel, ID: private.Channel.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs public preview: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 {
|
||||
t.Fatalf("dialogs = %+v, want only public preview dialog", list.Dialogs)
|
||||
}
|
||||
dialog := findChannelDialog(t, list, public.Channel.ID)
|
||||
if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date {
|
||||
t.Fatalf("preview dialog top = id %d date %d, want %d/%d", dialog.TopMessage, dialog.TopMessageDate, sent.Message.ID, sent.Message.Date)
|
||||
}
|
||||
if !dialog.ChannelLeft {
|
||||
t.Fatalf("preview dialog ChannelLeft = false, want read-only left preview")
|
||||
}
|
||||
if dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < sent.Message.ID || dialog.ReadOutboxMaxID < sent.Message.ID {
|
||||
t.Fatalf("preview dialog read/unread = %+v, want read through top and no unread", dialog)
|
||||
}
|
||||
if len(list.ChannelMessages) != 1 || list.ChannelMessages[0].Body != "public peer dialog top" {
|
||||
t.Fatalf("channel messages = %+v, want public top message", list.ChannelMessages)
|
||||
}
|
||||
if len(list.Channels) != 1 || list.Channels[0].ID != public.Channel.ID {
|
||||
t.Fatalf("channels = %+v, want public channel shell", list.Channels)
|
||||
}
|
||||
}
|
||||
|
||||
func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog {
|
||||
t.Helper()
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == channelID {
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs)
|
||||
return domain.Dialog{}
|
||||
}
|
||||
236
internal/app/files/blobcache.go
Normal file
236
internal/app/files/blobcache.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile
|
||||
// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/
|
||||
// reaction/头像更被大量用户重复拉)。
|
||||
//
|
||||
// FileBlob 元数据小(约百字节)且内容不可变:location_key 一旦写入即固定指向同一 object_key,
|
||||
// 新建 blob 用随机 id 生成 location_key 不会与已缓存项冲突,故只读填充、无需失效。
|
||||
type blobMetaCache struct {
|
||||
mu sync.Mutex
|
||||
cap int
|
||||
ll *list.List
|
||||
m map[string]*list.Element
|
||||
}
|
||||
|
||||
type blobMetaEntry struct {
|
||||
key string
|
||||
blob domain.FileBlob
|
||||
}
|
||||
|
||||
func newBlobMetaCache(capacity int) *blobMetaCache {
|
||||
if capacity <= 0 {
|
||||
capacity = 1
|
||||
}
|
||||
return &blobMetaCache{
|
||||
cap: capacity,
|
||||
ll: list.New(),
|
||||
m: make(map[string]*list.Element, capacity),
|
||||
}
|
||||
}
|
||||
|
||||
// get 返回缓存的 FileBlob 并把其移到 LRU 头部;未命中返回 ok=false。
|
||||
func (c *blobMetaCache) get(key string) (domain.FileBlob, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
return el.Value.(*blobMetaEntry).blob, true
|
||||
}
|
||||
return domain.FileBlob{}, false
|
||||
}
|
||||
|
||||
// put 写入/更新缓存,超出容量时淘汰最久未用项。
|
||||
func (c *blobMetaCache) put(key string, blob domain.FileBlob) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
el.Value.(*blobMetaEntry).blob = blob
|
||||
c.ll.MoveToFront(el)
|
||||
return
|
||||
}
|
||||
c.m[key] = c.ll.PushFront(&blobMetaEntry{key: key, blob: blob})
|
||||
if c.ll.Len() > c.cap {
|
||||
if oldest := c.ll.Back(); oldest != nil {
|
||||
c.ll.Remove(oldest)
|
||||
delete(c.m, oldest.Value.(*blobMetaEntry).key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blobBytesCache 是 object_key → 小 blob 全量字节的 LRU。Sticker / reaction /
|
||||
// 缩略图通常只有几 KB 到几十 KB,缓存全量内容可以避开点击历史时的本地磁盘冷读抖动;
|
||||
// 大媒体仍由 BlobBackend.GetRange 分段读取,避免把大文件放进内存。
|
||||
type blobBytesCache struct {
|
||||
mu sync.Mutex
|
||||
maxBytes int
|
||||
used int
|
||||
ll *list.List
|
||||
m map[string]*list.Element
|
||||
}
|
||||
|
||||
type blobBytesEntry struct {
|
||||
key string
|
||||
bytes []byte
|
||||
size int
|
||||
}
|
||||
|
||||
func newBlobBytesCache(maxBytes int) *blobBytesCache {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 1
|
||||
}
|
||||
return &blobBytesCache{
|
||||
maxBytes: maxBytes,
|
||||
ll: list.New(),
|
||||
m: make(map[string]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *blobBytesCache) get(key string) ([]byte, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
entry := el.Value.(*blobBytesEntry)
|
||||
return append([]byte(nil), entry.bytes...), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *blobBytesCache) has(key string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *blobBytesCache) put(key string, bytes []byte) {
|
||||
if len(bytes) > c.maxBytes {
|
||||
return
|
||||
}
|
||||
copied := append([]byte(nil), bytes...)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
entry := el.Value.(*blobBytesEntry)
|
||||
c.used += len(copied) - entry.size
|
||||
entry.bytes = copied
|
||||
entry.size = len(copied)
|
||||
c.ll.MoveToFront(el)
|
||||
} else {
|
||||
entry := &blobBytesEntry{key: key, bytes: copied, size: len(copied)}
|
||||
c.m[key] = c.ll.PushFront(entry)
|
||||
c.used += entry.size
|
||||
}
|
||||
for c.used > c.maxBytes {
|
||||
oldest := c.ll.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
c.ll.Remove(oldest)
|
||||
entry := oldest.Value.(*blobBytesEntry)
|
||||
delete(c.m, entry.key)
|
||||
c.used -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
type stickerSetFullCache struct {
|
||||
mu sync.RWMutex
|
||||
byID map[int64]stickerSetFullEntry
|
||||
byShort map[string]int64
|
||||
bySystem map[string]int64
|
||||
}
|
||||
|
||||
type stickerSetFullEntry struct {
|
||||
set domain.StickerSet
|
||||
docs []domain.Document
|
||||
}
|
||||
|
||||
func newStickerSetFullCache() *stickerSetFullCache {
|
||||
return &stickerSetFullCache{
|
||||
byID: map[int64]stickerSetFullEntry{},
|
||||
byShort: map[string]int64{},
|
||||
bySystem: map[string]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stickerSetFullCache) get(ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
var id int64
|
||||
switch ref.Kind {
|
||||
case domain.StickerSetRefByID:
|
||||
id = ref.ID
|
||||
case domain.StickerSetRefByShortName:
|
||||
id = c.byShort[ref.ShortName]
|
||||
case domain.StickerSetRefBySystem:
|
||||
id = c.bySystem[ref.SystemKey]
|
||||
default:
|
||||
return domain.StickerSet{}, nil, false
|
||||
}
|
||||
entry, ok := c.byID[id]
|
||||
if !ok {
|
||||
return domain.StickerSet{}, nil, false
|
||||
}
|
||||
return copyStickerSet(entry.set), copyDocuments(entry.docs), true
|
||||
}
|
||||
|
||||
func (c *stickerSetFullCache) put(set domain.StickerSet, docs []domain.Document) {
|
||||
if set.ID == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.byID[set.ID] = stickerSetFullEntry{
|
||||
set: copyStickerSet(set),
|
||||
docs: copyDocuments(docs),
|
||||
}
|
||||
if set.ShortName != "" {
|
||||
c.byShort[set.ShortName] = set.ID
|
||||
}
|
||||
if set.SystemKey != "" {
|
||||
c.bySystem[set.SystemKey] = set.ID
|
||||
}
|
||||
}
|
||||
|
||||
func copyStickerSet(set domain.StickerSet) domain.StickerSet {
|
||||
set.DocumentIDs = append([]int64(nil), set.DocumentIDs...)
|
||||
set.Packs = append([]domain.StickerPack(nil), set.Packs...)
|
||||
for i := range set.Packs {
|
||||
set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...)
|
||||
}
|
||||
set.Thumbs = copyPhotoSizes(set.Thumbs)
|
||||
return set
|
||||
}
|
||||
|
||||
func copyDocuments(docs []domain.Document) []domain.Document {
|
||||
out := append([]domain.Document(nil), docs...)
|
||||
for i := range out {
|
||||
out[i].FileReference = append([]byte(nil), out[i].FileReference...)
|
||||
out[i].Attributes = append([]domain.DocumentAttribute(nil), out[i].Attributes...)
|
||||
for j := range out[i].Attributes {
|
||||
out[i].Attributes[j].Waveform = append([]byte(nil), out[i].Attributes[j].Waveform...)
|
||||
}
|
||||
out[i].Thumbs = copyPhotoSizes(out[i].Thumbs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyPhotoSizes(sizes []domain.PhotoSize) []domain.PhotoSize {
|
||||
out := append([]domain.PhotoSize(nil), sizes...)
|
||||
for i := range out {
|
||||
out[i].Bytes = append([]byte(nil), out[i].Bytes...)
|
||||
out[i].Sizes = append([]int(nil), out[i].Sizes...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
233
internal/app/files/blobcache_test.go
Normal file
233
internal/app/files/blobcache_test.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBlobMetaCacheGetPutEvict(t *testing.T) {
|
||||
c := newBlobMetaCache(2)
|
||||
c.put("a", domain.FileBlob{LocationKey: "a", ObjectKey: "oa"})
|
||||
c.put("b", domain.FileBlob{LocationKey: "b", ObjectKey: "ob"})
|
||||
if b, ok := c.get("a"); !ok || b.ObjectKey != "oa" {
|
||||
t.Fatalf("get a = %+v ok=%v", b, ok)
|
||||
}
|
||||
// 容量 2:刚 access 过 a,再 put c 应淘汰最久未用的 b。
|
||||
c.put("c", domain.FileBlob{LocationKey: "c", ObjectKey: "oc"})
|
||||
if _, ok := c.get("b"); ok {
|
||||
t.Error("b should be evicted (least recently used)")
|
||||
}
|
||||
if _, ok := c.get("a"); !ok {
|
||||
t.Error("a should remain (recently used)")
|
||||
}
|
||||
if _, ok := c.get("c"); !ok {
|
||||
t.Error("c should be present")
|
||||
}
|
||||
}
|
||||
|
||||
// countingMediaStore 统计 GetFileBlob 次数,验证元数据缓存命中后不再查 PG。
|
||||
type countingMediaStore struct {
|
||||
*fakeMediaStore
|
||||
getBlobCalls int
|
||||
getSetByIDCalls int
|
||||
}
|
||||
|
||||
func (c *countingMediaStore) GetFileBlob(ctx context.Context, key string) (domain.FileBlob, bool, error) {
|
||||
c.getBlobCalls++
|
||||
return c.fakeMediaStore.GetFileBlob(ctx, key)
|
||||
}
|
||||
|
||||
func (c *countingMediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) {
|
||||
c.getSetByIDCalls++
|
||||
return c.fakeMediaStore.GetStickerSetByID(ctx, id)
|
||||
}
|
||||
|
||||
type countingBlobBackend struct {
|
||||
BlobBackend
|
||||
getRangeCalls int
|
||||
}
|
||||
|
||||
func (c *countingBlobBackend) GetRange(ctx context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) {
|
||||
c.getRangeCalls++
|
||||
return c.BlobBackend.GetRange(ctx, objectKey, offset, limit)
|
||||
}
|
||||
|
||||
func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
objectKey, err := local.Put(ctx, []byte("0123456789"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
counting := &countingMediaStore{fakeMediaStore: media}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(counting, blobs, 2)
|
||||
|
||||
// 第一次:查 PG 一次并填充元数据缓存;小 blob 读整块进字节缓存后返回 [0,5)。
|
||||
c1, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 0, Limit: 5})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile1 ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(c1.Bytes) != "01234" {
|
||||
t.Errorf("chunk1 = %q, want 01234", c1.Bytes)
|
||||
}
|
||||
if c1.Total != 10 {
|
||||
t.Errorf("total = %d, want 10", c1.Total)
|
||||
}
|
||||
if counting.getBlobCalls != 1 {
|
||||
t.Errorf("getBlobCalls = %d, want 1", counting.getBlobCalls)
|
||||
}
|
||||
if blobs.getRangeCalls != 1 {
|
||||
t.Errorf("getRangeCalls = %d, want 1", blobs.getRangeCalls)
|
||||
}
|
||||
|
||||
// 第二次:同 location 命中元数据与字节缓存;[5,10) 直接从内存切片。
|
||||
c2, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 5, Limit: 5})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile2 ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(c2.Bytes) != "56789" {
|
||||
t.Errorf("chunk2 = %q, want 56789", c2.Bytes)
|
||||
}
|
||||
if counting.getBlobCalls != 1 {
|
||||
t.Errorf("getBlobCalls = %d, want 1 (cache hit)", counting.getBlobCalls)
|
||||
}
|
||||
if blobs.getRangeCalls != 1 {
|
||||
t.Errorf("getRangeCalls = %d, want 1 (byte cache hit)", blobs.getRangeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
content := bytes.Repeat([]byte("x"), blobBytesCacheMaxEntryBytes+2)
|
||||
objectKey, err := local.Put(ctx, content)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: "doc:large",
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(content)),
|
||||
MimeType: "application/octet-stream",
|
||||
}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(media, blobs, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:large", Offset: 1, Limit: 7})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile %d ok=%v err=%v", i, ok, err)
|
||||
}
|
||||
if string(chunk.Bytes) != "xxxxxxx" {
|
||||
t.Fatalf("chunk %d = %q, want seven x bytes", i, chunk.Bytes)
|
||||
}
|
||||
}
|
||||
if blobs.getRangeCalls != 2 {
|
||||
t.Errorf("getRangeCalls = %d, want 2 (large blob is not byte cached)", blobs.getRangeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
mainKey, err := local.Put(ctx, []byte("sticker"))
|
||||
if err != nil {
|
||||
t.Fatalf("put main: %v", err)
|
||||
}
|
||||
thumbKey, err := local.Put(ctx, []byte("thumb"))
|
||||
if err != nil {
|
||||
t.Fatalf("put thumb: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
doc := domain.Document{
|
||||
ID: 100,
|
||||
AccessHash: 1,
|
||||
DCID: 2,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Size: 7,
|
||||
Thumbs: []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 5},
|
||||
},
|
||||
}
|
||||
if err := media.PutDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil {
|
||||
t.Fatalf("put main blob: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil {
|
||||
t.Fatalf("put thumb blob: %v", err)
|
||||
}
|
||||
set := domain.StickerSet{
|
||||
ID: 200,
|
||||
AccessHash: 2,
|
||||
ShortName: "pack",
|
||||
Title: "Pack",
|
||||
Kind: domain.StickerSetKindStickers,
|
||||
Count: 1,
|
||||
DocumentIDs: []int64{
|
||||
doc.ID,
|
||||
},
|
||||
}
|
||||
if err := media.PutStickerSet(ctx, set); err != nil {
|
||||
t.Fatalf("put set: %v", err)
|
||||
}
|
||||
counting := &countingMediaStore{fakeMediaStore: media}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(counting, blobs, 2)
|
||||
|
||||
stats, err := svc.WarmCaches(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("warm caches: %v", err)
|
||||
}
|
||||
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 {
|
||||
t.Fatalf("warm stats = %+v, want 1 set, 1 doc, 2 blobs", stats)
|
||||
}
|
||||
blobs.getRangeCalls = 0
|
||||
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:100", Offset: 0, Limit: 7})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(chunk.Bytes) != "sticker" {
|
||||
t.Fatalf("chunk = %q, want sticker", chunk.Bytes)
|
||||
}
|
||||
if blobs.getRangeCalls != 0 {
|
||||
t.Fatalf("prewarmed blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls)
|
||||
}
|
||||
|
||||
gotSet, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("resolve found=%v err=%v", found, err)
|
||||
}
|
||||
if gotSet.ID != set.ID || len(docs) != 1 || docs[0].ID != doc.ID {
|
||||
t.Fatalf("resolve = set %+v docs %+v", gotSet, docs)
|
||||
}
|
||||
if counting.getSetByIDCalls != 0 {
|
||||
t.Fatalf("ResolveStickerSet should hit full-set cache, GetStickerSetByID calls = %d", counting.getSetByIDCalls)
|
||||
}
|
||||
docs[0].ID = 999
|
||||
_, docsAgain, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID})
|
||||
if err != nil || !found || docsAgain[0].ID != doc.ID {
|
||||
t.Fatalf("cached docs were mutated: found=%v err=%v docs=%+v", found, err, docsAgain)
|
||||
}
|
||||
}
|
||||
105
internal/app/files/blobfs.go
Normal file
105
internal/app/files/blobfs.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// BlobBackend 是 blob 字节内容的存储后端。第一阶段只有本地磁盘实现。
|
||||
// 内容寻址:Put 返回的 objectKey 是内容 sha256,相同内容自动去重。
|
||||
type BlobBackend interface {
|
||||
Name() string
|
||||
Put(ctx context.Context, data []byte) (objectKey string, err error)
|
||||
Get(ctx context.Context, objectKey string) ([]byte, error)
|
||||
// GetRange 只读 [offset, offset+limit) 段并返回该段字节与文件总大小(limit<=0 读到末尾),
|
||||
// 避免大文件每个 chunk 都整文件读入内存(getFile 按 chunk 多次请求 ⇒ 否则 O(N²) 放大)。
|
||||
GetRange(ctx context.Context, objectKey string, offset, limit int64) (data []byte, total int64, err error)
|
||||
}
|
||||
|
||||
// LocalFS 把 blob 字节存到本地磁盘根目录下,路径按内容 hash 两级 fanout。
|
||||
type LocalFS struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewLocalFS 创建本地磁盘 blob backend,确保根目录存在。
|
||||
func NewLocalFS(root string) (*LocalFS, error) {
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("blob root dir is empty")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create blob root %q: %w", root, err)
|
||||
}
|
||||
return &LocalFS{root: root}, nil
|
||||
}
|
||||
|
||||
// Name 返回后端标识,与 file_blobs.backend 一致。
|
||||
func (l *LocalFS) Name() string { return "localfs" }
|
||||
|
||||
func (l *LocalFS) pathFor(objectKey string) string {
|
||||
if len(objectKey) < 4 {
|
||||
return filepath.Join(l.root, "_", objectKey)
|
||||
}
|
||||
return filepath.Join(l.root, objectKey[:2], objectKey[2:4], objectKey)
|
||||
}
|
||||
|
||||
// Put 写入内容并返回 sha256 hex 作为 objectKey;同内容已存在则跳过写入(去重)。
|
||||
func (l *LocalFS) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
path := l.pathFor(key)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create blob dir: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write blob: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return "", fmt.Errorf("commit blob: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Get 读取 objectKey 对应的全部字节。
|
||||
func (l *LocalFS) Get(_ context.Context, objectKey string) ([]byte, error) {
|
||||
return os.ReadFile(l.pathFor(objectKey))
|
||||
}
|
||||
|
||||
// GetRange 用 ReadAt 只读 [offset, offset+limit) 段,total 取自文件大小;
|
||||
// n 受 total 约束,故即便客户端传超大 limit 也只分配文件实际大小,不会按客户端巨值分配。
|
||||
func (l *LocalFS) GetRange(_ context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) {
|
||||
f, err := os.Open(l.pathFor(objectKey))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := info.Size()
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= total {
|
||||
return []byte{}, total, nil
|
||||
}
|
||||
n := total - offset
|
||||
if limit > 0 && limit < n {
|
||||
n = limit
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
read, err := f.ReadAt(buf, offset)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, 0, err
|
||||
}
|
||||
return buf[:read], total, nil
|
||||
}
|
||||
92
internal/app/files/blobfs_test.go
Normal file
92
internal/app/files/blobfs_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalFSPutGetRoundTrip(t *testing.T) {
|
||||
fs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new local fs: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
data := []byte("hello telesrv media blob 你好")
|
||||
|
||||
key, err := fs.Put(ctx, data)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if key == "" {
|
||||
t.Fatal("empty object key")
|
||||
}
|
||||
|
||||
// 内容寻址:相同内容应得到相同 key(去重)。
|
||||
key2, err := fs.Put(ctx, data)
|
||||
if err != nil {
|
||||
t.Fatalf("put again: %v", err)
|
||||
}
|
||||
if key != key2 {
|
||||
t.Fatalf("expected dedup key %q == %q", key, key2)
|
||||
}
|
||||
|
||||
got, err := fs.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("roundtrip mismatch: got %q want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSDistinctContent(t *testing.T) {
|
||||
fs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new local fs: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
k1, _ := fs.Put(ctx, []byte("aaa"))
|
||||
k2, _ := fs.Put(ctx, []byte("bbb"))
|
||||
if k1 == k2 {
|
||||
t.Fatal("distinct content must yield distinct keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSGetRange(t *testing.T) {
|
||||
fs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new local fs: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
key, err := fs.Put(ctx, []byte("0123456789"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
offset, limit int64
|
||||
want string
|
||||
}{
|
||||
{"head", 0, 4, "0123"},
|
||||
{"middle", 3, 4, "3456"},
|
||||
{"limit-exceeds-remaining", 7, 100, "789"},
|
||||
{"zero-limit-reads-to-end", 2, 0, "23456789"},
|
||||
{"offset-at-end", 10, 5, ""},
|
||||
{"offset-past-end", 20, 5, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, total, err := fs.GetRange(ctx, key, tc.offset, tc.limit)
|
||||
if err != nil {
|
||||
t.Fatalf("getrange: %v", err)
|
||||
}
|
||||
if string(data) != tc.want {
|
||||
t.Errorf("data = %q, want %q", data, tc.want)
|
||||
}
|
||||
if total != 10 {
|
||||
t.Errorf("total = %d, want 10", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
6
internal/app/files/doc.go
Normal file
6
internal/app/files/doc.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Package files 是文件应用服务:upload 分片累积、blob 落盘、getFile 下载,
|
||||
// 以及把上传文件组装成 Photo / Document(头像、图片/文件/贴纸消息)。
|
||||
//
|
||||
// 类型边界:本包只用 domain / store 类型,不依赖 tg.*;
|
||||
// rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 的转换。
|
||||
package files
|
||||
278
internal/app/files/photos.go
Normal file
278
internal/app/files/photos.go
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸
|
||||
_ "image/png" // 注册 png DecodeConfig
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。
|
||||
// 同一份上传字节在多个 location_key 下建 blob(不做实际缩放,dev 主路径足够)。
|
||||
|
||||
// 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) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
photo, err := s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photo.ID, date); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// CreatePhotoFromUpload 把已上传文件组装成 Photo(不绑定 profile_photos),用于频道头像 / 图片消息。
|
||||
func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data))
|
||||
}
|
||||
|
||||
// GetPhoto 按 id 返回已存储照片。
|
||||
func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) {
|
||||
return s.media.GetPhoto(ctx, id)
|
||||
}
|
||||
|
||||
// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。
|
||||
func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
|
||||
return s.media.GetDocument(ctx, id)
|
||||
}
|
||||
|
||||
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('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 {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
|
||||
}
|
||||
|
||||
// CreateDocumentFromUpload 把已上传文件组装成 Document(文件/视频/音频/gif/贴纸消息),落 blob + documents。
|
||||
func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
docID := randomID()
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: spec.MimeType,
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
doc := domain.Document{
|
||||
ID: docID,
|
||||
AccessHash: randomID(),
|
||||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: spec.MimeType,
|
||||
Size: int64(len(data)),
|
||||
DCID: s.dc,
|
||||
Attributes: spec.Attributes,
|
||||
}
|
||||
if spec.Thumb != nil {
|
||||
thumbData, err := s.assembleUpload(ctx, spec.Thumb.OwnerUserID, spec.Thumb.FileID, spec.Thumb.Parts)
|
||||
if err == nil && len(thumbData) > 0 {
|
||||
thumbKey, err := s.blobs.Put(ctx, thumbData)
|
||||
if err == nil {
|
||||
w, h := imageDimensions(thumbData, 0, 0)
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:m", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: thumbKey,
|
||||
Size: int64(len(thumbData)),
|
||||
MimeType: "image/jpeg",
|
||||
}); err == nil {
|
||||
doc.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。
|
||||
func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) {
|
||||
photo, ok, err := s.media.GetPhoto(ctx, photoID)
|
||||
if err != nil || !ok {
|
||||
return domain.Photo{}, ok, err
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photoID, date); err != nil {
|
||||
return domain.Photo{}, false, err
|
||||
}
|
||||
return photo, true, nil
|
||||
}
|
||||
|
||||
// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。
|
||||
func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) {
|
||||
id, ok, err := s.media.CurrentProfilePhoto(ctx, ownerType, ownerID)
|
||||
if err != nil || !ok {
|
||||
return domain.Photo{}, ok, err
|
||||
}
|
||||
return s.media.GetPhoto(ctx, id)
|
||||
}
|
||||
|
||||
// GetProfilePhotos 返回 owner 的头像历史(最新在前)。
|
||||
func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
|
||||
ids, total, err := s.media.ListProfilePhotos(ctx, ownerType, ownerID, offset, limit, maxID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
photos := make([]domain.Photo, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if p, ok, err := s.media.GetPhoto(ctx, id); err != nil {
|
||||
return nil, 0, err
|
||||
} else if ok {
|
||||
photos = append(photos, p)
|
||||
}
|
||||
}
|
||||
return photos, total, nil
|
||||
}
|
||||
|
||||
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
|
||||
func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) {
|
||||
deleted, err := s.media.DeleteProfilePhotos(ctx, ownerType, ownerID, photoIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(deleted), nil
|
||||
}
|
||||
|
||||
// createPhoto 把字节落 blob(每个尺寸一个 location_key,指向同一内容)并写 photos 表。
|
||||
func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSizeSpec) (domain.Photo, error) {
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
photoID := randomID()
|
||||
sizes := make([]domain.PhotoSize, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: "image/jpeg",
|
||||
}); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)})
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
type photoSizeSpec struct {
|
||||
Type string
|
||||
W int
|
||||
H int
|
||||
}
|
||||
|
||||
func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec {
|
||||
w, h := imageDimensions(data, 640, 640)
|
||||
small := 160
|
||||
if w < small {
|
||||
small = w
|
||||
}
|
||||
return []photoSizeSpec{
|
||||
{Type: "a", W: small, H: small},
|
||||
{Type: "c", W: w, H: h},
|
||||
}
|
||||
}
|
||||
|
||||
// photoSizeSpecsForMessage 给图片消息生成下载尺寸('m' 缩略 + 'x'/'y' 大图)。
|
||||
func photoSizeSpecsForMessage(data []byte) []photoSizeSpec {
|
||||
w, h := imageDimensions(data, 1280, 1280)
|
||||
thumbW, thumbH := scaleDown(w, h, 320)
|
||||
return []photoSizeSpec{
|
||||
{Type: "m", W: thumbW, H: thumbH},
|
||||
{Type: "x", W: w, H: h},
|
||||
}
|
||||
}
|
||||
|
||||
func imageDimensions(data []byte, defW, defH int) (int, int) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return defW, defH
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
func scaleDown(w, h, max int) (int, int) {
|
||||
if w <= max && h <= max {
|
||||
return w, h
|
||||
}
|
||||
if w >= h {
|
||||
return max, max * h / w
|
||||
}
|
||||
return max * w / h, max
|
||||
}
|
||||
|
||||
func randomID() int64 {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
v := int64(binary.BigEndian.Uint64(b[:]) >> 1)
|
||||
if v == 0 {
|
||||
v = 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func randomFileReference() []byte {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return b
|
||||
}
|
||||
860
internal/app/files/seed.go
Normal file
860
internal/app/files/seed.go
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 本文件实现从外部导出的 reaction / sticker 资源目录导入媒体种子:
|
||||
// JSON 元数据(外部导出 document id/access_hash/file_reference/attributes/thumbs)落 documents /
|
||||
// sticker_sets / available_reactions 表,二进制 .tgs/.webp/缩略图落 blob backend。
|
||||
// dc_id 统一重写为本 server 的 DC,使客户端从本 DC 下载。导入幂等(已存在则跳过)。
|
||||
|
||||
// SeedStats 汇报一次种子导入结果。
|
||||
type SeedStats struct {
|
||||
Reactions int
|
||||
StickerSets int
|
||||
Documents int
|
||||
Blobs int
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// SeedMedia 从导出根目录导入 reaction 与 sticker 资源。maxRegularSets<=0 表示不限。
|
||||
func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int) (SeedStats, error) {
|
||||
var stats SeedStats
|
||||
if root == "" {
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
// 目录不存在:跳过而非失败(开发机可能未放资源)。
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// reactions
|
||||
if n, err := s.media.CountAvailableReactions(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if n == 0 {
|
||||
if err := s.seedReactions(ctx, root, &stats); err != nil {
|
||||
return stats, fmt.Errorf("seed reactions: %w", err)
|
||||
}
|
||||
} else if incomplete, err := s.availableReactionSeedNeedsRepair(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if incomplete {
|
||||
if err := s.seedReactions(ctx, root, &stats); err != nil {
|
||||
return stats, fmt.Errorf("repair reactions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sticker sets(default 系统集 + 常规集)
|
||||
if n, err := s.media.CountStickerSets(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if n == 0 {
|
||||
if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil {
|
||||
return stats, fmt.Errorf("seed sticker sets: %w", err)
|
||||
}
|
||||
} else if stale, err := s.stickerSetDocumentThumbsNeedInlineCache(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if stale {
|
||||
if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil {
|
||||
return stats, fmt.Errorf("repair sticker set thumbs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if stats.Reactions == 0 && stats.StickerSets == 0 {
|
||||
stats.Skipped = true
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ---- reactions ----
|
||||
|
||||
func (s *Service) seedReactions(ctx context.Context, root string, stats *SeedStats) error {
|
||||
reactionsDir := filepath.Join(root, "telegram_reactions_export", "reactions")
|
||||
rawPath := filepath.Join(root, "telegram_reactions_export", "global_json", "available_reactions_raw.json")
|
||||
raw, err := os.ReadFile(rawPath)
|
||||
if err != nil {
|
||||
return nil // 没有 reaction 资源就跳过
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Reactions []seedReactionJSON `json:"reactions"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("parse available_reactions_raw.json: %w", err)
|
||||
}
|
||||
index, err := scanSeedDir(reactionsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rj := range parsed.Result.Reactions {
|
||||
ar := domain.AvailableReaction{
|
||||
Reaction: rj.Reaction,
|
||||
Title: rj.Title,
|
||||
Inactive: rj.Inactive,
|
||||
Premium: rj.Premium,
|
||||
Order: i,
|
||||
}
|
||||
set := func(dst *int64, d *seedDocumentJSON) error {
|
||||
if d == nil || d.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
doc, err := s.importDocument(ctx, *d, reactionsDir, index, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dst = doc.ID
|
||||
return nil
|
||||
}
|
||||
if err := set(&ar.StaticIconID, rj.StaticIcon); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.AppearAnimationID, rj.AppearAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.SelectAnimationID, rj.SelectAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.ActivateAnimationID, rj.ActivateAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.EffectAnimationID, rj.EffectAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.AroundAnimationID, rj.AroundAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.CenterIconID, rj.CenterIcon); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.media.PutAvailableReaction(ctx, ar); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.Reactions++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) availableReactionSeedNeedsRepair(ctx context.Context) (bool, error) {
|
||||
reactions, err := s.media.ListAvailableReactions(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var docIDs []int64
|
||||
for _, r := range reactions {
|
||||
for _, id := range r.DocumentIDs() {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
docIDs = append(docIDs, id)
|
||||
if _, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", id)); err != nil {
|
||||
return false, err
|
||||
} else if !ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if stale, err := s.documentsNeedInlineCachedThumbs(ctx, docIDs); err != nil || stale {
|
||||
return stale, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ---- sticker sets ----
|
||||
|
||||
func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, stats *SeedStats) error {
|
||||
// default 系统集:目录名 → system_key。
|
||||
defaultDir := filepath.Join(root, "telegram_default_stickers_export")
|
||||
order := 0
|
||||
if entries, err := os.ReadDir(defaultDir); err == nil {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
systemKey := systemKeyForDefaultSet(name)
|
||||
setDir := filepath.Join(defaultDir, name)
|
||||
if err := s.importStickerSetDir(ctx, setDir, systemKey, order, stats); err != nil {
|
||||
return fmt.Errorf("import default set %s: %w", name, err)
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
// 常规贴纸集。
|
||||
regularDir := filepath.Join(root, "telegram_stickers_export")
|
||||
if entries, err := os.ReadDir(regularDir); err == nil {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
imported := 0
|
||||
for _, name := range names {
|
||||
if maxRegular > 0 && imported >= maxRegular {
|
||||
break
|
||||
}
|
||||
setDir := filepath.Join(regularDir, name)
|
||||
if err := s.importStickerSetDir(ctx, setDir, "", order, stats); err != nil {
|
||||
return fmt.Errorf("import sticker set %s: %w", name, err)
|
||||
}
|
||||
order++
|
||||
imported++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, stats *SeedStats) error {
|
||||
infoPath := filepath.Join(setDir, "set_info.json")
|
||||
raw, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return nil // 该目录无 set_info.json → 跳过
|
||||
}
|
||||
var info struct {
|
||||
Result seedStickerSetResultJSON `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", infoPath, err)
|
||||
}
|
||||
sj := info.Result.Set
|
||||
if sj.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
stickersDir := filepath.Join(setDir, "stickers")
|
||||
index, err := scanSeedDir(stickersDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docIDs := make([]int64, 0, len(info.Result.Documents))
|
||||
docs := make([]domain.Document, 0, len(info.Result.Documents))
|
||||
docIDBySource := make(map[int64]int64, len(info.Result.Documents))
|
||||
for _, dj := range info.Result.Documents {
|
||||
sourceID := dj.ID
|
||||
doc, err := s.importDocument(ctx, dj, stickersDir, index, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc.ID != 0 {
|
||||
docIDBySource[sourceID] = doc.ID
|
||||
docIDs = append(docIDs, doc.ID)
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
}
|
||||
|
||||
kind := stickerSetKind(sj, systemKey)
|
||||
set := domain.StickerSet{
|
||||
ID: sj.ID,
|
||||
AccessHash: sj.AccessHash,
|
||||
ShortName: sj.ShortName,
|
||||
Title: sj.Title,
|
||||
Count: sj.Count,
|
||||
Hash: sj.Hash,
|
||||
Kind: kind,
|
||||
Official: sj.Official,
|
||||
Animated: true, // 导出资源均为 .tgs 动画贴纸
|
||||
Emojis: sj.Emojis,
|
||||
Masks: sj.Masks,
|
||||
Archived: sj.Archived,
|
||||
Installed: seedStickerSetInstalled(kind),
|
||||
ThumbDocumentID: seedDocumentStorageID(derefInt64(sj.ThumbDocumentID)),
|
||||
Thumbs: seedStickerSetPhotoSizes(sj.Thumbs),
|
||||
ThumbDCID: s.dc,
|
||||
ThumbVersion: sj.ThumbVersion,
|
||||
DocumentIDs: docIDs,
|
||||
Packs: seedStickerPacks(sj.Packs, info.Result.Packs, docIDBySource),
|
||||
SortOrder: order,
|
||||
SystemKey: systemKey,
|
||||
}
|
||||
if set.Count == 0 {
|
||||
set.Count = len(docIDs)
|
||||
}
|
||||
if err := s.media.PutStickerSet(ctx, set); err != nil {
|
||||
return err
|
||||
}
|
||||
s.stickerSetCache.put(set, docs)
|
||||
stats.StickerSets++
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 单个 document 导入 ----
|
||||
|
||||
func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDir string, index seedDirIndex, stats *SeedStats) (domain.Document, error) {
|
||||
if dj.ID == 0 {
|
||||
return domain.Document{}, nil
|
||||
}
|
||||
storageID := seedDocumentStorageID(dj.ID)
|
||||
ref, _ := hex.DecodeString(dj.FileReference)
|
||||
doc := domain.Document{
|
||||
ID: storageID,
|
||||
AccessHash: dj.AccessHash,
|
||||
FileReference: ref,
|
||||
Date: parseSeedDate(dj.Date),
|
||||
MimeType: dj.MimeType,
|
||||
Size: dj.Size,
|
||||
DCID: s.dc,
|
||||
Attributes: seedDocumentAttributes(dj.Attributes),
|
||||
}
|
||||
|
||||
// 主体 blob:doc:<server-owned-id>
|
||||
if mainPath, ok := index.main[dj.ID]; ok {
|
||||
data, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
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", storageID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: dj.MimeType,
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
}
|
||||
|
||||
// 缩略图:PhotoPathSize 内联;小的 PhotoSize 静态图同时写 blob 并作为
|
||||
// PhotoCachedSize 返回,让 TDesktop 处理 document 元数据时即可填本地 image cache。
|
||||
thumbs := make([]domain.PhotoSize, 0, len(dj.Thumbs))
|
||||
for _, tj := range dj.Thumbs {
|
||||
ps, downloadable := seedPhotoSize(tj)
|
||||
if ps.Kind == "" {
|
||||
continue
|
||||
}
|
||||
if downloadable {
|
||||
thumbPath, ok := index.thumb[dj.ID][ps.Type]
|
||||
if !ok {
|
||||
continue // 无可服务的缩略图文件,丢弃该尺寸(保留 PhotoPathSize 占位)
|
||||
}
|
||||
data, err := os.ReadFile(thumbPath)
|
||||
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", storageID, 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++
|
||||
}
|
||||
thumbs = append(thumbs, ps)
|
||||
}
|
||||
doc.Thumbs = seedPreferRasterDocumentThumbs(thumbs)
|
||||
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
stats.Documents++
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (s *Service) prewarmSmallBlob(objectKey string, data []byte) {
|
||||
if len(data) > 0 && len(data) <= blobBytesCacheMaxEntryBytes {
|
||||
s.byteCache.put(objectKey, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 目录扫描:docID → 主体文件 / 可下载缩略图 ----
|
||||
|
||||
type seedDirIndex struct {
|
||||
main map[int64]string // docID -> 主体文件路径
|
||||
thumb map[int64]map[string]string // docID -> thumbType -> 缩略图文件路径
|
||||
}
|
||||
|
||||
var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`)
|
||||
var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`)
|
||||
|
||||
const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024
|
||||
|
||||
// Exported Telegram resources keep their original id in filenames/JSON, but
|
||||
// telesrv owns the document catalog it serves. Imported high source ids are
|
||||
// normalized once at seed time so RPC/storage use one server-owned id.
|
||||
const seedExternalDocumentIDOffset int64 = 4_000_000_000_000_000_000
|
||||
|
||||
var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`)
|
||||
|
||||
func seedDocumentStorageID(sourceID int64) int64 {
|
||||
if sourceID <= 0 {
|
||||
return 0
|
||||
}
|
||||
if sourceID > seedExternalDocumentIDOffset {
|
||||
return sourceID - seedExternalDocumentIDOffset
|
||||
}
|
||||
return sourceID
|
||||
}
|
||||
|
||||
func scanSeedDir(dir string) (seedDirIndex, error) {
|
||||
idx := seedDirIndex{main: map[int64]string{}, thumb: map[int64]map[string]string{}}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return idx, nil // 目录不存在 → 空 index
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
full := filepath.Join(dir, name)
|
||||
if marker := seedThumbMarker.FindStringIndex(name); marker != nil {
|
||||
// 只收可下载的 PhotoSize 缩略图(jpg);PhotoPathSize(svg) 内联在 JSON。
|
||||
if ext != ".jpg" && ext != ".jpeg" {
|
||||
continue
|
||||
}
|
||||
m := seedThumbType.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
docID := docIDFromName(name[:marker[0]])
|
||||
if docID == 0 {
|
||||
continue
|
||||
}
|
||||
if idx.thumb[docID] == nil {
|
||||
idx.thumb[docID] = map[string]string{}
|
||||
}
|
||||
idx.thumb[docID][m[1]] = full
|
||||
continue
|
||||
}
|
||||
if ext == ".svg" || ext == ".json" {
|
||||
continue
|
||||
}
|
||||
docID := docIDFromName(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||
if docID == 0 {
|
||||
continue
|
||||
}
|
||||
idx.main[docID] = full
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// docIDFromName 取 base name 中末尾最长的数字串作为 document id。
|
||||
func docIDFromName(base string) int64 {
|
||||
matches := seedTrailingDigits.FindAllString(base, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
id, err := strconv.ParseInt(last, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func systemKeyForDefaultSet(dirName string) string {
|
||||
switch dirName {
|
||||
case "DefaultSet_AnimatedEmoji":
|
||||
return "animated_emoji"
|
||||
case "DefaultSet_AnimatedEmojiAnimations":
|
||||
return "animated_emoji_animations"
|
||||
case "DefaultSet_EmojiGenericAnimations":
|
||||
return "emoji_generic_animations"
|
||||
case "DefaultSet_Dice_Normal":
|
||||
return "dice:\U0001f3b2"
|
||||
case "DefaultSet_Dice_Dart":
|
||||
return "dice:\U0001f3af"
|
||||
case "DefaultSet_Dice_Basketball":
|
||||
return "dice:\U0001f3c0"
|
||||
case "DefaultSet_Dice_Football":
|
||||
return "dice:⚽"
|
||||
case "DefaultSet_Dice_Bowling":
|
||||
return "dice:\U0001f3b3"
|
||||
case "DefaultSet_Dice_Casino":
|
||||
return "dice:\U0001f3b0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKind {
|
||||
switch {
|
||||
case systemKey != "":
|
||||
return domain.StickerSetKindSystem
|
||||
case sj.Emojis:
|
||||
return domain.StickerSetKindEmoji
|
||||
case sj.Masks:
|
||||
return domain.StickerSetKindMasks
|
||||
default:
|
||||
return domain.StickerSetKindStickers
|
||||
}
|
||||
}
|
||||
|
||||
func seedStickerSetInstalled(kind domain.StickerSetKind) bool {
|
||||
return kind != domain.StickerSetKindSystem
|
||||
}
|
||||
|
||||
// ---- JSON → domain 转换 ----
|
||||
|
||||
func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute {
|
||||
out := make([]domain.DocumentAttribute, 0, len(attrs))
|
||||
for _, a := range attrs {
|
||||
switch a.Type {
|
||||
case "DocumentAttributeImageSize":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: a.W, H: a.H})
|
||||
case "DocumentAttributeAnimated":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
|
||||
case "DocumentAttributeSticker":
|
||||
attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: a.Alt, Mask: a.Mask}
|
||||
if a.Stickerset != nil {
|
||||
attr.StickerSetID = a.Stickerset.ID
|
||||
attr.StickerSetAccessHash = a.Stickerset.AccessHash
|
||||
}
|
||||
out = append(out, attr)
|
||||
case "DocumentAttributeCustomEmoji":
|
||||
attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: a.Alt, Free: a.Free, TextColor: a.TextColor}
|
||||
if a.Stickerset != nil {
|
||||
attr.StickerSetID = a.Stickerset.ID
|
||||
attr.StickerSetAccessHash = a.Stickerset.AccessHash
|
||||
}
|
||||
out = append(out, attr)
|
||||
case "DocumentAttributeVideo":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming})
|
||||
case "DocumentAttributeAudio":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: int(a.Duration), Voice: a.Voice, Title: a.Title, Performer: a.Performer})
|
||||
case "DocumentAttributeFilename":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: a.FileName})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize {
|
||||
out := make([]domain.PhotoSize, 0, len(thumbs))
|
||||
for _, t := range thumbs {
|
||||
ps, _ := seedPhotoSize(t)
|
||||
if ps.Kind != "" {
|
||||
out = append(out, ps)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedStickerSetPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize {
|
||||
out := make([]domain.PhotoSize, 0, len(thumbs))
|
||||
for _, t := range thumbs {
|
||||
ps, downloadable := seedPhotoSize(t)
|
||||
if ps.Kind == "" || downloadable {
|
||||
continue
|
||||
}
|
||||
out = append(out, ps)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedPhotoSize(t seedThumbJSON) (domain.PhotoSize, bool) {
|
||||
switch t.Type {
|
||||
case "PhotoSize":
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: t.SizeType, W: t.W, H: t.H, Size: t.Size}, true
|
||||
case "PhotoStrippedSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindStripped, Type: t.SizeType, Bytes: b}, false
|
||||
case "PhotoCachedSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindCached, Type: t.SizeType, W: t.W, H: t.H, Bytes: b}, false
|
||||
case "PhotoPathSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindPath, Type: t.SizeType, Bytes: b}, false
|
||||
case "PhotoSizeProgressive":
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindProgressive, Type: t.SizeType, W: t.W, H: t.H, Sizes: t.Sizes}, true
|
||||
default:
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.PhotoSize {
|
||||
if ps.Kind != domain.PhotoSizeKindDefault || len(data) == 0 || len(data) > seedInlineCachedDocumentThumbMaxBytes {
|
||||
return ps
|
||||
}
|
||||
ps.Kind = domain.PhotoSizeKindCached
|
||||
ps.Size = 0
|
||||
ps.Bytes = append([]byte(nil), data...)
|
||||
return ps
|
||||
}
|
||||
|
||||
func seedPreferRasterDocumentThumbs(sizes []domain.PhotoSize) []domain.PhotoSize {
|
||||
if !documentThumbsHaveRaster(sizes) {
|
||||
return sizes
|
||||
}
|
||||
out := sizes[:0]
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath {
|
||||
continue
|
||||
}
|
||||
out = append(out, size)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedThumbMimeType(data []byte) string {
|
||||
switch {
|
||||
case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P':
|
||||
return "image/webp"
|
||||
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "image/jpeg"
|
||||
case len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G':
|
||||
return "image/png"
|
||||
case len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F':
|
||||
return "image/gif"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (bool, error) {
|
||||
var ids []int64
|
||||
for _, kind := range []domain.StickerSetKind{
|
||||
domain.StickerSetKindStickers,
|
||||
domain.StickerSetKindEmoji,
|
||||
domain.StickerSetKindMasks,
|
||||
domain.StickerSetKindSystem,
|
||||
} {
|
||||
sets, err := s.media.ListStickerSets(ctx, kind)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, set := range sets {
|
||||
ids = append(ids, set.DocumentIDs...)
|
||||
}
|
||||
}
|
||||
return s.documentsNeedInlineCachedThumbs(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int64) (bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
unique := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, unique)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if documentThumbsHaveRaster(doc.Thumbs) && documentThumbsHavePath(doc.Thumbs) {
|
||||
return true, nil
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes {
|
||||
return true, nil
|
||||
}
|
||||
if thumb.Kind == domain.PhotoSizeKindCached && len(thumb.Bytes) > 0 {
|
||||
blob, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
want := seedThumbMimeType(thumb.Bytes)
|
||||
if want != "application/octet-stream" && blob.MimeType != want {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func documentThumbsHaveRaster(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
switch size.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
case domain.PhotoSizeKindDefault:
|
||||
if size.Type != "" && size.Size > 0 {
|
||||
return true
|
||||
}
|
||||
case domain.PhotoSizeKindProgressive:
|
||||
if size.Type != "" && len(size.Sizes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func documentThumbsHavePath(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func seedStickerPacks(setPacks, resultPacks []seedStickerPackJSON, docIDBySource map[int64]int64) []domain.StickerPack {
|
||||
packs := setPacks
|
||||
if len(packs) == 0 {
|
||||
packs = resultPacks
|
||||
}
|
||||
out := make([]domain.StickerPack, 0, len(packs))
|
||||
for _, p := range packs {
|
||||
documents := make([]int64, 0, len(p.Documents))
|
||||
for _, sourceID := range p.Documents {
|
||||
if id, ok := docIDBySource[sourceID]; ok {
|
||||
documents = append(documents, id)
|
||||
continue
|
||||
}
|
||||
if id := seedDocumentStorageID(sourceID); id != 0 {
|
||||
documents = append(documents, id)
|
||||
}
|
||||
}
|
||||
out = append(out, domain.StickerPack{Emoticon: p.Emoticon, DocumentIDs: documents})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseSeedDate(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return int(t.Unix())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ---- seed JSON 结构 ----
|
||||
|
||||
type seedInputStickerSetJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
}
|
||||
|
||||
type seedAttrJSON struct {
|
||||
Type string `json:"_"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Alt string `json:"alt"`
|
||||
Mask bool `json:"mask"`
|
||||
Duration float64 `json:"duration"`
|
||||
RoundMessage bool `json:"round_message"`
|
||||
SupportsStreaming bool `json:"supports_streaming"`
|
||||
Voice bool `json:"voice"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
FileName string `json:"file_name"`
|
||||
Free bool `json:"free"`
|
||||
TextColor bool `json:"text_color"`
|
||||
Stickerset *seedInputStickerSetJSON `json:"stickerset"`
|
||||
}
|
||||
|
||||
type seedThumbJSON struct {
|
||||
Type string `json:"_"`
|
||||
SizeType string `json:"type"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Size int `json:"size"`
|
||||
Bytes string `json:"bytes"`
|
||||
Sizes []int `json:"sizes"`
|
||||
}
|
||||
|
||||
type seedDocumentJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
FileReference string `json:"file_reference"`
|
||||
Date string `json:"date"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
DCID int `json:"dc_id"`
|
||||
Attributes []seedAttrJSON `json:"attributes"`
|
||||
Thumbs []seedThumbJSON `json:"thumbs"`
|
||||
}
|
||||
|
||||
type seedStickerPackJSON struct {
|
||||
Emoticon string `json:"emoticon"`
|
||||
Documents []int64 `json:"documents"`
|
||||
}
|
||||
|
||||
type seedStickerSetJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
Title string `json:"title"`
|
||||
ShortName string `json:"short_name"`
|
||||
Count int `json:"count"`
|
||||
Hash int `json:"hash"`
|
||||
Archived bool `json:"archived"`
|
||||
Official bool `json:"official"`
|
||||
Masks bool `json:"masks"`
|
||||
Emojis bool `json:"emojis"`
|
||||
Thumbs []seedThumbJSON `json:"thumbs"`
|
||||
ThumbDCID int `json:"thumb_dc_id"`
|
||||
ThumbVersion int `json:"thumb_version"`
|
||||
ThumbDocumentID *int64 `json:"thumb_document_id"`
|
||||
Packs []seedStickerPackJSON `json:"packs"`
|
||||
}
|
||||
|
||||
type seedStickerSetResultJSON struct {
|
||||
Set seedStickerSetJSON `json:"set"`
|
||||
Packs []seedStickerPackJSON `json:"packs"`
|
||||
Documents []seedDocumentJSON `json:"documents"`
|
||||
}
|
||||
|
||||
type seedReactionJSON struct {
|
||||
Reaction string `json:"reaction"`
|
||||
Title string `json:"title"`
|
||||
Inactive bool `json:"inactive"`
|
||||
Premium bool `json:"premium"`
|
||||
StaticIcon *seedDocumentJSON `json:"static_icon"`
|
||||
AppearAnimation *seedDocumentJSON `json:"appear_animation"`
|
||||
SelectAnimation *seedDocumentJSON `json:"select_animation"`
|
||||
ActivateAnimation *seedDocumentJSON `json:"activate_animation"`
|
||||
EffectAnimation *seedDocumentJSON `json:"effect_animation"`
|
||||
AroundAnimation *seedDocumentJSON `json:"around_animation"`
|
||||
CenterIcon *seedDocumentJSON `json:"center_icon"`
|
||||
}
|
||||
526
internal/app/files/seed_test.go
Normal file
526
internal/app/files/seed_test.go
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。
|
||||
type fakeMediaStore struct {
|
||||
mu sync.Mutex
|
||||
blobs map[string]domain.FileBlob
|
||||
docs map[int64]domain.Document
|
||||
photos map[int64]domain.Photo
|
||||
sets map[int64]domain.StickerSet
|
||||
reactions []domain.AvailableReaction
|
||||
parts map[string][]domain.UploadPart
|
||||
}
|
||||
|
||||
func newFakeMediaStore() *fakeMediaStore {
|
||||
return &fakeMediaStore{
|
||||
blobs: map[string]domain.FileBlob{},
|
||||
docs: map[int64]domain.Document{},
|
||||
photos: map[int64]domain.Photo{},
|
||||
sets: map[int64]domain.StickerSet{},
|
||||
parts: map[string][]domain.UploadPart{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeMediaStore) SaveFilePart(_ context.Context, _ domain.UploadPart) error { return nil }
|
||||
func (f *fakeMediaStore) LoadFileParts(_ context.Context, _, _ int64) ([]domain.UploadPart, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteFileParts(_ context.Context, _, _ int64) error { return nil }
|
||||
|
||||
func (f *fakeMediaStore) PutFileBlob(_ context.Context, blob domain.FileBlob) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.blobs[blob.LocationKey] = blob
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetFileBlob(_ context.Context, key string) (domain.FileBlob, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b, ok := f.blobs[key]
|
||||
return b, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeMediaStore) PutDocument(_ context.Context, doc domain.Document) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.docs[doc.ID] = doc
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
d, ok := f.docs[id]
|
||||
return d, ok, nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]domain.Document, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if d, ok := f.docs[id]; ok {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeMediaStore) PutPhoto(_ context.Context, p domain.Photo) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.photos[p.ID] = p
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
p, ok := f.photos[id]
|
||||
return p, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.sets[set.ID] = set
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sets[id]
|
||||
return s, ok, nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sets {
|
||||
if s.ShortName == name {
|
||||
return s, true, nil
|
||||
}
|
||||
}
|
||||
return domain.StickerSet{}, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetBySystemKey(_ context.Context, key string) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sets {
|
||||
if s.SystemKey == key {
|
||||
return s, true, nil
|
||||
}
|
||||
}
|
||||
return domain.StickerSet{}, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []domain.StickerSet
|
||||
for _, s := range f.sets {
|
||||
if s.Kind == kind {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.sets), nil
|
||||
}
|
||||
func (f *fakeMediaStore) PutAvailableReaction(_ context.Context, r domain.AvailableReaction) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, existing := range f.reactions {
|
||||
if existing.Reaction == r.Reaction {
|
||||
f.reactions[i] = r
|
||||
return nil
|
||||
}
|
||||
}
|
||||
f.reactions = append(f.reactions, r)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListAvailableReactions(_ context.Context) ([]domain.AvailableReaction, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]domain.AvailableReaction(nil), f.reactions...), nil
|
||||
}
|
||||
func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.reactions), nil
|
||||
}
|
||||
func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) {
|
||||
return 0, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||||
seedDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(seedDir, "telegram_reactions_export", "global_json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reactionsDir := filepath.Join(seedDir, "telegram_reactions_export", "reactions")
|
||||
if err := os.MkdirAll(reactionsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := `{"result":{"reactions":[{"reaction":"👍","title":"Like","static_icon":{"id":1111111,"access_hash":1,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"image/webp","size":4,"attributes":[],"thumbs":[]},"select_animation":{"id":2222222,"access_hash":2,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"attributes":[],"thumbs":[]}}]}}`
|
||||
if err := os.WriteFile(filepath.Join(seedDir, "telegram_reactions_export", "global_json", "available_reactions_raw.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111.webp"), []byte("webp"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111_thumb1_PhotoSize_types_72x72.jpg"), []byte("jpeg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_select_2222222.tgs"), []byte("tgs!"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
media := newFakeMediaStore()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(media, blobs, 2)
|
||||
if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil {
|
||||
t.Fatalf("initial seed: %v", err)
|
||||
} else if stats.Reactions != 1 || stats.Blobs != 2 {
|
||||
t.Fatalf("initial stats = %+v, want one reaction and two blobs", stats)
|
||||
}
|
||||
chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("prewarmed getfile ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(chunk.Bytes) != "tgs!" {
|
||||
t.Fatalf("prewarmed chunk = %q, want tgs!", chunk.Bytes)
|
||||
}
|
||||
if blobs.getRangeCalls != 0 {
|
||||
t.Fatalf("seeded small blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls)
|
||||
}
|
||||
|
||||
media.mu.Lock()
|
||||
delete(media.blobs, "doc:2222222")
|
||||
media.mu.Unlock()
|
||||
|
||||
stats, err := svc.SeedMedia(context.Background(), seedDir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("repair seed: %v", err)
|
||||
}
|
||||
if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
|
||||
t.Fatalf("repair stats = %+v, want repair import", stats)
|
||||
}
|
||||
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
|
||||
t.Fatal("missing reaction blob was not repaired")
|
||||
}
|
||||
if reactions, _ := media.ListAvailableReactions(context.Background()); len(reactions) != 1 {
|
||||
t.Fatalf("reaction upsert duplicated rows: got %d", len(reactions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMediaFromRealExport(t *testing.T) {
|
||||
seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR")
|
||||
if seedDir == "" {
|
||||
t.Skip("TELESRV_REAL_STICKER_SEED_DIR not set")
|
||||
}
|
||||
if _, err := os.Stat(seedDir); err != nil {
|
||||
t.Skipf("seed dir %s not present: %v", seedDir, err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 2)
|
||||
stats, err := svc.SeedMedia(context.Background(), seedDir, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("seed media: %v", err)
|
||||
}
|
||||
t.Logf("seed stats: reactions=%d sets=%d docs=%d blobs=%d", stats.Reactions, stats.StickerSets, stats.Documents, stats.Blobs)
|
||||
if stats.Reactions == 0 {
|
||||
t.Error("expected reactions imported")
|
||||
}
|
||||
if stats.StickerSets == 0 {
|
||||
t.Error("expected sticker sets imported")
|
||||
}
|
||||
if stats.Documents == 0 {
|
||||
t.Error("expected documents imported")
|
||||
}
|
||||
if stats.Blobs == 0 {
|
||||
t.Error("expected blobs imported")
|
||||
}
|
||||
|
||||
// reaction 引用的文档应能被解析回真实 document(带 sticker 属性 + 主体 blob)。
|
||||
reactions, _ := media.ListAvailableReactions(context.Background())
|
||||
if len(reactions) == 0 {
|
||||
t.Fatal("no reactions stored")
|
||||
}
|
||||
first := reactions[0]
|
||||
if first.Reaction == "" {
|
||||
t.Error("reaction emoticon empty")
|
||||
}
|
||||
if first.StaticIconID == 0 || first.SelectAnimationID == 0 {
|
||||
t.Error("reaction missing document ids")
|
||||
}
|
||||
if d, ok, _ := media.GetDocument(context.Background(), first.SelectAnimationID); !ok {
|
||||
t.Error("reaction select animation document missing")
|
||||
} else {
|
||||
if d.ID > seedExternalDocumentIDOffset {
|
||||
t.Errorf("reaction document kept external source id: %d", d.ID)
|
||||
}
|
||||
if d.DCID != 2 {
|
||||
t.Errorf("document dc_id not rewritten: %d", d.DCID)
|
||||
}
|
||||
if _, ok, _ := media.GetFileBlob(context.Background(), blobKeyDoc(d.ID)); !ok {
|
||||
t.Errorf("reaction document %d main blob missing", d.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 一个常规贴纸集应有 documents 且能按 short_name 解析。
|
||||
for _, s := range media.sets {
|
||||
for _, thumb := range s.Thumbs {
|
||||
if thumb.Downloadable() {
|
||||
t.Fatalf("sticker set %s exposes downloadable cover thumb %q without a serviceable blob", s.ShortName, thumb.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sample domain.StickerSet
|
||||
for _, s := range media.sets {
|
||||
if s.Kind == domain.StickerSetKindStickers && len(s.DocumentIDs) > 0 {
|
||||
sample = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if sample.ID == 0 {
|
||||
t.Fatal("no regular sticker set with documents imported")
|
||||
}
|
||||
if got, ok, _ := media.GetStickerSetByShortName(context.Background(), sample.ShortName); !ok || got.ID != sample.ID {
|
||||
t.Error("sticker set not resolvable by short name")
|
||||
}
|
||||
if doc, ok, _ := media.GetDocument(context.Background(), sample.DocumentIDs[0]); !ok {
|
||||
t.Fatalf("sample sticker document %d missing", sample.DocumentIDs[0])
|
||||
} else {
|
||||
if doc.ID > seedExternalDocumentIDOffset {
|
||||
t.Fatalf("sample sticker kept external source id: %d", doc.ID)
|
||||
}
|
||||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||||
if !ok {
|
||||
t.Fatalf("sample sticker document thumbs are not inline cached: %+v", doc.Thumbs)
|
||||
}
|
||||
blob, ok, err := media.GetFileBlob(context.Background(), blobKeyDoc(doc.ID)+":"+thumb.Type)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("sample sticker thumb blob ok=%v err=%v", ok, err)
|
||||
}
|
||||
if want := seedThumbMimeType(thumb.Bytes); blob.MimeType != want {
|
||||
t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want)
|
||||
}
|
||||
if hasPathThumb(doc.Thumbs) {
|
||||
t.Fatalf("sample sticker document still exposes path thumb together with raster: %+v", doc.Thumbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) {
|
||||
const sourceID int64 = 5382305375846410902
|
||||
const want int64 = 1382305375846410902
|
||||
if got := seedDocumentStorageID(sourceID); got != want {
|
||||
t.Fatalf("seedDocumentStorageID(%d) = %d, want %d", sourceID, got, want)
|
||||
}
|
||||
if got := seedDocumentStorageID(2222222); got != 2222222 {
|
||||
t.Fatalf("small server id changed: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind domain.StickerSetKind
|
||||
want bool
|
||||
}{
|
||||
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: true},
|
||||
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true},
|
||||
{name: "masks", kind: domain.StickerSetKindMasks, want: true},
|
||||
{name: "system resources", kind: domain.StickerSetKindSystem, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := seedStickerSetInstalled(tc.kind); got != tc.want {
|
||||
t.Fatalf("seedStickerSetInstalled(%q) = %v, want %v", tc.kind, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedInlineCachedDocumentThumb(t *testing.T) {
|
||||
input := domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 6400}
|
||||
got := seedInlineCachedDocumentThumb(input, []byte("jpeg"))
|
||||
if got.Kind != domain.PhotoSizeKindCached {
|
||||
t.Fatalf("kind = %q, want cached", got.Kind)
|
||||
}
|
||||
if got.Size != 0 || string(got.Bytes) != "jpeg" {
|
||||
t.Fatalf("cached thumb = %+v, want inline bytes without downloadable size", got)
|
||||
}
|
||||
large := make([]byte, seedInlineCachedDocumentThumbMaxBytes+1)
|
||||
if got := seedInlineCachedDocumentThumb(input, large); got.Kind != domain.PhotoSizeKindDefault || got.Size != input.Size || len(got.Bytes) != 0 {
|
||||
t.Fatalf("large thumb = %+v, want unchanged downloadable thumb", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedThumbMimeType(t *testing.T) {
|
||||
webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}
|
||||
if got := seedThumbMimeType(webp); got != "image/webp" {
|
||||
t.Fatalf("webp mime = %q, want image/webp", got)
|
||||
}
|
||||
jpeg := []byte{0xFF, 0xD8, 0xFF}
|
||||
if got := seedThumbMimeType(jpeg); got != "image/jpeg" {
|
||||
t.Fatalf("jpeg mime = %q, want image/jpeg", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedPreferRasterDocumentThumbsDropsPathWhenRasterExists(t *testing.T) {
|
||||
sizes := []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")},
|
||||
{Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")},
|
||||
}
|
||||
got := seedPreferRasterDocumentThumbs(sizes)
|
||||
if hasPathThumb(got) {
|
||||
t.Fatalf("path thumb should be dropped when raster exists: %+v", got)
|
||||
}
|
||||
if !hasCachedThumb(got) {
|
||||
t.Fatalf("cached thumb should be kept: %+v", got)
|
||||
}
|
||||
|
||||
onlyPath := []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}}
|
||||
if got := seedPreferRasterDocumentThumbs(onlyPath); !hasPathThumb(got) {
|
||||
t.Fatalf("path-only thumbs should be kept: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}
|
||||
doc := domain.Document{
|
||||
ID: 100,
|
||||
Thumbs: []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: webp},
|
||||
},
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
svc := NewService(media, nil, 2)
|
||||
stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("documentsNeedInlineCachedThumbs: %v", err)
|
||||
}
|
||||
if !stale {
|
||||
t.Fatal("expected stale mime to require repair")
|
||||
}
|
||||
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/webp"}); err != nil {
|
||||
t.Fatalf("put repaired blob: %v", err)
|
||||
}
|
||||
stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("documentsNeedInlineCachedThumbs after repair: %v", err)
|
||||
}
|
||||
if stale {
|
||||
t.Fatal("repaired mime should not require repair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentsNeedInlineCachedThumbsDetectsPathWithRaster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
doc := domain.Document{
|
||||
ID: 100,
|
||||
Thumbs: []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")},
|
||||
{Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")},
|
||||
},
|
||||
}
|
||||
if err := media.PutDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
svc := NewService(media, nil, 2)
|
||||
stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("documentsNeedInlineCachedThumbs: %v", err)
|
||||
}
|
||||
if !stale {
|
||||
t.Fatal("path thumb with raster should require repair")
|
||||
}
|
||||
}
|
||||
|
||||
func hasCachedThumb(sizes []domain.PhotoSize) bool {
|
||||
_, ok := findCachedThumb(sizes)
|
||||
return ok
|
||||
}
|
||||
|
||||
func findCachedThumb(sizes []domain.PhotoSize) (domain.PhotoSize, bool) {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindCached && len(size.Bytes) > 0 {
|
||||
return size, true
|
||||
}
|
||||
}
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
|
||||
func hasPathThumb(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func blobKeyDoc(id int64) string {
|
||||
return "doc:" + itoa(id)
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
256
internal/app/files/service.go
Normal file
256
internal/app/files/service.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// 上传分片上限:与 Telegram 客户端约定一致(单片 ≤512KB;分片总数有上限防止 OOM)。
|
||||
const (
|
||||
MaxUploadPartBytes = 524288 // 512KB
|
||||
MaxUploadParts = 8000 // 512KB * 8000 ≈ 4GB 理论上限,足够主路径媒体
|
||||
)
|
||||
|
||||
// blobMetaCacheCapacity 是 location_key→FileBlob 元数据 LRU 容量(每项约百字节,约 13MB)。
|
||||
const blobMetaCacheCapacity = 1 << 16
|
||||
|
||||
// 小文件热缓存只覆盖 sticker/reaction/thumbnail 一类不可变小 blob;大媒体继续分段读。
|
||||
const (
|
||||
blobBytesCacheMaxEntryBytes = 256 << 10 // 256KB
|
||||
blobBytesCacheMaxBytes = 64 << 20 // 64MB
|
||||
)
|
||||
|
||||
// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。
|
||||
type Service struct {
|
||||
media store.MediaStore
|
||||
blobs BlobBackend
|
||||
dc int
|
||||
blobCache *blobMetaCache
|
||||
byteCache *blobBytesCache
|
||||
stickerSetCache *stickerSetFullCache
|
||||
}
|
||||
|
||||
// NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。
|
||||
func NewService(media store.MediaStore, blobs BlobBackend, dc int) *Service {
|
||||
return &Service{
|
||||
media: media,
|
||||
blobs: blobs,
|
||||
dc: dc,
|
||||
blobCache: newBlobMetaCache(blobMetaCacheCapacity),
|
||||
byteCache: newBlobBytesCache(blobBytesCacheMaxBytes),
|
||||
stickerSetCache: newStickerSetFullCache(),
|
||||
}
|
||||
}
|
||||
|
||||
// SaveFilePart 累积一个 small file 分片。
|
||||
func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) {
|
||||
if err := validatePart(part, len(bytes)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
|
||||
OwnerUserID: ownerUserID,
|
||||
FileID: fileID,
|
||||
Part: part,
|
||||
Bytes: bytes,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SaveBigFilePart 累积一个 big file 分片(带已知总分片数)。
|
||||
func (s *Service) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error) {
|
||||
if err := validatePart(part, len(bytes)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if totalParts <= 0 || totalParts > MaxUploadParts {
|
||||
return false, domain.ErrFilePartsInvalid
|
||||
}
|
||||
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
|
||||
OwnerUserID: ownerUserID,
|
||||
FileID: fileID,
|
||||
Part: part,
|
||||
TotalParts: totalParts,
|
||||
Big: true,
|
||||
Bytes: bytes,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetFile 按 location_key 取一段 blob 内容。found=false 表示该 location 无对应 blob。
|
||||
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
|
||||
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
|
||||
func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
|
||||
blob, ok := s.blobCache.get(req.LocationKey)
|
||||
if !ok {
|
||||
var (
|
||||
found bool
|
||||
err error
|
||||
)
|
||||
blob, found, err = s.media.GetFileBlob(ctx, req.LocationKey)
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.FileChunk{}, false, nil
|
||||
}
|
||||
s.blobCache.put(req.LocationKey, blob)
|
||||
}
|
||||
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
|
||||
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
|
||||
return domain.FileChunk{
|
||||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||||
MimeType: blob.MimeType,
|
||||
Total: int64(len(data)),
|
||||
}, true, nil
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||
}
|
||||
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
||||
s.byteCache.put(blob.ObjectKey, data)
|
||||
return domain.FileChunk{
|
||||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||||
MimeType: blob.MimeType,
|
||||
Total: total,
|
||||
}, true, nil
|
||||
}
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||
}
|
||||
return domain.FileChunk{
|
||||
Bytes: data,
|
||||
MimeType: blob.MimeType,
|
||||
Total: total,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func sliceBlobBytes(data []byte, offset, limit int64) []byte {
|
||||
total := int64(len(data))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= total {
|
||||
return []byte{}
|
||||
}
|
||||
end := total
|
||||
if limit > 0 && offset+limit < end {
|
||||
end = offset + limit
|
||||
}
|
||||
return append([]byte(nil), data[offset:end]...)
|
||||
}
|
||||
|
||||
// ---- 资源读取(reaction / sticker / document)----
|
||||
|
||||
// ListAvailableReactions 返回可用 reaction 目录(带真实文档 id)。
|
||||
func (s *Service) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) {
|
||||
return s.media.ListAvailableReactions(ctx)
|
||||
}
|
||||
|
||||
// GetDocuments 按 id 批量加载文档(自定义 emoji / 贴纸)。
|
||||
func (s *Service) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) {
|
||||
return s.media.GetDocuments(ctx, ids)
|
||||
}
|
||||
|
||||
// ListStickerSets 列出某类贴纸集(用于 getAllStickers 等)。
|
||||
func (s *Service) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
|
||||
return s.media.ListStickerSets(ctx, kind)
|
||||
}
|
||||
|
||||
// ResolveStickerSet 按 ref 解析贴纸集,并按 DocumentIDs 顺序加载其文档。
|
||||
func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) {
|
||||
if set, docs, ok := s.stickerSetCache.get(ref); ok {
|
||||
return set, docs, true, nil
|
||||
}
|
||||
var (
|
||||
set domain.StickerSet
|
||||
found bool
|
||||
err error
|
||||
)
|
||||
switch ref.Kind {
|
||||
case domain.StickerSetRefByID:
|
||||
set, found, err = s.media.GetStickerSetByID(ctx, ref.ID)
|
||||
case domain.StickerSetRefByShortName:
|
||||
set, found, err = s.media.GetStickerSetByShortName(ctx, ref.ShortName)
|
||||
case domain.StickerSetRefBySystem:
|
||||
set, found, err = s.media.GetStickerSetBySystemKey(ctx, ref.SystemKey)
|
||||
default:
|
||||
return domain.StickerSet{}, nil, false, nil
|
||||
}
|
||||
if err != nil || !found {
|
||||
return domain.StickerSet{}, nil, found, err
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
|
||||
if err != nil {
|
||||
return domain.StickerSet{}, nil, false, err
|
||||
}
|
||||
ordered := orderDocuments(docs, set.DocumentIDs)
|
||||
s.stickerSetCache.put(set, ordered)
|
||||
return set, ordered, true, nil
|
||||
}
|
||||
|
||||
// orderDocuments 把无序的文档按 ids 顺序重排(GetDocuments 用 ANY 查询不保证顺序)。
|
||||
func orderDocuments(docs []domain.Document, ids []int64) []domain.Document {
|
||||
byID := make(map[int64]domain.Document, len(docs))
|
||||
for _, d := range docs {
|
||||
byID[d.ID] = d
|
||||
}
|
||||
out := make([]domain.Document, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if d, ok := byID[id]; ok {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assembleUpload 把已上传分片按 part 顺序拼成完整字节,并清理分片。
|
||||
// expectedParts>0 时校验分片连续且齐全。
|
||||
func (s *Service) assembleUpload(ctx context.Context, ownerUserID, fileID int64, expectedParts int) ([]byte, error) {
|
||||
parts, err := s.media.LoadFileParts(ctx, ownerUserID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil, domain.ErrFilePartsInvalid
|
||||
}
|
||||
if expectedParts > 0 && len(parts) != expectedParts {
|
||||
return nil, domain.ErrFilePartsInvalid
|
||||
}
|
||||
total := 0
|
||||
for i, p := range parts {
|
||||
if p.Part != i {
|
||||
return nil, domain.ErrFilePartsInvalid // 缺片或乱序
|
||||
}
|
||||
total += len(p.Bytes)
|
||||
}
|
||||
buf := make([]byte, 0, total)
|
||||
for _, p := range parts {
|
||||
buf = append(buf, p.Bytes...)
|
||||
}
|
||||
if err := s.media.DeleteFileParts(ctx, ownerUserID, fileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func validatePart(part, size int) error {
|
||||
if part < 0 || part >= MaxUploadParts {
|
||||
return domain.ErrFilePartInvalid
|
||||
}
|
||||
if size == 0 {
|
||||
return domain.ErrFilePartInvalid
|
||||
}
|
||||
if size > MaxUploadPartBytes {
|
||||
return domain.ErrFilePartTooBig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
136
internal/app/files/warm.go
Normal file
136
internal/app/files/warm.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// WarmStats 汇报一次启动资源缓存预热结果。
|
||||
type WarmStats struct {
|
||||
StickerSets int
|
||||
Documents int
|
||||
Blobs int
|
||||
}
|
||||
|
||||
// WarmCaches 从已持久化的 sticker/reaction 元数据预热小 blob 字节缓存与完整 sticker set 缓存。
|
||||
// SeedMedia 在已有数据时会跳过导入;该方法保证普通 server 重启后历史 sticker 首次渲染也不是冷缓存。
|
||||
func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) {
|
||||
var stats WarmStats
|
||||
seenDocs := make(map[int64]struct{})
|
||||
for _, kind := range []domain.StickerSetKind{
|
||||
domain.StickerSetKindStickers,
|
||||
domain.StickerSetKindEmoji,
|
||||
domain.StickerSetKindMasks,
|
||||
domain.StickerSetKindSystem,
|
||||
} {
|
||||
sets, err := s.media.ListStickerSets(ctx, kind)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
for _, set := range sets {
|
||||
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
ordered := orderDocuments(docs, set.DocumentIDs)
|
||||
s.stickerSetCache.put(set, ordered)
|
||||
stats.StickerSets++
|
||||
for _, doc := range ordered {
|
||||
if _, ok := seenDocs[doc.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenDocs[doc.ID] = struct{}{}
|
||||
stats.Documents++
|
||||
warmed, err := s.prewarmDocumentBlobs(ctx, doc)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Blobs += warmed
|
||||
}
|
||||
}
|
||||
}
|
||||
reactions, err := s.media.ListAvailableReactions(ctx)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
reactionIDs := make([]int64, 0, len(reactions)*4)
|
||||
for _, reaction := range reactions {
|
||||
reactionIDs = append(reactionIDs, reaction.DocumentIDs()...)
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, reactionIDs)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if _, ok := seenDocs[doc.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenDocs[doc.ID] = struct{}{}
|
||||
stats.Documents++
|
||||
warmed, err := s.prewarmDocumentBlobs(ctx, doc)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Blobs += warmed
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Service) prewarmDocumentBlobs(ctx context.Context, doc domain.Document) (int, error) {
|
||||
if doc.ID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
warmed := 0
|
||||
ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d", doc.ID))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ok {
|
||||
warmed++
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
if !thumb.Downloadable() {
|
||||
continue
|
||||
}
|
||||
ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ok {
|
||||
warmed++
|
||||
}
|
||||
}
|
||||
return warmed, nil
|
||||
}
|
||||
|
||||
func (s *Service) prewarmLocationKey(ctx context.Context, locationKey string) (bool, error) {
|
||||
blob, ok := s.blobCache.get(locationKey)
|
||||
if !ok {
|
||||
var (
|
||||
found bool
|
||||
err error
|
||||
)
|
||||
blob, found, err = s.media.GetFileBlob(ctx, locationKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
s.blobCache.put(locationKey, blob)
|
||||
}
|
||||
if blob.Size <= 0 || blob.Size > blobBytesCacheMaxEntryBytes || s.byteCache.has(blob.ObjectKey) {
|
||||
return false, nil
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||
}
|
||||
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
||||
s.byteCache.put(blob.ObjectKey, data)
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
2
internal/app/help/doc.go
Normal file
2
internal/app/help/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package help 提供 help.* RPC 背后的数据目录服务。
|
||||
package help
|
||||
76
internal/app/help/service.go
Normal file
76
internal/app/help/service.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package help
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const tdesktopClient = "tdesktop"
|
||||
const tdesktopDefaultAppConfig = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_in_chat_max":3}`
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
type Service struct {
|
||||
appConfigs store.AppConfigStore
|
||||
countries store.CountryStore
|
||||
}
|
||||
|
||||
// NewService 创建 help 服务。
|
||||
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore) *Service {
|
||||
return &Service{appConfigs: appConfigs, countries: countries}
|
||||
}
|
||||
|
||||
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。
|
||||
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
|
||||
if s == nil || s.appConfigs == nil {
|
||||
cfg := domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)}
|
||||
return cfg, hash == cfg.Hash, nil
|
||||
}
|
||||
cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient)
|
||||
if err != nil {
|
||||
return domain.AppConfig{}, false, err
|
||||
}
|
||||
if !found {
|
||||
cfg = domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)}
|
||||
}
|
||||
return cfg, hash != 0 && hash == cfg.Hash, nil
|
||||
}
|
||||
|
||||
// GetCountries 返回国家区号目录,hash 命中时返回 notModified。
|
||||
func (s *Service) GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error) {
|
||||
if s == nil || s.countries == nil {
|
||||
list := defaultCountries()
|
||||
return list, hash != 0 && hash == list.Hash, nil
|
||||
}
|
||||
list, err := s.countries.ListCountries(ctx, langCode)
|
||||
if err != nil {
|
||||
return domain.CountriesList{}, false, err
|
||||
}
|
||||
if len(list.Countries) == 0 {
|
||||
list = defaultCountries()
|
||||
}
|
||||
return list, hash != 0 && hash == list.Hash, nil
|
||||
}
|
||||
|
||||
func defaultCountries() domain.CountriesList {
|
||||
return domain.CountriesList{
|
||||
Hash: 1,
|
||||
Countries: []domain.Country{
|
||||
{
|
||||
ISO2: "US",
|
||||
DefaultName: "United States",
|
||||
CountryCodes: []domain.CountryCode{
|
||||
{CountryCode: "1", Prefixes: []string{"1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
ISO2: "CN",
|
||||
DefaultName: "China",
|
||||
CountryCodes: []domain.CountryCode{
|
||||
{CountryCode: "86", Prefixes: []string{"86"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
109
internal/app/langpack/parser.go
Normal file
109
internal/app/langpack/parser.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`)
|
||||
|
||||
// ParseTDesktopFile 解析 TDesktop .strings 文件为 domain 语言包。
|
||||
func ParseTDesktopFile(path string) (domain.LangPack, error) {
|
||||
pack, err := packFromFilename(path)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, fmt.Errorf("read tdesktop langpack %q: %w", path, err)
|
||||
}
|
||||
|
||||
plain := make([]domain.LangPackString, 0)
|
||||
plurals := make(map[string]*domain.LangPackString)
|
||||
pluralOrder := make([]string, 0)
|
||||
for _, match := range tdesktopStringRE.FindAllStringSubmatch(string(data), -1) {
|
||||
key := unquoteTDesktop(match[1])
|
||||
value := unquoteTDesktop(match[2])
|
||||
base, plural := splitPluralKey(key)
|
||||
if plural == "" {
|
||||
plain = append(plain, domain.LangPackString{Key: key, Value: value})
|
||||
continue
|
||||
}
|
||||
item, ok := plurals[base]
|
||||
if !ok {
|
||||
plurals[base] = &domain.LangPackString{Key: base, Pluralized: true}
|
||||
item = plurals[base]
|
||||
pluralOrder = append(pluralOrder, base)
|
||||
}
|
||||
setPluralValue(item, plural, value)
|
||||
}
|
||||
|
||||
pack.Strings = make([]domain.LangPackString, 0, len(plain)+len(pluralOrder))
|
||||
pack.Strings = append(pack.Strings, plain...)
|
||||
for _, key := range pluralOrder {
|
||||
pack.Strings = append(pack.Strings, *plurals[key])
|
||||
}
|
||||
return pack, nil
|
||||
}
|
||||
|
||||
func packFromFilename(path string) (domain.LangPack, error) {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
const prefix = "tdesktop_"
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path))
|
||||
}
|
||||
rest := strings.TrimPrefix(name, prefix)
|
||||
idx := strings.LastIndex(rest, "_v")
|
||||
if idx <= 0 || idx+2 >= len(rest) {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path))
|
||||
}
|
||||
version, err := strconv.Atoi(rest[idx+2:])
|
||||
if err != nil {
|
||||
return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", rest[idx+2:], err)
|
||||
}
|
||||
return domain.LangPack{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: rest[:idx],
|
||||
Version: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func splitPluralKey(key string) (base, plural string) {
|
||||
for _, suffix := range []string{"#zero", "#one", "#two", "#few", "#many", "#other"} {
|
||||
if strings.HasSuffix(key, suffix) {
|
||||
return strings.TrimSuffix(key, suffix), strings.TrimPrefix(suffix, "#")
|
||||
}
|
||||
}
|
||||
return key, ""
|
||||
}
|
||||
|
||||
func setPluralValue(item *domain.LangPackString, plural, value string) {
|
||||
switch plural {
|
||||
case "zero":
|
||||
item.ZeroValue = value
|
||||
case "one":
|
||||
item.OneValue = value
|
||||
case "two":
|
||||
item.TwoValue = value
|
||||
case "few":
|
||||
item.FewValue = value
|
||||
case "many":
|
||||
item.ManyValue = value
|
||||
case "other":
|
||||
item.OtherValue = value
|
||||
}
|
||||
}
|
||||
|
||||
func unquoteTDesktop(s string) string {
|
||||
v, err := strconv.Unquote(`"` + s + `"`)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return v
|
||||
}
|
||||
37
internal/app/langpack/parser_test.go
Normal file
37
internal/app/langpack/parser_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTDesktopFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tdesktop_en_v42.strings")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
"lng_plain" = "Plain value";
|
||||
"lng_escape" = "Line\nTwo";
|
||||
"lng_items#one" = "{count} item";
|
||||
"lng_items#other" = "{count} items";
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
pack, err := ParseTDesktopFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if pack.LangPack != "tdesktop" || pack.LangCode != "en" || pack.Version != 42 {
|
||||
t.Fatalf("pack meta = %+v", pack)
|
||||
}
|
||||
if len(pack.Strings) != 3 {
|
||||
t.Fatalf("strings count = %d, want 3", len(pack.Strings))
|
||||
}
|
||||
if got := pack.Strings[1].Value; got != "Line\nTwo" {
|
||||
t.Fatalf("escape value = %q", got)
|
||||
}
|
||||
plural := pack.Strings[2]
|
||||
if !plural.Pluralized || plural.Key != "lng_items" || plural.OneValue == "" || plural.OtherValue == "" {
|
||||
t.Fatalf("plural string = %+v", plural)
|
||||
}
|
||||
}
|
||||
56
internal/app/langpack/seed.go
Normal file
56
internal/app/langpack/seed.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 == "" {
|
||||
return 0, nil
|
||||
}
|
||||
dir := filepath.Clean(root)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("stat langpack seed dir: %w", err)
|
||||
}
|
||||
tdesktopDir := filepath.Join(dir, "tdesktop")
|
||||
if info, err := os.Stat(tdesktopDir); err == nil && info.IsDir() {
|
||||
dir = tdesktopDir
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read langpack seed dir: %w", err)
|
||||
}
|
||||
seeded := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
|
||||
continue
|
||||
}
|
||||
pack, err := ParseTDesktopFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
return seeded, err
|
||||
}
|
||||
existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version)
|
||||
if err != nil {
|
||||
return seeded, err
|
||||
}
|
||||
if existing.Version >= pack.Version {
|
||||
continue
|
||||
}
|
||||
if err := s.packs.UpsertPack(ctx, pack); err != nil {
|
||||
return seeded, err
|
||||
}
|
||||
seeded += len(pack.Strings)
|
||||
}
|
||||
return seeded, nil
|
||||
}
|
||||
53
internal/app/langpack/service.go
Normal file
53
internal/app/langpack/service.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供客户端语言包查询。
|
||||
type Service struct {
|
||||
packs store.LangPackStore
|
||||
}
|
||||
|
||||
// NewService 创建 langpack 服务。
|
||||
func NewService(packs store.LangPackStore) *Service {
|
||||
return &Service{packs: packs}
|
||||
}
|
||||
|
||||
// GetLangPack 返回完整语言包。
|
||||
func (s *Service) GetLangPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) {
|
||||
return s.GetDifference(ctx, langPack, langCode, 0)
|
||||
}
|
||||
|
||||
// GetDifference 返回从 fromVersion 到当前版本的语言包差异。
|
||||
func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
|
||||
if s == nil || s.packs == nil {
|
||||
return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil
|
||||
}
|
||||
return s.packs.GetPack(ctx, normalizePack(langPack), normalizeCode(langCode), fromVersion)
|
||||
}
|
||||
|
||||
// GetStrings 返回指定 key 的语言包字符串。
|
||||
func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
|
||||
if s == nil || s.packs == nil {
|
||||
return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil
|
||||
}
|
||||
return s.packs.GetStrings(ctx, normalizePack(langPack), normalizeCode(langCode), keys)
|
||||
}
|
||||
|
||||
func normalizePack(langPack string) string {
|
||||
if langPack == "" {
|
||||
return "tdesktop"
|
||||
}
|
||||
return langPack
|
||||
}
|
||||
|
||||
func normalizeCode(langCode string) string {
|
||||
if langCode == "" {
|
||||
return "en"
|
||||
}
|
||||
return langCode
|
||||
}
|
||||
74
internal/app/maintenance/retention.go
Normal file
74
internal/app/maintenance/retention.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DispatchOutboxRetentionStore 清理彻底失败(已放弃重试)的 outbox 死任务。
|
||||
type DispatchOutboxRetentionStore interface {
|
||||
DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error)
|
||||
}
|
||||
|
||||
// RetentionWorker 周期性回收存储中的死数据。
|
||||
//
|
||||
// 注意:本 worker 刻意不清理 user_update_events —— pts log 永久保留。原因:TDesktop 不支持
|
||||
// 账号级 updates.differenceTooLong(api_updates.cpp 收到该响应只打一行日志,且漏掉
|
||||
// setRequesting(false),会永久锁死整个 update 引擎),服务端因此无法让"落后超过保留期"的
|
||||
// 客户端整库重置;一旦裁剪 events,落后客户端的 getDifference 会拿到不完整的事件链而静默
|
||||
// 丢消息。详见 docs/performance-audit.md 与 docs/compatibility-matrix.md。user_update_events
|
||||
// 长期膨胀作为已知 todo。
|
||||
type RetentionWorker struct {
|
||||
outbox DispatchOutboxRetentionStore
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if retention <= 0 {
|
||||
retention = 168 * time.Hour
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = 10000
|
||||
}
|
||||
return &RetentionWorker{
|
||||
outbox: outbox,
|
||||
logger: logger,
|
||||
retention: retention,
|
||||
interval: interval,
|
||||
batch: batch,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) Run(ctx context.Context) {
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) runOnce(ctx context.Context) {
|
||||
outboxDeleted, err := w.outbox.DeleteFailed(ctx, w.retention, w.batch)
|
||||
if err != nil {
|
||||
w.logger.Warn("清理 failed dispatch_outbox 失败", zap.Error(err))
|
||||
} else if outboxDeleted > 0 {
|
||||
w.logger.Info("清理 failed dispatch_outbox 完成", zap.Int("deleted", outboxDeleted))
|
||||
}
|
||||
}
|
||||
3
internal/app/messages/doc.go
Normal file
3
internal/app/messages/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package messages 是消息应用服务:发消息、编辑、删除、已读、历史记录。
|
||||
// 第一阶段先实现官方系统会话所需的历史、搜索和已读最小闭环;第二阶段扩展私聊发送与推送。
|
||||
package messages
|
||||
180
internal/app/messages/service.go
Normal file
180
internal/app/messages/service.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package messages
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供消息历史、搜索与已读业务。
|
||||
type Service struct {
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
}
|
||||
|
||||
// NewService 创建 messages 服务。
|
||||
func NewService(messages store.MessageStore, dialogs store.DialogStore) *Service {
|
||||
return &Service{messages: messages, dialogs: dialogs}
|
||||
}
|
||||
|
||||
// SendPrivateText 发送一条私聊文本消息。
|
||||
func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.SendPrivateTextResult{}, nil
|
||||
}
|
||||
if req.SenderUserID == 0 {
|
||||
req.SenderUserID = userID
|
||||
}
|
||||
return s.messages.SendPrivateText(ctx, req)
|
||||
}
|
||||
|
||||
// ForwardPrivateMessages 转发当前账号可见的私聊文本消息。
|
||||
func (s *Service) ForwardPrivateMessages(ctx context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.ForwardPrivateMessagesResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.ForwardPrivateMessages(ctx, req)
|
||||
}
|
||||
|
||||
// GetMessages returns exact owner-visible message boxes in request order.
|
||||
func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 || len(ids) == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
return s.messages.GetByIDs(ctx, userID, ids)
|
||||
}
|
||||
|
||||
// GetHistory 返回当前账号某个 peer 的历史消息。
|
||||
func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
return s.list(ctx, userID, filter)
|
||||
}
|
||||
|
||||
// Search 返回当前账号消息搜索结果。Query 为空时等价于历史列表过滤。
|
||||
func (s *Service) Search(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
return s.list(ctx, userID, filter)
|
||||
}
|
||||
|
||||
// ReadHistory 将当前账号某个 peer 的 inbox 标记为已读,并为发送方生成 outbox 已读回执。
|
||||
func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return domain.ReadHistoryResult{Peer: req.Peer, MaxID: req.MaxID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if s.messages != nil {
|
||||
return s.messages.ReadHistory(ctx, req)
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
return s.dialogs.MarkRead(ctx, userID, req.Peer, req.MaxID)
|
||||
}
|
||||
return domain.ReadHistoryResult{OwnerUserID: userID, Peer: req.Peer, MaxID: req.MaxID}, nil
|
||||
}
|
||||
|
||||
// ReadMessageContents checks exact owner-visible private message IDs for content-read sync.
|
||||
func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) {
|
||||
res := domain.ReadMessageContentsResult{OwnerUserID: userID}
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return res, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.OwnerUserID != userID || len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
return s.messages.ReadMessageContents(ctx, req)
|
||||
}
|
||||
|
||||
// GetOutboxReadDate 返回当前账号某条 outgoing 私聊消息被对端读到的时间。
|
||||
func (s *Service) GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return 0, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.GetOutboxReadDate(ctx, req)
|
||||
}
|
||||
|
||||
// SetMessageReactions replaces the current user's reactions on a visible private message.
|
||||
func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return s.messages.SetMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// GetMessageReactions returns reaction summaries for visible private messages.
|
||||
func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PrivateMessageReactionsResult{}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.OwnerUserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
return s.messages.GetMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// EditMessage 编辑当前账号发出的私聊文本消息。
|
||||
func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.EditMessageResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.EditMessage(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteMessages 删除当前账号视角下的一组消息;revoke 时同步删除对端私聊盒子。
|
||||
func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.DeleteMessagesResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.DeleteMessages(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteHistory 清空当前账号与某个 peer 的历史;revoke 时同步删除对端私聊盒子。
|
||||
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.DeleteMessagesResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.DeleteHistory(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
return s.messages.ListByUser(ctx, userID, filter)
|
||||
}
|
||||
154
internal/app/updates/contiguous_test.go
Normal file
154
internal/app/updates/contiguous_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func seedEvent(t *testing.T, events *memory.UpdateEventStore, userID int64, pts int) {
|
||||
t.Helper()
|
||||
if err := events.Append(context.Background(), userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventNewMessage,
|
||||
Pts: pts,
|
||||
PtsCount: 1,
|
||||
Date: 1700000000 + pts,
|
||||
Message: domain.Message{
|
||||
ID: pts,
|
||||
OwnerUserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed event pts=%d: %v", pts, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDifferenceStopsAtHole:getDifference 只返回从 from 起连续的事件,遇在途空洞即截断,
|
||||
// State.Pts 取最后连续值;补洞后下次拉取可继续,绝不跳过空洞。
|
||||
func TestGetDifferenceStopsAtHole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
userID := int64(1000000001)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
|
||||
// pts 1,2,3 已提交,4 在途(缺),5,6 已提交 → 连续只到 3。
|
||||
for _, p := range []int{1, 2, 3, 5, 6} {
|
||||
seedEvent(t, events, userID, p)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 3 || diff.State.Pts != 3 {
|
||||
t.Fatalf("diff = %d events, state.pts %d; want 3 连续事件、止于空洞(pts=3)", len(diff.Events), diff.State.Pts)
|
||||
}
|
||||
if diff.Partial {
|
||||
t.Fatalf("Partial=true,want false(被空洞而非 limit 截断)")
|
||||
}
|
||||
|
||||
// 补上 pts=4(在途事务提交)后,从 3 继续应拿到 4,5,6。
|
||||
seedEvent(t, events, userID, 4)
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference after fill: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 3 || diff.State.Pts != 6 {
|
||||
t.Fatalf("补洞后 diff = %d events, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDifferenceSliceOnLimit:连续事件填满 limit 时返回 Partial(=differenceSlice),
|
||||
// State.Pts 为中间态;客户端据此续拉,最终一页 Partial=false。
|
||||
func TestGetDifferenceSliceOnLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
userID := int64(1000000002)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
|
||||
total := getDifferenceLimit + 25
|
||||
for p := 1; p <= total; p++ {
|
||||
seedEvent(t, events, userID, p)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != getDifferenceLimit || !diff.Partial || diff.State.Pts != getDifferenceLimit {
|
||||
t.Fatalf("第一页 = %d events partial %v state.pts %d; want %d/true/%d",
|
||||
len(diff.Events), diff.Partial, diff.State.Pts, getDifferenceLimit, getDifferenceLimit)
|
||||
}
|
||||
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: getDifferenceLimit})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference page2: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 25 || diff.Partial || diff.State.Pts != total {
|
||||
t.Fatalf("第二页 = %d events partial %v state.pts %d; want 25/false/%d",
|
||||
len(diff.Events), diff.Partial, diff.State.Pts, total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetStateReportsContiguousNotMax:getState 报告最大连续 pts,而非最大已提交 pts,
|
||||
// 避免首次登录基线越过在途空洞而丢消息。
|
||||
func TestGetStateReportsContiguousNotMax(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
userID := int64(1000000003)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
|
||||
for _, p := range []int{1, 2, 3, 5, 6} { // 4 在途空洞,最大已提交=6
|
||||
seedEvent(t, events, userID, p)
|
||||
}
|
||||
st, err := svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState: %v", err)
|
||||
}
|
||||
if st.Pts != 3 {
|
||||
t.Fatalf("GetState.Pts=%d, want 3(最大连续,而非最大已提交 6)", st.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 7
|
||||
userID := int64(1000000004)
|
||||
events := memory.NewUpdateEventStore()
|
||||
states := memory.NewUpdateStateStore()
|
||||
svc := NewService(states, events)
|
||||
|
||||
seedEvent(t, events, userID, 1)
|
||||
if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil {
|
||||
t.Fatalf("Save state: %v", err)
|
||||
}
|
||||
seedEvent(t, events, userID, 2)
|
||||
|
||||
st, err := svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState: %v", err)
|
||||
}
|
||||
if st.Pts != 1 {
|
||||
t.Fatalf("GetState.Pts=%d, want existing confirmed pts=1", st.Pts)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, st)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 1 || diff.Events[0].Pts != 2 || diff.State.Pts != 2 {
|
||||
t.Fatalf("diff = %+v, want one event at pts=2 and confirmed state pts=2", diff)
|
||||
}
|
||||
st, err = svc.GetState(ctx, authKeyID, userID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
3
internal/app/updates/doc.go
Normal file
3
internal/app/updates/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package updates 是更新状态机与投递:user 级 pts/qts/seq/date、离线差量(updates.getDifference)、
|
||||
// 在线推送。第一阶段持久化 auth_key 维度的初始空状态,消息事件队列留第二阶段。
|
||||
package updates
|
||||
465
internal/app/updates/service.go
Normal file
465
internal/app/updates/service.go
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供 update 状态查询。
|
||||
type Service struct {
|
||||
states store.UpdateStateStore
|
||||
events store.UpdateEventStore
|
||||
pts store.PtsAllocator
|
||||
}
|
||||
|
||||
type dispatchingEventAppender interface {
|
||||
AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error
|
||||
}
|
||||
|
||||
// ServiceOption 调整 updates 服务的运行时依赖。
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithPtsAllocator 使用外部 pts 分配器推进账号级 pts。
|
||||
func WithPtsAllocator(pts store.PtsAllocator) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.pts = pts
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 updates 服务。
|
||||
func NewService(states store.UpdateStateStore, events store.UpdateEventStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{states: states}
|
||||
s.events = events
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// UsesReliableDispatch 表示设置类 update 已写入 transactional outbox,由 outbox worker 投递在线 session。
|
||||
func (s *Service) UsesReliableDispatch() bool {
|
||||
if s == nil || s.events == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := s.events.(dispatchingEventAppender)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetState 返回当前 auth_key + user 维度已确认的 update 状态。
|
||||
// user_update_events 是账号级 durable log;auth_key 维度只保存设备已经通过
|
||||
// getDifference 确认到的状态,不能在 getState 中直接推进到账号最新水位。
|
||||
func (s *Service) GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
|
||||
now := int(time.Now().Unix())
|
||||
// 私聊阶段不维护账号级 seq:对外 UpdateState.Seq 恒为 0,客户端仅靠 pts 同步、
|
||||
// 跳过 seq gap 检测(推送信封 seq 同样恒 0)。
|
||||
if s.states == nil {
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
return domain.UpdateState{Pts: current, Date: now, Seq: 0}, nil
|
||||
}
|
||||
st, found, err := s.states.Get(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
if found {
|
||||
st.Seq = 0
|
||||
if st.Date == 0 {
|
||||
st.Date = now
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
st = domain.UpdateState{Pts: current, Date: now, Seq: 0}
|
||||
if err := s.states.Save(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// CurrentState 返回账号当前最大连续 update 状态,不修改任何设备已确认水位。
|
||||
func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
|
||||
return s.currentState(ctx, userID)
|
||||
}
|
||||
|
||||
// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。
|
||||
const getDifferenceLimit = 100
|
||||
|
||||
// GetDifference 返回当前 user 从 from 状态之后的增量事件。
|
||||
//
|
||||
// 对齐 MTProto:只返回从 from.Pts 起「连续」的事件(遇空洞即截断),State.Pts 取最后连续值,
|
||||
// 绝不让客户端跳过在途空洞而丢消息——空洞由并发发送的在途事务造成,提交/补洞后客户端下次拉取即可补齐。
|
||||
// 连续事件填满 limit 时置 Partial(映射 differenceSlice),客户端据返回 State 继续翻页。
|
||||
func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) {
|
||||
st, err := s.currentState(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
if s.events == nil || from.Pts >= st.Pts {
|
||||
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)
|
||||
if err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
contiguous := contiguousPrefix(events, from.Pts)
|
||||
last := from.Pts
|
||||
if len(contiguous) > 0 {
|
||||
last = contiguous[len(contiguous)-1].Pts
|
||||
}
|
||||
out := st
|
||||
out.Pts = last
|
||||
out.Seq = 0 // seq 恒 0,见 GetState 注释
|
||||
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,
|
||||
Partial: len(contiguous) == getDifferenceLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
return domain.UpdateState{
|
||||
Pts: current,
|
||||
Date: int(time.Now().Unix()),
|
||||
Seq: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveConfirmedState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState) error {
|
||||
if s.states == nil {
|
||||
return nil
|
||||
}
|
||||
st.Seq = 0
|
||||
return s.states.Save(ctx, authKeyID, userID, st)
|
||||
}
|
||||
|
||||
// contiguousPrefix 返回从 from 起 pts 严格连续(from+1, from+2, ...)的事件前缀。
|
||||
// 先按 pts 升序排序以兼容存储返回顺序,遇到空洞即停。
|
||||
func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEvent {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
sorted := make([]domain.UpdateEvent, len(events))
|
||||
copy(sorted, events)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Pts < sorted[j].Pts })
|
||||
cursor := from
|
||||
out := make([]domain.UpdateEvent, 0, len(sorted))
|
||||
for _, event := range sorted {
|
||||
ptsCount := event.PtsCount
|
||||
if ptsCount <= 0 {
|
||||
ptsCount = 1
|
||||
}
|
||||
if event.Pts != cursor+ptsCount {
|
||||
break
|
||||
}
|
||||
out = append(out, event)
|
||||
cursor = event.Pts
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClearAuthKey 清理某 auth_key 的设备状态。
|
||||
// user_update_events 是账号级 durable log,不能因设备退出登录被删除。
|
||||
func (s *Service) ClearAuthKey(ctx context.Context, authKeyID [8]byte) error {
|
||||
if s.states != nil {
|
||||
if err := s.states.DeleteAuthKey(ctx, authKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordNewMessage 推进 update 状态并追加一条 new_message 事件。
|
||||
func (s *Service) RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = msg.OwnerUserID
|
||||
}
|
||||
date := msg.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventNewMessage,
|
||||
Date: date,
|
||||
Message: msg,
|
||||
PtsCount: 1,
|
||||
}, false, 0)
|
||||
}
|
||||
|
||||
// RecordMessageReactions records a durable marker for message reaction changes.
|
||||
//
|
||||
// updateMessageReactions has no pts fields in Layer 225, but TDesktop still
|
||||
// needs getDifference to advance account pts and carry the latest reaction
|
||||
// aggregate for offline devices.
|
||||
func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = msg.OwnerUserID
|
||||
}
|
||||
date := msg.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventMessageReactions,
|
||||
Date: date,
|
||||
Message: msg,
|
||||
Peer: msg.Peer,
|
||||
PtsCount: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordReadHistory 推进 update 状态并追加一条 read_history_inbox 事件。
|
||||
func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = read.OwnerUserID
|
||||
}
|
||||
date := int(time.Now().Unix())
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventReadHistoryInbox,
|
||||
Date: date,
|
||||
Peer: read.Peer,
|
||||
MaxID: read.MaxID,
|
||||
StillUnreadCount: read.StillUnreadCount,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordContactsReset 记录通讯录视角变化,供离线设备通过 updates.getDifference 触发重拉。
|
||||
func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventContactsReset,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogPinned 记录单个会话置顶状态变化。
|
||||
func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogPinned,
|
||||
Peer: peer,
|
||||
Bool: pinned,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPinnedDialogs 记录置顶会话顺序变化,并把新顺序持久化给 getDifference/outbox。
|
||||
func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPinnedDialogs,
|
||||
Peers: append([]domain.Peer(nil), order...),
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogUnreadMark 记录手动未读标记变化。
|
||||
func (s *Service) RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogUnreadMark,
|
||||
Peer: peer,
|
||||
Bool: unread,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordChannelViewForumAsMessages records a per-account forum presentation state change.
|
||||
func (s *Service) RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventChannelViewForum,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
Bool: enabled,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPeerSettings 记录 peer settings 变化。
|
||||
func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPeerSettings,
|
||||
Peer: peer,
|
||||
Settings: settings,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogFilter 记录单个 filter 的创建、更新或删除;folder 为 nil 表示删除。
|
||||
func (s *Service) RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
var copyFolder *domain.DialogFolder
|
||||
if folder != nil {
|
||||
f := *folder
|
||||
copyFolder = &f
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogFilter,
|
||||
FilterID: folderID,
|
||||
DialogFilter: copyFolder,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogFilterOrder 记录 filter 顺序变化。
|
||||
func (s *Service) RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogFilterOrder,
|
||||
FilterOrder: append([]int(nil), order...),
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogFiltersReload 通知其他设备重新拉取 filter 列表。
|
||||
func (s *Service) RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogFilters,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordFolderPeers 记录归档/还原会话的 folder_id 变化。
|
||||
func (s *Service) RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventFolderPeers,
|
||||
FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...),
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordChannelAvailableMessages records a local channel history clear for multi-device sync.
|
||||
func (s *Service) RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventChannelAvailable,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
MaxID: availableMinID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
func (s *Service) recordEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEventCore(ctx, authKeyID, userID, event, dispatch, excludeSessionID, true)
|
||||
}
|
||||
|
||||
func (s *Service) recordEventWithoutState(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEventCore(ctx, [8]byte{}, userID, event, false, 0, false)
|
||||
}
|
||||
|
||||
func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64, saveState bool) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
date := event.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
if event.PtsCount == 0 {
|
||||
event.PtsCount = 1
|
||||
}
|
||||
pts, err := s.nextPtsN(ctx, userID, event.PtsCount)
|
||||
if err != nil {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
st := domain.UpdateState{Pts: pts, Date: date, Seq: 0}
|
||||
event.UserID = userID
|
||||
event.Pts = st.Pts
|
||||
event.Date = date
|
||||
if s.events != nil {
|
||||
var err error
|
||||
if dispatch {
|
||||
if appender, ok := s.events.(dispatchingEventAppender); ok {
|
||||
err = appender.AppendWithDispatch(ctx, userID, event, authKeyID, excludeSessionID)
|
||||
} else {
|
||||
err = s.events.Append(ctx, userID, event)
|
||||
}
|
||||
} else {
|
||||
err = s.events.Append(ctx, userID, event)
|
||||
}
|
||||
if err != nil {
|
||||
if !dispatch {
|
||||
_ = s.events.Append(ctx, userID, domain.UpdateEvent{
|
||||
UserID: userID,
|
||||
Type: domain.UpdateEventNoop,
|
||||
Pts: pts,
|
||||
PtsCount: event.PtsCount,
|
||||
Date: date,
|
||||
})
|
||||
}
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
}
|
||||
if saveState && s.states != nil {
|
||||
if err := s.states.Save(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
}
|
||||
return event, st, nil
|
||||
}
|
||||
|
||||
// currentPts 供 GetState 报告「当前 pts」。对齐 MTProto:报告最大连续已提交 pts,
|
||||
// 而非 Redis allocator 的最大已分配值——后者在并发发送在途时会超前于已提交事件,
|
||||
// 会让首次登录基线越过在途空洞而丢消息。allocator 仅在无 events 存储时兜底。
|
||||
func (s *Service) currentPts(ctx context.Context, userID int64) (int, error) {
|
||||
if s.events != nil {
|
||||
return s.events.MaxContiguousPts(ctx, userID)
|
||||
}
|
||||
if s.pts != nil {
|
||||
return s.pts.CurrentPts(ctx, userID)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) nextPts(ctx context.Context, userID int64) (int, error) {
|
||||
if s.pts != nil {
|
||||
return s.pts.NextPts(ctx, userID)
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current + 1, nil
|
||||
}
|
||||
|
||||
func (s *Service) nextPtsN(ctx context.Context, userID int64, count int) (int, error) {
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
if count == 1 {
|
||||
return s.nextPts(ctx, userID)
|
||||
}
|
||||
if s.pts != nil {
|
||||
if ranges, ok := s.pts.(store.PtsRangeAllocator); ok {
|
||||
return ranges.NextPtsN(ctx, userID, count)
|
||||
}
|
||||
var pts int
|
||||
var err error
|
||||
for i := 0; i < count; i++ {
|
||||
pts, err = s.pts.NextPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current + count, nil
|
||||
}
|
||||
252
internal/app/updates/service_test.go
Normal file
252
internal/app/updates/service_test.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestRecordNewMessageFeedsGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 1
|
||||
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
|
||||
msg := domain.Message{
|
||||
ID: 10,
|
||||
OwnerUserID: 1000000001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: 1700000000,
|
||||
Body: "Login code: 12345",
|
||||
}
|
||||
|
||||
event, state, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordNewMessage: %v", err)
|
||||
}
|
||||
if event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 || state.Seq != 0 {
|
||||
t.Fatalf("event/state = %+v / %+v, want first pts event with seq=0", event, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Message.ID != msg.ID {
|
||||
t.Fatalf("diff = %+v, want recorded login message event and state %+v", diff, state)
|
||||
}
|
||||
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, state)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference current: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 0 || diff.State != state {
|
||||
t.Fatalf("current diff = %+v, want empty events and same state", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReadHistoryFeedsGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 2
|
||||
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
|
||||
|
||||
ownerUserID := int64(1000000001)
|
||||
event, state, err := svc.RecordReadHistory(ctx, authKeyID, ownerUserID, domain.ReadHistoryResult{
|
||||
OwnerUserID: ownerUserID,
|
||||
Peer: peer,
|
||||
MaxID: 10,
|
||||
Changed: true,
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordReadHistory: %v", err)
|
||||
}
|
||||
if event.Type != domain.UpdateEventReadHistoryInbox || event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 {
|
||||
t.Fatalf("event/state = %+v / %+v, want read history event with first pts", event, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Peer != peer || diff.Events[0].MaxID != 10 {
|
||||
t.Fatalf("diff = %+v, want recorded read history event and state %+v", diff, state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 3
|
||||
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
|
||||
ownerUserID := int64(1000000001)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
||||
|
||||
if _, _, err := svc.RecordContactsReset(ctx, authKeyID, ownerUserID, 0); err != nil {
|
||||
t.Fatalf("RecordContactsReset: %v", err)
|
||||
}
|
||||
if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0); err != nil {
|
||||
t.Fatalf("RecordDialogPinned: %v", err)
|
||||
}
|
||||
order := []domain.Peer{peer}
|
||||
if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, order, 0); err != nil {
|
||||
t.Fatalf("RecordPinnedDialogs: %v", err)
|
||||
}
|
||||
if _, _, err := svc.RecordDialogUnreadMark(ctx, authKeyID, ownerUserID, peer, false, 0); err != nil {
|
||||
t.Fatalf("RecordDialogUnreadMark: %v", err)
|
||||
}
|
||||
settings := domain.PeerSettings{ShareContact: true}
|
||||
stateEvent, state, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPeerSettings: %v", err)
|
||||
}
|
||||
if stateEvent.Pts != 5 || state.Pts != 5 {
|
||||
t.Fatalf("last event/state = %+v / %+v, want pts=5", stateEvent, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 5 || len(diff.Events) != 5 {
|
||||
t.Fatalf("diff = %+v, want five settings events", diff)
|
||||
}
|
||||
wantTypes := []domain.UpdateEventType{
|
||||
domain.UpdateEventContactsReset,
|
||||
domain.UpdateEventDialogPinned,
|
||||
domain.UpdateEventPinnedDialogs,
|
||||
domain.UpdateEventDialogUnreadMark,
|
||||
domain.UpdateEventPeerSettings,
|
||||
}
|
||||
for i, typ := range wantTypes {
|
||||
if diff.Events[i].Type != typ || diff.Events[i].Pts != i+1 || diff.Events[i].PtsCount != 1 {
|
||||
t.Fatalf("event[%d] = %+v, want type=%s pts=%d pts_count=1", i, diff.Events[i], typ, i+1)
|
||||
}
|
||||
}
|
||||
if diff.Events[1].Peer != peer || !diff.Events[1].Bool {
|
||||
t.Fatalf("dialog pinned event = %+v, want peer and pinned=true", diff.Events[1])
|
||||
}
|
||||
if diff.Events[3].Peer != peer || diff.Events[3].Bool {
|
||||
t.Fatalf("unread mark event = %+v, want peer and unread=false", diff.Events[3])
|
||||
}
|
||||
if len(diff.Events[2].Peers) != 1 || diff.Events[2].Peers[0] != peer {
|
||||
t.Fatalf("pinned dialogs event = %+v, want order peer", diff.Events[2])
|
||||
}
|
||||
if diff.Events[4].Peer != peer || !diff.Events[4].Settings.ShareContact {
|
||||
t.Fatalf("peer settings event = %+v, want peer and settings", diff.Events[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 4
|
||||
events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
||||
|
||||
event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordDialogPinned: %v", err)
|
||||
}
|
||||
if event.Pts != 1 || state.Pts != 1 {
|
||||
t.Fatalf("event/state = %+v / %+v, want first pts", event, state)
|
||||
}
|
||||
if !events.dispatched || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 42 || events.event.Type != domain.UpdateEventDialogPinned || events.event.Peer != peer {
|
||||
t.Fatalf("dispatch capture = %+v exclude_auth=%v exclude_session=%d dispatched=%v, want dialog_pinned outbox", events.event, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAuthKeyDropsStateAndEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 8
|
||||
states := memory.NewUpdateStateStore()
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(states, events)
|
||||
msg := domain.Message{
|
||||
ID: 1,
|
||||
OwnerUserID: 1000000001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: 1700000000,
|
||||
}
|
||||
if _, _, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg); err != nil {
|
||||
t.Fatalf("RecordNewMessage: %v", err)
|
||||
}
|
||||
if err := svc.ClearAuthKey(ctx, authKeyID); err != nil {
|
||||
t.Fatalf("ClearAuthKey: %v", err)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 1 || len(diff.Events) != 1 {
|
||||
t.Fatalf("difference after clear = %+v, want durable user events to remain", diff)
|
||||
}
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID+1, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference other user: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 0 || len(diff.Events) != 0 {
|
||||
t.Fatalf("difference for other user after clear = %+v, want no cross-account events", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 9
|
||||
userID := int64(1000000001)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
for _, event := range []domain.UpdateEvent{
|
||||
{UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: 1700000001, Message: domain.Message{ID: 1, OwnerUserID: userID}},
|
||||
{UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 2, PtsCount: 1, Date: 1700000002, Message: domain.Message{ID: 2, OwnerUserID: userID}},
|
||||
{UserID: userID, Type: domain.UpdateEventDeleteMessages, Pts: 4, PtsCount: 2, Date: 1700000003, MessageIDs: []int{1, 2}},
|
||||
} {
|
||||
if err := events.Append(ctx, userID, event); err != nil {
|
||||
t.Fatalf("append event pts=%d: %v", event.Pts, err)
|
||||
}
|
||||
}
|
||||
|
||||
state, err := svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState: %v", err)
|
||||
}
|
||||
if state.Pts != 4 {
|
||||
t.Fatalf("state = %+v, want contiguous pts=4 across delete range", state)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 4 || len(diff.Events) != 1 {
|
||||
t.Fatalf("diff = %+v, want one delete event ending at pts=4", diff)
|
||||
}
|
||||
got := diff.Events[0]
|
||||
if got.Type != domain.UpdateEventDeleteMessages || got.Pts != 4 || got.PtsCount != 2 || len(got.MessageIDs) != 2 {
|
||||
t.Fatalf("delete event = %+v, want pts=4 pts_count=2 ids", got)
|
||||
}
|
||||
}
|
||||
|
||||
type captureDispatchAppender struct {
|
||||
*memory.UpdateEventStore
|
||||
dispatched bool
|
||||
userID int64
|
||||
event domain.UpdateEvent
|
||||
excludeAuthKeyID [8]byte
|
||||
excludeSessionID int64
|
||||
}
|
||||
|
||||
func (s *captureDispatchAppender) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
|
||||
s.dispatched = true
|
||||
s.userID = userID
|
||||
s.event = event
|
||||
s.excludeAuthKeyID = excludeAuthKeyID
|
||||
s.excludeSessionID = excludeSessionID
|
||||
return s.UpdateEventStore.Append(ctx, userID, event)
|
||||
}
|
||||
3
internal/app/users/doc.go
Normal file
3
internal/app/users/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package users 是用户应用服务:用户资料、用户名、头像、在线状态。
|
||||
// 第一阶段先支持 users.getUsers 返回自身。
|
||||
package users
|
||||
313
internal/app/users/service.go
Normal file
313
internal/app/users/service.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// ErrNotAuthorized 表示当前 auth_key 尚未登录。
|
||||
var ErrNotAuthorized = errors.New("not authorized")
|
||||
|
||||
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。
|
||||
type ProfilePhotoProvider interface {
|
||||
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
||||
}
|
||||
|
||||
// Service 提供用户查询。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
photos ProfilePhotoProvider
|
||||
}
|
||||
|
||||
// Option 调整用户服务可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithPhotoProvider 注入头像富化能力(缺省则用户不带头像)。
|
||||
func WithPhotoProvider(p ProfilePhotoProvider) Option {
|
||||
return func(s *Service) { s.photos = p }
|
||||
}
|
||||
|
||||
const (
|
||||
minUsernameLen = 5
|
||||
maxUsernameLen = 32
|
||||
maxProfileNameRunes = 64
|
||||
maxProfileAboutRunes = 70
|
||||
maxBatchUsers = 1000
|
||||
)
|
||||
|
||||
// NewService 创建用户服务。
|
||||
func NewService(users store.UserStore, opts ...Option) *Service {
|
||||
s := &Service{users: users}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// loadSelf 加载当前用户但不富化头像(供内部校验路径使用,避免无谓的头像查询)。
|
||||
func (s *Service) loadSelf(ctx context.Context, userID int64) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Self 返回当前登录的用户(带头像)。未登录返回 ErrNotAuthorized。
|
||||
func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) {
|
||||
u, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return s.enrichOne(ctx, u), nil
|
||||
}
|
||||
|
||||
// ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。
|
||||
func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) {
|
||||
if currentUserID == 0 {
|
||||
return domain.User{}, false, ErrNotAuthorized
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return u, false, nil
|
||||
}
|
||||
return s.enrichOne(ctx, u), true, nil
|
||||
}
|
||||
|
||||
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
||||
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||
if currentUserID == 0 {
|
||||
return nil, ErrNotAuthorized
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) >= maxBatchUsers {
|
||||
break
|
||||
}
|
||||
}
|
||||
users, err := s.users.ByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.enrich(ctx, users), nil
|
||||
}
|
||||
|
||||
// enrich 批量把当前头像富化到用户列表(best-effort:失败不影响用户查询)。
|
||||
func (s *Service) enrich(ctx context.Context, users []domain.User) []domain.User {
|
||||
if s.photos == nil || len(users) == 0 {
|
||||
return users
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID != 0 {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
}
|
||||
refs, err := s.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
||||
if err != nil {
|
||||
return users
|
||||
}
|
||||
for i := range users {
|
||||
if ref, ok := refs[users[i].ID]; ok {
|
||||
users[i].PhotoID = ref.PhotoID
|
||||
users[i].PhotoDCID = ref.DCID
|
||||
users[i].PhotoStripped = ref.Stripped
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func (s *Service) enrichOne(ctx context.Context, u domain.User) domain.User {
|
||||
enriched := s.enrich(ctx, []domain.User{u})
|
||||
return enriched[0]
|
||||
}
|
||||
|
||||
// CheckUsername 校验当前用户是否可以占用 username。
|
||||
func (s *Service) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if !validUsername(username) {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !found || u.ID == self.ID, nil
|
||||
}
|
||||
|
||||
// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。
|
||||
func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if username != "" {
|
||||
if !validUsername(username) {
|
||||
return domain.User{}, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if found && u.ID != self.ID {
|
||||
return domain.User{}, domain.ErrUsernameOccupied
|
||||
}
|
||||
}
|
||||
if self.Username == username {
|
||||
return self, nil
|
||||
}
|
||||
u, err := s.users.UpdateUsername(ctx, self.ID, username)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
firstName := self.FirstName
|
||||
lastName := self.LastName
|
||||
about := self.About
|
||||
if update.HasFirstName {
|
||||
firstName = strings.TrimSpace(update.FirstName)
|
||||
}
|
||||
if update.HasLastName {
|
||||
lastName = strings.TrimSpace(update.LastName)
|
||||
}
|
||||
if update.HasAbout {
|
||||
about = strings.TrimSpace(update.About)
|
||||
}
|
||||
if firstName == "" || utf8.RuneCountInString(firstName) > maxProfileNameRunes || utf8.RuneCountInString(lastName) > maxProfileNameRunes {
|
||||
return domain.User{}, domain.ErrFirstNameInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(about) > maxProfileAboutRunes {
|
||||
return domain.User{}, domain.ErrAboutTooLong
|
||||
}
|
||||
if firstName == self.FirstName && lastName == self.LastName && about == self.About {
|
||||
return self, nil
|
||||
}
|
||||
return s.users.UpdateProfile(ctx, self.ID, firstName, lastName, about)
|
||||
}
|
||||
|
||||
// UpdateLastSeen records the latest visible account activity time.
|
||||
func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error {
|
||||
if userID == 0 {
|
||||
return ErrNotAuthorized
|
||||
}
|
||||
if lastSeenAt <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.users.UpdateLastSeen(ctx, userID, lastSeenAt)
|
||||
}
|
||||
|
||||
// ResolveUsername 解析 username 到用户;调用方必须已登录。
|
||||
func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) {
|
||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if !validUsername(username) {
|
||||
return domain.User{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
if err != nil || !found {
|
||||
return u, found, err
|
||||
}
|
||||
return s.enrichOne(ctx, u), true, nil
|
||||
}
|
||||
|
||||
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
|
||||
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
|
||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
phone = normalizePhone(phone)
|
||||
if phone == "" {
|
||||
return domain.User{}, false, domain.ErrPhoneNotOccupied
|
||||
}
|
||||
u, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil || !found {
|
||||
return u, found, err
|
||||
}
|
||||
return s.enrichOne(ctx, u), true, nil
|
||||
}
|
||||
|
||||
func normalizeUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
return strings.TrimSpace(username)
|
||||
}
|
||||
|
||||
func validUsername(username string) bool {
|
||||
if len(username) < minUsernameLen || len(username) > maxUsernameLen {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(username); i++ {
|
||||
c := username[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c >= '0' && c <= '9':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
case c == '_':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if phone == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
130
internal/app/users/service_test.go
Normal file
130
internal/app/users/service_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceUsernameLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewUserStore()
|
||||
owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
other, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"})
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
svc := NewService(store)
|
||||
|
||||
if ok, err := svc.CheckUsername(ctx, owner.ID, "123bad"); err == nil || ok || !errors.Is(err, domain.ErrUsernameInvalid) {
|
||||
t.Fatalf("CheckUsername invalid = ok %v err %v, want username invalid", ok, err)
|
||||
}
|
||||
if ok, err := svc.CheckUsername(ctx, owner.ID, "taken_name"); err != nil || ok {
|
||||
t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err)
|
||||
}
|
||||
if ok, err := svc.CheckUsername(ctx, owner.ID, "owner_name"); err != nil || !ok {
|
||||
t.Fatalf("CheckUsername available = ok %v err %v, want true/nil", ok, err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateUsername(ctx, owner.ID, "@Owner_Name")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUsername: %v", err)
|
||||
}
|
||||
if updated.Username != "Owner_Name" {
|
||||
t.Fatalf("updated username = %q, want Owner_Name", updated.Username)
|
||||
}
|
||||
resolved, found, err := svc.ResolveUsername(ctx, other.ID, "owner_name")
|
||||
if err != nil || !found || resolved.ID != owner.ID {
|
||||
t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err)
|
||||
}
|
||||
if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err)
|
||||
}
|
||||
phoneUser, found, err := svc.ResolvePhone(ctx, owner.ID, "+1 (555) 000-0002")
|
||||
if err != nil || !found || phoneUser.ID != other.ID {
|
||||
t.Fatalf("ResolvePhone = user %+v found %v err %v, want other", phoneUser, found, err)
|
||||
}
|
||||
cleared, err := svc.UpdateUsername(ctx, owner.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("clear username: %v", err)
|
||||
}
|
||||
if cleared.Username != "" {
|
||||
t.Fatalf("cleared username = %q, want empty", cleared.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateProfile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewUserStore()
|
||||
owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner", LastName: "Old"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
svc := NewService(store)
|
||||
|
||||
updated, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{
|
||||
FirstName: " New ",
|
||||
HasFirstName: true,
|
||||
LastName: "Name",
|
||||
HasLastName: true,
|
||||
About: "bio",
|
||||
HasAbout: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
}
|
||||
if updated.FirstName != "New" || updated.LastName != "Name" || updated.About != "bio" {
|
||||
t.Fatalf("updated profile = %+v, want trimmed names and about", updated)
|
||||
}
|
||||
if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: " ", HasFirstName: true}); !errors.Is(err, domain.ErrFirstNameInvalid) {
|
||||
t.Fatalf("empty first name err = %v, want first name invalid", err)
|
||||
}
|
||||
if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{About: strings.Repeat("x", 71), HasAbout: true}); !errors.Is(err, domain.ErrAboutTooLong) {
|
||||
t.Fatalf("long about err = %v, want about too long", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceByIDDoesNotReloadSelf(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
store := &countingUserStore{UserStore: base}
|
||||
svc := NewService(store)
|
||||
|
||||
got, found, err := svc.ByID(ctx, owner.ID, target.ID)
|
||||
if err != nil || !found || got.ID != target.ID {
|
||||
t.Fatalf("ByID = %+v found %v err %v, want target", got, found, err)
|
||||
}
|
||||
if store.byIDCalls != 1 {
|
||||
t.Fatalf("store ByID calls = %d, want 1 target lookup only", store.byIDCalls)
|
||||
}
|
||||
if store.lastByID != target.ID {
|
||||
t.Fatalf("last ByID id = %d, want target %d", store.lastByID, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
type countingUserStore struct {
|
||||
*memory.UserStore
|
||||
byIDCalls int
|
||||
lastByID int64
|
||||
}
|
||||
|
||||
func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) {
|
||||
s.byIDCalls++
|
||||
s.lastByID = id
|
||||
return s.UserStore.ByID(ctx, id)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue